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`
+
+
+ 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 `` element's interface.
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLOptionElement
+ */
+ getItemValue?(this: Item): string;
+ /**
+ * Optional callback, called on the combobox input element to set its value.
+ * by default, returns the element's `value` DOM property.
+ */
+ getComboboxValue?(this: HTMLElement): string;
+ /**
+ * Optional callback, called on the combobox input element to set its value.
+ * by default, sets the element's `value` DOM property.
+ */
+ setComboboxValue?(this: HTMLElement, value: string): void;
+ /**
+ * Called on each item, with the combobox input, to determine if the item should be shown in the
+ * listbox or filtered out. Return false to hide the item. By default, checks whether the item's
+ * value starts with the input value (when both are lowercased).
+ */
+ isItemFiltered?(this: Item, value: string): boolean;
+ /**
+ * Called on each item when the filter changes.
+ * By default, toggles the `hidden` attribute on the item
+ */
+ setItemHidden?(this: Item, hidden: boolean): void;
+}
+
+/**
+ * @summary Implements the WAI-ARIA pattern [Editable Combobox with Both List and Inline Autocomplete].
+ *
+ * Combobox with keyboard and pointer navigation, using the aria-activedescendant pattern.
+ *
+ * WARNING: Safari VoiceOver does not support aria-activedescendant, so Safari users
+ * rely on the combobox input value being announced when navigating the listbox with the keyboard.
+ * We have erred on the side that it may be less-broken to avoid announcing disabled items in that
+ * case, rather than announcing the disabled items value without indicating that it is disabled.
+ * @see (https://bugs.webkit.org/show_bug.cgi?id=269026)
+ *
+ * [pattern]: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-both/
+ */
+export class ComboboxController<
+ Item extends HTMLElement
+> implements ReactiveController {
+ public static of(
+ host: ReactiveControllerHost,
+ options: ComboboxControllerOptions,
+ ): ComboboxController {
+ return new ComboboxController(host, options);
+ }
+
+ /**
+ * Whether the `ariaActiveDescendantElement` IDL attribute is supported for cross-root ARIA.
+ */
+ public static get canControlLightDom(): boolean {
+ return ActivedescendantController.canControlLightDom;
+ }
+
+ static #alert?: HTMLElement;
+
+ static #alertTemplate = document.createElement('template');
+
+ private static langs = [
+ 'en',
+ 'es',
+ 'de',
+ 'fr',
+ 'it',
+ 'ja',
+ 'zh',
+ ] as const;
+
+ private static langsRE = new RegExp(ComboboxController.langs.join('|'));
+
+ static {
+ // apply visually-hidden styles
+ this.#alertTemplate.innerHTML = `
+
+ `;
+ }
+
+ private options: RequireProps,
+ | 'isItemDisabled'
+ | 'isItem'
+ | 'isItemFiltered'
+ | 'getItemValue'
+ | 'getOrientation'
+ | 'getComboboxValue'
+ | 'setComboboxValue'
+ | 'setItemHidden'
+ >;
+
+ #lb: ListboxController- ;
+ #fc?: ATFocusController
- ;
+ #preventListboxGainingFocus = false;
+ #input: HTMLElement | null = null;
+ #button: HTMLElement | null = null;
+ #listbox: HTMLElement | null = null;
+ #buttonInitialRole: string | null = null;
+ #mo = new MutationObserver(() => this.#initItems());
+ #microcopy = new Map
>(Object.entries({
+ dimmed: {
+ en: 'dimmed',
+ es: 'atenuada',
+ de: 'gedimmt',
+ it: 'oscurato',
+ fr: 'atténué',
+ ja: '暗くなった',
+ zh: '变暗',
+ },
+ selected: {
+ en: 'selected',
+ es: 'seleccionado',
+ de: 'ausgewählt',
+ fr: 'choisie',
+ it: 'selezionato',
+ ja: '選ばれた',
+ zh: '选',
+ },
+ of: {
+ en: 'of',
+ es: 'de',
+ de: 'von',
+ fr: 'sur',
+ it: 'di',
+ ja: '件目',
+ zh: '的',
+ },
+ }));
+
+ private constructor(
+ public host: ReactiveControllerHost,
+ options: ComboboxControllerOptions- ,
+ ) {
+ host.addController(this);
+ this.options = {
+ isItem,
+ getItemValue,
+ isItemFiltered,
+ isItemDisabled,
+ getComboboxValue,
+ setComboboxValue,
+ setItemHidden,
+ getOrientation: () => 'vertical',
+ ...options,
+ };
+ this.#lb = ListboxController.of(host, {
+ isItem: this.options.isItem,
+ getItemsContainer: this.options.getListboxElement,
+ getControlsElements: () => [this.#button, this.#input].filter(x => !!x),
+ getATFocusedItem: () => this.items[this.#fc?.atFocusedItemIndex ?? -1] ?? null,
+ isItemDisabled: this.options.isItemDisabled,
+ setItemSelected: this.options.setItemSelected,
+ });
+ }
+
+ /** All items */
+ get items(): Item[] {
+ return this.#lb.items;
+ }
+
+ set items(value: Item[]) {
+ this.#lb.items = value;
+ }
+
+ /** Whether the combobox is disabled */
+ get disabled() {
+ return this.#lb.disabled;
+ }
+
+ set disabled(value: boolean) {
+ this.#lb.disabled = value;
+ }
+
+ /** Whether multiselect is enabled */
+ get multi() {
+ return this.#lb.multi;
+ }
+
+ set multi(value: boolean) {
+ this.#lb.multi = value;
+ }
+
+ /** The current selection: a list of items */
+ get selected() {
+ return this.#lb.selected;
+ }
+
+ set selected(value: Item[]) {
+ this.#lb.selected = value;
+ }
+
+ get #hasTextInput() {
+ return this.options.getComboboxInput();
+ }
+
+ get #focusedItem() {
+ return this.#fc?.items.at(Math.max(this.#fc?.atFocusedItemIndex ?? -1, 0)) ?? null;
+ }
+
+ get #element() {
+ if (this.host instanceof HTMLElement) {
+ return this.host;
+ } else if (this.options.getListboxElement() instanceof HTMLElement) {
+ return this.options.getListboxElement();
+ }
+ }
+
+ async hostConnected(): Promise
{
+ await this.host.updateComplete;
+ this.hostUpdated();
+ }
+
+ hostUpdated(): void {
+ if (!this.#fc) {
+ this.#init();
+ }
+ const expanded = this.options.isExpanded();
+ this.#button?.setAttribute('aria-expanded', String(expanded));
+ this.#input?.setAttribute('aria-expanded', String(expanded));
+ if (this.#hasTextInput) {
+ this.#button?.setAttribute('tabindex', '-1');
+ } else {
+ this.#button?.removeAttribute('tabindex');
+ }
+ this.#initLabels();
+ }
+
+ hostDisconnected(): void {
+ this.#fc?.hostDisconnected();
+ }
+
+ /**
+ * Order of operations is important
+ */
+ #init() {
+ this.#initListbox();
+ this.#initItems();
+ this.#initButton();
+ this.#initInput();
+ this.#initLabels();
+ this.#initController();
+ }
+
+ #initListbox() {
+ this.#mo.disconnect();
+ this.#listbox?.removeEventListener('focusout', this.#onFocusoutListbox);
+ this.#listbox?.removeEventListener('keydown', this.#onKeydownListbox);
+ this.#listbox?.removeEventListener('click', this.#onClickListbox);
+ this.#listbox = this.options.getListboxElement();
+ if (!this.#listbox) {
+ throw new Error('ComboboxController getListboxElement() option must return an element');
+ }
+ this.#listbox.addEventListener('focusout', this.#onFocusoutListbox);
+ this.#listbox.addEventListener('keydown', this.#onKeydownListbox);
+ this.#listbox.addEventListener('click', this.#onClickListbox);
+ this.#listbox.id ??= getRandomId();
+ this.#mo.observe(this.#listbox, { childList: true });
+ }
+
+ #initButton() {
+ this.#button?.removeEventListener('click', this.#onClickButton);
+ this.#button?.removeEventListener('keydown', this.#onKeydownButton);
+ this.#button = this.options.getToggleButton();
+ if (!this.#button) {
+ throw new Error('ComboboxController getToggleButton() option must return an element');
+ }
+ this.#buttonInitialRole = this.#button.role;
+ this.#button.role = 'combobox';
+ this.#button.setAttribute('aria-controls', this.#listbox?.id ?? '');
+ this.#button.addEventListener('click', this.#onClickButton);
+ this.#button.addEventListener('keydown', this.#onKeydownButton);
+ }
+
+ #initInput() {
+ this.#input?.removeEventListener('click', this.#onClickButton);
+ this.#input?.removeEventListener('keyup', this.#onKeyupInput);
+ this.#input?.removeEventListener('keydown', this.#onKeydownInput);
+
+ this.#input = this.options.getComboboxInput();
+ if (this.#input && !('value' in this.#input)) {
+ throw new Error(`ComboboxController getToggleInput() option must return an element with a value property`);
+ } else if (this.#input) {
+ this.#input.role = 'combobox';
+ this.#button!.role = this.#buttonInitialRole;
+ this.#input.setAttribute('aria-autocomplete', 'both');
+ this.#input.setAttribute('aria-controls', this.#listbox?.id ?? '');
+ this.#input.addEventListener('click', this.#onClickButton);
+ this.#input.addEventListener('keyup', this.#onKeyupInput);
+ this.#input.addEventListener('keydown', this.#onKeydownInput);
+ }
+ }
+
+ #initLabels() {
+ const labels = InternalsController.getLabels(this.host)
+ ?? this.#element?.ariaLabelledByElements
+ ?? [];
+ const label = this.options.getFallbackLabel()
+ || this.#element?.ariaLabelledByElements?.map(x => x.textContent).join('')
+ || null;
+
+ for (const element of [this.#button, this.#listbox, this.#input].filter(x => !!x)) {
+ if ('ariaLabelledByElements' in HTMLElement.prototype && labels.filter(x => !!x).length) {
+ element.ariaLabelledByElements = [...labels ?? []];
+ } else {
+ element.ariaLabel = label;
+ }
+ }
+ }
+
+ #initController() {
+ this.#fc?.hostDisconnected();
+ const { getOrientation } = this.options;
+ const getItems = () => this.items;
+ const getItemsContainer = () => this.#listbox;
+ if (this.#hasTextInput) {
+ this.#fc = ActivedescendantController.of(this.host, {
+ getItems, getItemsContainer, getOrientation,
+ getActiveDescendantContainer: () => this.#input,
+ getControlsElements: () => [this.#button, this.#input].filter(x => !!x),
+ setItemActive: this.options.setItemActive,
+ });
+ } else {
+ this.#fc = RovingTabindexController.of(this.host, {
+ getItems, getItemsContainer, getOrientation,
+ getControlsElements: () => [this.#button].filter(x => !!x),
+ });
+ }
+ }
+
+ #initItems() {
+ if (this.#listbox) {
+ this.items = this.options.getItems();
+ }
+ }
+
+ async #show(): Promise {
+ const success = await this.options.requestShowListbox();
+ if (success !== false && !this.#hasTextInput) {
+ if (!this.#preventListboxGainingFocus) {
+ (this.#focusedItem ?? this.#fc?.items.at(0))?.focus();
+ this.#preventListboxGainingFocus = false;
+ }
+ }
+ }
+
+ async #hide(): Promise {
+ await this.options.requestHideListbox();
+ }
+
+ async #toggle() {
+ if (this.options.isExpanded()) {
+ return this.#hide();
+ } else {
+ return this.#show();
+ }
+ }
+
+ #translate(key: string, lang: Lang) {
+ const strings = this.#microcopy.get(key);
+ return strings?.[lang] ?? key;
+ }
+
+ // TODO(bennypowers): perhaps move this to ActivedescendantController
+ #announce(item: Item) {
+ const value = this.options.getItemValue.call(item);
+ ComboboxController.#alert?.remove();
+ const fragment = ComboboxController.#alertTemplate.content.cloneNode(true) as DocumentFragment;
+ ComboboxController.#alert = fragment.firstElementChild as HTMLElement;
+ let text = value;
+ const lang = deepClosest(this.#listbox, '[lang]')?.getAttribute('lang') ?? 'en';
+ const langKey = lang?.match(ComboboxController.langsRE)?.at(0) as Lang ?? 'en';
+ if (this.options.isItemDisabled.call(item)) {
+ text += ` (${this.#translate('dimmed', langKey)})`;
+ }
+ if (this.#lb.isSelected(item)) {
+ text += `, (${this.#translate('selected', langKey)})`;
+ }
+ if (item.hasAttribute('aria-setsize') && item.hasAttribute('aria-posinset')) {
+ if (langKey === 'ja') {
+ text += `, (${item.getAttribute('aria-setsize')} 件中 ${item.getAttribute('aria-posinset')} 件目)`;
+ } else {
+ text += `, (${item.getAttribute('aria-posinset')} ${this.#translate('of', langKey)} ${item.getAttribute('aria-setsize')})`;
+ }
+ }
+ ComboboxController.#alert.lang = lang;
+ ComboboxController.#alert.innerText = text;
+ document.body.append(ComboboxController.#alert);
+ }
+
+ #onClickButton = (event: Event) => {
+ if (!this.options.isExpanded()) {
+ this.#show();
+ } else {
+ this.#hide();
+ }
+ };
+
+ #onClickListbox = (event: MouseEvent) => {
+ if (!this.multi && event.composedPath().some(this.options.isItem)) {
+ this.#hide();
+ }
+ };
+
+ /**
+ * Handle keypresses on the input
+ * ## `Down Arrow`
+ * - If the textbox is not empty and the listbox is displayed,
+ * moves visual focus to the first suggested value.
+ * - If the textbox is empty and the listbox is not displayed,
+ * opens the listbox and moves visual focus to the first option.
+ * - In both cases DOM focus remains on the textbox.
+ *
+ * ## `Alt + Down Arrow`
+ * Opens the listbox without moving focus or changing selection.
+ *
+ * ## `Up Arrow`
+ * - If the textbox is not empty and the listbox is displayed,
+ * moves visual focus to the last suggested value.
+ * - If the textbox is empty, first opens the listbox if it is not already displayed
+ * and then moves visual focus to the last option.
+ * - In both cases DOM focus remains on the textbox.
+ *
+ * ## `Enter`
+ * Closes the listbox if it is displayed.
+ *
+ * ## `Escape`
+ * - If the listbox is displayed, closes it.
+ * - If the listbox is not displayed, clears the textbox.
+ *
+ * ## Standard single line text editing keys
+ * - Keys used for cursor movement and text manipulation,
+ * such as `Delete` and `Shift + Right Arrow`.
+ * - An HTML `input` with `type="text"` is used for the textbox so the browser will provide
+ * platform-specific editing keys.
+ *
+ * @see https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list
+ * @param event keydown event
+ */
+ #onKeydownInput = (event: KeyboardEvent) => {
+ if (event.ctrlKey || event.shiftKey || !this.#input) {
+ return;
+ }
+ switch (event.key) {
+ case 'ArrowDown':
+ case 'ArrowUp':
+ if (!this.options.isExpanded()) {
+ this.#preventListboxGainingFocus = event.altKey;
+ this.#show();
+ }
+ break;
+ case 'Enter':
+ if (!this.multi) {
+ this.#hide();
+ }
+ break;
+ case 'Escape':
+ if (!this.options.isExpanded()) {
+ this.options.setComboboxValue.call(this.#input, '');
+ this.host.requestUpdate();
+ }
+ this.#hide();
+ break;
+ case 'Alt':
+ case 'AltGraph':
+ case 'Shift':
+ case 'Control':
+ case 'Fn':
+ case 'Symbol':
+ case 'Hyper':
+ case 'Super':
+ case 'Meta':
+ case 'CapsLock':
+ case 'FnLock':
+ case 'NumLock':
+ case 'Tab':
+ case 'ScrollLock':
+ case 'SymbolLock':
+ case ' ':
+ break;
+ default:
+ if (!this.options.isExpanded()) {
+ this.#show();
+ }
+ }
+ };
+
+ /**
+ * Populates the combobox input with the focused value when navigating the listbox,
+ * and filters the items when typing.
+ * @param event keyup event
+ */
+ #onKeyupInput = (event: KeyboardEvent) => {
+ if (!this.#input) {
+ return;
+ }
+ switch (event.key) {
+ case 'ArrowUp':
+ case 'ArrowDown':
+ /**
+ * Safari VoiceOver does not support aria-activedescendant, so we must.
+ * approximate the correct behaviour by constructing a visually-hidden alert role
+ * @see (https://bugs.webkit.org/show_bug.cgi?id=269026)
+ */
+ if (this.#focusedItem
+ && this.options.getComboboxInput()
+ && navigator.userAgent.includes('AppleWebKit')) {
+ this.#announce(this.#focusedItem);
+ }
+ break;
+ default: {
+ let value: string;
+ for (const item of this.items) {
+ const hidden =
+ !!this.options.isExpanded()
+ && !!(value = this.options.getComboboxValue.call(this.#input))
+ && this.options.isItemFiltered?.call(item, value)
+ || false;
+ this.options.setItemHidden.call(item, hidden);
+ }
+ }
+ }
+ };
+
+ #onKeydownButton = (event: KeyboardEvent) => {
+ if (this.#hasTextInput) {
+ return this.#onKeydownInput(event);
+ } else {
+ return this.#onKeydownToggleButton(event);
+ }
+ };
+
+ #onKeydownListbox = (event: KeyboardEvent) => {
+ if (!this.#hasTextInput) {
+ switch (event.key) {
+ case 'Home':
+ case 'End':
+ this.#onKeydownToggleButton(event);
+ break;
+ case 'Escape':
+ this.#hide();
+ this.#button?.focus();
+ break;
+ case 'Enter':
+ case ' ': {
+ const eventItem = event.composedPath().find(this.options.isItem);
+ if (eventItem
+ && !this.multi
+ && this.options.isExpanded()
+ && !this.options.isItemDisabled.call(eventItem)
+ ) {
+ this.#hide();
+ this.#button?.focus();
+ }
+ }
+ }
+ }
+ };
+
+ #onFocusoutListbox = (event: FocusEvent) => {
+ if (!this.#hasTextInput && this.options.isExpanded()) {
+ const root = this.#element?.getRootNode();
+ if ((root instanceof ShadowRoot || root instanceof Document)
+ && !this.items.includes(event.relatedTarget as Item)
+ ) {
+ this.#hide();
+ }
+ }
+ };
+
+ #onKeydownToggleButton = async (event: KeyboardEvent) => {
+ switch (event.key) {
+ case 'ArrowDown':
+ case 'ArrowUp':
+ if (!this.options.isExpanded()) {
+ this.#show();
+ }
+ break;
+ case 'Home':
+ if (!this.options.isExpanded()) {
+ await this.#show();
+ }
+ if (this.#fc) {
+ this.#fc.atFocusedItemIndex = 0;
+ }
+ break;
+ case 'End':
+ if (!this.options.isExpanded()) {
+ await this.#show();
+ }
+ if (this.#fc) {
+ this.#fc.atFocusedItemIndex = this.items.length - 1;
+ }
+ break;
+ case ' ':
+ case 'Enter':
+ // prevent scroll
+ event.preventDefault();
+ await this.#toggle();
+ await this.host.updateComplete;
+ if (!this.options.isExpanded()) {
+ this.#button?.focus();
+ }
+ break;
+ }
+ };
+
+ /**
+ * For Browsers which do not support `ariaActiveDescendantElement`, we must clone
+ * the listbox items into the same root as the combobox input
+ * Call this method to return either an array of (cloned) list box items, to be placed in your
+ * shadow template, or nothing in the case the browser supports cross-root aria.
+ */
+ public renderItemsToShadowRoot(): Node[] | typeof nothing {
+ if (this.#fc instanceof ActivedescendantController) {
+ return this.#fc.renderItemsToShadowRoot();
+ } else {
+ return nothing;
+ }
+ }
+}
diff --git a/core/pfe-core/controllers/internals-controller.ts b/core/pfe-core/controllers/internals-controller.ts
index 1636f91320..2c8ecfc709 100644
--- a/core/pfe-core/controllers/internals-controller.ts
+++ b/core/pfe-core/controllers/internals-controller.ts
@@ -49,8 +49,9 @@ function aria(
return internals[key];
},
set(this: InternalsController, value: string | null) {
- // @ts-expect-error: shamone!
+ // @ts-expect-error: ya know it!
const internals = this.attachOrRetrieveInternals();
+ // @ts-expect-error: shamone!
internals[key] = value;
this.host.requestUpdate();
},
@@ -78,6 +79,10 @@ export class InternalsController implements ReactiveController, ARIAMixin {
declare readonly willValidate: ElementInternals['willValidate'];
declare readonly validationMessage: ElementInternals['validationMessage'];
+ public static getLabels(host: ReactiveControllerHost): Element[] {
+ return Array.from(this.instances.get(host)?.internals.labels ?? []) as Element[];
+ }
+
public static of(
host: ReactiveControllerHost,
options?: InternalsControllerOptions,
@@ -250,7 +255,7 @@ export class InternalsController implements ReactiveController, ARIAMixin {
this.options.getHTMLElement ??= getHTMLElement;
for (const [key, val] of Object.entries(aria)) {
if (isARIAMixinProp(key)) {
- this[key] = val;
+ this[key as keyof this] = val as this[keyof this];
}
}
}
@@ -281,3 +286,17 @@ export class InternalsController implements ReactiveController, ARIAMixin {
this.internals.form?.reset();
}
}
+
+/** @see https://w3c.github.io/aria/#ref-for-dom-ariamixin-ariaactivedescendantelement-1 */
+declare global {
+ interface ARIAMixin {
+ ariaActiveDescendantElement: Element | null;
+ ariaControlsElements: readonly Element[] | null;
+ ariaDescribedByElements: readonly Element[] | null;
+ ariaDetailsElements: readonly Element[] | null;
+ ariaErrorMessageElements: readonly Element[] | null;
+ ariaFlowToElements: readonly Element[] | null;
+ ariaLabelledByElements: readonly Element[] | null;
+ ariaOwnsElements: readonly Element[] | null;
+ }
+}
diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts
index b89efe79d9..e0d4a7aebb 100644
--- a/core/pfe-core/controllers/listbox-controller.ts
+++ b/core/pfe-core/controllers/listbox-controller.ts
@@ -1,27 +1,105 @@
-import { isServer, type ReactiveController, type ReactiveControllerHost } from 'lit';
-
-export interface ListboxAccessibilityController<
- Item extends HTMLElement
-> extends ReactiveController {
- items: Item[];
- activeItem?: Item;
- nextItem?: Item;
- prevItem?: Item;
- firstItem?: Item;
- lastItem?: Item;
- updateItems(items: Item[]): void;
- setActiveItem(item: Item): void;
-}
+import type { ReactiveController, ReactiveControllerHost } from 'lit';
+import type { RequireProps } from '../core.ts';
+
+import { isServer } from 'lit';
+import { arraysAreEquivalent } from '../functions/arraysAreEquivalent.js';
/**
* Filtering, multiselect, and orientation options for listbox
*/
-export interface ListboxConfigOptions {
+export interface ListboxControllerOptions- {
+ /**
+ * Whether the listbox supports multiple selections.
+ */
multi?: boolean;
- a11yController: ListboxAccessibilityController
;
- getHTMLElement(): HTMLElement | null;
- requestSelect(option: T, force?: boolean): boolean;
- isSelected(option: T): boolean;
+ /**
+ * Optional callback to control the selection behavior of items. By default, ListboxController
+ * will set the `aria-selected` attribute. When overriding this option, it will call it on your
+ * element with the selected state.
+ * Callers **must** ensure that the correct ARIA state is set.
+ */
+ setItemSelected?(this: Item, selected: boolean): void;
+ /**
+ * Optional predicate to assertain whether a custom element item is disabled or not
+ * By default, if the item matches any of these conditions, it is considered disabled:
+ * 1. it's `disabled` DOM property is `true`
+ * 1. it has the `aria-disabled="true"` attribute
+ * 2. it has the `disabled` attribute present
+ * 3. it matches the `:disabled` pseudo selector
+ */
+ isItemDisabled?(this: Item): boolean;
+ /**
+ * Predicate which determines if a given element is in fact an item
+ * instead of e.g a presentational divider. By default, elements must meet the following criteria
+ * 1. element a child of a listbox role,
+ * 2. element does not have role="presentation"
+ * 2. element is not an ` `
+ * **NB**: When overriding, you must avoid outside references. This predicate must
+ * only consider the element itself, without reference to the host element's items array.
+ * @example ```js
+ * isItem: (item) => item instanceof MyCustomItem
+ * ```
+ */
+ isItem?(item: EventTarget | null): item is Item;
+ /**
+ * Function returning the item which currently has assistive technology focus.
+ * In most cases, this should be the `atFocusedItem` of an ATFocusController
+ * i.e. RovingTabindexController or ActivedescendantController.
+ *
+ */
+ getATFocusedItem(): Item | null;
+ /**
+ * Function returning the DOM node which is the direct parent of the item elements
+ * Defaults to the controller host.
+ * If the controller host is not an HTMLElement, this *must* be set
+ */
+ getItemsContainer?(): HTMLElement | null;
+ /**
+ * Optional function returning an additional DOM node which controls the listbox, e.g.
+ * a combobox input.
+ */
+ getControlsElements?(): HTMLElement[];
+}
+
+/**
+ * This is the default method for setting the selected state on an item element
+ * @param selected is this item selected
+ */
+function setItemSelected- (this: Item, selected: boolean) {
+ if (selected) {
+ this.setAttribute('aria-selected', 'true');
+ } else {
+ this.removeAttribute('aria-selected');
+ }
+}
+
+/**
+ * @param item possible disabled item
+ * @package do not import this outside of `@patternfly/pfe-core`, it is subject to change at any time
+ */
+export function isItem
- (item: EventTarget | null): item is Item {
+ return item instanceof Element
+ && item?.parentElement?.role === 'listbox'
+ && item?.role !== 'presentation'
+ && item?.localName !== 'hr';
+}
+
+/**
+ * This is a fib. aria-disabled might not be present on an element that uses internals,
+ * and the `disabled` attribute may not accurately represent the disabled state.
+ * short of patching the `attachInternals` constructor, it may not be possible at
+ * runtime to know with certainty that an arbitrary custom element is disabled or not.
+ * @param item possibly disabled item
+ * @package do not import this outside of `@patternfly/pfe-core`, it is subject to change at any time
+ */
+export function isItemDisabled
- (this: Item): boolean {
+ if ('disabled' in this && typeof this.disabled === 'boolean') {
+ return this.disabled;
+ } else {
+ return this.getAttribute('aria-disabled') === 'true'
+ || this.hasAttribute('disabled')
+ || this.matches(':disabled');
+ }
}
let constructingAllowed = false;
@@ -31,145 +109,284 @@ let constructingAllowed = false;
* patterns for implementing keyboard interactions with listbox patterns,
* provide a secondary controller (either RovingTabindexController or
* ActiveDescendantController) to complete the implementation.
+ *
+ * @see https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_focus_vs_selection
+ *
+ * > Occasionally, it may appear as if two elements on the page have focus at the same time.
+ * > For example, in a multi-select list box, when an option is selected it may be greyed.
+ * > Yet, the focus indicator can still be moved to other options, which may also be selected.
+ * > Similarly, when a user activates a tab in a tablist, the selected state is set on the tab
+ * > and its visual appearance changes. However, the user can still navigate, moving the focus
+ * > indicator elsewhere on the page while the tab retains its selected appearance and state.
+ * >
+ * > Focus and selection are quite different. From the keyboard user's perspective,
+ * > focus is a pointer, like a mouse pointer; it tracks the path of navigation.
+ * > There is only one point of focus at any time and all operations take place at the
+ * > point of focus. On the other hand, selection is an operation that can be performed in
+ * > some widgets, such as list boxes, trees, and tablists. If a widget supports only single
+ * > selection, then only one item can be selected and very often the selected state will simply
+ * > follow the focus when focus is moved inside of the widget.
+ * > That is, in some widgets, moving focus may also perform the select operation.
+ * > However, if the widget supports multiple selection, then more than one item can be in a
+ * > selected state, and keys for moving focus do not perform selection. Some multi-select widgets
+ * > do support key commands that both move focus and change selection, but those keys are
+ * > different from the normal navigation keys. Finally, when focus leaves a widget that includes
+ * > a selected element, the selected state persists.
+ * >
+ * > From the developer's perspective, the difference is simple -- the focused element is the
+ * > active element (document.activeElement). Selected elements are elements that have
+ * > aria-selected="true".
+ * >
+ * > With respect to focus and the selected state, the most important considerations for designers
+ * > and developers are:
+ * >
+ * > - The visual focus indicator must always be visible.
+ * > - The selected state must be visually distinct from the focus indicator.
*/
export class ListboxController
- implements ReactiveController {
private static instances = new WeakMap
>();
public static of- (
host: ReactiveControllerHost,
- options: ListboxConfigOptions
- ,
+ options: ListboxControllerOptions
- ,
): ListboxController
- {
constructingAllowed = true;
- const instance =
- ListboxController.instances.get(host) ?? new ListboxController
- (host, options);
+ const instance = new ListboxController
- (host, options);
constructingAllowed = false;
return instance as ListboxController
- ;
}
- private constructor(
- public host: ReactiveControllerHost,
- // this should ideally be ecma #private, but tsc/esbuild tooling isn't up to scratch yet
- // so for now we rely on the underscore convention to avoid compile-time errors
- // try refactoring after updating tooling dependencies
- private _options: ListboxConfigOptions
- ,
- ) {
- if (!constructingAllowed) {
- throw new Error('ListboxController must be constructed with `ListboxController.of()`');
- }
- if (!isServer
- && !(host instanceof HTMLElement)
- && typeof _options.getHTMLElement !== 'function') {
- throw new Error(
- `ListboxController requires the host to be an HTMLElement, or for the initializer to include a \`getHTMLElement()\` function`,
- );
- }
- if (!_options.a11yController) {
- throw new Error(
- `ListboxController requires an additional keyboard accessibility controller. Provide either a RovingTabindexController or an ActiveDescendantController`,
- );
- }
- ListboxController.instances.set(host, this);
- this.host.addController(this);
- if (this.element?.isConnected) {
- this.hostConnected();
- }
- }
-
/** Current active descendant when shift key is pressed */
#shiftStartingItem: Item | null = null;
- /** All options that will not be hidden by a filter */
+ #options: RequireProps
,
+ | 'setItemSelected'
+ | 'isItemDisabled'
+ | 'isItem'
+ >;
+
+ /** All items */
#items: Item[] = [];
+ #selectedItems = new Set- ;
+
#listening = false;
/** Whether listbox is disabled */
disabled = false;
- /** Current active descendant in listbox */
- get activeItem(): Item | undefined {
- return this.options.find(option =>
- option === this._options.a11yController.activeItem) || this._options.a11yController.firstItem;
+ get container(): HTMLElement {
+ return this.#options.getItemsContainer?.() ?? this.host as unknown as HTMLElement;
+ }
+
+ get controlsElements(): HTMLElement[] {
+ const elementOrElements = this.#options.getControlsElements?.();
+ return [elementOrElements].filter(x => !!x).flat();
}
- get nextItem(): Item | undefined {
- return this._options.a11yController.nextItem;
+ get multi(): boolean {
+ return !!this.#options.multi;
}
- get options(): Item[] {
+ set multi(v: boolean) {
+ this.#options.multi = v;
+ this.host.requestUpdate();
+ }
+
+ get items(): Item[] {
return this.#items;
}
/**
- * array of options which are selected
+ * register's the host's Item elements as listbox controller items
+ * sets aria-setsize and aria-posinset on items
+ * @param items items
+ */
+ set items(items: Item[]) {
+ this.#items = items;
+ this.#items.forEach((item, index, _items) => {
+ item.ariaSetSize = _items.length.toString();
+ item.ariaPosInSet = (index + 1).toString();
+ });
+ }
+
+ /**
+ * sets the listbox value based on selected options
+ * @param selected item or items
*/
- get selectedOptions(): Item[] {
- return this.options.filter(option => this._options.isSelected(option));
+ set selected(selected: Item[]) {
+ if (!arraysAreEquivalent(selected, Array.from(this.#selectedItems))) {
+ this.#selectedItems = new Set(selected);
+ for (const item of this.items) {
+ this.#options.setItemSelected.call(item, this.#selectedItems.has(item));
+ }
+ this.host.requestUpdate();
+ }
}
- get value(): Item | Item[] {
- const [firstItem] = this.selectedOptions;
- return this._options.multi ? this.selectedOptions : firstItem;
+ /**
+ * array of options which are selected
+ */
+ get selected(): Item[] {
+ return [...this.#selectedItems];
}
- private get element() {
- return this._options.getHTMLElement();
+ private constructor(
+ public host: ReactiveControllerHost,
+ options: ListboxControllerOptions
- ,
+ ) {
+ this.#options = { setItemSelected, isItemDisabled, isItem, ...options };
+ if (!constructingAllowed) {
+ throw new Error('ListboxController must be constructed with `ListboxController.of()`');
+ }
+ if (!isServer
+ && !(host instanceof HTMLElement)
+ && typeof options.getItemsContainer !== 'function') {
+ throw new Error([
+ 'ListboxController requires the host to be an HTMLElement',
+ 'or for the initializer to include a getItemsContainer() function',
+ ].join(' '));
+ }
+ const instance = ListboxController.instances.get(host) as unknown as ListboxController
- ;
+ if (instance) {
+ return instance as ListboxController
- ;
+ }
+ ListboxController.instances.set(host, this as unknown as ListboxController
);
+ this.host.addController(this);
+ this.multi = this.#options.multi ?? false;
+ if (this.container?.isConnected) {
+ this.hostConnected();
+ }
}
async hostConnected(): Promise {
if (!this.#listening) {
await this.host.updateComplete;
- this.element?.addEventListener('click', this.#onClick);
- this.element?.addEventListener('focus', this.#onFocus);
- this.element?.addEventListener('keydown', this.#onKeydown);
- this.element?.addEventListener('keyup', this.#onKeyup);
+ this.container?.addEventListener('click', this.#onClick);
+ this.container?.addEventListener('keydown', this.#onKeydown);
+ this.container?.addEventListener('keyup', this.#onKeyup);
+ this.controlsElements.forEach(el => el.addEventListener('keydown', this.#onKeydown));
+ this.controlsElements.forEach(el => el.addEventListener('keyup', this.#onKeyup));
this.#listening = true;
}
}
hostUpdated(): void {
- this.element?.setAttribute('role', 'listbox');
- this.element?.setAttribute('aria-disabled', String(!!this.disabled));
- this.element?.setAttribute('aria-multi-selectable', String(!!this._options.multi));
- for (const option of this._options.a11yController.items) {
- if (this._options.a11yController.activeItem === option) {
- option.setAttribute('aria-selected', 'true');
- } else {
- option.removeAttribute('aria-selected');
- }
- }
+ this.container?.setAttribute('role', 'listbox');
+ this.container?.setAttribute('aria-disabled', String(!!this.disabled));
+ this.container?.setAttribute('aria-multiselectable', String(!!this.#options.multi));
}
hostDisconnected(): void {
- this.element?.removeEventListener('click', this.#onClick);
- this.element?.removeEventListener('focus', this.#onFocus);
- this.element?.removeEventListener('keydown', this.#onKeydown);
- this.element?.removeEventListener('keyup', this.#onKeyup);
+ this.container?.removeEventListener('click', this.#onClick);
+ this.container?.removeEventListener('keydown', this.#onKeydown);
+ this.container?.removeEventListener('keyup', this.#onKeyup);
+ this.controlsElements.forEach(el => el.removeEventListener('keydown', this.#onKeydown));
+ this.controlsElements.forEach(el => el.removeEventListener('keyup', this.#onKeyup));
this.#listening = false;
}
- #getEnabledOptions(options = this.options) {
- return options.filter(option => !option.ariaDisabled && !option.closest('[disabled]'));
+ public isSelected(item: Item): boolean {
+ return this.#selectedItems.has(item);
}
- #getEventOption(event: Event): Item | undefined {
- return event
- .composedPath()
- .find(node => this.#items.includes(node as Item)) as Item | undefined;
- }
-
-
/**
- * handles focusing on an option:
- * updates roving tabindex and active descendant
- * @param event focus event
+ * In the case where aria IDL attributes are not supported,
+ * we need to correlate the item in the event path (i.e. the shadow dom clone)
+ * with the item in listbox controller's root (i.e. the hidden light dom original)
+ * XXX: as long as there is no DOM preceeding the shadow root clones, this will work
+ * @param event click or keyboard event
*/
- #onFocus = (event: FocusEvent) => {
- const target = this.#getEventOption(event);
- if (target && target !== this._options.a11yController.activeItem) {
- this._options.a11yController.setActiveItem(target);
+ #getItemFromEvent(event: Event): Item | null {
+ // NOTE(bennypowers): I am aware that this function *sucks*
+ // you're more than welcome to improve it.
+ // make sure there are unit tests first
+ const path = event.composedPath();
+ const tabindexed = this.items.some(x => x.hasAttribute('tabindex'));
+ if (tabindexed) {
+ const item = path.find(this.#options.isItem);
+ if (item) {
+ return item;
+ }
+ } else if (this.#options.isItem(event.target)
+ && event.target.getRootNode() !== this.container.getRootNode()
+ && 'ariaActiveDescendantElement' in HTMLElement.prototype) {
+ return event.target;
+ } else if (event.target instanceof HTMLElement && event.target.ariaActiveDescendantElement) {
+ return event.target.ariaActiveDescendantElement as Item;
+ } else if (event.type === 'click'
+ && this.#options.isItem(event.target)
+ && event.target.id) {
+ const element = event.target;
+ const root = element.getRootNode();
+ if (root instanceof ShadowRoot && this.container.getRootNode() === root) {
+ const shadowRootListboxElement = this.container;
+ const shadowRootItem = element;
+ if (shadowRootItem && shadowRootListboxElement) {
+ if (this.items.includes(shadowRootItem)) {
+ return shadowRootItem;
+ } else {
+ const index =
+ Array.from(shadowRootListboxElement?.children ?? [])
+ .filter(this.#options.isItem)
+ .filter(x => !x.hidden)
+ .indexOf(shadowRootItem);
+ return this.#items.filter(x => !x.hidden)[index];
+ }
+ }
+ }
+ } else {
+ // otherwise, query the root (e.g. shadow root) for the associated element
+ const element = event.target as HTMLElement;
+
+ const root = element.getRootNode() as ShadowRoot | Document;
+
+ const shadowRootListboxElement =
+ this.#options.isItem(element) ? this.container
+ : root.getElementById(element?.getAttribute('aria-controls') ?? '');
+
+ const shadowRootHasActiveDescendantElement =
+ root.querySelector(`[aria-controls="${shadowRootListboxElement?.id}"][aria-activedescendant]`);
+
+ const shadowRootItemId =
+ shadowRootHasActiveDescendantElement?.getAttribute('aria-activedescendant');
+
+ const shadowRootItem =
+ shadowRootItemId && root.getElementById(shadowRootItemId) as Item | null;
+
+ if (shadowRootItem && shadowRootListboxElement) {
+ if (this.items.includes(shadowRootItem)) {
+ return shadowRootItem;
+ } else {
+ const index =
+ Array.from(shadowRootListboxElement?.children ?? [])
+ .filter(this.#options.isItem)
+ .filter(x => !x.hidden)
+ .indexOf(shadowRootItem);
+ return this.#items.filter(x => !x.hidden)[index];
+ }
+ }
+
+ const itemFromEventContainer =
+ shadowRootListboxElement ? shadowRootListboxElement
+ : path.find(x =>
+ x instanceof HTMLElement && x.role === 'listbox') as HTMLElement;
+
+ if (itemFromEventContainer) {
+ const possiblyShadowRootContainerItems = Array.from(itemFromEventContainer.children)
+ .filter(this.#options.isItem);
+
+ const index = possiblyShadowRootContainerItems
+ .findIndex(node => path.includes(node));
+
+ if (index >= 0) {
+ return this.items[index] ?? null;
+ }
+ }
}
- };
+
+ return null;
+ }
/**
* handles clicking on a listbox option:
@@ -178,26 +395,41 @@ export class ListboxController- implements ReactiveCont
* @param event click event
*/
#onClick = (event: MouseEvent) => {
- const target = this.#getEventOption(event);
- if (target) {
- const oldValue = this.value;
- if (this._options.multi) {
- if (!event.shiftKey) {
- this._options.requestSelect(target, !this._options.isSelected(target));
- } else if (this.#shiftStartingItem && target) {
- this.#updateMultiselect(target, this.#shiftStartingItem);
- }
- } else {
+ const item = this.#getItemFromEvent(event);
+ this.#shiftStartingItem ??= this.#getItemFromEvent(event);
+ if (item && !this.#options.isItemDisabled.call(item)) {
+ // Case: single select?
+ // just reset the selected list.
+ if (!this.multi) {
// select target and deselect all other options
- this.options.forEach(option => this._options.requestSelect(option, option === target));
- }
- if (target !== this._options.a11yController.activeItem) {
- this._options.a11yController.setActiveItem(target);
- }
- if (oldValue !== this.value) {
- this.host.requestUpdate();
+ this.selected = [item];
+ // Case: multi select, but no shift key
+ // toggle target, keep all other previously selected
+ } else if (!event.shiftKey) {
+ this.selected = this.items.filter(possiblySelectedItem =>
+ this.#selectedItems.has(possiblySelectedItem) ? possiblySelectedItem !== item
+ : possiblySelectedItem === item);
+ // Case: multi select, with shift key
+ // find all items between previously selected and target,
+ // and select them (if reference item is selected) or deselect them (if reference item is deselected)
+ // Do not wrap around from end to start, rather, only select withing the range of 0-end
+ } else {
+ const startingItem = this.#shiftStartingItem!;
+ // whether options will be selected (true) or deselected (false)
+ const selecting = this.#selectedItems.has(startingItem);
+ const [start, end] = [this.items.indexOf(startingItem), this.items.indexOf(item)].sort();
+ // de/select all options between active descendant and target
+ this.selected = this.items.filter((item, i) => {
+ if (i >= start && i <= end) {
+ return selecting;
+ } else {
+ return this.#selectedItems.has(item);
+ }
+ });
}
}
+ this.#shiftStartingItem = item;
+ this.host.requestUpdate();
};
/**
@@ -205,14 +437,8 @@ export class ListboxController
- implements ReactiveCont
* @param event keyup event
*/
#onKeyup = (event: KeyboardEvent) => {
- const target = this.#getEventOption(event);
- if (target && event.shiftKey && this._options.multi) {
- if (this.#shiftStartingItem && target) {
- this.#updateMultiselect(target, this.#shiftStartingItem);
- }
- if (event.key === 'Shift') {
- this.#shiftStartingItem = null;
- }
+ if (event.key === 'Shift') {
+ this.#shiftStartingItem = null;
}
};
@@ -222,132 +448,90 @@ export class ListboxController
- implements ReactiveCont
* @param event keydown event
*/
#onKeydown = (event: KeyboardEvent) => {
- const target = this.#getEventOption(event);
+ const item = this.#getItemFromEvent(event);
- if (!target || event.altKey || event.metaKey || !this.options.includes(target)) {
+ if (this.disabled || event.altKey || event.metaKey) {
return;
}
- const first = this._options.a11yController.firstItem;
- const last = this._options.a11yController.lastItem;
-
// need to set for keyboard support of multiselect
- if (event.key === 'Shift' && this._options.multi) {
- this.#shiftStartingItem = this.activeItem ?? null;
+ if (event.key === 'Shift' && this.multi) {
+ this.#shiftStartingItem ??= this.#options.getATFocusedItem() ?? null;
}
switch (event.key) {
+ // ctrl+A de/selects all options
case 'a':
case 'A':
- if (event.ctrlKey) {
- // ctrl+A selects all options
- this.#updateMultiselect(first, last, true);
+ if (event.ctrlKey
+ && (event.target === this.container
+ || this.#options.isItem(event.target))) {
+ const selectableItems = this.items.filter(item =>
+ !this.#options.isItemDisabled.call(item));
+ if (arraysAreEquivalent(this.selected, selectableItems)) {
+ this.selected = [];
+ } else {
+ this.selected = selectableItems;
+ }
event.preventDefault();
}
break;
case 'Enter':
+ // enter and space are only applicable if a listbox option is clicked
+ // an external text input should not trigger multiselect
+ if (item && !event.shiftKey) {
+ const focused = item;
+ this.#selectItem(focused, event.shiftKey);
+ event.preventDefault();
+ }
+ break;
+ case 'ArrowUp':
+ if (this.multi && event.shiftKey && this.#options.isItem(event.target)) {
+ const item = event.target;
+ this.selected = this.items.filter((x, i) =>
+ this.#selectedItems.has(x)
+ || i === this.items.indexOf(item) - 1)
+ .filter(x => !this.#options.isItemDisabled.call(x));
+ }
+ break;
+ case 'ArrowDown':
+ if (this.multi && event.shiftKey && this.#options.isItem(event.target)) {
+ const item = event.target;
+ this.selected = this.items.filter((x, i) =>
+ this.#selectedItems.has(x)
+ || i === this.items.indexOf(item) + 1)
+ .filter(x => !this.#options.isItemDisabled.call(x));
+ }
+ break;
case ' ':
// enter and space are only applicable if a listbox option is clicked
// an external text input should not trigger multiselect
- if (this._options.multi) {
- if (event.shiftKey) {
- this.#updateMultiselect(target);
- } else if (!this.disabled) {
- this._options.requestSelect(target, !this._options.isSelected(target));
- }
- } else {
- this.#updateSingleselect();
+ if (item && event.target === this.container) {
+ this.#selectItem(item, event.shiftKey);
+ event.preventDefault();
+ } else if (this.#options.isItem(event.target)) {
+ this.#selectItem(event.target, event.shiftKey);
+ event.preventDefault();
}
- event.preventDefault();
break;
default:
break;
}
+ this.host.requestUpdate();
};
- /**
- * handles change to options given previous options array
- * @param oldOptions previous options list
- */
- #optionsChanged(oldOptions: Item[]) {
- const setSize = this.#items.length;
- if (setSize !== oldOptions.length
- || !oldOptions.every((element, index) => element === this.#items[index])) {
- this._options.a11yController.updateItems(this.options);
- }
- }
-
- /**
- * updates option selections for single select listbox
- */
- #updateSingleselect() {
- if (!this._options.multi && !this.disabled) {
- this.#getEnabledOptions()
- .forEach(option =>
- this._options.requestSelect(
- option,
- option === this._options.a11yController.activeItem,
- ));
- }
- }
-
- /**
- * updates option selections for multiselectable listbox:
- * toggles all options between active descendant and target
- * @param currentItem item being added
- * @param referenceItem item already selected.
- * @param ctrlA is ctrl-a held down?
- */
- #updateMultiselect(
- currentItem?: Item,
- referenceItem = this.activeItem,
- ctrlA = false,
- ) {
- if (referenceItem && this._options.multi && !this.disabled && currentItem) {
- // select all options between active descendant and target
- const [start, end] = [
- this.options.indexOf(referenceItem),
- this.options.indexOf(currentItem),
- ].sort();
- const options = [...this.options].slice(start, end + 1);
-
- // by default CTRL+A will select all options
- // if all options are selected, CTRL+A will deselect all options
- const allSelected = this.#getEnabledOptions(options)
- .filter(option => !this._options.isSelected(option))?.length === 0;
-
- // whether options will be selected (true) or deselected (false)
- const selected = ctrlA ? !allSelected : this._options.isSelected(referenceItem);
- this.#getEnabledOptions(options).forEach(option =>
- this._options.requestSelect(option, selected));
-
+ #selectItem(item: Item, shiftDown = false) {
+ if (this.#options.isItemDisabled.call(item)) {
+ return;
+ } else if (this.multi && shiftDown) {
// update starting item for other multiselect
- this.#shiftStartingItem = currentItem;
+ this.selected = [...this.selected, item];
+ } else if (this.multi && this.#selectedItems.has(item)) {
+ this.selected = this.selected.filter(x => x !== item);
+ } else if (this.multi) {
+ this.selected = this.selected.concat(item);
+ } else {
+ this.selected = [item];
}
}
-
- /**
- * sets the listbox value based on selected options
- * @param value item or items
- */
- setValue(value: Item | Item[]): void {
- const selected = Array.isArray(value) ? value : [value];
- const [firstItem = null] = selected;
- for (const option of this.options) {
- this._options.requestSelect(option, (
- !!this._options.multi && Array.isArray(value) ? value?.includes(option)
- : firstItem === option
- ));
- }
- }
-
- /**
- * register's the host's Item elements as listbox controller items
- * @param options items
- */
- setOptions(options: Item[]): void {
- const oldOptions = [...this.#items];
- this.#items = options;
- this.#optionsChanged(oldOptions);
- }
}
diff --git a/core/pfe-core/controllers/property-observer-controller.ts b/core/pfe-core/controllers/property-observer-controller.ts
index faac228bec..1b9234bd24 100644
--- a/core/pfe-core/controllers/property-observer-controller.ts
+++ b/core/pfe-core/controllers/property-observer-controller.ts
@@ -1,5 +1,7 @@
import type { ReactiveController, ReactiveElement } from 'lit';
+import { notEqual } from 'lit';
+
export type ChangeCallback
= (
this: T,
old?: V,
@@ -12,20 +14,43 @@ export interface PropertyObserverOptions {
waitFor?: 'connected' | 'updated' | 'firstUpdated';
}
+const UNINITIALIZED = Symbol('uninitialized');
+
export class PropertyObserverController<
T extends ReactiveElement
> implements ReactiveController {
- private oldVal: T[keyof T];
+ private oldVal: T[keyof T] = UNINITIALIZED as T[keyof T];
constructor(
private host: T,
private options: PropertyObserverOptions
) {
- this.oldVal = host[options.propertyName];
+ }
+
+ #neverRan = true;
+
+ hostConnected(): void {
+ this.#init();
+ }
+
+ /**
+ * Because of how typescript transpiles private fields,
+ * the __accessPrivate helper might not be entirely initialized
+ * by the time this constructor runs (in `addInitializer`'s instance callback')
+ * Therefore, we pull this shtick.
+ *
+ * When browser support improves to the point we can ship decorated private fields,
+ * we'll be able to get rid of this.
+ */
+ #init() {
+ if (this.oldVal === UNINITIALIZED) {
+ this.oldVal = this.host[this.options.propertyName];
+ }
}
/** Set any cached valued accumulated between constructor and connectedCallback */
async hostUpdate(): Promise {
+ this.#init();
const { oldVal, options: { waitFor, propertyName, callback } } = this;
if (!callback) {
throw new Error(`no callback for ${propertyName}`);
@@ -54,6 +79,13 @@ export class PropertyObserverController<
break;
}
}
- callback.call(this.host, oldVal as T[keyof T], newVal);
+ const Class = (this.host.constructor as typeof ReactiveElement);
+ const hasChanged = Class
+ .getPropertyOptions(this.options.propertyName)
+ .hasChanged ?? notEqual;
+ if (this.#neverRan || hasChanged(oldVal, newVal)) {
+ callback.call(this.host, oldVal as T[keyof T], newVal);
+ this.#neverRan = false;
+ }
}
}
diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts
index afaa2fcaa3..6d47fb68cc 100644
--- a/core/pfe-core/controllers/roving-tabindex-controller.ts
+++ b/core/pfe-core/controllers/roving-tabindex-controller.ts
@@ -1,18 +1,9 @@
-import type { ReactiveController, ReactiveControllerHost } from 'lit';
-import type { RequireProps } from '../core.js';
+import { isServer, type ReactiveControllerHost } from 'lit';
+import { ATFocusController, type ATFocusControllerOptions } from './at-focus-controller.js';
+import { Logger } from './logger.js';
-const isFocusableElement = (el: Element): el is HTMLElement =>
- !!el
- && !el.ariaHidden
- && !el.hasAttribute('hidden');
-
-export interface RovingTabindexControllerOptions- {
- /** @deprecated use getHTMLElement */
- getElement?: () => Element | null;
- getHTMLElement?: () => HTMLElement | null;
- getItems?: () => Item[];
- getItemContainer?: () => HTMLElement;
-}
+export type RovingTabindexControllerOptions
- =
+ ATFocusControllerOptions
- ;
/**
* Implements roving tabindex, as described in WAI-ARIA practices, [Managing Focus Within
@@ -22,286 +13,81 @@ export interface RovingTabindexControllerOptions
- {
*/
export class RovingTabindexController<
Item extends HTMLElement = HTMLElement
-> implements ReactiveController {
- private static hosts = new WeakMap
();
-
+> extends ATFocusController- {
static of
- (
host: ReactiveControllerHost,
- options: RovingTabindexControllerOptions
- & { getItems(): Item[] },
+ options: RovingTabindexControllerOptions
- ,
): RovingTabindexController
- {
return new RovingTabindexController(host, options);
}
- /** @internal */
- static elements: WeakMap
> =
- new WeakMap();
-
- /** active focusable element */
- #activeItem?: Item;
-
- /** closest ancestor containing items */
- #itemsContainer?: Element;
+ #logger = new Logger(this.host);
- /** array of all focusable elements */
- #items: Item[] = [];
-
- /** flags whether the host's element has gained focus at least once */
#gainedInitialFocus = false;
- /**
- * finds focusable items from a group of items
- */
- get #focusableItems(): Item[] {
- return this.#items.filter(isFocusableElement);
- }
-
- /**
- * index of active item in array of focusable items
- */
- get #activeIndex(): number {
- return !!this.#focusableItems
- && !!this.activeItem ? this.#focusableItems.indexOf(this.activeItem) : -1;
- }
-
- /**
- * index of active item in array of items
- */
- get #itemIndex(): number {
- return this.activeItem ? this.#items.indexOf(this.activeItem) : -1;
- }
-
- /**
- * active item of array of items
- */
- get activeItem(): Item | undefined {
- return this.#activeItem;
- }
-
- /**
- * all items from array
- */
- get items(): Item[] {
- return this.#items;
- }
-
- /**
- * all focusable items from array
- */
- get focusableItems(): Item[] {
- return this.#focusableItems;
- }
-
- /**
- * first item in array of focusable items
- */
- get firstItem(): Item | undefined {
- return this.#focusableItems[0];
- }
-
- /**
- * last item in array of focusable items
- */
- get lastItem(): Item | undefined {
- return this.#focusableItems.at(-1);
- }
+ #itemsSet = new Set- ();
- /**
- * next item after active item in array of focusable items
- */
- get nextItem(): Item | undefined {
- return (
- this.#activeIndex >= this.#focusableItems.length - 1 ? this.firstItem
- : this.#focusableItems[this.#activeIndex + 1]
- );
+ get atFocusedItemIndex(): number {
+ return super.atFocusedItemIndex;
}
/**
- * previous item after active item in array of focusable items
+ * Sets the DOM Focus on the item with assistive technology focus
+ * @param item item
*/
- get prevItem(): Item | undefined {
- return (
- this.#activeIndex > 0 ? this.#focusableItems[this.#activeIndex - 1]
- : this.lastItem
- );
- }
-
- #options: RequireProps
, 'getHTMLElement'>;
-
- constructor(
- public host: ReactiveControllerHost,
- options?: RovingTabindexControllerOptions- ,
- ) {
- this.#options = {
- getHTMLElement: options?.getHTMLElement
- ?? (options?.getElement as (() => HTMLElement | null))
- ?? (() => host instanceof HTMLElement ? host : null),
- getItems: options?.getItems,
- getItemContainer: options?.getItemContainer,
- };
- const instance = RovingTabindexController.hosts.get(host);
- if (instance) {
- return instance as RovingTabindexController
- ;
- }
- RovingTabindexController.hosts.set(host, this);
- this.host.addController(this);
- this.updateItems();
- }
-
- hostUpdated(): void {
- const oldContainer = this.#itemsContainer;
- const newContainer = this.#options.getHTMLElement();
- if (oldContainer !== newContainer) {
- oldContainer?.removeEventListener('keydown', this.#onKeydown);
- RovingTabindexController.elements.delete(oldContainer!);
- this.updateItems();
+ set atFocusedItemIndex(index: number) {
+ super.atFocusedItemIndex = index;
+ const item = this.items.at(this.atFocusedItemIndex);
+ for (const i of this.items) {
+ i.tabIndex = item === i ? 0 : -1;
}
- if (newContainer) {
- this.#initContainer(newContainer);
+ if (this.#gainedInitialFocus) {
+ item?.focus();
}
+ this.host.requestUpdate();
}
- /**
- * removes event listeners from items container
- */
- hostDisconnected(): void {
- this.#itemsContainer?.removeEventListener('keydown', this.#onKeydown);
- this.#itemsContainer = undefined;
- this.#gainedInitialFocus = false;
- }
-
- #initContainer(container: Element) {
- RovingTabindexController.elements.set(container, this);
- this.#itemsContainer = container;
- this.#itemsContainer.addEventListener('keydown', this.#onKeydown);
- this.#itemsContainer.addEventListener('focusin', () => {
- this.#gainedInitialFocus = true;
- }, { once: true });
+ get items() {
+ return this._items;
}
- /**
- * handles keyboard navigation
- * @param event keydown event
- */
- #onKeydown = (event: Event) => {
- if (!(event instanceof KeyboardEvent)
- || event.ctrlKey
- || event.altKey
- || event.metaKey
- || !this.#focusableItems.length
- || !event.composedPath().some(x =>
- this.#focusableItems.includes(x as Item))) {
- return;
- }
-
- const orientation = this.#options.getHTMLElement()?.getAttribute('aria-orientation');
-
- const item = this.activeItem;
- let shouldPreventDefault = false;
- const horizontalOnly =
- !item ? false
- : item.tagName === 'SELECT'
- || item.getAttribute('role') === 'spinbutton' || orientation === 'horizontal';
- const verticalOnly = orientation === 'vertical';
- switch (event.key) {
- case 'ArrowLeft':
- if (verticalOnly) {
- return;
- }
- this.setActiveItem(this.prevItem);
- shouldPreventDefault = true;
- break;
- case 'ArrowRight':
- if (verticalOnly) {
- return;
- }
-
- this.setActiveItem(this.nextItem);
- shouldPreventDefault = true;
- break;
- case 'ArrowUp':
- if (horizontalOnly) {
- return;
- }
- this.setActiveItem(this.prevItem);
- shouldPreventDefault = true;
- break;
- case 'ArrowDown':
- if (horizontalOnly) {
- return;
- }
- this.setActiveItem(this.nextItem);
- shouldPreventDefault = true;
- break;
- case 'Home':
- this.setActiveItem(this.firstItem);
- shouldPreventDefault = true;
- break;
- case 'End':
- this.setActiveItem(this.lastItem);
- shouldPreventDefault = true;
- break;
- default:
- break;
- }
-
- if (shouldPreventDefault) {
- event.stopPropagation();
- event.preventDefault();
- }
- };
-
- /**
- * Sets the active item and focuses it
- * @param item tabindex item
- */
- setActiveItem(item?: Item): void {
- this.#activeItem = item;
- for (const item of this.#focusableItems) {
- item.tabIndex = this.#activeItem === item ? 0 : -1;
- }
+ public set items(items: Item[]) {
+ this._items = items;
+ this.#itemsSet = new Set(items);
+ const pivot = Math.max(0, this.atFocusedItemIndex);
+ const [firstFocusable] = this.atFocusableItems;
+ const firstFocusableIndex = firstFocusable ? items.indexOf(firstFocusable) : -1;
+ const pivotFocusableIndex = items.indexOf(this.items
+ .slice(pivot)
+ .concat(this.items.slice(0, pivot))
+ .find(item => this.atFocusableItems.includes(item))!);
+ this.atFocusedItemIndex = Math.max(firstFocusableIndex, pivotFocusableIndex);
this.host.requestUpdate();
- if (this.#gainedInitialFocus) {
- this.#activeItem?.focus();
- }
- }
-
- /**
- * Focuses next focusable item
- * @param items tabindex items
- */
- updateItems(items: Item[] = this.#options.getItems?.() ?? []): void {
- 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;
- const activeItem = focusableItem ?? first ?? this.firstItem;
- this.setActiveItem(activeItem);
}
- /**
- * @deprecated use setActiveItem
- * @param item tabindex item
- */
- focusOnItem(item?: Item): void {
- this.setActiveItem(item);
+ private constructor(
+ public host: ReactiveControllerHost,
+ options: RovingTabindexControllerOptions
- ,
+ ) {
+ super(host, options);
+ this.initItems();
+ const container = options.getItemsContainer?.() ?? this.host;
+ if (!isServer) {
+ if (container instanceof HTMLElement) {
+ container.addEventListener('focusin', () =>
+ this.#gainedInitialFocus = true, { once: true });
+ } else {
+ this.#logger.warn('RovingTabindexController requires a getItemsContainer function');
+ }
+ }
}
- /**
- * from array of HTML items, and sets active items
- * @deprecated use getItems and getItemContainer option functions
- * @param items tabindex items
- * @param itemsContainer
- */
- initItems(items: Item[], itemsContainer?: Element): void {
- const element = itemsContainer
- ?? this.#options?.getItemContainer?.()
- ?? this.#options.getHTMLElement();
- if (element) {
- this.#initContainer(element);
- }
- this.updateItems(items);
+ protected override isRelevantKeyboardEvent(event: Event): event is KeyboardEvent {
+ return ((event instanceof KeyboardEvent)
+ && !event.ctrlKey
+ && !event.altKey
+ && !event.metaKey
+ && !!this.atFocusableItems.length
+ && !!event.composedPath().some(node => this.#itemsSet.has(node as Item)));
}
}
diff --git a/core/pfe-core/controllers/test/combobox-controller.spec.ts b/core/pfe-core/controllers/test/combobox-controller.spec.ts
new file mode 100644
index 0000000000..062021fe94
--- /dev/null
+++ b/core/pfe-core/controllers/test/combobox-controller.spec.ts
@@ -0,0 +1,247 @@
+/* eslint-disable lit-a11y/accessible-name */
+import { expect, fixture } from '@open-wc/testing';
+import { sendKeys } from '@web/test-runner-commands';
+import { a11ySnapshot, querySnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js';
+
+import { customElement } from 'lit/decorators/custom-element.js';
+import { query } from 'lit/decorators/query.js';
+import { state } from 'lit/decorators/state.js';
+import { LitElement, html } from 'lit';
+
+import { ComboboxController } from '../combobox-controller.js';
+import { SlotController } from '../slot-controller.js';
+
+function press(key: string) {
+ return async function() {
+ await sendKeys({ press: key });
+ };
+}
+
+
+describe('ComboboxController', function() {
+ describe('With toggle and combobox in shadow root but items in light DOM', function() {
+ let element: XComboboxShadow;
+
+ @customElement('x-combobox-shadow')
+ class XComboboxShadow extends LitElement {
+ @query('#listbox') private _listbox?: HTMLElement;
+ @query('#button') private _button?: HTMLElement;
+ @query('#combobox') private _combobox?: HTMLInputElement;
+ @query('#placeholder') private _placeholder?: HTMLOptionElement;
+
+ @state() expanded = false;
+
+ /** List of options */
+ get options(): HTMLOptionElement[] {
+ return [this._placeholder, ...Array.from(this.querySelectorAll('option'))].filter(x => !!x);
+ }
+
+ #combobox = ComboboxController.of(this, {
+ multi: false,
+ getItems: () => this.options,
+ isItem: item => item instanceof HTMLOptionElement,
+ getFallbackLabel: () => 'options',
+ getListboxElement: () => this._listbox ?? null,
+ getToggleButton: () => this._button ?? null,
+ getComboboxInput: () => this._combobox ?? null,
+ isExpanded: () => this.expanded,
+ requestShowListbox: () => this.expanded ||= true,
+ requestHideListbox: () => ((this.expanded &&= false), true),
+ setItemActive(active) {
+ this.classList.toggle('active', !!active);
+ },
+ setItemSelected(selected) {
+ this.selected = selected;
+ },
+ });
+
+ override render() {
+ const { canControlLightDom } = ComboboxController;
+ return html`
+
+
+ Show Options
+
+
+
+ Select an Option
+
+ ${this.#combobox.renderItemsToShadowRoot()}
+
+
+
+
+ `;
+ }
+ }
+
+ beforeEach(async function() {
+ element = await fixture(html`
+ ${Array.from({ length: 10 }, (_, i) => html`
+ ${i + 1} `)}
+
+ `);
+ await element.updateComplete;
+ });
+
+ describe('tabbing to the combobox', function() {
+ beforeEach(press('Tab'));
+ it('focuses the combobox', async function() {
+ const snapshot = await a11ySnapshot();
+ expect(querySnapshot(snapshot, { focused: true })?.role).to.equal('combobox');
+ });
+ describe('tabbing out of the combobox', function() {
+ beforeEach(press('Tab'));
+ it('does not focus the toggle button', async function() {
+ const snapshot = await a11ySnapshot();
+ expect(querySnapshot(snapshot, { focused: true })).to.equal(null);
+ });
+ });
+ describe('pressing ArrowDown', function() {
+ beforeEach(press('ArrowDown'));
+
+ it('expands the listbox', async function() {
+ const snapshot = await a11ySnapshot();
+ expect(querySnapshot(snapshot, { role: 'listbox' })?.children, 'listbox children').to.have.length(11);
+ expect(querySnapshot(snapshot, { role: 'combobox' })?.expanded, 'expanded').to.equal(true);
+ });
+
+ it('maintains DOM focus on the combobox', async function() {
+ const snapshot = await a11ySnapshot();
+ expect(querySnapshot(snapshot, { focused: true })?.role).to.equal('combobox');
+ });
+
+ it.skip('sets active state on the first option', async function() {
+ // maybe this is a problem with the demo element
+ await element.updateComplete;
+ expect(element.querySelector('option')?.classList.contains('active')).to.equal(true);
+ });
+
+ describe('pressing Escape', function() {
+ beforeEach(press('Escape'));
+ it('collapses the listbox', async function() {
+ const snapshot = await a11ySnapshot();
+ expect(querySnapshot(snapshot, { role: 'combobox' })?.expanded, 'expanded').to.equal(undefined);
+ });
+ it('maintains DOM focus on the combobox', async function() {
+ const snapshot = await a11ySnapshot();
+ expect(querySnapshot(snapshot, { focused: true })?.role).to.equal('combobox');
+ });
+ });
+ });
+ });
+ });
+
+ describe('With input, listbox, and options in the lightdom', function() {
+ let element: XComboboxLight;
+
+ @customElement('x-combobox-light')
+ class XComboboxLight extends LitElement {
+ #slots = new SlotController(this, null, 'input', 'button');
+
+ /** List of options */
+ get options(): HTMLOptionElement[] {
+ return [...Array.from(this.querySelectorAll('option'))].filter(x => !!x);
+ }
+
+ #canControlLightDom = ComboboxController.canControlLightDom;
+
+ #combobox = ComboboxController.of(this, {
+ multi: false,
+ isExpanded: () => this.expanded,
+ requestShowListbox: () => this.expanded ||= true,
+ requestHideListbox: () => ((this.expanded &&= false), true),
+ getListboxElement: () => this.#canControlLightDom ? this._listbox as HTMLElement : [...this.#slots.getSlotted()].shift() as HTMLElement,
+ getToggleButton: () => [...this.#slots.getSlotted('button')].shift() as HTMLElement,
+ getComboboxInput: () => [...this.#slots.getSlotted('input')].shift() as HTMLElement,
+ getFallbackLabel: () => 'options',
+ getItems: () => this.options,
+ });
+
+ @query('#listbox') private _listbox?: HTMLElement;
+
+ @state() expanded = false;
+
+ override render() {
+ return html`
+
+
+
+
+
+
+ Select an Option
+
+ ${this.#combobox.renderItemsToShadowRoot()}
+
+
+
+
+
+ `;
+ }
+ }
+
+ beforeEach(async function() {
+ element = await fixture(html`
+
+
+ Show Options
+ ${Array.from({ length: 10 }, (_, i) => html`${i + 1} `)}å
+
+ `);
+ await element.updateComplete;
+ });
+
+ describe('tabbing to the combobox', function() {
+ beforeEach(press('Tab'));
+ it('focuses the combobox', async function() {
+ const snapshot = await a11ySnapshot();
+ expect(querySnapshot(snapshot, { focused: true })?.role).to.equal('combobox');
+ });
+ describe('tabbing out of the combobox', function() {
+ beforeEach(press('Tab'));
+ it('does not focus the toggle button', async function() {
+ const snapshot = await a11ySnapshot();
+ expect(querySnapshot(snapshot, { focused: true })).to.equal(null);
+ });
+ });
+ describe('pressing ArrowDown', function() {
+ beforeEach(press('ArrowDown'));
+ it('expands the listbox', async function() {
+ const snapshot = await a11ySnapshot();
+ expect(querySnapshot(snapshot, { role: 'listbox' })?.children, 'listbox children').to.have.length(11);
+ expect(querySnapshot(snapshot, { role: 'combobox' })?.expanded, 'expanded').to.equal(true);
+ });
+ it('maintains DOM focus on the combobox', async function() {
+ const snapshot = await a11ySnapshot();
+ expect(querySnapshot(snapshot, { focused: true })?.role).to.equal('combobox');
+ });
+ it.skip('sets active state on the first option', async function() {
+ // maybe this is a problem with the demo element
+ await element.updateComplete;
+ expect(element.querySelector('option')?.classList.contains('active')).to.equal(true);
+ });
+ describe('pressing Escape', function() {
+ beforeEach(press('Escape'));
+ it('collapses the listbox', async function() {
+ const snapshot = await a11ySnapshot();
+ expect(querySnapshot(snapshot, { role: 'combobox' })?.expanded, 'expanded').to.equal(undefined);
+ });
+ it('maintains DOM focus on the combobox', async function() {
+ const snapshot = await a11ySnapshot();
+ expect(querySnapshot(snapshot, { focused: true })?.role).to.equal('combobox');
+ });
+ });
+ });
+ });
+ });
+
+ describe('All options are in the shadowdom', function() {
+ /* test here */
+ });
+});
diff --git a/core/pfe-core/decorators/observes.ts b/core/pfe-core/decorators/observes.ts
index 485ddd7809..943b84962e 100644
--- a/core/pfe-core/decorators/observes.ts
+++ b/core/pfe-core/decorators/observes.ts
@@ -20,7 +20,7 @@ import {
*/
export function observes(
propertyName: string & keyof T,
- options?: Partial, 'waitFor'>>,
+ options?: Partial, 'callback' | 'propertyName'>>,
) {
return function(proto: T, methodName: string): void {
const callback = proto[methodName as keyof T] as ChangeCallback;
diff --git a/core/pfe-core/functions/arraysAreEquivalent.ts b/core/pfe-core/functions/arraysAreEquivalent.ts
new file mode 100644
index 0000000000..475520cd1e
--- /dev/null
+++ b/core/pfe-core/functions/arraysAreEquivalent.ts
@@ -0,0 +1,24 @@
+/**
+ * Whether the two arrays are equivalent
+ * Arrays are equivalent when they are both empty, or when their lengths are equal and each of
+ * their members is equal (===) to the corresponding member in the other array.
+ * If either argument is not an array, the result will be strict equivalence (===)
+ * @param a first array
+ * @param b second array
+ */
+export function arraysAreEquivalent(a: unknown, b: unknown): boolean {
+ if (!Array.isArray(a) || !Array.isArray(b)) {
+ return a === b;
+ } else if (a.length !== b.length) { // lengths are different
+ return false;
+ } else if (!a.length && !b.length) { // both are empty
+ return true;
+ } else { // multi and length of both is equal
+ for (const [i, element] of a.entries()) {
+ if (element !== b[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/core/pfe-core/package.json b/core/pfe-core/package.json
index 67706c9ef9..ff8cd3b2ed 100644
--- a/core/pfe-core/package.json
+++ b/core/pfe-core/package.json
@@ -16,6 +16,7 @@
"./functions/*": "./functions/*",
"./core.js": "./core.js",
"./decorators.js": "./decorators.js",
+ "./controllers/activedescendant-controller.js": "./controllers/activedescendant-controller.js",
"./controllers/cascade-controller.js": "./controllers/cascade-controller.js",
"./controllers/css-variable-controller.js": "./controllers/css-variable-controller.js",
"./controllers/floating-dom-controller.js": "./controllers/floating-dom-controller.js",
@@ -35,8 +36,10 @@
"./decorators/deprecation.js": "./decorators/deprecation.js",
"./decorators/initializer.js": "./decorators/initializer.js",
"./decorators/observed.js": "./decorators/observed.js",
+ "./decorators/observes.js": "./decorators/observes.js",
"./decorators/time.js": "./decorators/time.js",
"./decorators/trace.js": "./decorators/trace.js",
+ "./functions/arraysAreEquivalent.js": "./functions/arraysAreEquivalent.js",
"./functions/context.js": "./functions/context.js",
"./functions/containsDeep.js": "./functions/containsDeep.js",
"./functions/debounce.js": "./functions/debounce.js",
diff --git a/core/pfe-core/test/decorators.spec.ts b/core/pfe-core/test/decorators.spec.ts
index a96b5efd97..51bde2874b 100644
--- a/core/pfe-core/test/decorators.spec.ts
+++ b/core/pfe-core/test/decorators.spec.ts
@@ -26,7 +26,7 @@ describe('@observes', function() {
element = await fixture(html` `);
});
it('initializes with old and new values', function() {
- expect(element.old, 'old').to.equal(undefined);
+ expect(element.old, 'old').to.equal(0);
expect(element.current, 'current').to.equal(0);
});
describe('setting the observed prop', function() {
@@ -60,7 +60,7 @@ describe('@observed', function() {
element = await fixture(html` `);
});
it('initializes with old and new values', function() {
- expect(element.old, 'old').to.equal(undefined);
+ expect(element.old, 'old').to.equal(0);
expect(element.current, 'current').to.equal(0);
});
describe('setting the observed prop', function() {
@@ -94,7 +94,7 @@ describe('@observed(\'_myCallback\')', function() {
element = await fixture(html` `);
});
it('initializes with old and new values', function() {
- expect(element.old, 'old').to.equal(undefined);
+ expect(element.old, 'old').to.equal(0);
expect(element.current, 'current').to.equal(0);
});
describe('setting the observed prop', function() {
@@ -124,7 +124,7 @@ describe('@observed(() => {...})', function() {
});
it('initializes with old and new values', function() {
- expect(dump).to.have.been.calledWith(undefined, 0);
+ expect(dump).to.have.been.calledWith(0, 0);
});
describe('setting the observed prop', function() {
beforeEach(function() {
diff --git a/core/pfe-core/tsconfig.json b/core/pfe-core/tsconfig.json
index fdd9d94497..c47017aa80 100644
--- a/core/pfe-core/tsconfig.json
+++ b/core/pfe-core/tsconfig.json
@@ -7,5 +7,10 @@
"declaration": true,
"composite": true,
"allowJs": false
- }
+ },
+ "references": [
+ {
+ "path": "../../tools/pfe-tools"
+ }
+ ]
}
diff --git a/elements/pf-accordion/demo/nested.html b/elements/pf-accordion/demo/nested.html
index f0e56bf5c2..e7fd5ad477 100644
--- a/elements/pf-accordion/demo/nested.html
+++ b/elements/pf-accordion/demo/nested.html
@@ -1,5 +1,4 @@
- Nested Accordion
Level One - Item one (single attr: true)
@@ -116,6 +115,6 @@ Level One - Item five
diff --git a/elements/pf-accordion/demo/pf-accordion.html b/elements/pf-accordion/demo/pf-accordion.html
index dc76ddd6c0..c0d0b08395 100644
--- a/elements/pf-accordion/demo/pf-accordion.html
+++ b/elements/pf-accordion/demo/pf-accordion.html
@@ -1,34 +1,34 @@
- Level One - Item one
+ Item one
- Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore
+
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua.
- Level One - Item two
+ Item two
- Vivamus et tortor sed arcu congue vehicula eget et diam. Praesent nec dictum lorem. Aliquam id diam ultrices,
+
Vivamus et tortor sed arcu congue vehicula eget et diam. Praesent nec dictum lorem. Aliquam id diam ultrices,
faucibus erat id, maximus nunc.
- Level One - Item three
+ Item three
- Morbi vitae urna quis nunc convallis hendrerit. Aliquam congue orci quis ultricies tempus.
+ Morbi vitae urna quis nunc convallis hendrerit. Aliquam congue orci quis ultricies tempus.
- Level One - Item four
+ Item four
Donec vel posuere orci. Phasellus quis tortor a ex hendrerit efficitur. Aliquam lacinia ligula pharetra,
sagittis ex ut, pellentesque diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere
cubilia Curae;
- Vestibulum ultricies nulla nibh. Etiam vel dui fermentum ligula ullamcorper eleifend non quis tortor. Morbi
+ Vestibulum ultricies nulla nibh . Etiam vel dui fermentum ligula ullamcorper eleifend non quis tortor. Morbi
tempus ornare tempus. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.
Mauris
et velit neque. Donec ultricies condimentum mauris, pellentesque imperdiet libero convallis convallis. Aliquam
@@ -38,17 +38,14 @@
Level One - Item four
- Level One - Item five
+ Item five
- Vivamus finibus dictum ex id ultrices. Mauris dictum neque a iaculis blandit.
+ Vivamus finibus dictum ex id ultrices. Mauris dictum neque a iaculis blandit.
-
-
-
diff --git a/elements/pf-accordion/pf-accordion-header.ts b/elements/pf-accordion/pf-accordion-header.ts
index 3ae5f97612..f703ba2aca 100644
--- a/elements/pf-accordion/pf-accordion-header.ts
+++ b/elements/pf-accordion/pf-accordion-header.ts
@@ -125,7 +125,6 @@ export class PfAccordionHeader extends LitElement {
override connectedCallback(): void {
super.connectedCallback();
- this.hidden = true;
this.id ||= getRandomId(this.localName);
this.#initHeader();
}
@@ -144,10 +143,11 @@ export class PfAccordionHeader extends LitElement {
+ class="icon"
+ size="lg"
+ set="${this.iconSet ?? 'fas'}"
+ icon="${this.icon ?? 'angle-right'}"
+ >
`;
switch (this.headingTag) {
diff --git a/elements/pf-accordion/pf-accordion.ts b/elements/pf-accordion/pf-accordion.ts
index f5e2795cc6..52c56a695f 100644
--- a/elements/pf-accordion/pf-accordion.ts
+++ b/elements/pf-accordion/pf-accordion.ts
@@ -128,10 +128,7 @@ export class PfAccordion extends LitElement {
*
* ```
*/
- @property({
- attribute: 'expanded-index',
- converter: NumberListConverter,
- })
+ @property({ attribute: 'expanded-index', converter: NumberListConverter })
get expandedIndex(): number[] {
return this.#expandedIndex;
}
@@ -139,6 +136,7 @@ export class PfAccordion extends LitElement {
set expandedIndex(value) {
const old = this.#expandedIndex;
this.#expandedIndex = value;
+ this.#tabindex.atFocusedItemIndex = value.at(-1) ?? -1;
if (JSON.stringify(old) !== JSON.stringify(value)) {
this.requestUpdate('expandedIndex', old);
this.collapseAll().then(async () => {
@@ -157,7 +155,7 @@ export class PfAccordion extends LitElement {
#mo = new MutationObserver(() => this.#init());
- #headerIndex = new RovingTabindexController(this, {
+ #tabindex = RovingTabindexController.of(this, {
getItems: () => this.headers,
});
@@ -233,15 +231,14 @@ export class PfAccordion extends LitElement {
*/
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.updateAccessibility();
}
@listen('focusin')
protected updateActiveHeader(): void {
- if (this.#activeHeader !== this.#headerIndex.activeItem) {
- this.#headerIndex.setActiveItem(this.#activeHeader);
+ if (this.#activeHeader
+ && this.#activeHeader !== this.headers.at(this.#tabindex.atFocusedItemIndex)) {
+ this.#tabindex.atFocusedItemIndex = this.headers.indexOf(this.#activeHeader);
}
}
@@ -322,7 +319,6 @@ export class PfAccordion extends LitElement {
}
public updateAccessibility(): void {
- this.#headerIndex.updateItems();
const { headers } = this;
// For each header in the accordion, attach the aria connections
@@ -367,8 +363,6 @@ export class PfAccordion extends LitElement {
this.#expandHeader(header, index);
this.#expandPanel(panel);
- header.focus();
-
this.dispatchEvent(new PfAccordionExpandEvent(header, panel));
await this.updateComplete;
diff --git a/elements/pf-accordion/test/pf-accordion.spec.ts b/elements/pf-accordion/test/pf-accordion.spec.ts
index f5fb3a215e..dae0f0ee09 100644
--- a/elements/pf-accordion/test/pf-accordion.spec.ts
+++ b/elements/pf-accordion/test/pf-accordion.spec.ts
@@ -1,5 +1,4 @@
-import { expect, html, aTimeout, nextFrame } from '@open-wc/testing';
-import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js';
+import { expect, fixture, html, aTimeout, nextFrame } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import { allUpdates, clickElementAtCenter } from '@patternfly/pfe-tools/test/utils.js';
@@ -71,7 +70,7 @@ describe('', function() {
});
it('simply instantiating', async function() {
- element = await createFixture(html` `);
+ element = await fixture(html` `);
expect(element, 'pf-accordion should be an instance of PfAccordion')
.to.be.an.instanceof(customElements.get('pf-accordion'))
.and
@@ -80,29 +79,29 @@ describe('', function() {
describe('in typical usage', function() {
beforeEach(async function() {
- element = await createFixture(html`
+ element = await fixture(html`
- Lorem ipsum dolor sit amet , consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt
+
Panel1 Panel1 link Lorem ipsum dolor , sit amet consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt
ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea
rebum.
- Labore et dolore magna aliquyam erat?
+ Header2 Labore et dolore magna aliquyam erat?
- Lorem ipsum dolor sit amet , consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt
+
Panel2 Panel2 link Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt
ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea
rebum.
- Incididunt in Lorem voluptate eiusmod dolor?
+ Header3 Incididunt in Lorem voluptate eiusmod dolor?
- Lorem ipsum dolor sit amet , consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt
+
Panel3Panel3 link Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt
ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea
rebum.
@@ -112,9 +111,10 @@ describe('', function() {
panels = Array.from(element.querySelectorAll('pf-accordion-panel'));
[header, secondHeader] = headers;
[panel, secondPanel] = panels;
- await allUpdates(element);
});
+ beforeEach(() => allUpdates(element));
+
it('randomly generates ids on children', function() {
expect(secondHeader.id).to.match(/pf-/);
expect(secondPanel.id).to.match(/panel-/);
@@ -130,8 +130,8 @@ describe('', function() {
it('expands first pair', async function() {
const snapshot = await a11ySnapshot();
- const expanded = snapshot?.children?.find(x => x.expanded);
- const focused = snapshot?.children?.find(x => x.focused);
+ const expanded = querySnapshot(snapshot, { expanded: true });
+ const focused = querySnapshot(snapshot, { focused: true });
expect(expanded?.name).to.equal(header.textContent?.trim());
expect(header.expanded).to.be.true;
expect(panel.hasAttribute('expanded')).to.be.true;
@@ -226,11 +226,11 @@ describe('', function() {
/* ATTRIBUTE TESTS */
describe('setting expanded-index attribute', function() {
const indices = '1,2';
- beforeEach(async function() {
+ beforeEach(function() {
element.setAttribute('expanded-index', indices);
- await allUpdates(element);
- await nextFrame();
});
+ beforeEach(() => allUpdates(element));
+ beforeEach(nextFrame);
it('expands the pairs listed in the expanded-index attribute', function() {
for (const idx of indices.split(',').map(x => parseInt(x))) {
@@ -245,7 +245,7 @@ describe('', function() {
});
describe('dynamically adding pairs', function() {
- beforeEach(async function() {
+ beforeEach(function() {
const newHeader = document.createElement('pf-accordion-header');
newHeader.id = 'newHeader';
newHeader.innerHTML = `New Header `;
@@ -256,10 +256,10 @@ describe('', function() {
element.appendChild(newHeader);
element.appendChild(newPanel);
-
- await allUpdates(element);
});
+ beforeEach(() => allUpdates(element));
+
it('properly initializes new pairs', function() {
const newHeader = headers.at(-1);
const newPanel = panels.at(-1);
@@ -330,23 +330,22 @@ describe('', function() {
afterEach(async function() {
[header1, header2, header3] = [] as PfAccordionHeader[];
[panel1, panel2, panel3] = [] as PfAccordionPanel[];
+ await fixture('');
});
- describe('with all panels closed', function() {
- it('applies hidden attribute to all panels', function() {
- expect(panel1.hidden, 'panel1').to.be.true;
- expect(panel2.hidden, 'panel2').to.be.true;
- expect(panel3.hidden, 'panel3').to.be.true;
- });
+ it('applies hidden attribute to all panels', function() {
+ expect(panel1.hidden, 'panel1').to.be.true;
+ expect(panel2.hidden, 'panel2').to.be.true;
+ expect(panel3.hidden, 'panel3').to.be.true;
});
- describe('with all panels open', function() {
+ describe('clicking every header', function() {
beforeEach(async function() {
for (const header of element.querySelectorAll('pf-accordion-header')) {
await clickElementAtCenter(header);
}
- await nextFrame();
});
+ beforeEach(nextFrame);
it('removes hidden attribute from all panels', function() {
expect(panel1.hidden, 'panel1').to.be.false;
expect(panel2.hidden, 'panel2').to.be.false;
@@ -354,7 +353,7 @@ describe('', function() {
});
});
- describe('when focus is on the first header', function() {
+ describe('calling focus() on the first header', function() {
beforeEach(function() {
header1.focus();
});
@@ -391,22 +390,22 @@ describe('', function() {
describe('Tab', function() {
beforeEach(press('Tab'));
- it('moves focus to the body', function() {
- expect(document.activeElement).to.equal(document.body);
+ it('blurs out of the accordion', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(document.body);
});
});
describe('Shift+Tab', function() {
beforeEach(press('Shift+Tab'));
- it('moves focus to the body', function() {
- expect(document.activeElement).to.equal(document.body);
+ it('blurs out of the accordion', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(document.body);
});
});
describe('ArrowDown', function() {
beforeEach(press('ArrowDown'));
- it('moves focus to the second header', function() {
- expect(document.activeElement).to.equal(header2);
+ it('moves focus to the second header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header2);
});
it('does not open panels', function() {
expect(panel1.expanded).to.be.false;
@@ -417,8 +416,8 @@ describe('', function() {
describe('ArrowUp', function() {
beforeEach(press('ArrowUp'));
- it('moves focus to the last header', function() {
- expect(document.activeElement).to.equal(header3);
+ it('moves focus to the last header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header3);
});
it('does not open panels', function() {
expect(panel1.expanded).to.be.false;
@@ -429,8 +428,8 @@ describe('', function() {
describe('Home', function() {
beforeEach(press('Home'));
- it('moves focus to the first header', function() {
- expect(document.activeElement).to.equal(header1);
+ it('moves focus to the first header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header1);
});
it('does not open panels', function() {
expect(panel1.expanded).to.be.false;
@@ -441,8 +440,8 @@ describe('', function() {
describe('End', function() {
beforeEach(press('End'));
- it('moves focus to the last header', function() {
- expect(document.activeElement).to.equal(header3);
+ it('moves focus to the last header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header3);
});
it('does not open panels', function() {
expect(panel1.expanded).to.be.false;
@@ -452,12 +451,13 @@ describe('', function() {
});
});
- describe('when focus is on the middle header', function() {
- beforeEach(async function() {
+ describe('calling focus() on the middle header', function() {
+ beforeEach(function() {
header2.focus();
- await nextFrame();
});
+ beforeEach(nextFrame);
+
describe('Space', function() {
beforeEach(press(' '));
it('expands the middle panel', function() {
@@ -478,47 +478,48 @@ describe('', function() {
describe('Tab', function() {
beforeEach(press('Tab'));
- it('moves focus to the body', function() {
- expect(document.activeElement).to.equal(document.body);
+ it('moves focus to the body', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(document.body);
});
});
describe('ArrowDown', function() {
beforeEach(press('ArrowDown'));
- it('moves focus to the last header', function() {
- expect(document.activeElement).to.equal(header3);
+ it('moves focus to the last header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header3);
});
});
describe('ArrowUp', function() {
beforeEach(press('ArrowUp'));
- it('moves focus to the first header', function() {
- expect(document.activeElement).to.equal(header1);
+ it('moves focus to the first header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header1);
});
});
describe('Home', function() {
beforeEach(press('Home'));
- it('moves focus to the first header', function() {
- expect(document.activeElement).to.equal(header1);
+ it('moves focus to the first header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header1);
});
});
describe('End', function() {
beforeEach(press('End'));
- it('moves focus to the last header', function() {
- expect(document.activeElement).to.equal(header3);
+ it('moves focus to the last header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header3);
});
});
});
- describe('when focus is on the last header', function() {
- beforeEach(async function() {
+ describe('calling focus() on the last header', function() {
+ beforeEach(function() {
header3.focus();
- await nextFrame();
});
+ beforeEach(nextFrame);
+
describe('Space', function() {
beforeEach(press(' '));
it('expands the last panel', function() {
@@ -555,50 +556,54 @@ describe('', function() {
describe('Shift+Tab', function() {
beforeEach(press('Shift+Tab'));
- it('moves focus to the body', function() {
- expect(document.activeElement).to.equal(document.body);
+ it('moves focus to the body', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(document.body);
});
});
-
describe('ArrowDown', function() {
beforeEach(press('ArrowDown'));
- it('moves focus to the first header', function() {
- expect(document.activeElement).to.equal(header1);
+ it('moves focus to the first header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header1);
});
});
describe('ArrowUp', function() {
beforeEach(press('ArrowUp'));
- it('moves focus to the middle header', function() {
- expect(document.activeElement).to.equal(header2);
+ it('moves focus to the middle header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header2);
});
});
describe('Home', function() {
beforeEach(press('Home'));
- it('moves focus to the first header', function() {
- expect(document.activeElement).to.equal(header1);
+ it('moves focus to the first header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header1);
});
});
describe('End', function() {
beforeEach(press('End'));
- it('moves focus to the last header', function() {
- expect(document.activeElement).to.equal(header3);
+ it('moves focus to the last header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header3);
});
});
});
- describe('when the first panel is expanded', function() {
- beforeEach(async function() {
+ describe('expand(0)', function() {
+ beforeEach(function() {
element.expand(0);
- await aTimeout(500);
});
- describe('and focus is on the first header', function() {
+ beforeEach(nextFrame);
+
+ describe('calling focus() on the first header', function() {
+ beforeEach(function() {
+ header1.focus();
+ });
describe('Space', function() {
beforeEach(press(' '));
+ beforeEach(nextFrame);
it('collapses the first panel', function() {
expect(panel1.expanded).to.be.false;
expect(panel2.expanded).to.be.false;
@@ -617,27 +622,29 @@ describe('', function() {
describe('Tab', function() {
beforeEach(press('Tab'));
- it('moves focus to the link in the first panel', function() {
- expect(document.activeElement).to.equal(panel1.querySelector('a'));
+ it('moves focus to the link in the first panel', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel1.querySelector('a'));
});
+
describe('Tab', function() {
beforeEach(press('Tab'));
- it('moves focus to the body', function() {
- expect(document.activeElement).to.equal(document.body);
+ it('moves focus to the body', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(document.body);
});
describe('Shift+Tab', function() {
beforeEach(press('Shift+Tab'));
- it('keeps focus on the link in the first panel', function() {
- expect(document.activeElement).to.equal(panel1.querySelector('a'));
+ it('keeps focus on the link in the first panel', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel1.querySelector('a'));
});
});
});
+
describe('Space', function() {
beforeEach(press(' '));
describe('ArrowDown', function() {
beforeEach(press('ArrowDown'));
- it('keeps focus on the link in the first panel', function() {
- expect(document.activeElement).to.equal(panel1.querySelector('a'));
+ it('keeps focus on the link in the first panel', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel1.querySelector('a'));
});
it('does not open other panels', function() {
expect(panel1.expanded).to.be.true;
@@ -648,8 +655,8 @@ describe('', function() {
describe('ArrowUp', function() {
beforeEach(press('ArrowUp'));
- it('keeps focus on the link in the first panel', function() {
- expect(document.activeElement).to.equal(panel1.querySelector('a'));
+ it('keeps focus on the link in the first panel', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel1.querySelector('a'));
});
it('does not open other panels', function() {
expect(panel1.expanded).to.be.true;
@@ -661,8 +668,8 @@ describe('', function() {
describe('Home', function() {
beforeEach(press('Home'));
- it('keeps focus on the link in the first panel', function() {
- expect(document.activeElement).to.equal(panel1.querySelector('a'));
+ it('keeps focus on the link in the first panel', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel1.querySelector('a'));
});
it('does not open other panels', function() {
@@ -674,8 +681,8 @@ describe('', function() {
describe('End', function() {
beforeEach(press('End'));
- it('keeps focus on the link in the first panel', function() {
- expect(document.activeElement).to.equal(panel1.querySelector('a'));
+ it('keeps focus on the link in the first panel', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel1.querySelector('a'));
});
it('does not open other panels', function() {
expect(panel1.expanded).to.be.true;
@@ -688,16 +695,17 @@ describe('', function() {
describe('Shift+Tab', function() {
beforeEach(press('Shift+Tab'));
- it('moves focus to the body', function() {
- expect(document.activeElement).to.equal(document.body);
+ it('moves focus to the body', async function() {
+ const snapshot = await a11ySnapshot();
+ expect(querySnapshot(snapshot, { focused: true })).to.not.be.ok;
});
});
describe('ArrowDown', function() {
beforeEach(press('ArrowDown'));
- it('moves focus to the second header', function() {
- expect(document.activeElement).to.equal(header2);
+ it('moves focus to the second header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header2);
});
it('does not open other panels', function() {
@@ -710,8 +718,8 @@ describe('', function() {
describe('ArrowUp', function() {
beforeEach(press('ArrowUp'));
- it('moves focus to the last header', function() {
- expect(document.activeElement).to.equal(header3);
+ it('moves focus to the last header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header3);
});
it('does not open other panels', function() {
@@ -723,8 +731,8 @@ describe('', function() {
describe('Home', function() {
beforeEach(press('Home'));
- it('moves focus to the first header', function() {
- expect(document.activeElement).to.equal(header1);
+ it('moves focus to the first header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header1);
});
it('does not open other panels', function() {
@@ -736,8 +744,8 @@ describe('', function() {
describe('End', function() {
beforeEach(press('End'));
- it('moves focus to the last header', function() {
- expect(document.activeElement).to.equal(header3);
+ it('moves focus to the last header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header3);
});
it('does not open other panels', function() {
expect(panel1.expanded).to.be.true;
@@ -747,23 +755,29 @@ describe('', function() {
});
});
- describe('and focus is on the middle header', function() {
- beforeEach(press('Tab'));
- beforeEach(press('Tab'));
+ describe('calling focus() on the middle header', function() {
+ beforeEach(function() {
+ header2.focus();
+ });
describe('Shift+Tab', function() {
beforeEach(press('Shift+Tab'));
+ beforeEach(nextFrame);
it('moves focus to the link in first panel', async function() {
- expect(document.activeElement).to.equal(panel1.querySelector('a'));
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel1.querySelector('a'));
});
});
});
});
- describe('when the middle panel is expanded', function() {
- beforeEach(async function() {
+ describe('expand(1)', function() {
+ beforeEach(function() {
element.expand(1);
- await nextFrame();
+ });
+
+ beforeEach(nextFrame);
+
+ it('sets the expanded property on the second panel', function() {
expect(panel2.expanded).to.be.true;
});
@@ -773,12 +787,13 @@ describe('', function() {
expect(panel3.hidden, 'panel3').to.be.true;
});
- describe('and focus is on the middle header', function() {
- beforeEach(async function() {
+ describe('calling focus() on the middle header', function() {
+ beforeEach(function() {
header2.focus();
- await nextFrame();
});
+ beforeEach(nextFrame);
+
describe('Space', function() {
beforeEach(press(' '));
it('collapses the second panel', function() {
@@ -799,65 +814,71 @@ describe('', function() {
describe('Tab', function() {
beforeEach(press('Tab'));
- it('moves focus to the link in the second panel', function() {
- expect(document.activeElement).to.equal(panel2.querySelector('a'));
+ it('moves focus to the link in the second panel', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel2.querySelector('a'));
});
});
describe('Shift+Tab', function() {
beforeEach(press('Shift+Tab'));
- it('moves focus to the body', function() {
- expect(document.activeElement).to.equal(document.body);
+ it('moves focus to the body', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(document.body);
});
});
describe('ArrowUp', function() {
beforeEach(press('ArrowUp'));
- it('moves focus to the first header', function() {
- expect(document.activeElement).to.equal(header1);
+ it('moves focus to the first header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header1);
});
});
describe('ArrowDown', function() {
beforeEach(press('ArrowDown'));
- it('moves focus to the last header', function() {
- expect(document.activeElement).to.equal(header3);
+ it('moves focus to the last header', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(header3);
});
});
});
- describe('and focus is on the last header', function() {
- beforeEach(async function() {
+ describe('calling focus() on the last header', function() {
+ beforeEach(function() {
header3.focus();
- await nextFrame();
});
+ beforeEach(nextFrame);
+
describe('Shift+Tab', function() {
beforeEach(press('Shift+Tab'));
- it('moves focus to the link in middle panel', function() {
- expect(document.activeElement).to.equal(panel2.querySelector('a'));
+ it('moves focus to the link in middle panel', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel2.querySelector('a'));
});
});
});
});
- describe('when the last panel is expanded', function() {
- beforeEach(async function() {
+ describe('expand(2)', function() {
+ beforeEach(function() {
element.expand(2);
- await nextFrame();
+ });
+
+ beforeEach(nextFrame);
+
+ it('sets the expanded property on the last panel', function() {
expect(panel3.expanded).to.be.true;
});
- describe('when focus is on the last header', function() {
- beforeEach(async function() {
+ describe('calling focus() is on the last header', function() {
+ beforeEach(function() {
header3.focus();
- await nextFrame();
});
+ beforeEach(nextFrame);
+
describe('Tab', function() {
beforeEach(press('Tab'));
- it('moves focus to the link in last panel', function() {
- expect(document.activeElement).to.equal(panel3.querySelector('a'));
+ it('moves focus to the link in last panel', async function() {
+ expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel3.querySelector('a'));
});
});
});
@@ -867,7 +888,7 @@ describe('', function() {
describe('with single attribute', function() {
beforeEach(async function() {
- element = await createFixture(html`
+ element = await fixture(html`