diff --git a/.changeset/context-with-root.md b/.changeset/context-with-root.md new file mode 100644 index 0000000000..3068fbe930 --- /dev/null +++ b/.changeset/context-with-root.md @@ -0,0 +1,5 @@ +--- +"@patternfly/pfe-core": minor +--- +**Context**: added `createContextWithRoot`. Use this when creating contexts that +are shared with child elements. 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 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/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index c43fafb383..da3ee3e641 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -142,7 +142,7 @@ export class RovingTabindexController< } RovingTabindexController.hosts.set(host, this); this.host.addController(this); - this.#init(); + this.updateItems(); } hostUpdated() { @@ -151,7 +151,7 @@ export class RovingTabindexController< if (oldContainer !== newContainer) { oldContainer?.removeEventListener('keydown', this.#onKeydown); RovingTabindexController.elements.delete(oldContainer!); - this.#init(); + this.updateItems(); } if (newContainer) { this.#initContainer(newContainer); @@ -167,12 +167,6 @@ export class RovingTabindexController< this.#gainedInitialFocus = false; } - #init() { - if (typeof this.#options?.getItems === 'function') { - this.updateItems(this.#options.getItems()); - } - } - #initContainer(container: Element) { RovingTabindexController.elements.set(container, this); this.#itemsContainer = container; @@ -267,16 +261,11 @@ export class RovingTabindexController< } } - /** @deprecated use setActiveItem */ - focusOnItem(item?: Item): void { - this.setActiveItem(item); - } - /** * Focuses next focusable item */ - updateItems(items?: Item[]) { - this.#items = items ?? this.#options.getItems?.() ?? []; + updateItems(items: Item[] = this.#options.getItems?.() ?? []) { + this.#items = items; const sequence = [...this.#items.slice(this.#itemIndex - 1), ...this.#items.slice(0, this.#itemIndex - 1)]; const first = sequence.find(item => this.#focusableItems.includes(item)); const [focusableItem] = this.#focusableItems; @@ -284,6 +273,11 @@ export class RovingTabindexController< this.setActiveItem(activeItem); } + /** @deprecated use setActiveItem */ + focusOnItem(item?: Item): void { + this.setActiveItem(item); + } + /** * from array of HTML items, and sets active items * @deprecated: use getItems and getItemContainer option functions 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..13c69ac72d --- /dev/null +++ b/core/pfe-core/controllers/tabs-aria-controller.ts @@ -0,0 +1,121 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; + +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; + /** 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: TabsAriaControllerOptions; + + #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: TabsAriaControllerOptions, + ) { + 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) { + 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/core/pfe-core/functions/context.ts b/core/pfe-core/functions/context.ts new file mode 100644 index 0000000000..ec97e4bcfb --- /dev/null +++ b/core/pfe-core/functions/context.ts @@ -0,0 +1,19 @@ +import { ContextRoot, createContext } from '@lit/context'; + +let root: ContextRoot; + +function makeContextRoot() { + root = new ContextRoot(); + root.attach(document.body); + return root; +} + +/** + * In order to prevent late-upgrading-context-consumers from 'missing' + * their rightful context providers, we must set up a `ContextRoot` on the body. + * Always use this function when creating contexts that are shared with child elements. + */ +export function createContextWithRoot(...args: Parameters) { + root ??= makeContextRoot(); + return createContext(...args); +} diff --git a/core/pfe-core/package.json b/core/pfe-core/package.json index baad24ade6..0a0f4b7480 100644 --- a/core/pfe-core/package.json +++ b/core/pfe-core/package.json @@ -37,6 +37,7 @@ "./decorators/observed.js": "./decorators/observed.js", "./decorators/time.js": "./decorators/time.js", "./decorators/trace.js": "./decorators/trace.js", + "./functions/context.js": "./functions/context.js", "./functions/containsDeep.js": "./functions/containsDeep.js", "./functions/debounce.js": "./functions/debounce.js", "./functions/random.js": "./functions/random.js", @@ -52,8 +53,9 @@ "test": "wtr --files './test/*.spec.ts' --config ../../web-test-runner.config.js" }, "dependencies": { - "@floating-ui/dom": "^1.5.4", - "lit": "^3.1.1" + "@floating-ui/dom": "^1.6.3", + "@lit/context": "^1.1.0", + "lit": "^3.1.2" }, "repository": { "type": "git", diff --git a/elements/package.json b/elements/package.json index f4c8eecf0f..142ec102be 100644 --- a/elements/package.json +++ b/elements/package.json @@ -141,9 +141,10 @@ "Wes Ruvalcaba" ], "dependencies": { + "@lit/context": "^1.1.0", "@patternfly/icons": "^1.0.2", "@patternfly/pfe-core": "^2.4.1", - "lit": "^3.1.1", + "lit": "^3.1.2", "tslib": "^2.6.2" } } 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/context.ts b/elements/pf-tabs/context.ts new file mode 100644 index 0000000000..c71e48d216 --- /dev/null +++ b/elements/pf-tabs/context.ts @@ -0,0 +1,22 @@ +import type { PfTab } from './pf-tab.js'; + +import { createContextWithRoot } from '@patternfly/pfe-core/functions/context.js'; + +export interface PfTabsContext { + activeTab: PfTab | undefined; + box: 'light' | 'dark' | null; + fill: boolean; + vertical: boolean; + manual: boolean; + borderBottom: 'true' | 'false'; +} + +export class TabExpandEvent extends Event { + constructor( + public tab: Tab, + ) { + super('expand', { bubbles: true, cancelable: true }); + } +} + +export const context = createContextWithRoot(Symbol('pf-tabs-context')); diff --git a/elements/pf-tabs/demo/active-tab-disabled.html b/elements/pf-tabs/demo/active-tab-disabled.html index b85c4e254d..c0017cb3a4 100644 --- a/elements/pf-tabs/demo/active-tab-disabled.html +++ b/elements/pf-tabs/demo/active-tab-disabled.html @@ -8,8 +8,6 @@ Database Disabled Disabled - Aria Disabled - Aria Disabled diff --git a/elements/pf-tabs/demo/active-tab-is-disabled.html b/elements/pf-tabs/demo/active-tab-is-disabled.html index 54261632f3..1017433161 100644 --- a/elements/pf-tabs/demo/active-tab-is-disabled.html +++ b/elements/pf-tabs/demo/active-tab-is-disabled.html @@ -8,8 +8,6 @@ Database Disabled Disabled - Aria Disabled - Aria Disabled diff --git a/elements/pf-tabs/demo/box.html b/elements/pf-tabs/demo/box.html index e047cd25bf..c6284fed35 100644 --- a/elements/pf-tabs/demo/box.html +++ b/elements/pf-tabs/demo/box.html @@ -8,8 +8,6 @@ Database Disabled Disabled - Aria Disabled - Aria Disabled
diff --git a/elements/pf-tabs/demo/dynamic-tabs.html b/elements/pf-tabs/demo/dynamic-tabs.html index 574938d489..ee0ac1762a 100644 --- a/elements/pf-tabs/demo/dynamic-tabs.html +++ b/elements/pf-tabs/demo/dynamic-tabs.html @@ -19,8 +19,6 @@ Database Disabled Disabled - Aria Disabled - Aria Disabled diff --git a/elements/pf-tabs/demo/inset.html b/elements/pf-tabs/demo/inset.html index 3afdb8daff..258aaa10ef 100644 --- a/elements/pf-tabs/demo/inset.html +++ b/elements/pf-tabs/demo/inset.html @@ -8,8 +8,6 @@ Database Disabled Disabled - Aria Disabled - Aria Disabled diff --git a/elements/pf-tabs/demo/manual-activation.html b/elements/pf-tabs/demo/manual-activation.html index 5632048c7c..6bfa97e626 100644 --- a/elements/pf-tabs/demo/manual-activation.html +++ b/elements/pf-tabs/demo/manual-activation.html @@ -1,36 +1,14 @@ -
-

Manual Activation

- - Users - Containers - Database - Disabled - Aria Disabled - Users - Containers - Database - Disabled - Aria Disabled - -
+ + Users + Containers + Database + Disabled + Users + Containers + Database + Disabled + - - diff --git a/elements/pf-tabs/demo/nested.html b/elements/pf-tabs/demo/nested.html index c0a111e1e2..a2a83c95e0 100644 --- a/elements/pf-tabs/demo/nested.html +++ b/elements/pf-tabs/demo/nested.html @@ -14,14 +14,10 @@ Database 2 Disabled 2 Disabled 2 - Aria Disabled 2 - Aria Disabled 2 Disabled Disabled - Aria Disabled - Aria Disabled