diff --git a/.changeset/pf-tooltip-deprecate-base.md b/.changeset/pf-tooltip-deprecate-base.md new file mode 100644 index 0000000000..c3b6300a24 --- /dev/null +++ b/.changeset/pf-tooltip-deprecate-base.md @@ -0,0 +1,5 @@ +--- +"@patternfly/elements": patch +--- +``: marks `BaseTooltip` and it's stylesheet as deprecated. +The files will remain in place until the next major version. diff --git a/.changeset/pf-tooltip-flip.md b/.changeset/pf-tooltip-flip.md new file mode 100644 index 0000000000..1240cc71d9 --- /dev/null +++ b/.changeset/pf-tooltip-flip.md @@ -0,0 +1,4 @@ +--- +"@patternfly/elements": minor +--- +``: added `no-flip` and `flip-behaviour` attributes as in `` diff --git a/.changeset/pf-tooltip-trigger.md b/.changeset/pf-tooltip-trigger.md new file mode 100644 index 0000000000..710698e908 --- /dev/null +++ b/.changeset/pf-tooltip-trigger.md @@ -0,0 +1,11 @@ +--- +"@patternfly/element": minor +--- +`` added the `trigger` attribute to specify a tooltip-invoking +element outside of the tooltip's children. + +```html +Button + +``` diff --git a/.changeset/rich-kangaroos-brake.md b/.changeset/rich-kangaroos-brake.md new file mode 100644 index 0000000000..37d6bb82fa --- /dev/null +++ b/.changeset/rich-kangaroos-brake.md @@ -0,0 +1,9 @@ +--- +"@patternfly/elements": minor +--- + +✨ Added `` + +```html + +``` diff --git a/.changeset/test-tools-click-el.md b/.changeset/test-tools-click-el.md new file mode 100644 index 0000000000..d0166aab01 --- /dev/null +++ b/.changeset/test-tools-click-el.md @@ -0,0 +1,4 @@ +--- +"@patternfly/pfe-tools": minor +--- +Test: add `clickElementCenter` utility function for tests diff --git a/elements/package.json b/elements/package.json index 96a7f8d45b..935443b810 100644 --- a/elements/package.json +++ b/elements/package.json @@ -41,6 +41,7 @@ "./pf-panel/pf-panel.js": "./pf-panel/pf-panel.js", "./pf-progress-stepper/pf-progress-step.js": "./pf-progress-stepper/pf-progress-step.js", "./pf-progress-stepper/pf-progress-stepper.js": "./pf-progress-stepper/pf-progress-stepper.js", + "./pf-progress/pf-progress.js": "./pf-progress/pf-progress.js", "./pf-spinner/BaseSpinner.js": "./pf-spinner/BaseSpinner.js", "./pf-spinner/pf-spinner.js": "./pf-spinner/pf-spinner.js", "./pf-switch/BaseSwitch.js": "./pf-switch/BaseSwitch.js", diff --git a/elements/pf-popover/demo/flip.html b/elements/pf-popover/demo/flip.html new file mode 100644 index 0000000000..6d94889b6d --- /dev/null +++ b/elements/pf-popover/demo/flip.html @@ -0,0 +1,41 @@ + + + +
+
+ No flip + + Toggle popover + +
+ +
+ Flip fallback + + + Toggle popover + +
+
diff --git a/elements/pf-popover/demo/pf-popover.html b/elements/pf-popover/demo/pf-popover.html index cf0b399c22..e36c6a8578 100644 --- a/elements/pf-popover/demo/pf-popover.html +++ b/elements/pf-popover/demo/pf-popover.html @@ -1,5 +1,5 @@ - - + +

Basic

