From 062a2cb5c0784b7c83696c908710e05de7289409 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Sun, 3 Mar 2024 11:29:43 +0200 Subject: [PATCH 01/17] docs(select): update changeset --- .changeset/pf-select.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pf-select.md b/.changeset/pf-select.md index dad70e03b4..1ed797a29b 100644 --- a/.changeset/pf-select.md +++ b/.changeset/pf-select.md @@ -7,7 +7,7 @@ A select list enables users to select one or more items from a list. ```html - + Blue Green Magenta From 95ef2412136957e2441f55a93f9b686f2948f71f Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Sun, 3 Mar 2024 11:30:39 +0200 Subject: [PATCH 02/17] fix(tabs)!: refactor controllers and tabs state --- .changeset/tabs-controller.md | 4 +- .../controllers/tabs-aria-controller.ts | 123 +++++ core/pfe-core/controllers/tabs-controller.ts | 309 ----------- elements/package.json | 1 + elements/pf-tabs/BaseTabs.ts | 5 +- elements/pf-tabs/pf-tab-panel.ts | 7 + elements/pf-tabs/pf-tab.css | 52 +- elements/pf-tabs/pf-tab.ts | 79 ++- elements/pf-tabs/pf-tabs.ts | 86 ++- elements/pf-tabs/test/pf-tabs.spec.ts | 503 +++++++++--------- package-lock.json | 11 +- tools/pfe-tools/dev-server/config.ts | 1 + 12 files changed, 534 insertions(+), 647 deletions(-) create mode 100644 core/pfe-core/controllers/tabs-aria-controller.ts delete mode 100644 core/pfe-core/controllers/tabs-controller.ts diff --git a/.changeset/tabs-controller.md b/.changeset/tabs-controller.md index b26d83e190..cd7952b4f3 100644 --- a/.changeset/tabs-controller.md +++ b/.changeset/tabs-controller.md @@ -2,10 +2,10 @@ "@patternfly/core": minor --- -`TabsController`: Added TabsController. This controller is used to manage the state of the tabs and panels. +`TabsAriaController`: Added TabsAriaController, used to manage the accesibility tree for tabs and panels. ```ts -#tabs = new TabsController(this, { +#tabs = new TabsAriaController(this, { isTab: (x: Node): x is PfTab => x instanceof PfTab, isPanel: (x: Node): x is PfTabPanel => x instanceof PfTabPanel, }); diff --git a/core/pfe-core/controllers/tabs-aria-controller.ts b/core/pfe-core/controllers/tabs-aria-controller.ts new file mode 100644 index 0000000000..120be600df --- /dev/null +++ b/core/pfe-core/controllers/tabs-aria-controller.ts @@ -0,0 +1,123 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; + +export interface TabsControllerOptions { + /** Add an `isTab` predicate to ensure this tabs instance' state does not leak into parent tabs' state */ + isTab: (node: unknown) => node is Tab; + isActiveTab: (tab: Tab) => boolean; + /** Add an `isPanel` predicate to ensure this tabs instance' state does not leak into parent tabs' state */ + isPanel: (node: unknown) => node is Panel; + getHTMLElement?: () => HTMLElement; +} + +export class TabsAriaController< + Tab extends HTMLElement = HTMLElement, + Panel extends HTMLElement = HTMLElement, +> implements ReactiveController { + #logger: Logger; + + #host: ReactiveControllerHost; + + #element: HTMLElement; + + #tabPanelMap = new Map(); + + #options: TabsControllerOptions; + + #mo = new MutationObserver(this.#onSlotchange.bind(this)); + + get #tabs() { + return [...this.#tabPanelMap.keys()] as Tab[]; + } + + get activeTab(): Tab | undefined { + return this.#tabs.find(x => this.#options.isActiveTab(x)); + } + + /** + * @example Usage in PfTab + * ```ts + * new TabsController(this, { + * isTab: (x): x is PfTab => x instanceof PfTab, + * isPanel: (x): x is PfTabPanel => x instanceof PfTabPanel + * }); + * ``` + */ + constructor( + host: ReactiveControllerHost, + options: TabsControllerOptions, + ) { + this.#options = options; + this.#logger = new Logger(host); + if (host instanceof HTMLElement) { + this.#element = host; + } else { + const element = options.getHTMLElement?.(); + if (!element) { + throw new Error('TabsController must be instantiated with an HTMLElement or a `getHTMLElement()` option'); + } + this.#element = element; + } + (this.#host = host).addController(this); + this.#element.addEventListener('slotchange', this.#onSlotchange); + if (this.#element.isConnected) { + this.hostConnected(); + } + } + + hostConnected() { + this.#mo.observe(this.#element, { attributes: false, childList: true, subtree: false }); + this.#onSlotchange(); + } + + hostUpdated() { + for (const [tab, panel] of this.#tabPanelMap) { + if (!panel.hasAttribute('aria-labelledby')) { + panel.setAttribute('aria-labelledby', tab.id); + } + tab.setAttribute('aria-controls', panel.id); + } + } + + hostDisconnected(): void { + this.#mo.disconnect(); + } + + /** + * zip the tabs and panels together into #tabPanelMap + */ + #onSlotchange() { + this.#tabPanelMap.clear(); + const tabs = []; + const panels = []; + for (const child of this.#element.children) { + if (this.#options.isTab(child)) { + tabs.push(child); + } else if (this.#options.isPanel(child)) { + panels.push(child); + } + } + if (tabs.length > panels.length) { + this.#logger.warn('Too many tabs!'); + } else if (panels.length > tabs.length) { + this.#logger.warn('Too many panels!'); + } + while (tabs.length) { + this.#tabPanelMap.set(tabs.shift()!, panels.shift()!); + } + this.#host.requestUpdate(); + } + + panelFor(tab: Tab): Panel | undefined { + return this.#tabPanelMap.get(tab); + } + + tabFor(panel: Panel): Tab | undefined { + for (const [tab, panelToCheck] of this.#tabPanelMap) { + if (panel === panelToCheck) { + return tab; + } + } + } +} diff --git a/core/pfe-core/controllers/tabs-controller.ts b/core/pfe-core/controllers/tabs-controller.ts deleted file mode 100644 index 2feac17aa3..0000000000 --- a/core/pfe-core/controllers/tabs-controller.ts +++ /dev/null @@ -1,309 +0,0 @@ -import type { ReactiveController, ReactiveControllerHost } from 'lit'; - -import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; -import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; - -export interface Tab extends HTMLElement { - active: boolean; - disabled: boolean; -} - -export type Panel = HTMLElement - -export interface TabsControllerOptions { - /** Add an `isTab` predicate to ensure this tabs instance' state does not leak into parent tabs' state */ - isTab: (node: unknown) => node is Tab; - /** Add an `isPanel` predicate to ensure this tabs instance' state does not leak into parent tabs' state */ - isPanel: (node: unknown) => node is Panel; - /** If the controller host is not an element, pass a getter to supply the tabs container element */ - getHTMLElement?: () => HTMLElement; -} - -function isReactiveControllerHost(element: HTMLElement): element is HTMLElement & ReactiveControllerHost { - return 'addController' in element; -} - -export class TabExpandEvent extends Event { - constructor( - public tab: Tab, - ) { - super('expand', { bubbles: true, cancelable: true }); - } -} - -export class TabDisabledEvent extends Event { - constructor( - public tab: Tab, - ) { - super('disabled', { bubbles: true, cancelable: true }); - } -} - -export class TabsController implements ReactiveController { - static #instances = new Set(); - - static #tabsClasses = new WeakSet(); - - static { - window.addEventListener('expand', event => { - if (event instanceof TabExpandEvent) { - for (const instance of this.#instances) { - if (instance.#isTab(event.tab) && instance.#tabPanelMap.has(event.tab)) { - instance.#onTabExpand(event.tab); - } - } - } - }); - - window.addEventListener('disabled', event => { - if (event instanceof TabDisabledEvent) { - for (const instance of this.#instances) { - if (instance.#isTab(event.tab) && instance.#tabPanelMap.has(event.tab)) { - instance.#onTabDisabled(); - } - } - } - }); - } - - #logger: Logger; - - #host: ReactiveControllerHost; - - #element: HTMLElement; - - #tabPanelMap = new Map(); - - #isTab: TabsControllerOptions['isTab']; - - #isPanel: TabsControllerOptions['isPanel']; - - #slottedTabs: Tab[] = []; - - #slottedPanels: Panel[] = []; - - #tabindex: RovingTabindexController; - - #mo = new MutationObserver(this.#mutationsCallback.bind(this)); - - #rebuilding: Promise | null = null; - - #init = 0; - - get activeIndex() { - if (!this.#activeTab) { - return -1; - } - return this._tabs.indexOf(this.#activeTab); - } - - /** - * Sets the active index of the tabs element which will set the active tab. - * document.querySelector('tabs-element-selector').activeIndex = 0 - */ - set activeIndex(index: number) { - const firstFocusableTab = this.#tabindex.firstItem; - if (!firstFocusableTab) { - this.#logger.error(`No focusable tabs found.`); - return; - } - - let error = false; - if (this._tabs[index] === undefined) { - error = true; - this.#logger.warn(`The index provided is out of bounds: ${index} in 0 - ${this._tabs.length - 1}. Setting first focusable tab active.`); - } else if (this._tabs[index].disabled || this._tabs[index].hasAttribute('aria-disabled')) { - error = true; - this.#logger.warn(`The tab at index ${index} is disabled. Setting first focusable tab active.`); - } - if (error) { - index = this._tabs.indexOf(firstFocusableTab); - } - this._tabs[index].active = true; - } - - get activeTab(): Tab | undefined { - return this.#activeTab; - } - - set activeTab(tab: Tab | undefined) { - if (tab === undefined || !this.#tabPanelMap.has(tab)) { - this.#logger.warn(`The tab provided is not a valid tab.`); - return; - } - // get tab index - const index = this._tabs.indexOf(tab); - this.activeIndex = index; - } - - protected get _tabs() { - return [...this.#tabPanelMap.keys()] as Tab[]; - } - - get #activeTab(): Tab | undefined { - return this._tabs.find(tab => tab.active); - } - - /** - * @example Usage in PfTab - * ```ts - * new TabsController(this, { - * isTab: (x): x is PfTab => x instanceof PfTab, - * isPanel: (x): x is PfTabPanel => x instanceof PfTabPanel - * }); - * ``` - */ - constructor(host: ReactiveControllerHost, options: TabsControllerOptions) { - this.#logger = new Logger(host); - if (host instanceof HTMLElement) { - this.#element = host; - this.#tabindex = new RovingTabindexController(host, { - getItems: () => this._tabs, - }); - } else { - const element = options.getHTMLElement?.(); - if (!element) { - throw new Error('TabsController must be instantiated with an HTMLElement of a `getHTMLElement` function'); - } - // TODO(bennypowers): remove after #2570, by forwarding the `getHTMLElement` options - if (!isReactiveControllerHost(element)) { - throw new Error('TabsController\'s host HTMLElement must be a controller host as well'); - } - this.#element = element; - this.#tabindex = new RovingTabindexController(element); - } - this.#isTab = options.isTab; - this.#isPanel = options.isPanel; - TabsController.#tabsClasses.add(host.constructor); - if (this.#element.isConnected) { - TabsController.#instances.add(this); - } - (this.#host = host).addController(this); - this.#mo.observe(this.#element, { attributes: false, childList: true, subtree: false }); - this.#element.addEventListener('slotchange', this.#onSlotchange); - } - - hostConnected() { - TabsController.#instances.add(this); - this.#onSlotchange(); - } - - hostDisconnected() { - TabsController.#instances.delete(this); - } - - async hostUpdate() { - if (this.#init <= 1) { - this.#rebuilding ??= await this.#rebuild(); - this.#host.requestUpdate(); - } - this.#init++; - } - - async #mutationsCallback(mutations: MutationRecord[]): Promise { - for (const mutation of mutations) { - if ([...mutation.addedNodes.values()].some(node => this.#isTab(node))) { - this.#rebuild(); - break; - } - - if ([...mutation.removedNodes.values()].some(node => this.#isTab(node))) { - this.#rebuild(); - break; - } - } - } - - async #rebuild() { - const tabSlot = this.#element.shadowRoot?.querySelector('slot[name=tab]'); - const panelSlot = this.#element.shadowRoot?.querySelector('slot:not([name])'); - this.#slottedPanels = panelSlot?.assignedElements().filter(this.#isPanel) ?? []; - this.#slottedTabs = tabSlot?.assignedElements().filter(this.#isTab) ?? []; - - this.#tabPanelMap.clear(); - await this.#registerSlottedTabs(); - - if (this._tabs.length > 0) { - this.#updateAccessibility(); - this.#tabindex.updateItems(); - this.#setActiveTab(); - } - - return null; - } - - async #onSlotchange() { - this.#host.requestUpdate(); - } - - #onTabExpand(tab: Tab) { - this.#tabindex.setActiveItem(tab); - this.#deactivateExcept(this._tabs.indexOf(tab)); - } - - async #onTabDisabled() { - await this.#rebuild(); - } - - async #registerSlottedTabs(): Promise { - for (const [index, slotted] of this.#slottedTabs.entries()) { - this.#addPairForTab(index, slotted); - } - return null; - } - - #addPairForTab(index: number, tab: Tab) { - const panel = this.#slottedPanels[index]; - if (this.#isPanel(panel)) { - this.#tabPanelMap.set(tab, panel); - } else { - this.#logger.warn(`Tab and panel do not match`, tab, panel); - } - } - - #deactivateExcept(indexToKeep: number) { - [...this.#tabPanelMap].forEach(([tab, panel], currentIndex) => { - tab.active = currentIndex === indexToKeep; - panel.hidden = currentIndex !== indexToKeep; - }); - } - - #setActiveTab() { - // check for an active tab, if not set one - if (!this.#activeTab) { - this.#logger.warn('No active tab found. Setting to first focusable tab.'); - this.#setFirstFocusableTabActive(); - return; - } - if (this.#activeTab.disabled) { - this.#logger.warn('Active tab is disabled. Setting to first focusable tab.'); - this.#setFirstFocusableTabActive(); - return; - } - - // update RTI with active tab and deactivate others - this.#tabindex.setActiveItem(this.#activeTab); - this.#deactivateExcept(this._tabs.indexOf(this.#activeTab)); - } - - #setFirstFocusableTabActive() { - const first = this.#tabindex.firstItem; - if (!first) { - this.#logger.warn('No focusable tab found.'); - return; - } - const firstFocusable = this._tabs.find(tab => tab === first); - if (firstFocusable) { - firstFocusable.active = true; - } - } - - #updateAccessibility(): void { - for (const [tab, panel] of this.#tabPanelMap) { - if (!panel.hasAttribute('aria-labelledby')) { - panel.setAttribute('aria-labelledby', tab.id); - } - tab.setAttribute('aria-controls', panel.id); - } - } -} diff --git a/elements/package.json b/elements/package.json index f4c8eecf0f..82956bd410 100644 --- a/elements/package.json +++ b/elements/package.json @@ -141,6 +141,7 @@ "Wes Ruvalcaba" ], "dependencies": { + "@lit/context": "^1.1.0", "@patternfly/icons": "^1.0.2", "@patternfly/pfe-core": "^2.4.1", "lit": "^3.1.1", diff --git a/elements/pf-tabs/BaseTabs.ts b/elements/pf-tabs/BaseTabs.ts index bd5b731067..f9d4daa69b 100644 --- a/elements/pf-tabs/BaseTabs.ts +++ b/elements/pf-tabs/BaseTabs.ts @@ -97,10 +97,9 @@ export abstract class BaseTabs extends LitElement { this.#logger.warn(`Disabled tabs can not be active, setting first focusable tab to active`); this.#tabindex.setActiveItem(this.#firstFocusable); index = this.#activeItemIndex; - } else if (!tab.active) { - // if the activeIndex was set through the CLI e.g.`$0.activeIndex = 2` - tab.active = true; return; + } else { + tab.active = true; } } diff --git a/elements/pf-tabs/pf-tab-panel.ts b/elements/pf-tabs/pf-tab-panel.ts index d448a1cdf0..e1acaa2a95 100644 --- a/elements/pf-tabs/pf-tab-panel.ts +++ b/elements/pf-tabs/pf-tab-panel.ts @@ -1,9 +1,12 @@ import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; +import { consume } from '@lit/context/lib/decorators/consume.js'; +import { property } from 'lit/decorators/property.js'; import styles from './pf-tab-panel.css'; import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; +import { boxContext, verticalContext } from './pf-tabs.js'; /** * @slot - Tab panel content @@ -18,6 +21,10 @@ export class PfTabPanel extends LitElement { #internals = this.attachInternals(); + @consume({ context: boxContext }) @property({ reflect: true }) box: 'light' | 'dark' | null = null; + + @consume({ context: verticalContext }) @property({ type: Boolean, reflect: true }) vertical = false; + render() { return html` diff --git a/elements/pf-tabs/pf-tab.css b/elements/pf-tabs/pf-tab.css index e9c19d2995..1722a9942e 100644 --- a/elements/pf-tabs/pf-tab.css +++ b/elements/pf-tabs/pf-tab.css @@ -28,7 +28,7 @@ slot[name="icon"] { display: block; } -button { +#button { margin: 0; font-family: inherit; font-size: 100%; @@ -54,8 +54,8 @@ button { background-color: var(--pf-c-tabs__link--BackgroundColor, transparent); } -button::before, -button::after { +#button::before, +#button::after { position: absolute; top: 0; right: 0; @@ -69,7 +69,7 @@ button::after { pointer-events: none; } -button::before { +#button::before { border-block-start-width: var(--pf-c-tabs__link--before--BorderTopWidth, 0); border-inline-end-width: var(--pf-c-tabs__link--before--BorderRightWidth, 0); border-block-end-width: var(--pf-c-tabs__link--before--BorderBottomWidth, 0); @@ -80,7 +80,7 @@ button::before { border-inline-start-color: var(--pf-c-tabs__link--before--BorderLeftColor, var(--pf-c-tabs__link--before--border-color--base, var(--pf-global--BorderColor--100, #d2d2d2))); } -button::after { +#button::after { top: var(--pf-c-tabs__link--after--Top, auto); right: var(--pf-c-tabs__link--after--Right, 0); bottom: var(--pf-c-tabs__link--after--Bottom, 0); @@ -92,44 +92,44 @@ button::after { border-inline-start-width: var(--pf-c-tabs__link--after--BorderLeftWidth); } -button:hover { +#button:hover { --pf-c-tabs__link-toggle-icon--Color: var(--pf-c-tabs__link--hover__toggle-icon--Color); --pf-c-tabs__link--after--BorderWidth: var(--pf-c-tabs__link--hover--after--BorderWidth, var(--pf-global--BorderWidth--lg, 3px)); } -button:focus, -button:focus-visible { +#button:focus, +#button:focus-visible { outline-color: var(--pf-c-tabs__link--after--BorderColor, #06c); --pf-c-tabs__link--after--BorderWidth: var(--pf-c-tabs__link--focus--after--BorderWidth, var(--pf-global--BorderWidth--lg, 3px)); } -button:active { +#button:active { --pf-c-tabs__link--after--BorderWidth: var(--pf-c-tabs__link--active--after--BorderWidth, var(--pf-global--BorderWidth--lg, 3px)); } -:host([fill]) button { +:host([fill]) #button { flex-basis: 100%; justify-content: center; } -:host(:disabled) button { +:host(:disabled) #button { pointer-events: none; } -:host([aria-disabled="true"]) button { +:host([aria-disabled="true"]) #button { cursor: default; } -:host([box]) button { +:host([box]) #button { --pf-c-tabs__link--after--BorderTopWidth: var(--pf-c-tabs__link--after--BorderWidth, 0); } -:host([box]) button, -:host([vertical]) button { +:host([box]) #button, +:host([vertical]) #button { --pf-c-tabs__link--after--BorderBottomWidth: 0; } -:host([vertical]) button { +:host([vertical]) #button { --pf-c-tabs__link--after--Bottom: 0; --pf-c-tabs__link--after--BorderTopWidth: 0; --pf-c-tabs__link--after--BorderLeftWidth: var(--pf-c-tabs__link--after--BorderWidth, 0); @@ -137,32 +137,32 @@ button:active { text-align: left; } -:host([box][vertical]) button::after { +:host([box][vertical]) #button::after { top: calc(var(--pf-c-tabs__link--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px)) * -1); } -:host([box][vertical]:first-of-type) button::after, -:host([box][vertical][active]) button::after { +:host([box][vertical]:first-of-type) #button::after, +:host([box][vertical][active]) #button::after { top: 0; } -:host([box][vertical][active]) button::before { +:host([box][vertical][active]) #button::before { --pf-c-tabs__link--before--BorderRightColor: var(--pf-c-tabs__item--m-current__link--BackgroundColor, var(--pf-global--BackgroundColor--100, #ffffff)); --pf-c-tabs__link--before--BorderBottomWidth: var(--pf-c-tabs__link--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px)); --pf-c-tabs__link--before--BorderBottomColor: var(--pf-c-tabs__link--before--border-color--base, var(--pf-global--BorderColor--100, #d2d2d2)); } -:host([box][active]:first-of-type) button::before { +:host([box][active]:first-of-type) #button::before { border-block-start-width: var(--pf-c-tabs--m-box__item--m-current--first-child__link--before--BorderTopWidth, var(--pf-c-tabs__link--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px))); border-inline-start-width: var(--pf-c-tabs--m-box__item--m-current--first-child__link--before--BorderLeftWidth, var(--pf-c-tabs__link--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px))); } -:host([box][active]:last-of-type) button::before { +:host([box][active]:last-of-type) #button::before { border-inline-end-width: var(--pf-c-tabs--m-box__item--m-current--last-child__link--before--BorderRightWidth, var(--pf-c-tabs--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px))); } -:host([disabled]) button, -:host([aria-disabled="true"]) button { +:host([disabled]) #button, +:host([aria-disabled="true"]) #button { --pf-c-tabs__link--Color: var(--pf-c-tabs__link--disabled--Color, var(--pf-global--disabled-color--100, #6a6e73)); --pf-c-tabs__link--BackgroundColor: var(--pf-c-tabs__link--disabled--BackgroundColor, var(--pf-global--palette--black-150, #f5f5f5)); --pf-c-tabs__link--before--BorderRightWidth: var(--pf-c-tabs__link--disabled--before--BorderRightWidth, 0); @@ -179,7 +179,7 @@ button:active { display: none !important; } -:host([disabled][border-bottom="false"]) button, -:host([aria-disabled="true"][border-bottom="false"]) button { +:host([disabled][border-bottom="false"]) #button, +:host([aria-disabled="true"][border-bottom="false"]) #button { --pf-c-tabs__link--before--BorderBottomWidth: 0; } diff --git a/elements/pf-tabs/pf-tab.ts b/elements/pf-tabs/pf-tab.ts index efbac05978..3fabbb5ad6 100644 --- a/elements/pf-tabs/pf-tab.ts +++ b/elements/pf-tabs/pf-tab.ts @@ -7,9 +7,26 @@ import { query } from 'lit/decorators/query.js'; import { observed } from '@patternfly/pfe-core/decorators.js'; import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; -import { TabExpandEvent, TabDisabledEvent } from '@patternfly/pfe-core/controllers/tabs-controller.js'; +import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; + +import { + boxContext, + fillContext, + verticalContext, + manualContext, + borderBottomContext, +} from './pf-tabs.js'; import styles from './pf-tab.css'; +import { consume } from '@lit/context/lib/decorators/consume.js'; + +export class TabExpandEvent extends Event { + constructor( + public tab: Tab, + ) { + super('expand', { bubbles: true, cancelable: true }); + } +} /** * Tab @@ -69,7 +86,7 @@ import styles from './pf-tab.css'; * * @cssprop {} --pf-c-tabs__link--child--MarginRight {@default `1rem`} * - * @fires { TabExpandEvent } expand - when a tab expands + * @fires {TabExpandEvent} expand - when a tab expands */ @customElement('pf-tab') export class PfTab extends LitElement { @@ -77,48 +94,67 @@ export class PfTab extends LitElement { static override shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, delegatesFocus: true }; - @query('button') private button!: HTMLButtonElement; + @query('#button') private button!: HTMLButtonElement; @queryAssignedElements({ slot: 'icon', flatten: true }) private icons!: Array; - @property({ type: Boolean }) manual = false; - @observed @property({ reflect: true, type: Boolean }) active = false; @observed @property({ reflect: true, type: Boolean }) disabled = false; - #internals = this.attachInternals(); + @consume({ context: boxContext }) @property({ reflect: true }) box: 'light' | 'dark' | null = null; + + @consume({ context: verticalContext }) @property({ type: Boolean, reflect: true }) vertical = false; + + @consume({ context: fillContext }) @property({ type: Boolean, reflect: true }) fill = false; + + @consume({ context: manualContext }) @property({ type: Boolean, reflect: true }) manual = false; + + @consume({ context: borderBottomContext }) @property({ attribute: 'border-bottom' }) borderBottom: 'true' | 'false' = 'false'; + + #internals = InternalsController.of(this, { role: 'tab' }); override connectedCallback() { super.connectedCallback(); this.id ||= getRandomId(this.localName); - this.#internals.role = 'tab'; - this.#internals.ariaDisabled = this.#setInternalsAriaDisabled(); - this.addEventListener('click', this.#onClick); - this.addEventListener('focus', this.#onFocus); } render() { return html` - + `; } #onClick() { - if (this.disabled || this.#internals.ariaDisabled === 'true') { - return; + if (!this.disabled && this.#internals.ariaDisabled !== 'true') { + this.#activate(); + this.focus(); + } + } + + #onKeydown(event: KeyboardEvent) { + switch (event.key) { + case 'Enter': + if (!this.disabled) { + this.#activate(); + this.focus(); + } } - this.#activate(); - this.button.focus(); } #onFocus() { @@ -133,20 +169,13 @@ export class PfTab extends LitElement { } private _activeChanged(oldVal: boolean, newVal: boolean) { - if (oldVal !== newVal && newVal === true) { + if (newVal && oldVal !== newVal) { this.dispatchEvent(new TabExpandEvent(this)); } } private _disabledChanged() { - this.dispatchEvent(new TabDisabledEvent(this)); - } - - #setInternalsAriaDisabled() { - if (this.disabled) { - return 'true'; - } - return this.ariaDisabled ?? 'false'; + this.#internals.ariaDisabled = this.disabled ? 'true' : this.ariaDisabled ?? 'false'; } } diff --git a/elements/pf-tabs/pf-tabs.ts b/elements/pf-tabs/pf-tabs.ts index 5750891e45..837737878c 100644 --- a/elements/pf-tabs/pf-tabs.ts +++ b/elements/pf-tabs/pf-tabs.ts @@ -3,21 +3,29 @@ import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; import { query } from 'lit/decorators/query.js'; import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js'; +import { provide, createContext } from '@lit/context'; import { classMap } from 'lit/directives/class-map.js'; import { OverflowController } from '@patternfly/pfe-core/controllers/overflow-controller.js'; -import { TabExpandEvent, TabsController } from '@patternfly/pfe-core/controllers/tabs-controller.js'; +import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; +import { TabsAriaController } from '@patternfly/pfe-core/controllers/tabs-aria-controller.js'; import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; -import { cascades } from '@patternfly/pfe-core/decorators.js'; -import { PfTab } from './pf-tab.js'; +import { PfTab, TabExpandEvent } from './pf-tab.js'; import { PfTabPanel } from './pf-tab-panel.js'; import '@patternfly/elements/pf-icon/pf-icon.js'; import styles from './pf-tabs.css'; +export const activeIndexContext = createContext('pf-tabs-active-index'); +export const boxContext = createContext<'light' | 'dark' | null>('pf-tabs-box'); +export const fillContext = createContext('pf-tabs-fill'); +export const verticalContext = createContext('pf-tabs-vertical'); +export const manualContext = createContext('pf-tabs-manual'); +export const borderBottomContext = createContext<'true' | 'false'>('pf-tabs-border-bottom'); + /** * **Tabs** allow users to navigate between views within the same page or context. * @@ -72,32 +80,32 @@ export class PfTabs extends LitElement { protected static readonly scrollTimeoutDelay = 150; - static isExpandEvent(event: Event): event is TabExpandEvent { + static isExpandEvent(event: Event): event is TabExpandEvent { return event instanceof TabExpandEvent; } /** * Box styling on tabs. Defaults to null */ - @cascades('pf-tab', 'pf-tab-panel') + @provide({ context: boxContext }) @property({ reflect: true }) box: 'light' | 'dark' | null = null; /** * Set to true to enable vertical tab styling. */ - @cascades('pf-tab', 'pf-tab-panel') + @provide({ context: verticalContext }) @property({ reflect: true, type: Boolean }) vertical = false; /** * Set to true to enable filled tab styling. */ - @cascades('pf-tab') + @provide({ context: fillContext }) @property({ reflect: true, type: Boolean }) fill = false; /** * Border bottom tab styling on tabs. To remove the bottom border, set this prop to false. */ - @cascades('pf-tab') + @provide({ context: borderBottomContext }) @property({ attribute: 'border-bottom' }) borderBottom: 'true' | 'false' = 'true'; /** @@ -105,7 +113,7 @@ export class PfTabs extends LitElement { * unless a user clicks on them or uses the keyboard space or enter key to select them. Roving * tabindex will still update allowing user to keyboard navigate through the tabs with arrow keys. */ - @cascades('pf-tab') + @provide({ context: manualContext }) @property({ reflect: true, type: Boolean }) manual = false; /** @@ -121,21 +129,15 @@ export class PfTabs extends LitElement { /** * The index of the active tab */ - @property({ attribute: 'active-index', reflect: true, type: Number }) - get activeIndex() { - return this.#tabs.activeIndex; - } - - set activeIndex(index: number) { - this.#tabs.activeIndex = index; - } + @provide({ context: activeIndexContext }) + @property({ attribute: 'active-index', reflect: true, type: Number }) activeIndex = -1; - get activeTab(): PfTab { - return this.#tabs.activeTab as PfTab; + get activeTab(): PfTab | undefined { + return this.tabs?.find((_, i) => i === this.activeIndex); } set activeTab(tab: PfTab) { - this.#tabs.activeTab = tab; + this.activeIndex = this.tabs?.indexOf(tab) ?? -1; } @query('#tabs') private tabsContainer!: HTMLElement; @@ -144,23 +146,55 @@ export class PfTabs extends LitElement { #overflow = new OverflowController(this, { scrollTimeoutDelay: 200 }); - #tabs = new TabsController(this, { + #tabs = new TabsAriaController(this, { isTab: (x): x is PfTab => x instanceof PfTab, isPanel: (x): x is PfTabPanel => x instanceof PfTabPanel, + isActiveTab: x => x.active, + }); + + #tabindex = new RovingTabindexController(this, { }); override connectedCallback() { super.connectedCallback(); + this.addEventListener('expand', this.#onExpand); this.id ||= getRandomId(this.localName); } + protected override async getUpdateComplete(): Promise { + const here = await super.getUpdateComplete(); + const ps = await Promise.all(Array.from( + this.querySelectorAll('pf-tab, pf-tab-panel'), + x => x.updateComplete, + )); + return here && ps.every(x => !!x); + } + override willUpdate(): void { + if (!this.manual) { + for (const tab of this.tabs ?? []) { + this.#tabs.panelFor(tab)?.toggleAttribute('hidden', !tab.active); + } + } this.#overflow.update(); } + async firstUpdated() { + await this.updateComplete; + const active = this.tabs?.at(this.activeIndex); + if (active) { + this.activeTab = active; + } + for (const tab of this.tabs ?? []) { + tab.active = tab === this.#tabs.activeTab; + this.#tabs.panelFor(tab)?.toggleAttribute('hidden', !tab.active); + } + } + render() { return html` -
+
${!this.#overflow.showScrollButtons ? '' : html`
{% endrenderOverview %} @@ -140,8 +138,6 @@ export const Expander = () => ( Database Disabled Disabled - Aria Disabled - Aria Disabled {% endhtmlexample %} @@ -156,8 +152,6 @@ export const Expander = () => ( Database Disabled Disabled - Aria Disabled - Aria Disabled {% endhtmlexample %} @@ -172,8 +166,6 @@ export const Expander = () => ( Database Disabled Disabled - Aria Disabled - Aria Disabled {% endhtmlexample %} From 39550150bfddc1981b9b08d07f5845d53ac9713b Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Mon, 4 Mar 2024 16:52:37 +0200 Subject: [PATCH 11/17] style: whitespace --- core/pfe-core/functions/context.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/core/pfe-core/functions/context.ts b/core/pfe-core/functions/context.ts index c9852ba2ab..ec97e4bcfb 100644 --- a/core/pfe-core/functions/context.ts +++ b/core/pfe-core/functions/context.ts @@ -1,6 +1,5 @@ import { ContextRoot, createContext } from '@lit/context'; - let root: ContextRoot; function makeContextRoot() { From 2515c32fef91775805b7386a77013d285e91062f Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Tue, 5 Mar 2024 12:35:31 +0200 Subject: [PATCH 12/17] refactor(tabs): rename options type --- core/pfe-core/controllers/tabs-aria-controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/pfe-core/controllers/tabs-aria-controller.ts b/core/pfe-core/controllers/tabs-aria-controller.ts index 09e7d38b70..9dd4a3f85c 100644 --- a/core/pfe-core/controllers/tabs-aria-controller.ts +++ b/core/pfe-core/controllers/tabs-aria-controller.ts @@ -2,7 +2,7 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit'; import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; -export interface TabsControllerOptions { +export interface TabsAriaControllerOptions { /** Add an `isTab` predicate to ensure this tabs instance' state does not leak into parent tabs' state */ isTab: (node: unknown) => node is Tab; isActiveTab: (tab: Tab) => boolean; @@ -23,7 +23,7 @@ export class TabsAriaController< #tabPanelMap = new Map(); - #options: TabsControllerOptions; + #options: TabsAriaControllerOptions; #mo = new MutationObserver(this.#onSlotchange.bind(this)); @@ -46,7 +46,7 @@ export class TabsAriaController< */ constructor( host: ReactiveControllerHost, - options: TabsControllerOptions, + options: TabsAriaControllerOptions, ) { this.#options = options; this.#logger = new Logger(host); From 49f7ec922f955830b063bd8ce321c58ff7387b6f Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Tue, 5 Mar 2024 12:44:17 +0200 Subject: [PATCH 13/17] fix(tabs): warn on disabled active tab --- elements/pf-tabs/pf-tabs.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/elements/pf-tabs/pf-tabs.ts b/elements/pf-tabs/pf-tabs.ts index fd9972676a..fadd11c1dd 100644 --- a/elements/pf-tabs/pf-tabs.ts +++ b/elements/pf-tabs/pf-tabs.ts @@ -5,6 +5,7 @@ import { query } from 'lit/decorators/query.js'; import { provide } from '@lit/context'; import { classMap } from 'lit/directives/class-map.js'; +import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; import { OverflowController } from '@patternfly/pfe-core/controllers/overflow-controller.js'; import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; import { TabsAriaController } from '@patternfly/pfe-core/controllers/tabs-aria-controller.js'; @@ -149,6 +150,8 @@ export class PfTabs extends LitElement { getItems: () => this.tabs ?? [], }); + #logger = new Logger(this); + override connectedCallback() { super.connectedCallback(); this.addEventListener('expand', this.#onExpand); @@ -176,6 +179,13 @@ export class PfTabs extends LitElement { this.ctx = this.#ctx; } + protected override updated(changed: PropertyValues): void { + if (changed.has('activeTab') && this.activeTab?.disabled) { + this.#logger.warn('Active tab is disabled. Setting to first focusable tab'); + this.activeIndex = 0; + } + } + protected override firstUpdated(): void { if (this.tabs.length && this.activeIndex === -1) { this.select(this.tabs.findIndex(x => !x.disabled)); From 5a37c9116af7f0c5de967328207d8dd8d8da72fc Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Tue, 5 Mar 2024 12:54:22 +0200 Subject: [PATCH 14/17] style: whitespace --- elements/pf-tabs/context.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/elements/pf-tabs/context.ts b/elements/pf-tabs/context.ts index 4f278bf567..c71e48d216 100644 --- a/elements/pf-tabs/context.ts +++ b/elements/pf-tabs/context.ts @@ -4,7 +4,8 @@ import { createContextWithRoot } from '@patternfly/pfe-core/functions/context.js export interface PfTabsContext { activeTab: PfTab | undefined; - box: 'light' | 'dark' | null; fill: boolean; + box: 'light' | 'dark' | null; + fill: boolean; vertical: boolean; manual: boolean; borderBottom: 'true' | 'false'; From c8b39e603727339f7428eb1454aa1be9a406d573 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Tue, 5 Mar 2024 13:16:43 +0200 Subject: [PATCH 15/17] fix(tabs): aria-selected on tab --- elements/pf-tabs/pf-tab.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/elements/pf-tabs/pf-tab.ts b/elements/pf-tabs/pf-tab.ts index 8fd1d8add1..a919feb1b1 100644 --- a/elements/pf-tabs/pf-tab.ts +++ b/elements/pf-tabs/pf-tab.ts @@ -161,6 +161,7 @@ export class PfTab extends LitElement { } private _activeChanged(old: boolean) { + this.#internals.ariaSelected = String(!!this.active); if (this.active && !old) { this.#activate(); } From 608a2a8dd820ca7a6f3bdd2373392a0c12dd2d8f Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Tue, 5 Mar 2024 14:54:19 +0200 Subject: [PATCH 16/17] chore: update deps --- package-lock.json | 42 ++++++++++++++++++++------------ tools/eslint-config/package.json | 4 +-- tools/pfe-tools/package.json | 2 +- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index f74c886981..b53c7b883b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2219,9 +2219,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", - "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -6854,15 +6854,15 @@ } }, "node_modules/eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", - "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.56.0", - "@humanwhocodes/config-array": "^0.11.13", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -6986,16 +6986,17 @@ } }, "node_modules/eslint-plugin-jsonc": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsonc/-/eslint-plugin-jsonc-2.12.2.tgz", - "integrity": "sha512-iv2BLi1bqkSxCPEvDOY6xiBXzAFi5iS2gTOU8fnXGfKxkC6MvC5Tw2XAgbP6R6WRlqV7AtFItx4Xb7mCONtmmw==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsonc/-/eslint-plugin-jsonc-2.13.0.tgz", + "integrity": "sha512-2wWdJfpO/UbZzPDABuUVvlUQjfMJa2p2iQfYt/oWxOMpXCcjuiMUSaA02gtY/Dbu82vpaSqc+O7Xq6ECHwtIxA==", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "eslint-compat-utils": "^0.4.0", "espree": "^9.6.1", "graphemer": "^1.4.0", "jsonc-eslint-parser": "^2.0.4", - "natural-compare": "^1.4.0" + "natural-compare": "^1.4.0", + "synckit": "^0.6.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -7007,6 +7008,17 @@ "eslint": ">=6.0.0" } }, + "node_modules/eslint-plugin-jsonc/node_modules/synckit": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.6.2.tgz", + "integrity": "sha512-Vhf+bUa//YSTYKseDiiEuQmhGCoIF3CVBhunm3r/DQnYiGT4JssmnKQc44BIyOZRK2pKjXXAgbhfmbeoC9CJpA==", + "dependencies": { + "tslib": "^2.3.1" + }, + "engines": { + "node": ">=12.20" + } + }, "node_modules/eslint-plugin-lit": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/eslint-plugin-lit/-/eslint-plugin-lit-1.11.0.tgz", @@ -15369,11 +15381,11 @@ "@types/eslint": "^8.56.2", "@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/parser": "^6.19.0", - "eslint": "^8.56.0", + "eslint": "^8.57.0", "eslint-config-google": "0.14.0", "eslint-plugin-html": "7.1.0", "eslint-plugin-json-schema-validator": "^4.8.3", - "eslint-plugin-jsonc": "^2.12.2", + "eslint-plugin-jsonc": "^2.13.0", "eslint-plugin-lit-a11y": "^4.1.2", "eslint-plugin-no-only-tests": "^3.1.0", "eslint-plugin-unicorn": "46.0.0" @@ -15470,7 +15482,7 @@ "esbuild": "^0.19.11", "esbuild-plugin-lit-css": "^2.1.0", "esbuild-plugin-minify-html-literals": "^1.0.6", - "eslint": "^8.56.0", + "eslint": "^8.57.0", "execa": "^8.0.1", "glob": "^10.3.10", "html-include-element": "^0.3.0", diff --git a/tools/eslint-config/package.json b/tools/eslint-config/package.json index 9b6b26fd2d..8c75edc419 100644 --- a/tools/eslint-config/package.json +++ b/tools/eslint-config/package.json @@ -24,11 +24,11 @@ "@types/eslint": "^8.56.2", "@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/parser": "^6.19.0", - "eslint": "^8.56.0", + "eslint": "^8.57.0", "eslint-config-google": "0.14.0", "eslint-plugin-html": "7.1.0", "eslint-plugin-json-schema-validator": "^4.8.3", - "eslint-plugin-jsonc": "^2.12.2", + "eslint-plugin-jsonc": "^2.13.0", "eslint-plugin-lit-a11y": "^4.1.2", "eslint-plugin-no-only-tests": "^3.1.0", "eslint-plugin-unicorn": "46.0.0" diff --git a/tools/pfe-tools/package.json b/tools/pfe-tools/package.json index 35b18ac891..b46f742bc0 100644 --- a/tools/pfe-tools/package.json +++ b/tools/pfe-tools/package.json @@ -101,7 +101,7 @@ "esbuild": "^0.19.11", "esbuild-plugin-lit-css": "^2.1.0", "esbuild-plugin-minify-html-literals": "^1.0.6", - "eslint": "^8.56.0", + "eslint": "^8.57.0", "execa": "^8.0.1", "glob": "^10.3.10", "html-include-element": "^0.3.0", From 0d19f9963a85de9d2b9e847aeeccebf42bbd0f5e Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Wed, 6 Mar 2024 10:16:05 +0200 Subject: [PATCH 17/17] fix(core): tabs aria controller overwrites user aria-labelledby --- core/pfe-core/controllers/tabs-aria-controller.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/core/pfe-core/controllers/tabs-aria-controller.ts b/core/pfe-core/controllers/tabs-aria-controller.ts index 9dd4a3f85c..13c69ac72d 100644 --- a/core/pfe-core/controllers/tabs-aria-controller.ts +++ b/core/pfe-core/controllers/tabs-aria-controller.ts @@ -73,9 +73,7 @@ export class TabsAriaController< hostUpdated() { for (const [tab, panel] of this.#tabPanelMap) { - if (!panel.hasAttribute('aria-labelledby')) { - panel.setAttribute('aria-labelledby', tab.id); - } + panel.setAttribute('aria-labelledby', tab.id); tab.setAttribute('aria-controls', panel.id); } }