From af07dc2bd388ebb9d06a084086b80ce0421d2c54 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 23 May 2024 09:52:56 +0300 Subject: [PATCH] fix(accordion)!: remove BaseAccordion Closes #2612 fix(accordion): expand logic refactor(accordion): prevent circular imports --- .changeset/hip-coins-prove.md | 32 ++ elements/pf-accordion/BaseAccordion.ts | 327 ------------------ elements/pf-accordion/BaseAccordionHeader.css | 39 --- elements/pf-accordion/BaseAccordionHeader.ts | 145 -------- elements/pf-accordion/BaseAccordionPanel.css | 27 -- elements/pf-accordion/BaseAccordionPanel.ts | 30 -- elements/pf-accordion/pf-accordion-header.css | 23 ++ elements/pf-accordion/pf-accordion-header.ts | 153 +++++++- elements/pf-accordion/pf-accordion-panel.css | 15 + elements/pf-accordion/pf-accordion-panel.ts | 28 +- elements/pf-accordion/pf-accordion.ts | 315 ++++++++++++++++- 11 files changed, 535 insertions(+), 599 deletions(-) create mode 100644 .changeset/hip-coins-prove.md delete mode 100644 elements/pf-accordion/BaseAccordion.ts delete mode 100644 elements/pf-accordion/BaseAccordionHeader.css delete mode 100644 elements/pf-accordion/BaseAccordionHeader.ts delete mode 100644 elements/pf-accordion/BaseAccordionPanel.css delete mode 100644 elements/pf-accordion/BaseAccordionPanel.ts 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/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..63f68b07de 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,6 +121,79 @@ 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) { @@ -107,7 +203,16 @@ export class PfAccordion extends BaseAccordion { index = allHeaders.indexOf(lastExpanded); } } - await super.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); + } + } + }); if (index !== null) { this.headers.forEach((_, i) => { this.headers.at(i)?.toggleAttribute('expanded', i === index); @@ -116,21 +221,213 @@ export class PfAccordion extends BaseAccordion { } } - override async expand(index: number, parentAccordion?: BaseAccordion) { - if (index === -1) { + 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); + } + } + + #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; + } + } + + #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; } }