diff --git a/elements/pf-popover/demo/pf-popover.js b/elements/pf-popover/demo/pf-popover.js index f7eaeb01e6..0a7b7ed7bf 100644 --- a/elements/pf-popover/demo/pf-popover.js +++ b/elements/pf-popover/demo/pf-popover.js @@ -12,11 +12,11 @@ select.addEventListener('change', event => // Close popover from content const closeButton = document.getElementById('close-button'); -closeButton.addEventListener('click', event => event.target.closest('pf-popover').hide()); +closeButton?.addEventListener('click', event => event.target.closest('pf-popover').hide()); // Alert variants const alert = document.getElementById('alert'); -alert.addEventListener('change', event => +alert?.addEventListener('change', event => alert .querySelector('pf-popover') .setAttribute('alert-severity', event.target.closest('form').elements.severity.value)); diff --git a/elements/pf-popover/pf-popover.ts b/elements/pf-popover/pf-popover.ts index d1f3ad5ba8..be5dd10262 100644 --- a/elements/pf-popover/pf-popover.ts +++ b/elements/pf-popover/pf-popover.ts @@ -1,4 +1,4 @@ -import { LitElement, nothing, html } from 'lit'; +import { LitElement, nothing, html, type PropertyValues } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; import { query } from 'lit/decorators/query.js'; @@ -9,7 +9,6 @@ import { FloatingDOMController } from '@patternfly/pfe-core/controllers/floating import { SlotController } from '@patternfly/pfe-core/controllers/slot-controller.js'; import { bound } from '@patternfly/pfe-core/decorators/bound.js'; import { ComposedEvent, StringListConverter } from '@patternfly/pfe-core/core.js'; -import { observed } from '@patternfly/pfe-core/decorators/observed.js'; import type { Placement } from '@patternfly/pfe-core/controllers/floating-dom-controller.js'; import '@patternfly/elements/pf-button/pf-button.js'; import styles from './pf-popover.css'; @@ -301,7 +300,6 @@ export class PfPopover extends LitElement { /** * The ID of the element to attach the popover to. */ - @observed('triggerChanged') @property({ reflect: true }) trigger?: string; @query('#popover') private _popover!: HTMLDialogElement; @@ -360,7 +358,10 @@ export class PfPopover extends LitElement {
- +
@@ -389,10 +390,27 @@ export class PfPopover extends LitElement { disconnectedCallback() { super.disconnectedCallback(); PfPopover.instances.delete(this); - this.#referenceTrigger?.removeEventListener('click', this.show); + this.#referenceTrigger?.removeEventListener('click', this.toggle); this.#referenceTrigger?.removeEventListener('keydown', this.onKeydown); } + #getReferenceTrigger() { + const root = this.getRootNode() as Document | ShadowRoot; + return !this.trigger ? null : root.getElementById(this.trigger); + } + + + #triggerChanged() { + const oldReferenceTrigger = this.#referenceTrigger; + this.#referenceTrigger = this.#getReferenceTrigger(); + if (oldReferenceTrigger !== this.#referenceTrigger) { + oldReferenceTrigger?.removeEventListener('click', this.toggle); + oldReferenceTrigger?.removeEventListener('keydown', this.onKeydown); + this.#referenceTrigger?.addEventListener('click', this.toggle); + this.#referenceTrigger?.addEventListener('keydown', this.onKeydown); + } + } + @bound private onKeydown(event: KeyboardEvent) { switch (event.key) { case 'Escape': @@ -420,15 +438,9 @@ export class PfPopover extends LitElement { * Removes event listeners from the old trigger element and attaches * them to the new trigger element. */ - triggerChanged(oldValue?: string, newValue?: string) { - if (oldValue) { - this.#referenceTrigger?.removeEventListener('click', this.show); - this.#referenceTrigger?.removeEventListener('keydown', this.onKeydown); - } - if (newValue) { - this.#referenceTrigger = (this.getRootNode() as Document | ShadowRoot).getElementById(newValue); - this.#referenceTrigger?.addEventListener('click', this.show); - this.#referenceTrigger?.addEventListener('keydown', this.onKeydown); + override willUpdate(changed: PropertyValues) { + if (changed.has('trigger')) { + this.#triggerChanged(); } } diff --git a/elements/pf-popover/test/pf-popover.spec.ts b/elements/pf-popover/test/pf-popover.spec.ts index 0b32d38ff2..d55dbc369a 100644 --- a/elements/pf-popover/test/pf-popover.spec.ts +++ b/elements/pf-popover/test/pf-popover.spec.ts @@ -1,15 +1,29 @@ import { expect, html, fixture, fixtureCleanup } from '@open-wc/testing'; -import type { A11yTreeSnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; -import { a11ySnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; -import { sendKeys } from '@web/test-runner-commands'; +import { a11ySnapshot, type A11yTreeSnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; +import { clickElementCenter } from '@patternfly/pfe-tools/test/utils.js'; +import { sendKeys, resetMouse } from '@web/test-runner-commands'; import { PfPopover } from '@patternfly/elements/pf-popover/pf-popover.js'; import { PfButton } from '@patternfly/elements/pf-button/pf-button.js'; +const takeProps = (props: string[]) => (obj: object) => + Object.fromEntries(Object.entries(obj).filter(([k]) => props.includes(k))); + +function press(key: string) { + return async function() { + await sendKeys({ press: key }); + }; +} + describe('', function() { let element: PfPopover; - let snapshot: A11yTreeSnapshot; - beforeEach(async function() { + /** create a simple test fixture */ + async function setupSimpleInstance() { + element = await fixture(html``); + } + + /** create a test fixture with slotted trigger and content attrs */ + async function setupPopoverWithSlottedTriggerAndContentAttrs() { element = await fixture(html` ', function() { Toggle popover `); - snapshot = await a11ySnapshot(); - }); - - it('imperatively instantiates', function() { - expect(document.createElement('pf-popover')).to.be.an.instanceof(PfPopover); - }); + } - it('should upgrade', async function() { - const klass = customElements.get('pf-popover'); - expect(element).to.be.an.instanceOf(klass).and.to.be.an.instanceOf(PfPopover); - }); + /** Wait on the element's update cycle */ + async function updateComplete() { + await element.updateComplete; + } - it('should be accessible', async function() { - await expect(element).shadowDom.to.be.accessible(); - }); + /** Asserts that an aXe audit on the page passes */ + async function expectA11yAxe() { + await expect(element).to.be.accessible(); + } - it('should hide popover content from assistive technology', function() { - expect(snapshot.children).to.deep.equal([{ name: 'Toggle popover', role: 'button' }]); - }); + /** + * Assert that the accessibility tree reports the expected snapshot + * e.g. for a closed popover, does not announce popover child content + * e.g. for an opened popover, it does announce popover child content + * If the expected children snapshot is undefined, then assistive technology + * reports nothing at all, e.g. a popover element with no attrs and no children + */ + function expectA11ySnapshot(expected?: Pick[]) { + return async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.map(takeProps(['name', 'role']))) + .to.deep.equal(expected); + }; + } - it('should show popover content to assistive technology', async function() { - await sendKeys({ press: 'Tab' }); - expect(document.activeElement).to.be.an.instanceof(PfButton); - await sendKeys({ press: 'Enter' }); - snapshot = await a11ySnapshot(); - expect(snapshot.children).to.deep.equal([ - { - name: 'Toggle popover', - role: 'button', - }, - { - focused: true, - name: 'Close popover', - role: 'button', - }, - { - level: 6, - name: 'Popover heading', - role: 'heading', - }, - { - name: 'Popovers are triggered by click rather than hover.', - role: 'text', - }, - { - name: 'Popover footer', - role: 'text', - }, - ]); - }); + function resetElement() { + // @ts-expect-error: resetting test state, so we don't mind the ts error. + element = undefined; + } - it('should be closeable on close button select', async function() { - await sendKeys({ press: 'Tab' }); - await sendKeys({ press: 'Enter' }); - await sendKeys({ press: 'Enter' }); - snapshot = await a11ySnapshot(); - expect(snapshot.children).to.deep.equal([{ focused: true, name: 'Toggle popover', role: 'button' }]); - }); + afterEach(resetElement); + afterEach(fixtureCleanup); - it('should be closeable on escape', async function() { - await sendKeys({ press: 'Tab' }); - await sendKeys({ press: 'Enter' }); - await sendKeys({ press: 'Escape' }); - snapshot = await a11ySnapshot(); - expect(snapshot.children).to.deep.equal([{ focused: true, name: 'Toggle popover', role: 'button' }]); + describe('simply instantiating', function() { + beforeEach(setupSimpleInstance); + it('should upgrade', async function() { + const klass = customElements.get('pf-popover'); + expect(element) + .to.be.an.instanceOf(klass) + .and + .to.be.an.instanceOf(PfPopover); + }); + it('should be accessible', expectA11yAxe); + it('imperatively instantiates', function() { + expect(document.createElement('pf-popover')) + .to.be.an.instanceof(PfPopover); + }); + it('should not report anything to assistive technology', expectA11ySnapshot()); }); - it('should properly clean up event handlers', async () => { - const expectClose = () => - expect(snapshot.children).to.deep.equal([ + describe('with a slotted trigger; and with heading, body, and footer attributes', function() { + /** Setup the a11y tree snapshot expected results for this suite */ + const snapshots = { + opened: [ { - name: 'Toggle popover 1', + name: 'Toggle popover', role: 'button', }, { - name: 'Toggle popover 2', + name: 'Close popover', role: 'button', }, - ]); + { + name: 'Popover heading', + role: 'heading', + }, + { + name: 'Popovers are triggered by click rather than hover.', + role: 'text', + }, + { + name: 'Popover footer', + role: 'text', + }, + ], + closed: [ + { + name: 'Toggle popover', + role: 'button', + } + ], + }; + + beforeEach(setupPopoverWithSlottedTriggerAndContentAttrs); + + it('should be accessible', expectA11yAxe); + it('should hide popover content from assistive technology', expectA11ySnapshot(snapshots.closed)); - const expectOpen = () => - expect(snapshot.children).to.deep.equal([ + describe('tabbing to the trigger', function() { + beforeEach(updateComplete); + beforeEach(press('Tab')); + beforeEach(updateComplete); + + it('doesn\'t steal tab order', function() { + expect(document.activeElement).to.be.an.instanceof(PfButton); + }); + + describe('and pressing Enter', function() { + beforeEach(updateComplete); + beforeEach(press('Enter')); + beforeEach(updateComplete); + it('should show popover content to assistive technology', expectA11ySnapshot(snapshots.opened)); + describe('then pressing Enter again', function() { + beforeEach(updateComplete); + beforeEach(press('Enter')); + beforeEach(updateComplete); + it('should hide popover content from assistive technology', expectA11ySnapshot(snapshots.closed)); + }); + describe('then pressing Escape', function() { + beforeEach(updateComplete); + beforeEach(press('Escape')); + beforeEach(updateComplete); + it('should hide popover content from assistive technology', expectA11ySnapshot(snapshots.closed)); + }); + }); + }); + }); + + describe('with a trigger and a sibling button', function() { + let btn1: HTMLButtonElement; + let btn2: HTMLButtonElement; + + /** Setup the a11y tree snapshot expected results for this suite */ + const snapshots = { + opened: [ { - focused: true, name: 'Close popover', role: 'button', }, { - level: 6, name: 'Popover heading', role: 'heading', }, @@ -125,39 +182,79 @@ describe('', function() { name: 'Toggle popover 2', role: 'button', }, - ]); - fixtureCleanup(); - const container = await fixture(html` -
- - - - -
- `); - snapshot = await a11ySnapshot(); - const popover: PfPopover | null = container.querySelector('pf-popover'); - const btn1: HTMLButtonElement | null = container.querySelector('#btn-1'); - const btn2: HTMLButtonElement | null = container.querySelector('#btn-2'); - expectClose(); - btn1?.click(); - snapshot = await a11ySnapshot(); - expectOpen(); - // Close the popover - await sendKeys({ press: 'Enter' }); - // Update trigger element - popover?.setAttribute('trigger', 'btn-2'); - btn1?.click(); - snapshot = await a11ySnapshot(); - expectClose(); - btn2?.click(); - snapshot = await a11ySnapshot(); - expectOpen(); + ], + closed: [ + { + name: 'Toggle popover 1', + role: 'button', + }, + { + name: 'Toggle popover 2', + role: 'button', + }, + ], + }; + + async function clickButton1() { + await clickElementCenter(btn1); + await resetMouse(); + } + + async function clickButton2() { + await clickElementCenter(btn2); + await resetMouse(); + } + + beforeEach(async function() { + const container = await fixture(html` +
+ + + +
+ `); + element = container.querySelector('pf-popover')!; + btn1 = container.querySelector('#btn-1')!; + btn2 = container.querySelector('#btn-2')!; + }); + + it('starts closed', expectA11ySnapshot(snapshots.closed)); + describe('clicking the trigger', function() { + beforeEach(updateComplete); + beforeEach(clickButton1); + beforeEach(updateComplete); + it('shows the popover', expectA11ySnapshot(snapshots.opened)); + }); + describe('then setting the trigger to the sibling button', function() { + beforeEach(updateComplete); + // set trigger attr to the id of the second button + beforeEach(async function() { + element.setAttribute('trigger', 'btn-2'); + }); + beforeEach(updateComplete); + describe('clicking the first button', function() { + beforeEach(updateComplete); + beforeEach(clickButton1); + beforeEach(updateComplete); + it('remains closed', expectA11ySnapshot(snapshots.closed)); + }); + describe('clicking the sibling button', function() { + beforeEach(updateComplete); + beforeEach(clickButton2); + beforeEach(updateComplete); + it('shows the popup', expectA11ySnapshot(snapshots.opened)); + }); + }); + describe('then pressing the Enter key', function() { + beforeEach(updateComplete); + // Close the popover + beforeEach(press('Enter')); + beforeEach(updateComplete); + it('closes the popover', expectA11ySnapshot(snapshots.closed)); + }); }); }); diff --git a/elements/pf-progress/README.md b/elements/pf-progress/README.md new file mode 100644 index 0000000000..b03913e672 --- /dev/null +++ b/elements/pf-progress/README.md @@ -0,0 +1,33 @@ +# Progress + +A progress bar gives the user a visual representation of their completion status of an ongoing process or task. + +Read more about Progress in the [PatternFly Elements Progress documentation][docs]. + +## Installation + +Load `` via CDN: + +```html + +``` + +Or, if you are using [NPM](https://npm.im), install it + +```bash +npm install @patternfly/elements +``` + +Then once installed, import it to your application: + +```js +import '@patternfly/elements/pf-progress/pf-progress.js'; +``` + +## Usage + +```html + +``` + +[docs]: https://patternflyelements.org/components/progress diff --git a/elements/pf-progress/demo/demo.css b/elements/pf-progress/demo/demo.css new file mode 100644 index 0000000000..4ef939b648 --- /dev/null +++ b/elements/pf-progress/demo/demo.css @@ -0,0 +1,6 @@ +[data-demo="pf-progress"] { + max-width: 600px; +} +pf-progress { + width: 100%; +} diff --git a/elements/pf-progress/demo/kitchen-sink.css b/elements/pf-progress/demo/kitchen-sink.css new file mode 100644 index 0000000000..6271c537ff --- /dev/null +++ b/elements/pf-progress/demo/kitchen-sink.css @@ -0,0 +1,4 @@ +pf-progress { + padding-bottom: 0.25rem; + display: block; +} \ No newline at end of file diff --git a/elements/pf-progress/demo/kitchen-sink.html b/elements/pf-progress/demo/kitchen-sink.html new file mode 100644 index 0000000000..d5f19ced86 --- /dev/null +++ b/elements/pf-progress/demo/kitchen-sink.html @@ -0,0 +1,138 @@ + + + +

pf-progress

+ +

Default States

+ + + +

Value

+ + +

Description

+ + +

Aria-label

+ + +

Max

+ + +

Min

+ + +

Size (sm, lg)

+ + + +

Measure Location (Inside, Outside, None)

+ + + + +

Variant (Sucess, danger, warning)

+ + + + +

Variant (Success, Danger, Warning) and Size (sm, lg)

+ + + + + + + +

Variant (Success, Danger, Warning) and Measure Location (Inside, Outside, None)

+ + + + + + + + + + +

Variant (Success, Danger, Warning), Size (sm, lg) and Measure Location (Inside, Outside, None)

+ + + + + + + + + + + + + + + + + + + +

Label w/ no description

+ +

Value

+ + + + +

Size (sm, lg)

+ + + + + + + +

Measure Location (Inside, Outside)

+ + + + + + + +

Variant (Sucess, danger, warning)

+ + + + + + + + + + +

Variant (Success, Danger, Warning) and Size (sm, lg)

+ + + + + + + + + + + + + + + + + + + +

Truncated description

+ + diff --git a/elements/pf-progress/demo/pf-progress.html b/elements/pf-progress/demo/pf-progress.html new file mode 100644 index 0000000000..adfee3bcf2 --- /dev/null +++ b/elements/pf-progress/demo/pf-progress.html @@ -0,0 +1,5 @@ + + +

Default:

+ + \ No newline at end of file diff --git a/elements/pf-progress/demo/pf-progress.js b/elements/pf-progress/demo/pf-progress.js new file mode 100644 index 0000000000..bc46f8f80b --- /dev/null +++ b/elements/pf-progress/demo/pf-progress.js @@ -0,0 +1 @@ +import '@patternfly/elements/pf-progress/pf-progress.js'; diff --git a/elements/pf-progress/demo/truncated-description.html b/elements/pf-progress/demo/truncated-description.html new file mode 100644 index 0000000000..62993a68c4 --- /dev/null +++ b/elements/pf-progress/demo/truncated-description.html @@ -0,0 +1,10 @@ + + + + diff --git a/elements/pf-progress/docs/pf-progress.md b/elements/pf-progress/docs/pf-progress.md new file mode 100644 index 0000000000..eeeeb930c2 --- /dev/null +++ b/elements/pf-progress/docs/pf-progress.md @@ -0,0 +1,19 @@ +{% renderInstallation %} {% endrenderInstallation %} + +{% renderOverview %} + +{% endrenderOverview %} + +{% band header="Usage" %}{% endband %} + +{% renderSlots %}{% endrenderSlots %} + +{% renderAttributes %}{% endrenderAttributes %} + +{% renderMethods %}{% endrenderMethods %} + +{% renderEvents %}{% endrenderEvents %} + +{% renderCssCustomProperties %}{% endrenderCssCustomProperties %} + +{% renderCssParts %}{% endrenderCssParts %} diff --git a/elements/pf-progress/pf-progress.css b/elements/pf-progress/pf-progress.css new file mode 100644 index 0000000000..6f4226a05b --- /dev/null +++ b/elements/pf-progress/pf-progress.css @@ -0,0 +1,210 @@ +* { + box-sizing: border-box; +} + +#container { + --_pf-c-progress__bar--before--BackgroundColorWithOpacity: #0066cc33; /* WARNING: not a recognized token value */ + --_pf-c-progress--m-success__bar--BackgroundColorWithOpacity: #3e863533; /* WARNING: not a recognized token value */ + --_pf-c-progress--m-warning__bar--BackgroundColorWithOpacity: #f0ab0033; /* WARNING: not a recognized token value */ + --_pf-c-progress--m-danger__bar--BackgroundColorWithOpacity: #c9190b33; /* WARNING: not a recognized token value */ + + --pf-c-progress--GridGap: var(--pf-global--spacer--md, 1rem); + --pf-c-progress__bar--before--BackgroundColor: var(--pf-global--primary-color--100, #0066cc); + --pf-c-progress__bar--Height: var(--pf-global--spacer--md, 1rem); + --pf-c-progress__bar--BackgroundColor: var(--pf-global--BackgroundColor--light-100, #ffffff); + --pf-c-progress__status-icon--Color: var(--pf-global--Color--100, #151515); + --pf-c-progress__status-icon--MarginLeft: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-progress__indicator--Height: var(--pf-c-progress__bar--Height); + --pf-c-progress__indicator--BackgroundColor: var(--pf-c-progress__bar--before--BackgroundColor); + --pf-c-progress--m-success__bar--BackgroundColor: var(--pf-global--success-color--100, #3e8635); + --pf-c-progress--m-warning__bar--BackgroundColor: var(--pf-global--warning-color--100, #f0ab00); + --pf-c-progress--m-danger__bar--BackgroundColor: var(--pf-global--danger-color--100, #c9190b); + --pf-c-progress--m-success__status-icon--Color: var(--pf-global--success-color--100, #3e8635); + --pf-c-progress--m-warning__status-icon--Color: var(--pf-global--warning-color--100, #f0ab00); + --pf-c-progress--m-danger__status-icon--Color: var(--pf-global--danger-color--100, #c9190b); + --pf-c-progress--m-success--m-inside__measure--Color: var(--pf-global--Color--light-100, #ffffff); + --pf-c-progress--m-outside__measure--FontSize: var(--pf-global--FontSize--sm, 0.875rem); + --pf-c-progress--m-sm__bar--Height: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-progress--m-sm__description--FontSize: var(--pf-global--FontSize--sm, 0.875rem); + --pf-c-progress--m-lg__bar--Height: var(--pf-global--spacer--lg, 1.5rem); + display: grid; + align-items: end; + grid-gap: var(--pf-c-progress--GridGap); + grid-template-columns: 1fr auto; + grid-template-rows: 1fr auto; + width: 100%; +} + +.sm { + --pf-c-progress__bar--Height: var(--pf-c-progress--m-sm__bar--Height); + --pf-c-progress__indicator--Height: var(--pf-c-progress--m-sm__bar--Height); +} + +.sm #description { + font-size: var(--pf-c-progress--m-sm__description--FontSize); +} + +.lg { + --pf-c-progress__bar--Height: var(--pf-c-progress--m-lg__bar--Height); + --pf-c-progress__indicator--Height: var(--pf-c-progress--m-lg__bar--Height); +} + +.outside #description { + grid-column: 1/3; +} + +.outside #status { + grid-column: 2/3; + grid-row: 2/3; + align-self: center; +} + +.outside progress, +.outside span { + display: inline-block; + font-size: var(--pf-c-progress--m-outside__measure--FontSize); + grid-column: 1/2; +} + +.singleline { + grid-template-rows: 1fr; +} + +.singleline #description { + display: none; + visibility: hidden; +} + +.singleline progress, +.singleline span { + grid-row: 1/2; + grid-column: 1/2; +} + +.singleline #status { + grid-row: 1/2; + grid-column: 2/3; +} + +.outside, .singleline { + grid-template-columns: 1fr fit-content(50%); +} + +.success { + --pf-c-progress__bar--before--BackgroundColor: var(--pf-c-progress--m-success__bar--BackgroundColor); + --_pf-c-progress__bar--before--BackgroundColorWithOpacity: var(--_pf-c-progress--m-success__bar--BackgroundColorWithOpacity); + --pf-c-progress__status-icon--Color: var(--pf-c-progress--m-success__status-icon--Color); +} + +.warning { + --pf-c-progress__bar--before--BackgroundColor: var(--pf-c-progress--m-warning__bar--BackgroundColor); + --_pf-c-progress__bar--before--BackgroundColorWithOpacity: var(--_pf-c-progress--m-warning__bar--BackgroundColorWithOpacity); + --pf-c-progress__status-icon--Color: var(--pf-c-progress--m-warning__status-icon--Color); +} + +.danger { + --pf-c-progress__bar--before--BackgroundColor: var(--pf-c-progress--m-danger__bar--BackgroundColor); + --_pf-c-progress__bar--before--BackgroundColorWithOpacity: var(--_pf-c-progress--m-danger__bar--BackgroundColorWithOpacity); + --pf-c-progress__status-icon--Color: var(--pf-c-progress--m-danger__status-icon--Color); +} + +#description { + word-break: break-word; + grid-column: 1/2; +} + +.descriptionTruncated #description { + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#status { + grid-column: 2/3; + grid-row: 1/2; + text-align: right; + word-break: break-word; + display: flex; + align-items: center; + justify-content: end; +} + +pf-icon { + margin-left: var(--pf-c-progress__status-icon--MarginLeft); + color: var(--pf-c-progress__status-icon--Color); +} + +progress { + position: relative; + grid-column: 1/3; + grid-row: 2/3; + align-self: center; + height: var(--pf-c-progress__bar--Height); + background-color: var(--pf-c-progress__bar--BackgroundColor); +} + +.indicator { + position: absolute; + top: 0; + left: 0; + height: var(--pf-c-progress__indicator--Height); + background-color: var(--pf-c-progress__indicator--BackgroundColor); +} + +.indicator { + width: 100%; + height: var(--pf-c-progress__bar--Height); + + display: block; +} + +span { + grid-column: 1/3; + grid-row: 2/3; + text-align: center; + color: var(--pf-c-progress--m-success--m-inside__measure--Color); +} + +span::after { + content: attr(data-value); + position: relative; + height: 100%; +} + +progress[value] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + + background: var(--_pf-c-progress__bar--before--BackgroundColorWithOpacity); + + width: 100%; + height: var(--pf-c-progress__bar--Height); +} + +progress:not([value]) { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +progress[value]::-webkit-progress-bar { + background: var(--_pf-c-progress__bar--before--BackgroundColorWithOpacity); +} + +progress[value]::-moz-progress-bar { + background: var(--pf-c-progress__bar--before--BackgroundColor); +} + +progress[value]::-webkit-progress-value { + background-size: 100% 100%; + background-image: linear-gradient( + 90deg, + var(--pf-c-progress__bar--before--BackgroundColor) 100%, + var(--pf-c-progress__bar--before--BackgroundColor) 100% + ); +} + +pf-tooltip { + height: 0.01px; +} diff --git a/elements/pf-progress/pf-progress.ts b/elements/pf-progress/pf-progress.ts new file mode 100644 index 0000000000..a8dc28e021 --- /dev/null +++ b/elements/pf-progress/pf-progress.ts @@ -0,0 +1,213 @@ +import type { PropertyValues } from 'lit'; +import { LitElement, html } from 'lit'; +import { classMap } from 'lit/directives/class-map.js'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import styles from './pf-progress.css'; + +const ICONS = new Map(Object.entries({ + success: { icon: 'circle-check' }, + danger: { icon: 'circle-xmark' }, + warning: { icon: 'triangle-exclamation' } +})); + +/** + * A progress bar gives the user a visual representation of their completion status of an ongoing process or task. + * + * @summary Display completion status of ongoing process or task. + * + * @cssprop {} --pf-c-progress--GridGap + * Gap between the sections of the progress bar. + * {@default `1rem`} + * + * @cssprop {} --pf-c-progress__bar--before--BackgroundColor + * Color of the progress bar. + * {@default `#06c`} + * + * @cssprop {} --pf-c-progress__bar--Height + * Height of the progress bar. + * {@default `1rem`} + * + * @cssprop {} --pf-c-progress__bar--BackgroundColor + * Background color of the progress bar. + * {@default `#ffffff`} + * + * @cssprop {} --pf-c-progress__status-icon--Color + * Color of the status icon. + * {@default `#151515`} + * + * @cssprop {} --pf-c-progress__status-icon--MarginLeft + * Margin left of the status icon. + * {@default `0.5rem`} + * + * @cssprop {} --pf-c-progress__indicator--Height + * Height of the progress bar indicator. + * {@default `1rem`} + * + * @cssprop {} --pf-c-progress__indicator--BackgroundColor + * Background color of the progress bar indicator. + * {@default `#ffffff`} + * + * @cssprop {} --pf-c-progress--m-success__bar--BackgroundColor + * Background color of the progress bar when variant is success. + * {@default `#3e8635`} + * + * @cssprop {} --pf-c-progress--m-warning__bar--BackgroundColor + * Background color of the progress bar when variant is warning. + * {@default `#f0ab00`} + * + * @cssprop {} --pf-c-progress--m-danger__bar--BackgroundColor + * Background color of the progress bar when variant is danger. + * {@default `#c9190b`} + * + * @cssprop {} --pf-c-progress--m-success__status-icon--Color + * Color of the status icon when variant is success. + * {@default `#3e8635`} + * + * @cssprop {} --pf-c-progress--m-warning__status-icon--Color + * Color of the status icon when variant is warning. + * {@default `#f0ab00`} + * + * @cssprop {} --pf-c-progress--m-danger__status-icon--Color + * Color of the status icon when variant is danger. + * {@default `#c9190b`} + * + * @cssprop {} --pf-c-progress--m-success--m-inside__measure--Color + * Color of the progress bar measure when variant is success and measure location is inside. + * {@default `#ffffff`} + * + * @cssprop {} --pf-c-progress--m-outside__measure--FontSize + * Font size of the progress bar measure when measure location is outside. + * {@default `0.875rem`} + * + * @cssprop {} --pf-c-progress--m-sm__bar--Height + * Height of the progress bar when the size is small. + * {@default `0.5rem`} + * + * @cssprop {} --pf-c-progress--m-sm__description--FontSize + * Font size of the progress bar description when the size is small. + * {@default `0.875rem`} + * + * @cssprop {} --pf-c-progress--m-lg__bar--Height + * Height of the progress bar when the size is large. + * {@default `1.5rem`} + * + */ +@customElement('pf-progress') +export class PfProgress extends LitElement { + static readonly styles = [styles]; + + #internals = this.attachInternals(); + + /** Represents the value of the progress bar */ + @property({ reflect: true, type: Number }) value = 0; + + /** Description (title) above the progress bar */ + @property() description?: string; + + /** Indicate whether to truncate the string description (title) */ + @property({ + type: Boolean, + reflect: true, + attribute: 'description-truncated', + }) descriptionTruncated = false; + + /** Maximum value for the progress bar */ + @property({ type: Number, reflect: true }) max = 100; + + /** Minimum value for the progress bar */ + @property({ type: Number, reflect: true }) min = 0; + + /** Size of the progress bar (height) */ + @property() size?: 'sm' | 'lg'; + + /** Where the percentage will be displayed with the progress element */ + @property({ attribute: 'measure-location' }) measureLocation?: 'outside' | 'inside' | 'none'; + + /** Variant of the progress bar */ + @property() variant?: 'success' | 'danger' | 'warning'; + + get #calculatedPercentage(): number { + const { value, min, max } = this; + const percentage = Math.round((value - min) / (max - min) * 100); + if (Number.isNaN(percentage) || percentage < 0) { + return 0; + } + return Math.min(percentage, 100); + } + + get #icon() { + return ICONS.get(this.variant ?? '')?.icon; + } + + override willUpdate(changed: PropertyValues) { + if (changed.has('value') || changed.has('min') || changed.has('max')) { + this.#internals.ariaValueNow = this.#calculatedPercentage.toString(); + } + if (this.#icon) { + import('@patternfly/elements/pf-icon/pf-icon.js'); + } + if (this.descriptionTruncated) { + import('@patternfly/elements/pf-tooltip/pf-tooltip.js'); + } + } + + render() { + const { size, measureLocation, variant, description, descriptionTruncated } = this; + const icon = this.#icon; + const singleLine = description?.length === 0; + const pct = this.#calculatedPercentage; + const width = `${pct}%`; + + return html` +
+ + + + ${!descriptionTruncated ? '' : html` + + `} + + ${measureLocation === 'none' ? '' : html` + + `} + + + + ${measureLocation !== 'inside' ? '' : html` + + `} +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-progress': PfProgress; + } +} diff --git a/elements/pf-progress/test/pf-progress.e2e.ts b/elements/pf-progress/test/pf-progress.e2e.ts new file mode 100644 index 0000000000..e19cba99d9 --- /dev/null +++ b/elements/pf-progress/test/pf-progress.e2e.ts @@ -0,0 +1,12 @@ +import { test } from '@playwright/test'; +import { PfeDemoPage } from '@patternfly/pfe-tools/test/playwright/PfeDemoPage.js'; + +const tagName = 'pf-progress'; + +test.describe(tagName, () => { + test('snapshot', async ({ page }) => { + const componentPage = new PfeDemoPage(page, tagName); + await componentPage.navigate(); + await componentPage.snapshot(); + }); +}); diff --git a/elements/pf-progress/test/pf-progress.spec.ts b/elements/pf-progress/test/pf-progress.spec.ts new file mode 100644 index 0000000000..3265c59949 --- /dev/null +++ b/elements/pf-progress/test/pf-progress.spec.ts @@ -0,0 +1,52 @@ +import { expect, html, fixture } from '@open-wc/testing'; +import { PfProgress } from '@patternfly/elements/pf-progress/pf-progress.js'; + +describe('', function() { + let element: PfProgress; + + beforeEach(async function() { + element = await fixture(html` + + + `); + }); + + it('should upgrade', async function() { + const klass = customElements.get('pf-progress'); + expect(element).to.be.an.instanceOf(klass).and.to.be.an.instanceOf(PfProgress); + }); + + it('should be accessible', async function() { + await expect(element).shadowDom.to.be.accessible(); + }); + + it('should set the correct value on the progress bar', async function() { + const element = await fixture(html` + + + `); + expect(element.value).to.equal(33); + }); + + it('should set the correct title on the progress bar', async function() { + const element = await fixture(html` + + + `); + expect(element.title).to.equal('Progress title'); + }); + + it('should have the correct value with the max value set', async function() { + const max = Math.floor(Math.random() * 100); + const value = Math.floor(Math.random() * (max)); + + const element = await fixture(html` + + + `); + expect(element.value).to.equal(value); + expect(element.max).to.equal(max); + }); +}); diff --git a/elements/pf-switch/pf-switch.ts b/elements/pf-switch/pf-switch.ts index fcd45a37b6..31c8234a17 100644 --- a/elements/pf-switch/pf-switch.ts +++ b/elements/pf-switch/pf-switch.ts @@ -47,8 +47,8 @@ export class PfSwitch extends BaseSwitch { static readonly styles = [...BaseSwitch.styles, styles]; } - declare global { - interface HTMLElementTagNameMap { - 'pf-switch': PfSwitch; +declare global { + interface HTMLElementTagNameMap { + 'pf-switch': PfSwitch; } } diff --git a/elements/pf-tooltip/BaseTooltip.css b/elements/pf-tooltip/BaseTooltip.css index faae1f87d8..43a366a4f7 100644 --- a/elements/pf-tooltip/BaseTooltip.css +++ b/elements/pf-tooltip/BaseTooltip.css @@ -6,14 +6,10 @@ #container { display: inline-flex; position: relative; + max-width: 100%; --_floating-arrow-size: 0.5rem; } -#invoker { - display: inline-block; - position: relative; -} - #tooltip, #tooltip::after { position: absolute; diff --git a/elements/pf-tooltip/BaseTooltip.ts b/elements/pf-tooltip/BaseTooltip.ts index 2791e83e79..0d6843f8f6 100644 --- a/elements/pf-tooltip/BaseTooltip.ts +++ b/elements/pf-tooltip/BaseTooltip.ts @@ -11,6 +11,9 @@ import style from './BaseTooltip.css'; const enterEvents = ['focusin', 'tap', 'click', 'mouseenter']; const exitEvents = ['focusout', 'blur', 'mouseleave']; +/** + * @deprecated - Will be removed in the next major version. Use FloatingDOMController + */ export abstract class BaseTooltip extends LitElement { static readonly styles = [style]; @@ -48,9 +51,9 @@ export abstract class BaseTooltip extends LitElement { return html`
+ class="${classMap({ open, + [anchor]: !!anchor, + [alignment]: !!alignment })}"> + + Tooltip! + + + +
Flip fallback
+ + +
+ + Tooltip! + +
No flip
+
+ + + + + diff --git a/elements/pf-tooltip/demo/trigger.html b/elements/pf-tooltip/demo/trigger.html new file mode 100644 index 0000000000..1e789d4d7c --- /dev/null +++ b/elements/pf-tooltip/demo/trigger.html @@ -0,0 +1,8 @@ + +Button + + + diff --git a/elements/pf-tooltip/pf-tooltip.css b/elements/pf-tooltip/pf-tooltip.css index 825da52d9c..b731903f80 100644 --- a/elements/pf-tooltip/pf-tooltip.css +++ b/elements/pf-tooltip/pf-tooltip.css @@ -1,11 +1,43 @@ :host { --_timestamp-text-decoration: underline dashed 1px; --_timestamp-text-underline-offset: 4px; + display: inline; +} + +* { box-sizing: border-box; } + +#container { + display: inline-flex; + position: relative; + max-width: 100%; + --_floating-arrow-size: var(--pf-c-tooltip__arrow--Width, 0.5rem); +} + +#invoker.block { + display: block; +} + +#tooltip, +#tooltip::after { + position: absolute; } #tooltip { --_timestamp-text-decoration: none; --_timestamp-text-underline-offset: initial; + display: block; + opacity: 0; + pointer-events: none; + z-index: 10000; + transition: opacity 300ms cubic-bezier(0.54, 1.5, 0.38, 1.11) 0s; + text-align: center; + word-break: break-word; + translate: var(--_floating-content-translate); + max-width: calc(100vw - 10px); + width: max-content; + top: 0; + left: 0; + will-change: opacity; line-height: var(--pf-c-tooltip--line-height, 1.5); max-width: var(--pf-c-tooltip--MaxWidth, 18.75rem); box-shadow: var(--pf-c-tooltip--BoxShadow, @@ -29,12 +61,42 @@ var(--pf-global--BackgroundColor--dark-100, #151515)); } -#container { - --_floating-arrow-size: var(--pf-c-tooltip__arrow--Width, 0.5rem); -} - #tooltip::after { + display: block; + content: ''; + rotate: 45deg; + width: var(--_floating-arrow-size); + height: var(--_floating-arrow-size); + will-change: left top right bottom; background-color: var(--pf-c-tooltip__content--BackgroundColor, var(--pf-global--BackgroundColor--dark-100, #151515)); } +.open #tooltip { + opacity: 1; +} + +/* LEFT */ +.left #tooltip::after { right: calc(-0.5 * var(--_floating-arrow-size)); } +.left.center #tooltip::after { top: calc(50% - 0.5 * var(--_floating-arrow-size)); } +.left.start #tooltip::after { top: var(--_floating-arrow-size); } +.left.end #tooltip::after { bottom: var(--_floating-arrow-size); } + +/* TOP */ +.top #tooltip::after { top: calc(100% - 0.5 * var(--_floating-arrow-size)); } +.top.center #tooltip::after { right: calc(50% - 0.5 * var(--_floating-arrow-size)); } +.top.start #tooltip::after { left: var(--_floating-arrow-size); } +.top.end #tooltip::after { right: var(--_floating-arrow-size); } + +/* RIGHT */ +.right #tooltip::after { right: calc(100% - 0.5 * var(--_floating-arrow-size)); } +.right.center #tooltip::after { top: calc(50% - 0.5 * var(--_floating-arrow-size)); } +.right.start #tooltip::after { top: var(--_floating-arrow-size); } +.right.end #tooltip::after { bottom: var(--_floating-arrow-size); } + +/* BOTTOM */ +.bottom #tooltip::after { bottom: calc(100% - 0.5 * var(--_floating-arrow-size)); } +.bottom.center #tooltip::after { right: calc(50% - 0.5 * var(--_floating-arrow-size)); } +.bottom.start #tooltip::after { left: var(--_floating-arrow-size); } +.bottom.end #tooltip::after { right: var(--_floating-arrow-size); } + diff --git a/elements/pf-tooltip/pf-tooltip.ts b/elements/pf-tooltip/pf-tooltip.ts index 04596d885c..ca7c56ebb8 100644 --- a/elements/pf-tooltip/pf-tooltip.ts +++ b/elements/pf-tooltip/pf-tooltip.ts @@ -1,11 +1,25 @@ -import type { Placement } from '@patternfly/pfe-core/controllers/floating-dom-controller.js'; - +import type { PropertyValues } from 'lit'; +import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; -import { BaseTooltip } from './BaseTooltip.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import { + FloatingDOMController, + type Placement, +} from '@patternfly/pfe-core/controllers/floating-dom-controller.js'; + +import { bound } from '@patternfly/pfe-core/decorators/bound.js'; + +import { StringListConverter } from '@patternfly/pfe-core'; + import styles from './pf-tooltip.css'; +const EnterEvents = ['focusin', 'tap', 'click', 'mouseenter']; +const ExitEvents = ['focusout', 'blur', 'mouseleave']; + /** * A **tooltip** is in-app messaging used to identify elements on a page with short, * clarifying text. @@ -94,13 +108,150 @@ import styles from './pf-tooltip.css'; * {@default `45deg`} */ @customElement('pf-tooltip') -export class PfTooltip extends BaseTooltip { - static readonly styles = [...BaseTooltip.styles, styles]; +export class PfTooltip extends LitElement { + static readonly styles = [styles]; + /** The position of the tooltip, relative to the invoking content */ @property() position: Placement = 'top'; /** Tooltip content. Overridden by the content slot */ @property() content?: string; + + /** If false, prevents the tooltip from trying to remain in view by flipping itself when necessary */ + @property({ type: Boolean, attribute: 'no-flip' }) noFlip = false; + + @property() trigger?: string | Element; + + /** + * The flip order when flip is enabled and the initial position is not possible. + * There are 12 options: `top`, `bottom`, `left`, `right`, `top-start`, `top-end`, + * `bottom-start`, `bottom-end`, `left-start`, `left-end`,`right-start`, `right-end`. + * The default is [oppositePlacement], where only the opposite placement is tried. + */ + @property({ + attribute: 'flip-behavior', + converter: StringListConverter, + }) flipBehavior?: Placement[]; + + get #invoker(): HTMLSlotElement | null { + return this.shadowRoot?.querySelector('#invoker') ?? null; + } + + get #content(): HTMLElement | null { + return this.shadowRoot?.querySelector('#tooltip') ?? null; + } + + #blockInvoker = false; + + #referenceTrigger?: HTMLElement | null; + + #float = new FloatingDOMController(this, { + content: (): HTMLElement | null | undefined => this.#content, + invoker: (): HTMLElement | null | undefined => { + if (this.#referenceTrigger) { + return this.#referenceTrigger; + } else if (this.#invoker instanceof HTMLSlotElement && this.#invoker.assignedElements().length > 0) { + return this.#invoker.assignedElements().at(0) as HTMLElement; + } else { + return this.#invoker; + } + }, + }); + + override connectedCallback() { + super.connectedCallback(); + this.#invokerChanged(); + this.#updateTrigger(); + } + + /** + * Removes event listeners from the old trigger element and attaches + * them to the new trigger element. + */ + override willUpdate(changed: PropertyValues) { + if (changed.has('trigger')) { + this.#updateTrigger(); + } + } + + override render() { + const { alignment, anchor, open, styles } = this.#float; + + const block = this.#blockInvoker; + + return html` +
+ + ${this.content} +
+ `; + } + + /** the invoker slot should render at block level if it only has text nodes */ + #invokerChanged() { + this.#blockInvoker = + this.#invoker?.assignedElements().length === 0 && + this.#invoker?.assignedNodes().length > 0; + this.requestUpdate(); + } + + #getReferenceTrigger() { + return (this.getRootNode() as Document | ShadowRoot).getElementById(this.trigger?.normalize() ?? ''); + } + + #updateTrigger() { + const oldReferenceTrigger = this.#referenceTrigger; + this.#referenceTrigger = + this.trigger instanceof HTMLElement ? this.trigger + : typeof this.trigger === 'string' ? this.#getReferenceTrigger() + : null; + for (const evt of EnterEvents) { + if (this.#referenceTrigger) { + this.removeEventListener(evt, this.show); + this.#referenceTrigger.addEventListener(evt, this.show); + } else { + oldReferenceTrigger?.removeEventListener(evt, this.show); + this.addEventListener(evt, this.show); + } + } + for (const evt of ExitEvents) { + if (this.#referenceTrigger) { + this.removeEventListener(evt, this.hide); + this.#referenceTrigger.addEventListener(evt, this.hide); + } else { + oldReferenceTrigger?.removeEventListener(evt, this.hide); + this.addEventListener(evt, this.hide); + } + } + } + + @bound async show() { + await this.updateComplete; + const placement = this.position; + const offset = + !placement?.match(/top|bottom/) ? 15 + : { mainAxis: 15, alignmentAxis: -4 }; + await this.#float.show({ + offset, + placement, + flip: !this.noFlip, + fallbackPlacements: this.flipBehavior, + }); + } + + @bound async hide() { + await this.#float.hide(); + } } declare global { diff --git a/tools/pfe-tools/test/utils.ts b/tools/pfe-tools/test/utils.ts index 6ce1f0f986..5691d11d6c 100644 --- a/tools/pfe-tools/test/utils.ts +++ b/tools/pfe-tools/test/utils.ts @@ -1,3 +1,4 @@ +import { sendMouse } from '@web/test-runner-commands'; import type { ReactiveElement } from 'lit'; export type Position = [x: number, y: number]; @@ -11,6 +12,11 @@ export function getElementPosition(element: Element): Position { ]; } +export async function clickElementCenter(element: Element): Promise { + const position = getElementPosition(element); + await sendMouse({ type: 'click', position }); +} + /** * Waits for an element to completely finish updating, or throws after 100 attempts * Will also throw if the element doesn't have an `updateComplete` promise