diff --git a/.changeset/a11y-controller-opts.md b/.changeset/a11y-controller-opts.md new file mode 100644 index 0000000000..e9c6f7534e --- /dev/null +++ b/.changeset/a11y-controller-opts.md @@ -0,0 +1,6 @@ +--- +"@patternfly/pfe-core": major +--- +`RovingTabindexController`, `ListboxController`: constructor options were changed + +TODO: elaborate, give before-and-after cases diff --git a/.changeset/a11y-snapshot-chai.md b/.changeset/a11y-snapshot-chai.md new file mode 100644 index 0000000000..1137fa3f94 --- /dev/null +++ b/.changeset/a11y-snapshot-chai.md @@ -0,0 +1,32 @@ +--- +"@patternfly/pfe-tools": minor +--- +`a11ySnapshot`: Added chai assertions for various accessibility-tree scenarios + +Examples: +```ts +describe('', function() { + beforeEach(() => fixture(html` + + header-1 + panel-1 + + `)) + describe('clicking the first heading', function() { + beforeEach(clickFirstHeading); + it('expands the first panel', async function() { + expect(await a11ySnapshot()) + .to.axContainName('panel-1'); + }); + it('focuses the first panel', async function() { + expect(await a11ySnapshot()) + .to.have.axTreeFocusOn(document.getElementById('header1')); + }); + it('shows the collapse all button', async function() { + expect(await a11ySnapshot()) + .to.axContainRole('button'); + }); + }) +}) + +``` diff --git a/core/pfe-core/controllers/activedescendant-controller.ts b/core/pfe-core/controllers/activedescendant-controller.ts new file mode 100644 index 0000000000..7c13448b43 --- /dev/null +++ b/core/pfe-core/controllers/activedescendant-controller.ts @@ -0,0 +1,265 @@ +import type { ReactiveControllerHost } from 'lit'; + +import { type ATFocusControllerOptions, ATFocusController } from './at-focus-controller.js'; + +import { isServer, nothing } from 'lit'; +import { getRandomId } from '../functions/random.js'; + +export interface ActivedescendantControllerOptions< + Item extends HTMLElement +> extends ATFocusControllerOptions { + /** + * Returns a reference to the element which acts as the assistive technology container for + * the items. In the case of a combobox, this is the input element. + */ + getActiveDescendantContainer(): HTMLElement | null; + /** + * Optional callback to control the assistive technology focus behavior of items. + * By default, ActivedescendantController will not do anything special to items when they receive + * assistive technology focus, and will only set the `activedescendant` property on the container. + * If you provide this callback, ActivedescendantController will call it on your item with the + * active state. You may use this to set active styles. + */ + setItemActive?(this: Item, active: boolean): void; + /** + * Optional callback to retrieve the value from an option element. + * By default, retrieves the `value` attribute, or the text content. + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLOptionElement + */ + getItemValue?(this: Item): string; +} + +/** + * Implements activedescendant pattern, as described in WAI-ARIA practices, + * [Managing Focus in Composites Using aria-activedescendant][ad] + * + * The steps for using the aria-activedescendant method of managing focus are as follows. + * + * - When the container element that has a role that supports aria-activedescendant is loaded + * or created, ensure that: + * - The container element is included in the tab sequence as described in + * Keyboard Navigation Between Components or is a focusable element of a composite + * that implements a roving tabindex. + * - It has aria-activedescendant="IDREF" where IDREF is the ID of the element within + * the container that should be identified as active when the widget receives focus. + * The referenced element needs to meet the DOM relationship requirements described below. + * - When the container element receives DOM focus, draw a visual focus indicator on the active + * element and ensure the active element is scrolled into view. + * - When the composite widget contains focus and the user presses a navigation key that moves + * focus within the widget, such as an arrow key: + * - Change the value of aria-activedescendant on the container to refer to the element + * that should be reported to assistive technologies as active. + * - Move the visual focus indicator and, if necessary, scrolled the active element into view. + * - If the design calls for a specific element to be focused the next time a user moves focus + * into the composite with Tab or Shift+Tab, check if aria-activedescendant is referring to + * that target element when the container loses focus. If it is not, set aria-activedescendant + * to refer to the target element. + * + * The specification for aria-activedescendant places important restrictions on the + * DOM relationship between the focused element that has the aria-activedescendant attribute + * and the element referenced as active by the value of the attribute. + * One of the following three conditions must be met. + * + * 1. The element referenced as active is a DOM descendant of the focused referencing element. + * 2. The focused referencing element has a value specified for the aria-owns property that + * includes the ID of the element referenced as active. + * 3. The focused referencing element has role of combobox, textbox, or searchbox + * and has aria-controls property referring to an element with a role that supports + * aria-activedescendant and either: + * 1. The element referenced as active is a descendant of the controlled element. + * 2. The controlled element has a value specified for the aria-owns property that includes + * the ID of the element referenced as active. + * + * [ad]: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_focus_activedescendant + */ +export class ActivedescendantController< + Item extends HTMLElement = HTMLElement +> extends ATFocusController { + /** + * When true, the browser supports cross-root ARIA such that the controller does not need + * to copy item nodes into the controlling nodes' root + */ + public static get canControlLightDom(): boolean { + return !isServer && 'ariaActiveDescendantElement' in HTMLElement.prototype; + } + + static of( + host: ReactiveControllerHost, + options: ActivedescendantControllerOptions, + ): ActivedescendantController { + return new ActivedescendantController(host, options); + } + + /** Maps from original element to shadow DOM clone */ + #lightToShadowMap = new WeakMap(); + + /** Maps from shadow DOM clone to original element */ + #shadowToLightMap = new WeakMap(); + + /** Set of item which should not be cloned */ + #noCloneSet = new WeakSet(); + + /** Element which controls the list i.e. combobox */ + #controlsElements: HTMLElement[] = []; + + #observing = false; + + #listMO = new MutationObserver(records => this.#onItemsDOMChange(records)); + + #attrMO = new MutationObserver(records => this.#onItemAttributeChange(records)); + + #syncAttr(attributeName: string, fromNode: Item) { + const toNode = this.#shadowToLightMap.get(fromNode as Item) + ?? this.#lightToShadowMap.get(fromNode as Item); + const newVal = fromNode.getAttribute(attributeName); + const oldVal = toNode?.getAttribute(attributeName); + if (!fromNode.hasAttribute(attributeName)) { + toNode?.removeAttribute(attributeName); + } else if (oldVal !== newVal) { + toNode?.setAttribute(attributeName, newVal!); + } + } + + get atFocusedItemIndex(): number { + return super.atFocusedItemIndex; + } + + /** + * Rather than setting DOM focus, applies the `aria-activedescendant` attribute, + * using AriaIDLAttributes for cross-root aria, if supported by the browser + * @param item item + */ + set atFocusedItemIndex(index: number) { + const { canControlLightDom } = ActivedescendantController; + super.atFocusedItemIndex = index; + const item = this._items.at(this.atFocusedItemIndex); + for (const _item of this.items) { + this.options.setItemActive?.call(_item, _item === item); + } + const container = this.options.getActiveDescendantContainer(); + if (!canControlLightDom) { + container?.setAttribute('aria-activedescendant', item?.id ?? ''); + } else if (container) { + container.ariaActiveDescendantElement = item ?? null; + } + this.host.requestUpdate(); + } + + get controlsElements(): HTMLElement[] { + return this.#controlsElements; + } + + set controlsElements(elements: HTMLElement[]) { + for (const old of this.#controlsElements) { + old?.removeEventListener('keydown', this.onKeydown); + } + this.#controlsElements = elements; + for (const element of this.#controlsElements) { + element.addEventListener('keydown', this.onKeydown); + } + } + + /** All items */ + get items() { + return this._items; + } + + /** + * Sets the list of items and activates the next activatable item after the current one + * @param items tabindex items + */ + override set items(items: Item[]) { + const container = this.options.getItemsContainer?.() ?? this.host; + if (!(container instanceof HTMLElement)) { + throw new Error('items container must be an HTMLElement'); + } + this.itemsContainerElement = container; + if (ActivedescendantController.canControlLightDom + || [container] // all nodes are in the same root + .concat(this.controlsElements) + .concat(items) + .every((node, _, a) => node.getRootNode() === a[0].getRootNode())) { + this._items = items; + } else { + this._items = items?.map((item: Item) => { + item.removeAttribute('tabindex'); + if (container.contains(item)) { + item.id ||= getRandomId(); + this.#noCloneSet.add(item); + this.#shadowToLightMap.set(item, item); + return item; + } else { + const clone = item.cloneNode(true) as Item; + clone.id = getRandomId(); + this.#lightToShadowMap.set(item, clone); + this.#shadowToLightMap.set(clone, item); + // Though efforts were taken to disconnect + // this observer, it may still be a memory leak + this.#attrMO.observe(clone, { attributes: true }); + this.#attrMO.observe(item, { attributes: true }); + return clone; + } + }); + } + } + + private constructor( + public host: ReactiveControllerHost, + protected options: ActivedescendantControllerOptions, + ) { + super(host, options); + this.options.getItemValue ??= function(this: Item) { + return (this as unknown as HTMLOptionElement).value; + }; + } + + #onItemsDOMChange(records: MutationRecord[]) { + for (const { removedNodes } of records) { + for (const removed of removedNodes as NodeListOf) { + this.#lightToShadowMap.get(removed)?.remove(); + this.#lightToShadowMap.delete(removed); + } + } + }; + + #onItemAttributeChange(records: MutationRecord[]) { + for (const { target, attributeName } of records) { + if (attributeName) { + this.#syncAttr(attributeName, target as Item); + } + } + }; + + protected override initItems(): void { + this.#attrMO.disconnect(); + super.initItems(); + this.controlsElements = this.options.getControlsElements?.() ?? []; + if (!this.#observing && this.itemsContainerElement && this.itemsContainerElement.isConnected) { + this.#listMO.observe(this.itemsContainerElement, { childList: true }); + this.#observing = true; + } + } + + hostDisconnected(): void { + this.controlsElements = []; + this.#observing = false; + this.#listMO.disconnect(); + this.#attrMO.disconnect(); + } + + protected override isRelevantKeyboardEvent(event: Event): event is KeyboardEvent { + return !(!(event instanceof KeyboardEvent) + || event.ctrlKey + || event.altKey + || event.metaKey + || !this.atFocusableItems.length); + } + + public renderItemsToShadowRoot(): typeof nothing | Node[] { + if (ActivedescendantController.canControlLightDom) { + return nothing; + } else { + return this.items?.filter(x => !this.#noCloneSet.has(x)); + } + } +} diff --git a/core/pfe-core/controllers/at-focus-controller.ts b/core/pfe-core/controllers/at-focus-controller.ts new file mode 100644 index 0000000000..fb58733120 --- /dev/null +++ b/core/pfe-core/controllers/at-focus-controller.ts @@ -0,0 +1,216 @@ +import { isServer, type ReactiveControllerHost } from 'lit'; + +function isATFocusableItem(el: Element): el is HTMLElement { + return !!el + && el.ariaHidden !== 'true' + && !el.hasAttribute('inert') + && !el.hasAttribute('hidden'); +} + +export interface ATFocusControllerOptions { + /** + * Callback to return the list of items + */ + getItems(): Item[]; + /** + * Callback to return the listbox container element + */ + getItemsContainer?(): HTMLElement | null; + /** + * Callback to return the direction of navigation in the list box. + */ + getOrientation?(): 'horizontal' | 'vertical' | 'both' | 'undefined'; + /** + * Function returning the DOM nodes which are accessibility controllers of item container + * e.g. the button toggle and combobox input which control a listbox. + */ + getControlsElements?(): HTMLElement[]; +} + +export abstract class ATFocusController { + #itemsContainerElement: HTMLElement | null = null; + + #atFocusedItemIndex = -1; + + protected _items: Item[] = []; + + /** All items */ + abstract items: Item[]; + + /** + * Index of the Item which currently has assistive technology focus + * Set this to change focus. Setting to an out-of-bounds value will + * wrap around to the other side of the list. + */ + get atFocusedItemIndex() { + return this.#atFocusedItemIndex; + } + + set atFocusedItemIndex(index: number) { + const previousIndex = this.#atFocusedItemIndex; + const direction = index > previousIndex ? 1 : -1; + const { items, atFocusableItems } = this; + const itemsIndexOfLastATFocusableItem = items.indexOf(this.atFocusableItems.at(-1)!); + let itemToGainFocus = items.at(index); + let itemToGainFocusIsFocusable = atFocusableItems.includes(itemToGainFocus!); + if (atFocusableItems.length) { + let count = 0; + while (!itemToGainFocus || !itemToGainFocusIsFocusable && count++ <= 1000) { + if (index < 0) { + index = itemsIndexOfLastATFocusableItem; + } else if (index >= itemsIndexOfLastATFocusableItem) { + index = 0; + } else { + index = index + direction; + } + itemToGainFocus = items.at(index); + itemToGainFocusIsFocusable = atFocusableItems.includes(itemToGainFocus!); + } + if (count >= 1000) { + throw new Error('Could not atFocusedItemIndex'); + } + } + this.#atFocusedItemIndex = index; + } + + /** Elements which control the items container e.g. a combobox input */ + get controlsElements(): HTMLElement[] { + return this.options.getControlsElements?.() ?? []; + } + + /** All items which are able to receive assistive technology focus */ + get atFocusableItems(): Item[] { + return this._items.filter(isATFocusableItem); + } + + /** The element containing focusable items, e.g. a listbox */ + get itemsContainerElement() { + return this.#itemsContainerElement ?? null; + } + + set itemsContainerElement(container: HTMLElement | null) { + if (container !== this.#itemsContainerElement) { + this.#itemsContainerElement?.removeEventListener('keydown', this.onKeydown); + this.#itemsContainerElement = container; + this.#itemsContainerElement?.addEventListener('keydown', this.onKeydown); + this.host.requestUpdate(); + } + } + + constructor( + public host: ReactiveControllerHost, + protected options: ATFocusControllerOptions, + ) { + this.host.updateComplete.then(() => this.initItems()); + } + + /** + * Initialize the items and itemsContainerElement fields + */ + protected initItems(): void { + this.items = this.options.getItems(); + this.itemsContainerElement ??= this.#initContainer(); + } + + hostConnected(): void { + this.hostUpdate(); + } + + hostDisconnected(): void { + this.#itemsContainerElement?.removeEventListener('keydown', this.onKeydown); + } + + hostUpdate(): void { + this.itemsContainerElement ??= this.#initContainer(); + } + + #initContainer() { + return this.options.getItemsContainer?.() + ?? (!isServer && this.host instanceof HTMLElement ? this.host : null); + } + + /** + * Implement this predicate to filter out keyboard events + * which should not result in a focus change. If this predicate returns true, then + * a focus change should occur. + */ + protected abstract isRelevantKeyboardEvent(event: Event): event is KeyboardEvent; + + /** + * DO NOT OVERRIDE + * @param event keyboard event + */ + protected onKeydown = (event: Event): void => { + if (this.isRelevantKeyboardEvent(event)) { + const orientation = this.options.getOrientation?.() ?? this + .#itemsContainerElement + ?.getAttribute('aria-orientation') as + 'horizontal' | 'vertical' | 'grid' | 'undefined'; + + const item = this._items.at(this.atFocusedItemIndex); + + const horizontalOnly = + orientation === 'horizontal' + || item?.tagName === 'SELECT' + || item?.getAttribute('role') === 'spinbutton'; + + const verticalOnly = orientation === 'vertical'; + + switch (event.key) { + case 'ArrowLeft': + if (verticalOnly) { + return; + } + this.atFocusedItemIndex--; + event.stopPropagation(); + event.preventDefault(); + break; + case 'ArrowRight': + if (verticalOnly) { + return; + } + this.atFocusedItemIndex++; + event.stopPropagation(); + event.preventDefault(); + break; + case 'ArrowUp': + if (horizontalOnly) { + return; + } + this.atFocusedItemIndex--; + event.stopPropagation(); + event.preventDefault(); + break; + case 'ArrowDown': + if (horizontalOnly) { + return; + } + this.atFocusedItemIndex++; + event.stopPropagation(); + event.preventDefault(); + break; + case 'Home': + if (!(event.target instanceof HTMLElement + && (event.target.hasAttribute('aria-activedescendant') + || event.target.ariaActiveDescendantElement))) { + this.atFocusedItemIndex = 0; + event.stopPropagation(); + event.preventDefault(); + } + break; + case 'End': + if (!(event.target instanceof HTMLElement + && (event.target.hasAttribute('aria-activedescendant') + || event.target.ariaActiveDescendantElement))) { + this.atFocusedItemIndex = this.items.length - 1; + event.stopPropagation(); + event.preventDefault(); + } + break; + default: + break; + } + this.host.requestUpdate(); + } + }; +} diff --git a/core/pfe-core/controllers/combobox-controller.ts b/core/pfe-core/controllers/combobox-controller.ts new file mode 100644 index 0000000000..86d635d623 --- /dev/null +++ b/core/pfe-core/controllers/combobox-controller.ts @@ -0,0 +1,747 @@ +import { nothing, type ReactiveController, type ReactiveControllerHost } from 'lit'; +import type { ActivedescendantControllerOptions } from './activedescendant-controller.js'; +import type { RovingTabindexControllerOptions } from './roving-tabindex-controller.js'; +import type { ATFocusController } from './at-focus-controller'; +import type { ListboxControllerOptions } from './listbox-controller.js'; + +import { ListboxController, isItem, isItemDisabled } from './listbox-controller.js'; +import { RovingTabindexController } from './roving-tabindex-controller.js'; +import { ActivedescendantController } from './activedescendant-controller.js'; +import { InternalsController } from './internals-controller.js'; +import { getRandomId } from '../functions/random.js'; +import type { RequireProps } from '../core.js'; + +type AllOptions = + ActivedescendantControllerOptions + & ListboxControllerOptions + & RovingTabindexControllerOptions; + +type Lang = typeof ComboboxController['langs'][number]; + +function getItemValue(this: Item): string { + if ('value' in this && typeof this.value === 'string') { + return this.value; + } else { + return ''; + } +} + +function deepClosest(element: Element | null, selector: string) { + let closest = element?.closest(selector); + let root = element?.getRootNode(); + let count = 0; + while (count < 500 && !closest && element) { + count++; + root = element.getRootNode(); + if (root instanceof ShadowRoot) { + element = root.host; + } else if (root instanceof Document) { + element = document.documentElement; + } else { + return null; + } + closest = element.closest(selector); + } + return closest; +} + +function isItemFiltered(this: Item, value: string): boolean { + return !getItemValue.call(this) + .toLowerCase() + .startsWith(value.toLowerCase()); +} + +function setItemHidden(this: HTMLElement, hidden: boolean) { + this.hidden = hidden; +} + +function setComboboxValue(this: HTMLElement, value: string): void { + if (!('value' in this)) { + // eslint-disable-next-line no-console + return console.warn(`Cannot set value on combobox element ${this.localName}`); + } else { + this.value = value; + } +} + +function getComboboxValue(this: HTMLElement): string { + if ('value' in this && typeof this.value === 'string') { + return this.value; + } else { + // eslint-disable-next-line no-console + return console.warn(`Cannot get value from combobox element ${this.localName}`), ''; + } +} + +export interface ComboboxControllerOptions extends + Omit, + | 'getATFocusedItem' + | 'getControlsElements' + | 'getActiveDescendantContainer' + | 'getItemsContainer'> { + /** + * Predicate which establishes whether the listbox is expanded + * e.g. `isExpanded: () => this.expanded`, if the host's `expanded` property + * should correspond to the listbox expanded state. + */ + isExpanded(): boolean; + /** + * Callback which the host must implement to change the expanded state to true. + * Return or resolve false to prevent the change. + */ + requestShowListbox(): boolean | Promise; + /** + * Callback which the host must implement to change the expanded to false. + * Return or resolve false to prevent the default. + */ + requestHideListbox(): boolean | Promise; + /** + * Returns the listbox container element + */ + getListboxElement(): HTMLElement | null; + /** + * Returns the toggle button, if it exists + */ + getToggleButton(): HTMLElement | null; + /** + * Returns the combobox input, if it exists + */ + getComboboxInput(): HTMLElement | null; + /** + * Returns the label for the toggle button, combobox input, and listbox. + * when `ariaLabelledByElements` is supported, the label elements associated with + * the host element are used instead, and this value is ignored. + */ + getFallbackLabel(): string; + /** + * Called on an item to retrieve it's value string. By default, returns the `value` property + * of the item, as if it implemented the `