diff --git a/.changeset/heavy-peas-appear.md b/.changeset/heavy-peas-appear.md new file mode 100644 index 0000000000..0df3aec13c --- /dev/null +++ b/.changeset/heavy-peas-appear.md @@ -0,0 +1,18 @@ +--- +"@patternfly/pfe-tools": minor +--- +Added `querySnapshot` accessibility testing helper + +```ts + +describe('then clicking the toggle', function() { + beforeEach(async function() { + await clickElementAtCenter(toggle); + }); + it('expands the disclosure panel', async function() { + const snapshot = await a11ySnapshot(); + const expanded = querySnapshot(snapshot, { expanded: true }); + expect(expanded).to.be.ok; + }); +}); +``` diff --git a/.changeset/hip-coins-prove.md b/.changeset/hip-coins-prove.md new file mode 100644 index 0000000000..861aa6a252 --- /dev/null +++ b/.changeset/hip-coins-prove.md @@ -0,0 +1,32 @@ +--- +"@patternfly/elements": major +--- +``: Removed `BaseAccordion*` classes, as well as static `isPanel`, `isHeader`, and `isAccordion` methods. Removed the optional `parentAccordion` parameter to `PfAccordion#expand(index)`. Renamed accordion event classes by adding the `Pf` prefix: + +**Before**: + +```js +import { + AccordionHeaderChangeEvent +} from '@patternfly/elements/pf-accordion/pf-accordion.js'; + +addEventListener('change', function(event) { + if (event instanceof AccordionHeaderChangeEvent) { + // ... + } +}); +``` + +**After**: + +```js +import { + PfAccordionHeaderChangeEvent +} from '@patternfly/elements/pf-accordion/pf-accordion.js'; + +addEventListener('change', function(event) { + if (event instanceof PfAccordionHeaderChangeEvent) { + // ... + } +}); +``` diff --git a/elements/pf-accordion/BaseAccordion.ts b/elements/pf-accordion/BaseAccordion.ts deleted file mode 100644 index a79afd49a3..0000000000 --- a/elements/pf-accordion/BaseAccordion.ts +++ /dev/null @@ -1,327 +0,0 @@ -import type { TemplateResult } from 'lit'; - -import { LitElement, html } from 'lit'; -import { property } from 'lit/decorators/property.js'; - -import { NumberListConverter, ComposedEvent } from '@patternfly/pfe-core'; -import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; - -import { AccordionHeaderChangeEvent, BaseAccordionHeader } from './BaseAccordionHeader.js'; -import { BaseAccordionPanel } from './BaseAccordionPanel.js'; - -import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; - -export class AccordionExpandEvent extends ComposedEvent { - constructor( - public toggle: BaseAccordionHeader, - public panel: BaseAccordionPanel, - ) { - super('expand'); - } -} - -export class AccordionCollapseEvent extends ComposedEvent { - constructor( - public toggle: BaseAccordionHeader, - public panel: BaseAccordionPanel, - ) { - super('collapse'); - } -} - -export abstract class BaseAccordion extends LitElement { - static isAccordion(target: EventTarget | null): target is BaseAccordion { - return target instanceof BaseAccordion; - } - - static isHeader(target: EventTarget | null): target is BaseAccordionHeader { - return target instanceof BaseAccordionHeader; - } - - static isPanel(target: EventTarget | null): target is BaseAccordionPanel { - return target instanceof BaseAccordionPanel; - } - - static #isAccordionChangeEvent(event: Event): event is AccordionHeaderChangeEvent { - return event instanceof AccordionHeaderChangeEvent; - } - - #headerIndex = new RovingTabindexController(this, { - getItems: () => this.headers, - }); - - #expandedIndex: number[] = []; - - /** - * Sets and reflects the currently expanded accordion 0-based indexes. - * Use commas to separate multiple indexes. - * ```html - * - * ... - * - * ``` - */ - @property({ - attribute: 'expanded-index', - converter: NumberListConverter, - }) - get expandedIndex() { - return this.#expandedIndex; - } - - set expandedIndex(value) { - const old = this.#expandedIndex; - this.#expandedIndex = value; - if (JSON.stringify(old) !== JSON.stringify(value)) { - this.requestUpdate('expandedIndex', old); - this.collapseAll().then(async () => { - for (const i of this.expandedIndex) { - await this.expand(i, this); - } - }); - } - } - - get headers() { - return this.#allHeaders(); - } - - get panels() { - return this.#allPanels(); - } - - get #activeHeader() { - const { headers } = this; - const index = headers.findIndex(header => header.matches(':focus,:focus-within')); - return index > -1 ? headers.at(index) : undefined; - } - - protected expandedSets = new Set(); - - #logger = new Logger(this); - - // actually is read in #init, by the `||=` operator - // eslint-disable-next-line no-unused-private-class-members - #initialized = false; - - protected override async getUpdateComplete(): Promise { - const c = await super.getUpdateComplete(); - const results = await Promise.all([ - ...this.#allHeaders().map(x => x.updateComplete), - ...this.#allPanels().map(x => x.updateComplete), - ]); - return c && results.every(Boolean); - } - - #mo = new MutationObserver(() => this.#init()); - - connectedCallback() { - super.connectedCallback(); - this.addEventListener('change', this.#onChange as EventListener); - this.#mo.observe(this, { childList: true }); - this.#init(); - } - - render(): TemplateResult { - return html` - - `; - } - - async firstUpdated() { - const { headers } = this; - headers.forEach((header, index) => { - if (header.expanded) { - this.#expandHeader(header, index); - const panel = this.#panelForHeader(header); - if (panel) { - this.#expandPanel(panel); - } - } - }); - } - - /** - * Initialize the accordion by connecting headers and panels - * with aria controls and labels; set up the default disclosure - * state if not set by the author; and check the URL for default - * open - */ - async #init() { - this.#initialized ||= !!await this.updateComplete; - // Event listener to the accordion header after the accordion has been initialized to add the roving tabindex - this.addEventListener('focusin', this.#updateActiveHeader); - this.updateAccessibility(); - } - - #updateActiveHeader() { - if (this.#activeHeader !== this.#headerIndex.activeItem) { - this.#headerIndex.setActiveItem(this.#activeHeader); - } - } - - #panelForHeader(header: BaseAccordionHeader) { - const next = header.nextElementSibling; - if (!BaseAccordion.isPanel(next)) { - return void this.#logger.error('Sibling element to a header needs to be a panel'); - } else { - return next; - } - } - - #expandHeader(header: BaseAccordionHeader, index = this.#getIndex(header)) { - // If this index is not already listed in the expandedSets array, add it - this.expandedSets.add(index); - this.#expandedIndex = [...this.expandedSets as Set]; - header.expanded = true; - } - - #expandPanel(panel: BaseAccordionPanel) { - panel.expanded = true; - panel.hidden = false; - } - - async #collapseHeader(header: BaseAccordionHeader, index = this.#getIndex(header)) { - if (!this.expandedSets) { - await this.updateComplete; - } - this.expandedSets.delete(index); - header.expanded = false; - await header.updateComplete; - } - - async #collapsePanel(panel: BaseAccordionPanel) { - await panel.updateComplete; - if (!panel.expanded) { - return; - } - - panel.expanded = false; - panel.hidden = true; - } - - #onChange(event: AccordionHeaderChangeEvent) { - if (BaseAccordion.#isAccordionChangeEvent(event)) { - const index = this.#getIndex(event.target); - if (event.expanded) { - this.expand(index, event.accordion); - } else { - this.collapse(index); - } - } - } - - #allHeaders(accordion: BaseAccordion = this): BaseAccordionHeader[] { - return Array.from(accordion.children).filter(BaseAccordion.isHeader); - } - - #allPanels(accordion: BaseAccordion = this): BaseAccordionPanel[] { - return Array.from(accordion.children).filter(BaseAccordion.isPanel); - } - - #getIndex(el: Element | null) { - if (BaseAccordion.isHeader(el)) { - return this.headers.findIndex(header => header.id === el.id); - } - - if (BaseAccordion.isPanel(el)) { - return this.panels.findIndex(panel => panel.id === el.id); - } - - this.#logger.warn('The #getIndex method expects to receive a header or panel element.'); - return -1; - } - - public updateAccessibility() { - this.#headerIndex.updateItems(); - const { headers } = this; - - // For each header in the accordion, attach the aria connections - headers.forEach(header => { - const panel = this.#panelForHeader(header); - if (panel) { - header.setAttribute('aria-controls', panel.id); - panel.setAttribute('aria-labelledby', header.id); - panel.hidden = !panel.expanded; - } - }); - } - - /** - * Accepts a 0-based index value (integer) for the set of accordion items to expand or collapse. - */ - public async toggle(index: number) { - const { headers } = this; - const header = headers[index]; - - if (!header.expanded) { - await this.expand(index); - } else { - await this.collapse(index); - } - } - - /** - * Accepts a 0-based index value (integer) for the set of accordion items to expand. - * Accepts an optional parent accordion to search for headers and panels. - */ - public async expand(index: number, parentAccordion?: BaseAccordion) { - const allHeaders: BaseAccordionHeader[] = this.#allHeaders(parentAccordion); - - const header = allHeaders[index]; - if (!header) { - return; - } - - const panel = this.#panelForHeader(header); - if (!panel) { - return; - } - - // If the header and panel exist, open both - this.#expandHeader(header, index), - this.#expandPanel(panel), - - header.focus(); - - this.dispatchEvent(new AccordionExpandEvent(header, panel)); - - await this.updateComplete; - } - - /** - * Expands all accordion items. - */ - public async expandAll() { - this.headers.forEach(header => this.#expandHeader(header)); - this.panels.forEach(panel => this.#expandPanel(panel)); - await this.updateComplete; - } - - /** - * Accepts a 0-based index value (integer) for the set of accordion items to collapse. - */ - public async collapse(index: number) { - const header = this.headers.at(index); - const panel = this.panels.at(index); - - if (!header || !panel) { - return; - } - - this.#collapseHeader(header); - this.#collapsePanel(panel); - - this.dispatchEvent(new AccordionCollapseEvent(header, panel)); - await this.updateComplete; - } - - /** - * Collapses all accordion items. - */ - public async collapseAll() { - this.headers.forEach(header => this.#collapseHeader(header)); - this.panels.forEach(panel => this.#collapsePanel(panel)); - await this.updateComplete; - } -} diff --git a/elements/pf-accordion/BaseAccordionHeader.css b/elements/pf-accordion/BaseAccordionHeader.css deleted file mode 100644 index d6de7bfbfe..0000000000 --- a/elements/pf-accordion/BaseAccordionHeader.css +++ /dev/null @@ -1,39 +0,0 @@ -#heading { - font-size: 100%; - padding: 0; - margin: 0; -} - -button, -a { - cursor: pointer; -} - -.toggle, -.toggle:before, -.toggle:after { - padding: 0; - margin: 0; -} - -.toggle { - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - border: 0; -} - -.toggle:after { - content: ""; - position: absolute; - bottom: 0; - left: 0; -} - -span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} diff --git a/elements/pf-accordion/BaseAccordionHeader.ts b/elements/pf-accordion/BaseAccordionHeader.ts deleted file mode 100644 index 07e5e1f9dd..0000000000 --- a/elements/pf-accordion/BaseAccordionHeader.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { TemplateResult } from 'lit'; - -import { LitElement, html } from 'lit'; -import { property } from 'lit/decorators/property.js'; - -import { BaseAccordion } from './BaseAccordion.js'; -import { ComposedEvent } from '@patternfly/pfe-core'; -import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; -import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; - -import style from './BaseAccordionHeader.css'; - -const isPorHeader = - (el: Node): el is HTMLElement => - el instanceof HTMLElement && !!el.tagName.match(/P|^H[1-6]/); - -export class AccordionHeaderChangeEvent extends ComposedEvent { - declare target: BaseAccordionHeader; - constructor( - public expanded: boolean, - public toggle: BaseAccordionHeader, - public accordion: BaseAccordion - ) { - super('change'); - } -} - -export abstract class BaseAccordionHeader extends LitElement { - static readonly styles = [style]; - - static override readonly shadowRootOptions = { - ...LitElement.shadowRootOptions, - delegatesFocus: true, - }; - - @property({ type: Boolean, reflect: true }) expanded = false; - - @property({ reflect: true, attribute: 'heading-text' }) headingText?: string; - - @property({ reflect: true, attribute: 'heading-tag' }) headingTag?: string; - - #generatedHtag?: HTMLHeadingElement; - - #logger = new Logger(this); - - #header?: HTMLElement; - - override connectedCallback() { - super.connectedCallback(); - this.addEventListener('click', this.#onClick); - this.hidden = true; - this.id ||= getRandomId(this.localName); - this.#initHeader(); - } - - async #initHeader() { - if (this.headingText && !this.headingTag) { - this.headingTag = 'h3'; - } - this.#header = this.#getOrCreateHeader(); - - // prevent double-logging - if (this.#header !== this.#generatedHtag) { - this.#generatedHtag = undefined; - } - - do { - await this.updateComplete; - } while (!await this.updateComplete); - - // Remove the hidden attribute after upgrade - this.hidden = false; - } - - /** Template hook: before */ - renderAfterButton?(): TemplateResult; - - override render(): TemplateResult { - switch (this.headingTag) { - case 'h1': return html`

${this.#renderHeaderContent()}

`; - case 'h2': return html`

${this.#renderHeaderContent()}

`; - case 'h3': return html`

${this.#renderHeaderContent()}

`; - case 'h4': return html`

${this.#renderHeaderContent()}

`; - case 'h5': return html`
${this.#renderHeaderContent()}
`; - case 'h6': return html`
${this.#renderHeaderContent()}
`; - default: return this.#renderHeaderContent(); - } - } - - #renderHeaderContent() { - const headingText = this.headingText?.trim() ?? this.#header?.textContent?.trim(); - return html` - - `; - } - - #getOrCreateHeader(): HTMLElement | undefined { - // Check if there is no nested element or nested textNodes - if (!this.firstElementChild && !this.firstChild) { - return void this.#logger.warn('No header content provided'); - } else if (this.firstElementChild) { - const [heading, ...otherContent] = Array.from(this.children) - .filter((x): x is HTMLElement => !x.hasAttribute('slot') && isPorHeader(x)); - - // If there is no content inside the slot, return empty with a warning - // else, if there is more than 1 element in the slot, capture the first h-tag - if (!heading) { - return void this.#logger.warn('No heading information was provided.'); - } else if (otherContent.length) { - this.#logger.warn('Heading currently only supports 1 tag; extra tags will be ignored.'); - } - return heading; - } else { - if (!this.#generatedHtag) { - this.#logger.warn('Header should contain at least 1 heading tag for correct semantics.'); - } - this.#generatedHtag = document.createElement('h3'); - - // If a text node was provided but no semantics, default to an h3 - // otherwise, incorrect semantics were used, create an H3 and try to capture the content - if (this.firstChild?.nodeType === Node.TEXT_NODE) { - this.#generatedHtag.textContent = this.firstChild.textContent; - } else { - this.#generatedHtag.textContent = this.textContent; - } - - return this.#generatedHtag; - } - } - - #onClick(event: MouseEvent) { - const expanded = !this.expanded; - const acc = event.composedPath().find(BaseAccordion.isAccordion); - if (acc) { - this.dispatchEvent(new AccordionHeaderChangeEvent(expanded, this, acc)); - } - } -} diff --git a/elements/pf-accordion/BaseAccordionPanel.css b/elements/pf-accordion/BaseAccordionPanel.css deleted file mode 100644 index da1f4f4f89..0000000000 --- a/elements/pf-accordion/BaseAccordionPanel.css +++ /dev/null @@ -1,27 +0,0 @@ -:host { - display: none; - overflow: hidden; - will-change: height; -} - -:host([expanded]) { - display: block; - position: relative; -} - -:host([fixed]) { - overflow-y: auto; -} - -.body { - position: relative; - overflow: hidden; -} - -.body:after { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 0; -} diff --git a/elements/pf-accordion/BaseAccordionPanel.ts b/elements/pf-accordion/BaseAccordionPanel.ts deleted file mode 100644 index ba8067eec9..0000000000 --- a/elements/pf-accordion/BaseAccordionPanel.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { LitElement, html } from 'lit'; -import { property } from 'lit/decorators/property.js'; - -import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; - -import style from './BaseAccordionPanel.css'; - -export class BaseAccordionPanel extends LitElement { - static readonly styles = [style]; - - @property({ type: Boolean, reflect: true }) expanded = false; - - connectedCallback() { - super.connectedCallback(); - this.id ||= getRandomId(this.localName); - this.setAttribute('role', 'region'); - } - - override render() { - return html` -
-
-
- -
-
-
- `; - } -} diff --git a/elements/pf-accordion/demo/single-expanded-panel.html b/elements/pf-accordion/demo/single-expanded-panel.html index 164fa17229..97b4e07160 100644 --- a/elements/pf-accordion/demo/single-expanded-panel.html +++ b/elements/pf-accordion/demo/single-expanded-panel.html @@ -1,58 +1,49 @@ -
- - -

Level One - Item one

-
- -

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua.

-
- -

Level One - Item two

-
- -

Vivamus et tortor sed arcu congue vehicula eget et diam. Praesent nec dictum lorem. Aliquam id diam ultrices, - faucibus erat id, maximus nunc.

-
- -

Level One - Item three

-
- -

Morbi vitae urna quis nunc convallis hendrerit. Aliquam congue orci quis ultricies tempus.

-
- -

Level One - Item four

-
- -

- Donec vel posuere orci. Phasellus quis tortor a ex hendrerit efficitur. Aliquam lacinia ligula pharetra, - sagittis ex ut, pellentesque diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere - cubilia Curae; - Vestibulum ultricies nulla nibh. Etiam vel dui fermentum ligula ullamcorper eleifend non quis tortor. Morbi - tempus ornare tempus. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. - Mauris - et velit neque. Donec ultricies condimentum mauris, pellentesque imperdiet libero convallis convallis. Aliquam - erat volutpat. Donec rutrum semper tempus. Proin dictum imperdiet nibh, quis dapibus nulla. Integer sed - tincidunt - lectus, sit amet auctor eros. -

-
- -

Level One - Item five

-
- -

Vivamus finibus dictum ex id ultrices. Mauris dictum neque a iaculis blandit.

-
-
-
- + + +

Level One - Item one

+
+ +

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua.

+
+ +

Level One - Item two

+
+ +

Vivamus et tortor sed arcu congue vehicula eget et diam. Praesent nec dictum lorem. Aliquam id diam ultrices, + faucibus erat id, maximus nunc.

+
+ +

Level One - Item three

+
+ +

Morbi vitae urna quis nunc convallis hendrerit. Aliquam congue orci quis ultricies tempus.

+
+ +

Level One - Item four

+
+ +

+ Donec vel posuere orci. Phasellus quis tortor a ex hendrerit efficitur. Aliquam lacinia ligula pharetra, + sagittis ex ut, pellentesque diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere + cubilia Curae; + Vestibulum ultricies nulla nibh. Etiam vel dui fermentum ligula ullamcorper eleifend non quis tortor. Morbi + tempus ornare tempus. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. + Mauris + et velit neque. Donec ultricies condimentum mauris, pellentesque imperdiet libero convallis convallis. Aliquam + erat volutpat. Donec rutrum semper tempus. Proin dictum imperdiet nibh, quis dapibus nulla. Integer sed + tincidunt + lectus, sit amet auctor eros. +

+
+ +

Level One - Item five

+
+ +

Vivamus finibus dictum ex id ultrices. Mauris dictum neque a iaculis blandit.

+
+
- - diff --git a/elements/pf-accordion/pf-accordion-header.css b/elements/pf-accordion/pf-accordion-header.css index 9351af8836..cec8e35d07 100644 --- a/elements/pf-accordion/pf-accordion-header.css +++ b/elements/pf-accordion/pf-accordion-header.css @@ -32,11 +32,21 @@ #heading { font-weight: var(--pf-c-accordion__toggle--FontWeight, var(--pf-global--FontWeight--normal, 400)); + font-size: 100%; + padding: 0; + margin: 0; +} + +button, +a { + cursor: pointer; } .toggle, .toggle:before, .toggle:after { + padding: 0; + margin: 0; background-color: var(--pf-c-accordion__toggle--BackgroundColor, transparent); } @@ -45,6 +55,12 @@ } .toggle { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + border: 0; padding: var(--pf-c-accordion__toggle--PaddingTop, var(--pf-global--spacer--md, 0.5rem)) var(--pf-c-accordion__toggle--PaddingRight, var(--pf-global--spacer--md, 1rem)) @@ -77,9 +93,16 @@ top: var(--pf-c-accordion__toggle--before--Top, -1px); width: var(--pf-c-accordion__toggle--before--Width, var(--pf-global--BorderWidth--lg, 3px)); background-color: var(--pf-c-accordion__toggle--after--BackgroundColor, transparent); + content: ""; + position: absolute; + bottom: 0; + left: 0; } span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; max-width: var(--pf-c-accordion__toggle-text--MaxWidth, calc(100% - var(--pf-global--spacer--lg, 1.5rem))); } diff --git a/elements/pf-accordion/pf-accordion-header.ts b/elements/pf-accordion/pf-accordion-header.ts index 42347d740c..c6d5f859d5 100644 --- a/elements/pf-accordion/pf-accordion-header.ts +++ b/elements/pf-accordion/pf-accordion-header.ts @@ -1,30 +1,44 @@ -import { html } from 'lit'; +import type { PfAccordion } from './pf-accordion.js'; + +import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; import { SlotController } from '@patternfly/pfe-core/controllers/slot-controller.js'; +import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; -import { BaseAccordionHeader } from './BaseAccordionHeader.js'; +import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; import style from './pf-accordion-header.css'; import '@patternfly/elements/pf-icon/pf-icon.js'; +const isPorHeader = + (el: Node): el is HTMLElement => + el instanceof HTMLElement && !!el.tagName.match(/P|^H[1-6]/); + +export class PfAccordionHeaderChangeEvent extends Event { + declare target: PfAccordionHeader; + constructor( + public expanded: boolean, + public toggle: PfAccordionHeader, + public accordion: PfAccordion + ) { + super('change', { bubbles: true }); + } +} + /** * Accordion Header - * * @csspart text - inline element containing the heading text or slotted heading content * @csspart accents - container for accents within the header * @csspart icon - caret icon - * * @slot * We expect the light DOM of the pf-accordion-header to be a heading level tag (h1, h2, h3, h4, h5, h6) * @slot accents * These elements will appear inline with the accordion header, between the header and the chevron * (or after the chevron and header in disclosure mode). - * * @fires {AccordionHeaderChangeEvent} change - when the open panels change - * * @cssprop {} --pf-c-accordion__toggle--Color * Sets the font color for the accordion header. * {@default `var(--pf-global--Color--100, #151515)`} @@ -81,8 +95,13 @@ import '@patternfly/elements/pf-icon/pf-icon.js'; * {@default `0.2s ease-in 0s`} */ @customElement('pf-accordion-header') -export class PfAccordionHeader extends BaseAccordionHeader { - static readonly styles = [...BaseAccordionHeader.styles, style]; +export class PfAccordionHeader extends LitElement { + static readonly styles = [style]; + + static override readonly shadowRootOptions = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; @property({ reflect: true }) bordered?: 'true' | 'false'; @@ -90,19 +109,117 @@ export class PfAccordionHeader extends BaseAccordionHeader { @property({ reflect: true, attribute: 'icon-set' }) iconSet?: string; + @property({ type: Boolean, reflect: true }) expanded = false; + + @property({ reflect: true, attribute: 'heading-text' }) headingText?: string; + + @property({ reflect: true, attribute: 'heading-tag' }) headingTag?: string; + + #generatedHtag?: HTMLHeadingElement; + + #logger = new Logger(this); + + #header?: HTMLElement; + #slots = new SlotController(this, 'accents', null); - renderAfterButton() { - return html`${!this.#slots.hasSlotted('accents') ? '' : html` - - - `} - + override connectedCallback() { + super.connectedCallback(); + this.hidden = true; + this.id ||= getRandomId(this.localName); + this.#initHeader(); + } + + override render() { + const headingText = this.headingText?.trim() ?? this.#header?.textContent?.trim(); + const content = html` + `; + switch (this.headingTag) { + case 'h1': return html`

${content}

`; + case 'h2': return html`

${content}

`; + case 'h3': return html`

${content}

`; + case 'h4': return html`

${content}

`; + case 'h5': return html`
${content}
`; + case 'h6': return html`
${content}
`; + default: return content; + } + } + + async #initHeader() { + if (this.headingText) { + this.headingTag ||= 'h3'; + } + this.#header = this.#getOrCreateHeader(); + + // prevent double-logging + if (this.#header !== this.#generatedHtag) { + this.#generatedHtag = undefined; + } + + do { + await this.updateComplete; + } while (!await this.updateComplete); + + // Remove the hidden attribute after upgrade + this.hidden = false; + } + + #getOrCreateHeader(): HTMLElement | undefined { + // Check if there is no nested element or nested textNodes + if (!this.firstElementChild && !this.firstChild) { + return void this.#logger.warn('No header content provided'); + } else if (this.firstElementChild) { + const [heading, ...otherContent] = Array.from(this.children) + .filter((x): x is HTMLElement => !x.hasAttribute('slot') && isPorHeader(x)); + + // If there is no content inside the slot, return empty with a warning + // else, if there is more than 1 element in the slot, capture the first h-tag + if (!heading) { + return void this.#logger.warn('No heading information was provided.'); + } else if (otherContent.length) { + this.#logger.warn('Heading currently only supports 1 tag; extra tags will be ignored.'); + } + return heading; + } else { + if (!this.#generatedHtag) { + this.#logger.warn('Header should contain at least 1 heading tag for correct semantics.'); + } + this.#generatedHtag = document.createElement('h3'); + + // If a text node was provided but no semantics, default to an h3 + // otherwise, incorrect semantics were used, create an H3 and try to capture the content + if (this.firstChild?.nodeType === Node.TEXT_NODE) { + this.#generatedHtag.textContent = this.firstChild.textContent; + } else { + this.#generatedHtag.textContent = this.textContent; + } + + return this.#generatedHtag; + } + } + + #onClick() { + const expanded = !this.expanded; + const acc = this.closest('pf-accordion'); + if (acc) { + this.dispatchEvent(new PfAccordionHeaderChangeEvent(expanded, this, acc)); + } } } diff --git a/elements/pf-accordion/pf-accordion-panel.css b/elements/pf-accordion/pf-accordion-panel.css index cf780d1bc4..25e90c75ba 100644 --- a/elements/pf-accordion/pf-accordion-panel.css +++ b/elements/pf-accordion/pf-accordion-panel.css @@ -1,4 +1,8 @@ :host { + display: none; + position: relative; + overflow: hidden; + will-change: height; color: var(--pf-global--Color--100, #151515); background-color: var( @@ -16,6 +20,11 @@ } .body:after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; width: var(--pf-c-accordion__panel-body--before--Width, var(--pf-global--BorderWidth--lg, 3px)); background-color: var(--pf-c-accordion__panel-body--before--BackgroundColor, transparent); } @@ -46,9 +55,15 @@ } :host([fixed]) { + overflow-y: auto; max-height: var(--pf-c-accordion__panel--m-fixed--MaxHeight, 9.375rem); } +:host([expanded]) { + display: block; + position: relative; +} + .content[expanded], :host([expanded]) .content { --pf-c-accordion__panel-body--before--BackgroundColor: diff --git a/elements/pf-accordion/pf-accordion-panel.ts b/elements/pf-accordion/pf-accordion-panel.ts index 90fc37fe8b..30fa296fb6 100644 --- a/elements/pf-accordion/pf-accordion-panel.ts +++ b/elements/pf-accordion/pf-accordion-panel.ts @@ -1,13 +1,13 @@ +import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; -import { BaseAccordionPanel } from './BaseAccordionPanel.js'; +import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; import style from './pf-accordion-panel.css'; /** * Accordion Panel - * * @slot - Panel content * @cssprop {} --pf-c-accordion--BackgroundColor * Sets the background color for the panel content. @@ -45,10 +45,30 @@ import style from './pf-accordion-panel.css'; * {@default `var(--pf-global--BorderWidth--lg, 3px)`} */ @customElement('pf-accordion-panel') -export class PfAccordionPanel extends BaseAccordionPanel { - static readonly styles = [...BaseAccordionPanel.styles, style]; +export class PfAccordionPanel extends LitElement { + static readonly styles = [style]; + + @property({ type: Boolean, reflect: true }) expanded = false; @property({ reflect: true }) bordered?: 'true' | 'false'; + + override connectedCallback() { + super.connectedCallback(); + this.id ||= getRandomId(this.localName); + this.setAttribute('role', 'region'); + } + + override render() { + return html` +
+
+
+ +
+
+
+ `; + } } declare global { diff --git a/elements/pf-accordion/pf-accordion.ts b/elements/pf-accordion/pf-accordion.ts index b1de02153c..173e1c77e3 100644 --- a/elements/pf-accordion/pf-accordion.ts +++ b/elements/pf-accordion/pf-accordion.ts @@ -1,15 +1,38 @@ +import { LitElement, html } from 'lit'; import { observed } from '@patternfly/pfe-core/decorators.js'; import { property } from 'lit/decorators/property.js'; import { customElement } from 'lit/decorators/custom-element.js'; -import { BaseAccordion } from './BaseAccordion.js'; -import { BaseAccordionHeader } from './BaseAccordionHeader.js'; +import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; +import { NumberListConverter } from '@patternfly/pfe-core'; +import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; + +import { PfAccordionHeader, PfAccordionHeaderChangeEvent } from './pf-accordion-header.js'; +import { PfAccordionPanel } from './pf-accordion-panel.js'; export * from './pf-accordion-header.js'; export * from './pf-accordion-panel.js'; import style from './pf-accordion.css'; +export class PfAccordionExpandEvent extends Event { + constructor( + public toggle: PfAccordionHeader, + public panel: PfAccordionPanel, + ) { + super('expand', { bubbles: true, cancelable: true }); + } +} + +export class PfAccordionCollapseEvent extends Event { + constructor( + public toggle: PfAccordionHeader, + public panel: PfAccordionPanel, + ) { + super('collapse', { bubbles: true, cancelable: true }); + } +} + /** * An **accordion** is an interactive container that expands and collapses to hide or reveal nested content. It takes advantage of progressive disclosure to help reduce page scrolling, by allowing users to choose whether they want to show or hide more detailed information as needed. * @summary Toggle the visibility of sections of content @@ -81,7 +104,7 @@ import style from './pf-accordion.css'; * @cssprop --pf-c-accordion--m-bordered__expanded-content--m-expanded__expanded-content-body--last-child--after--BorderBottomColor {@default var(--pf-global--BorderColor--100, #d2d2d2)} */ @customElement('pf-accordion') -export class PfAccordion extends BaseAccordion { +export class PfAccordion extends LitElement { static readonly styles = [style]; /** When true, only one accordion panel may be expanded at a time */ @@ -98,39 +121,304 @@ export class PfAccordion extends BaseAccordion { @property({ type: Boolean, reflect: true }) fixed = false; + /** + * Sets and reflects the currently expanded accordion 0-based indexes. + * Use commas to separate multiple indexes. + * ```html + * + * ... + * + * ``` + */ + @property({ + attribute: 'expanded-index', + converter: NumberListConverter, + }) + get expandedIndex() { + return this.#expandedIndex; + } + + set expandedIndex(value) { + const old = this.#expandedIndex; + this.#expandedIndex = value; + if (JSON.stringify(old) !== JSON.stringify(value)) { + this.requestUpdate('expandedIndex', old); + this.collapseAll().then(async () => { + for (const i of this.expandedIndex) { + await this.expand(i); + } + }); + } + } + + #logger = new Logger(this); + + // actually is read in #init, by the `||=` operator + // eslint-disable-next-line no-unused-private-class-members + #initialized = false; + + #mo = new MutationObserver(() => this.#init()); + + #headerIndex = new RovingTabindexController(this, { + getItems: () => this.headers, + }); + + #expandedIndex: number[] = []; + + protected expandedSets = new Set(); + + get #activeHeader() { + const { headers } = this; + const index = headers.findIndex(header => header.matches(':focus,:focus-within')); + return index > -1 ? headers.at(index) : undefined; + } + + get headers() { + return this.#allHeaders(); + } + + get panels() { + return this.#allPanels(); + } + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('change', this.#onChange as EventListener); + this.#mo.observe(this, { childList: true }); + this.#init(); + } + + render() { + return html` + + `; + } + async firstUpdated() { - let index: number | null = null; - if (this.single) { - const allHeaders = [...this.querySelectorAll('pf-accordion-header')]; - const lastExpanded = allHeaders.filter(x => x.hasAttribute('expanded')).pop(); - if (lastExpanded) { - index = allHeaders.indexOf(lastExpanded); + let lastExpandedIndex: number; + const { headers, single } = this; + const lastExpanded = headers.filter(x => x.hasAttribute('expanded')).pop(); + if (lastExpanded) { + lastExpandedIndex = headers.indexOf(lastExpanded); + } + headers.forEach((header, index) => { + if (header.expanded && (!single || index === lastExpandedIndex)) { + this.#expandHeader(header, index); + const panel = this.#panelForHeader(header); + if (panel) { + this.#expandPanel(panel); + } } + }); + } + + protected override async getUpdateComplete(): Promise { + const c = await super.getUpdateComplete(); + const results = await Promise.all([ + ...this.#allHeaders().map(x => x.updateComplete), + ...this.#allPanels().map(x => x.updateComplete), + ]); + return c && results.every(Boolean); + } + + /** + * Initialize the accordion by connecting headers and panels + * with aria controls and labels; set up the default disclosure + * state if not set by the author; and check the URL for default + * open + */ + async #init() { + this.#initialized ||= !!await this.updateComplete; + // Event listener to the accordion header after the accordion has been initialized to add the roving tabindex + this.addEventListener('focusin', this.#updateActiveHeader); + this.updateAccessibility(); + } + + #updateActiveHeader() { + if (this.#activeHeader !== this.#headerIndex.activeItem) { + this.#headerIndex.setActiveItem(this.#activeHeader); } - await super.firstUpdated(); - if (index !== null) { - this.headers.forEach((_, i) => { - this.headers.at(i)?.toggleAttribute('expanded', i === index); - this.panels.at(i)?.toggleAttribute('expanded', i === index); - }); + } + + #panelForHeader(header: PfAccordionHeader) { + const next = header.nextElementSibling; + if (!(next instanceof PfAccordionPanel)) { + return void this.#logger.error('Sibling element to a header needs to be a panel'); + } else { + return next; } } - override async expand(index: number, parentAccordion?: BaseAccordion) { - if (index === -1) { + #expandHeader(header: PfAccordionHeader, index = this.#getIndex(header)) { + // If this index is not already listed in the expandedSets array, add it + this.expandedSets.add(index); + this.#expandedIndex = [...this.expandedSets as Set]; + header.expanded = true; + } + + #expandPanel(panel: PfAccordionPanel) { + panel.expanded = true; + panel.hidden = false; + } + + async #collapseHeader(header: PfAccordionHeader, index = this.#getIndex(header)) { + if (!this.expandedSets) { + await this.updateComplete; + } + this.expandedSets.delete(index); + header.expanded = false; + await header.updateComplete; + } + + async #collapsePanel(panel: PfAccordionPanel) { + await panel.updateComplete; + if (!panel.expanded) { return; } - const allHeaders: BaseAccordionHeader[] = this.headers; + panel.expanded = false; + panel.hidden = true; + } + + #onChange(event: PfAccordionHeaderChangeEvent) { + if (event instanceof PfAccordionHeaderChangeEvent && event.accordion === this) { + const index = this.#getIndex(event.target); + if (event.expanded) { + this.expand(index); + } else { + this.collapse(index); + } + event.stopPropagation(); + } + } + + #allHeaders(accordion: PfAccordion = this): PfAccordionHeader[] { + return Array.from(accordion.children).filter((x): x is PfAccordionHeader => + x instanceof PfAccordionHeader); + } + + #allPanels(accordion: PfAccordion = this): PfAccordionPanel[] { + return Array.from(accordion.children).filter((x): x is PfAccordionPanel => + x instanceof PfAccordionPanel); + } + + #getIndex(el: Element | null) { + if (el instanceof PfAccordionHeader) { + return this.headers.findIndex(header => header.id === el.id); + } + + if (el instanceof PfAccordionPanel) { + return this.panels.findIndex(panel => panel.id === el.id); + } + + this.#logger.warn('The #getIndex method expects to receive a header or panel element.'); + return -1; + } + + public updateAccessibility() { + this.#headerIndex.updateItems(); + const { headers } = this; + + // For each header in the accordion, attach the aria connections + headers.forEach(header => { + const panel = this.#panelForHeader(header); + if (panel) { + header.setAttribute('aria-controls', panel.id); + panel.setAttribute('aria-labelledby', header.id); + panel.hidden = !panel.expanded; + } + }); + } + + /** + * Accepts a 0-based index value (integer) for the set of accordion items to expand. + * Accepts an optional parent accordion to search for headers and panels. + * @param index index (0-based) of the panel to expand + */ + public async expand(index: number) { + if (index === -1) { + return; + } // Get all the headers and capture the item by index value if (this.single) { await Promise.all([ - ...allHeaders.map((header, index) => header.expanded && this.collapse(index)), + ...this.headers.map((header, index) => header.expanded && this.collapse(index)), ]); } - await super.expand(index, parentAccordion); + const header = this.headers[index]; + if (!header) { + return; + } + + const panel = this.#panelForHeader(header); + if (!panel) { + return; + } + + // If the header and panel exist, open both + this.#expandHeader(header, index), + this.#expandPanel(panel), + + header.focus(); + + this.dispatchEvent(new PfAccordionExpandEvent(header, panel)); + + await this.updateComplete; + } + + /** + * Accepts a 0-based index value (integer) for the set of accordion items to collapse. + * @param index index (0-based) of the panel to collapse + */ + public async collapse(index: number) { + const header = this.headers.at(index); + const panel = this.panels.at(index); + + if (!header || !panel) { + return; + } + + this.#collapseHeader(header); + this.#collapsePanel(panel); + + this.dispatchEvent(new PfAccordionCollapseEvent(header, panel)); + await this.updateComplete; + } + + /** + * Accepts a 0-based index value (integer) for the set of accordion items to expand or collapse. + * @param index index (0-based) of the panel to toggle + */ + public async toggle(index: number) { + const { headers } = this; + const header = headers[index]; + + if (!header.expanded) { + await this.expand(index); + } else { + await this.collapse(index); + } + } + + /** + * Expands all accordion items. + */ + public async expandAll() { + this.headers.forEach(header => this.#expandHeader(header)); + this.panels.forEach(panel => this.#expandPanel(panel)); + await this.updateComplete; + } + + + /** + * Collapses all accordion items. + */ + public async collapseAll() { + this.headers.forEach(header => this.#collapseHeader(header)); + this.panels.forEach(panel => this.#collapsePanel(panel)); + await this.updateComplete; } } diff --git a/elements/pf-accordion/test/pf-accordion.spec.ts b/elements/pf-accordion/test/pf-accordion.spec.ts index 5bb00395e6..032878cf7a 100644 --- a/elements/pf-accordion/test/pf-accordion.spec.ts +++ b/elements/pf-accordion/test/pf-accordion.spec.ts @@ -2,6 +2,9 @@ import { expect, html, aTimeout, nextFrame } from '@open-wc/testing'; import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; import { sendKeys } from '@web/test-runner-commands'; +import { allUpdates, clickElementAtCenter } from '@patternfly/pfe-tools/test/utils.js'; +import { a11ySnapshot, querySnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; + // Import the element we're testing. import { PfAccordion, PfAccordionPanel, PfAccordionHeader } from '@patternfly/elements/pf-accordion/pf-accordion.js'; import { PfSwitch } from '@patternfly/elements/pf-switch/pf-switch.js'; @@ -9,7 +12,6 @@ import { PfSwitch } from '@patternfly/elements/pf-switch/pf-switch.js'; import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; import '@patternfly/pfe-tools/test/stub-logger.js'; -import { allUpdates } from '@patternfly/pfe-tools/test/utils.js'; describe('', function() { let element: PfAccordion; @@ -21,12 +23,12 @@ describe('', function() { let secondPanel: PfAccordionPanel; async function clickFirstHeader() { - header.click(); + await clickElementAtCenter(header); await allUpdates(element); } async function clickSecondHeader() { - secondHeader.click(); + await clickElementAtCenter(secondHeader); await allUpdates(element); } @@ -126,18 +128,24 @@ describe('', function() { describe('clicking the first header', function() { beforeEach(clickFirstHeader); - it('expands first pair', function() { - expect(header.shadowRoot!.querySelector('button')?.getAttribute('aria-expanded')).to.equal('true'); + it('expands first pair', async function() { + const snapshot = await a11ySnapshot(); + const expanded = snapshot?.children?.find(x => x.expanded); + const focused = snapshot?.children?.find(x => x.focused); + expect(expanded?.name).to.equal(header.textContent?.trim()); expect(header.expanded).to.be.true; expect(panel.hasAttribute('expanded')).to.be.true; expect(panel.expanded).to.be.true; + expect(expanded).to.equal(focused); }); describe('then clicking first header again', function() { beforeEach(clickFirstHeader); - it('collapses first pair', function() { - expect(header.shadowRoot!.querySelector('button')?.getAttribute('aria-expanded')).to.equal('false'); + it('collapses first pair', async function() { + const snapshot = await a11ySnapshot(); + const expanded = snapshot?.children?.find(x => x.expanded); + expect(expanded).to.not.be.ok; expect(header.expanded).to.be.false; expect(panel.hasAttribute('expanded')).to.be.false; expect(panel.expanded).to.be.false; @@ -335,7 +343,7 @@ describe('', function() { describe('with all panels open', function() { beforeEach(async function() { for (const header of element.querySelectorAll('pf-accordion-header')) { - header.click(); + await clickElementAtCenter(header); } await nextFrame(); }); @@ -1019,27 +1027,52 @@ describe('', function() { beforeEach(async function() { element = await createFixture(html` - + + top-header-1 + + top-panel-1 - - + + nest-1-header-1 + + + nest-1-panel-1 + - + + top-header-2 + + top-panel-2 - - - - - - + + nest-2-header-1 + + + nest-2-header-1 + + + nest-2-header-2 + + + nest-2-panel-2 + + + nest-2-header-3 + + + nest-2-panel-3 + - - - + + top-header-3 + + + top-panel-3 + `); topLevelHeaderOne = document.getElementById('header-1') as PfAccordionHeader; @@ -1062,47 +1095,44 @@ describe('', function() { describe('clicking the first top-level heading', function() { beforeEach(async function() { - topLevelHeaderOne.click(); + await clickElementAtCenter(topLevelHeaderOne); await allUpdates(element); }); describe('then clicking the second top-level heading', function() { beforeEach(async function() { - topLevelHeaderTwo.click(); + await clickElementAtCenter(topLevelHeaderTwo); await allUpdates(element); }); describe('then clicking the first nested heading', function() { beforeEach(async function() { - nestedHeaderOne.click(); + await clickElementAtCenter(nestedHeaderOne); await allUpdates(element); }); describe('then clicking the second nested heading', function() { beforeEach(async function() { - nestedHeaderTwo.click(); + await clickElementAtCenter(nestedHeaderTwo); await allUpdates(element); }); - it('expands the first top-level pair', function() { - expect(topLevelHeaderOne.shadowRoot!.querySelector('button')?.getAttribute('aria-expanded'), 'top level header 1 button aria-expanded attr').to.equal('true'); - expect(topLevelHeaderOne.expanded, 'top level header 1 expanded DOM property').to.be.true; - expect(topLevelPanelOne.hasAttribute('expanded'), 'top level panel 1 expanded attr').to.be.true; - expect(topLevelPanelOne.expanded, 'top level panel 1 DOM property').to.be.true; + it('expands the first top-level pair', async function() { + const snapshot = await a11ySnapshot(); + const expanded = snapshot?.children?.find(x => x.expanded); + expect(expanded?.name).to.equal(topLevelHeaderOne.textContent?.trim()); + expect(topLevelHeaderOne.expanded).to.be.true; + expect(topLevelPanelOne.hasAttribute('expanded')).to.be.true; + expect(topLevelPanelOne.expanded).to.be.true; }); - it('collapses the second top-level pair', function() { - expect(topLevelHeaderTwo.shadowRoot!.querySelector('button')?.getAttribute('aria-expanded'), 'top level header 2 button aria-expanded attr').to.equal('true'); - expect(topLevelHeaderTwo.expanded, 'top level header 2 expanded DOM property').to.be.true; - expect(topLevelPanelTwo.hasAttribute('expanded'), 'top level panel 2 expanded attr').to.be.true; - expect(topLevelPanelTwo.expanded, 'top level panel 2 expanded DOM property').to.be.true; + it('collapses the second top-level pair', async function() { + const snapshot = await a11ySnapshot(); + const header2 = querySnapshot(snapshot, { name: 'top-header-2' }); + expect(header2).to.have.property('expanded', true); }); - it('collapses the first nested pair', function() { - expect(nestedHeaderOne.shadowRoot!.querySelector('button')?.getAttribute('aria-expanded'), 'nested header 1 button aria-expanded attr').to.equal('false'); - expect(nestedHeaderOne.expanded, 'nested header 1 expanded DOM property').to.be.false; - expect(nestedPanelOne.hasAttribute('expanded'), 'nested panel 1 expanded attr').to.be.false; - expect(nestedPanelOne.expanded, 'nested panel 1 expanded DOM property').to.be.false; + it('collapses the first nested pair', async function() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { name: 'nest-1-header-1' })).to.not.have.property('expanded'); }); - it('collapses the second nested pair', function() { - expect(nestedHeaderTwo.shadowRoot!.querySelector('button')?.getAttribute('aria-expanded'), 'nested header 2 button aria-expanded attr').to.equal('true'); - expect(nestedHeaderTwo.expanded, 'nested header 2 expanded DOM property').to.be.true; - expect(nestedPanelTwo.hasAttribute('expanded'), 'nested panel 2 expanded attr').to.be.true; - expect(nestedPanelTwo.expanded, 'nested panel 2 expanded DOM property').to.be.true; + it('collapses the second nested pair', async function() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { name: 'nest-2-header-1' })).to.not.have.property('expanded'); }); }); }); @@ -1122,9 +1152,10 @@ describe('', function() { describe('with all panels open', function() { beforeEach(async function() { - for (const header of element.querySelectorAll('pf-accordion-header')) { - header.click(); - } + await Promise.all(Array.from( + document.querySelectorAll('pf-accordion'), + accordion => accordion.expandAll(), + )); await nextFrame(); }); it('removes hidden attribute from all panels', function() { @@ -1278,7 +1309,7 @@ describe('', function() { describe('with all panels open', function() { beforeEach(async function() { for (const header of multipleAccordionElements.querySelectorAll('pf-accordion-header')) { - header.click(); + await clickElementAtCenter(header); } await nextFrame(); }); @@ -1351,7 +1382,7 @@ describe('', function() { describe('clicking the checkbox', function() { beforeEach(async function() { - checkbox.click(); + await clickElementAtCenter(checkbox); await element.updateComplete; }); it('does not collapse the panel', function() { @@ -1362,7 +1393,7 @@ describe('', function() { describe('clicking the switch', function() { beforeEach(async function() { const { checked } = pfswitch; - pfswitch.click(); + await clickElementAtCenter(pfswitch); await element.updateComplete; await pfswitch.updateComplete; expect(pfswitch.checked).to.not.equal(checked); diff --git a/tools/pfe-tools/test/a11y-snapshot.ts b/tools/pfe-tools/test/a11y-snapshot.ts index f3d4edf173..bcb625a4b5 100644 --- a/tools/pfe-tools/test/a11y-snapshot.ts +++ b/tools/pfe-tools/test/a11y-snapshot.ts @@ -7,6 +7,7 @@ export interface A11yTreeSnapshot { checked?: boolean; disabled?: boolean; description?: string; + expanded?: boolean; focused?: boolean; haspopup?: string; level?: number; @@ -29,3 +30,38 @@ export async function a11ySnapshot( } while (!snapshot && tries < 10); return snapshot; } + +type SnapshotQuery = Partial>; + +function matches(snapshot: A11yTreeSnapshot, query: SnapshotQuery) { + return Object.entries(query).every(([key, value]) => + JSON.stringify(snapshot[key as keyof typeof snapshot]) === JSON.stringify(value)); +} + +function doQuery(snapshot: A11yTreeSnapshot, query: SnapshotQuery): A11yTreeSnapshot | null { + if (matches(snapshot, query)) { + return snapshot; + } else if (!snapshot.children) { + return null; + } else { + for (const kid of snapshot.children) { + const result = doQuery(kid, query); + if (result) { + return result; + } + } + } + return null; +} + +/** + * Deeply search an accessibility tree snapshot for an object matching your query + * @param snapshot the snapshot root to recurse through + * @param query object with properties matching the snapshot child you seek + */ +export function querySnapshot( + snapshot: A11yTreeSnapshot, + query: SnapshotQuery, +): A11yTreeSnapshot | null { + return doQuery(snapshot, query); +}