From ccdaf1dc97c338dfceb9d3afb53ecb3df9a05a12 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 11 Jul 2024 11:01:28 +0300 Subject: [PATCH 001/122] fix(select): display value from attribute --- elements/pf-select/pf-option.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elements/pf-select/pf-option.ts b/elements/pf-select/pf-option.ts index 46ccfe4a13..d97cb6b972 100644 --- a/elements/pf-select/pf-option.ts +++ b/elements/pf-select/pf-option.ts @@ -101,7 +101,7 @@ export class PfOption extends LitElement { - + ${this.value} Date: Thu, 11 Jul 2024 10:59:55 +0300 Subject: [PATCH 002/122] feat(select): typeahead adds `ActivedescendantController` to core --- .../activedescendant-controller.ts | 329 ++++++++++++++++++ .../controllers/roving-tabindex-controller.ts | 10 +- core/pfe-core/package.json | 1 + elements/pf-select/demo/typeahead.html | 15 + elements/pf-select/pf-select.ts | 225 +++++++----- 5 files changed, 489 insertions(+), 91 deletions(-) create mode 100644 core/pfe-core/controllers/activedescendant-controller.ts create mode 100644 elements/pf-select/demo/typeahead.html diff --git a/core/pfe-core/controllers/activedescendant-controller.ts b/core/pfe-core/controllers/activedescendant-controller.ts new file mode 100644 index 0000000000..c7648dfa3f --- /dev/null +++ b/core/pfe-core/controllers/activedescendant-controller.ts @@ -0,0 +1,329 @@ +import type { ListboxAccessibilityController } from './listbox-controller.js'; +import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import { getRandomId } from '../functions/random.js'; + +const isActivatableElement = (el: Element): el is HTMLElement => + !!el + && !el.ariaHidden + && !el.hasAttribute('hidden'); + +export interface ActivedescendantControllerOptions { + getControllingElement: () => HTMLElement | null; + getItems?: () => Item[]; + getItemContainer: () => HTMLElement | null; +} + +/** + * 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 +> implements ReactiveController, ListboxAccessibilityController { + private static hosts = new WeakMap(); + + private static IDLAttrsSupported = 'ariaActiveDescendantElement' in HTMLElement.prototype; + + static of( + host: ReactiveControllerHost, + options: ActivedescendantControllerOptions & { getItems(): Item[] }, + ): ActivedescendantController { + return new ActivedescendantController(host, options); + } + + /** active element */ + #activeItem?: Item; + + /** accessibility container of items */ + #ancestor?: Element; + + /** accessibility controllers of items */ + #controller?: Element; + + /** array of all activatable elements */ + #items: Item[] = []; + + /** + * finds focusable items from a group of items + */ + get #activatableItems(): Item[] { + return this.#items.filter(isActivatableElement); + } + + /** + * index of active item in array of focusable items + */ + get #activeIndex(): number { + return !!this.#activatableItems + && !!this.activeItem ? this.#activatableItems.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.#activatableItems; + } + + /** + * first item in array of focusable items + */ + get firstItem(): Item | undefined { + return this.#activatableItems[0]; + } + + /** + * last item in array of focusable items + */ + get lastItem(): Item | undefined { + return this.#activatableItems.at(-1); + } + + /** + * next item after active item in array of focusable items + */ + get nextItem(): Item | undefined { + return ( + this.#activeIndex >= this.#activatableItems.length - 1 ? this.firstItem + : this.#activatableItems[this.#activeIndex + 1] + ); + } + + /** + * previous item after active item in array of focusable items + */ + get prevItem(): Item | undefined { + return ( + this.#activeIndex > 0 ? this.#activatableItems[this.#activeIndex - 1] + : this.lastItem + ); + } + + #options: ActivedescendantControllerOptions; + + constructor( + public host: ReactiveControllerHost, + options: ActivedescendantControllerOptions, + ) { + this.#options = options; + const instance = ActivedescendantController.hosts.get(host); + if (instance) { + return instance as ActivedescendantController; + } + ActivedescendantController.hosts.set(host, this); + this.host.addController(this); + this.updateItems(); + } + + #liftMO = new MutationObserver(this.#onMutation); + + #liftOptions() { + if (this.#ancestor) { + this.#liftMO.observe(this.#ancestor); + } + } + + #onMutation(/* records: MutationRecord[]*/) { + // todo: copy listbox items to shadowroot + } + + hostUpdated(): void { + const oldContainer = this.#ancestor; + const oldController = this.#controller; + const container = this.#options.getItemContainer(); + const controller = this.#options.getControllingElement(); + if (container && controller && ( + container !== oldContainer + || controller !== oldController)) { + this.#initDOM(container, controller); + } + } + + /** + * removes event listeners from items container + */ + hostDisconnected(): void { + this.#ancestor?.removeEventListener('keydown', this.#onKeydown); + this.#ancestor = undefined; + } + + #initDOM(ancestor: HTMLElement, controller: HTMLElement) { + this.#ancestor = ancestor; + this.#controller = controller; + controller.addEventListener('keydown', this.#onKeydown); + this.updateItems(); + } + + /** + * handles keyboard activation of items + * @param event keydown event + */ + #onKeydown = (event: Event) => { + if (!(event instanceof KeyboardEvent) + || event.ctrlKey + || event.altKey + || event.metaKey + || !this.#activatableItems.length) { + return; + } + + const orientation = this.#options.getControllingElement() + ?.getAttribute('aria-orientation'); + const verticalOnly = orientation === 'vertical'; + const item = this.activeItem; + const horizontalOnly = + !!item + && (item.tagName === 'SELECT' + || item.getAttribute('role') === 'spinbutton' + || orientation === 'horizontal'); + + switch (event.key) { + case 'ArrowLeft': + if (verticalOnly) { + return; + } + this.setActiveItem(this.prevItem); + event.stopPropagation(); + event.preventDefault(); + break; + case 'ArrowUp': + if (horizontalOnly) { + return; + } + this.setActiveItem(this.prevItem); + event.stopPropagation(); + event.preventDefault(); + break; + case 'ArrowRight': + if (verticalOnly) { + return; + } + this.setActiveItem(this.nextItem); + event.stopPropagation(); + event.preventDefault(); + break; + case 'ArrowDown': + if (horizontalOnly) { + return; + } + this.setActiveItem(this.nextItem); + event.stopPropagation(); + event.preventDefault(); + break; + case 'Home': + this.setActiveItem(this.firstItem); + event.stopPropagation(); + event.preventDefault(); + break; + case 'End': + this.setActiveItem(this.lastItem); + event.stopPropagation(); + event.preventDefault(); + break; + default: + break; + } + }; + + /** + * Sets the active item and focuses it + * @param item tabindex item + */ + setActiveItem(item?: Item): void { + this.#activeItem = item; + if (this.#ancestor) { + if (ActivedescendantController.IDLAttrsSupported) { + // @ts-expect-error: waiting on tslib: https://w3c.github.io/aria/#ref-for-dom-ariamixin-ariaactivedescendantelement-1 + this.#controller.ariaActiveDescendantElement = + item; + } else { + for (const el of [this.#ancestor, this.#controller]) { + el?.setAttribute('aria-activedescendant', item?.id ?? ''); + } + } + this.host.requestUpdate(); + } + } + + /** + * Sets the list of items and activates the next activatable item after the current one + * @param items tabindex items + */ + updateItems(items: Item[] = this.#options.getItems?.() ?? []): void { + if (!ActivedescendantController.IDLAttrsSupported) { + for (const item of items) { + item.id ??= getRandomId(item.localName); + } + } + + if (!ActivedescendantController.IDLAttrsSupported) { + this.#liftOptions(); + } + + this.#items = items; + const [first] = this.#activatableItems; + const next = this.#activatableItems.find(((_, i) => i !== this.#itemIndex)); + const activeItem = next ?? first ?? this.firstItem; + this.setActiveItem(activeItem); + } +} diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index afaa2fcaa3..8e9a7fb29d 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -1,5 +1,6 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit'; import type { RequireProps } from '../core.js'; +import type { ListboxAccessibilityController } from './listbox-controller.js'; const isFocusableElement = (el: Element): el is HTMLElement => !!el @@ -22,9 +23,12 @@ export interface RovingTabindexControllerOptions { */ export class RovingTabindexController< Item extends HTMLElement = HTMLElement -> implements ReactiveController { +> implements ReactiveController, ListboxAccessibilityController { private static hosts = new WeakMap(); + private static elements: WeakMap> = + new WeakMap(); + static of( host: ReactiveControllerHost, options: RovingTabindexControllerOptions & { getItems(): Item[] }, @@ -32,10 +36,6 @@ export class RovingTabindexController< return new RovingTabindexController(host, options); } - /** @internal */ - static elements: WeakMap> = - new WeakMap(); - /** active focusable element */ #activeItem?: Item; diff --git a/core/pfe-core/package.json b/core/pfe-core/package.json index 67706c9ef9..1cdee0b671 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", diff --git a/elements/pf-select/demo/typeahead.html b/elements/pf-select/demo/typeahead.html new file mode 100644 index 0000000000..c37f55a28f --- /dev/null +++ b/elements/pf-select/demo/typeahead.html @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/elements/pf-select/pf-select.ts b/elements/pf-select/pf-select.ts index 817327facc..0ed47b7a23 100644 --- a/elements/pf-select/pf-select.ts +++ b/elements/pf-select/pf-select.ts @@ -1,4 +1,8 @@ -import { LitElement, html, isServer, type PropertyValues, type TemplateResult } from 'lit'; +import type { PfChipGroup } from '../pf-chip/pf-chip-group.js'; +import type { Placement } from '@patternfly/pfe-core/controllers/floating-dom-controller.js'; +import type { PropertyValues, TemplateResult } from 'lit'; + +import { LitElement, html, isServer } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; import { query } from 'lit/decorators/query.js'; @@ -8,17 +12,16 @@ import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { ListboxController } from '@patternfly/pfe-core/controllers/listbox-controller.js'; +import { ActivedescendantController } from '@patternfly/pfe-core/controllers/activedescendant-controller.js'; import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; +import { SlotController } from '@patternfly/pfe-core/controllers/slot-controller.js'; import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; -import { - FloatingDOMController, - type Placement, -} from '@patternfly/pfe-core/controllers/floating-dom-controller.js'; +import { FloatingDOMController } from '@patternfly/pfe-core/controllers/floating-dom-controller.js'; import { PfOption } from './pf-option.js'; +import { PfChipRemoveEvent } from '../pf-chip/pf-chip.js'; import styles from './pf-select.css'; -import { SlotController } from '@patternfly/pfe-core/controllers/slot-controller.js'; export interface PfSelectUserOptions { id: string; @@ -31,11 +34,6 @@ export class PfSelectChangeEvent extends Event { } } -// NOTE: this file contains numerous // comments, which ordinarily would be deleted -// They are here to save the work already done on typeahead, which has a much more complex -// accessibility model, and which is planned for the next release -// * @fires filter - when the filter value changes. used to perform custom filtering - /** * A select list enables users to select one or more items from a list. * @@ -46,6 +44,7 @@ export class PfSelectChangeEvent extends Event { * @slot placeholder - placeholder text for the select. Overrides the `placeholder` attribute. * @fires open - when the menu toggles open * @fires close - when the menu toggles closed + * @fires filter - when the filter value changes. used to perform custom filtering */ @customElement('pf-select') export class PfSelect extends LitElement { @@ -66,10 +65,10 @@ export class PfSelect extends LitElement { #slots = new SlotController(this, null, 'placeholder'); - #listbox?: ListboxController; /* | ListboxActiveDescendantController */ + #listbox?: ListboxController; /** Variant of rendered Select */ - @property() variant: 'single' | 'checkbox' /* | 'typeahead' | 'typeaheadmulti' */ = 'single'; + @property() variant: 'single' | 'checkbox' | 'typeahead' | 'typeaheadmulti' = 'single'; /** * Accessible label for the select @@ -124,14 +123,16 @@ export class PfSelect extends LitElement { type: Boolean, }) checkboxSelectionBadgeHidden = false; - // @property({ attribute: false }) customFilter?: (option: PfOption) => boolean; + @property({ attribute: false }) filter?: (option: PfOption) => boolean; /** * Single select option value for single select menus, * or array of select option values for multi select. */ set selected(optionsList: PfOption | PfOption[]) { + this.#lastSelected = this.selected; this.#listbox?.setValue(optionsList); + this.requestUpdate('selected', this.#lastSelected); } get selected(): PfOption | PfOption[] | undefined { @@ -155,18 +156,14 @@ export class PfSelect extends LitElement { } } - // @query('pf-chip-group') private _chipGroup?: PfChipGroup; + @query('pf-chip-group') private _chipGroup?: PfChipGroup; - // @query('#toggle-input') private _input?: HTMLInputElement; + @query('#toggle-input') private _input?: HTMLInputElement; @query('#toggle-button') private _toggle?: HTMLButtonElement; #lastSelected = this.selected; - get #listboxElement() { - return this.shadowRoot?.getElementById('listbox') ?? null; - } - /** * whether select has badge for number of selected items */ @@ -177,9 +174,8 @@ export class PfSelect extends LitElement { get #buttonLabel() { switch (this.variant) { - // TODO: implement typeaheadmulti with ActiveDescendantController - // case 'typeaheadmulti': - // return `${this.#listbox?.selectedOptions?.length ?? 0} ${this.itemsSelectedText}` + case 'typeaheadmulti': + return `${this.#listbox?.selectedOptions?.length ?? 0} ${this.itemsSelectedText}`; case 'checkbox': return this.#listbox ?.selectedOptions @@ -202,6 +198,9 @@ export class PfSelect extends LitElement { if (changed.has('variant')) { this.#variantChanged(); } + if (changed.has('selected')) { + this.#selectedChanged(changed.get('selected'), this.selected); + } if (changed.has('value')) { this.#internals.setFormValue(this.value ?? ''); } @@ -209,9 +208,6 @@ export class PfSelect extends LitElement { this.#listbox!.disabled = this.disabled; } // TODO: handle filtering in the element, not the controller - // if (changed.has('filter')) { - // this.#listbox.filter = this.filter; - // } } override render(): TemplateResult<1> { @@ -248,23 +244,28 @@ export class PfSelect extends LitElement { `} ${!typeahead ? '' : /* TODO: aria attrs */ html` this.#listboxElement; + const getItems = () => this.options; + const getHTMLElement = () => this.shadowRoot?.getElementById('listbox') ?? null; + const isSelected = (option: PfOption) => option.selected; + const requestSelect = (option: PfOption, selected: boolean) => { + this.selected = option; + option.selected = !option.disabled && !!selected; + return option.selected; + }; switch (this.variant) { - // TODO - // case 'typeahead': - // case 'typeaheadmulti': - // this.#controller = new ListboxController.of(this, { - // multi: this.variant==='typeaheadmulti', - // a11yController: ActiveDescendantController.of(this) - // }); - // break; + case 'typeahead': + case 'typeaheadmulti': + return this.#listbox = ListboxController.of(this, { + a11yController: ActivedescendantController.of(this, { + getItems, + getControllingElement: () => + this.shadowRoot?.getElementById('toggle-input') ?? null, + getItemContainer: () => this.shadowRoot?.getElementById('listbox') ?? null, + }), + multi: this.variant === 'typeaheadmulti', + getHTMLElement, + isSelected, + requestSelect, + }); default: - this.#listbox = ListboxController.of(this, { + return this.#listbox = ListboxController.of(this, { + a11yController: RovingTabindexController.of(this, { getHTMLElement, getItems }), multi: this.variant === 'checkbox', getHTMLElement, - isSelected: option => option.selected, - requestSelect: (option, selected) => { - this.#lastSelected = this.selected; - option.selected = !option.disabled && !!selected; - this.#selectedChanged(); - return true; - }, - a11yController: RovingTabindexController.of(this, { - getHTMLElement, - getItems: () => this.options, - }), + isSelected, + requestSelect, }); - break; } } @@ -369,17 +372,28 @@ export class PfSelect extends LitElement { this.dispatchEvent(new Event(will)); if (this.expanded) { await this.#float.show({ placement: this.position || 'bottom', flip: !!this.enableFlip }); - const focusableItem = this.#listbox?.activeItem ?? this.#listbox?.nextItem; - focusableItem?.focus(); + switch (this.variant) { + case 'single': + case 'checkbox': { + const focusableItem = this.#listbox?.activeItem ?? this.#listbox?.nextItem; + focusableItem?.focus(); + } + } } else if (this.#lastSelected === this.selected) { await this.#float.hide(); - this._toggle?.focus(); + switch (this.variant) { + case 'single': + case 'checkbox': + this._toggle?.focus(); + } } } - async #selectedChanged() { - await this.updateComplete; - this.value = [this.selected] + async #selectedChanged( + _?: PfOption | PfOption[], + selected?: PfOption | PfOption[], + ) { + this.value = [selected] .flat() .filter(x => !!x) .map(x => x!.value) @@ -388,14 +402,23 @@ export class PfSelect extends LitElement { case 'single': this.hide(); this._toggle?.focus(); + break; + case 'typeahead': + this._input!.value = this.value; + this.requestUpdate(); } + await this.updateComplete; } #onListboxKeydown(event: KeyboardEvent) { - switch (event.key) { - case 'Escape': - this.hide(); - this._toggle?.focus(); + switch (this.variant) { + case 'single': + case 'checkbox': + switch (event.key) { + case 'Escape': + this.hide(); + this._toggle?.focus(); + } } } @@ -414,14 +437,44 @@ export class PfSelect extends LitElement { } } + #onKeydownMenu(event: KeyboardEvent) { + switch (event.key) { + case 'ArrowDown': + case 'ArrowUp': + this.show(); + } + } + + async #onKeydownTypeahead(event: KeyboardEvent) { + switch (event.key) { + case 'ArrowDown': + case 'ArrowUp': + // TODO: per www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list + // alt + Down Arrow should Open the listbox without moving focus of changing selection + await this.show(); + // TODO: thread the needle of passing state between controllers + await new Promise(r => setTimeout(r)); + this._input!.value = this.#listbox?.activeItem?.value ?? ''; + break; + case 'Enter': + this.hide(); + break; + case 'Escape': + if (this.expanded) { + this.hide(); + } else { + this._input!.value = ''; + this.requestUpdate(); + } + } + } + #onButtonKeydown(event: KeyboardEvent) { switch (this.variant) { case 'single': - case 'checkbox': - switch (event.key) { - case 'ArrowDown': - this.show(); - } + case 'checkbox': return this.#onKeydownMenu(event); + case 'typeahead': + case 'typeaheadmulti': return this.#onKeydownTypeahead(event); } } @@ -439,10 +492,10 @@ export class PfSelect extends LitElement { * @param opt pf-option */ #onChipRemove(opt: PfOption, event: Event) { - // if (event.chip) { - // opt.selected = false; - // this._input?.focus(); - // } + if (event instanceof PfChipRemoveEvent) { + opt.selected = false; + this._input?.focus(); + } } /** @@ -450,10 +503,10 @@ export class PfSelect extends LitElement { */ #onTypeaheadInput() { // update filter - // if (this.filter !== this._input?.value) { - // this.filter = this._input?.value || ''; - // this.show(); - // } + if (this.filter !== this._input?.value) { + this.filter = option => option.value.includes(this._input?.value ?? ''); + this.show(); + } // TODO: handle hiding && aria hiding options } From 32f312ba61edbe572c2e7f11d05a4fba0c6a1ebb Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Wed, 17 Jul 2024 18:00:22 +0300 Subject: [PATCH 003/122] fix: wip activedescendantcontroller clone nodes --- .../activedescendant-controller.ts | 56 +++++++++---------- .../controllers/listbox-controller.ts | 12 +++- elements/pf-select/pf-select.ts | 35 +++++++----- 3 files changed, 57 insertions(+), 46 deletions(-) diff --git a/core/pfe-core/controllers/activedescendant-controller.ts b/core/pfe-core/controllers/activedescendant-controller.ts index c7648dfa3f..e5a7428d22 100644 --- a/core/pfe-core/controllers/activedescendant-controller.ts +++ b/core/pfe-core/controllers/activedescendant-controller.ts @@ -1,5 +1,7 @@ import type { ListboxAccessibilityController } from './listbox-controller.js'; import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +import { nothing } from 'lit'; import { getRandomId } from '../functions/random.js'; const isActivatableElement = (el: Element): el is HTMLElement => @@ -63,6 +65,10 @@ export class ActivedescendantController< private static IDLAttrsSupported = 'ariaActiveDescendantElement' in HTMLElement.prototype; + public static canControlLightDom(): boolean { + return this.IDLAttrsSupported; + } + static of( host: ReactiveControllerHost, options: ActivedescendantControllerOptions & { getItems(): Item[] }, @@ -74,10 +80,10 @@ export class ActivedescendantController< #activeItem?: Item; /** accessibility container of items */ - #ancestor?: Element; + #a11yContainerElement?: Element; /** accessibility controllers of items */ - #controller?: Element; + #a11yControllerElement?: Element; /** array of all activatable elements */ #items: Item[] = []; @@ -175,21 +181,21 @@ export class ActivedescendantController< this.updateItems(); } - #liftMO = new MutationObserver(this.#onMutation); - - #liftOptions() { - if (this.#ancestor) { - this.#liftMO.observe(this.#ancestor); + public render(): typeof nothing | Node[] { + if (ActivedescendantController.canControlLightDom()) { + return nothing; + } else { + return this.items.map(item => { + const node = item.cloneNode(true) as Element; + node.id ??= getRandomId(item.localName); + return node; + }); } } - #onMutation(/* records: MutationRecord[]*/) { - // todo: copy listbox items to shadowroot - } - hostUpdated(): void { - const oldContainer = this.#ancestor; - const oldController = this.#controller; + const oldContainer = this.#a11yContainerElement; + const oldController = this.#a11yControllerElement; const container = this.#options.getItemContainer(); const controller = this.#options.getControllingElement(); if (container && controller && ( @@ -203,13 +209,13 @@ export class ActivedescendantController< * removes event listeners from items container */ hostDisconnected(): void { - this.#ancestor?.removeEventListener('keydown', this.#onKeydown); - this.#ancestor = undefined; + this.#a11yContainerElement?.removeEventListener('keydown', this.#onKeydown); + this.#a11yContainerElement = undefined; } #initDOM(ancestor: HTMLElement, controller: HTMLElement) { - this.#ancestor = ancestor; - this.#controller = controller; + this.#a11yContainerElement = ancestor; + this.#a11yControllerElement = controller; controller.addEventListener('keydown', this.#onKeydown); this.updateItems(); } @@ -291,13 +297,13 @@ export class ActivedescendantController< */ setActiveItem(item?: Item): void { this.#activeItem = item; - if (this.#ancestor) { + if (this.#a11yContainerElement) { if (ActivedescendantController.IDLAttrsSupported) { // @ts-expect-error: waiting on tslib: https://w3c.github.io/aria/#ref-for-dom-ariamixin-ariaactivedescendantelement-1 - this.#controller.ariaActiveDescendantElement = + this.#a11yControllerElement.ariaActiveDescendantElement = item; } else { - for (const el of [this.#ancestor, this.#controller]) { + for (const el of [this.#a11yContainerElement, this.#a11yControllerElement]) { el?.setAttribute('aria-activedescendant', item?.id ?? ''); } } @@ -310,16 +316,6 @@ export class ActivedescendantController< * @param items tabindex items */ updateItems(items: Item[] = this.#options.getItems?.() ?? []): void { - if (!ActivedescendantController.IDLAttrsSupported) { - for (const item of items) { - item.id ??= getRandomId(item.localName); - } - } - - if (!ActivedescendantController.IDLAttrsSupported) { - this.#liftOptions(); - } - this.#items = items; const [first] = this.#activatableItems; const next = this.#activatableItems.find(((_, i) => i !== this.#itemIndex)); diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index b89efe79d9..39914c4879 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -1,4 +1,6 @@ -import { isServer, type ReactiveController, type ReactiveControllerHost } from 'lit'; +import type { LitElement, ReactiveController, ReactiveControllerHost } from 'lit'; + +import { isServer, nothing } from 'lit'; export interface ListboxAccessibilityController< Item extends HTMLElement @@ -11,8 +13,11 @@ export interface ListboxAccessibilityController< lastItem?: Item; updateItems(items: Item[]): void; setActiveItem(item: Item): void; + render?(): LitRenderable; } +type LitRenderable = ReturnType; + /** * Filtering, multiselect, and orientation options for listbox */ @@ -326,6 +331,11 @@ export class ListboxController implements ReactiveCont } } + public render(): LitRenderable { + const { a11yController } = this._options; + return a11yController.render?.() ?? nothing; + } + /** * sets the listbox value based on selected options * @param value item or items diff --git a/elements/pf-select/pf-select.ts b/elements/pf-select/pf-select.ts index 0ed47b7a23..ba3826cd93 100644 --- a/elements/pf-select/pf-select.ts +++ b/elements/pf-select/pf-select.ts @@ -11,7 +11,10 @@ import { styleMap } from 'lit/directives/style-map.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { ListboxController } from '@patternfly/pfe-core/controllers/listbox-controller.js'; +import { + type ListboxAccessibilityController, + ListboxController, +} from '@patternfly/pfe-core/controllers/listbox-controller.js'; import { ActivedescendantController } from '@patternfly/pfe-core/controllers/activedescendant-controller.js'; import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; import { SlotController } from '@patternfly/pfe-core/controllers/slot-controller.js'; @@ -297,7 +300,9 @@ export class PfSelect extends LitElement { ?hidden="${!this.placeholder && !this.#slots.hasSlotted('placeholder')}"> ${this.placeholder} - + ${this.#listbox?.render()} + @@ -331,34 +336,34 @@ export class PfSelect extends LitElement { // TODO: don't do filtering in the controller } + #a11yController?: ListboxAccessibilityController; + #variantChanged() { this.#listbox?.hostDisconnected(); const getItems = () => this.options; const getHTMLElement = () => this.shadowRoot?.getElementById('listbox') ?? null; const isSelected = (option: PfOption) => option.selected; - const requestSelect = (option: PfOption, selected: boolean) => { - this.selected = option; + const requestSelect = (option: PfOption, selected: boolean) => option.selected = !option.disabled && !!selected; - return option.selected; - }; switch (this.variant) { case 'typeahead': - case 'typeaheadmulti': + case 'typeaheadmulti': { + this.#a11yController = ActivedescendantController.of(this, { + getItems, + getControllingElement: () => this.shadowRoot?.getElementById('toggle-input') ?? null, + getItemContainer: () => this.shadowRoot?.getElementById('listbox') ?? null, + }); return this.#listbox = ListboxController.of(this, { - a11yController: ActivedescendantController.of(this, { - getItems, - getControllingElement: () => - this.shadowRoot?.getElementById('toggle-input') ?? null, - getItemContainer: () => this.shadowRoot?.getElementById('listbox') ?? null, - }), + a11yController: this.#a11yController, multi: this.variant === 'typeaheadmulti', getHTMLElement, isSelected, requestSelect, }); - default: + } default: + this.#a11yController = RovingTabindexController.of(this, { getHTMLElement, getItems }); return this.#listbox = ListboxController.of(this, { - a11yController: RovingTabindexController.of(this, { getHTMLElement, getItems }), + a11yController: this.#a11yController, multi: this.variant === 'checkbox', getHTMLElement, isSelected, From 30a2c8292a36097860e5591cc156e639e3f37427 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Wed, 17 Jul 2024 18:48:20 +0300 Subject: [PATCH 004/122] fix(select): slightly less janky clonenode path --- .../activedescendant-controller.ts | 42 +++++++++++++------ elements/pf-select/pf-option.ts | 6 --- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/core/pfe-core/controllers/activedescendant-controller.ts b/core/pfe-core/controllers/activedescendant-controller.ts index e5a7428d22..ebef0da0db 100644 --- a/core/pfe-core/controllers/activedescendant-controller.ts +++ b/core/pfe-core/controllers/activedescendant-controller.ts @@ -92,7 +92,7 @@ export class ActivedescendantController< * finds focusable items from a group of items */ get #activatableItems(): Item[] { - return this.#items.filter(isActivatableElement); + return this.items.filter(isActivatableElement); } /** @@ -107,7 +107,7 @@ export class ActivedescendantController< * index of active item in array of items */ get #itemIndex(): number { - return this.activeItem ? this.#items.indexOf(this.activeItem) : -1; + return this.activeItem ? this.items.indexOf(this.activeItem) : -1; } /** @@ -167,6 +167,8 @@ export class ActivedescendantController< #options: ActivedescendantControllerOptions; + #cloneMap = new WeakMap(); + constructor( public host: ReactiveControllerHost, options: ActivedescendantControllerOptions, @@ -185,11 +187,7 @@ export class ActivedescendantController< if (ActivedescendantController.canControlLightDom()) { return nothing; } else { - return this.items.map(item => { - const node = item.cloneNode(true) as Element; - node.id ??= getRandomId(item.localName); - return node; - }); + return this.items; } } @@ -297,17 +295,19 @@ export class ActivedescendantController< */ setActiveItem(item?: Item): void { this.#activeItem = item; - if (this.#a11yContainerElement) { + this.#applyAriaRelationship(item); + this.host.requestUpdate(); + } + + #applyAriaRelationship(item?: Item) { + if (this.#a11yContainerElement && this.#a11yControllerElement) { if (ActivedescendantController.IDLAttrsSupported) { - // @ts-expect-error: waiting on tslib: https://w3c.github.io/aria/#ref-for-dom-ariamixin-ariaactivedescendantelement-1 - this.#a11yControllerElement.ariaActiveDescendantElement = - item; + this.#a11yControllerElement.ariaActiveDescendantElement = item; } else { for (const el of [this.#a11yContainerElement, this.#a11yControllerElement]) { el?.setAttribute('aria-activedescendant', item?.id ?? ''); } } - this.host.requestUpdate(); } } @@ -316,10 +316,26 @@ export class ActivedescendantController< * @param items tabindex items */ updateItems(items: Item[] = this.#options.getItems?.() ?? []): void { - this.#items = items; + this.#items = ActivedescendantController.IDLAttrsSupported ? items : items.map(item => { + if (item.id && this.#a11yContainerElement?.querySelector(`#${item.id}`)) { + return nothing; + } else { + const clone = item.cloneNode(true) as Item; + this.#cloneMap.set(item, clone); + clone.id = getRandomId(); + return clone; + } + }).filter(x => x !== nothing); const [first] = this.#activatableItems; const next = this.#activatableItems.find(((_, i) => i !== this.#itemIndex)); const activeItem = next ?? first ?? this.firstItem; this.setActiveItem(activeItem); } } + +declare global { + interface ARIAMixin { + /** @see https://w3c.github.io/aria/#ref-for-dom-ariamixin-ariaactivedescendantelement-1 */ + ariaActiveDescendantElement?: Element; + } +} diff --git a/elements/pf-select/pf-option.ts b/elements/pf-select/pf-option.ts index d97cb6b972..248d48b883 100644 --- a/elements/pf-select/pf-option.ts +++ b/elements/pf-select/pf-option.ts @@ -4,7 +4,6 @@ import { queryAssignedNodes } from 'lit/decorators/query-assigned-nodes.js'; import { property } from 'lit/decorators/property.js'; import { classMap } from 'lit/directives/class-map.js'; -import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; import styles from './pf-option.css'; @@ -83,11 +82,6 @@ export class PfOption extends LitElement { #internals = InternalsController.of(this, { role: 'option' }); - override connectedCallback(): void { - super.connectedCallback(); - this.id ||= getRandomId(); - } - render(): TemplateResult<1> { const { disabled, active } = this; return html` From 2fdb4bf96faef6acbf6640eefc03dff9dc0d297a Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Wed, 17 Jul 2024 20:28:54 +0300 Subject: [PATCH 005/122] refactor: iterative improvements --- .../controllers/activedescendant-controller.ts | 16 +++++----------- .../pfe-core/controllers/internals-controller.ts | 12 ++++++++++-- core/pfe-core/controllers/listbox-controller.ts | 2 +- elements/pf-select/pf-select.ts | 8 ++++++-- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/core/pfe-core/controllers/activedescendant-controller.ts b/core/pfe-core/controllers/activedescendant-controller.ts index ebef0da0db..36838b7769 100644 --- a/core/pfe-core/controllers/activedescendant-controller.ts +++ b/core/pfe-core/controllers/activedescendant-controller.ts @@ -168,6 +168,7 @@ export class ActivedescendantController< #options: ActivedescendantControllerOptions; #cloneMap = new WeakMap(); + #noCloneSet = new WeakSet(); constructor( public host: ReactiveControllerHost, @@ -187,7 +188,7 @@ export class ActivedescendantController< if (ActivedescendantController.canControlLightDom()) { return nothing; } else { - return this.items; + return this.items.filter(x => !this.#noCloneSet.has(x)); } } @@ -302,7 +303,7 @@ export class ActivedescendantController< #applyAriaRelationship(item?: Item) { if (this.#a11yContainerElement && this.#a11yControllerElement) { if (ActivedescendantController.IDLAttrsSupported) { - this.#a11yControllerElement.ariaActiveDescendantElement = item; + this.#a11yControllerElement.ariaActiveDescendantElement = item ?? null; } else { for (const el of [this.#a11yContainerElement, this.#a11yControllerElement]) { el?.setAttribute('aria-activedescendant', item?.id ?? ''); @@ -318,24 +319,17 @@ export class ActivedescendantController< updateItems(items: Item[] = this.#options.getItems?.() ?? []): void { this.#items = ActivedescendantController.IDLAttrsSupported ? items : items.map(item => { if (item.id && this.#a11yContainerElement?.querySelector(`#${item.id}`)) { - return nothing; + this.#noCloneSet.add(item); } else { const clone = item.cloneNode(true) as Item; this.#cloneMap.set(item, clone); clone.id = getRandomId(); return clone; } - }).filter(x => x !== nothing); + }).filter(x => !!x); const [first] = this.#activatableItems; const next = this.#activatableItems.find(((_, i) => i !== this.#itemIndex)); const activeItem = next ?? first ?? this.firstItem; this.setActiveItem(activeItem); } } - -declare global { - interface ARIAMixin { - /** @see https://w3c.github.io/aria/#ref-for-dom-ariamixin-ariaactivedescendantelement-1 */ - ariaActiveDescendantElement?: Element; - } -} diff --git a/core/pfe-core/controllers/internals-controller.ts b/core/pfe-core/controllers/internals-controller.ts index 1636f91320..cfe83718ce 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(); }, @@ -250,7 +251,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 +282,10 @@ export class InternalsController implements ReactiveController, ARIAMixin { this.internals.form?.reset(); } } + +declare global { + interface ARIAMixin { + /** @see https://w3c.github.io/aria/#ref-for-dom-ariamixin-ariaactivedescendantelement-1 */ + ariaActiveDescendantElement: Element | null; + } +} diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index 39914c4879..97c6c488d8 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -140,7 +140,7 @@ export class ListboxController implements ReactiveCont if (this._options.a11yController.activeItem === option) { option.setAttribute('aria-selected', 'true'); } else { - option.removeAttribute('aria-selected'); + option?.removeAttribute('aria-selected'); } } } diff --git a/elements/pf-select/pf-select.ts b/elements/pf-select/pf-select.ts index ba3826cd93..dd5837725a 100644 --- a/elements/pf-select/pf-select.ts +++ b/elements/pf-select/pf-select.ts @@ -343,8 +343,13 @@ export class PfSelect extends LitElement { const getItems = () => this.options; const getHTMLElement = () => this.shadowRoot?.getElementById('listbox') ?? null; const isSelected = (option: PfOption) => option.selected; - const requestSelect = (option: PfOption, selected: boolean) => + const requestSelect = (option: PfOption, selected: boolean) => { option.selected = !option.disabled && !!selected; + if (selected) { + this.selected = option; + } + return selected; + }; switch (this.variant) { case 'typeahead': case 'typeaheadmulti': { @@ -410,7 +415,6 @@ export class PfSelect extends LitElement { break; case 'typeahead': this._input!.value = this.value; - this.requestUpdate(); } await this.updateComplete; } From a3a670e62e0da68fd47d51dde59e1e16fa4f25b3 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 18 Jul 2024 18:22:25 +0300 Subject: [PATCH 006/122] fix(core)!: a11y controller options --- .changeset/a11y-controller-opts.md | 6 + .../activedescendant-controller.ts | 194 ++++++++++-------- .../controllers/listbox-controller.ts | 103 +++++----- .../controllers/roving-tabindex-controller.ts | 167 +++++++-------- 4 files changed, 235 insertions(+), 235 deletions(-) create mode 100644 .changeset/a11y-controller-opts.md 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/core/pfe-core/controllers/activedescendant-controller.ts b/core/pfe-core/controllers/activedescendant-controller.ts index 36838b7769..750ef9e99b 100644 --- a/core/pfe-core/controllers/activedescendant-controller.ts +++ b/core/pfe-core/controllers/activedescendant-controller.ts @@ -10,9 +10,11 @@ const isActivatableElement = (el: Element): el is HTMLElement => && !el.hasAttribute('hidden'); export interface ActivedescendantControllerOptions { - getControllingElement: () => HTMLElement | null; - getItems?: () => Item[]; - getItemContainer: () => HTMLElement | null; + getOwningElement: () => HTMLElement | null; + getItems: () => Item[]; + getItemsContainer?: () => HTMLElement | null; + // todo: maybe this needs to be "horizontal"| "vertical" | "undefined" | "grid" + getOrientation?(): 'horizontal' | 'vertical' | 'undefined'; } /** @@ -61,7 +63,8 @@ export interface ActivedescendantControllerOptions { export class ActivedescendantController< Item extends HTMLElement = HTMLElement > implements ReactiveController, ListboxAccessibilityController { - private static hosts = new WeakMap(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private static hosts = new WeakMap>(); private static IDLAttrsSupported = 'ariaActiveDescendantElement' in HTMLElement.prototype; @@ -71,7 +74,7 @@ export class ActivedescendantController< static of( host: ReactiveControllerHost, - options: ActivedescendantControllerOptions & { getItems(): Item[] }, + options: ActivedescendantControllerOptions, ): ActivedescendantController { return new ActivedescendantController(host, options); } @@ -80,10 +83,10 @@ export class ActivedescendantController< #activeItem?: Item; /** accessibility container of items */ - #a11yContainerElement?: Element; + #itemsContainerElement?: Element; /** accessibility controllers of items */ - #a11yControllerElement?: Element; + #ownerElement?: Element; /** array of all activatable elements */ #items: Item[] = []; @@ -135,7 +138,7 @@ export class ActivedescendantController< * first item in array of focusable items */ get firstItem(): Item | undefined { - return this.#activatableItems[0]; + return this.#activatableItems.at(0); } /** @@ -165,38 +168,34 @@ export class ActivedescendantController< ); } - #options: ActivedescendantControllerOptions; + #options: Required>; #cloneMap = new WeakMap(); #noCloneSet = new WeakSet(); - constructor( + private constructor( public host: ReactiveControllerHost, options: ActivedescendantControllerOptions, ) { - this.#options = options; + this.#options = options as Required>; + this.#options.getItemsContainer ??= () => host instanceof HTMLElement ? host : null; + this.#options.getOrientation ??= () => + this.#itemsContainerElement?.getAttribute('aria-orientation') as + 'horizontal' | 'vertical' | 'undefined'; const instance = ActivedescendantController.hosts.get(host); if (instance) { - return instance as ActivedescendantController; + return instance as unknown as ActivedescendantController; } ActivedescendantController.hosts.set(host, this); this.host.addController(this); this.updateItems(); } - public render(): typeof nothing | Node[] { - if (ActivedescendantController.canControlLightDom()) { - return nothing; - } else { - return this.items.filter(x => !this.#noCloneSet.has(x)); - } - } - hostUpdated(): void { - const oldContainer = this.#a11yContainerElement; - const oldController = this.#a11yControllerElement; - const container = this.#options.getItemContainer(); - const controller = this.#options.getControllingElement(); + const oldContainer = this.#itemsContainerElement; + const oldController = this.#ownerElement; + const container = this.#options.getItemsContainer(); + const controller = this.#options.getOwningElement(); if (container && controller && ( container !== oldContainer || controller !== oldController)) { @@ -208,17 +207,79 @@ export class ActivedescendantController< * removes event listeners from items container */ hostDisconnected(): void { - this.#a11yContainerElement?.removeEventListener('keydown', this.#onKeydown); - this.#a11yContainerElement = undefined; + this.#itemsContainerElement?.removeEventListener('keydown', this.#onKeydown); + this.#itemsContainerElement = undefined; + } + + public renderItemsToShadowRoot(): typeof nothing | Node[] { + if (ActivedescendantController.canControlLightDom()) { + return nothing; + } else { + return this.items.filter(x => !this.#noCloneSet.has(x)); + } + } + + /** + * Sets the focus of assistive technology to the item + * In the case of Active Descendant, does not change the DOM Focus + * @param item item + */ + public setATFocus(item?: Item): void { + this.#activeItem = item; + this.#applyAriaRelationship(item); + this.host.requestUpdate(); + } + + #registerItemsPriorToPossiblyCloning = (item: Item) => { + if (this.#itemsContainerElement?.contains(item)) { + item.id ||= getRandomId(); + this.#noCloneSet.add(item); + return item; + } else { + const clone = item.cloneNode(true) as Item; + this.#cloneMap.set(item, clone); + clone.id = getRandomId(); + return clone; + } + }; + + /** + * Sets the list of items and activates the next activatable item after the current one + * @param items tabindex items + */ + public updateItems(items: Item[] = this.#options.getItems?.() ?? []): void { + this.#items = ActivedescendantController.IDLAttrsSupported ? items + : items + .map(this.#registerItemsPriorToPossiblyCloning) + .filter(x => !!x); + const [first] = this.#activatableItems; + const next = this.#activatableItems.find(((_, i) => i !== this.#itemIndex)); + const activeItem = next ?? first ?? this.firstItem; + this.setATFocus(activeItem); } - #initDOM(ancestor: HTMLElement, controller: HTMLElement) { - this.#a11yContainerElement = ancestor; - this.#a11yControllerElement = controller; - controller.addEventListener('keydown', this.#onKeydown); + #initDOM(container: HTMLElement, owner: HTMLElement) { + this.#itemsContainerElement = container; + this.#ownerElement = owner; + owner.addEventListener('keydown', this.#onKeydown); this.updateItems(); } + #applyAriaRelationship(item?: Item) { + if (this.#itemsContainerElement && this.#ownerElement) { + if (ActivedescendantController.IDLAttrsSupported) { + this.#ownerElement.ariaActiveDescendantElement = item ?? null; + } else { + for (const el of [ + this.#itemsContainerElement, + this.#ownerElement, + ]) { + el?.setAttribute('aria-activedescendant', item?.id ?? ''); + } + } + } + } + /** * handles keyboard activation of items * @param event keydown event @@ -232,38 +293,36 @@ export class ActivedescendantController< return; } - const orientation = this.#options.getControllingElement() - ?.getAttribute('aria-orientation'); + const orientation = this.#options.getOrientation(); const verticalOnly = orientation === 'vertical'; const item = this.activeItem; const horizontalOnly = - !!item - && (item.tagName === 'SELECT' - || item.getAttribute('role') === 'spinbutton' - || orientation === 'horizontal'); + orientation === 'horizontal' + || item?.localName === 'select' + || item?.getAttribute('role') === 'spinbutton'; switch (event.key) { case 'ArrowLeft': if (verticalOnly) { return; } - this.setActiveItem(this.prevItem); + this.setATFocus(this.prevItem); event.stopPropagation(); event.preventDefault(); break; - case 'ArrowUp': - if (horizontalOnly) { + case 'ArrowRight': + if (verticalOnly) { return; } - this.setActiveItem(this.prevItem); + this.setATFocus(this.nextItem); event.stopPropagation(); event.preventDefault(); break; - case 'ArrowRight': - if (verticalOnly) { + case 'ArrowUp': + if (horizontalOnly) { return; } - this.setActiveItem(this.nextItem); + this.setATFocus(this.prevItem); event.stopPropagation(); event.preventDefault(); break; @@ -271,17 +330,17 @@ export class ActivedescendantController< if (horizontalOnly) { return; } - this.setActiveItem(this.nextItem); + this.setATFocus(this.nextItem); event.stopPropagation(); event.preventDefault(); break; case 'Home': - this.setActiveItem(this.firstItem); + this.setATFocus(this.firstItem); event.stopPropagation(); event.preventDefault(); break; case 'End': - this.setActiveItem(this.lastItem); + this.setATFocus(this.lastItem); event.stopPropagation(); event.preventDefault(); break; @@ -289,47 +348,4 @@ export class ActivedescendantController< break; } }; - - /** - * Sets the active item and focuses it - * @param item tabindex item - */ - setActiveItem(item?: Item): void { - this.#activeItem = item; - this.#applyAriaRelationship(item); - this.host.requestUpdate(); - } - - #applyAriaRelationship(item?: Item) { - if (this.#a11yContainerElement && this.#a11yControllerElement) { - if (ActivedescendantController.IDLAttrsSupported) { - this.#a11yControllerElement.ariaActiveDescendantElement = item ?? null; - } else { - for (const el of [this.#a11yContainerElement, this.#a11yControllerElement]) { - el?.setAttribute('aria-activedescendant', item?.id ?? ''); - } - } - } - } - - /** - * Sets the list of items and activates the next activatable item after the current one - * @param items tabindex items - */ - updateItems(items: Item[] = this.#options.getItems?.() ?? []): void { - this.#items = ActivedescendantController.IDLAttrsSupported ? items : items.map(item => { - if (item.id && this.#a11yContainerElement?.querySelector(`#${item.id}`)) { - this.#noCloneSet.add(item); - } else { - const clone = item.cloneNode(true) as Item; - this.#cloneMap.set(item, clone); - clone.id = getRandomId(); - return clone; - } - }).filter(x => !!x); - const [first] = this.#activatableItems; - const next = this.#activatableItems.find(((_, i) => i !== this.#itemIndex)); - const activeItem = next ?? first ?? this.firstItem; - this.setActiveItem(activeItem); - } } diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index 97c6c488d8..daacc3b3ba 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -1,6 +1,6 @@ -import type { LitElement, ReactiveController, ReactiveControllerHost } from 'lit'; +import type { ReactiveController, ReactiveControllerHost } from 'lit'; -import { isServer, nothing } from 'lit'; +import { isServer } from 'lit'; export interface ListboxAccessibilityController< Item extends HTMLElement @@ -12,21 +12,18 @@ export interface ListboxAccessibilityController< firstItem?: Item; lastItem?: Item; updateItems(items: Item[]): void; - setActiveItem(item: Item): void; - render?(): LitRenderable; + setATFocus(item: Item): void; } -type LitRenderable = ReturnType; - /** * Filtering, multiselect, and orientation options for listbox */ export interface ListboxConfigOptions { multi?: boolean; - a11yController: ListboxAccessibilityController; - getHTMLElement(): HTMLElement | null; + getA11yController(): ListboxAccessibilityController; requestSelect(option: T, force?: boolean): boolean; isSelected(option: T): boolean; + getItemsContainer?(): HTMLElement | null; } let constructingAllowed = false; @@ -63,19 +60,22 @@ export class ListboxController implements ReactiveCont } 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`, - ); + && 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(' ')); } - if (!_options.a11yController) { - throw new Error( - `ListboxController requires an additional keyboard accessibility controller. Provide either a RovingTabindexController or an ActiveDescendantController`, - ); + if (!_options.getA11yController) { + throw new Error([ + 'ListboxController requires an additional keyboard accessibility controller.', + 'Provide a getA11yController function which returns either a RovingTabindexController', + 'or an ActiveDescendantController', + ].join(' ')); } ListboxController.instances.set(host, this); this.host.addController(this); - if (this.element?.isConnected) { + if (this.#itemsContainer?.isConnected) { this.hostConnected(); } } @@ -91,14 +91,26 @@ export class ListboxController implements ReactiveCont /** Whether listbox is disabled */ disabled = false; + get #controller() { + return this._options.getA11yController(); + } + + get multi(): boolean { + return !!this._options.multi; + } + + set multi(v: boolean) { + this._options.multi = v; + } + /** Current active descendant in listbox */ get activeItem(): Item | undefined { - return this.options.find(option => - option === this._options.a11yController.activeItem) || this._options.a11yController.firstItem; + return this.options.find(option => option === this.#controller.activeItem) + || this.#controller.firstItem; } get nextItem(): Item | undefined { - return this._options.a11yController.nextItem; + return this.#controller.nextItem; } get options(): Item[] { @@ -117,27 +129,27 @@ export class ListboxController implements ReactiveCont return this._options.multi ? this.selectedOptions : firstItem; } - private get element() { - return this._options.getHTMLElement(); + get #itemsContainer() { + return this._options.getItemsContainer?.() ?? this.host as unknown as HTMLElement; } 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.#itemsContainer?.addEventListener('click', this.#onClick); + this.#itemsContainer?.addEventListener('focus', this.#onFocus); + this.#itemsContainer?.addEventListener('keydown', this.#onKeydown); + this.#itemsContainer?.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) { + this.#itemsContainer?.setAttribute('role', 'listbox'); + this.#itemsContainer?.setAttribute('aria-disabled', String(!!this.disabled)); + this.#itemsContainer?.setAttribute('aria-multi-selectable', String(!!this._options.multi)); + for (const option of this.#controller.items) { + if (this.#controller.activeItem === option) { option.setAttribute('aria-selected', 'true'); } else { option?.removeAttribute('aria-selected'); @@ -146,10 +158,10 @@ export class ListboxController implements ReactiveCont } 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.#itemsContainer?.removeEventListener('click', this.#onClick); + this.#itemsContainer?.removeEventListener('focus', this.#onFocus); + this.#itemsContainer?.removeEventListener('keydown', this.#onKeydown); + this.#itemsContainer?.removeEventListener('keyup', this.#onKeyup); this.#listening = false; } @@ -171,8 +183,8 @@ export class ListboxController implements ReactiveCont */ #onFocus = (event: FocusEvent) => { const target = this.#getEventOption(event); - if (target && target !== this._options.a11yController.activeItem) { - this._options.a11yController.setActiveItem(target); + if (target && target !== this.#controller.activeItem) { + this.#controller.setATFocus(target); } }; @@ -196,8 +208,8 @@ export class ListboxController implements ReactiveCont // 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 (target !== this.#controller.activeItem) { + this.#controller.setATFocus(target); } if (oldValue !== this.value) { this.host.requestUpdate(); @@ -233,8 +245,8 @@ export class ListboxController implements ReactiveCont return; } - const first = this._options.a11yController.firstItem; - const last = this._options.a11yController.lastItem; + const first = this.#controller.firstItem; + const last = this.#controller.lastItem; // need to set for keyboard support of multiselect if (event.key === 'Shift' && this._options.multi) { @@ -278,7 +290,7 @@ export class ListboxController implements ReactiveCont const setSize = this.#items.length; if (setSize !== oldOptions.length || !oldOptions.every((element, index) => element === this.#items[index])) { - this._options.a11yController.updateItems(this.options); + this.#controller.updateItems(this.options); } } @@ -291,7 +303,7 @@ export class ListboxController implements ReactiveCont .forEach(option => this._options.requestSelect( option, - option === this._options.a11yController.activeItem, + option === this.#controller.activeItem, )); } } @@ -331,11 +343,6 @@ export class ListboxController implements ReactiveCont } } - public render(): LitRenderable { - const { a11yController } = this._options; - return a11yController.render?.() ?? nothing; - } - /** * sets the listbox value based on selected options * @param value item or items diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index 8e9a7fb29d..5d2f312d63 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -1,5 +1,4 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit'; -import type { RequireProps } from '../core.js'; import type { ListboxAccessibilityController } from './listbox-controller.js'; const isFocusableElement = (el: Element): el is HTMLElement => @@ -8,11 +7,9 @@ const isFocusableElement = (el: Element): el is HTMLElement => && !el.hasAttribute('hidden'); export interface RovingTabindexControllerOptions { - /** @deprecated use getHTMLElement */ - getElement?: () => Element | null; - getHTMLElement?: () => HTMLElement | null; - getItems?: () => Item[]; - getItemContainer?: () => HTMLElement; + getItems: () => Item[]; + getItemsContainer?: () => HTMLElement | null; + getOrientation?(): 'horizontal' | 'vertical' | 'undefined'; } /** @@ -31,7 +28,7 @@ export class RovingTabindexController< static of( host: ReactiveControllerHost, - options: RovingTabindexControllerOptions & { getItems(): Item[] }, + options: RovingTabindexControllerOptions, ): RovingTabindexController { return new RovingTabindexController(host, options); } @@ -125,19 +122,17 @@ export class RovingTabindexController< ); } - #options: RequireProps, 'getHTMLElement'>; + #options: Required>; - constructor( + private constructor( public host: ReactiveControllerHost, - options?: RovingTabindexControllerOptions, + options: RovingTabindexControllerOptions, ) { - this.#options = { - getHTMLElement: options?.getHTMLElement - ?? (options?.getElement as (() => HTMLElement | null)) - ?? (() => host instanceof HTMLElement ? host : null), - getItems: options?.getItems, - getItemContainer: options?.getItemContainer, - }; + this.#options = options as Required>; + this.#options.getItemsContainer ??= () => host instanceof HTMLElement ? host : null; + this.#options.getOrientation ??= () => + this.#options.getItemsContainer()?.getAttribute('aria-orientation') as + 'horizontal' | 'vertical' | 'undefined'; const instance = RovingTabindexController.hosts.get(host); if (instance) { return instance as RovingTabindexController; @@ -149,7 +144,7 @@ export class RovingTabindexController< hostUpdated(): void { const oldContainer = this.#itemsContainer; - const newContainer = this.#options.getHTMLElement(); + const newContainer = this.#options.getItemsContainer(); if (oldContainer !== newContainer) { oldContainer?.removeEventListener('keydown', this.#onKeydown); RovingTabindexController.elements.delete(oldContainer!); @@ -169,6 +164,38 @@ export class RovingTabindexController< this.#gainedInitialFocus = false; } + /** + * Sets the focus of assistive technology to the item + * In the case of Roving Tab Index, also sets the DOM Focus + * @param item item + */ + public setATFocus(item?: Item): void { + this.#activeItem = item; + for (const item of this.#focusableItems) { + item.tabIndex = this.#activeItem === item ? 0 : -1; + } + this.host.requestUpdate(); + if (this.#gainedInitialFocus) { + this.#activeItem?.focus(); + } + } + + /** + * Focuses next focusable item + * @param items tabindex items + */ + public 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.setATFocus(activeItem); + } + #initContainer(container: Element) { RovingTabindexController.elements.set(container, this); this.#itemsContainer = container; @@ -193,115 +220,59 @@ export class RovingTabindexController< return; } - const orientation = this.#options.getHTMLElement()?.getAttribute('aria-orientation'); - + const orientation = this.#options.getOrientation?.(); + const verticalOnly = orientation === 'vertical'; const item = this.activeItem; - let shouldPreventDefault = false; const horizontalOnly = - !item ? false - : item.tagName === 'SELECT' - || item.getAttribute('role') === 'spinbutton' || orientation === 'horizontal'; - const verticalOnly = orientation === 'vertical'; + orientation === 'horizontal' + || item?.localName === 'select' + || item?.getAttribute('role') === 'spinbutton'; + switch (event.key) { case 'ArrowLeft': if (verticalOnly) { return; } - this.setActiveItem(this.prevItem); - shouldPreventDefault = true; + this.setATFocus(this.prevItem); + event.stopPropagation(); + event.preventDefault(); break; case 'ArrowRight': if (verticalOnly) { return; } - - this.setActiveItem(this.nextItem); - shouldPreventDefault = true; + this.setATFocus(this.nextItem); + event.stopPropagation(); + event.preventDefault(); break; case 'ArrowUp': if (horizontalOnly) { return; } - this.setActiveItem(this.prevItem); - shouldPreventDefault = true; + this.setATFocus(this.prevItem); + event.stopPropagation(); + event.preventDefault(); break; case 'ArrowDown': if (horizontalOnly) { return; } - this.setActiveItem(this.nextItem); - shouldPreventDefault = true; + this.setATFocus(this.nextItem); + event.stopPropagation(); + event.preventDefault(); break; case 'Home': - this.setActiveItem(this.firstItem); - shouldPreventDefault = true; + this.setATFocus(this.firstItem); + event.stopPropagation(); + event.preventDefault(); break; case 'End': - this.setActiveItem(this.lastItem); - shouldPreventDefault = true; + this.setATFocus(this.lastItem); + event.stopPropagation(); + event.preventDefault(); 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; - } - 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); - } - - /** - * 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); - } } From 2f7f48e56a74b519f8fb273160ceed36dd2e7525 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 18 Jul 2024 18:22:44 +0300 Subject: [PATCH 007/122] fix: elements usage of a11y controllers --- elements/pf-accordion/pf-accordion.ts | 4 +- elements/pf-chip/pf-chip-group.ts | 8 +- elements/pf-dropdown/pf-dropdown-menu.ts | 6 +- elements/pf-jump-links/pf-jump-links.ts | 22 ++- elements/pf-select/pf-select.ts | 165 ++++++++++++----------- elements/pf-tabs/pf-tabs.ts | 8 +- 6 files changed, 107 insertions(+), 106 deletions(-) diff --git a/elements/pf-accordion/pf-accordion.ts b/elements/pf-accordion/pf-accordion.ts index 76f7c914f5..3576333472 100644 --- a/elements/pf-accordion/pf-accordion.ts +++ b/elements/pf-accordion/pf-accordion.ts @@ -159,7 +159,7 @@ export class PfAccordion extends LitElement { #mo = new MutationObserver(() => this.#init()); - #headerIndex = new RovingTabindexController(this, { + #headerIndex = RovingTabindexController.of(this, { getItems: () => this.headers, }); @@ -236,7 +236,7 @@ export class PfAccordion extends LitElement { #updateActiveHeader() { if (this.#activeHeader !== this.#headerIndex.activeItem) { - this.#headerIndex.setActiveItem(this.#activeHeader); + this.#headerIndex.setATFocus(this.#activeHeader); } } diff --git a/elements/pf-chip/pf-chip-group.ts b/elements/pf-chip/pf-chip-group.ts index 2ca7a6abcd..a1eafd0f53 100644 --- a/elements/pf-chip/pf-chip-group.ts +++ b/elements/pf-chip/pf-chip-group.ts @@ -96,7 +96,7 @@ export class PfChipGroup extends LitElement { #buttons: HTMLElement[] = []; - #tabindex = new RovingTabindexController(this, { + #tabindex = RovingTabindexController.of(this, { getItems: () => this.#buttons.filter(x => !x.hidden), }); @@ -161,7 +161,7 @@ export class PfChipGroup extends LitElement { set activeChip(chip: HTMLElement) { const button = chip.shadowRoot?.querySelector('button') as HTMLElement; - this.#tabindex.setActiveItem(button); + this.#tabindex.setATFocus(button); } /** @@ -203,7 +203,7 @@ export class PfChipGroup extends LitElement { if (event instanceof PfChipRemoveEvent) { await this.#updateChips(); await this.updateComplete; - this.#tabindex.setActiveItem(this.#tabindex.activeItem); + this.#tabindex.setATFocus(this.#tabindex.activeItem); } } @@ -264,7 +264,7 @@ export class PfChipGroup extends LitElement { * @param chip pf-chip element */ focusOnChip(chip: HTMLElement): void { - this.#tabindex.setActiveItem(chip); + this.#tabindex.setATFocus(chip); } } diff --git a/elements/pf-dropdown/pf-dropdown-menu.ts b/elements/pf-dropdown/pf-dropdown-menu.ts index 80c5d2d927..6dcc23ab5c 100644 --- a/elements/pf-dropdown/pf-dropdown-menu.ts +++ b/elements/pf-dropdown/pf-dropdown-menu.ts @@ -39,7 +39,7 @@ export class PfDropdownMenu extends LitElement { #internals = InternalsController.of(this, { role: 'menu' }); - #tabindex = new RovingTabindexController(this, { + #tabindex = RovingTabindexController.of(this, { getItems: () => this.items.map(x => x.menuItem), }); @@ -112,7 +112,7 @@ export class PfDropdownMenu extends LitElement { event.stopPropagation(); } else if (event.target instanceof PfDropdownItem && event.target.menuItem !== this.#tabindex.activeItem) { - this.#tabindex.setActiveItem(event.target.menuItem); + this.#tabindex.setATFocus(event.target.menuItem); } } @@ -128,7 +128,7 @@ export class PfDropdownMenu extends LitElement { event.stopPropagation(); } else if (event.target instanceof PfDropdownItem && event.target.menuItem !== this.#tabindex.activeItem) { - this.#tabindex.setActiveItem(event.target.menuItem); + this.#tabindex.setATFocus(event.target.menuItem); } } diff --git a/elements/pf-jump-links/pf-jump-links.ts b/elements/pf-jump-links/pf-jump-links.ts index e9b7120459..e447f93dbf 100644 --- a/elements/pf-jump-links/pf-jump-links.ts +++ b/elements/pf-jump-links/pf-jump-links.ts @@ -77,7 +77,13 @@ export class PfJumpLinks extends LitElement { #kids = this.querySelectorAll?.(':is(pf-jump-links-item, pf-jump-links-list)'); - #tabindex?: RovingTabindexController; + #tabindex = RovingTabindexController.of(this, { + getItems: () => Array.from(this.#kids) + .flatMap(i => [ + ...i.shadowRoot?.querySelectorAll?.('a') ?? [], + ...i.querySelectorAll?.('a') ?? [], + ]), + }); #spy = new ScrollSpyController(this, { rootMargin: `${this.offset}px 0px 0px 0px`, @@ -97,16 +103,6 @@ export class PfJumpLinks extends LitElement { } override firstUpdated(): void { - this.#tabindex = new RovingTabindexController(this, { - getItems: () => { - const items = Array.from(this.#kids) - .flatMap(i => [ - ...i.shadowRoot?.querySelectorAll?.('a') ?? [], - ...i.querySelectorAll?.('a') ?? [], - ]); - return items; - }, - }); const active = this.querySelector?.('pf-jump-links-item[active]'); if (active) { this.#setActiveItem(active); @@ -140,7 +136,7 @@ export class PfJumpLinks extends LitElement { } #updateItems() { - this.#tabindex?.updateItems(); + this.#tabindex.updateItems(); } #onSelect(event: Event) { @@ -150,7 +146,7 @@ export class PfJumpLinks extends LitElement { } #setActiveItem(item: PfJumpLinksItem) { - this.#tabindex?.setActiveItem(item.shadowRoot?.querySelector?.('a') ?? undefined); + this.#tabindex.setATFocus(item.shadowRoot?.querySelector?.('a') ?? undefined); this.#spy.setActive(item); } diff --git a/elements/pf-select/pf-select.ts b/elements/pf-select/pf-select.ts index dd5837725a..56c410a8e4 100644 --- a/elements/pf-select/pf-select.ts +++ b/elements/pf-select/pf-select.ts @@ -2,7 +2,7 @@ import type { PfChipGroup } from '../pf-chip/pf-chip-group.js'; import type { Placement } from '@patternfly/pfe-core/controllers/floating-dom-controller.js'; import type { PropertyValues, TemplateResult } from 'lit'; -import { LitElement, html, isServer } from 'lit'; +import { LitElement, html, isServer, nothing } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; import { query } from 'lit/decorators/query.js'; @@ -11,10 +11,7 @@ import { styleMap } from 'lit/directives/style-map.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { - type ListboxAccessibilityController, - ListboxController, -} from '@patternfly/pfe-core/controllers/listbox-controller.js'; +import { ListboxController } from '@patternfly/pfe-core/controllers/listbox-controller.js'; import { ActivedescendantController } from '@patternfly/pfe-core/controllers/activedescendant-controller.js'; import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; import { SlotController } from '@patternfly/pfe-core/controllers/slot-controller.js'; @@ -53,23 +50,13 @@ export class PfSelectChangeEvent extends Event { export class PfSelect extends LitElement { static readonly styles: CSSStyleSheet[] = [styles]; + static readonly formAssociated = true; + static override readonly shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, delegatesFocus: true, }; - static readonly formAssociated = true; - - #internals = InternalsController.of(this); - - #float = new FloatingDOMController(this, { - content: () => this.shadowRoot?.getElementById('listbox-container') ?? null, - }); - - #slots = new SlotController(this, null, 'placeholder'); - - #listbox?: ListboxController; - /** Variant of rendered Select */ @property() variant: 'single' | 'checkbox' | 'typeahead' | 'typeaheadmulti' = 'single'; @@ -128,18 +115,65 @@ export class PfSelect extends LitElement { @property({ attribute: false }) filter?: (option: PfOption) => boolean; + @query('pf-chip-group') private _chipGroup?: PfChipGroup; + + @query('#toggle-input') private _input?: HTMLInputElement; + + @query('#toggle-button') private _toggle?: HTMLButtonElement; + + @query('#listbox') private _listbox?: HTMLElement; + + @query('#listbox-container') private _listboxContainer?: HTMLElement; + + @query('#placeholder') private _placeholder?: PfOption; + + #getListboxContainer = () => this._listbox ?? null; + + #getComboboxInput = () => this._input ?? null; + + #isOptionSelected = (option: PfOption) => option.selected; + + #isNotPlaceholderOption = (option: PfOption) => option !== this._placeholder; + + // TODO: differentiate between selection and focus in a11yControllers + #requestSelect = (option: PfOption, selected: boolean) => { + option.selected = !option.disabled && !!selected; + if (selected) { + this.selected = option; + } + return selected; + }; + + #a11yController = this.#getA11yController(); + + #internals = InternalsController.of(this); + + #float = new FloatingDOMController(this, { + content: () => this._listboxContainer, + }); + + #slots = new SlotController(this, null, 'placeholder'); + + #listbox = ListboxController.of(this, { + multi: this.variant === 'typeaheadmulti' || this.variant === 'checkbox', + getA11yController: () => this.#a11yController, + getItemsContainer: this.#getListboxContainer, + isSelected: this.#isOptionSelected, + requestSelect: this.#requestSelect, + }); + /** * Single select option value for single select menus, * or array of select option values for multi select. */ set selected(optionsList: PfOption | PfOption[]) { this.#lastSelected = this.selected; - this.#listbox?.setValue(optionsList); + this.#listbox.setValue(optionsList); this.requestUpdate('selected', this.#lastSelected); } - get selected(): PfOption | PfOption[] | undefined { - return this.#listbox?.value; + get selected(): PfOption | PfOption[] { + return this.#listbox.value; } /** @@ -150,21 +184,14 @@ export class PfSelect extends LitElement { return []; // TODO: expose a DOM property to allow setting options in SSR scenarios } else { const opts = Array.from(this.querySelectorAll('pf-option')); - const placeholder = this.shadowRoot?.getElementById('placeholder') as PfOption | null; - if (placeholder) { - return [placeholder, ...opts]; + if (this._placeholder) { + return [this._placeholder, ...opts]; } else { return opts; } } } - @query('pf-chip-group') private _chipGroup?: PfChipGroup; - - @query('#toggle-input') private _input?: HTMLInputElement; - - @query('#toggle-button') private _toggle?: HTMLButtonElement; - #lastSelected = this.selected; /** @@ -178,13 +205,13 @@ export class PfSelect extends LitElement { get #buttonLabel() { switch (this.variant) { case 'typeaheadmulti': - return `${this.#listbox?.selectedOptions?.length ?? 0} ${this.itemsSelectedText}`; + return `${this.#listbox.selectedOptions?.length ?? 0} ${this.itemsSelectedText}`; case 'checkbox': return this.#listbox - ?.selectedOptions - ?.map?.(option => option.optionText || '') - ?.join(' ') - ?.trim() + .selectedOptions + .map(option => option.optionText || '') + .join(' ') + .trim() || this.#computePlaceholderText() || 'Options'; default: @@ -208,7 +235,7 @@ export class PfSelect extends LitElement { this.#internals.setFormValue(this.value ?? ''); } if (changed.has('disabled')) { - this.#listbox!.disabled = this.disabled; + this.#listbox.disabled = this.disabled; } // TODO: handle filtering in the element, not the controller } @@ -220,7 +247,7 @@ export class PfSelect extends LitElement { const { height, width } = this.getBoundingClientRect?.() || {}; const buttonLabel = this.#buttonLabel; const hasBadge = this.#hasBadge; - const selectedOptions = this.#listbox?.selectedOptions ?? []; + const selectedOptions = this.#listbox.selectedOptions ?? []; const typeahead = variant.startsWith('typeahead'); const checkboxes = variant === 'checkbox'; const offscreen = typeahead && 'offscreen'; @@ -300,7 +327,8 @@ export class PfSelect extends LitElement { ?hidden="${!this.placeholder && !this.#slots.hasSlotted('placeholder')}"> ${this.placeholder} - ${this.#listbox?.render()} + ${!(this.#a11yController instanceof ActivedescendantController) ? nothing + : this.#a11yController.renderItemsToShadowRoot()} @@ -336,47 +364,23 @@ export class PfSelect extends LitElement { // TODO: don't do filtering in the controller } - #a11yController?: ListboxAccessibilityController; - - #variantChanged() { - this.#listbox?.hostDisconnected(); + #getA11yController() { const getItems = () => this.options; - const getHTMLElement = () => this.shadowRoot?.getElementById('listbox') ?? null; - const isSelected = (option: PfOption) => option.selected; - const requestSelect = (option: PfOption, selected: boolean) => { - option.selected = !option.disabled && !!selected; - if (selected) { - this.selected = option; - } - return selected; - }; - switch (this.variant) { - case 'typeahead': - case 'typeaheadmulti': { - this.#a11yController = ActivedescendantController.of(this, { - getItems, - getControllingElement: () => this.shadowRoot?.getElementById('toggle-input') ?? null, - getItemContainer: () => this.shadowRoot?.getElementById('listbox') ?? null, - }); - return this.#listbox = ListboxController.of(this, { - a11yController: this.#a11yController, - multi: this.variant === 'typeaheadmulti', - getHTMLElement, - isSelected, - requestSelect, - }); - } default: - this.#a11yController = RovingTabindexController.of(this, { getHTMLElement, getItems }); - return this.#listbox = ListboxController.of(this, { - a11yController: this.#a11yController, - multi: this.variant === 'checkbox', - getHTMLElement, - isSelected, - requestSelect, - }); + const getItemsContainer = this.#getListboxContainer; + const getOwningElement = this.#getComboboxInput; + if (this.variant.startsWith('typeahead')) { + return ActivedescendantController.of(this, { getItems, getItemsContainer, getOwningElement }); + } else { + return RovingTabindexController.of(this, { getItems, getItemsContainer }); } } + #variantChanged() { + this.#listbox.multi = this.variant === 'typeaheadmulti' || this.variant === 'checkbox'; + this.#a11yController.hostDisconnected(); + this.#a11yController = this.#getA11yController(); + } + async #expandedChanged() { const will = this.expanded ? 'close' : 'open'; this.dispatchEvent(new Event(will)); @@ -385,7 +389,7 @@ export class PfSelect extends LitElement { switch (this.variant) { case 'single': case 'checkbox': { - const focusableItem = this.#listbox?.activeItem ?? this.#listbox?.nextItem; + const focusableItem = this.#listbox.activeItem ?? this.#listbox.nextItem; focusableItem?.focus(); } } @@ -463,7 +467,7 @@ export class PfSelect extends LitElement { await this.show(); // TODO: thread the needle of passing state between controllers await new Promise(r => setTimeout(r)); - this._input!.value = this.#listbox?.activeItem?.value ?? ''; + this._input!.value = this.#listbox.activeItem?.value ?? ''; break; case 'Enter': this.hide(); @@ -488,7 +492,7 @@ export class PfSelect extends LitElement { } #onListboxSlotchange() { - this.#listbox?.setOptions(this.options); + this.#listbox.setOptions(this.options); this.options.forEach((option, index, options) => { option.setSize = options.length; option.posInSet = index; @@ -524,9 +528,10 @@ export class PfSelect extends LitElement { || this.querySelector?.('[slot=placeholder]') ?.assignedNodes() ?.reduce((acc, node) => `${acc}${node.textContent}`, '')?.trim() - || this.#listbox?.options - ?.filter(x => x !== this.shadowRoot?.getElementById('placeholder')) - ?.at(0)?.value + || this.#listbox.options + .filter(this.#isNotPlaceholderOption) + .at(0) + ?.value || ''; } diff --git a/elements/pf-tabs/pf-tabs.ts b/elements/pf-tabs/pf-tabs.ts index c95a111d65..db09024e20 100644 --- a/elements/pf-tabs/pf-tabs.ts +++ b/elements/pf-tabs/pf-tabs.ts @@ -134,8 +134,8 @@ export class PfTabs extends LitElement { isActiveTab: x => x.active, }); - #tabindex = new RovingTabindexController(this, { - getHTMLElement: () => this.shadowRoot?.getElementById('tabs') ?? null, + #tabindex = RovingTabindexController.of(this, { + getItemsContainer: () => this.tabsContainer ?? null, getItems: () => this.tabs ?? [], }); @@ -248,9 +248,9 @@ export class PfTabs extends LitElement { select(option: PfTab | number): void { if (typeof option === 'number') { const item = this.tabs[option]; - this.#tabindex.setActiveItem(item); + this.#tabindex.setATFocus(item); } else { - this.#tabindex.setActiveItem(option); + this.#tabindex.setATFocus(option); } this.#updateActive({ force: true }); } From 10f2d49e2072fe95535a81560af3d1d07573cfd1 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Sun, 21 Jul 2024 17:09:27 +0300 Subject: [PATCH 008/122] fix!: abstract ATFocusController --- .../activedescendant-controller.ts | 293 ++++-------------- .../controllers/at-focus-controller.ts | 182 +++++++++++ .../controllers/listbox-controller.ts | 258 ++++++++------- .../controllers/roving-tabindex-controller.ts | 284 +++-------------- elements/pf-accordion/pf-accordion-header.ts | 1 - elements/pf-accordion/pf-accordion.ts | 7 +- elements/pf-chip/pf-chip-group.ts | 12 +- elements/pf-dropdown/pf-dropdown-menu.ts | 24 +- elements/pf-jump-links/pf-jump-links.ts | 18 +- elements/pf-select/pf-select.ts | 58 ++-- elements/pf-tabs/pf-tabs.ts | 24 +- package.json | 2 +- 12 files changed, 491 insertions(+), 672 deletions(-) create mode 100644 core/pfe-core/controllers/at-focus-controller.ts diff --git a/core/pfe-core/controllers/activedescendant-controller.ts b/core/pfe-core/controllers/activedescendant-controller.ts index 750ef9e99b..181e91d3b3 100644 --- a/core/pfe-core/controllers/activedescendant-controller.ts +++ b/core/pfe-core/controllers/activedescendant-controller.ts @@ -1,20 +1,14 @@ -import type { ListboxAccessibilityController } from './listbox-controller.js'; -import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import type { ReactiveControllerHost } from 'lit'; + +import { type ATFocusControllerOptions, ATFocusController } from './at-focus-controller.js'; import { nothing } from 'lit'; import { getRandomId } from '../functions/random.js'; -const isActivatableElement = (el: Element): el is HTMLElement => - !!el - && !el.ariaHidden - && !el.hasAttribute('hidden'); - -export interface ActivedescendantControllerOptions { - getOwningElement: () => HTMLElement | null; - getItems: () => Item[]; - getItemsContainer?: () => HTMLElement | null; - // todo: maybe this needs to be "horizontal"| "vertical" | "undefined" | "grid" - getOrientation?(): 'horizontal' | 'vertical' | 'undefined'; +export interface ActivedescendantControllerOptions< + Item extends HTMLElement +> extends ATFocusControllerOptions { + getItemsControlsElement: () => HTMLElement | null; } /** @@ -62,10 +56,7 @@ export interface ActivedescendantControllerOptions { */ export class ActivedescendantController< Item extends HTMLElement = HTMLElement -> implements ReactiveController, ListboxAccessibilityController { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private static hosts = new WeakMap>(); - +> extends ATFocusController { private static IDLAttrsSupported = 'ariaActiveDescendantElement' in HTMLElement.prototype; public static canControlLightDom(): boolean { @@ -79,136 +70,41 @@ export class ActivedescendantController< return new ActivedescendantController(host, options); } - /** active element */ - #activeItem?: Item; - - /** accessibility container of items */ - #itemsContainerElement?: Element; - - /** accessibility controllers of items */ - #ownerElement?: Element; - - /** array of all activatable elements */ - #items: Item[] = []; - - /** - * finds focusable items from a group of items - */ - get #activatableItems(): Item[] { - return this.items.filter(isActivatableElement); - } - - /** - * index of active item in array of focusable items - */ - get #activeIndex(): number { - return !!this.#activatableItems - && !!this.activeItem ? this.#activatableItems.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.#activatableItems; - } - - /** - * first item in array of focusable items - */ - get firstItem(): Item | undefined { - return this.#activatableItems.at(0); - } - - /** - * last item in array of focusable items - */ - get lastItem(): Item | undefined { - return this.#activatableItems.at(-1); - } - - /** - * next item after active item in array of focusable items - */ - get nextItem(): Item | undefined { - return ( - this.#activeIndex >= this.#activatableItems.length - 1 ? this.firstItem - : this.#activatableItems[this.#activeIndex + 1] - ); - } + /** Maps from original element to shadow DOM clone */ + #cloneMap = new WeakMap(); - /** - * previous item after active item in array of focusable items - */ - get prevItem(): Item | undefined { - return ( - this.#activeIndex > 0 ? this.#activatableItems[this.#activeIndex - 1] - : this.lastItem - ); - } + /** Set of item which should not be cloned */ + #noCloneSet = new WeakSet(); - #options: Required>; + /** Element which controls the list i.e. combobox */ + #itemsControlsElement: HTMLElement | null = null; - #cloneMap = new WeakMap(); - #noCloneSet = new WeakSet(); + #options: ActivedescendantControllerOptions; private constructor( public host: ReactiveControllerHost, options: ActivedescendantControllerOptions, ) { - this.#options = options as Required>; - this.#options.getItemsContainer ??= () => host instanceof HTMLElement ? host : null; - this.#options.getOrientation ??= () => - this.#itemsContainerElement?.getAttribute('aria-orientation') as - 'horizontal' | 'vertical' | 'undefined'; - const instance = ActivedescendantController.hosts.get(host); - if (instance) { - return instance as unknown as ActivedescendantController; - } - ActivedescendantController.hosts.set(host, this); - this.host.addController(this); - this.updateItems(); + super(host, options); + this.#options = options; + this.itemsControlsElement = this.#options.getItemsControlsElement(); } - hostUpdated(): void { - const oldContainer = this.#itemsContainerElement; - const oldController = this.#ownerElement; - const container = this.#options.getItemsContainer(); - const controller = this.#options.getOwningElement(); - if (container && controller && ( - container !== oldContainer - || controller !== oldController)) { - this.#initDOM(container, controller); - } + hostConnected(): void { + this.itemsControlsElement = this.#options.getItemsControlsElement(); } - /** - * removes event listeners from items container - */ - hostDisconnected(): void { - this.#itemsContainerElement?.removeEventListener('keydown', this.#onKeydown); - this.#itemsContainerElement = undefined; + get itemsControlsElement() { + return this.#itemsControlsElement ?? null; + } + + set itemsControlsElement(element: HTMLElement | null) { + const oldController = this.#itemsControlsElement; + oldController?.removeEventListener('keydown', this.onKeydown); + this.#itemsControlsElement = element; + if (element && oldController !== element) { + element.addEventListener('keydown', this.onKeydown); + } } public renderItemsToShadowRoot(): typeof nothing | Node[] { @@ -219,19 +115,16 @@ export class ActivedescendantController< } } - /** - * Sets the focus of assistive technology to the item - * In the case of Active Descendant, does not change the DOM Focus - * @param item item - */ - public setATFocus(item?: Item): void { - this.#activeItem = item; - this.#applyAriaRelationship(item); - this.host.requestUpdate(); + protected override isRelevantKeyboardEvent(event: Event): event is KeyboardEvent { + return !(!(event instanceof KeyboardEvent) + || event.ctrlKey + || event.altKey + || event.metaKey + || !this.atFocusableItems.length); } #registerItemsPriorToPossiblyCloning = (item: Item) => { - if (this.#itemsContainerElement?.contains(item)) { + if (this.itemsContainerElement?.contains(item)) { item.id ||= getRandomId(); this.#noCloneSet.add(item); return item; @@ -247,105 +140,37 @@ export class ActivedescendantController< * Sets the list of items and activates the next activatable item after the current one * @param items tabindex items */ - public updateItems(items: Item[] = this.#options.getItems?.() ?? []): void { - this.#items = ActivedescendantController.IDLAttrsSupported ? items + override set items(items: Item[]) { + super.items = (ActivedescendantController.IDLAttrsSupported ? items : items - .map(this.#registerItemsPriorToPossiblyCloning) - .filter(x => !!x); - const [first] = this.#activatableItems; - const next = this.#activatableItems.find(((_, i) => i !== this.#itemIndex)); - const activeItem = next ?? first ?? this.firstItem; - this.setATFocus(activeItem); + ?.map(this.#registerItemsPriorToPossiblyCloning) + ?.filter(x => !!x)); + const [first] = this.atFocusableItems; + const atFocusedItemIndex = this.atFocusableItems.indexOf(this.atFocusedItem!); + const next = this.atFocusableItems.find(((_, i) => i !== atFocusedItemIndex)); + const activeItem = next ?? first ?? this.firstATFocusableItem; + this.atFocusedItem = activeItem; } - #initDOM(container: HTMLElement, owner: HTMLElement) { - this.#itemsContainerElement = container; - this.#ownerElement = owner; - owner.addEventListener('keydown', this.#onKeydown); - this.updateItems(); - } - - #applyAriaRelationship(item?: Item) { - if (this.#itemsContainerElement && this.#ownerElement) { + /** + * Rather than setting DOM focus, applies the `aria-activedescendant` attribute, + * using AriaIDLAttributes for cross-root aria, if supported by the browser + * @param item item + */ + override set atFocusedItem(item: Item | null) { + super.atFocusedItem = item; + if (this.itemsContainerElement && this.itemsControlsElement) { if (ActivedescendantController.IDLAttrsSupported) { - this.#ownerElement.ariaActiveDescendantElement = item ?? null; + this.itemsControlsElement.ariaActiveDescendantElement = item ?? null; } else { for (const el of [ - this.#itemsContainerElement, - this.#ownerElement, + this.itemsContainerElement, + this.itemsControlsElement, ]) { el?.setAttribute('aria-activedescendant', item?.id ?? ''); } } } + this.host.requestUpdate(); } - - /** - * handles keyboard activation of items - * @param event keydown event - */ - #onKeydown = (event: Event) => { - if (!(event instanceof KeyboardEvent) - || event.ctrlKey - || event.altKey - || event.metaKey - || !this.#activatableItems.length) { - return; - } - - const orientation = this.#options.getOrientation(); - const verticalOnly = orientation === 'vertical'; - const item = this.activeItem; - const horizontalOnly = - orientation === 'horizontal' - || item?.localName === 'select' - || item?.getAttribute('role') === 'spinbutton'; - - switch (event.key) { - case 'ArrowLeft': - if (verticalOnly) { - return; - } - this.setATFocus(this.prevItem); - event.stopPropagation(); - event.preventDefault(); - break; - case 'ArrowRight': - if (verticalOnly) { - return; - } - this.setATFocus(this.nextItem); - event.stopPropagation(); - event.preventDefault(); - break; - case 'ArrowUp': - if (horizontalOnly) { - return; - } - this.setATFocus(this.prevItem); - event.stopPropagation(); - event.preventDefault(); - break; - case 'ArrowDown': - if (horizontalOnly) { - return; - } - this.setATFocus(this.nextItem); - event.stopPropagation(); - event.preventDefault(); - break; - case 'Home': - this.setATFocus(this.firstItem); - event.stopPropagation(); - event.preventDefault(); - break; - case 'End': - this.setATFocus(this.lastItem); - event.stopPropagation(); - event.preventDefault(); - break; - default: - break; - } - }; } 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..bf92819ffb --- /dev/null +++ b/core/pfe-core/controllers/at-focus-controller.ts @@ -0,0 +1,182 @@ +import type { ReactiveControllerHost } from 'lit'; + +function isATFocusableItem(el: Element): el is HTMLElement { + return !!el + && !el.ariaHidden + && !el.hasAttribute('hidden'); +} + +export interface ATFocusControllerOptions { + getItems(): Item[]; + getItemsContainer?(): HTMLElement | null; + getOrientation?(): 'horizontal' | 'vertical' | 'grid' | 'undefined'; +} + +export abstract class ATFocusController { + // funny name to prevent transpiled private access errors + #optionz: ATFocusControllerOptions; + + #itemsContainerElement: HTMLElement | null = null; + + #atFocusedItem: Item | null = null; + + #items: Item[] = []; + + get #atFocusedItemIndex() { + return this.atFocusableItems.indexOf(this.#atFocusedItem!); + } + + /** All items */ + get items(): Item[] { + return this.#items; + } + + set items(items: Item[]) { + this.#items = items; + } + + /** All items which are able to receive assistive technology focus */ + get atFocusableItems(): Item[] { + return this.#items.filter(isATFocusableItem); + } + + /** Item which currently has assistive technology focus */ + get atFocusedItem(): Item | null { + return this.#atFocusedItem; + } + + set atFocusedItem(item: Item | null) { + this.#atFocusedItem = item; + this.host.requestUpdate(); + } + + /** First item which is able to receive assistive technology focus */ + get firstATFocusableItem(): Item | null { + return this.atFocusableItems.at(0) ?? null; + } + + /** Last item which is able to receive assistive technology focus */ + get lastATFocusableItem(): Item | null { + return this.atFocusableItems.at(-1) ?? null; + } + + /** Focusable item following the item which currently has assistive technology focus */ + get nextATFocusableItem(): Item | null { + const index = this.#atFocusedItemIndex; + const outOfBounds = index >= this.atFocusableItems.length - 1; + return outOfBounds ? this.firstATFocusableItem + : this.atFocusableItems.at(index + 1) ?? null; + } + + /** Focusable item preceding the item which currently has assistive technology focus */ + get previousATFocusableItem(): Item | null { + const index = this.#atFocusedItemIndex; + const outOfBounds = index > 0; + return outOfBounds ? this.atFocusableItems.at(index - 1) ?? null + : this.lastATFocusableItem; + } + + 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.items = this.#optionz.getItems(); + this.host.requestUpdate(); + } + } + + constructor(public host: ReactiveControllerHost, options: ATFocusControllerOptions) { + this.#optionz = options; + if (host instanceof HTMLElement && host.isConnected) { + this.hostConnected(); + } + } + + hostConnected(): void { + this.hostUpdate(); + } + + hostUpdate(): void { + this.atFocusedItem ??= this.firstATFocusableItem; + this.itemsContainerElement + ??= this.#optionz.getItemsContainer?.() + ?? (this.host instanceof HTMLElement ? this.host : null); + } + + protected abstract isRelevantKeyboardEvent(event: Event): event is KeyboardEvent; + + /** DO NOT OVERRIDE */ + protected onKeydown(event: Event): void { + this.#onKeydown(event); + } + + #onKeydown = (event: Event) => { + if (this.isRelevantKeyboardEvent(event)) { + const orientation = this.#optionz.getOrientation?.() ?? this + .#itemsContainerElement + ?.getAttribute('aria-orientation') as + 'horizontal' | 'vertical' | 'grid' | 'undefined'; + + const item = this.atFocusedItem; + + const horizontalOnly = + orientation === 'horizontal' + || item?.tagName === 'SELECT' + || item?.getAttribute('role') === 'spinbutton'; + + const verticalOnly = orientation === 'vertical'; + + switch (event.key) { + case 'ArrowLeft': + if (verticalOnly) { + return; + } + this.atFocusedItem = this.previousATFocusableItem ?? null; + event.stopPropagation(); + event.preventDefault(); + break; + case 'ArrowRight': + if (verticalOnly) { + return; + } + this.atFocusedItem = this.nextATFocusableItem ?? null; + event.stopPropagation(); + event.preventDefault(); + break; + case 'ArrowUp': + if (horizontalOnly) { + return; + } + this.atFocusedItem = this.previousATFocusableItem ?? null; + event.stopPropagation(); + event.preventDefault(); + break; + case 'ArrowDown': + if (horizontalOnly) { + return; + } + this.atFocusedItem = this.nextATFocusableItem ?? null; + event.stopPropagation(); + event.preventDefault(); + break; + case 'Home': + this.atFocusedItem = this.firstATFocusableItem ?? null; + event.stopPropagation(); + event.preventDefault(); + break; + case 'End': + this.atFocusedItem = this.lastATFocusableItem ?? null; + event.stopPropagation(); + event.preventDefault(); + break; + default: + break; + } + } + }; +} diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index daacc3b3ba..4ca210a9c1 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -1,26 +1,14 @@ +import type { ATFocusController } from './at-focus-controller'; import type { ReactiveController, ReactiveControllerHost } from 'lit'; import { isServer } 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; - setATFocus(item: Item): void; -} - /** * Filtering, multiselect, and orientation options for listbox */ -export interface ListboxConfigOptions { +export interface ListboxControllerOptions { multi?: boolean; - getA11yController(): ListboxAccessibilityController; + getATFocusController(): ATFocusController; requestSelect(option: T, force?: boolean): boolean; isSelected(option: T): boolean; getItemsContainer?(): HTMLElement | null; @@ -33,46 +21,83 @@ 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>(); + private static instances = new WeakMap< + ReactiveControllerHost, + ListboxController + >(); 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, + options: ListboxControllerOptions, ) { + this.#options = options; if (!constructingAllowed) { throw new Error('ListboxController must be constructed with `ListboxController.of()`'); } if (!isServer && !(host instanceof HTMLElement) - && typeof _options.getItemsContainer !== 'function') { + && 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(' ')); } - if (!_options.getA11yController) { + if (!this.#controller) { throw new Error([ 'ListboxController requires an additional keyboard accessibility controller.', 'Provide a getA11yController function which returns either a RovingTabindexController', 'or an ActiveDescendantController', ].join(' ')); } + const instance = ListboxController.instances.get(host); + if (instance) { + return instance as ListboxController; + } ListboxController.instances.set(host, this); this.host.addController(this); if (this.#itemsContainer?.isConnected) { @@ -83,73 +108,82 @@ export class ListboxController implements ReactiveCont /** Current active descendant when shift key is pressed */ #shiftStartingItem: Item | null = null; + #options: ListboxControllerOptions; + /** All options that will not be hidden by a filter */ #items: Item[] = []; #listening = false; - /** Whether listbox is disabled */ - disabled = false; + #lastATFocusedItem?: Item | null; + + get #itemsContainer() { + return this.#options.getItemsContainer?.() ?? this.host as unknown as HTMLElement; + } get #controller() { - return this._options.getA11yController(); + return this.#options.getATFocusController(); } + /** Whether listbox is disabled */ + disabled = false; + get multi(): boolean { - return !!this._options.multi; + return !!this.#options.multi; } set multi(v: boolean) { - this._options.multi = v; - } - - /** Current active descendant in listbox */ - get activeItem(): Item | undefined { - return this.options.find(option => option === this.#controller.activeItem) - || this.#controller.firstItem; + this.#options.multi = v; } - get nextItem(): Item | undefined { - return this.#controller.nextItem; + get items(): Item[] { + return this.#items; } - get options(): Item[] { - return this.#items; + /** + * register's the host's Item elements as listbox controller items + * @param items items + */ + set items(items: Item[]) { + this.#items = items; } /** * array of options which are selected */ - get selectedOptions(): Item[] { - return this.options.filter(option => this._options.isSelected(option)); + get selectedItems(): Item[] { + return this.items.filter(option => this.#options.isSelected(option)); } get value(): Item | Item[] { - const [firstItem] = this.selectedOptions; - return this._options.multi ? this.selectedOptions : firstItem; - } - - get #itemsContainer() { - return this._options.getItemsContainer?.() ?? this.host as unknown as HTMLElement; + const [firstItem] = this.selectedItems; + return this.#options.multi ? this.selectedItems : firstItem; } async hostConnected(): Promise { if (!this.#listening) { await this.host.updateComplete; this.#itemsContainer?.addEventListener('click', this.#onClick); - this.#itemsContainer?.addEventListener('focus', this.#onFocus); this.#itemsContainer?.addEventListener('keydown', this.#onKeydown); this.#itemsContainer?.addEventListener('keyup', this.#onKeyup); this.#listening = true; } } + hostUpdate(): void { + const { atFocusedItem } = this.#controller; + if (atFocusedItem && atFocusedItem !== this.#lastATFocusedItem) { + this.#options.requestSelect(atFocusedItem); + } + this.#lastATFocusedItem = atFocusedItem; + } + hostUpdated(): void { this.#itemsContainer?.setAttribute('role', 'listbox'); this.#itemsContainer?.setAttribute('aria-disabled', String(!!this.disabled)); - this.#itemsContainer?.setAttribute('aria-multi-selectable', String(!!this._options.multi)); + this.#itemsContainer?.setAttribute('aria-multi-selectable', String(!!this.#options.multi)); for (const option of this.#controller.items) { - if (this.#controller.activeItem === option) { + if (this.#controller.atFocusedItem === option) { option.setAttribute('aria-selected', 'true'); } else { option?.removeAttribute('aria-selected'); @@ -159,35 +193,19 @@ export class ListboxController implements ReactiveCont hostDisconnected(): void { this.#itemsContainer?.removeEventListener('click', this.#onClick); - this.#itemsContainer?.removeEventListener('focus', this.#onFocus); this.#itemsContainer?.removeEventListener('keydown', this.#onKeydown); this.#itemsContainer?.removeEventListener('keyup', this.#onKeyup); this.#listening = false; } - #getEnabledOptions(options = this.options) { - return options.filter(option => !option.ariaDisabled && !option.closest('[disabled]')); - } + #isSelectableItem = (item: Item) => !item.ariaDisabled && !item.closest('[disabled]'); - #getEventOption(event: Event): Item | undefined { + #getItemFromEvent(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 - */ - #onFocus = (event: FocusEvent) => { - const target = this.#getEventOption(event); - if (target && target !== this.#controller.activeItem) { - this.#controller.setATFocus(target); - } - }; - /** * handles clicking on a listbox option: * which selects an item by default @@ -195,21 +213,18 @@ export class ListboxController implements ReactiveCont * @param event click event */ #onClick = (event: MouseEvent) => { - const target = this.#getEventOption(event); + const target = this.#getItemFromEvent(event); if (target) { const oldValue = this.value; - if (this._options.multi) { + if (this.multi) { if (!event.shiftKey) { - this._options.requestSelect(target, !this._options.isSelected(target)); + this.#options.requestSelect(target, !this.#options.isSelected(target)); } else if (this.#shiftStartingItem && target) { this.#updateMultiselect(target, this.#shiftStartingItem); } } else { // select target and deselect all other options - this.options.forEach(option => this._options.requestSelect(option, option === target)); - } - if (target !== this.#controller.activeItem) { - this.#controller.setATFocus(target); + this.items.forEach(option => this.#options.requestSelect(option, option === target)); } if (oldValue !== this.value) { this.host.requestUpdate(); @@ -222,8 +237,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) { + const target = this.#getItemFromEvent(event); + if (target && event.shiftKey && this.multi) { if (this.#shiftStartingItem && target) { this.#updateMultiselect(target, this.#shiftStartingItem); } @@ -239,18 +254,18 @@ export class ListboxController implements ReactiveCont * @param event keydown event */ #onKeydown = (event: KeyboardEvent) => { - const target = this.#getEventOption(event); + const target = this.#getItemFromEvent(event); - if (!target || event.altKey || event.metaKey || !this.options.includes(target)) { + if (!target || event.altKey || event.metaKey || !this.items.includes(target)) { return; } - const first = this.#controller.firstItem; - const last = this.#controller.lastItem; + const first = this.#controller.firstATFocusableItem; + const last = this.#controller.lastATFocusableItem; // 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.#controller.atFocusedItem ?? null; } switch (event.key) { @@ -266,14 +281,12 @@ export class ListboxController implements ReactiveCont 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 { + if (!this.multi) { this.#updateSingleselect(); + } else if (event.shiftKey) { + this.#updateMultiselect(target); + } else if (!this.disabled) { + this.#options.requestSelect(target, !this.#options.isSelected(target)); } event.preventDefault(); break; @@ -282,28 +295,17 @@ export class ListboxController implements ReactiveCont } }; - /** - * 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.#controller.updateItems(this.options); - } - } - /** * updates option selections for single select listbox */ #updateSingleselect() { - if (!this._options.multi && !this.disabled) { - this.#getEnabledOptions() + if (!this.multi && !this.disabled) { + this.items + .filter(this.#isSelectableItem) .forEach(option => - this._options.requestSelect( + this.#options.requestSelect( option, - option === this.#controller.activeItem, + option === this.#controller.atFocusedItem, )); } } @@ -317,26 +319,30 @@ export class ListboxController implements ReactiveCont */ #updateMultiselect( currentItem?: Item, - referenceItem = this.activeItem, + referenceItem = this.#controller.atFocusedItem, ctrlA = false, ) { - if (referenceItem && this._options.multi && !this.disabled && currentItem) { + 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), + this.items.indexOf(referenceItem), + this.items.indexOf(currentItem), ].sort(); - const options = [...this.options].slice(start, end + 1); + const items = [...this.items].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; + const allSelected = this.items + .filter(this.#isSelectableItem) + .filter(item => this.#isSelectableItem(item) + && !this.#options.isSelected(item)) + .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)); + const selected = ctrlA ? !allSelected : this.#options.isSelected(referenceItem); + for (const item of items.filter(this.#isSelectableItem)) { + this.#options.requestSelect(item, selected); + } // update starting item for other multiselect this.#shiftStartingItem = currentItem; @@ -350,21 +356,11 @@ export class ListboxController implements ReactiveCont 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 + for (const item of this.items) { + this.#options.requestSelect(item, ( + !!this.multi && Array.isArray(value) ? value?.includes(item) + : firstItem === item )); } } - - /** - * 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/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index 5d2f312d63..6387a3d0ce 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -1,16 +1,8 @@ -import type { ReactiveController, ReactiveControllerHost } from 'lit'; -import type { ListboxAccessibilityController } from './listbox-controller.js'; +import type { ReactiveControllerHost } from 'lit'; +import { ATFocusController, type ATFocusControllerOptions } from './at-focus-controller.js'; -const isFocusableElement = (el: Element): el is HTMLElement => - !!el - && !el.ariaHidden - && !el.hasAttribute('hidden'); - -export interface RovingTabindexControllerOptions { - getItems: () => Item[]; - getItemsContainer?: () => HTMLElement | null; - getOrientation?(): 'horizontal' | 'vertical' | 'undefined'; -} +export type RovingTabindexControllerOptions = + ATFocusControllerOptions; /** * Implements roving tabindex, as described in WAI-ARIA practices, [Managing Focus Within @@ -20,12 +12,7 @@ export interface RovingTabindexControllerOptions { */ export class RovingTabindexController< Item extends HTMLElement = HTMLElement -> implements ReactiveController, ListboxAccessibilityController { - private static hosts = new WeakMap(); - - private static elements: WeakMap> = - new WeakMap(); - +> extends ATFocusController { static of( host: ReactiveControllerHost, options: RovingTabindexControllerOptions, @@ -33,246 +20,67 @@ export class RovingTabindexController< return new RovingTabindexController(host, options); } - /** active focusable element */ - #activeItem?: Item; - - /** closest ancestor containing items */ - #itemsContainer?: Element; - - /** 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); - } + /** Whether the host's element has gained focus at least once */ + private gainedInitialFocus = false; - /** - * 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] - ); - } - - /** - * previous item after active item in array of focusable items - */ - get prevItem(): Item | undefined { - return ( - this.#activeIndex > 0 ? this.#focusableItems[this.#activeIndex - 1] - : this.lastItem - ); + override set itemsContainerElement(container: HTMLElement) { + super.itemsContainerElement = container; + container?.addEventListener('focusin', () => { + this.gainedInitialFocus = true; + }, { once: true }); } - #options: Required>; - private constructor( public host: ReactiveControllerHost, options: RovingTabindexControllerOptions, ) { - this.#options = options as Required>; - this.#options.getItemsContainer ??= () => host instanceof HTMLElement ? host : null; - this.#options.getOrientation ??= () => - this.#options.getItemsContainer()?.getAttribute('aria-orientation') as - 'horizontal' | 'vertical' | 'undefined'; - const instance = RovingTabindexController.hosts.get(host); - if (instance) { - return instance as RovingTabindexController; - } - RovingTabindexController.hosts.set(host, this); - this.host.addController(this); - this.updateItems(); + super(host, options); + this.items = options.getItems(); } - hostUpdated(): void { - const oldContainer = this.#itemsContainer; - const newContainer = this.#options.getItemsContainer(); - if (oldContainer !== newContainer) { - oldContainer?.removeEventListener('keydown', this.#onKeydown); - RovingTabindexController.elements.delete(oldContainer!); - this.updateItems(); - } - if (newContainer) { - this.#initContainer(newContainer); - } + protected override isRelevantKeyboardEvent(event: Event): event is KeyboardEvent { + return ((event instanceof KeyboardEvent) + && !event.ctrlKey + && !event.altKey + && !event.metaKey + && !!this.atFocusableItems.length + && !!event.composedPath().some(x => + this.atFocusableItems.includes(x as Item))); } - /** - * removes event listeners from items container - */ + /** Resets initial focus */ hostDisconnected(): void { - this.#itemsContainer?.removeEventListener('keydown', this.#onKeydown); - this.#itemsContainer = undefined; - this.#gainedInitialFocus = false; + this.gainedInitialFocus = false; + } + + override get atFocusedItem() { + return super.atFocusedItem; } /** - * Sets the focus of assistive technology to the item - * In the case of Roving Tab Index, also sets the DOM Focus + * Sets the DOM Focus on the item with assistive technology focus * @param item item */ - public setATFocus(item?: Item): void { - this.#activeItem = item; - for (const item of this.#focusableItems) { - item.tabIndex = this.#activeItem === item ? 0 : -1; + override set atFocusedItem(item: Item | null) { + for (const focusable of this.atFocusableItems) { + focusable.tabIndex = item === focusable ? 0 : -1; } - this.host.requestUpdate(); - if (this.#gainedInitialFocus) { - this.#activeItem?.focus(); + if (this.gainedInitialFocus) { + item?.focus(); } + super.atFocusedItem = item; } - /** - * Focuses next focusable item - * @param items tabindex items - */ - public 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.setATFocus(activeItem); - } - - #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 }); + public set items(items: Item[]) { + super.items = items; + const pivot = this.atFocusableItems.indexOf(this.atFocusedItem!) - 1; + this.atFocusedItem = + this.atFocusableItems.at(0) + ?? this.items + .slice(pivot) + .concat(this.items.slice(0, pivot)) + .find(item => this.atFocusableItems.includes(item)) + ?? this.firstATFocusableItem + ?? null; } - - /** - * 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.getOrientation?.(); - const verticalOnly = orientation === 'vertical'; - const item = this.activeItem; - const horizontalOnly = - orientation === 'horizontal' - || item?.localName === 'select' - || item?.getAttribute('role') === 'spinbutton'; - - switch (event.key) { - case 'ArrowLeft': - if (verticalOnly) { - return; - } - this.setATFocus(this.prevItem); - event.stopPropagation(); - event.preventDefault(); - break; - case 'ArrowRight': - if (verticalOnly) { - return; - } - this.setATFocus(this.nextItem); - event.stopPropagation(); - event.preventDefault(); - break; - case 'ArrowUp': - if (horizontalOnly) { - return; - } - this.setATFocus(this.prevItem); - event.stopPropagation(); - event.preventDefault(); - break; - case 'ArrowDown': - if (horizontalOnly) { - return; - } - this.setATFocus(this.nextItem); - event.stopPropagation(); - event.preventDefault(); - break; - case 'Home': - this.setATFocus(this.firstItem); - event.stopPropagation(); - event.preventDefault(); - break; - case 'End': - this.setATFocus(this.lastItem); - event.stopPropagation(); - event.preventDefault(); - break; - default: - break; - } - }; } diff --git a/elements/pf-accordion/pf-accordion-header.ts b/elements/pf-accordion/pf-accordion-header.ts index 3ae5f97612..140abf79a0 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(); } diff --git a/elements/pf-accordion/pf-accordion.ts b/elements/pf-accordion/pf-accordion.ts index 74b6d04a4d..cd9a7ea7c6 100644 --- a/elements/pf-accordion/pf-accordion.ts +++ b/elements/pf-accordion/pf-accordion.ts @@ -233,15 +233,13 @@ 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.setATFocus(this.#activeHeader); + if (this.#activeHeader !== this.#headerIndex.atFocusedItem) { + this.#headerIndex.atFocusedItem = this.#activeHeader ?? null; } } @@ -322,7 +320,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 diff --git a/elements/pf-chip/pf-chip-group.ts b/elements/pf-chip/pf-chip-group.ts index a1eafd0f53..c6197b607d 100644 --- a/elements/pf-chip/pf-chip-group.ts +++ b/elements/pf-chip/pf-chip-group.ts @@ -154,14 +154,14 @@ export class PfChipGroup extends LitElement { * active chip that receives focus when group receives focus */ get activeChip() { - const button = this.#tabindex.activeItem as HTMLElement; + const button = this.#tabindex.atFocusedItem as HTMLElement; const shadow = button?.getRootNode() as ShadowRoot; return shadow?.host as PfChip; } set activeChip(chip: HTMLElement) { const button = chip.shadowRoot?.querySelector('button') as HTMLElement; - this.#tabindex.setATFocus(button); + this.#tabindex.atFocusedItem = button; } /** @@ -189,7 +189,7 @@ export class PfChipGroup extends LitElement { ].filter((x): x is PfChip => !!x); if (oldButtons.length !== this.#buttons.length || !oldButtons.every((element, index) => element === this.#buttons[index])) { - this.#tabindex.updateItems(); + this.#tabindex.items = (this.#chips); } this.#updateOverflow(); } @@ -203,7 +203,7 @@ export class PfChipGroup extends LitElement { if (event instanceof PfChipRemoveEvent) { await this.#updateChips(); await this.updateComplete; - this.#tabindex.setATFocus(this.#tabindex.activeItem); + // this.#tabindex.setATFocus(this.#tabindex.atFocusedItem); } } @@ -243,7 +243,7 @@ export class PfChipGroup extends LitElement { this.#chips = [...this.querySelectorAll('pf-chip:not([slot]):not([overflow-chip])')]; this.requestUpdate(); await this.updateComplete; - this.#tabindex.updateItems(this.#chips); + this.#tabindex.items = (this.#chips); this.#handleChipsChanged(); return this.#chips; } @@ -264,7 +264,7 @@ export class PfChipGroup extends LitElement { * @param chip pf-chip element */ focusOnChip(chip: HTMLElement): void { - this.#tabindex.setATFocus(chip); + this.#tabindex.atFocusedItem = chip; } } diff --git a/elements/pf-dropdown/pf-dropdown-menu.ts b/elements/pf-dropdown/pf-dropdown-menu.ts index 6dcc23ab5c..71bd8cb44e 100644 --- a/elements/pf-dropdown/pf-dropdown-menu.ts +++ b/elements/pf-dropdown/pf-dropdown-menu.ts @@ -39,25 +39,29 @@ export class PfDropdownMenu extends LitElement { #internals = InternalsController.of(this, { role: 'menu' }); + get #items() { + return this.items.map(x => x.menuItem); + } + #tabindex = RovingTabindexController.of(this, { - getItems: () => this.items.map(x => x.menuItem), + getItems: () => this.#items, }); /** * current active descendant in menu */ get activeItem(): HTMLElement | undefined { - return this.#tabindex.activeItem ?? this.#tabindex.firstItem; + return this.#tabindex.atFocusedItem ?? this.#tabindex.firstATFocusableItem; } /** * index of current active descendant in menu */ get activeIndex(): number { - if (!this.#tabindex.activeItem) { + if (!this.#tabindex.atFocusedItem) { return -1; } else { - return this.#tabindex.items.indexOf(this.#tabindex.activeItem); + return this.#tabindex.items.indexOf(this.#tabindex.atFocusedItem); } } @@ -90,7 +94,7 @@ export class PfDropdownMenu extends LitElement { */ #onItemChange(event: Event) { if (event instanceof DropdownItemChange) { - this.#tabindex.updateItems(); + this.#onSlotChange(); } } @@ -98,7 +102,7 @@ export class PfDropdownMenu extends LitElement { * handles slot change event */ #onSlotChange() { - this.#tabindex.updateItems(); + this.#tabindex.items = this.#items; } /** @@ -111,8 +115,8 @@ export class PfDropdownMenu extends LitElement { event.preventDefault(); event.stopPropagation(); } else if (event.target instanceof PfDropdownItem - && event.target.menuItem !== this.#tabindex.activeItem) { - this.#tabindex.setATFocus(event.target.menuItem); + && event.target.menuItem !== this.#tabindex.atFocusedItem) { + this.#tabindex.atFocusedItem = event.target.menuItem; } } @@ -127,8 +131,8 @@ export class PfDropdownMenu extends LitElement { event.preventDefault(); event.stopPropagation(); } else if (event.target instanceof PfDropdownItem - && event.target.menuItem !== this.#tabindex.activeItem) { - this.#tabindex.setATFocus(event.target.menuItem); + && event.target.menuItem !== this.#tabindex.atFocusedItem) { + this.#tabindex.atFocusedItem = event.target.menuItem; } } diff --git a/elements/pf-jump-links/pf-jump-links.ts b/elements/pf-jump-links/pf-jump-links.ts index e447f93dbf..02dd3d8fc6 100644 --- a/elements/pf-jump-links/pf-jump-links.ts +++ b/elements/pf-jump-links/pf-jump-links.ts @@ -77,12 +77,16 @@ export class PfJumpLinks extends LitElement { #kids = this.querySelectorAll?.(':is(pf-jump-links-item, pf-jump-links-list)'); - #tabindex = RovingTabindexController.of(this, { - getItems: () => Array.from(this.#kids) + get #items() { + return Array.from(this.#kids) .flatMap(i => [ ...i.shadowRoot?.querySelectorAll?.('a') ?? [], ...i.querySelectorAll?.('a') ?? [], - ]), + ]); + } + + #tabindex = RovingTabindexController.of(this, { + getItems: () => this.#items, }); #spy = new ScrollSpyController(this, { @@ -98,7 +102,7 @@ export class PfJumpLinks extends LitElement { override connectedCallback(): void { super.connectedCallback(); - this.addEventListener('slotchange', this.#updateItems); + this.addEventListener('slotchange', this.#onSlotChange); this.addEventListener('select', this.#onSelect); } @@ -135,8 +139,8 @@ export class PfJumpLinks extends LitElement { `; } - #updateItems() { - this.#tabindex.updateItems(); + #onSlotChange() { + this.#tabindex.items = this.#items; } #onSelect(event: Event) { @@ -146,7 +150,7 @@ export class PfJumpLinks extends LitElement { } #setActiveItem(item: PfJumpLinksItem) { - this.#tabindex.setATFocus(item.shadowRoot?.querySelector?.('a') ?? undefined); + this.#tabindex.atFocusedItem = item.shadowRoot?.querySelector?.('a') ?? null; this.#spy.setActive(item); } diff --git a/elements/pf-select/pf-select.ts b/elements/pf-select/pf-select.ts index 56c410a8e4..86f4984924 100644 --- a/elements/pf-select/pf-select.ts +++ b/elements/pf-select/pf-select.ts @@ -1,6 +1,7 @@ import type { PfChipGroup } from '../pf-chip/pf-chip-group.js'; import type { Placement } from '@patternfly/pfe-core/controllers/floating-dom-controller.js'; import type { PropertyValues, TemplateResult } from 'lit'; +import type { ATFocusController } from '@patternfly/pfe-core/controllers/at-focus-controller.js'; import { LitElement, html, isServer, nothing } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; @@ -144,24 +145,24 @@ export class PfSelect extends LitElement { return selected; }; - #a11yController = this.#getA11yController(); + #atFocusController = this.#createATFocusController(); #internals = InternalsController.of(this); - #float = new FloatingDOMController(this, { - content: () => this._listboxContainer, - }); + #float = new FloatingDOMController(this, { content: () => this._listboxContainer }); #slots = new SlotController(this, null, 'placeholder'); #listbox = ListboxController.of(this, { multi: this.variant === 'typeaheadmulti' || this.variant === 'checkbox', - getA11yController: () => this.#a11yController, + getATFocusController: () => this.#atFocusController, getItemsContainer: this.#getListboxContainer, isSelected: this.#isOptionSelected, requestSelect: this.#requestSelect, }); + #lastSelected = this.#listbox.value; + /** * Single select option value for single select menus, * or array of select option values for multi select. @@ -192,8 +193,6 @@ export class PfSelect extends LitElement { } } - #lastSelected = this.selected; - /** * whether select has badge for number of selected items */ @@ -205,10 +204,10 @@ export class PfSelect extends LitElement { get #buttonLabel() { switch (this.variant) { case 'typeaheadmulti': - return `${this.#listbox.selectedOptions?.length ?? 0} ${this.itemsSelectedText}`; + return `${this.#listbox.selectedItems?.length ?? 0} ${this.itemsSelectedText}`; case 'checkbox': return this.#listbox - .selectedOptions + .selectedItems .map(option => option.optionText || '') .join(' ') .trim() @@ -247,7 +246,7 @@ export class PfSelect extends LitElement { const { height, width } = this.getBoundingClientRect?.() || {}; const buttonLabel = this.#buttonLabel; const hasBadge = this.#hasBadge; - const selectedOptions = this.#listbox.selectedOptions ?? []; + const selectedOptions = this.#listbox.selectedItems ?? []; const typeahead = variant.startsWith('typeahead'); const checkboxes = variant === 'checkbox'; const offscreen = typeahead && 'offscreen'; @@ -327,8 +326,8 @@ export class PfSelect extends LitElement { ?hidden="${!this.placeholder && !this.#slots.hasSlotted('placeholder')}"> ${this.placeholder} - ${!(this.#a11yController instanceof ActivedescendantController) ? nothing - : this.#a11yController.renderItemsToShadowRoot()} + ${!(this.#atFocusController instanceof ActivedescendantController) ? nothing + : this.#atFocusController.renderItemsToShadowRoot()} @@ -356,29 +355,26 @@ export class PfSelect extends LitElement { this._input.value = ''; } } + this.#lastSelected = this.selected; } - override firstUpdated(): void { - // kick the renderer to that the placeholder gets picked up - this.requestUpdate(); - // TODO: don't do filtering in the controller - } - - #getA11yController() { + #createATFocusController(): ATFocusController { const getItems = () => this.options; const getItemsContainer = this.#getListboxContainer; - const getOwningElement = this.#getComboboxInput; if (this.variant.startsWith('typeahead')) { - return ActivedescendantController.of(this, { getItems, getItemsContainer, getOwningElement }); + return ActivedescendantController.of(this, { + getItems, getItemsContainer, + getItemsControlsElement: this.#getComboboxInput }); } else { return RovingTabindexController.of(this, { getItems, getItemsContainer }); } } #variantChanged() { + this.#listbox.hostDisconnected(); this.#listbox.multi = this.variant === 'typeaheadmulti' || this.variant === 'checkbox'; - this.#a11yController.hostDisconnected(); - this.#a11yController = this.#getA11yController(); + this.#atFocusController = this.#createATFocusController(); + this.#listbox.hostConnected(); } async #expandedChanged() { @@ -389,7 +385,9 @@ export class PfSelect extends LitElement { switch (this.variant) { case 'single': case 'checkbox': { - const focusableItem = this.#listbox.activeItem ?? this.#listbox.nextItem; + const focusableItem = + this.#atFocusController.atFocusedItem + ?? this.#atFocusController.nextATFocusableItem; focusableItem?.focus(); } } @@ -467,7 +465,10 @@ export class PfSelect extends LitElement { await this.show(); // TODO: thread the needle of passing state between controllers await new Promise(r => setTimeout(r)); - this._input!.value = this.#listbox.activeItem?.value ?? ''; + this._input!.value = + this.#atFocusController.atFocusedItem?.value + ?? this._input?.value + ?? ''; break; case 'Enter': this.hide(); @@ -492,7 +493,7 @@ export class PfSelect extends LitElement { } #onListboxSlotchange() { - this.#listbox.setOptions(this.options); + this.#listbox.items = this.options; this.options.forEach((option, index, options) => { option.setSize = options.length; option.posInSet = index; @@ -527,8 +528,9 @@ export class PfSelect extends LitElement { return this.placeholder || this.querySelector?.('[slot=placeholder]') ?.assignedNodes() - ?.reduce((acc, node) => `${acc}${node.textContent}`, '')?.trim() - || this.#listbox.options + ?.reduce((acc, node) => `${acc}${node.textContent}`, '') + ?.trim() + || this.#listbox.items .filter(this.#isNotPlaceholderOption) .at(0) ?.value diff --git a/elements/pf-tabs/pf-tabs.ts b/elements/pf-tabs/pf-tabs.ts index 943364a34b..987d12a802 100644 --- a/elements/pf-tabs/pf-tabs.ts +++ b/elements/pf-tabs/pf-tabs.ts @@ -157,13 +157,15 @@ export class PfTabs extends LitElement { return here && ps.every(x => !!x); } - override willUpdate(changed: PropertyValues): void { - if (changed.has('activeIndex')) { + #lastTab: PfTab | null = null; + + protected override willUpdate(changed: PropertyValues): void { + if (this.#lastTab !== this.#tabindex.atFocusedItem) { + this.#tabChanged(); + } else if (changed.has('activeIndex')) { this.select(this.activeIndex); } else if (changed.has('activeTab') && this.activeTab) { this.select(this.activeTab); - } else { - this.#updateActive(); } this.#overflow.update(); this.ctx = this.#ctx; @@ -231,11 +233,12 @@ export class PfTabs extends LitElement { } } - #updateActive({ force = false } = {}) { - if (!this.#tabindex.activeItem?.disabled) { + #tabChanged() { + this.#lastTab = this.#tabindex.atFocusedItem; + if (!this.#tabindex.atFocusedItem?.disabled) { this.tabs?.forEach((tab, i) => { - if (force || !this.manual) { - const active = tab === this.#tabindex.activeItem; + if (!this.manual) { + const active = tab === this.#tabindex.atFocusedItem; tab.active = active; if (active) { this.activeIndex = i; @@ -250,11 +253,10 @@ export class PfTabs extends LitElement { select(option: PfTab | number): void { if (typeof option === 'number') { const item = this.tabs[option]; - this.#tabindex.setATFocus(item); + this.#tabindex.atFocusedItem = item; } else { - this.#tabindex.setATFocus(option); + this.#tabindex.atFocusedItem = option; } - this.#updateActive({ force: true }); } } diff --git a/package.json b/package.json index bb3e9be319..3ed40f1953 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "leftover-check": "bash scripts/leftover-check.sh", "⚙️--UTIL": "❓ Manages the repo", "postinstall": "wireit", - "clean": "git clean -dfx -e node_modules -e .husky -e .wireit", + "clean": "git clean -dfx -e node_modules -e .husky", "nuke": "git clean -dfx", "lint": "eslint", "patch": "patch-package", From a8f9ae80b1f1e074d4cd65ba0e58b013a55a03e6 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Mon, 22 Jul 2024 14:26:46 +0300 Subject: [PATCH 009/122] fix!: inching towards correctness --- .../activedescendant-controller.ts | 173 +++++++---- .../controllers/at-focus-controller.ts | 73 ++--- .../controllers/listbox-controller.ts | 275 ++++++++---------- .../property-observer-controller.ts | 38 ++- .../controllers/roving-tabindex-controller.ts | 68 ++--- core/pfe-core/decorators/observes.ts | 2 +- .../pfe-core/functions/arraysAreEquivalent.ts | 23 ++ core/pfe-core/package.json | 2 + elements/pf-dropdown/pf-dropdown-menu.ts | 2 +- elements/pf-select/pf-option.css | 2 +- elements/pf-select/pf-option.ts | 27 +- elements/pf-select/pf-select.ts | 222 +++++++------- 12 files changed, 477 insertions(+), 430 deletions(-) create mode 100644 core/pfe-core/functions/arraysAreEquivalent.ts diff --git a/core/pfe-core/controllers/activedescendant-controller.ts b/core/pfe-core/controllers/activedescendant-controller.ts index 181e91d3b3..aab2a6cbc7 100644 --- a/core/pfe-core/controllers/activedescendant-controller.ts +++ b/core/pfe-core/controllers/activedescendant-controller.ts @@ -8,7 +8,19 @@ import { getRandomId } from '../functions/random.js'; export interface ActivedescendantControllerOptions< Item extends HTMLElement > extends ATFocusControllerOptions { + /** + * Function returning the DOM node which is the accessibility controller of the listbox + * e.g. the button element associated with the combobox. + */ getItemsControlsElement: () => 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; } /** @@ -79,19 +91,39 @@ export class ActivedescendantController< /** Element which controls the list i.e. combobox */ #itemsControlsElement: HTMLElement | null = null; - #options: ActivedescendantControllerOptions; + #observing = false; - private constructor( - public host: ReactiveControllerHost, - options: ActivedescendantControllerOptions, - ) { - super(host, options); - this.#options = options; - this.itemsControlsElement = this.#options.getItemsControlsElement(); + #mo = new MutationObserver(records => this.onMutation(records)); + + #atFocusedItem: Item | null = null; + + get atFocusedItem(): Item | null { + return this.#atFocusedItem; } - hostConnected(): void { - this.itemsControlsElement = this.#options.getItemsControlsElement(); + /** + * 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 atFocusedItem(item: Item | null) { + this.#atFocusedItem = item; + for (const i of this.items) { + this.options.setItemActive?.call(i, i === item); + } + if (this.itemsContainerElement && this.itemsControlsElement) { + if (ActivedescendantController.IDLAttrsSupported) { + this.itemsControlsElement.ariaActiveDescendantElement = item ?? null; + } else { + for (const el of [ + this.itemsContainerElement, + this.itemsControlsElement, // necessary for ff mac voiceover + ]) { + el?.setAttribute('aria-activedescendant', item?.id ?? ''); + } + } + } + this.host.requestUpdate(); } get itemsControlsElement() { @@ -107,44 +139,32 @@ export class ActivedescendantController< } } - public renderItemsToShadowRoot(): typeof nothing | Node[] { - if (ActivedescendantController.canControlLightDom()) { - return nothing; - } else { - return this.items.filter(x => !this.#noCloneSet.has(x)); - } + get items() { + return this._items; } - protected override isRelevantKeyboardEvent(event: Event): event is KeyboardEvent { - return !(!(event instanceof KeyboardEvent) - || event.ctrlKey - || event.altKey - || event.metaKey - || !this.atFocusableItems.length); - } - - #registerItemsPriorToPossiblyCloning = (item: Item) => { - if (this.itemsContainerElement?.contains(item)) { - item.id ||= getRandomId(); - this.#noCloneSet.add(item); - return item; - } else { - const clone = item.cloneNode(true) as Item; - this.#cloneMap.set(item, clone); - clone.id = getRandomId(); - return clone; - } - }; - /** * Sets the list of items and activates the next activatable item after the current one * @param items tabindex items */ override set items(items: Item[]) { - super.items = (ActivedescendantController.IDLAttrsSupported ? items - : items - ?.map(this.#registerItemsPriorToPossiblyCloning) - ?.filter(x => !!x)); + const container = this.ensureContainer(); + this._items = (ActivedescendantController.IDLAttrsSupported ? items + : items + ?.map((item: Item) => { + item.removeAttribute('tabindex'); + if (container.contains(item)) { + item.id ||= getRandomId(); + this.#noCloneSet.add(item); + return item; + } else { + const clone = item.cloneNode(true) as Item; + this.#cloneMap.set(item, clone); + clone.id = getRandomId(); + return clone; + } + }) + ?.filter(x => !!x)); const [first] = this.atFocusableItems; const atFocusedItemIndex = this.atFocusableItems.indexOf(this.atFocusedItem!); const next = this.atFocusableItems.find(((_, i) => i !== atFocusedItemIndex)); @@ -152,25 +172,60 @@ export class ActivedescendantController< this.atFocusedItem = activeItem; } - /** - * Rather than setting DOM focus, applies the `aria-activedescendant` attribute, - * using AriaIDLAttributes for cross-root aria, if supported by the browser - * @param item item - */ - override set atFocusedItem(item: Item | null) { - super.atFocusedItem = item; - if (this.itemsContainerElement && this.itemsControlsElement) { - if (ActivedescendantController.IDLAttrsSupported) { - this.itemsControlsElement.ariaActiveDescendantElement = item ?? null; - } else { - for (const el of [ - this.itemsContainerElement, - this.itemsControlsElement, - ]) { - el?.setAttribute('aria-activedescendant', item?.id ?? ''); - } + private constructor( + public host: ReactiveControllerHost, + protected options: ActivedescendantControllerOptions, + ) { + super(host, options); + } + + private onMutation = (records: MutationRecord[]) => { + // todo: respond to attrs changing on lightdom nodes + for (const { removedNodes } of records) { + for (const removed of removedNodes as NodeListOf) { + this.#cloneMap.get(removed)?.remove(); + this.#cloneMap.delete(removed); } } - this.host.requestUpdate(); + }; + + protected override initItems(): void { + super.initItems(); + this.itemsControlsElement ??= this.options.getItemsControlsElement(); + if (!this.#observing && this.itemsContainerElement && this.itemsContainerElement.isConnected) { + this.#mo.observe(this.itemsContainerElement, { attributes: true, childList: true }); + this.#observing = true; + } + } + + hostDisconnected(): void { + this.#observing = false; + this.#mo.disconnect(); + } + + private ensureContainer() { + const container = this.options.getItemsContainer?.() ?? this.host; + if (!(container instanceof HTMLElement)) { + throw new Error('items container must be an HTMLElement'); + } + this.itemsContainerElement = container; + return container; + } + + 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 index bf92819ffb..839aeedf09 100644 --- a/core/pfe-core/controllers/at-focus-controller.ts +++ b/core/pfe-core/controllers/at-focus-controller.ts @@ -13,41 +13,23 @@ export interface ATFocusControllerOptions { } export abstract class ATFocusController { - // funny name to prevent transpiled private access errors - #optionz: ATFocusControllerOptions; - #itemsContainerElement: HTMLElement | null = null; - #atFocusedItem: Item | null = null; - - #items: Item[] = []; - get #atFocusedItemIndex() { - return this.atFocusableItems.indexOf(this.#atFocusedItem!); + return this.atFocusableItems.indexOf(this.atFocusedItem!); } + protected _items: Item[] = []; + /** All items */ - get items(): Item[] { - return this.#items; - } + abstract items: Item[]; - set items(items: Item[]) { - this.#items = items; - } + /** Item which currently has assistive technology focus */ + abstract atFocusedItem: Item | null; /** All items which are able to receive assistive technology focus */ get atFocusableItems(): Item[] { - return this.#items.filter(isATFocusableItem); - } - - /** Item which currently has assistive technology focus */ - get atFocusedItem(): Item | null { - return this.#atFocusedItem; - } - - set atFocusedItem(item: Item | null) { - this.#atFocusedItem = item; - this.host.requestUpdate(); + return this.items.filter(isATFocusableItem); } /** First item which is able to receive assistive technology focus */ @@ -82,19 +64,23 @@ export abstract class ATFocusController { set itemsContainerElement(container: HTMLElement | null) { if (container !== this.#itemsContainerElement) { - this.#itemsContainerElement?.removeEventListener('keydown', this.#onKeydown); + this.#itemsContainerElement?.removeEventListener('keydown', this.onKeydown); this.#itemsContainerElement = container; - this.#itemsContainerElement?.addEventListener('keydown', this.#onKeydown); - this.items = this.#optionz.getItems(); + this.#itemsContainerElement?.addEventListener('keydown', this.onKeydown); this.host.requestUpdate(); } } - constructor(public host: ReactiveControllerHost, options: ATFocusControllerOptions) { - this.#optionz = options; - if (host instanceof HTMLElement && host.isConnected) { - this.hostConnected(); - } + constructor( + public host: ReactiveControllerHost, + protected options: ATFocusControllerOptions, + ) { + this.host.updateComplete.then(() => this.initItems()); + } + + protected initItems(): void { + this.items = this.options.getItems(); + this.itemsContainerElement ??= this.#initContainer(); } hostConnected(): void { @@ -103,21 +89,23 @@ export abstract class ATFocusController { hostUpdate(): void { this.atFocusedItem ??= this.firstATFocusableItem; - this.itemsContainerElement - ??= this.#optionz.getItemsContainer?.() + this.itemsContainerElement ??= this.#initContainer(); + } + + #initContainer() { + return this.options.getItemsContainer?.() ?? (this.host instanceof HTMLElement ? this.host : null); } protected abstract isRelevantKeyboardEvent(event: Event): event is KeyboardEvent; - /** DO NOT OVERRIDE */ - protected onKeydown(event: Event): void { - this.#onKeydown(event); - } - - #onKeydown = (event: Event) => { + /** + * DO NOT OVERRIDE + * @param event keyboard event + */ + protected onKeydown = (event: Event): void => { if (this.isRelevantKeyboardEvent(event)) { - const orientation = this.#optionz.getOrientation?.() ?? this + const orientation = this.options.getOrientation?.() ?? this .#itemsContainerElement ?.getAttribute('aria-orientation') as 'horizontal' | 'vertical' | 'grid' | 'undefined'; @@ -177,6 +165,7 @@ export abstract class ATFocusController { default: break; } + this.host.requestUpdate(); } }; } diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index 4ca210a9c1..260de687cf 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -1,4 +1,3 @@ -import type { ATFocusController } from './at-focus-controller'; import type { ReactiveController, ReactiveControllerHost } from 'lit'; import { isServer } from 'lit'; @@ -6,14 +5,41 @@ import { isServer } from 'lit'; /** * Filtering, multiselect, and orientation options for listbox */ -export interface ListboxControllerOptions { +export interface ListboxControllerOptions { + /** + * Whether the listbox supports multiple selections. + */ multi?: boolean; - getATFocusController(): ATFocusController; - 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; + /** + * 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; } +function setItemSelected(this: Item, selected: boolean) { + if (selected) { + this.setAttribute('aria-selected', 'true'); + } else { + this?.removeAttribute('aria-selected'); + } +} + let constructingAllowed = false; /** @@ -58,7 +84,7 @@ let constructingAllowed = false; export class ListboxController implements ReactiveController { private static instances = new WeakMap< ReactiveControllerHost, - ListboxController + ListboxController >(); public static of( @@ -71,60 +97,20 @@ export class ListboxController implements ReactiveCont return instance as ListboxController; } - private constructor( - public host: ReactiveControllerHost, - options: ListboxControllerOptions, - ) { - this.#options = 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(' ')); - } - if (!this.#controller) { - throw new Error([ - 'ListboxController requires an additional keyboard accessibility controller.', - 'Provide a getA11yController function which returns either a RovingTabindexController', - 'or an ActiveDescendantController', - ].join(' ')); - } - const instance = ListboxController.instances.get(host); - if (instance) { - return instance as ListboxController; - } - ListboxController.instances.set(host, this); - this.host.addController(this); - if (this.#itemsContainer?.isConnected) { - this.hostConnected(); - } - } - /** Current active descendant when shift key is pressed */ #shiftStartingItem: Item | null = null; #options: ListboxControllerOptions; - /** All options that will not be hidden by a filter */ + /** All items */ #items: Item[] = []; #listening = false; - #lastATFocusedItem?: Item | null; - get #itemsContainer() { return this.#options.getItemsContainer?.() ?? this.host as unknown as HTMLElement; } - get #controller() { - return this.#options.getATFocusController(); - } - /** Whether listbox is disabled */ disabled = false; @@ -148,16 +134,54 @@ export class ListboxController implements ReactiveCont this.#items = items; } + #selectedItems = new Set; + + /** + * sets the listbox value based on selected options + * @param selected item or items + */ + set selected(selected: Item | Item[] | null) { + if (selected == null) { + this.#selectedItems = new Set; + } else { + this.#selectedItems = new Set(Array.isArray(selected) ? selected : [selected]); + } + this.host.requestUpdate(); + } + /** * array of options which are selected */ - get selectedItems(): Item[] { - return this.items.filter(option => this.#options.isSelected(option)); + get selected(): Item[] { + return [...this.#selectedItems]; } - get value(): Item | Item[] { - const [firstItem] = this.selectedItems; - return this.#options.multi ? this.selectedItems : firstItem; + private constructor( + public host: ReactiveControllerHost, + options: ListboxControllerOptions, + ) { + this.#options = 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; + } + this.#options.setItemSelected ??= setItemSelected; + ListboxController.instances.set(host, this as unknown as ListboxController); + this.host.addController(this); + if (this.#itemsContainer?.isConnected) { + this.hostConnected(); + } } async hostConnected(): Promise { @@ -170,24 +194,12 @@ export class ListboxController implements ReactiveCont } } - hostUpdate(): void { - const { atFocusedItem } = this.#controller; - if (atFocusedItem && atFocusedItem !== this.#lastATFocusedItem) { - this.#options.requestSelect(atFocusedItem); - } - this.#lastATFocusedItem = atFocusedItem; - } - hostUpdated(): void { this.#itemsContainer?.setAttribute('role', 'listbox'); this.#itemsContainer?.setAttribute('aria-disabled', String(!!this.disabled)); this.#itemsContainer?.setAttribute('aria-multi-selectable', String(!!this.#options.multi)); - for (const option of this.#controller.items) { - if (this.#controller.atFocusedItem === option) { - option.setAttribute('aria-selected', 'true'); - } else { - option?.removeAttribute('aria-selected'); - } + for (const item of this.items) { + this.#options.setItemSelected.call(item, this.isSelected(item)); } } @@ -198,6 +210,10 @@ export class ListboxController implements ReactiveCont this.#listening = false; } + public isSelected(item: Item): boolean { + return this.#selectedItems.has(item); + } + #isSelectableItem = (item: Item) => !item.ariaDisabled && !item.closest('[disabled]'); #getItemFromEvent(event: Event): Item | undefined { @@ -215,21 +231,19 @@ export class ListboxController implements ReactiveCont #onClick = (event: MouseEvent) => { const target = this.#getItemFromEvent(event); if (target) { - const oldValue = this.value; - if (this.multi) { - if (!event.shiftKey) { - this.#options.requestSelect(target, !this.#options.isSelected(target)); - } else if (this.#shiftStartingItem && target) { - this.#updateMultiselect(target, this.#shiftStartingItem); - } - } else { + if (!this.multi) { // select target and deselect all other options - this.items.forEach(option => this.#options.requestSelect(option, option === target)); - } - if (oldValue !== this.value) { - this.host.requestUpdate(); + this.selected = target; + } else if (!event.shiftKey) { + this.selected = this.items // todo: improve this intercalation + .map(item => item === target || this.isSelected(item) ? item : null) + .filter(x => !!x); + } else if (this.#shiftStartingItem && target) { + this.selected = this.#getMultiSelection(target, this.#shiftStartingItem); + this.#shiftStartingItem = target; } } + this.host.requestUpdate(); }; /** @@ -240,7 +254,7 @@ export class ListboxController implements ReactiveCont const target = this.#getItemFromEvent(event); if (target && event.shiftKey && this.multi) { if (this.#shiftStartingItem && target) { - this.#updateMultiselect(target, this.#shiftStartingItem); + this.selected = this.#getMultiSelection(target, this.#shiftStartingItem); } if (event.key === 'Shift') { this.#shiftStartingItem = null; @@ -256,24 +270,26 @@ export class ListboxController implements ReactiveCont #onKeydown = (event: KeyboardEvent) => { const target = this.#getItemFromEvent(event); - if (!target || event.altKey || event.metaKey || !this.items.includes(target)) { + if (this.disabled || !target || event.altKey || event.metaKey || !this.items.includes(target)) { return; } - const first = this.#controller.firstATFocusableItem; - const last = this.#controller.lastATFocusableItem; - // need to set for keyboard support of multiselect if (event.key === 'Shift' && this.multi) { - this.#shiftStartingItem = this.#controller.atFocusedItem ?? null; + this.#shiftStartingItem = this.#options.getATFocusedItem() ?? null; } switch (event.key) { + // ctrl+A de/selects all options case 'a': case 'A': + // TODO: selectableItems if (event.ctrlKey) { - // ctrl+A selects all options - this.#updateMultiselect(first, last, true); + if (this.#selectedItems.size === this.items.filter(this.#isSelectableItem).length) { + this.#selectedItems = new Set(this.items); + } else { + this.#selectedItems = new Set; + } event.preventDefault(); } break; @@ -282,85 +298,42 @@ export class ListboxController implements ReactiveCont // enter and space are only applicable if a listbox option is clicked // an external text input should not trigger multiselect if (!this.multi) { - this.#updateSingleselect(); - } else if (event.shiftKey) { - this.#updateMultiselect(target); - } else if (!this.disabled) { - this.#options.requestSelect(target, !this.#options.isSelected(target)); + this.#selectedItems = new Set([target]); + } else if (this.multi && event.shiftKey) { + // update starting item for other multiselect + this.selected = this.#getMultiSelection(target, this.#options.getATFocusedItem()); + this.#shiftStartingItem = target; } event.preventDefault(); break; default: break; } + this.host.requestUpdate(); }; - /** - * updates option selections for single select listbox - */ - #updateSingleselect() { - if (!this.multi && !this.disabled) { - this.items - .filter(this.#isSelectableItem) - .forEach(option => - this.#options.requestSelect( - option, - option === this.#controller.atFocusedItem, - )); - } - } - /** * 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? + * @param to item being added + * @param from item already selected. */ - #updateMultiselect( - currentItem?: Item, - referenceItem = this.#controller.atFocusedItem, - ctrlA = false, - ) { - if (referenceItem && this.#options.multi && !this.disabled && currentItem) { - // select all options between active descendant and target - const [start, end] = [ - this.items.indexOf(referenceItem), - this.items.indexOf(currentItem), - ].sort(); - const items = [...this.items].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.items - .filter(this.#isSelectableItem) - .filter(item => this.#isSelectableItem(item) - && !this.#options.isSelected(item)) - .length === 0; - + #getMultiSelection(to?: Item, from = this.#options.getATFocusedItem()) { + if (from && to && this.#options.multi) { // whether options will be selected (true) or deselected (false) - const selected = ctrlA ? !allSelected : this.#options.isSelected(referenceItem); - for (const item of items.filter(this.#isSelectableItem)) { - this.#options.requestSelect(item, selected); - } - - // update starting item for other multiselect - this.#shiftStartingItem = currentItem; - } - } + const selecting = this.isSelected(from); - /** - * 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 item of this.items) { - this.#options.requestSelect(item, ( - !!this.multi && Array.isArray(value) ? value?.includes(item) - : firstItem === item - )); + // select all options between active descendant and target + // todo: flatten loops here, but be careful of off-by-one errors + // maybe use the new set methods difference/union + const [start, end] = [this.items.indexOf(from), this.items.indexOf(to)].sort(); + const itemsInRange = new Set(this.items + .slice(start, end + 1) + .filter(this.#isSelectableItem)); + return this.items + .filter(item => selecting ? itemsInRange.has(item) : !itemsInRange.has(item)); + } else { + return this.selected; } } } 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 6387a3d0ce..1b0e567179 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -20,59 +20,36 @@ export class RovingTabindexController< return new RovingTabindexController(host, options); } - /** Whether the host's element has gained focus at least once */ - private gainedInitialFocus = false; - override set itemsContainerElement(container: HTMLElement) { super.itemsContainerElement = container; - container?.addEventListener('focusin', () => { - this.gainedInitialFocus = true; - }, { once: true }); } - private constructor( - public host: ReactiveControllerHost, - options: RovingTabindexControllerOptions, - ) { - super(host, options); - this.items = options.getItems(); - } + #atFocusedItem: Item | null = null; - protected override isRelevantKeyboardEvent(event: Event): event is KeyboardEvent { - return ((event instanceof KeyboardEvent) - && !event.ctrlKey - && !event.altKey - && !event.metaKey - && !!this.atFocusableItems.length - && !!event.composedPath().some(x => - this.atFocusableItems.includes(x as Item))); - } - - /** Resets initial focus */ - hostDisconnected(): void { - this.gainedInitialFocus = false; - } - - override get atFocusedItem() { - return super.atFocusedItem; + get atFocusedItem(): Item | null { + return this.#atFocusedItem; } /** * Sets the DOM Focus on the item with assistive technology focus * @param item item */ - override set atFocusedItem(item: Item | null) { + set atFocusedItem(item: Item | null) { + this.#atFocusedItem = item; for (const focusable of this.atFocusableItems) { focusable.tabIndex = item === focusable ? 0 : -1; } - if (this.gainedInitialFocus) { - item?.focus(); - } - super.atFocusedItem = item; + item?.focus(); + this.host.requestUpdate(); + } + + + get items() { + return this._items; } public set items(items: Item[]) { - super.items = items; + this._items = items; const pivot = this.atFocusableItems.indexOf(this.atFocusedItem!) - 1; this.atFocusedItem = this.atFocusableItems.at(0) @@ -82,5 +59,24 @@ export class RovingTabindexController< .find(item => this.atFocusableItems.includes(item)) ?? this.firstATFocusableItem ?? null; + this.host.requestUpdate(); + } + + private constructor( + public host: ReactiveControllerHost, + options: RovingTabindexControllerOptions, + ) { + super(host, options); + this.items = options.getItems(); + } + + protected override isRelevantKeyboardEvent(event: Event): event is KeyboardEvent { + return ((event instanceof KeyboardEvent) + && !event.ctrlKey + && !event.altKey + && !event.metaKey + && !!this.atFocusableItems.length + && !!event.composedPath().some(x => + this.atFocusableItems.includes(x as Item))); } } 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..4fd5a43b18 --- /dev/null +++ b/core/pfe-core/functions/arraysAreEquivalent.ts @@ -0,0 +1,23 @@ +/** + * 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. + * @param a first array + * @param b second array + */ +export function arraysAreEquivalent(a: unknown, b: unknown): boolean { + if (!Array.isArray(a) || !Array.isArray(b)) { + return false; + } 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 1cdee0b671..ff8cd3b2ed 100644 --- a/core/pfe-core/package.json +++ b/core/pfe-core/package.json @@ -36,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/elements/pf-dropdown/pf-dropdown-menu.ts b/elements/pf-dropdown/pf-dropdown-menu.ts index 71bd8cb44e..2e7a046c12 100644 --- a/elements/pf-dropdown/pf-dropdown-menu.ts +++ b/elements/pf-dropdown/pf-dropdown-menu.ts @@ -50,7 +50,7 @@ export class PfDropdownMenu extends LitElement { /** * current active descendant in menu */ - get activeItem(): HTMLElement | undefined { + get activeItem(): HTMLElement | null { return this.#tabindex.atFocusedItem ?? this.#tabindex.firstATFocusableItem; } diff --git a/elements/pf-select/pf-option.css b/elements/pf-select/pf-option.css index 1b6172987b..93063af5d8 100644 --- a/elements/pf-select/pf-option.css +++ b/elements/pf-select/pf-option.css @@ -14,7 +14,7 @@ :host(:focus) #outer, :host(:hover) #outer, -:host([aria-selected="true"]) { +#outer.selected { background-color: #e0e0e0; } diff --git a/elements/pf-select/pf-option.ts b/elements/pf-select/pf-option.ts index 248d48b883..d2c166e0f0 100644 --- a/elements/pf-select/pf-option.ts +++ b/elements/pf-select/pf-option.ts @@ -1,4 +1,4 @@ -import { LitElement, html, type PropertyValues, type TemplateResult } from 'lit'; +import { LitElement, html, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { queryAssignedNodes } from 'lit/decorators/query-assigned-nodes.js'; import { property } from 'lit/decorators/property.js'; @@ -6,6 +6,8 @@ import { classMap } from 'lit/directives/class-map.js'; import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; +import { observes } from '@patternfly/pfe-core/decorators/observes.js'; + import styles from './pf-option.css'; /** @@ -35,7 +37,7 @@ export class PfOption extends LitElement { } /** whether option is selected */ - @property({ type: Boolean }) selected = false; + @property({ type: Boolean, reflect: true }) selected = false; /** whether option is active descendant */ @property({ type: Boolean }) active = false; @@ -83,9 +85,9 @@ export class PfOption extends LitElement { #internals = InternalsController.of(this, { role: 'option' }); render(): TemplateResult<1> { - const { disabled, active } = this; + const { disabled, active, selected } = this; return html` -
+
): void { - if (changed.has('selected') - // don't fire on initialization - && !(changed.get('selected') === undefined) && this.selected === false) { - this.#internals.ariaSelected = this.selected ? 'true' : 'false'; - } - if (changed.has('disabled')) { - this.#internals.ariaDisabled = String(!!this.disabled); - } + @observes('selected') + private selectedChanged() { + this.#internals.ariaSelected = String(!!this.selected); + } + + @observes('disabled') + private disabledChanged() { + this.#internals.ariaDisabled = String(!!this.disabled); } /** diff --git a/elements/pf-select/pf-select.ts b/elements/pf-select/pf-select.ts index 86f4984924..bf8ecd6c3f 100644 --- a/elements/pf-select/pf-select.ts +++ b/elements/pf-select/pf-select.ts @@ -1,6 +1,6 @@ import type { PfChipGroup } from '../pf-chip/pf-chip-group.js'; import type { Placement } from '@patternfly/pfe-core/controllers/floating-dom-controller.js'; -import type { PropertyValues, TemplateResult } from 'lit'; +import type { TemplateResult } from 'lit'; import type { ATFocusController } from '@patternfly/pfe-core/controllers/at-focus-controller.js'; import { LitElement, html, isServer, nothing } from 'lit'; @@ -19,6 +19,9 @@ import { SlotController } from '@patternfly/pfe-core/controllers/slot-controller import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; import { FloatingDOMController } from '@patternfly/pfe-core/controllers/floating-dom-controller.js'; +import { arraysAreEquivalent } from '@patternfly/pfe-core/functions/arraysAreEquivalent.js'; +import { observes } from '@patternfly/pfe-core/decorators/observes.js'; + import { PfOption } from './pf-option.js'; import { PfChipRemoveEvent } from '../pf-chip/pf-chip.js'; @@ -128,23 +131,8 @@ export class PfSelect extends LitElement { @query('#placeholder') private _placeholder?: PfOption; - #getListboxContainer = () => this._listbox ?? null; - - #getComboboxInput = () => this._input ?? null; - - #isOptionSelected = (option: PfOption) => option.selected; - #isNotPlaceholderOption = (option: PfOption) => option !== this._placeholder; - // TODO: differentiate between selection and focus in a11yControllers - #requestSelect = (option: PfOption, selected: boolean) => { - option.selected = !option.disabled && !!selected; - if (selected) { - this.selected = option; - } - return selected; - }; - #atFocusController = this.#createATFocusController(); #internals = InternalsController.of(this); @@ -155,26 +143,24 @@ export class PfSelect extends LitElement { #listbox = ListboxController.of(this, { multi: this.variant === 'typeaheadmulti' || this.variant === 'checkbox', - getATFocusController: () => this.#atFocusController, - getItemsContainer: this.#getListboxContainer, - isSelected: this.#isOptionSelected, - requestSelect: this.#requestSelect, + getItemsContainer: () => this._listbox ?? null, + getATFocusedItem: () => this.#atFocusController.atFocusedItem, + setItemSelected(selected) { + this.selected = selected; + }, }); - #lastSelected = this.#listbox.value; - /** * Single select option value for single select menus, * or array of select option values for multi select. */ + @property({ hasChanged: (a, b) => !arraysAreEquivalent(a, b) }) set selected(optionsList: PfOption | PfOption[]) { - this.#lastSelected = this.selected; - this.#listbox.setValue(optionsList); - this.requestUpdate('selected', this.#lastSelected); + this.#listbox.selected = optionsList; } - get selected(): PfOption | PfOption[] { - return this.#listbox.value; + get selected(): PfOption[] { + return this.#listbox.selected; } /** @@ -202,43 +188,24 @@ export class PfSelect extends LitElement { } get #buttonLabel() { + const { selected } = this.#listbox; switch (this.variant) { case 'typeaheadmulti': - return `${this.#listbox.selectedItems?.length ?? 0} ${this.itemsSelectedText}`; + return `${selected?.length ?? 0} ${this.itemsSelectedText}`; case 'checkbox': - return this.#listbox - .selectedItems + return selected .map(option => option.optionText || '') .join(' ') .trim() || this.#computePlaceholderText() || 'Options'; default: - return (this.selected ? this.value : '') + return (selected ? this.value : '') || this.#computePlaceholderText() || 'Select a value'; } } - override willUpdate(changed: PropertyValues): void { - if (this.variant === 'checkbox') { - import('@patternfly/elements/pf-badge/pf-badge.js'); - } - if (changed.has('variant')) { - this.#variantChanged(); - } - if (changed.has('selected')) { - this.#selectedChanged(changed.get('selected'), this.selected); - } - if (changed.has('value')) { - this.#internals.setFormValue(this.value ?? ''); - } - if (changed.has('disabled')) { - this.#listbox.disabled = this.disabled; - } - // TODO: handle filtering in the element, not the controller - } - override render(): TemplateResult<1> { const { disabled, expanded, variant } = this; const { anchor = 'bottom', alignment = 'start', styles = {} } = this.#float; @@ -246,7 +213,7 @@ export class PfSelect extends LitElement { const { height, width } = this.getBoundingClientRect?.() || {}; const buttonLabel = this.#buttonLabel; const hasBadge = this.#hasBadge; - const selectedOptions = this.#listbox.selectedItems ?? []; + const selectedOptions = this.#listbox.selected ?? []; const typeahead = variant.startsWith('typeahead'); const checkboxes = variant === 'checkbox'; const offscreen = typeahead && 'offscreen'; @@ -283,7 +250,7 @@ export class PfSelect extends LitElement { ?disabled="${disabled}" ?hidden="${!typeahead}" placeholder="${buttonLabel}" - @click="${() => this.toggle()}" + @click="${this.toggle}" @keydown="${this.#onButtonKeydown}" @input="${this.#onTypeaheadInput}">`}
@@ -306,17 +307,17 @@ export class PfSelect extends LitElement { #createATFocusController(): ATFocusController { const getItems = () => this.options; const getItemsContainer = () => this._listbox ?? null; - if (this.variant === 'typeahead' || this.variant === 'typeaheadmulti' ) { + if (this.variant !== 'typeahead' && this.variant !== 'typeaheadmulti' ) { + return RovingTabindexController.of(this, { getItems, getItemsContainer }); + } else { return ActivedescendantController.of(this, { getItems, getItemsContainer, - getItemsControlsElement: () => this._input ?? null, + getControlsElement: () => this._input ?? null, setItemActive(active) { this.active = active; }, }); - } else { - return RovingTabindexController.of(this, { getItems, getItemsContainer }); } } @@ -327,18 +328,14 @@ export class PfSelect extends LitElement { @observes('expanded') private async expandedChanged(old: boolean, expanded: boolean) { - const will = this.expanded ? 'close' : 'open'; - if (this.dispatchEvent(new Event(will))) { + if (this.dispatchEvent(new Event(this.expanded ? 'close' : 'open'))) { if (expanded) { await this.#float.show({ placement: this.position || 'bottom', flip: !!this.enableFlip }); switch (this.variant) { case 'single': - case 'checkbox': { - const focusableItem = - this.#atFocusController.atFocusedItem - ?? this.#atFocusController.nextATFocusableItem; - focusableItem?.focus(); - } + case 'checkbox': + (this.#atFocusController.atFocusedItem + ?? this.#atFocusController.nextATFocusableItem)?.focus(); } } else { await this.#float.hide(); From bd0ff4a6abb93eeac80129dd9c5268f48ea5ce62 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Tue, 23 Jul 2024 16:44:46 +0300 Subject: [PATCH 011/122] fix: inching closer --- .../activedescendant-controller.ts | 51 ++++++++----------- .../controllers/at-focus-controller.ts | 14 +++++ .../controllers/listbox-controller.ts | 44 ++++++++-------- elements/pf-select/pf-select.ts | 43 ++++++++-------- 4 files changed, 79 insertions(+), 73 deletions(-) diff --git a/core/pfe-core/controllers/activedescendant-controller.ts b/core/pfe-core/controllers/activedescendant-controller.ts index 96bf0005ee..3940b57101 100644 --- a/core/pfe-core/controllers/activedescendant-controller.ts +++ b/core/pfe-core/controllers/activedescendant-controller.ts @@ -8,11 +8,6 @@ import { getRandomId } from '../functions/random.js'; export interface ActivedescendantControllerOptions< Item extends HTMLElement > extends ATFocusControllerOptions { - /** - * Function returning the DOM node which is the accessibility controller of the listbox - * e.g. the button element associated with the combobox. - */ - getControlsElement: () => 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 @@ -83,11 +78,14 @@ export class ActivedescendantController< /** Maps from original element to shadow DOM clone */ #cloneMap = new WeakMap(); + /** Maps from shadow DOM clone to original element */ + #deCloneMap = new WeakMap(); + /** Set of item which should not be cloned */ #noCloneSet = new WeakSet(); /** Element which controls the list i.e. combobox */ - #itemsControlsElement: HTMLElement | null = null; + #controlsElements: HTMLElement[] = []; #observing = false; @@ -109,12 +107,9 @@ export class ActivedescendantController< for (const i of this.items) { this.options.setItemActive?.call(i, i === item); } - if (this.itemsContainerElement && this.itemsControlsElement) { - for (const el of [ - this.itemsContainerElement, // necessary for ff mac voiceover - this.itemsControlsElement, - ]) { - if (!ActivedescendantController.IDLAttrsSupported) { + if (this.itemsContainerElement) { + for (const el of [this.itemsContainerElement, ...this.#controlsElements]) { + if (!ActivedescendantController.canControlLightDom) { el?.setAttribute('aria-activedescendant', item?.id ?? ''); } else if (el) { el.ariaActiveDescendantElement = item ?? null; @@ -124,15 +119,16 @@ export class ActivedescendantController< this.host.requestUpdate(); } - get itemsControlsElement() { - return this.#itemsControlsElement ?? null; + get controlsElements(): HTMLElement[] { + return this.#controlsElements; } - set itemsControlsElement(element: HTMLElement | null) { - const oldController = this.#itemsControlsElement; - oldController?.removeEventListener('keydown', this.onKeydown); - this.#itemsControlsElement = element; - if (element && oldController !== element) { + 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); } } @@ -146,7 +142,11 @@ export class ActivedescendantController< * @param items tabindex items */ override set items(items: Item[]) { - const container = this.ensureContainer(); + const container = this.options.getItemsContainer?.() ?? this.host; + if (!(container instanceof HTMLElement)) { + throw new Error('items container must be an HTMLElement'); + } + this.itemsContainerElement = container; this._items = ActivedescendantController.canControlLightDom ? items : items?.map((item: Item) => { @@ -190,7 +190,7 @@ export class ActivedescendantController< protected override initItems(): void { super.initItems(); - this.itemsControlsElement ??= this.options.getControlsElement(); + this.controlsElements = this.options.getControlsElements?.() ?? []; if (!this.#observing && this.itemsContainerElement && this.itemsContainerElement.isConnected) { this.#mo.observe(this.itemsContainerElement, { attributes: true, childList: true }); this.#observing = true; @@ -202,15 +202,6 @@ export class ActivedescendantController< this.#mo.disconnect(); } - private ensureContainer() { - const container = this.options.getItemsContainer?.() ?? this.host; - if (!(container instanceof HTMLElement)) { - throw new Error('items container must be an HTMLElement'); - } - this.itemsContainerElement = container; - return container; - } - protected override isRelevantKeyboardEvent(event: Event): event is KeyboardEvent { return !(!(event instanceof KeyboardEvent) || event.ctrlKey diff --git a/core/pfe-core/controllers/at-focus-controller.ts b/core/pfe-core/controllers/at-focus-controller.ts index 839aeedf09..1e7ee37370 100644 --- a/core/pfe-core/controllers/at-focus-controller.ts +++ b/core/pfe-core/controllers/at-focus-controller.ts @@ -10,6 +10,11 @@ export interface ATFocusControllerOptions { getItems(): Item[]; getItemsContainer?(): HTMLElement | null; getOrientation?(): 'horizontal' | 'vertical' | 'grid' | 'undefined'; + /** + * Function returning the DOM node which is the accessibility controller of item container + * e.g. the button element in the combobox which is associated with the listbox. + */ + getControlsElements?(): HTMLElement[]; } export abstract class ATFocusController { @@ -27,6 +32,15 @@ export abstract class ATFocusController { /** Item which currently has assistive technology focus */ abstract atFocusedItem: Item | null; + 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(); + } + /** All items which are able to receive assistive technology focus */ get atFocusableItems(): Item[] { return this.items.filter(isATFocusableItem); diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index 5303a3b12e..755eb79b99 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -35,7 +35,7 @@ export interface ListboxControllerOptions { * Optional function returning an additional DOM node which controls the listbox, e.g. * a combobox input. */ - getControlsElement?(): HTMLElement | HTMLElement[] | null; + getControlsElements?(): HTMLElement | HTMLElement[] | null; } /** @@ -117,18 +117,18 @@ export class ListboxController implements ReactiveCont #listening = false; - get #itemsContainer() { + /** Whether listbox is disabled */ + disabled = false; + + get container(): HTMLElement { return this.#options.getItemsContainer?.() ?? this.host as unknown as HTMLElement; } - get #controlsElements(): HTMLElement[] { - const elementOrElements = this.#options.getControlsElement?.(); + get controlsElements(): HTMLElement[] { + const elementOrElements = this.#options.getControlsElements?.(); return [elementOrElements].filter(x => !!x).flat(); } - /** Whether listbox is disabled */ - disabled = false; - get multi(): boolean { return !!this.#options.multi; } @@ -193,7 +193,7 @@ export class ListboxController implements ReactiveCont } ListboxController.instances.set(host, this as unknown as ListboxController); this.host.addController(this); - if (this.#itemsContainer?.isConnected) { + if (this.container?.isConnected) { this.hostConnected(); } } @@ -201,30 +201,30 @@ export class ListboxController implements ReactiveCont async hostConnected(): Promise { if (!this.#listening) { await this.host.updateComplete; - this.#itemsContainer?.addEventListener('click', this.#onClick); - this.#itemsContainer?.addEventListener('keydown', this.#onKeydown); - this.#itemsContainer?.addEventListener('keyup', this.#onKeyup); - this.#controlsElements.forEach(el => el.addEventListener('keydown', this.#onKeydown)); - this.#controlsElements.forEach(el => el.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.#itemsContainer?.setAttribute('role', 'listbox'); - this.#itemsContainer?.setAttribute('aria-disabled', String(!!this.disabled)); - this.#itemsContainer?.setAttribute('aria-multi-selectable', String(!!this.#options.multi)); + this.container?.setAttribute('role', 'listbox'); + this.container?.setAttribute('aria-disabled', String(!!this.disabled)); + this.container?.setAttribute('aria-multi-selectable', String(!!this.#options.multi)); for (const item of this.items) { this.#options.setItemSelected.call(item, this.isSelected(item)); } } hostDisconnected(): void { - this.#itemsContainer?.removeEventListener('click', this.#onClick); - this.#itemsContainer?.removeEventListener('keydown', this.#onKeydown); - this.#itemsContainer?.removeEventListener('keyup', this.#onKeyup); - this.#controlsElements.forEach(el => el.removeEventListener('keydown', this.#onKeydown)); - this.#controlsElements.forEach(el => el.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; } @@ -289,7 +289,7 @@ export class ListboxController implements ReactiveCont const target = this.#getItemFromEvent(event); const item = target ?? this.#options.getATFocusedItem(); - if (this.disabled || !item || event.altKey || event.metaKey || !this.items.includes(item)) { + if (this.disabled || !item || event.altKey || event.metaKey) { return; } diff --git a/elements/pf-select/pf-select.ts b/elements/pf-select/pf-select.ts index 129ca83bfb..81fec74251 100644 --- a/elements/pf-select/pf-select.ts +++ b/elements/pf-select/pf-select.ts @@ -133,18 +133,36 @@ export class PfSelect extends LitElement { #isNotPlaceholderOption = (option: PfOption) => option !== this._placeholder; - #atFocusController = this.#createATFocusController(); - #internals = InternalsController.of(this); #float = new FloatingDOMController(this, { content: () => this._listboxContainer }); #slots = new SlotController(this, null, 'placeholder'); + #atFocusController = this.#createATFocusController(); + + #createATFocusController(): ATFocusController { + const getItems = () => this.options; + const getItemsContainer = () => this._listbox ?? null; + const getControlsElements = () => [this._input, this._toggle].filter(x => !!x); + if (this.variant !== 'typeahead' && this.variant !== 'typeaheadmulti' ) { + return RovingTabindexController.of(this, { getItems, getItemsContainer }); + } else { + return ActivedescendantController.of(this, { + getItems, + getItemsContainer, + getControlsElements, + setItemActive(active) { + this.active = active; + }, + }); + } + } + #listbox = ListboxController.of(this, { multi: this.variant === 'typeaheadmulti' || this.variant === 'checkbox', - getItemsContainer: () => this._listbox ?? null, - getControlsElement: () => this._input ?? null, + getItemsContainer: () => this.#atFocusController.container, + getControlsElements: () => this.#atFocusController.controlsElements, getATFocusedItem: () => this.#atFocusController.atFocusedItem, setItemSelected(selected) { this.selected = selected; @@ -304,23 +322,6 @@ export class PfSelect extends LitElement { `; } - #createATFocusController(): ATFocusController { - const getItems = () => this.options; - const getItemsContainer = () => this._listbox ?? null; - if (this.variant !== 'typeahead' && this.variant !== 'typeaheadmulti' ) { - return RovingTabindexController.of(this, { getItems, getItemsContainer }); - } else { - return ActivedescendantController.of(this, { - getItems, - getItemsContainer, - getControlsElement: () => this._input ?? null, - setItemActive(active) { - this.active = active; - }, - }); - } - } - @observes('disabled') private disabledChanged() { this.#listbox.disabled = this.disabled; From 9c4e1fd108e6879e6fe66e42b74810be5d71669d Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Wed, 24 Jul 2024 16:53:52 +0300 Subject: [PATCH 012/122] fix(core): index at focus item by number --- .../activedescendant-controller.ts | 85 ++++++++++++++----- .../controllers/at-focus-controller.ts | 30 +++---- .../controllers/listbox-controller.ts | 14 +-- .../controllers/roving-tabindex-controller.ts | 27 +++--- 4 files changed, 99 insertions(+), 57 deletions(-) diff --git a/core/pfe-core/controllers/activedescendant-controller.ts b/core/pfe-core/controllers/activedescendant-controller.ts index 3940b57101..7e087e04e7 100644 --- a/core/pfe-core/controllers/activedescendant-controller.ts +++ b/core/pfe-core/controllers/activedescendant-controller.ts @@ -76,10 +76,10 @@ export class ActivedescendantController< } /** Maps from original element to shadow DOM clone */ - #cloneMap = new WeakMap(); + #lightToShadowMap = new WeakMap(); /** Maps from shadow DOM clone to original element */ - #deCloneMap = new WeakMap(); + #shadowToLightMap = new WeakMap(); /** Set of item which should not be cloned */ #noCloneSet = new WeakSet(); @@ -91,10 +91,38 @@ export class ActivedescendantController< #mo = new MutationObserver(records => this.onMutation(records)); - #atFocusedItem: Item | null = null; + #attrMO = new MutationObserver(records => { + for (const { target, attributeName } of records) { + if (attributeName) { + if (this.#shadowToLightMap.has(target as Item)) { + const shadow = target as Item; + const light = this.#shadowToLightMap.get(shadow); + const newVal = shadow.getAttribute(attributeName); + const oldVal = light?.getAttribute(attributeName); + if (oldVal !== newVal) { + light?.setAttribute(attributeName, newVal!); + } + } else if (this.#lightToShadowMap.has(target as Item)) { + const light = target as Item; + const shadow = this.#lightToShadowMap.get(light); + const newVal = light.getAttribute(attributeName); + const oldVal = shadow?.getAttribute(attributeName); + if (oldVal !== newVal) { + shadow?.setAttribute(attributeName, newVal!); + } + } + } + } + }); + + // #atFocusedItem: Item | null = null; - get atFocusedItem(): Item | null { - return this.#atFocusedItem; + #atFocusedItemIndex = -1; + + get atFocusedItemIndex(): number { + return this.#atFocusedItemIndex; + // return this.#shadowToLightMap.get(shadowItem!) ?? shadowItem; + // return this.#atFocusedItem; } /** @@ -102,17 +130,31 @@ export class ActivedescendantController< * using AriaIDLAttributes for cross-root aria, if supported by the browser * @param item item */ - set atFocusedItem(item: Item | null) { - this.#atFocusedItem = item; - for (const i of this.items) { - this.options.setItemActive?.call(i, i === item); + set atFocusedItemIndex(index: number) { + this.#atFocusedItemIndex = index; + let lightItem = this._items.at(index); + let shadowItem = this._items.at(index); + while (!shadowItem || !this.atFocusableItems.includes(shadowItem)) { + if (index < 0) { + index = this.items.length; + } else if (index >= this.items.length) { + index = 0; + } else { + index = index + 1; + } + this.#atFocusedItemIndex = index; + lightItem = this._items.at(index); + shadowItem = this._items.at(index); + } + for (const item of this.items) { + this.options.setItemActive?.call(item, item === lightItem || item === shadowItem); } if (this.itemsContainerElement) { for (const el of [this.itemsContainerElement, ...this.#controlsElements]) { if (!ActivedescendantController.canControlLightDom) { - el?.setAttribute('aria-activedescendant', item?.id ?? ''); + el?.setAttribute('aria-activedescendant', shadowItem?.id ?? ''); } else if (el) { - el.ariaActiveDescendantElement = item ?? null; + el.ariaActiveDescendantElement = lightItem ?? null; } } } @@ -154,21 +196,22 @@ export class ActivedescendantController< if (container.contains(item)) { item.id ||= getRandomId(); this.#noCloneSet.add(item); - this.#deCloneMap.set(item, item); + this.#shadowToLightMap.set(item, item); return item; } else { const clone = item.cloneNode(true) as Item; - this.#cloneMap.set(item, clone); - this.#deCloneMap.set(clone, item); + this.#lightToShadowMap.set(item, clone); + this.#shadowToLightMap.set(clone, item); + // QUESTION memory leak? + this.#attrMO.observe(clone, { attributes: true }); + this.#attrMO.observe(item, { attributes: true }); clone.id = getRandomId(); return clone; } }); - const [first] = this.atFocusableItems; - const atFocusedItemIndex = this.atFocusableItems.indexOf(this.atFocusedItem!); - const next = this.atFocusableItems.find(((_, i) => i !== atFocusedItemIndex)); - const activeItem = next ?? first ?? this.firstATFocusableItem; - this.atFocusedItem = activeItem; + const next = this.atFocusableItems.find(((_, i) => i !== this.#atFocusedItemIndex)); + const activeItem = next ?? this.firstATFocusableItem; + this.#atFocusedItemIndex = this._items.indexOf(activeItem!); } private constructor( @@ -182,8 +225,8 @@ export class ActivedescendantController< // todo: respond to attrs changing on lightdom nodes for (const { removedNodes } of records) { for (const removed of removedNodes as NodeListOf) { - this.#cloneMap.get(removed)?.remove(); - this.#cloneMap.delete(removed); + this.#lightToShadowMap.get(removed)?.remove(); + this.#lightToShadowMap.delete(removed); } } }; diff --git a/core/pfe-core/controllers/at-focus-controller.ts b/core/pfe-core/controllers/at-focus-controller.ts index 1e7ee37370..4e4e1f7d0d 100644 --- a/core/pfe-core/controllers/at-focus-controller.ts +++ b/core/pfe-core/controllers/at-focus-controller.ts @@ -20,17 +20,13 @@ export interface ATFocusControllerOptions { export abstract class ATFocusController { #itemsContainerElement: HTMLElement | null = null; - get #atFocusedItemIndex() { - return this.atFocusableItems.indexOf(this.atFocusedItem!); - } - protected _items: Item[] = []; /** All items */ abstract items: Item[]; - /** Item which currently has assistive technology focus */ - abstract atFocusedItem: Item | null; + /** Index of the Item which currently has assistive technology focus */ + abstract atFocusedItemIndex: number; get container(): HTMLElement { return this.options.getItemsContainer?.() ?? this.host as unknown as HTMLElement; @@ -43,7 +39,7 @@ export abstract class ATFocusController { /** All items which are able to receive assistive technology focus */ get atFocusableItems(): Item[] { - return this.items.filter(isATFocusableItem); + return this._items.filter(isATFocusableItem); } /** First item which is able to receive assistive technology focus */ @@ -58,7 +54,7 @@ export abstract class ATFocusController { /** Focusable item following the item which currently has assistive technology focus */ get nextATFocusableItem(): Item | null { - const index = this.#atFocusedItemIndex; + const index = this.atFocusedItemIndex; const outOfBounds = index >= this.atFocusableItems.length - 1; return outOfBounds ? this.firstATFocusableItem : this.atFocusableItems.at(index + 1) ?? null; @@ -66,7 +62,7 @@ export abstract class ATFocusController { /** Focusable item preceding the item which currently has assistive technology focus */ get previousATFocusableItem(): Item | null { - const index = this.#atFocusedItemIndex; + const index = this.atFocusedItemIndex; const outOfBounds = index > 0; return outOfBounds ? this.atFocusableItems.at(index - 1) ?? null : this.lastATFocusableItem; @@ -102,7 +98,7 @@ export abstract class ATFocusController { } hostUpdate(): void { - this.atFocusedItem ??= this.firstATFocusableItem; + // this.atFocusedItemIndex ??= this.firstATFocusableItem; this.itemsContainerElement ??= this.#initContainer(); } @@ -124,7 +120,7 @@ export abstract class ATFocusController { ?.getAttribute('aria-orientation') as 'horizontal' | 'vertical' | 'grid' | 'undefined'; - const item = this.atFocusedItem; + const item = this._items.at(this.atFocusedItemIndex); const horizontalOnly = orientation === 'horizontal' @@ -138,7 +134,7 @@ export abstract class ATFocusController { if (verticalOnly) { return; } - this.atFocusedItem = this.previousATFocusableItem ?? null; + this.atFocusedItemIndex--; event.stopPropagation(); event.preventDefault(); break; @@ -146,7 +142,7 @@ export abstract class ATFocusController { if (verticalOnly) { return; } - this.atFocusedItem = this.nextATFocusableItem ?? null; + this.atFocusedItemIndex++; event.stopPropagation(); event.preventDefault(); break; @@ -154,7 +150,7 @@ export abstract class ATFocusController { if (horizontalOnly) { return; } - this.atFocusedItem = this.previousATFocusableItem ?? null; + this.atFocusedItemIndex--; event.stopPropagation(); event.preventDefault(); break; @@ -162,17 +158,17 @@ export abstract class ATFocusController { if (horizontalOnly) { return; } - this.atFocusedItem = this.nextATFocusableItem ?? null; + this.atFocusedItemIndex++; event.stopPropagation(); event.preventDefault(); break; case 'Home': - this.atFocusedItem = this.firstATFocusableItem ?? null; + this.atFocusedItemIndex = 0; event.stopPropagation(); event.preventDefault(); break; case 'End': - this.atFocusedItem = this.lastATFocusableItem ?? null; + this.atFocusedItemIndex = this.items.length; event.stopPropagation(); event.preventDefault(); break; diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index 755eb79b99..51ffcf7865 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -2,6 +2,7 @@ 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 @@ -159,7 +160,10 @@ export class ListboxController implements ReactiveCont if (selected == null) { this.#selectedItems = new Set; } else { - this.#selectedItems = new Set(Array.isArray(selected) ? selected : [selected]); + const possiblyClonedItemsArray = Array.isArray(selected) ? selected : [selected]; + // this.#selectedItems = new Set(possiblyClonedItemsArray.map(item => + // this.#decloneMap.get(item))); + this.#selectedItems = new Set(possiblyClonedItemsArray); } this.host.requestUpdate(); } @@ -303,10 +307,10 @@ export class ListboxController implements ReactiveCont case 'a': case 'A': if (event.ctrlKey) { - if (this.#selectedItems.size === this.items.filter(this.#isSelectableItem).length) { - this.#selectedItems = new Set(this.items); + if (!arraysAreEquivalent(this.selected, this.items.filter(this.#isSelectableItem))) { + this.selected = null; } else { - this.#selectedItems = new Set; + this.selected = this.items; } event.preventDefault(); } @@ -316,7 +320,7 @@ export class ListboxController implements ReactiveCont // enter and space are only applicable if a listbox option is clicked // an external text input should not trigger multiselect if (!this.multi) { - this.#selectedItems = new Set([item]); + this.selected = item; } else if (this.multi && event.shiftKey) { // update starting item for other multiselect this.selected = this.#getMultiSelection(item, this.#options.getATFocusedItem()); diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index 1b0e567179..cc3cea5e47 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -24,18 +24,19 @@ export class RovingTabindexController< super.itemsContainerElement = container; } - #atFocusedItem: Item | null = null; + #atFocusedItemIndex = -1; - get atFocusedItem(): Item | null { - return this.#atFocusedItem; + get atFocusedItemIndex(): number { + return this.#atFocusedItemIndex; } /** * Sets the DOM Focus on the item with assistive technology focus * @param item item */ - set atFocusedItem(item: Item | null) { - this.#atFocusedItem = item; + set atFocusedItemIndex(index: number) { + this.#atFocusedItemIndex = index; + const item = this.items.at(index); for (const focusable of this.atFocusableItems) { focusable.tabIndex = item === focusable ? 0 : -1; } @@ -50,15 +51,13 @@ export class RovingTabindexController< public set items(items: Item[]) { this._items = items; - const pivot = this.atFocusableItems.indexOf(this.atFocusedItem!) - 1; - this.atFocusedItem = - this.atFocusableItems.at(0) - ?? this.items - .slice(pivot) - .concat(this.items.slice(0, pivot)) - .find(item => this.atFocusableItems.includes(item)) - ?? this.firstATFocusableItem - ?? null; + const pivot = this.atFocusedItemIndex; + const firstFocusableIndex = items.indexOf(this.atFocusableItems.at(0)!); + 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(); } From 2a82612b61f62bcddd68f912be8e8f29721d8a6b Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 25 Jul 2024 12:38:41 +0300 Subject: [PATCH 013/122] fix(select): typeahead, focus, filter --- .../activedescendant-controller.ts | 88 ++++--- .../controllers/listbox-controller.ts | 66 ++--- elements/pf-accordion/pf-accordion.ts | 5 +- elements/pf-chip/pf-chip-group.ts | 6 +- elements/pf-dropdown/pf-dropdown-menu.ts | 26 +- elements/pf-jump-links/pf-jump-links-item.ts | 4 +- elements/pf-jump-links/pf-jump-links.ts | 7 +- elements/pf-select/demo/typeahead.html | 7 +- elements/pf-select/pf-select.ts | 225 ++++++++++++------ elements/pf-tabs/pf-tabs.ts | 18 +- 10 files changed, 267 insertions(+), 185 deletions(-) diff --git a/core/pfe-core/controllers/activedescendant-controller.ts b/core/pfe-core/controllers/activedescendant-controller.ts index 7e087e04e7..83c2e4e45c 100644 --- a/core/pfe-core/controllers/activedescendant-controller.ts +++ b/core/pfe-core/controllers/activedescendant-controller.ts @@ -89,40 +89,26 @@ export class ActivedescendantController< #observing = false; - #mo = new MutationObserver(records => this.onMutation(records)); + #listMO = new MutationObserver(records => this.#onItemsDOMChange(records)); - #attrMO = new MutationObserver(records => { - for (const { target, attributeName } of records) { - if (attributeName) { - if (this.#shadowToLightMap.has(target as Item)) { - const shadow = target as Item; - const light = this.#shadowToLightMap.get(shadow); - const newVal = shadow.getAttribute(attributeName); - const oldVal = light?.getAttribute(attributeName); - if (oldVal !== newVal) { - light?.setAttribute(attributeName, newVal!); - } - } else if (this.#lightToShadowMap.has(target as Item)) { - const light = target as Item; - const shadow = this.#lightToShadowMap.get(light); - const newVal = light.getAttribute(attributeName); - const oldVal = shadow?.getAttribute(attributeName); - if (oldVal !== newVal) { - shadow?.setAttribute(attributeName, newVal!); - } - } - } - } - }); + #attrMO = new MutationObserver(records => this.#onItemAttributeChange(records)); - // #atFocusedItem: Item | null = null; + #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!); + } + } #atFocusedItemIndex = -1; get atFocusedItemIndex(): number { return this.#atFocusedItemIndex; - // return this.#shadowToLightMap.get(shadowItem!) ?? shadowItem; - // return this.#atFocusedItem; } /** @@ -131,30 +117,30 @@ export class ActivedescendantController< * @param item item */ set atFocusedItemIndex(index: number) { + const direction = this.atFocusedItemIndex < index ? -1 : 1; this.#atFocusedItemIndex = index; - let lightItem = this._items.at(index); - let shadowItem = this._items.at(index); - while (!shadowItem || !this.atFocusableItems.includes(shadowItem)) { + let item = this._items.at(index); + while (!item || !this.atFocusableItems.includes(item)) { if (index < 0) { - index = this.items.length; - } else if (index >= this.items.length) { + index = this.items.indexOf(this.lastATFocusableItem!); + } else if (index >= this.items.length + || index === this.items.indexOf(this.lastATFocusableItem!)) { index = 0; } else { - index = index + 1; + index = index + direction; } this.#atFocusedItemIndex = index; - lightItem = this._items.at(index); - shadowItem = this._items.at(index); + item = this._items.at(index); } - for (const item of this.items) { - this.options.setItemActive?.call(item, item === lightItem || item === shadowItem); + for (const _item of this.items) { + this.options.setItemActive?.call(_item, _item === item); } if (this.itemsContainerElement) { for (const el of [this.itemsContainerElement, ...this.#controlsElements]) { if (!ActivedescendantController.canControlLightDom) { - el?.setAttribute('aria-activedescendant', shadowItem?.id ?? ''); + el?.setAttribute('aria-activedescendant', item?.id ?? ''); } else if (el) { - el.ariaActiveDescendantElement = lightItem ?? null; + el.ariaActiveDescendantElement = item ?? null; } } } @@ -189,6 +175,7 @@ export class ActivedescendantController< throw new Error('items container must be an HTMLElement'); } this.itemsContainerElement = container; + // this.#attrMO.disconnect(); this._items = ActivedescendantController.canControlLightDom ? items : items?.map((item: Item) => { @@ -200,12 +187,13 @@ export class ActivedescendantController< return item; } else { const clone = item.cloneNode(true) as Item; + clone.id = getRandomId(); this.#lightToShadowMap.set(item, clone); this.#shadowToLightMap.set(clone, item); - // QUESTION memory leak? + // 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 }); - clone.id = getRandomId(); return clone; } }); @@ -221,8 +209,7 @@ export class ActivedescendantController< super(host, options); } - private onMutation = (records: MutationRecord[]) => { - // todo: respond to attrs changing on lightdom nodes + #onItemsDOMChange(records: MutationRecord[]) { for (const { removedNodes } of records) { for (const removed of removedNodes as NodeListOf) { this.#lightToShadowMap.get(removed)?.remove(); @@ -231,18 +218,28 @@ export class ActivedescendantController< } }; + #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.#mo.observe(this.itemsContainerElement, { attributes: true, childList: true }); + this.#listMO.observe(this.itemsContainerElement, { childList: true }); this.#observing = true; } } hostDisconnected(): void { this.#observing = false; - this.#mo.disconnect(); + this.#listMO.disconnect(); + this.#attrMO.disconnect(); } protected override isRelevantKeyboardEvent(event: Event): event is KeyboardEvent { @@ -253,7 +250,6 @@ export class ActivedescendantController< || !this.atFocusableItems.length); } - public renderItemsToShadowRoot(): typeof nothing | Node[] { if (ActivedescendantController.canControlLightDom) { return nothing; diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index 51ffcf7865..4f97985a60 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -19,6 +19,14 @@ export interface ListboxControllerOptions { * 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 has the `aria-disabled="true"` attribute + * 2. it has the `disabled` attribute present + * 3. it matches the `:disabled` pseudo selector + */ + isItemDisabled?(this: Item): boolean; /** * Function returning the item which currently has assistive technology focus. * In most cases, this should be the `atFocusedItem` of an ATFocusController @@ -51,6 +59,19 @@ function setItemSelected(this: Item, selected: boolean } } +/** + * 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 + */ +function isItemDisabled(this: Item): boolean { + return this.getAttribute('aria-disabled') === 'true' + || this.hasAttribute('disabled') + || this.matches(':disabled'); +} + let constructingAllowed = false; /** @@ -93,10 +114,7 @@ let constructingAllowed = false; * > - The selected state must be visually distinct from the focus indicator. */ export class ListboxController implements ReactiveController { - private static instances = new WeakMap< - ReactiveControllerHost, - ListboxController - >(); + private static instances = new WeakMap>(); public static of( host: ReactiveControllerHost, @@ -111,11 +129,13 @@ export class ListboxController implements ReactiveCont /** Current active descendant when shift key is pressed */ #shiftStartingItem: Item | null = null; - #options: RequireProps, 'setItemSelected'>; + #options: RequireProps, 'setItemSelected' | 'isItemDisabled'>; /** All items */ #items: Item[] = []; + #selectedItems = new Set; + #listening = false; /** Whether listbox is disabled */ @@ -136,6 +156,7 @@ export class ListboxController implements ReactiveCont set multi(v: boolean) { this.#options.multi = v; + this.container?.setAttribute('aria-multi-selectable', String(!!this.#options.multi)); } get items(): Item[] { @@ -150,21 +171,14 @@ export class ListboxController implements ReactiveCont this.#items = items; } - #selectedItems = new Set; - /** * sets the listbox value based on selected options * @param selected item or items */ set selected(selected: Item | Item[] | null) { - if (selected == null) { - this.#selectedItems = new Set; - } else { - const possiblyClonedItemsArray = Array.isArray(selected) ? selected : [selected]; - // this.#selectedItems = new Set(possiblyClonedItemsArray.map(item => - // this.#decloneMap.get(item))); - this.#selectedItems = new Set(possiblyClonedItemsArray); - } + this.#selectedItems = new Set(selected == null ? selected + : Array.isArray(selected) ? selected + : [selected]); this.host.requestUpdate(); } @@ -179,7 +193,7 @@ export class ListboxController implements ReactiveCont public host: ReactiveControllerHost, options: ListboxControllerOptions, ) { - this.#options = { setItemSelected, ...options }; + this.#options = { setItemSelected, isItemDisabled, ...options }; if (!constructingAllowed) { throw new Error('ListboxController must be constructed with `ListboxController.of()`'); } @@ -200,6 +214,7 @@ export class ListboxController implements ReactiveCont if (this.container?.isConnected) { this.hostConnected(); } + this.multi = this.#options.multi ?? false; } async hostConnected(): Promise { @@ -217,7 +232,6 @@ export class ListboxController implements ReactiveCont hostUpdated(): void { this.container?.setAttribute('role', 'listbox'); this.container?.setAttribute('aria-disabled', String(!!this.disabled)); - this.container?.setAttribute('aria-multi-selectable', String(!!this.#options.multi)); for (const item of this.items) { this.#options.setItemSelected.call(item, this.isSelected(item)); } @@ -236,8 +250,6 @@ export class ListboxController implements ReactiveCont return this.#selectedItems.has(item); } - #isSelectableItem = (item: Item) => !item.ariaDisabled && !item.closest('[disabled]'); - #getItemFromEvent(event: Event): Item | undefined { return event .composedPath() @@ -273,15 +285,9 @@ export class ListboxController implements ReactiveCont * @param event keyup event */ #onKeyup = (event: KeyboardEvent) => { - // const target = this.#getItemFromEvent(event); - // if (target && event.shiftKey && this.multi) { - // if (this.#shiftStartingItem && target) { - // this.selected = this.#getMultiSelection(target, this.#shiftStartingItem); - // } if (event.key === 'Shift') { this.#shiftStartingItem = null; } - // } }; /** @@ -307,10 +313,12 @@ export class ListboxController implements ReactiveCont case 'a': case 'A': if (event.ctrlKey) { - if (!arraysAreEquivalent(this.selected, this.items.filter(this.#isSelectableItem))) { + const selectableItems = this.items.filter(item => + !this.#options.isItemDisabled.call(item)); + if (!arraysAreEquivalent(this.selected, selectableItems)) { this.selected = null; } else { - this.selected = this.items; + this.selected = selectableItems; } event.preventDefault(); } @@ -319,7 +327,7 @@ export class ListboxController implements ReactiveCont case ' ': // enter and space are only applicable if a listbox option is clicked // an external text input should not trigger multiselect - if (!this.multi) { + if (!this.multi && !this.#options.isItemDisabled.call(item)) { this.selected = item; } else if (this.multi && event.shiftKey) { // update starting item for other multiselect @@ -351,7 +359,7 @@ export class ListboxController implements ReactiveCont const [start, end] = [this.items.indexOf(from), this.items.indexOf(to)].sort(); const itemsInRange = new Set(this.items .slice(start, end + 1) - .filter(this.#isSelectableItem)); + .filter(item => !this.#options.isItemDisabled.call(item))); return this.items .filter(item => selecting ? itemsInRange.has(item) : !itemsInRange.has(item)); } else { diff --git a/elements/pf-accordion/pf-accordion.ts b/elements/pf-accordion/pf-accordion.ts index cd9a7ea7c6..6c41de071d 100644 --- a/elements/pf-accordion/pf-accordion.ts +++ b/elements/pf-accordion/pf-accordion.ts @@ -238,8 +238,9 @@ export class PfAccordion extends LitElement { @listen('focusin') protected updateActiveHeader(): void { - if (this.#activeHeader !== this.#headerIndex.atFocusedItem) { - this.#headerIndex.atFocusedItem = this.#activeHeader ?? null; + if (this.#activeHeader + && this.#activeHeader !== this.headers.at(this.#headerIndex.atFocusedItemIndex)) { + this.#headerIndex.atFocusedItemIndex = this.headers.indexOf(this.#activeHeader); } } diff --git a/elements/pf-chip/pf-chip-group.ts b/elements/pf-chip/pf-chip-group.ts index c6197b607d..7e8e9a80bc 100644 --- a/elements/pf-chip/pf-chip-group.ts +++ b/elements/pf-chip/pf-chip-group.ts @@ -154,14 +154,14 @@ export class PfChipGroup extends LitElement { * active chip that receives focus when group receives focus */ get activeChip() { - const button = this.#tabindex.atFocusedItem as HTMLElement; + const button = this.#tabindex.items.at(this.#tabindex.atFocusedItemIndex); const shadow = button?.getRootNode() as ShadowRoot; return shadow?.host as PfChip; } set activeChip(chip: HTMLElement) { const button = chip.shadowRoot?.querySelector('button') as HTMLElement; - this.#tabindex.atFocusedItem = button; + this.#tabindex.atFocusedItemIndex = this.#tabindex.items.indexOf(button); } /** @@ -264,7 +264,7 @@ export class PfChipGroup extends LitElement { * @param chip pf-chip element */ focusOnChip(chip: HTMLElement): void { - this.#tabindex.atFocusedItem = chip; + this.#tabindex.atFocusedItemIndex = this.#tabindex.items.indexOf(chip); } } diff --git a/elements/pf-dropdown/pf-dropdown-menu.ts b/elements/pf-dropdown/pf-dropdown-menu.ts index 2e7a046c12..4a0fe187eb 100644 --- a/elements/pf-dropdown/pf-dropdown-menu.ts +++ b/elements/pf-dropdown/pf-dropdown-menu.ts @@ -51,18 +51,15 @@ export class PfDropdownMenu extends LitElement { * current active descendant in menu */ get activeItem(): HTMLElement | null { - return this.#tabindex.atFocusedItem ?? this.#tabindex.firstATFocusableItem; + return this.#tabindex.items.at(this.#tabindex.atFocusedItemIndex) + ?? this.#tabindex.firstATFocusableItem; } /** * index of current active descendant in menu */ get activeIndex(): number { - if (!this.#tabindex.atFocusedItem) { - return -1; - } else { - return this.#tabindex.items.indexOf(this.#tabindex.atFocusedItem); - } + return this.#tabindex.atFocusedItemIndex; } get items(): PfDropdownItem[] { @@ -114,9 +111,8 @@ export class PfDropdownMenu extends LitElement { if (this.ctx?.disabled) { event.preventDefault(); event.stopPropagation(); - } else if (event.target instanceof PfDropdownItem - && event.target.menuItem !== this.#tabindex.atFocusedItem) { - this.#tabindex.atFocusedItem = event.target.menuItem; + } else if (event.target instanceof PfDropdownItem) { + this.#focusItem(event.target.menuItem); } } @@ -130,9 +126,15 @@ export class PfDropdownMenu extends LitElement { if (this.ctx?.disabled || isDisabledItemClick(event)) { event.preventDefault(); event.stopPropagation(); - } else if (event.target instanceof PfDropdownItem - && event.target.menuItem !== this.#tabindex.atFocusedItem) { - this.#tabindex.atFocusedItem = event.target.menuItem; + } else if (event.target instanceof PfDropdownItem) { + this.#focusItem(event.target.menuItem); + } + } + + #focusItem(item: HTMLElement) { + const itemIndex = this.#tabindex.items.indexOf(item); + if (itemIndex !== this.#tabindex.atFocusedItemIndex) { + this.#tabindex.atFocusedItemIndex = itemIndex; } } diff --git a/elements/pf-jump-links/pf-jump-links-item.ts b/elements/pf-jump-links/pf-jump-links-item.ts index 380a697f7d..50d699017b 100644 --- a/elements/pf-jump-links/pf-jump-links-item.ts +++ b/elements/pf-jump-links/pf-jump-links-item.ts @@ -33,9 +33,7 @@ export class PfJumpLinksItem extends LitElement { /** hypertext reference for this link */ @property({ reflect: true }) href?: string; - #internals = InternalsController.of(this, { - role: 'listitem', - }); + #internals = InternalsController.of(this, { role: 'listitem' }); render(): TemplateResult<1> { return html` diff --git a/elements/pf-jump-links/pf-jump-links.ts b/elements/pf-jump-links/pf-jump-links.ts index 02dd3d8fc6..7179e4c9f8 100644 --- a/elements/pf-jump-links/pf-jump-links.ts +++ b/elements/pf-jump-links/pf-jump-links.ts @@ -150,8 +150,11 @@ export class PfJumpLinks extends LitElement { } #setActiveItem(item: PfJumpLinksItem) { - this.#tabindex.atFocusedItem = item.shadowRoot?.querySelector?.('a') ?? null; - this.#spy.setActive(item); + const itemLink = item.shadowRoot?.querySelector?.('a') ?? null; + if (itemLink) { + this.#tabindex.atFocusedItemIndex = this.#tabindex.items.indexOf(itemLink); + this.#spy.setActive(item); + } } #onToggle(event: Event) { diff --git a/elements/pf-select/demo/typeahead.html b/elements/pf-select/demo/typeahead.html index c37f55a28f..f014aa7b97 100644 --- a/elements/pf-select/demo/typeahead.html +++ b/elements/pf-select/demo/typeahead.html @@ -2,8 +2,11 @@ - - + + diff --git a/elements/pf-select/pf-select.ts b/elements/pf-select/pf-select.ts index 81fec74251..1e939478a6 100644 --- a/elements/pf-select/pf-select.ts +++ b/elements/pf-select/pf-select.ts @@ -64,40 +64,28 @@ export class PfSelect extends LitElement { /** Variant of rendered Select */ @property() variant: 'single' | 'checkbox' | 'typeahead' | 'typeaheadmulti' = 'single'; - /** - * Accessible label for the select - */ + /** Accessible label for the select */ @property({ attribute: 'accessible-label' }) accessibleLabel?: string; - /** - * Accessible label for chip group used to describe chips - */ + /** Accessible label for chip group used to describe chips */ @property({ attribute: 'accessible-current-selections-label', }) accessibleCurrentSelectionsLabel = 'Current selections'; - /** - * multi listbox button text - */ + /** Multi listbox button text */ @property({ attribute: 'items-selected-text' }) itemsSelectedText = 'items selected'; - /** - * whether select is disabled - */ + /** Whether the select is disabled */ @property({ type: Boolean, reflect: true }) disabled = false; - /** - * Whether the select listbox is expanded - */ + /** Whether the select listbox is expanded */ @property({ type: Boolean, reflect: true }) expanded = false; /** - * enable to flip listbox when it reaches boundary + * Enable to flip listbox when it reaches boundary */ @property({ attribute: 'enable-flip', type: Boolean }) enableFlip = false; - // @property() filter = ''; - /** Current form value */ @property() value?: string; @@ -117,8 +105,6 @@ export class PfSelect extends LitElement { type: Boolean, }) checkboxSelectionBadgeHidden = false; - @property({ attribute: false }) filter?: (option: PfOption) => boolean; - @query('pf-chip-group') private _chipGroup?: PfChipGroup; @query('#toggle-input') private _input?: HTMLInputElement; @@ -141,6 +127,8 @@ export class PfSelect extends LitElement { #atFocusController = this.#createATFocusController(); + #preventListboxGainingFocus = false; + #createATFocusController(): ATFocusController { const getItems = () => this.options; const getItemsContainer = () => this._listbox ?? null; @@ -163,7 +151,7 @@ export class PfSelect extends LitElement { multi: this.variant === 'typeaheadmulti' || this.variant === 'checkbox', getItemsContainer: () => this.#atFocusController.container, getControlsElements: () => this.#atFocusController.controlsElements, - getATFocusedItem: () => this.#atFocusController.atFocusedItem, + getATFocusedItem: () => this.options.at(this.#atFocusController.atFocusedItemIndex) ?? null, setItemSelected(selected) { this.selected = selected; }, @@ -270,8 +258,8 @@ export class PfSelect extends LitElement { ?hidden="${!typeahead}" placeholder="${buttonLabel}" @click="${this.toggle}" - @keydown="${this.#onButtonKeydown}" - @input="${this.#onTypeaheadInput}">`} + @keyup="${this.#onKeyupInput}" + @keydown="${this.#onKeydownInput}">`} + +
+ + ${this.#combobox.renderItemsToShadowRoot()} +
+ +
+
+ `; + } + } + + beforeEach(async function() { + element = await fixture(html` + ${Array.from({ length: 10 }, (_, i) => html` + `)} + + `); + 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.not.be.ok; + }); + }); + 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.be.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.be.true; + }); + }); + }); + }); +}); From dc9b0bc1ea1180a7709dab682c79e9f1b2f363d6 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Wed, 7 Aug 2024 13:47:23 +0300 Subject: [PATCH 025/122] fix(core): oopsies forgot to commit these yesterday --- .../activedescendant-controller.ts | 22 +++++++++++----- .../controllers/combobox-controller.ts | 10 +++++--- .../controllers/listbox-controller.ts | 12 ++++----- core/pfe-core/tsconfig.json | 7 +++++- elements/pf-select/pf-select.ts | 25 ++++++------------- 5 files changed, 41 insertions(+), 35 deletions(-) diff --git a/core/pfe-core/controllers/activedescendant-controller.ts b/core/pfe-core/controllers/activedescendant-controller.ts index 6f0fe0ec75..c83b428012 100644 --- a/core/pfe-core/controllers/activedescendant-controller.ts +++ b/core/pfe-core/controllers/activedescendant-controller.ts @@ -75,6 +75,10 @@ export interface ActivedescendantControllerOptions< 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 'ariaActiveDescendantElement' in HTMLElement.prototype; } @@ -170,9 +174,14 @@ export class ActivedescendantController< throw new Error('items container must be an HTMLElement'); } this.itemsContainerElement = container; - this._items = - ActivedescendantController.canControlLightDom ? items - : items?.map((item: Item) => { + 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(); @@ -191,9 +200,10 @@ export class ActivedescendantController< return clone; } }); - const next = this.atFocusableItems.find(((_, i) => i !== this.atFocusedItemIndex)); - const activeItem = next ?? this.firstATFocusableItem; - this.atFocusedItemIndex = this._items.indexOf(activeItem!); + const next = this.atFocusableItems.find(((_, i) => i !== this.atFocusedItemIndex)); + const activeItem = next ?? this.firstATFocusableItem; + this.atFocusedItemIndex = this._items.indexOf(activeItem!); + } } private constructor( diff --git a/core/pfe-core/controllers/combobox-controller.ts b/core/pfe-core/controllers/combobox-controller.ts index f68b2b710e..7a7c7a1196 100644 --- a/core/pfe-core/controllers/combobox-controller.ts +++ b/core/pfe-core/controllers/combobox-controller.ts @@ -62,12 +62,12 @@ export interface ComboboxControllerOptions extends isExpanded(): boolean; /** * Callback which the host must implement to change the expanded state to true. - * Return false to prevent the change. + * Return or resolve false to prevent the change. */ requestShowListbox(): boolean | Promise; /** * Callback which the host must implement to change the expanded to false. - * Return false to prevent the default. + * Return or resolve false to prevent the default. */ requestHideListbox(): boolean | Promise; /** @@ -536,7 +536,8 @@ export class ComboboxController< }; async #show(): Promise { - if (await this.options.requestShowListbox() && !this.#isTypeahead) { + const success = await this.options.requestShowListbox(); + if (success !== false && !this.#isTypeahead) { if (!this.#preventListboxGainingFocus) { (this.#focusedItem ?? this.#fc?.nextATFocusableItem)?.focus(); this.#preventListboxGainingFocus = false; @@ -545,7 +546,8 @@ export class ComboboxController< } async #hide(): Promise { - if (await this.options.requestHideListbox() && !this.#isTypeahead) { + const success = await this.options.requestHideListbox(); + if (success !== false && !this.#isTypeahead) { this.options.getToggleButton()?.focus(); } } diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index 47d273340d..0685c4ad4b 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -210,13 +210,13 @@ export class ListboxController implements ReactiveCont * @param selected item or items */ set selected(selected: Item[]) { - this.#selectedItems = new Set(selected == null ? selected - : Array.isArray(selected) ? selected - : [selected]); - for (const item of this.items) { - this.#options.setItemSelected.call(item, this.#selectedItems.has(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(); } - this.host.requestUpdate(); } /** 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-select/pf-select.ts b/elements/pf-select/pf-select.ts index c4c1d75966..98f3d9e631 100644 --- a/elements/pf-select/pf-select.ts +++ b/elements/pf-select/pf-select.ts @@ -40,7 +40,6 @@ export class PfSelectChangeEvent extends Event { * @slot placeholder - placeholder text for the select. Overrides the `placeholder` attribute. * @fires open - when the menu toggles open * @fires close - when the menu toggles closed - * @fires filter - when the filter value changes. used to perform custom filtering */ @customElement('pf-select') export class PfSelect extends LitElement { @@ -125,13 +124,11 @@ export class PfSelect extends LitElement { || this.#buttonLabel, getListboxElement: () => this._listbox ?? null, getToggleButton: () => this._toggleButton ?? null, - getToggleInput: () => this._toggleInput ?? null, + getComboboxInput: () => this._toggleInput ?? null, isExpanded: () => this.expanded, - requestExpand: () => this.expanded ||= true, - requestCollapse: () => ((this.expanded &&= false), true), - getItemValue() { - return this.value; - }, + requestShowListbox: () => this.expanded ||= true, + requestHideListbox: () => ((this.expanded &&= false), true), + isItem: item => item instanceof PfOption, setItemActive(active) { this.active = active; }, @@ -251,9 +248,9 @@ export class PfSelect extends LitElement { ?hidden="${!this.placeholder && !this.#slots.hasSlotted('placeholder')}"> ${this.placeholder} - ${this.#combobox?.renderItemsToShadowRoot()} -
- + ${this.#combobox.renderItemsToShadowRoot()} +
+
@@ -324,14 +321,6 @@ export class PfSelect extends LitElement { } } - #onSlotchangeListbox() { - this.#combobox.items = this.options; - this.options.forEach((option, index, options) => { - option.setSize = options.length; - option.posInSet = index; - }); - } - async #doExpand() { try { await this.#float.show({ placement: this.position || 'bottom', flip: !!this.enableFlip }); From b0ef3e35ea9c2c97f62beaa4313a29edf2f12076 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Wed, 7 Aug 2024 13:50:47 +0300 Subject: [PATCH 026/122] refactor(select): polish --- elements/pf-select/demo/pf-select.html | 32 ++++++++++----- elements/pf-select/demo/typeahead.html | 39 ++++++++++++------- .../demos-to-implement-later/typeahead.html | 15 ------- elements/pf-select/pf-select.ts | 6 ++- 4 files changed, 51 insertions(+), 41 deletions(-) delete mode 100644 elements/pf-select/demos-to-implement-later/typeahead.html diff --git a/elements/pf-select/demo/pf-select.html b/elements/pf-select/demo/pf-select.html index de62d6e2de..b279eaeb79 100644 --- a/elements/pf-select/demo/pf-select.html +++ b/elements/pf-select/demo/pf-select.html @@ -1,14 +1,26 @@ - - Mr - Miss - Mrs - Ms -
- Dr - Other -
+
+ + Mr + Miss + Mrs + Ms +
+ Dr + Other +
+
+ + + diff --git a/elements/pf-select/demo/typeahead.html b/elements/pf-select/demo/typeahead.html index f014aa7b97..303fa43faa 100644 --- a/elements/pf-select/demo/typeahead.html +++ b/elements/pf-select/demo/typeahead.html @@ -1,18 +1,29 @@ - - - - - - - - - +
+ + + + + + + + + +
+ + diff --git a/elements/pf-select/demos-to-implement-later/typeahead.html b/elements/pf-select/demos-to-implement-later/typeahead.html deleted file mode 100644 index e6e50e1477..0000000000 --- a/elements/pf-select/demos-to-implement-later/typeahead.html +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/elements/pf-select/pf-select.ts b/elements/pf-select/pf-select.ts index 98f3d9e631..f2f3e4a9a8 100644 --- a/elements/pf-select/pf-select.ts +++ b/elements/pf-select/pf-select.ts @@ -126,8 +126,10 @@ export class PfSelect extends LitElement { getToggleButton: () => this._toggleButton ?? null, getComboboxInput: () => this._toggleInput ?? null, isExpanded: () => this.expanded, - requestShowListbox: () => this.expanded ||= true, - requestHideListbox: () => ((this.expanded &&= false), true), + requestShowListbox: () => + this.expanded ||= true, + requestHideListbox: () => + ((this.expanded &&= false), true), isItem: item => item instanceof PfOption, setItemActive(active) { this.active = active; From 3fe2e041a0a4b96d5de365505ec9a5f6c08b8299 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Wed, 7 Aug 2024 16:10:48 +0300 Subject: [PATCH 027/122] fix(tabs): update to use new rtic stuff --- .../controllers/roving-tabindex-controller.ts | 5 +- elements/pf-tabs/pf-tab.ts | 7 ++- elements/pf-tabs/pf-tabs.ts | 60 +++++++++---------- 3 files changed, 33 insertions(+), 39 deletions(-) diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index a38ca980b0..5d826834b9 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -31,14 +31,13 @@ export class RovingTabindexController< set atFocusedItemIndex(index: number) { super.atFocusedItemIndex = index; const item = this.items.at(this.atFocusedItemIndex); - for (const focusable of this.atFocusableItems) { - focusable.tabIndex = item === focusable ? 0 : -1; + for (const i of this.items) { + i.tabIndex = item === i ? 0 : -1; } item?.focus(); this.host.requestUpdate(); } - get items() { return this._items; } diff --git a/elements/pf-tabs/pf-tab.ts b/elements/pf-tabs/pf-tab.ts index 4adcfb4047..780027ee0b 100644 --- a/elements/pf-tabs/pf-tab.ts +++ b/elements/pf-tabs/pf-tab.ts @@ -127,7 +127,8 @@ export class PfTab extends LitElement { #onKeydown(event: KeyboardEvent) { if (!this.disabled) { switch (event.key) { - case 'Enter': this.#activate(); + case 'Enter': + this.#activate(); } } } @@ -138,8 +139,8 @@ export class PfTab extends LitElement { } } - #activate() { - return this.dispatchEvent(new TabExpandEvent(this)); + async #activate() { + this.dispatchEvent(new TabExpandEvent(this)); } @observes('active') diff --git a/elements/pf-tabs/pf-tabs.ts b/elements/pf-tabs/pf-tabs.ts index f1b96a3419..df189fddc5 100644 --- a/elements/pf-tabs/pf-tabs.ts +++ b/elements/pf-tabs/pf-tabs.ts @@ -106,10 +106,25 @@ export class PfTabs extends LitElement { */ @property({ reflect: true, type: Boolean }) manual = false; - /** - * The index of the active tab - */ - @property({ attribute: 'active-index', reflect: true, type: Number }) activeIndex = -1; + #activeIndex = -1; + + /** The index of the active tab */ + @property({ attribute: 'active-index', reflect: true, type: Number }) + get activeIndex() { + return this.#activeIndex; + } + + set activeIndex(v: number) { + this.#tabindex.atFocusedItemIndex = v; + this.#activeIndex = v; + this.activeTab = this.tabs[v]; + for (const tab of this.tabs) { + if (!this.activeTab?.disabled) { + tab.active = tab === this.activeTab; + } + this.#tabs.panelFor(tab)?.toggleAttribute('hidden', !tab.active); + } + } @property({ attribute: false }) activeTab?: PfTab; @@ -146,6 +161,7 @@ export class PfTabs extends LitElement { super.connectedCallback(); this.addEventListener('expand', this.#onExpand); this.id ||= getRandomId(this.localName); + this.activeIndex = this.#tabindex.atFocusedItemIndex; } protected override async getUpdateComplete(): Promise { @@ -157,15 +173,9 @@ export class PfTabs extends LitElement { return here && ps.every(x => !!x); } - #lastTab: PfTab | null = null; - - protected override willUpdate(changed: PropertyValues): void { - if (this.#lastTab && this.tabs.indexOf(this.#lastTab) !== this.#tabindex.atFocusedItemIndex) { - this.#tabChanged(); - } else if (changed.has('activeIndex')) { - this.select(this.activeIndex); - } else if (changed.has('activeTab') && this.activeTab) { - this.select(this.activeTab); + protected override willUpdate(): void { + if (!this.manual && this.activeIndex !== this.#tabindex.atFocusedItemIndex) { + this.activeIndex = this.#tabindex.atFocusedItemIndex; } this.#overflow.update(); this.ctx = this.#ctx; @@ -176,6 +186,8 @@ export class PfTabs extends LitElement { if (activeTab?.disabled) { this.#logger.warn('Active tab is disabled. Setting to first focusable tab'); this.activeIndex = 0; + } if (activeTab) { + this.activeIndex = this.tabs.indexOf(activeTab); } } @@ -233,29 +245,11 @@ export class PfTabs extends LitElement { } } - #tabChanged() { - this.#lastTab = this.tabs.at(this.#tabindex.atFocusedItemIndex) ?? null; - const focusedTab = this.tabs.at(this.#tabindex.atFocusedItemIndex); - if (!focusedTab?.disabled) { - this.tabs?.forEach((tab, i) => { - if (!this.manual) { - const active = tab === focusedTab; - tab.active = active; - if (active) { - this.activeIndex = i; - this.activeTab = tab; - } - } - this.#tabs.panelFor(tab)?.toggleAttribute('hidden', !tab.active); - }); - } - } - select(tab: PfTab | number): void { if (typeof tab === 'number') { - this.#tabindex.atFocusedItemIndex = tab; + this.activeIndex = tab; } else { - this.#tabindex.atFocusedItemIndex = this.tabs.indexOf(tab); + this.activeIndex = this.tabs.indexOf(tab); } } } From c0f23101f3381cb2924f31215670588991ac8742 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Wed, 7 Aug 2024 18:39:14 +0300 Subject: [PATCH 028/122] fix(core): initial focus for rti --- .../controllers/roving-tabindex-controller.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index 5d826834b9..624c9c954b 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -1,5 +1,6 @@ import type { ReactiveControllerHost } from 'lit'; import { ATFocusController, type ATFocusControllerOptions } from './at-focus-controller.js'; +import { Logger } from './logger.js'; export type RovingTabindexControllerOptions = ATFocusControllerOptions; @@ -20,6 +21,10 @@ export class RovingTabindexController< return new RovingTabindexController(host, options); } + #logger = new Logger(this.host); + + #gainedInitialFocus = false; + get atFocusedItemIndex(): number { return super.atFocusedItemIndex; } @@ -34,7 +39,9 @@ export class RovingTabindexController< for (const i of this.items) { i.tabIndex = item === i ? 0 : -1; } - item?.focus(); + if (this.#gainedInitialFocus) { + item?.focus(); + } this.host.requestUpdate(); } @@ -60,6 +67,12 @@ export class RovingTabindexController< ) { super(host, options); this.initItems(); + const container = options.getItemsContainer?.() ?? this.host; + if (container instanceof HTMLElement) { + container.addEventListener('focusin', () => this.#gainedInitialFocus = true, { once: true }); + } else { + this.#logger.warn('RovingTabindexController requires a getItemsContainer function'); + } } protected override isRelevantKeyboardEvent(event: Event): event is KeyboardEvent { From 6e2dec49f50066711fba419671406854ed5164b3 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Wed, 7 Aug 2024 18:39:35 +0300 Subject: [PATCH 029/122] fix(accordion): wip rtic migration --- elements/pf-accordion/pf-accordion.ts | 14 +- .../pf-accordion/test/pf-accordion.spec.ts | 181 +++++++++++------- 2 files changed, 115 insertions(+), 80 deletions(-) diff --git a/elements/pf-accordion/pf-accordion.ts b/elements/pf-accordion/pf-accordion.ts index 6c41de071d..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 = RovingTabindexController.of(this, { + #tabindex = RovingTabindexController.of(this, { getItems: () => this.headers, }); @@ -239,8 +237,8 @@ export class PfAccordion extends LitElement { @listen('focusin') protected updateActiveHeader(): void { if (this.#activeHeader - && this.#activeHeader !== this.headers.at(this.#headerIndex.atFocusedItemIndex)) { - this.#headerIndex.atFocusedItemIndex = this.headers.indexOf(this.#activeHeader); + && this.#activeHeader !== this.headers.at(this.#tabindex.atFocusedItemIndex)) { + this.#tabindex.atFocusedItemIndex = this.headers.indexOf(this.#activeHeader); } } @@ -365,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..c791766e50 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'; @@ -64,6 +63,10 @@ describe('', function() { }; } + async function getFocusedSnapshot() { + return querySnapshot(await a11ySnapshot(), { focused: true }); + } + it('imperatively instantiates', function() { expect(document.createElement('pf-accordion')).to.be.an.instanceof(PfAccordion); expect(document.createElement('pf-accordion-header')).to.be.an.instanceof(PfAccordionHeader); @@ -71,7 +74,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,7 +83,7 @@ describe('', function() { describe('in typical usage', function() { beforeEach(async function() { - element = await createFixture(html` + element = await fixture(html`

Consetetur sadipscing elitr?

@@ -130,8 +133,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; @@ -330,6 +333,7 @@ describe('', function() { afterEach(async function() { [header1, header2, header3] = [] as PfAccordionHeader[]; [panel1, panel2, panel3] = [] as PfAccordionPanel[]; + await fixture(''); }); describe('with all panels closed', function() { @@ -345,8 +349,8 @@ describe('', 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; @@ -391,22 +395,24 @@ 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 getFocusedSnapshot()).to.not.be.ok; }); }); 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 getFocusedSnapshot()).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 getFocusedSnapshot()) + .to.have.property('name', + header2.textContent?.trim()); }); it('does not open panels', function() { expect(panel1.expanded).to.be.false; @@ -417,8 +423,10 @@ 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 getFocusedSnapshot()) + .to.have.property('name', + header3.textContent?.trim()); }); it('does not open panels', function() { expect(panel1.expanded).to.be.false; @@ -429,8 +437,10 @@ 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 getFocusedSnapshot()) + .to.have.property('name', + header1.textContent?.trim()); }); it('does not open panels', function() { expect(panel1.expanded).to.be.false; @@ -441,8 +451,10 @@ 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 getFocusedSnapshot()) + .to.have.property('name', + header3.textContent?.trim()); }); it('does not open panels', function() { expect(panel1.expanded).to.be.false; @@ -455,9 +467,10 @@ describe('', function() { describe('when focus is on the middle header', function() { beforeEach(async function() { header2.focus(); - await nextFrame(); }); + beforeEach(nextFrame); + describe('Space', function() { beforeEach(press(' ')); it('expands the middle panel', function() { @@ -478,37 +491,46 @@ 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 getFocusedSnapshot()) + .to.not.be.ok; }); }); 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 getFocusedSnapshot()) + .to.have.property('name', + header3.textContent?.trim()); }); }); 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 getFocusedSnapshot()) + .to.have.property('name', + header1.textContent?.trim()); }); }); 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 getFocusedSnapshot()) + .to.have.property('name', + header1.textContent?.trim()); }); }); 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 getFocusedSnapshot()) + .to.have.property('name', + header3.textContent?.trim()); }); }); }); @@ -516,9 +538,10 @@ describe('', function() { describe('when focus is on the last header', function() { beforeEach(async function() { header3.focus(); - await nextFrame(); }); + beforeEach(nextFrame); + describe('Space', function() { beforeEach(press(' ')); it('expands the last panel', function() { @@ -555,37 +578,42 @@ 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 first header', function() { - expect(document.activeElement).to.equal(header1); + it('moves focus to the first header', async function() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', '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() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', '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() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', '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() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header3'); }); }); }); @@ -622,8 +650,9 @@ 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() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { focused: true })).to.not.be.ok; }); describe('Shift+Tab', function() { beforeEach(press('Shift+Tab')); @@ -688,16 +717,18 @@ 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() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header2'); }); it('does not open other panels', function() { @@ -710,8 +741,9 @@ 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() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header3'); }); it('does not open other panels', function() { @@ -723,8 +755,9 @@ 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() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header1'); }); it('does not open other panels', function() { @@ -736,8 +769,9 @@ 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() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header3'); }); it('does not open other panels', function() { expect(panel1.expanded).to.be.true; @@ -806,22 +840,25 @@ 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('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() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', '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() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header3'); }); }); }); @@ -867,7 +904,7 @@ describe('', function() { describe('with single attribute', function() { beforeEach(async function() { - element = await createFixture(html` + element = await fixture(html`

Consetetur sadipscing elitr?

@@ -931,7 +968,7 @@ describe('', function() { describe('with expanded attribute on two headers', function() { beforeEach(async function() { - element = await createFixture(html` + element = await fixture(html`

h

p

@@ -960,7 +997,7 @@ describe('', function() { describe('with single attribute and expanded attribute on two headers', function() { beforeEach(async function() { - element = await createFixture(html` + element = await fixture(html`

h

p

@@ -990,7 +1027,7 @@ describe('', function() { describe('with no h* tag in heading lightdom', function() { beforeEach(async function() { - element = await createFixture(html` + element = await fixture(html` Bad Header @@ -1024,7 +1061,7 @@ describe('', function() { let nestedPanelThree: PfAccordionPanel; beforeEach(async function() { - element = await createFixture(html` + element = await fixture(html` top-header-1 @@ -1189,8 +1226,9 @@ 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() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { focused: true })).to.not.be.ok; }); }); }); @@ -1227,7 +1265,7 @@ describe('', function() { describe('ArrowUp', function() { beforeEach(press('ArrowUp')); - it('moves focus to the last header', function() { + it('moves focus to the last nested header', function() { expect(document.activeElement).to.equal(nestedHeaderThree); }); }); @@ -1255,8 +1293,9 @@ describe('', function() { describe('Tab', function() { beforeEach(press('Tab')); - it('should move focus back to the body', function() { - expect(document.activeElement).to.equal(document.body); + it('should move focus back to the body', async function() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { focused: true })).to.not.be.ok; }); }); }); @@ -1275,7 +1314,7 @@ describe('', function() { let accordionTwoPanelOne: PfAccordionPanel; beforeEach(async function() { - multipleAccordionElements = await createFixture(html` + multipleAccordionElements = await fixture(html`
@@ -1359,7 +1398,7 @@ describe('', function() { let accordionPanelOne: PfAccordionPanel; beforeEach(async function() { - element = await createFixture(html` + element = await fixture(html` From e6cb3f320e7f9382d062181669bec60911c3dc43 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Wed, 7 Aug 2024 19:03:54 +0300 Subject: [PATCH 030/122] test(accordion): update tests --- .../pf-accordion/test/pf-accordion.spec.ts | 185 +++++++++++------- 1 file changed, 114 insertions(+), 71 deletions(-) diff --git a/elements/pf-accordion/test/pf-accordion.spec.ts b/elements/pf-accordion/test/pf-accordion.spec.ts index c791766e50..23fc7fbeeb 100644 --- a/elements/pf-accordion/test/pf-accordion.spec.ts +++ b/elements/pf-accordion/test/pf-accordion.spec.ts @@ -579,8 +579,8 @@ describe('', function() { describe('Shift+Tab', function() { beforeEach(press('Shift+Tab')); it('moves focus to the body', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { focused: true })).to.not.be.ok; + expect(await getFocusedSnapshot()) + .to.not.be.ok; }); }); @@ -588,32 +588,36 @@ describe('', function() { describe('ArrowDown', function() { beforeEach(press('ArrowDown')); it('moves focus to the first header', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header1'); + expect(await getFocusedSnapshot()) + .to.have.property('name', + header1.textContent?.trim()); }); }); describe('ArrowUp', function() { beforeEach(press('ArrowUp')); it('moves focus to the middle header', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header2'); + expect(await getFocusedSnapshot()) + .to.have.property('name', + header2.textContent?.trim()); }); }); describe('Home', function() { beforeEach(press('Home')); it('moves focus to the first header', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header1'); + expect(await getFocusedSnapshot()) + .to.have.property('name', + header1.textContent?.trim()); }); }); describe('End', function() { beforeEach(press('End')); it('moves focus to the last header', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header3'); + expect(await getFocusedSnapshot()) + .to.have.property('name', + header3.textContent?.trim()); }); }); }); @@ -645,19 +649,23 @@ 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 getFocusedSnapshot()) + .to.have.property('name', + panel1.querySelector('a')?.textContent?.trim()); }); describe('Tab', function() { beforeEach(press('Tab')); it('moves focus to the body', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { focused: true })).to.not.be.ok; + expect(await getFocusedSnapshot()) + .to.not.be.ok; }); 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 getFocusedSnapshot()) + .to.have.property('name', + panel1.querySelector('a')?.textContent?.trim()); }); }); }); @@ -665,8 +673,10 @@ describe('', 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 getFocusedSnapshot()) + .to.have.property('name', + panel1.querySelector('a')?.textContent?.trim()); }); it('does not open other panels', function() { expect(panel1.expanded).to.be.true; @@ -677,8 +687,10 @@ 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 getFocusedSnapshot()) + .to.have.property('name', + panel1.querySelector('a')?.textContent?.trim()); }); it('does not open other panels', function() { expect(panel1.expanded).to.be.true; @@ -690,8 +702,10 @@ 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 getFocusedSnapshot()) + .to.have.property('name', + panel1.querySelector('a')?.textContent?.trim()); }); it('does not open other panels', function() { @@ -703,8 +717,10 @@ 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 getFocusedSnapshot()) + .to.have.property('name', + panel1.querySelector('a')?.textContent?.trim()); }); it('does not open other panels', function() { expect(panel1.expanded).to.be.true; @@ -727,8 +743,8 @@ describe('', function() { beforeEach(press('ArrowDown')); it('moves focus to the second header', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header2'); + expect(await getFocusedSnapshot()) + .to.not.be.ok; }); it('does not open other panels', function() { @@ -742,8 +758,9 @@ describe('', function() { beforeEach(press('ArrowUp')); it('moves focus to the last header', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header3'); + expect(await getFocusedSnapshot()) + .to.have.property('name', + header3?.textContent?.trim()); }); it('does not open other panels', function() { @@ -756,8 +773,9 @@ describe('', function() { describe('Home', function() { beforeEach(press('Home')); it('moves focus to the first header', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header1'); + expect(await getFocusedSnapshot()) + .to.have.property('name', + header1?.textContent?.trim()); }); it('does not open other panels', function() { @@ -770,8 +788,9 @@ describe('', function() { describe('End', function() { beforeEach(press('End')); it('moves focus to the last header', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header3'); + expect(await getFocusedSnapshot()) + .to.have.property('name', + header3?.textContent?.trim()); }); it('does not open other panels', function() { expect(panel1.expanded).to.be.true; @@ -788,7 +807,9 @@ describe('', function() { describe('Shift+Tab', function() { beforeEach(press('Shift+Tab')); it('moves focus to the link in first panel', async function() { - expect(document.activeElement).to.equal(panel1.querySelector('a')); + expect(await getFocusedSnapshot()) + .to.have.property('name', + panel1.querySelector('a')?.textContent?.trim()); }); }); }); @@ -833,32 +854,36 @@ 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 getFocusedSnapshot()) + .to.have.property('name', + panel2.querySelector('a')?.textContent?.trim()); }); }); describe('Shift+Tab', function() { beforeEach(press('Shift+Tab')); it('moves focus to the body', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { focused: true })).to.not.be.ok; + expect(await getFocusedSnapshot()) + .to.not.be.ok; }); }); describe('ArrowUp', function() { beforeEach(press('ArrowUp')); it('moves focus to the first header', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header1'); + expect(await getFocusedSnapshot()) + .to.have.property('name', + header1?.textContent?.trim()); }); }); describe('ArrowDown', function() { beforeEach(press('ArrowDown')); it('moves focus to the last header', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { focused: true })).to.have.property('name', 'header3'); + expect(await getFocusedSnapshot()) + .to.have.property('name', + header3?.textContent?.trim()); }); }); }); @@ -871,8 +896,10 @@ describe('', function() { 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 getFocusedSnapshot()) + .to.have.property('name', + panel2.querySelector('a')?.textContent?.trim()); }); }); }); @@ -893,8 +920,10 @@ describe('', function() { 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 getFocusedSnapshot()) + .to.have.property('name', + panel3.querySelector('a')?.textContent?.trim()); }); }); }); @@ -1052,9 +1081,9 @@ describe('', function() { let topLevelPanelOne: PfAccordionPanel; let topLevelPanelTwo: PfAccordionPanel; - let nestedHeaderOne: PfAccordionHeader; - let nestedHeaderTwo: PfAccordionHeader; - let nestedHeaderThree: PfAccordionHeader; + let nestedHeader1: PfAccordionHeader; + let nestedHeader2: PfAccordionHeader; + let nestedHeader3: PfAccordionHeader; let nestedPanelOne: PfAccordionPanel; let nestedPanelTwo: PfAccordionPanel; @@ -1117,9 +1146,9 @@ describe('', function() { topLevelPanelOne = document.getElementById('panel-1') as PfAccordionPanel; topLevelPanelTwo = document.getElementById('panel-2') as PfAccordionPanel; - nestedHeaderOne = document.getElementById('header-2-1') as PfAccordionHeader; - nestedHeaderTwo = document.getElementById('header-2-2') as PfAccordionHeader; - nestedHeaderThree = document.getElementById('header-2-3') as PfAccordionHeader; + nestedHeader1 = document.getElementById('header-2-1') as PfAccordionHeader; + nestedHeader2 = document.getElementById('header-2-2') as PfAccordionHeader; + nestedHeader3 = document.getElementById('header-2-3') as PfAccordionHeader; nestedPanelOne = document.getElementById('panel-2-1') as PfAccordionPanel; nestedPanelTwo = document.getElementById('panel-2-2') as PfAccordionPanel; @@ -1140,12 +1169,12 @@ describe('', function() { }); describe('then clicking the first nested heading', function() { beforeEach(async function() { - await clickElementAtCenter(nestedHeaderOne); + await clickElementAtCenter(nestedHeader1); await allUpdates(element); }); describe('then clicking the second nested heading', function() { beforeEach(async function() { - await clickElementAtCenter(nestedHeaderTwo); + await clickElementAtCenter(nestedHeader2); await allUpdates(element); }); it('expands the first top-level pair', async function() { @@ -1227,8 +1256,8 @@ describe('', function() { describe('Tab', function() { beforeEach(press('Tab')); it('moves focus to the body', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { focused: true })).to.not.be.ok; + expect(await getFocusedSnapshot()) + .to.not.be.ok; }); }); }); @@ -1259,43 +1288,53 @@ describe('', function() { describe('Opening the panel containing the nested accordion and pressing TAB', function() { beforeEach(press('Space')); beforeEach(press('Tab')); - it('moves focus to the nested accordion header', function() { - expect(document.activeElement).to.equal(nestedHeaderOne); + it('moves focus to the nested accordion header', async function() { + expect(await getFocusedSnapshot()) + .to.have.property('name', + nestedHeader1?.textContent?.trim()); }); describe('ArrowUp', function() { beforeEach(press('ArrowUp')); - it('moves focus to the last nested header', function() { - expect(document.activeElement).to.equal(nestedHeaderThree); + it('moves focus to the last nested header', async function() { + expect(await getFocusedSnapshot()) + .to.have.property('name', + nestedHeader3?.textContent?.trim()); }); }); describe('ArrowLeft', function() { beforeEach(press('ArrowLeft')); - it('moves focus to the last header', function() { - expect(document.activeElement).to.equal(nestedHeaderThree); + it('moves focus to the last header', async function() { + expect(await getFocusedSnapshot()) + .to.have.property('name', + nestedHeader3?.textContent?.trim()); }); }); describe('ArrowDown', function() { beforeEach(press('ArrowDown')); - it('moves focus to the second header', function() { - expect(document.activeElement).to.equal(nestedHeaderTwo); + it('moves focus to the second header', async function() { + expect(await getFocusedSnapshot()) + .to.have.property('name', + nestedHeader2?.textContent?.trim()); }); }); describe('ArrowRight', function() { beforeEach(press('ArrowRight')); - it('moves focus to the second header', function() { - expect(document.activeElement).to.equal(nestedHeaderTwo); + it('moves focus to the second header', async function() { + expect(await getFocusedSnapshot()) + .to.have.property('name', + nestedHeader2?.textContent?.trim()); }); }); describe('Tab', function() { beforeEach(press('Tab')); it('should move focus back to the body', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { focused: true })).to.not.be.ok; + expect(await getFocusedSnapshot()) + .to.not.be.ok; }); }); }); @@ -1376,13 +1415,17 @@ describe('', function() { describe('Tab', function() { beforeEach(press('Tab')); - it('moves focus to the second accordion', function() { - expect(document.activeElement).to.equal(accordionTwoHeaderOne); + it('moves focus to the second accordion', async function() { + expect(await getFocusedSnapshot()) + .to.have.property('name', + accordionTwoHeaderOne?.textContent?.trim()); }); describe('Shift+Tab', function() { beforeEach(press('Shift+Tab')); - it('moves focus back to the first accordion', function() { - expect(document.activeElement).to.equal(accordionOneHeaderOne); + it('moves focus back to the first accordion', async function() { + expect(await getFocusedSnapshot()) + .to.have.property('name', + accordionOneHeaderOne?.textContent?.trim()); }); }); }); From 0ae0a32b215fcc67e2fc54d1cafd7ae0c2e7b6d7 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 11:55:35 +0300 Subject: [PATCH 031/122] refactor(accordion): whitespace --- elements/pf-accordion/pf-accordion-header.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/elements/pf-accordion/pf-accordion-header.ts b/elements/pf-accordion/pf-accordion-header.ts index 140abf79a0..f703ba2aca 100644 --- a/elements/pf-accordion/pf-accordion-header.ts +++ b/elements/pf-accordion/pf-accordion-header.ts @@ -143,10 +143,11 @@ export class PfAccordionHeader extends LitElement { + class="icon" + size="lg" + set="${this.iconSet ?? 'fas'}" + icon="${this.icon ?? 'angle-right'}" + > `; switch (this.headingTag) { From 7781dbc9530ba27beb0dc47d90150d3b0144f647 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 11:55:54 +0300 Subject: [PATCH 032/122] docs(accordion): focusable content in panel --- elements/pf-accordion/demo/pf-accordion.html | 23 +++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) 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.

- - - From 95cb272582a6738303307e536cd768e76701336d Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 11:56:03 +0300 Subject: [PATCH 033/122] docs(accordion): demo formatting --- elements/pf-accordion/demo/nested.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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

From 15782d9df9d9d58046dc940c6f6f67cf70b3bb26 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 11:56:22 +0300 Subject: [PATCH 034/122] test(accordion): fix and refactor tests --- .../pf-accordion/test/pf-accordion.spec.ts | 681 ++++++++---------- 1 file changed, 307 insertions(+), 374 deletions(-) diff --git a/elements/pf-accordion/test/pf-accordion.spec.ts b/elements/pf-accordion/test/pf-accordion.spec.ts index 23fc7fbeeb..0ab04c3d31 100644 --- a/elements/pf-accordion/test/pf-accordion.spec.ts +++ b/elements/pf-accordion/test/pf-accordion.spec.ts @@ -63,10 +63,6 @@ describe('', function() { }; } - async function getFocusedSnapshot() { - return querySnapshot(await a11ySnapshot(), { focused: true }); - } - it('imperatively instantiates', function() { expect(document.createElement('pf-accordion')).to.be.an.instanceof(PfAccordion); expect(document.createElement('pf-accordion-header')).to.be.an.instanceof(PfAccordionHeader); @@ -86,26 +82,26 @@ describe('', function() { element = await fixture(html` -

Consetetur sadipscing elitr?

+

Header1 Consetetur sadipscing elitr?

-

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.

@@ -115,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-/); @@ -229,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))) { @@ -248,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

`; @@ -259,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); @@ -336,15 +333,13 @@ describe('', function() { 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); @@ -358,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(); }); @@ -396,23 +391,21 @@ describe('', function() { describe('Tab', function() { beforeEach(press('Tab')); it('blurs out of the accordion', async function() { - expect(await getFocusedSnapshot()).to.not.be.ok; + expect(await a11ySnapshot()).to.have.axTreeFocusOn(document.body); }); }); describe('Shift+Tab', function() { beforeEach(press('Shift+Tab')); it('blurs out of the accordion', async function() { - expect(await getFocusedSnapshot()).to.not.be.ok; + expect(await a11ySnapshot()).to.have.axTreeFocusOn(document.body); }); }); describe('ArrowDown', function() { beforeEach(press('ArrowDown')); it('moves focus to the second header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header2.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(header2); }); it('does not open panels', function() { expect(panel1.expanded).to.be.false; @@ -424,9 +417,7 @@ describe('', function() { describe('ArrowUp', function() { beforeEach(press('ArrowUp')); it('moves focus to the last header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header3.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(header3); }); it('does not open panels', function() { expect(panel1.expanded).to.be.false; @@ -438,9 +429,7 @@ describe('', function() { describe('Home', function() { beforeEach(press('Home')); it('moves focus to the first header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header1.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(header1); }); it('does not open panels', function() { expect(panel1.expanded).to.be.false; @@ -452,9 +441,7 @@ describe('', function() { describe('End', function() { beforeEach(press('End')); it('moves focus to the last header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header3.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(header3); }); it('does not open panels', function() { expect(panel1.expanded).to.be.false; @@ -464,8 +451,8 @@ 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(); }); @@ -492,8 +479,7 @@ describe('', function() { describe('Tab', function() { beforeEach(press('Tab')); it('moves focus to the body', async function() { - expect(await getFocusedSnapshot()) - .to.not.be.ok; + expect(await a11ySnapshot()).to.have.axTreeFocusOn(document.body); }); }); @@ -501,42 +487,34 @@ describe('', function() { describe('ArrowDown', function() { beforeEach(press('ArrowDown')); it('moves focus to the last header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header3.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(header3); }); }); describe('ArrowUp', function() { beforeEach(press('ArrowUp')); it('moves focus to the first header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header1.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(header1); }); }); describe('Home', function() { beforeEach(press('Home')); it('moves focus to the first header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header1.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(header1); }); }); describe('End', function() { beforeEach(press('End')); it('moves focus to the last header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header3.textContent?.trim()); + 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(); }); @@ -579,58 +557,53 @@ describe('', function() { describe('Shift+Tab', function() { beforeEach(press('Shift+Tab')); it('moves focus to the body', async function() { - expect(await getFocusedSnapshot()) - .to.not.be.ok; + expect(await a11ySnapshot()).to.have.axTreeFocusOn(document.body); }); }); - describe('ArrowDown', function() { beforeEach(press('ArrowDown')); it('moves focus to the first header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header1.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(header1); }); }); describe('ArrowUp', function() { beforeEach(press('ArrowUp')); it('moves focus to the middle header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header2.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(header2); }); }); describe('Home', function() { beforeEach(press('Home')); it('moves focus to the first header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header1.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(header1); }); }); describe('End', function() { beforeEach(press('End')); it('moves focus to the last header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header3.textContent?.trim()); + 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; @@ -650,33 +623,28 @@ describe('', function() { describe('Tab', function() { beforeEach(press('Tab')); it('moves focus to the link in the first panel', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - panel1.querySelector('a')?.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel1.querySelector('a')); }); + describe('Tab', function() { beforeEach(press('Tab')); it('moves focus to the body', async function() { - expect(await getFocusedSnapshot()) - .to.not.be.ok; + 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', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - panel1.querySelector('a')?.textContent?.trim()); + 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', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - panel1.querySelector('a')?.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel1.querySelector('a')); }); it('does not open other panels', function() { expect(panel1.expanded).to.be.true; @@ -688,9 +656,7 @@ describe('', function() { describe('ArrowUp', function() { beforeEach(press('ArrowUp')); it('keeps focus on the link in the first panel', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - panel1.querySelector('a')?.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel1.querySelector('a')); }); it('does not open other panels', function() { expect(panel1.expanded).to.be.true; @@ -703,9 +669,7 @@ describe('', function() { beforeEach(press('Home')); it('keeps focus on the link in the first panel', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - panel1.querySelector('a')?.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel1.querySelector('a')); }); it('does not open other panels', function() { @@ -718,9 +682,7 @@ describe('', function() { describe('End', function() { beforeEach(press('End')); it('keeps focus on the link in the first panel', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - panel1.querySelector('a')?.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel1.querySelector('a')); }); it('does not open other panels', function() { expect(panel1.expanded).to.be.true; @@ -743,8 +705,7 @@ describe('', function() { beforeEach(press('ArrowDown')); it('moves focus to the second header', async function() { - expect(await getFocusedSnapshot()) - .to.not.be.ok; + expect(await a11ySnapshot()).to.have.axTreeFocusOn(header2); }); it('does not open other panels', function() { @@ -758,9 +719,7 @@ describe('', function() { beforeEach(press('ArrowUp')); it('moves focus to the last header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header3?.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(header3); }); it('does not open other panels', function() { @@ -773,9 +732,7 @@ describe('', function() { describe('Home', function() { beforeEach(press('Home')); it('moves focus to the first header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header1?.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(header1); }); it('does not open other panels', function() { @@ -788,9 +745,7 @@ describe('', function() { describe('End', function() { beforeEach(press('End')); it('moves focus to the last header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header3?.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(header3); }); it('does not open other panels', function() { expect(panel1.expanded).to.be.true; @@ -800,25 +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(await getFocusedSnapshot()) - .to.have.property('name', - panel1.querySelector('a')?.textContent?.trim()); + 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; }); @@ -828,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() { @@ -855,75 +815,70 @@ describe('', function() { describe('Tab', function() { beforeEach(press('Tab')); it('moves focus to the link in the second panel', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - panel2.querySelector('a')?.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel2.querySelector('a')); }); }); describe('Shift+Tab', function() { beforeEach(press('Shift+Tab')); it('moves focus to the body', async function() { - expect(await getFocusedSnapshot()) - .to.not.be.ok; + expect(await a11ySnapshot()).to.have.axTreeFocusOn(document.body); }); }); describe('ArrowUp', function() { beforeEach(press('ArrowUp')); it('moves focus to the first header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header1?.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(header1); }); }); describe('ArrowDown', function() { beforeEach(press('ArrowDown')); it('moves focus to the last header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - header3?.textContent?.trim()); + 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', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - panel2.querySelector('a')?.textContent?.trim()); + 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', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - panel3.querySelector('a')?.textContent?.trim()); + expect(await a11ySnapshot()).to.have.axTreeFocusOn(panel3.querySelector('a')); }); }); }); @@ -1074,116 +1029,94 @@ describe('', function() { }); }); - describe('with nested pf-accordion', function() { - let topLevelHeaderOne: PfAccordionHeader; - let topLevelHeaderTwo: PfAccordionHeader; + describe('with nested ', function() { + let topLevelHeader1: PfAccordionHeader; + let topLevelHeader2: PfAccordionHeader; - let topLevelPanelOne: PfAccordionPanel; - let topLevelPanelTwo: PfAccordionPanel; + let topLevelPanel1: PfAccordionPanel; + let topLevelPanel2: PfAccordionPanel; let nestedHeader1: PfAccordionHeader; let nestedHeader2: PfAccordionHeader; let nestedHeader3: PfAccordionHeader; - let nestedPanelOne: PfAccordionPanel; - let nestedPanelTwo: PfAccordionPanel; - let nestedPanelThree: PfAccordionPanel; + let nestedPanel1: PfAccordionPanel; + let nestedPanel2: PfAccordionPanel; + let nestedPanel3: PfAccordionPanel; beforeEach(async function() { element = await fixture(html` - - top-header-1 - + top-header-1 top-panel-1 - - nest-1-header-1 - - - nest-1-panel-1 - + nest-1-header-1 + nest-1-panel-1 - - top-header-2 - + top-header-2 top-panel-2 - - nest-2-header-1 - - - nest-2-header-1 - - - nest-2-header-2 - - - nest-2-panel-2 - - - nest-2-header-3 - - - nest-2-panel-3 - + nest-2-header-1 + nest-2-header-1 + nest-2-header-2 + nest-2-panel-2 + nest-2-header-3 + nest-2-panel-3 - - top-header-3 - - - top-panel-3 - + top-header-3 + top-panel-3 `); - topLevelHeaderOne = document.getElementById('header-1') as PfAccordionHeader; - topLevelHeaderTwo = document.getElementById('header-2') as PfAccordionHeader; + topLevelHeader1 = document.getElementById('header-1') as PfAccordionHeader; + topLevelHeader2 = document.getElementById('header-2') as PfAccordionHeader; - topLevelPanelOne = document.getElementById('panel-1') as PfAccordionPanel; - topLevelPanelTwo = document.getElementById('panel-2') as PfAccordionPanel; + topLevelPanel1 = document.getElementById('panel-1') as PfAccordionPanel; + topLevelPanel2 = document.getElementById('panel-2') as PfAccordionPanel; nestedHeader1 = document.getElementById('header-2-1') as PfAccordionHeader; nestedHeader2 = document.getElementById('header-2-2') as PfAccordionHeader; nestedHeader3 = document.getElementById('header-2-3') as PfAccordionHeader; - nestedPanelOne = document.getElementById('panel-2-1') as PfAccordionPanel; - nestedPanelTwo = document.getElementById('panel-2-2') as PfAccordionPanel; - nestedPanelThree = document.getElementById('panel-2-3') as PfAccordionPanel; - - await allUpdates(element); + nestedPanel1 = document.getElementById('panel-2-1') as PfAccordionPanel; + nestedPanel2 = document.getElementById('panel-2-2') as PfAccordionPanel; + nestedPanel3 = document.getElementById('panel-2-3') as PfAccordionPanel; }); + beforeEach(() => allUpdates(element)); + describe('clicking the first top-level heading', function() { beforeEach(async function() { - await clickElementAtCenter(topLevelHeaderOne); - await allUpdates(element); + await clickElementAtCenter(topLevelHeader1); }); + + beforeEach(() => allUpdates(element)); + describe('then clicking the second top-level heading', function() { beforeEach(async function() { - await clickElementAtCenter(topLevelHeaderTwo); - await allUpdates(element); + await clickElementAtCenter(topLevelHeader2); }); + beforeEach(() => allUpdates(element)); describe('then clicking the first nested heading', function() { beforeEach(async function() { await clickElementAtCenter(nestedHeader1); - await allUpdates(element); }); + beforeEach(() => allUpdates(element)); describe('then clicking the second nested heading', function() { beforeEach(async function() { await clickElementAtCenter(nestedHeader2); - await allUpdates(element); }); + beforeEach(() => allUpdates(element)); it('expands the first top-level pair', async function() { const snapshot = await a11ySnapshot(); const expanded = snapshot?.children?.find(x => x.expanded); - expect(expanded?.name).to.equal(topLevelHeaderOne.textContent?.trim()); - expect(topLevelHeaderOne.expanded).to.be.true; - expect(topLevelPanelOne.hasAttribute('expanded')).to.be.true; - expect(topLevelPanelOne.expanded).to.be.true; + expect(expanded?.name).to.equal(topLevelHeader1.textContent?.trim()); + expect(topLevelHeader1.expanded).to.be.true; + expect(topLevelPanel1.hasAttribute('expanded')).to.be.true; + expect(topLevelPanel1.expanded).to.be.true; }); it('collapses the second top-level pair', async function() { const snapshot = await a11ySnapshot(); @@ -1203,154 +1136,160 @@ describe('', function() { }); }); - describe('for assistive technology', function() { - describe('with all panels closed', function() { - it('applies hidden attribute to all panels', function() { - expect(topLevelPanelOne.hidden, 'panel-1').to.be.true; - expect(topLevelPanelTwo.hidden, 'panel-2').to.be.true; - expect(nestedPanelOne.hidden, 'panel-1-1').to.be.true; - expect(nestedPanelTwo.hidden, 'panel-2-2').to.be.true; - expect(nestedPanelThree.hidden, 'panel-2-3').to.be.true; - }); + describe('with all panels closed', function() { + it('applies hidden attribute to all panels', function() { + expect(topLevelPanel1.hidden, 'panel-1').to.be.true; + expect(topLevelPanel2.hidden, 'panel-2').to.be.true; + expect(nestedPanel1.hidden, 'panel-1-1').to.be.true; + expect(nestedPanel2.hidden, 'panel-2-2').to.be.true; + expect(nestedPanel3.hidden, 'panel-2-3').to.be.true; }); + }); - describe('with all panels open', function() { - beforeEach(async function() { - await Promise.all(Array.from( - document.querySelectorAll('pf-accordion'), - accordion => accordion.expandAll(), - )); - await nextFrame(); - }); - it('removes hidden attribute from all panels', function() { - expect(topLevelPanelOne.hidden, 'panel-1').to.be.false; - expect(topLevelPanelTwo.hidden, 'panel-2').to.be.false; - expect(nestedPanelOne.hidden, 'panel-1-1').to.be.false; - expect(nestedPanelTwo.hidden, 'panel-2-2').to.be.false; - expect(nestedPanelThree.hidden, 'panel-2-3').to.be.false; - }); + describe('calling expandAll() on all accordions', function() { + beforeEach(() => Promise.all(Array.from(document.querySelectorAll('pf-accordion'), a => + a.expandAll()))); + + beforeEach(nextFrame); + + it('removes hidden attribute from all panels', function() { + expect(topLevelPanel1.hidden, 'panel-1').to.be.false; + expect(topLevelPanel2.hidden, 'panel-2').to.be.false; + expect(nestedPanel1.hidden, 'panel-1-1').to.be.false; + expect(nestedPanel2.hidden, 'panel-2-2').to.be.false; + expect(nestedPanel3.hidden, 'panel-2-3').to.be.false; }); + }); - describe('when focus is on the first header of the parent accordion', function() { - beforeEach(async function() { - topLevelHeaderOne.focus(); - await nextFrame(); - }); + describe('calling focus() on the first header of the parent accordion', function() { + beforeEach(function() { + topLevelHeader1.focus(); + }); - describe('Space', function() { - beforeEach(press(' ')); - it('expands the first panel', function() { - expect(topLevelPanelOne.expanded).to.be.true; - expect(topLevelPanelTwo.expanded).to.be.false; - expect(nestedPanelOne.expanded).to.be.false; - expect(nestedPanelTwo.expanded).to.be.false; - }); - it('removes hidden attribute from the first panel', function() { - expect(topLevelPanelOne.hidden, 'panel-1').to.be.false; - expect(topLevelPanelTwo.hidden, 'panel-2').to.be.true; - expect(nestedPanelOne.hidden, 'panel-1-1').to.be.true; - expect(nestedPanelTwo.hidden, 'panel-2-2').to.be.true; - }); - }); + beforeEach(nextFrame); - describe('Tab', function() { - beforeEach(press('Tab')); - it('moves focus to the body', async function() { - expect(await getFocusedSnapshot()) - .to.not.be.ok; - }); + describe('Space', function() { + beforeEach(press(' ')); + it('expands the first panel', function() { + expect(topLevelPanel1.expanded).to.be.true; + expect(topLevelPanel2.expanded).to.be.false; + expect(nestedPanel1.expanded).to.be.false; + expect(nestedPanel2.expanded).to.be.false; + }); + it('removes hidden attribute from the first panel', function() { + expect(topLevelPanel1.hidden, 'panel-1').to.be.false; + expect(topLevelPanel2.hidden, 'panel-2').to.be.true; + expect(nestedPanel1.hidden, 'panel-1-1').to.be.true; + expect(nestedPanel2.hidden, 'panel-2-2').to.be.true; }); }); - describe('when focus is on the last header of the parent accordion', function() { - beforeEach(async function() { - topLevelHeaderTwo.focus(); - await nextFrame(); + describe('Tab', function() { + beforeEach(press('Tab')); + it('moves focus to the body', async function() { + expect(await a11ySnapshot()).to.have.axTreeFocusOn(document.body); }); + }); + }); - describe('Space', function() { - beforeEach(press(' ')); - it('expands the first panel', function() { - expect(topLevelPanelOne.expanded).to.be.false; - expect(topLevelPanelTwo.expanded).to.be.true; - expect(nestedPanelOne.expanded).to.be.false; - expect(nestedPanelTwo.expanded).to.be.false; - }); - it('removes hidden attribute from the first panel', function() { - expect(topLevelPanelOne.hidden, 'panel-2').to.be.true; - expect(topLevelPanelTwo.hidden, 'panel-1').to.be.false; - expect(nestedPanelOne.hidden, 'panel-1-1').to.be.true; - expect(nestedPanelTwo.hidden, 'panel-2-2').to.be.true; - }); + describe('calling focus() on the second header of the parent accordion', function() { + beforeEach(function() { + topLevelHeader2.focus(); + }); + + beforeEach(nextFrame); + + describe('Space', function() { + beforeEach(press(' ')); + beforeEach(nextFrame); + it('expands the panel containing the nested ', async function() { + expect(await a11ySnapshot()).to.have.axTreeNodeWithName('nest-2-header-1'); }); + describe('Tab', function() { + beforeEach(press('Tab')); + beforeEach(nextFrame); - describe('Navigating from parent to child accordion', function() { - describe('Opening the panel containing the nested accordion and pressing TAB', function() { - beforeEach(press('Space')); - beforeEach(press('Tab')); - it('moves focus to the nested accordion header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - nestedHeader1?.textContent?.trim()); - }); + it('moves focus to the first nested header', async function() { + expect(await a11ySnapshot()).to.have.axTreeFocusOn(nestedHeader1); + }); - describe('ArrowUp', function() { - beforeEach(press('ArrowUp')); - it('moves focus to the last nested header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - nestedHeader3?.textContent?.trim()); - }); + describe('ArrowUp', function() { + beforeEach(press('ArrowUp')); + beforeEach(nextFrame); + it('moves focus to the last nested header', async function() { + expect(await a11ySnapshot()).to.have.axTreeFocusOn(nestedHeader3); }); + }); - describe('ArrowLeft', function() { - beforeEach(press('ArrowLeft')); - it('moves focus to the last header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - nestedHeader3?.textContent?.trim()); - }); + describe('ArrowLeft', function() { + beforeEach(press('ArrowLeft')); + beforeEach(nextFrame); + it('moves focus to the last nested header', async function() { + expect(await a11ySnapshot()).to.have.axTreeFocusOn(nestedHeader3); }); + }); - describe('ArrowDown', function() { - beforeEach(press('ArrowDown')); - it('moves focus to the second header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - nestedHeader2?.textContent?.trim()); - }); + describe('ArrowDown', function() { + beforeEach(press('ArrowDown')); + beforeEach(nextFrame); + it('moves focus to the second nested header', async function() { + expect(await a11ySnapshot()).to.have.axTreeFocusOn(nestedHeader2); }); + }); - describe('ArrowRight', function() { - beforeEach(press('ArrowRight')); - it('moves focus to the second header', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - nestedHeader2?.textContent?.trim()); - }); + describe('ArrowRight', function() { + beforeEach(press('ArrowRight')); + beforeEach(nextFrame); + it('moves focus to the second nested header', async function() { + expect(await a11ySnapshot()).to.have.axTreeFocusOn(nestedHeader2); }); + }); - describe('Tab', function() { - beforeEach(press('Tab')); - it('should move focus back to the body', async function() { - expect(await getFocusedSnapshot()) - .to.not.be.ok; - }); + describe('Tab', function() { + beforeEach(press('Tab')); + beforeEach(nextFrame); + it('should move focus back to the body', async function() { + expect(await a11ySnapshot()).to.have.axTreeFocusOn(document.body); }); }); }); }); }); + + describe('calling focus() on the last header of the parent accordion', function() { + beforeEach(function() { + topLevelHeader2.focus(); + }); + + beforeEach(nextFrame); + + describe('Space', function() { + beforeEach(press(' ')); + it('expands the first panel', function() { + expect(topLevelPanel1.expanded).to.be.false; + expect(topLevelPanel2.expanded).to.be.true; + expect(nestedPanel1.expanded).to.be.false; + expect(nestedPanel2.expanded).to.be.false; + }); + + it('removes hidden attribute from the first panel', function() { + expect(topLevelPanel1.hidden, 'panel-2').to.be.true; + expect(topLevelPanel2.hidden, 'panel-1').to.be.false; + expect(nestedPanel1.hidden, 'panel-1-1').to.be.true; + expect(nestedPanel2.hidden, 'panel-2-2').to.be.true; + }); + }); + }); }); - describe('with multiple pf-accordion', function() { + describe('with multiple elements', function() { let multipleAccordionElements: HTMLElement; - let accordionOneHeaderOne: PfAccordionHeader; - let accordionOnePanelOne: PfAccordionPanel; + let accordion1Header1: PfAccordionHeader; + let accordion1Panel1: PfAccordionPanel; - let accordionTwoHeaderOne: PfAccordionHeader; - let accordionTwoPanelOne: PfAccordionPanel; + let accordion2Header1: PfAccordionHeader; + let accordion2Panel1: PfAccordionPanel; beforeEach(async function() { multipleAccordionElements = await fixture(html` @@ -1365,68 +1304,62 @@ describe('', function() {
`); - accordionOneHeaderOne = document.getElementById('header-1-1') as PfAccordionHeader; + accordion1Header1 = document.getElementById('header-1-1') as PfAccordionHeader; - accordionOnePanelOne = document.getElementById('panel-1-1') as PfAccordionPanel; + accordion1Panel1 = document.getElementById('panel-1-1') as PfAccordionPanel; - accordionTwoHeaderOne = document.getElementById('header-2-1') as PfAccordionHeader; + accordion2Header1 = document.getElementById('header-2-1') as PfAccordionHeader; - accordionTwoPanelOne = document.getElementById('panel-2-1') as PfAccordionPanel; + accordion2Panel1 = document.getElementById('panel-2-1') as PfAccordionPanel; }); - describe('for assistive technology', function() { - describe('with all panels closed', function() { - it('applies hidden attribute to all panels', function() { - expect(accordionOnePanelOne.hidden, 'panel-1-1').to.be.true; - expect(accordionTwoPanelOne.hidden, 'panel-2-1').to.be.true; - }); + it('applies hidden attribute to all panels', function() { + expect(accordion1Panel1.hidden, 'panel-1-1').to.be.true; + expect(accordion2Panel1.hidden, 'panel-2-1').to.be.true; + }); + + describe('clicking every header', function() { + beforeEach(async function() { + for (const header of multipleAccordionElements.querySelectorAll('pf-accordion-header')) { + await clickElementAtCenter(header); + } }); - describe('with all panels open', function() { - beforeEach(async function() { - for (const header of multipleAccordionElements.querySelectorAll('pf-accordion-header')) { - await clickElementAtCenter(header); - } - await nextFrame(); - }); - it('removes hidden attribute from all panels', function() { - expect(accordionOnePanelOne.hidden, 'panel-1-1').to.be.false; - expect(accordionTwoPanelOne.hidden, 'panel-2-1').to.be.false; - }); + beforeEach(nextFrame); + + it('removes hidden attribute from all panels', function() { + expect(accordion1Panel1.hidden, 'panel-1-1').to.be.false; + expect(accordion2Panel1.hidden, 'panel-2-1').to.be.false; + }); + }); + + describe('calling focus() on the first header of the first accordion', function() { + beforeEach(function() { + accordion1Header1.focus(); }); + beforeEach(nextFrame); - describe('when focus is on the first header of the first accordion', function() { - beforeEach(async function() { - accordionOneHeaderOne.focus(); - await nextFrame(); + describe('Space', function() { + beforeEach(press(' ')); + it('expands the first panel', function() { + expect(accordion1Panel1.expanded).to.be.true; + expect(accordion2Panel1.expanded).to.be.false; }); - - describe('Space', function() { - beforeEach(press(' ')); - it('expands the first panel', function() { - expect(accordionOnePanelOne.expanded).to.be.true; - expect(accordionTwoPanelOne.expanded).to.be.false; - }); - it('removes hidden attribute from the first panel', function() { - expect(accordionOnePanelOne.hidden, 'panel-1-1').to.be.false; - expect(accordionTwoPanelOne.hidden, 'panel-1-1').to.be.true; - }); + it('removes hidden attribute from the first panel', function() { + expect(accordion1Panel1.hidden, 'panel-1-1').to.be.false; + expect(accordion2Panel1.hidden, 'panel-1-1').to.be.true; }); + }); - describe('Tab', function() { - beforeEach(press('Tab')); - it('moves focus to the second accordion', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - accordionTwoHeaderOne?.textContent?.trim()); - }); - describe('Shift+Tab', function() { - beforeEach(press('Shift+Tab')); - it('moves focus back to the first accordion', async function() { - expect(await getFocusedSnapshot()) - .to.have.property('name', - accordionOneHeaderOne?.textContent?.trim()); - }); + describe('Tab', function() { + beforeEach(press('Tab')); + it('moves focus to the second accordion', async function() { + expect(await a11ySnapshot()).to.have.axTreeFocusOn(accordion2Header1); + }); + describe('Shift+Tab', function() { + beforeEach(press('Shift+Tab')); + it('moves focus to the first accordion, first header', async function() { + expect(await a11ySnapshot()).to.have.axTreeFocusOn(accordion1Header1); }); }); }); From 44bef461c1457c2f1d1e4262e85df32ff95781c4 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 11:56:42 +0300 Subject: [PATCH 035/122] fix(core): off-by-one error in RTIC controller --- core/pfe-core/controllers/at-focus-controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/pfe-core/controllers/at-focus-controller.ts b/core/pfe-core/controllers/at-focus-controller.ts index 3d172a6e9f..570830bcfb 100644 --- a/core/pfe-core/controllers/at-focus-controller.ts +++ b/core/pfe-core/controllers/at-focus-controller.ts @@ -216,7 +216,7 @@ export abstract class ATFocusController { event.preventDefault(); break; case 'End': - this.atFocusedItemIndex = this.items.length; + this.atFocusedItemIndex = this.items.length - 1; event.stopPropagation(); event.preventDefault(); break; From 2f703b466b363a0b71ba31ae36a6dd7716b2bf5d Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 11:56:59 +0300 Subject: [PATCH 036/122] fix(core): nested rtic --- core/pfe-core/controllers/roving-tabindex-controller.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index 624c9c954b..c7812f45c5 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -25,6 +25,8 @@ export class RovingTabindexController< #gainedInitialFocus = false; + #itemsSet = new Set(); + get atFocusedItemIndex(): number { return super.atFocusedItemIndex; } @@ -51,6 +53,7 @@ export class RovingTabindexController< public set items(items: Item[]) { this._items = items; + this.#itemsSet = new Set(items); const pivot = Math.max(0, this.atFocusedItemIndex); const firstFocusableIndex = items.indexOf(this.atFocusableItems.at(0)!); const pivotFocusableIndex = items.indexOf(this.items @@ -81,6 +84,6 @@ export class RovingTabindexController< && !event.altKey && !event.metaKey && !!this.atFocusableItems.length - && !!event.composedPath().includes(this.itemsContainerElement!)); + && !!event.composedPath().some(node => this.#itemsSet.has(node as Item))); } } From 0b79d6a2a6431300fac6b3e4c5e78bc728805ed7 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 11:57:18 +0300 Subject: [PATCH 037/122] feat(tools): chai a11y snapshot assertions --- .changeset/a11y-snapshot-chai.md | 28 +++++ tools/pfe-tools/test/a11y-snapshot.ts | 156 +++++++++++++++++++++++++- 2 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 .changeset/a11y-snapshot-chai.md diff --git a/.changeset/a11y-snapshot-chai.md b/.changeset/a11y-snapshot-chai.md new file mode 100644 index 0000000000..ab2edac55c --- /dev/null +++ b/.changeset/a11y-snapshot-chai.md @@ -0,0 +1,28 @@ +--- +"@patternfly/pfe-tools": minor +--- +`a11ySnapshot`: Added chai assertions for various accessibility-tree scenarios + +Examples: +```ts +describe('', function() { + beforeEach(() => fixture(html` + + header-1 + panel-1 + + `)) + describe('clicking the first heading', function() { + beforeEach(clickFirstHeading); + it('expands the first panel', async function() { + expect(await a11ySnapshot()) + .to.have.axTreeNodeWithName('panel-1'); + }); + it('focuses the first panel', async function() { + expect(await a11ySnapshot()) + .to.have.axTreeFocusOn(document.getElementById('header1')); + }); + }) +}) + +``` diff --git a/tools/pfe-tools/test/a11y-snapshot.ts b/tools/pfe-tools/test/a11y-snapshot.ts index 7ffbb2df56..b0a797a78a 100644 --- a/tools/pfe-tools/test/a11y-snapshot.ts +++ b/tools/pfe-tools/test/a11y-snapshot.ts @@ -1,3 +1,4 @@ +import { chai } from '@open-wc/testing'; import { a11ySnapshot as snap } from '@web/test-runner-commands'; export interface A11yTreeSnapshot { @@ -41,14 +42,22 @@ function matches(snapshot: A11yTreeSnapshot, query: SnapshotQuery) { JSON.stringify(snapshot[key as keyof typeof snapshot]) === JSON.stringify(value)); } -function doQuery(snapshot: A11yTreeSnapshot, query: SnapshotQuery): A11yTreeSnapshot | null { +function doQuery( + snapshot: A11yTreeSnapshot, + query: SnapshotQuery, + items?: Set, +): A11yTreeSnapshot | null { if (matches(snapshot, query)) { - return snapshot; + if (items) { + items.add(snapshot); + } else { + return snapshot; + } } else if (!snapshot.children) { return null; } else { for (const kid of snapshot.children) { - const result = doQuery(kid, query); + const result = doQuery(kid, query, items); if (result) { return result; } @@ -68,3 +77,144 @@ export function querySnapshot( ): A11yTreeSnapshot | null { return doQuery(snapshot, query); } + +/** + * Deeply search an accessibility tree snapshot for all objects matching your query + * @param snapshot the snapshot root to recurse through + * @param query object with properties matching the snapshot children you seek + */ +export function querySnapshotAll( + snapshot: A11yTreeSnapshot, + query: SnapshotQuery, +): A11yTreeSnapshot[] { + const items = new Set(); + doQuery(snapshot, query, items); + return [...items]; +} + +/** @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; + } +} + +const internalsMap = new WeakMap(); +const attachInternalsOrig = HTMLElement.prototype.attachInternals; +HTMLElement.prototype.attachInternals = function() { + const internals = attachInternalsOrig.call(this); + internalsMap.set(this, internals); + return internals; +}; + +function getElementLabelText(element: Element): string { + if (element.ariaLabel) { + return element.ariaLabel; + } else { + const ariaLabelledByElements: Element[] = element.ariaLabelledByElements + ?? internalsMap.get(element)?.ariaLabelledByElements; + return Array.from(ariaLabelledByElements ?? [], x => + getElementLabelText(x) || x.textContent || '') + .join() || element.textContent || ''; + } +} + +function isSnapshot(obj: unknown): obj is A11yTreeSnapshot { + return obj instanceof Object && obj !== null && 'role' in obj; +} + +function axTreeFocusOn( + this: Chai.AssertionPrototype, + element: Element, + msg?: string, +) { + const snapshot = this._obj as A11yTreeSnapshot; + if (!isSnapshot(snapshot)) { + throw new Error(`axTreeFocusOn can only assert on A11yTreeSnapshots, got ${snapshot}`); + } + if (element == null || element === document.body) { + const focused = querySnapshot(snapshot, { focused: true }); + this.assert( + focused === null, + `expected no element to have assistive technology focus`, + `expected any element to have assistive technology focus`, + null, + focused, + ); + } else if (element instanceof Element) { + const focused = querySnapshot(snapshot, { focused: true }); + const actualAXName = getElementLabelText(element).trim(); + const [nodeSnapshotItem, ...others] = querySnapshotAll(snapshot, { name: actualAXName }); + if (others.length) { + throw new Error(`More than one ax tree node has name "${actualAXName}". axTreeFocusOn cannot produce a definitive assertion`); + } + const focusedAXName = focused?.name; + const printable = chai.util.inspect(element); + this.assert( + focusedAXName?.trim() === actualAXName, + `expected ${msg && ' ' || ''}${printable} to have assistive technology focus`, + `expected ${msg && ' ' || ''}${printable} to not have assistive technology focus`, + focused, + nodeSnapshotItem, + ); + } else { + this.assert( + false, + `expected ${element} to be an Element`, + `expected ${element} to not have assistive technology focus`, + element + ); + } +} + +function axTreeNodeWithName( + this: Chai.AssertionPrototype, + name: string, + msg?: string +) { + const snapshot = this._obj as A11yTreeSnapshot; + if (!isSnapshot(snapshot)) { + throw new Error(`axTreeFocusOn can only assert on A11yTreeSnapshots, got ${snapshot}`); + } + const named = querySnapshot(snapshot, { name }); + this.assert( + !!named, + `expected to find element with assistive technology name ${name}${!msg ? '' : `(${msg})`}`, + `expected to not find element with assistive technology name ${name}${!msg ? '' : `(${msg})`}`, + name, + named, + ); +} + +chai.use(function(_chai) { + _chai.Assertion.addMethod('axTreeFocusOn', axTreeFocusOn); + _chai.Assertion.addMethod('axTreeNodeWithName', axTreeNodeWithName); +}); + + +declare global { + // That's just the way the chai boils + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Chai { + interface Assertion { + /** + * Assert that the a11ySnapshot shows that a given element has focus. + * This assertion ultimately matches on the accessible name of the given element, + * so test authors must ensure that every element has a unique accessible name + * (i.e. aria-label or textContent). + */ + axTreeFocusOn(element?: Element | null, msg?: string): void; + /** + * Assert that the a11ySnapshot contains a node with the given name + */ + axTreeNodeWithName(name: string, msg?: string): void; + } + } +} From 1dab99ca035cd4cc5d080772ccf1cbe68edc44d4 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 12:38:07 +0300 Subject: [PATCH 038/122] fix(core): more ssr-able controllers --- core/pfe-core/controllers/activedescendant-controller.ts | 4 ++-- core/pfe-core/controllers/at-focus-controller.ts | 4 ++-- elements/pf-dropdown/pf-dropdown.ts | 1 - elements/pf-jump-links/pf-jump-links.ts | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/core/pfe-core/controllers/activedescendant-controller.ts b/core/pfe-core/controllers/activedescendant-controller.ts index c83b428012..9a0351a64c 100644 --- a/core/pfe-core/controllers/activedescendant-controller.ts +++ b/core/pfe-core/controllers/activedescendant-controller.ts @@ -2,7 +2,7 @@ import type { ReactiveControllerHost } from 'lit'; import { type ATFocusControllerOptions, ATFocusController } from './at-focus-controller.js'; -import { nothing } from 'lit'; +import { isServer, nothing } from 'lit'; import { getRandomId } from '../functions/random.js'; export interface ActivedescendantControllerOptions< @@ -80,7 +80,7 @@ export class ActivedescendantController< * to copy item nodes into the controlling nodes' root */ public static get canControlLightDom(): boolean { - return 'ariaActiveDescendantElement' in HTMLElement.prototype; + return !isServer && 'ariaActiveDescendantElement' in HTMLElement.prototype; } static of( diff --git a/core/pfe-core/controllers/at-focus-controller.ts b/core/pfe-core/controllers/at-focus-controller.ts index 570830bcfb..5931d45a71 100644 --- a/core/pfe-core/controllers/at-focus-controller.ts +++ b/core/pfe-core/controllers/at-focus-controller.ts @@ -1,4 +1,4 @@ -import type { ReactiveControllerHost } from 'lit'; +import { isServer, type ReactiveControllerHost } from 'lit'; function isATFocusableItem(el: Element): el is HTMLElement { return !!el @@ -122,7 +122,7 @@ export abstract class ATFocusController { public host: ReactiveControllerHost, protected options: ATFocusControllerOptions, ) { - this.host.updateComplete.then(() => this.initItems()); + this.host.updateComplete.then(() => isServer && this.initItems()); } /** diff --git a/elements/pf-dropdown/pf-dropdown.ts b/elements/pf-dropdown/pf-dropdown.ts index 4cbef2ab97..b1f8395d81 100644 --- a/elements/pf-dropdown/pf-dropdown.ts +++ b/elements/pf-dropdown/pf-dropdown.ts @@ -63,7 +63,6 @@ export class PfDropdown extends LitElement { delegatesFocus: true, }; - /** * When disabled, the dropdown can still be toggled open and closed via keyboard, but menu items cannot be activated. */ diff --git a/elements/pf-jump-links/pf-jump-links.ts b/elements/pf-jump-links/pf-jump-links.ts index 7179e4c9f8..9f9ba022a7 100644 --- a/elements/pf-jump-links/pf-jump-links.ts +++ b/elements/pf-jump-links/pf-jump-links.ts @@ -78,7 +78,7 @@ export class PfJumpLinks extends LitElement { #kids = this.querySelectorAll?.(':is(pf-jump-links-item, pf-jump-links-list)'); get #items() { - return Array.from(this.#kids) + return Array.from(this.#kids ?? []) .flatMap(i => [ ...i.shadowRoot?.querySelectorAll?.('a') ?? [], ...i.querySelectorAll?.('a') ?? [], From 83fde8423158b311256d976adff24ea1f8c89c34 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 12:42:58 +0300 Subject: [PATCH 039/122] fix(core): more ssr-able controllers --- core/pfe-core/controllers/at-focus-controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/pfe-core/controllers/at-focus-controller.ts b/core/pfe-core/controllers/at-focus-controller.ts index 5931d45a71..25e4524686 100644 --- a/core/pfe-core/controllers/at-focus-controller.ts +++ b/core/pfe-core/controllers/at-focus-controller.ts @@ -122,7 +122,7 @@ export abstract class ATFocusController { public host: ReactiveControllerHost, protected options: ATFocusControllerOptions, ) { - this.host.updateComplete.then(() => isServer && this.initItems()); + this.host.updateComplete.then(() => this.initItems()); } /** @@ -147,7 +147,7 @@ export abstract class ATFocusController { #initContainer() { return this.options.getItemsContainer?.() - ?? (this.host instanceof HTMLElement ? this.host : null); + ?? (!isServer && this.host instanceof HTMLElement ? this.host : null); } /** From 0c01ad515498b8c2573b37a30886a07bc1f29b90 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 12:47:08 +0300 Subject: [PATCH 040/122] fix(core): more ssr-able controllers --- .../controllers/roving-tabindex-controller.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index c7812f45c5..3b7f380e18 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -1,4 +1,4 @@ -import type { ReactiveControllerHost } from 'lit'; +import { isServer, type ReactiveControllerHost } from 'lit'; import { ATFocusController, type ATFocusControllerOptions } from './at-focus-controller.js'; import { Logger } from './logger.js'; @@ -71,10 +71,13 @@ export class RovingTabindexController< super(host, options); this.initItems(); const container = options.getItemsContainer?.() ?? this.host; - if (container instanceof HTMLElement) { - container.addEventListener('focusin', () => this.#gainedInitialFocus = true, { once: true }); - } else { - this.#logger.warn('RovingTabindexController requires a getItemsContainer function'); + if (!isServer) { + if (container instanceof HTMLElement) { + container.addEventListener('focusin', () => + this.#gainedInitialFocus = true, { once: true }); + } else { + this.#logger.warn('RovingTabindexController requires a getItemsContainer function'); + } } } From 300fe398dd5969fbd75a3943a58ed609605d2f44 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 13:00:53 +0300 Subject: [PATCH 041/122] fix(icon): more ssr-able icon --- elements/pf-icon/pf-icon.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/elements/pf-icon/pf-icon.ts b/elements/pf-icon/pf-icon.ts index fad725eda8..f1c77c578d 100644 --- a/elements/pf-icon/pf-icon.ts +++ b/elements/pf-icon/pf-icon.ts @@ -180,7 +180,7 @@ export class PfIcon extends LitElement { async #contentChanged() { await this.updateComplete; - this.dispatchEvent(new Event('load', { bubbles: true })); + this.dispatchEvent?.(new Event('load', { bubbles: true })); } connectedCallback(): void { @@ -219,7 +219,7 @@ export class PfIcon extends LitElement { this.#contentChanged(); } catch (error: unknown) { this.#logger.error((error as IconResolveError).message); - this.dispatchEvent(new IconResolveError(set, icon, error as Error)); + this.dispatchEvent?.(new IconResolveError(set, icon, error as Error)); } } } From b45f1867fd22ae1d0cd41f9935baefd4915ee071 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 16:21:20 +0300 Subject: [PATCH 042/122] fix(core): remove unused ax controller apis --- .../activedescendant-controller.ts | 2 +- .../controllers/at-focus-controller.ts | 45 ++++++------------- .../controllers/combobox-controller.ts | 2 +- .../controllers/roving-tabindex-controller.ts | 3 +- elements/pf-dropdown/pf-dropdown-menu.ts | 3 +- 5 files changed, 20 insertions(+), 35 deletions(-) diff --git a/core/pfe-core/controllers/activedescendant-controller.ts b/core/pfe-core/controllers/activedescendant-controller.ts index 9a0351a64c..cf209d0e9f 100644 --- a/core/pfe-core/controllers/activedescendant-controller.ts +++ b/core/pfe-core/controllers/activedescendant-controller.ts @@ -201,7 +201,7 @@ export class ActivedescendantController< } }); const next = this.atFocusableItems.find(((_, i) => i !== this.atFocusedItemIndex)); - const activeItem = next ?? this.firstATFocusableItem; + const activeItem = next ?? this.atFocusableItems.at(0); this.atFocusedItemIndex = this._items.indexOf(activeItem!); } } diff --git a/core/pfe-core/controllers/at-focus-controller.ts b/core/pfe-core/controllers/at-focus-controller.ts index 25e4524686..58b00433f2 100644 --- a/core/pfe-core/controllers/at-focus-controller.ts +++ b/core/pfe-core/controllers/at-focus-controller.ts @@ -2,7 +2,7 @@ import { isServer, type ReactiveControllerHost } from 'lit'; function isATFocusableItem(el: Element): el is HTMLElement { return !!el - && !el.ariaHidden + && el.ariaHidden !== 'true' && !el.hasAttribute('hidden'); } @@ -48,12 +48,13 @@ export abstract class ATFocusController { set atFocusedItemIndex(index: number) { const previousIndex = this.#atFocusedItemIndex; const direction = index > previousIndex ? 1 : -1; - const { items, atFocusableItems, lastATFocusableItem } = this; - const itemsIndexOfLastATFocusableItem = items.indexOf(lastATFocusableItem!); + const { items, atFocusableItems } = this; + const itemsIndexOfLastATFocusableItem = items.indexOf(this.atFocusableItems.at(-1)!); let itemToGainFocus = items.at(index); let itemToGainFocusIsFocusable = atFocusableItems.includes(itemToGainFocus!); - if (items.length) { - while (!itemToGainFocus || !itemToGainFocusIsFocusable) { + if (atFocusableItems.length) { + let count = 0; + while (!itemToGainFocus || !itemToGainFocusIsFocusable && count++ <= 1000) { if (index < 0) { index = itemsIndexOfLastATFocusableItem; } else if (index >= itemsIndexOfLastATFocusableItem) { @@ -64,6 +65,14 @@ export abstract class ATFocusController { itemToGainFocus = items.at(index); itemToGainFocusIsFocusable = atFocusableItems.includes(itemToGainFocus!); } + if (count >= 1000) { + // console.log(items.map(el => ({ + // ariaHidden: el.ariaHidden, + // hidden: el.hasAttribute('hidden'), + // }))); + // console.log({ items, atFocusableItems, index }); + throw new Error('Could not atFocusedItemIndex'); + } } this.#atFocusedItemIndex = index; } @@ -78,32 +87,6 @@ export abstract class ATFocusController { return this._items.filter(isATFocusableItem); } - /** First item which is able to receive assistive technology focus */ - get firstATFocusableItem(): Item | null { - return this.atFocusableItems.at(0) ?? null; - } - - /** Last item which is able to receive assistive technology focus */ - get lastATFocusableItem(): Item | null { - return this.atFocusableItems.at(-1) ?? null; - } - - /** Focusable item following the item which currently has assistive technology focus */ - get nextATFocusableItem(): Item | null { - const index = this.atFocusedItemIndex; - const outOfBounds = index >= this.atFocusableItems.length - 1; - return outOfBounds ? this.firstATFocusableItem - : this.atFocusableItems.at(index + 1) ?? null; - } - - /** Focusable item preceding the item which currently has assistive technology focus */ - get previousATFocusableItem(): Item | null { - const index = this.atFocusedItemIndex; - const outOfBounds = index > 0; - return outOfBounds ? this.atFocusableItems.at(index - 1) ?? null - : this.lastATFocusableItem; - } - /** The element containing focusable items, e.g. a listbox */ get itemsContainerElement() { return this.#itemsContainerElement ?? null; diff --git a/core/pfe-core/controllers/combobox-controller.ts b/core/pfe-core/controllers/combobox-controller.ts index 7a7c7a1196..0ed554a1cf 100644 --- a/core/pfe-core/controllers/combobox-controller.ts +++ b/core/pfe-core/controllers/combobox-controller.ts @@ -539,7 +539,7 @@ export class ComboboxController< const success = await this.options.requestShowListbox(); if (success !== false && !this.#isTypeahead) { if (!this.#preventListboxGainingFocus) { - (this.#focusedItem ?? this.#fc?.nextATFocusableItem)?.focus(); + (this.#focusedItem ?? this.#fc?.items.at(0))?.focus(); this.#preventListboxGainingFocus = false; } } diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index 3b7f380e18..6d47fb68cc 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -55,7 +55,8 @@ export class RovingTabindexController< this._items = items; this.#itemsSet = new Set(items); const pivot = Math.max(0, this.atFocusedItemIndex); - const firstFocusableIndex = items.indexOf(this.atFocusableItems.at(0)!); + 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)) diff --git a/elements/pf-dropdown/pf-dropdown-menu.ts b/elements/pf-dropdown/pf-dropdown-menu.ts index 4a0fe187eb..36a3390040 100644 --- a/elements/pf-dropdown/pf-dropdown-menu.ts +++ b/elements/pf-dropdown/pf-dropdown-menu.ts @@ -52,7 +52,8 @@ export class PfDropdownMenu extends LitElement { */ get activeItem(): HTMLElement | null { return this.#tabindex.items.at(this.#tabindex.atFocusedItemIndex) - ?? this.#tabindex.firstATFocusableItem; + ?? this.#tabindex.atFocusableItems.at(0) + ?? null; } /** From 953f3d6e4dab7dd97d406d5ed6ebc8f939e6a45d Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 16:22:17 +0300 Subject: [PATCH 043/122] test(core): observes decorator --- core/pfe-core/test/decorators.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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() { From 9fb9030d8b91ca57bce64421c6844fdd29dbfd89 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 16:43:47 +0300 Subject: [PATCH 044/122] fix(chip): rtic apis, tests --- elements/pf-chip/pf-chip-group.ts | 145 ++++++++------------ elements/pf-chip/test/pf-chip-group.spec.ts | 64 ++++++--- 2 files changed, 100 insertions(+), 109 deletions(-) diff --git a/elements/pf-chip/pf-chip-group.ts b/elements/pf-chip/pf-chip-group.ts index 7e8e9a80bc..6b346ceed1 100644 --- a/elements/pf-chip/pf-chip-group.ts +++ b/elements/pf-chip/pf-chip-group.ts @@ -1,10 +1,11 @@ -import { LitElement, html, type PropertyValues, type TemplateResult } from 'lit'; +import { LitElement, html, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; import { query } from 'lit/decorators/query.js'; import { queryAssignedNodes } from 'lit/decorators/query-assigned-nodes.js'; import { classMap } from 'lit/directives/class-map.js'; +import { observes } from '@patternfly/pfe-core/decorators/observes.js'; import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; import { PfChip, PfChipRemoveEvent } from './pf-chip.js'; @@ -86,23 +87,55 @@ export class PfChipGroup extends LitElement { */ @property({ reflect: true, type: Boolean }) closeable = false; - @query('#overflow') private _overflowChip?: HTMLButtonElement; + @query('#overflow') private _overflowChip?: PfChip; @query('#close-button') private _button?: HTMLButtonElement; @queryAssignedNodes({ slot: 'category-name', flatten: true }) private _categorySlotted?: Node[]; - #chips: PfChip[] = []; - - #buttons: HTMLElement[] = []; + get #chips(): NodeListOf { + return this.querySelectorAll('pf-chip:not([slot]):not([overflow-chip])'); + } #tabindex = RovingTabindexController.of(this, { - getItems: () => this.#buttons.filter(x => !x.hidden), + getItems: () => [ + ...Array.prototype.slice.call(this.#chips, 0, this.open ? this.#chips.length + : Math.min(this.#chips.length, this.numChips)), + this._overflowChip, + this._button, + ].filter(x => !!x), }); + /** + * active chip that receives focus when group receives focus + */ + get activeChip() { + const button = this.#tabindex.items.at(this.#tabindex.atFocusedItemIndex); + const shadow = button?.getRootNode() as ShadowRoot; + return shadow?.host as PfChip; + } + + set activeChip(chip: HTMLElement) { + const button = chip.shadowRoot?.querySelector('button'); + if (button) { + this.#tabindex.atFocusedItemIndex = this.#tabindex.items.indexOf(button); + } + } + + /** + * whether or not group has a category + */ + get hasCategory(): boolean { + return (this._categorySlotted || []).length > 0; + } + + get remaining(): number { + return this.#chips.length - this.numChips; + } + constructor() { super(); - this.addEventListener('remove', this.#onChipRemoved); + this.addEventListener('remove', this.#onRemove); } render(): TemplateResult<1> { @@ -117,10 +150,7 @@ export class PfChipGroup extends LitElement { ${this.accessibleLabel ?? ''} - + ): void { - if (changed.has('accessibleCloseLabel') - || changed.has('numChips') - || changed.has('closeable') - || changed.has('open')) { - this.#handleChipsChanged(); - } - } - - /** - * active chip that receives focus when group receives focus - */ - get activeChip() { - const button = this.#tabindex.items.at(this.#tabindex.atFocusedItemIndex); - const shadow = button?.getRootNode() as ShadowRoot; - return shadow?.host as PfChip; - } - - set activeChip(chip: HTMLElement) { - const button = chip.shadowRoot?.querySelector('button') as HTMLElement; - this.#tabindex.atFocusedItemIndex = this.#tabindex.items.indexOf(button); - } - - /** - * whether or not group has a category - */ - get hasCategory(): boolean { - return (this._categorySlotted || []).length > 0; - } - - get remaining(): number { - return this.#chips.length - this.numChips; - } - /** * updates chips when they change */ - #handleChipsChanged() { - if (this.#chips.length > 0) { - const oldButtons = [...(this.#tabindex.items || [])]; - const max = this.open ? this.#chips.length : Math.min(this.#chips.length, this.numChips); - this.#buttons = [ - ...this.#chips.slice(0, max), - this._overflowChip, - this._button, - ].filter((x): x is PfChip => !!x); - if (oldButtons.length !== this.#buttons.length - || !oldButtons.every((element, index) => element === this.#buttons[index])) { - this.#tabindex.items = (this.#chips); - } - this.#updateOverflow(); - } - } - - /** - * handles a chip's `chip-remove` event - * @param event remove event - */ - async #onChipRemoved(event: Event) { - if (event instanceof PfChipRemoveEvent) { - await this.#updateChips(); - await this.updateComplete; - // this.#tabindex.setATFocus(this.#tabindex.atFocusedItem); - } + @observes('accessibleCloseLabel') + @observes('numChips') + @observes('closeable') + @observes('open') + private chipsChanged(): void { + this.#updateOverflow(); } /** @@ -212,7 +187,6 @@ export class PfChipGroup extends LitElement { */ #onCloseClick() { this.dispatchEvent(new PfChipGroupRemoveEvent()); - this.remove(); } /** @@ -223,7 +197,7 @@ export class PfChipGroup extends LitElement { event.stopPropagation(); this.open = !this.open; await this.updateComplete; - this.#handleChipsChanged(); + this.chipsChanged(); if (this._overflowChip) { this.focusOnChip(this._overflowChip); } @@ -231,21 +205,18 @@ export class PfChipGroup extends LitElement { } #onSlotchange() { - this.#updateChips(); this.requestUpdate(); } - /** - * updates which chips variable - */ - async #updateChips() { - await this.updateComplete; - this.#chips = [...this.querySelectorAll('pf-chip:not([slot]):not([overflow-chip])')]; - this.requestUpdate(); - await this.updateComplete; - this.#tabindex.items = (this.#chips); - this.#handleChipsChanged(); - return this.#chips; + #onRemove(event: Event) { + if (event instanceof PfChipRemoveEvent) { + const index = this.#tabindex.atFocusedItemIndex; + if (event.chip) { + this.#tabindex.atFocusedItemIndex = index + 1; + } + } else if (event instanceof PfChipGroupRemoveEvent) { + this.remove(); + } } /** @@ -263,7 +234,7 @@ export class PfChipGroup extends LitElement { * Activates the specified chip and sets focus on it * @param chip pf-chip element */ - focusOnChip(chip: HTMLElement): void { + focusOnChip(chip: PfChip): void { this.#tabindex.atFocusedItemIndex = this.#tabindex.items.indexOf(chip); } } diff --git a/elements/pf-chip/test/pf-chip-group.spec.ts b/elements/pf-chip/test/pf-chip-group.spec.ts index 4c59672e15..b844d77a56 100644 --- a/elements/pf-chip/test/pf-chip-group.spec.ts +++ b/elements/pf-chip/test/pf-chip-group.spec.ts @@ -1,6 +1,6 @@ -import { expect, html } from '@open-wc/testing'; +import { expect, html, nextFrame } from '@open-wc/testing'; import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; -import { a11ySnapshot, type A11yTreeSnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; +import { a11ySnapshot, querySnapshot, querySnapshotAll, type A11yTreeSnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; import { PfChipGroup } from '../pf-chip-group.js'; import { PfChip } from '../pf-chip.js'; import { sendKeys } from '@web/test-runner-commands'; @@ -29,7 +29,7 @@ describe('', async function() { }); }); - describe('with 3 chips', function() { + describe('with 4 chips', function() { let element: PfChipGroup; const updateComplete = () => element.updateComplete; @@ -44,28 +44,46 @@ describe('', async function() { `); }); - it('only 3 chips and an overflow should be visible', async function() { + it('displays 3 chips and an overflow button', async function() { const snapshot = await a11ySnapshot(); - expect(snapshot.children?.filter(x => x.name.startsWith('Chip'))?.length).to.equal(3); - expect(snapshot.children?.filter(x => x.role === 'button')?.length).to.equal(4); + expect(querySnapshotAll(snapshot, { name: /^Chip/ })).to.have.length(3); + expect(querySnapshotAll(snapshot, { role: 'button' })).to.have.length(4); }); - describe('clicking overflow chip', function() { - beforeEach(() => element.focus()); - beforeEach(press('ArrowLeft')); - beforeEach(press('Enter')); - beforeEach(updateComplete); - it('should show all chips', async function() { - const snapshot = await a11ySnapshot(); - expect(snapshot.children?.filter(x => x.name.startsWith('Chip'))?.length).to.equal(4); - }); - it('should show collapse button', async function() { + describe('Tab', function() { + beforeEach(press('Tab')); + beforeEach(nextFrame); + it('focuses the first close button', async function() { const snapshot = await a11ySnapshot(); - expect(snapshot.children?.filter(x => x.role === 'button')?.length).to.equal(5); + const focused = querySnapshot(snapshot, { focused: true }); + expect(focused).to.have.property('name', 'Close'); + expect(focused).to.have.property('description', 'Chip 1'); }); - it('should focus collapse button', async function() { - const snapshot = await a11ySnapshot(); - expect(snapshot.children?.find(x => x.focused)?.name).to.equal('show less'); + describe('ArrowLeft', function() { + beforeEach(press('ArrowLeft')); + it('focuses the show less button', async function() { + const snapshot = await a11ySnapshot(); + const focused = querySnapshot(snapshot, { focused: true }); + expect(focused).to.have.property('name', '1 more'); + }); + describe('Enter', function() { + beforeEach(press('Enter')); + beforeEach(updateComplete); + it('should show all chips', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.filter(x => x.name.startsWith('Chip'))?.length).to.equal(4); + }); + it('should show collapse button', async function() { + const snapshot = await a11ySnapshot(); + const buttons = querySnapshotAll(snapshot, { role: 'button' }); + expect(buttons).to.have.length(5); + }); + it('should focus collapse button', async function() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { focused: true })) + .to.have.property('name', 'show less'); + }); + }); }); }); }); @@ -100,7 +118,7 @@ describe('', async function() { beforeEach(updateComplete); it('should remove element', async function() { const snapshot = await a11ySnapshot(); - expect(snapshot.children).to.be.undefined; + expect(snapshot.children).to.not.be.ok; }); }); }); @@ -205,7 +223,9 @@ describe('', async function() { it('should focus on close button', async function() { const snapshot = await a11ySnapshot(); - expect(snapshot.children?.find(x => x.focused)?.name).to.equal('Close'); + expect(querySnapshot(snapshot, { focused: true })) + .to.be.ok + .and.have.property('name', 'Close'); }); }); }); From 159b003ca76a4358eef5df585c8e910afb792ad4 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 16:44:16 +0300 Subject: [PATCH 045/122] feat(tools): a11yShapshot queries can match regex --- tools/pfe-tools/test/a11y-snapshot.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/pfe-tools/test/a11y-snapshot.ts b/tools/pfe-tools/test/a11y-snapshot.ts index b0a797a78a..aacb061cd5 100644 --- a/tools/pfe-tools/test/a11y-snapshot.ts +++ b/tools/pfe-tools/test/a11y-snapshot.ts @@ -35,11 +35,12 @@ export async function a11ySnapshot( return snapshot; } -type SnapshotQuery = Partial>; +type SnapshotQuery = Partial>; function matches(snapshot: A11yTreeSnapshot, query: SnapshotQuery) { return Object.entries(query).every(([key, value]) => - JSON.stringify(snapshot[key as keyof typeof snapshot]) === JSON.stringify(value)); + value instanceof RegExp ? value.test(snapshot[key as keyof typeof snapshot] as string) + : JSON.stringify(snapshot[key as keyof typeof snapshot]) === JSON.stringify(value)); } function doQuery( From 77c550159bcb4a58713ffc9a70fbcfd72697a52c Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 16:44:30 +0300 Subject: [PATCH 046/122] chore: import maps in tests --- web-test-runner.config.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/web-test-runner.config.js b/web-test-runner.config.js index 32a1faa76f..f516b87209 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -1,11 +1,27 @@ import { pfeTestRunnerConfig } from '@patternfly/pfe-tools/test/config.js'; import { a11ySnapshotPlugin } from '@web/test-runner-commands/plugins'; +import { getPatternflyIconNodemodulesImports } from '@patternfly/pfe-tools/dev-server/config.js'; export default pfeTestRunnerConfig({ // workaround for https://github.com/evanw/esbuild/issues/3019 tsconfig: 'tsconfig.esbuild.json', files: ['!tools/create-element/templates/**/*'], // uncomment to get default wtr reporter + importMapOptions: { + providers: { + 'zero-md': 'nodemodules', + '@patternfly/icons': 'nodemodules', + '@patternfly/elements': 'monorepotypescript', + '@patternfly/pfe-tools': 'monorepotypescript', + '@patternfly/pfe-core': 'monorepotypescript', + }, + inputMap: { + imports: { + '@patternfly/pfe-tools/environment.js': './_site/tools/environment.js', + ...await getPatternflyIconNodemodulesImports(import.meta.url), + }, + }, + }, reporter: 'default', plugins: [ a11ySnapshotPlugin(), From 719f88b40107c9868db962c2343c1b5475a9b007 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 16:44:50 +0300 Subject: [PATCH 047/122] refactor(select): type assertion --- elements/pf-select/pf-select.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/elements/pf-select/pf-select.ts b/elements/pf-select/pf-select.ts index f2f3e4a9a8..acdcf3e984 100644 --- a/elements/pf-select/pf-select.ts +++ b/elements/pf-select/pf-select.ts @@ -20,7 +20,7 @@ import { arraysAreEquivalent } from '@patternfly/pfe-core/functions/arraysAreEqu import { observes } from '@patternfly/pfe-core/decorators/observes.js'; import { PfOption } from './pf-option.js'; -import { PfChipRemoveEvent } from '../pf-chip/pf-chip.js'; +import { type PfChip, PfChipRemoveEvent } from '../pf-chip/pf-chip.js'; import styles from './pf-select.css'; @@ -315,7 +315,7 @@ export class PfSelect extends LitElement { const hasChips = this.variant === 'typeaheadmulti'; if (hasChips && this._toggleInput?.value) { const chip = - this.shadowRoot?.querySelector(`pf-chip#chip-${this._toggleInput?.value}`) as HTMLElement; + this.shadowRoot?.querySelector(`pf-chip#chip-${this._toggleInput?.value}`) as PfChip; if (chip && this._chipGroup) { this._chipGroup.focusOnChip(chip); this._toggleInput.value = ''; From b9f186694e9137d005807b3517356301f34a2814 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 18:10:21 +0300 Subject: [PATCH 048/122] feat(tools): more ax assertions --- .changeset/a11y-snapshot-chai.md | 3 ++ elements/pf-select/test/pf-select.spec.ts | 46 ++++++++++++----------- tools/pfe-tools/test/a11y-snapshot.ts | 42 +++++++++++++++++++++ 3 files changed, 69 insertions(+), 22 deletions(-) diff --git a/.changeset/a11y-snapshot-chai.md b/.changeset/a11y-snapshot-chai.md index ab2edac55c..300ac514cb 100644 --- a/.changeset/a11y-snapshot-chai.md +++ b/.changeset/a11y-snapshot-chai.md @@ -22,6 +22,9 @@ describe('', function() { expect(await a11ySnapshot()) .to.have.axTreeFocusOn(document.getElementById('header1')); }); + it('shows the collapse all button', async function() { + expect(await a11ySnapshot()).to.have.axRole('button'); + }); }) }) diff --git a/elements/pf-select/test/pf-select.spec.ts b/elements/pf-select/test/pf-select.spec.ts index a396b3292e..8ba35ee7d3 100644 --- a/elements/pf-select/test/pf-select.spec.ts +++ b/elements/pf-select/test/pf-select.spec.ts @@ -2,7 +2,7 @@ import { expect, html, nextFrame } from '@open-wc/testing'; import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; import { PfSelect } from '../pf-select.js'; import { sendKeys } from '@web/test-runner-commands'; -import { a11ySnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; +import { a11ySnapshot, querySnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; async function shiftHold() { await sendKeys({ down: 'Shift' }); @@ -25,8 +25,8 @@ function press(key: string) { }; } -function getValues(element: PfSelect) { - return [element.selected].flat().filter(x => !!x).map(x => x!.value); +function getValues(element: PfSelect): string[] { + return element.selected.filter(x => !!x).map(x => x!.value); } describe('', function() { @@ -198,7 +198,7 @@ describe('', function() { }); }); - describe('then pressing Space', function() { + describe('Space', function() { beforeEach(press(' ')); beforeEach(updateComplete); @@ -208,14 +208,16 @@ describe('', function() { it('hides the listbox', async function() { const snapshot = await a11ySnapshot(); - expect(snapshot.children?.find(x => x.role === 'listbox')).to.be.undefined; + expect(querySnapshot(snapshot, { role: 'listbox' })).to.not.be.ok; }); it('focuses the button', async function() { const snapshot = await a11ySnapshot(); - const focused = snapshot.children?.find(x => x.focused); - expect(focused?.role).to.equal('combobox'); - expect(focused?.haspopup).to.equal('listbox'); + const focused = querySnapshot(snapshot, { + focused: true, + role: 'combobox', + }); + expect(focused).to.be.ok; }); it('does not select anything', async function() { @@ -306,13 +308,13 @@ describe('', function() { await expect(element).to.be.accessible(); }); - describe('calling focus())', function() { + describe('focus()', function() { beforeEach(function() { element.focus(); }); beforeEach(updateComplete); - describe('pressing Enter', function() { + describe('Enter', function() { beforeEach(press('Enter')); beforeEach(updateComplete); @@ -330,7 +332,7 @@ describe('', function() { }); }); - describe('pressing Space', function() { + describe('Space', function() { beforeEach(press(' ')); beforeEach(updateComplete); it('expands', async function() { @@ -341,7 +343,7 @@ describe('', function() { }); }); - describe('pressing ArrowDown', function() { + describe('ArrowDown', function() { beforeEach(press('ArrowDown')); beforeEach(updateComplete); it('expands', async function() { @@ -351,7 +353,7 @@ describe('', function() { expect(snapshot.children?.at(1)?.role).to.equal('listbox'); }); - describe('then pressing Shift+Tab', function() { + describe('Shift+Tab', function() { beforeEach(shiftHold); beforeEach(press('Tab')); beforeEach(shiftRelease); @@ -373,7 +375,7 @@ describe('', function() { }); }); - describe('then pressing Tab', function() { + describe('Tab', function() { beforeEach(press('Tab')); beforeEach(nextFrame); beforeEach(updateComplete); @@ -390,7 +392,7 @@ describe('', function() { }); }); - describe('then pressing Escape', function() { + describe('Escape', function() { beforeEach(press('Escape')); beforeEach(updateComplete); it('closes', function() { @@ -402,12 +404,12 @@ describe('', function() { }); it('focuses the button', async function() { const snapshot = await a11ySnapshot(); - const focused = snapshot.children?.find(x => x.focused); + const focused = querySnapshot(snapshot, { focused: true }); expect(focused?.role).to.equal('combobox'); }); }); - describe('then pressing Space', function() { + describe('Space', function() { beforeEach(press(' ')); beforeEach(updateComplete); @@ -419,10 +421,10 @@ describe('', function() { it('remains expanded', async function() { expect(element.expanded).to.be.true; const snapshot = await a11ySnapshot(); - expect(snapshot.children?.at(1)?.role).to.equal('listbox'); + expect(snapshot).to.have.axRole('listbox'); }); - describe('then pressing ArrowDown', function() { + describe('ArrowDown', function() { beforeEach(press('ArrowDown')); beforeEach(updateComplete); it('focuses option 1', async function() { @@ -431,7 +433,7 @@ describe('', function() { const focused = listbox?.children?.find(x => x.focused); expect(focused?.name).to.equal('2'); }); - describe('then pressing Enter', function() { + describe('Enter', function() { beforeEach(press('Enter')); beforeEach(updateComplete); it('adds option 2 to selection', function() { @@ -444,10 +446,10 @@ describe('', function() { it('remains expanded', async function() { expect(element.expanded).to.be.true; const snapshot = await a11ySnapshot(); - expect(snapshot.children?.at(1)?.role).to.equal('listbox'); + expect(snapshot).to.have.axRole('listbox'); }); - describe('then holding Shift and pressing down arrow / enter twice in a row', function() { + describe('Shift+ArrowDown, Shift+Enter, Shift+ArrowDown, Shift+Enter', function() { beforeEach(shiftHold); beforeEach(press('ArrowDown')); beforeEach(press('Enter')); diff --git a/tools/pfe-tools/test/a11y-snapshot.ts b/tools/pfe-tools/test/a11y-snapshot.ts index aacb061cd5..e3ce5087c3 100644 --- a/tools/pfe-tools/test/a11y-snapshot.ts +++ b/tools/pfe-tools/test/a11y-snapshot.ts @@ -194,7 +194,41 @@ function axTreeNodeWithName( ); } +function axRole( + this: Chai.AssertionPrototype, + role: string, + msg?: string +) { + const snapshot = this._obj as A11yTreeSnapshot; + if (!isSnapshot(snapshot)) { + throw new Error(`axRole can only assert on A11yTreeSnapshots, got ${snapshot}`); + } + const needle = querySnapshot(snapshot, { role }); + this.assert(!!needle, + `expected to find element with role ${role}${!msg ? '' : `(${msg})`}`, + `expected to not find element with role ${role}${!msg ? '' : `(${msg})`}`, + role, needle); +} + +function axQuery( + this: Chai.AssertionPrototype, + query: SnapshotQuery, + msg?: string +) { + const snapshot = this._obj as A11yTreeSnapshot; + if (!isSnapshot(snapshot)) { + throw new Error(`axQuery can only assert on A11yTreeSnapshots, got ${snapshot}`); + } + const needle = querySnapshot(snapshot, query); + this.assert(!!needle, + `expected to find element matching ${chai.util.inspect(query)}${!msg ? '' : `(${msg})`}`, + `expected to not find element with role ${chai.util.inspect(query)}${!msg ? '' : `(${msg})`}`, + query, needle); +} + chai.use(function(_chai) { + _chai.Assertion.addMethod('axRole', axRole); + _chai.Assertion.addMethod('axQuery', axQuery); _chai.Assertion.addMethod('axTreeFocusOn', axTreeFocusOn); _chai.Assertion.addMethod('axTreeNodeWithName', axTreeNodeWithName); }); @@ -205,6 +239,14 @@ declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Chai { interface Assertion { + /** + * Assert that a given role exists in the ax tree + */ + axRole(role: string, msg?: string): void; + /** + * Assert that an AX Tree node that matches the query object exists in the tre + */ + axQuery(query: SnapshotQuery, msg?: string): void; /** * Assert that the a11ySnapshot shows that a given element has focus. * This assertion ultimately matches on the accessible name of the given element, From 71bf140ada2465e2d739bcf18f549db3a1597cd3 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 18:10:47 +0300 Subject: [PATCH 049/122] fix(core): listbox/combobox selection state --- .../controllers/combobox-controller.ts | 33 +++++++++++++------ .../controllers/listbox-controller.ts | 12 +++++-- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/core/pfe-core/controllers/combobox-controller.ts b/core/pfe-core/controllers/combobox-controller.ts index 0ed554a1cf..c75788c248 100644 --- a/core/pfe-core/controllers/combobox-controller.ts +++ b/core/pfe-core/controllers/combobox-controller.ts @@ -291,19 +291,20 @@ export class ComboboxController< } #initButton() { - this.#button?.removeEventListener('click', this.#onClickToggle); + 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.#button.role = 'combobox'; this.#button.setAttribute('aria-controls', this.#listbox?.id ?? ''); - this.#button.addEventListener('click', this.#onClickToggle); + this.#button.addEventListener('click', this.#onClickButton); this.#button.addEventListener('keydown', this.#onKeydownButton); } #initInput() { - this.#input?.removeEventListener('click', this.#onClickToggle); + this.#input?.removeEventListener('click', this.#onClickButton); this.#input?.removeEventListener('keyup', this.#onKeyupInput); this.#input?.removeEventListener('keydown', this.#onKeydownInput); @@ -312,9 +313,10 @@ export class ComboboxController< 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 = 'button'; this.#input.setAttribute('aria-autocomplete', 'both'); this.#input.setAttribute('aria-controls', this.#listbox?.id ?? ''); - this.#input.addEventListener('click', this.#onClickToggle); + this.#input.addEventListener('click', this.#onClickButton); this.#input.addEventListener('keyup', this.#onKeyupInput); this.#input.addEventListener('keydown', this.#onKeydownInput); } @@ -365,7 +367,7 @@ export class ComboboxController< } } - #onClickToggle = () => { + #onClickButton = () => { if (!this.options.isExpanded()) { this.#show(); } else { @@ -374,7 +376,7 @@ export class ComboboxController< }; #onClickListbox = (event: MouseEvent) => { - if (event.composedPath().some(this.options.isItem)) { + if (!this.multi && event.composedPath().some(this.options.isItem)) { this.#hide(); } }; @@ -427,7 +429,9 @@ export class ComboboxController< } break; case 'Enter': - this.#hide(); + if (!this.multi) { + this.#hide(); + } break; case 'Escape': if (!this.options.isExpanded()) { @@ -501,14 +505,21 @@ export class ComboboxController< #onKeydownButton = (event: KeyboardEvent) => { if (this.#isTypeahead) { - return this.#onKeydownMenu(event); - } else { return this.#onKeydownInput(event); + } else { + return this.#onKeydownMenu(event); } }; #onKeydownListbox = (event: KeyboardEvent) => { - if (!this.#isTypeahead && event.key === 'Escape') { + if (!this.#isTypeahead + && event.key === 'Escape' + || (!this.#isTypeahead + && (event.key === 'Enter' || event.key === ' ') + && event.composedPath().some(this.options.isItem) + && !this.multi + ) + ) { this.#hide(); this.#button?.focus(); } @@ -529,6 +540,8 @@ export class ComboboxController< switch (event.key) { case 'ArrowDown': case 'ArrowUp': + case 'Enter': + case ' ': if (!this.options.isExpanded()) { this.#show(); } diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index 0685c4ad4b..bbeb94e412 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -424,7 +424,7 @@ export class ListboxController implements ReactiveCont case ' ': // enter and space are only applicable if a listbox option is clicked // an external text input should not trigger multiselect - if (event.target === this.container) { + if (event.target === this.container || this.#options.isItem(event.target)) { this.#selectItem(item, event.shiftKey); event.preventDefault(); } @@ -436,12 +436,18 @@ export class ListboxController implements ReactiveCont }; #selectItem(item: Item, multiSelection = false) { - if (!this.multi && !this.#options.isItemDisabled.call(item)) { - this.selected = [item]; + if (this.#options.isItemDisabled.call(item)) { + return; } else if (this.multi && multiSelection) { // update starting item for other multiselect this.selected = this.#getMultiSelection(item, this.#options.getATFocusedItem()); this.#shiftStartingItem = 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]; } } From 3393ba23aa495d7342ceb255106514d82194a45b Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 18:39:31 +0300 Subject: [PATCH 050/122] fix(select): no placeholder label --- elements/pf-select/pf-select.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/elements/pf-select/pf-select.ts b/elements/pf-select/pf-select.ts index acdcf3e984..329d92df84 100644 --- a/elements/pf-select/pf-select.ts +++ b/elements/pf-select/pf-select.ts @@ -120,8 +120,7 @@ export class PfSelect extends LitElement { multi: this.variant === 'typeaheadmulti' || this.variant === 'checkbox', getItems: () => this.options, getFallbackLabel: () => this.accessibleLabel - || this.#internals.computedLabelText - || this.#buttonLabel, + || this.#internals.computedLabelText, getListboxElement: () => this._listbox ?? null, getToggleButton: () => this._toggleButton ?? null, getComboboxInput: () => this._toggleInput ?? null, From 8eb2678c1c79de7549cdd34803c71edfa18d7c24 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 8 Aug 2024 20:53:24 +0300 Subject: [PATCH 051/122] fix(select): checkboxes --- .../controllers/listbox-controller.ts | 43 ++++++-- elements/pf-select/test/pf-select.spec.ts | 102 +++++++----------- 2 files changed, 73 insertions(+), 72 deletions(-) diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index bbeb94e412..de37e2fc79 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -363,7 +363,7 @@ export class ListboxController implements ReactiveCont .map(item => item === target || this.isSelected(item) ? item : null) .filter(x => !!x); } else if (this.#shiftStartingItem && target) { - this.selected = this.#getMultiSelection(target, this.#shiftStartingItem); + this.selected = this.#getMultiSelection(target); this.#shiftStartingItem = target; } } @@ -416,11 +416,30 @@ export class ListboxController implements ReactiveCont // enter and space are only applicable if a listbox option is clicked // an external text input should not trigger multiselect if (this.#options.isItem(event.target) - || (event.target as HTMLElement).getAttribute?.('aria-controls') === this.container.id) { + && !event.shiftKey + && (event.target as HTMLElement).getAttribute?.('aria-controls') !== this.container.id) { this.#selectItem(item, 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 @@ -435,13 +454,12 @@ export class ListboxController implements ReactiveCont this.host.requestUpdate(); }; - #selectItem(item: Item, multiSelection = false) { + #selectItem(item: Item, shiftDown = false) { if (this.#options.isItemDisabled.call(item)) { return; - } else if (this.multi && multiSelection) { + } else if (this.multi && shiftDown) { // update starting item for other multiselect - this.selected = this.#getMultiSelection(item, this.#options.getATFocusedItem()); - this.#shiftStartingItem = item; + 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) { @@ -457,21 +475,26 @@ export class ListboxController implements ReactiveCont * @param to item being added * @param from item already selected. */ - #getMultiSelection(to?: Item, from = this.#options.getATFocusedItem()) { - if (from && to && this.#options.multi) { + #getMultiSelection(to?: Item) { + const from = this.#shiftStartingItem; + if (to && this.#options.multi) { // whether options will be selected (true) or deselected (false) - const selecting = this.isSelected(from); + const selecting = from && this.isSelected(from); // select all options between active descendant and target // todo: flatten loops here, but be careful of off-by-one errors // maybe use the new set methods difference/union - const [start, end] = [this.items.indexOf(from), this.items.indexOf(to)].sort(); + const [start, end] = [this.items.indexOf(from!), this.items.indexOf(to)] + .filter(x => x >= 0) + .sort(); const itemsInRange = new Set(this.items .slice(start, end + 1) .filter(item => !this.#options.isItemDisabled.call(item))); + this.#shiftStartingItem = to; return this.items .filter(item => selecting ? itemsInRange.has(item) : !itemsInRange.has(item)); } else { + this.#shiftStartingItem = to ?? null; return this.selected; } } diff --git a/elements/pf-select/test/pf-select.spec.ts b/elements/pf-select/test/pf-select.spec.ts index 8ba35ee7d3..052661829b 100644 --- a/elements/pf-select/test/pf-select.spec.ts +++ b/elements/pf-select/test/pf-select.spec.ts @@ -338,8 +338,7 @@ describe('', function() { it('expands', async function() { expect(element.expanded).to.be.true; const snapshot = await a11ySnapshot(); - expect(snapshot.children?.at(1)).to.be.ok; - expect(snapshot.children?.at(1)?.role).to.equal('listbox'); + expect(snapshot).to.have.axRole('listbox'); }); }); @@ -349,8 +348,12 @@ describe('', function() { it('expands', async function() { expect(element.expanded).to.be.true; const snapshot = await a11ySnapshot(); - expect(snapshot.children?.at(1)).to.be.ok; - expect(snapshot.children?.at(1)?.role).to.equal('listbox'); + expect(snapshot).to.have.axRole('listbox'); + }); + + it('focuses the first item', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot).to.have.axTreeFocusOn(document.querySelector('pf-option')); }); describe('Shift+Tab', function() { @@ -427,12 +430,15 @@ describe('', function() { describe('ArrowDown', function() { beforeEach(press('ArrowDown')); beforeEach(updateComplete); - it('focuses option 1', async function() { + + it('focuses option 2', async function() { const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - const focused = listbox?.children?.find(x => x.focused); - expect(focused?.name).to.equal('2'); + expect(snapshot).to.have.axQuery({ + focused: true, + name: '2', + }); }); + describe('Enter', function() { beforeEach(press('Enter')); beforeEach(updateComplete); @@ -448,79 +454,51 @@ describe('', function() { const snapshot = await a11ySnapshot(); expect(snapshot).to.have.axRole('listbox'); }); + }); + }); - describe('Shift+ArrowDown, Shift+Enter, Shift+ArrowDown, Shift+Enter', function() { - beforeEach(shiftHold); - beforeEach(press('ArrowDown')); - beforeEach(press('Enter')); - beforeEach(press('ArrowDown')); + describe('holding Shift', function() { + beforeEach(shiftHold); + afterEach(shiftRelease); + describe('ArrowDown', function() { + beforeEach(press('ArrowDown')); + beforeEach(nextFrame); + it('adds option 2 to selection', function() { + expect(getValues(element)).to.deep.equal([ + '1', + '2', + ]); + }); + describe('Enter', function() { beforeEach(press('Enter')); - beforeEach(shiftRelease); beforeEach(updateComplete); - - it('adds options 2 and 3 to the selected list', function() { + it('makes no change', function() { expect(getValues(element)).to.deep.equal([ '1', '2', - '3', - '4', ]); }); - - describe('then pressing ArrowUp and Enter', function() { - beforeEach(press('ArrowUp')); - beforeEach(press('Enter')); + beforeEach(updateComplete); + describe('ArrowDown', function() { + beforeEach(press('ArrowDown')); beforeEach(updateComplete); - - it('deselects option 3', function() { + it('adds option 3 to the selected list', function() { expect(getValues(element)).to.deep.equal([ '1', '2', - '4', + '3', ]); }); - - describe('then holding down Shift and pressing arrow up / enter twice in a row', function() { - beforeEach(press('ArrowUp')); - beforeEach(press('Enter')); - beforeEach(updateComplete); - beforeEach(press('ArrowUp')); + describe('ArrowUp', function() { beforeEach(press('Enter')); beforeEach(updateComplete); - beforeEach(shiftRelease); - beforeEach(updateComplete); - - it('deselects options 1 and 2', function() { + it('makes no change to selection', function() { expect(getValues(element)).to.deep.equal([ - '4', + '1', + '2', + '3', ]); }); - - describe('then pressing Ctrl+A', function() { - beforeEach(ctrlA); - beforeEach(updateComplete); - - it('selects all options', function() { - expect(getValues(element)).to.deep.equal([ - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - ]); - }); - - describe('then pressing Ctrl+A again', function() { - beforeEach(ctrlA); - beforeEach(updateComplete); - it('deselects all options', function() { - expect(getValues(element)).to.deep.equal([]); - }); - }); - }); }); }); }); From a7ab3c957869ee2fee8fa020309fd6d379418c78 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Fri, 9 Aug 2024 12:58:41 +0300 Subject: [PATCH 052/122] fix(core): listbox select behaviour --- .../controllers/listbox-controller.ts | 76 +++++++++---------- 1 file changed, 34 insertions(+), 42 deletions(-) diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index de37e2fc79..9d3fa4ffbf 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -354,19 +354,38 @@ export class ListboxController implements ReactiveCont */ #onClick = (event: MouseEvent) => { const target = this.#getItemFromEvent(event); - if (target) { + this.#shiftStartingItem ??= target; + if (target && !this.#options.isItemDisabled.call(target)) { + // Case: single select? + // just reset the selected list. if (!this.multi) { // select target and deselect all other options this.selected = [target]; + // Case: multi select, but no shift key + // toggle target, keep all other previously selected } else if (!event.shiftKey) { - this.selected = this.items // todo: improve this intercalation - .map(item => item === target || this.isSelected(item) ? item : null) - .filter(x => !!x); - } else if (this.#shiftStartingItem && target) { - this.selected = this.#getMultiSelection(target); - this.#shiftStartingItem = target; + this.selected = this.items.filter(item => + this.#selectedItems.has(item) ? item !== target : item === target); + // 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(target)].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 = target; this.host.requestUpdate(); }; @@ -394,17 +413,19 @@ export class ListboxController implements ReactiveCont // need to set for keyboard support of multiselect if (event.key === 'Shift' && this.multi) { - this.#shiftStartingItem = this.#options.getATFocusedItem() ?? null; + this.#shiftStartingItem ??= this.#options.getATFocusedItem() ?? null; } switch (event.key) { // ctrl+A de/selects all options case 'a': case 'A': - if (event.ctrlKey && event.target === this.container) { + 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)) { + if (arraysAreEquivalent(this.selected, selectableItems)) { this.selected = []; } else { this.selected = selectableItems; @@ -416,8 +437,9 @@ export class ListboxController implements ReactiveCont // enter and space are only applicable if a listbox option is clicked // an external text input should not trigger multiselect if (this.#options.isItem(event.target) - && !event.shiftKey - && (event.target as HTMLElement).getAttribute?.('aria-controls') !== this.container.id) { + || (event.target as HTMLElement).getAttribute?.('aria-controls') === this.container.id + && !event.shiftKey + ) { this.#selectItem(item, event.shiftKey); event.preventDefault(); } @@ -468,34 +490,4 @@ export class ListboxController implements ReactiveCont this.selected = [item]; } } - - /** - * updates option selections for multiselectable listbox: - * toggles all options between active descendant and target - * @param to item being added - * @param from item already selected. - */ - #getMultiSelection(to?: Item) { - const from = this.#shiftStartingItem; - if (to && this.#options.multi) { - // whether options will be selected (true) or deselected (false) - const selecting = from && this.isSelected(from); - - // select all options between active descendant and target - // todo: flatten loops here, but be careful of off-by-one errors - // maybe use the new set methods difference/union - const [start, end] = [this.items.indexOf(from!), this.items.indexOf(to)] - .filter(x => x >= 0) - .sort(); - const itemsInRange = new Set(this.items - .slice(start, end + 1) - .filter(item => !this.#options.isItemDisabled.call(item))); - this.#shiftStartingItem = to; - return this.items - .filter(item => selecting ? itemsInRange.has(item) : !itemsInRange.has(item)); - } else { - this.#shiftStartingItem = to ?? null; - return this.selected; - } - } } From 7782577399d56e2f1765962a35749f7cdcae0821 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Fri, 9 Aug 2024 12:59:08 +0300 Subject: [PATCH 053/122] feat(tools): more ax chai helpers --- tools/pfe-tools/test/a11y-snapshot.ts | 138 ++++++++++++++++++++++---- 1 file changed, 117 insertions(+), 21 deletions(-) diff --git a/tools/pfe-tools/test/a11y-snapshot.ts b/tools/pfe-tools/test/a11y-snapshot.ts index e3ce5087c3..bb23c05ca2 100644 --- a/tools/pfe-tools/test/a11y-snapshot.ts +++ b/tools/pfe-tools/test/a11y-snapshot.ts @@ -1,6 +1,12 @@ import { chai } from '@open-wc/testing'; import { a11ySnapshot as snap } from '@web/test-runner-commands'; +const { + Assertion, + AssertionError, + util, +} = chai; + export interface A11yTreeSnapshot { name: string; role: string; @@ -136,9 +142,12 @@ function axTreeFocusOn( element: Element, msg?: string, ) { + util.flag(this, 'message', msg); const snapshot = this._obj as A11yTreeSnapshot; if (!isSnapshot(snapshot)) { - throw new Error(`axTreeFocusOn can only assert on A11yTreeSnapshots, got ${snapshot}`); + throw new AssertionError(`axTreeFocusOn can only assert on A11yTreeSnapshots`, + undefined, + util.flag(this, 'ssfi')); } if (element == null || element === document.body) { const focused = querySnapshot(snapshot, { focused: true }); @@ -154,14 +163,18 @@ function axTreeFocusOn( const actualAXName = getElementLabelText(element).trim(); const [nodeSnapshotItem, ...others] = querySnapshotAll(snapshot, { name: actualAXName }); if (others.length) { - throw new Error(`More than one ax tree node has name "${actualAXName}". axTreeFocusOn cannot produce a definitive assertion`); + throw new AssertionError( + `More than one ax tree node has name "${actualAXName}". axTreeFocusOn cannot produce a definitive assertion`, + undefined, + util.flag(this, 'ssfi') + ); } const focusedAXName = focused?.name; - const printable = chai.util.inspect(element); + const printable = util.inspect(element); this.assert( focusedAXName?.trim() === actualAXName, - `expected ${msg && ' ' || ''}${printable} to have assistive technology focus`, - `expected ${msg && ' ' || ''}${printable} to not have assistive technology focus`, + `expected ${printable} to have assistive technology focus`, + `expected ${printable} to not have assistive technology focus`, focused, nodeSnapshotItem, ); @@ -175,38 +188,97 @@ function axTreeFocusOn( } } +function axTreeFocusedNode( + this: Chai.AssertionPrototype, + msg?: string, +) { + util.flag(this, 'message', msg); + const snapshot = util.flag(this, 'object') as A11yTreeSnapshot; + const focused = querySnapshot(snapshot, { focused: true }); + this.assert( + focused != null, + `expected an element to have focus`, + `expected no element to have focus`, + null, + focused, + ); + util.flag(this, 'object', focused); +} + function axTreeNodeWithName( this: Chai.AssertionPrototype, name: string, msg?: string ) { + util.flag(this, 'message', msg); const snapshot = this._obj as A11yTreeSnapshot; if (!isSnapshot(snapshot)) { - throw new Error(`axTreeFocusOn can only assert on A11yTreeSnapshots, got ${snapshot}`); + throw new AssertionError( + `${util.flag(this, 'message')}axTreeFocusNodeWithName can only assert on A11yTreeSnapshots`, + undefined, + util.flag(this, 'ssfi') + ); } const named = querySnapshot(snapshot, { name }); this.assert( !!named, - `expected to find element with assistive technology name ${name}${!msg ? '' : `(${msg})`}`, - `expected to not find element with assistive technology name ${name}${!msg ? '' : `(${msg})`}`, + `expected to find element with assistive technology name ${name}`, + `expected to not find element with assistive technology name ${name}`, name, named, ); } -function axRole( +function makeAxPropCallback( + propName: keyof A11yTreeSnapshot, + testName: `ax${string}`, +) { + return function( + this: Chai.AssertionPrototype, + value: A11yTreeSnapshot[keyof A11yTreeSnapshot], + msg?: string, + ) { + util.flag(this, 'message', msg); + const snapshot = this._obj as A11yTreeSnapshot; + if (!isSnapshot(snapshot)) { + throw new AssertionError(`${testName} can only assert on A11yTreeSnapshots`, + undefined, + util.flag(this, 'ssfi')); + } + this.assert(snapshot[propName] === value, + `expected element to have ${propName} "${value}"`, + `expected element to not have ${propName} "${value}"`, + value, + snapshot[propName], + ); + }; +} + +function axProperty( + this: Chai.AssertionPrototype, + propName: keyof A11yTreeSnapshot, + value: A11yTreeSnapshot[keyof A11yTreeSnapshot], + msg?: string +) { + makeAxPropCallback(propName, 'axProperty').call(this, value, msg); +} + +function axRoleInTree( this: Chai.AssertionPrototype, role: string, msg?: string ) { + util.flag(this, 'message', msg); const snapshot = this._obj as A11yTreeSnapshot; if (!isSnapshot(snapshot)) { - throw new Error(`axRole can only assert on A11yTreeSnapshots, got ${snapshot}`); + throw new AssertionError(`axRoleInTree can only assert on A11yTreeSnapshots`, + undefined, + util.flag(this, 'ssfi')); } const needle = querySnapshot(snapshot, { role }); this.assert(!!needle, - `expected to find element with role ${role}${!msg ? '' : `(${msg})`}`, - `expected to not find element with role ${role}${!msg ? '' : `(${msg})`}`, + `expected to find element with role "${role}"`, + `expected to not find element with role "${role}"`, role, needle); } @@ -215,22 +287,29 @@ function axQuery( query: SnapshotQuery, msg?: string ) { + util.flag(this, 'message', msg); const snapshot = this._obj as A11yTreeSnapshot; if (!isSnapshot(snapshot)) { - throw new Error(`axQuery can only assert on A11yTreeSnapshots, got ${snapshot}`); + throw new AssertionError(`axQuery can only assert on A11yTreeSnapshots`, + undefined, + util.flag(this, 'ssfi')); } const needle = querySnapshot(snapshot, query); this.assert(!!needle, - `expected to find element matching ${chai.util.inspect(query)}${!msg ? '' : `(${msg})`}`, - `expected to not find element with role ${chai.util.inspect(query)}${!msg ? '' : `(${msg})`}`, + `expected to find element matching ${util.inspect(query)}`, + `expected to not find element with role ${util.inspect(query)}`, query, needle); } -chai.use(function(_chai) { - _chai.Assertion.addMethod('axRole', axRole); - _chai.Assertion.addMethod('axQuery', axQuery); - _chai.Assertion.addMethod('axTreeFocusOn', axTreeFocusOn); - _chai.Assertion.addMethod('axTreeNodeWithName', axTreeNodeWithName); +chai.use(function() { + Assertion.addMethod('axName', makeAxPropCallback('name', 'axName')); + Assertion.addMethod('axRole', makeAxPropCallback('role', 'axRole')); + Assertion.addMethod('axProperty', axProperty); + Assertion.addMethod('axRoleInTree', axRoleInTree); + Assertion.addMethod('axQuery', axQuery); + Assertion.addMethod('axTreeFocusOn', axTreeFocusOn); + Assertion.addProperty('axTreeFocusedNode', axTreeFocusedNode); + Assertion.addMethod('axTreeNodeWithName', axTreeNodeWithName); }); @@ -242,7 +321,7 @@ declare global { /** * Assert that a given role exists in the ax tree */ - axRole(role: string, msg?: string): void; + axRoleInTree(role: string, msg?: string): Assertion; /** * Assert that an AX Tree node that matches the query object exists in the tre */ @@ -254,6 +333,23 @@ declare global { * (i.e. aria-label or textContent). */ axTreeFocusOn(element?: Element | null, msg?: string): void; + /** + * Assert that the a11ySnapshot shows that a given element has focus. + * This assertion ultimately matches on the accessible name of the given element, + * so test authors must ensure that every element has a unique accessible name + * (i.e. aria-label or textContent). + */ + axTreeFocusedNode: Assertion; + /** Assert that an AX Tree node has a given role */ + axRole(role: string, msg?: string): Assertion; + /** Assert that an AX Tree node has a given name */ + axName(role: string, msg?: string): Assertion; + /** Assert that an AX Tree node has a given property with a given value */ + axProperty( + propName: keyof A11yTreeSnapshot, + value: A11yTreeSnapshot[keyof A11yTreeSnapshot], + msg?: string, + ): Assertion; /** * Assert that the a11ySnapshot contains a node with the given name */ From cd5bf365009ca4be7245662830da217ea3659b06 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Fri, 9 Aug 2024 12:59:25 +0300 Subject: [PATCH 054/122] docs(select): checkbox demo padding --- elements/pf-select/demo/checkbox-input.html | 25 ++++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/elements/pf-select/demo/checkbox-input.html b/elements/pf-select/demo/checkbox-input.html index 962bede1f6..cd7003192b 100644 --- a/elements/pf-select/demo/checkbox-input.html +++ b/elements/pf-select/demo/checkbox-input.html @@ -1,14 +1,23 @@ - + - Active - Cancelled - Paused -
- Warning - Restarted -
+ Active + Cancelled + Paused +
+ Warning + Restarted +
+ + + From f4a3c5128d02fdc1dd7b53884bdba3e27e704d46 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Fri, 9 Aug 2024 12:59:46 +0300 Subject: [PATCH 055/122] test(select): all green --- elements/pf-select/test/pf-select.spec.ts | 709 ++++++++++++---------- 1 file changed, 389 insertions(+), 320 deletions(-) diff --git a/elements/pf-select/test/pf-select.spec.ts b/elements/pf-select/test/pf-select.spec.ts index 052661829b..4d1858c645 100644 --- a/elements/pf-select/test/pf-select.spec.ts +++ b/elements/pf-select/test/pf-select.spec.ts @@ -3,19 +3,22 @@ import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; import { PfSelect } from '../pf-select.js'; import { sendKeys } from '@web/test-runner-commands'; import { a11ySnapshot, querySnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; +import { clickElementAtCenter } from '@patternfly/pfe-tools/test/utils.js'; +import type { PfOption } from '../pf-option.js'; -async function shiftHold() { +async function holdShift() { await sendKeys({ down: 'Shift' }); } -async function shiftRelease() { +async function releaseShift() { await sendKeys({ up: 'Shift' }); } -async function ctrlA() { +async function holdCtrl() { await sendKeys({ down: 'Control' }); - await sendKeys({ down: 'a' }); - await sendKeys({ up: 'a' }); +} + +async function releaseCtrl() { await sendKeys({ up: 'Control' }); } @@ -89,106 +92,78 @@ describe('', function() { beforeEach(updateComplete); - describe('pressing Enter', function() { + describe('Enter', function() { beforeEach(press('Enter')); beforeEach(updateComplete); it('expands', async function() { expect(element.expanded).to.be.true; - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - expect(listbox).to.be.ok; + expect(await a11ySnapshot()).to.have.axRoleInTree('listbox'); }); it('focuses on the placeholder', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - const focused = listbox?.children?.find(x => x.focused); - expect(focused?.name).to.equal('Choose a number'); + expect(await a11ySnapshot()).axTreeFocusedNode.to.have.axName('Choose a number'); }); }); - describe('pressing Space', function() { + describe('Space', function() { beforeEach(press(' ')); beforeEach(updateComplete); it('expands', async function() { - expect(element.expanded).to.be.true; - const snapshot = await a11ySnapshot(); - expect(snapshot.children?.at(1)).to.be.ok; - expect(snapshot.children?.at(1)?.role).to.equal('listbox'); + expect(await a11ySnapshot()).to.have.axRoleInTree('listbox'); }); it('focuses on the placeholder', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - const focused = listbox?.children?.find(x => x.focused); - expect(focused?.name).to.equal('Choose a number'); + expect(await a11ySnapshot()).axTreeFocusedNode.to.have.axName('Choose a number'); }); }); - describe('pressing ArrowDown', function() { + describe('ArrowDown', function() { beforeEach(press('ArrowDown')); beforeEach(updateComplete); it('expands', async function() { expect(element.expanded).to.be.true; - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - expect(listbox).to.be.ok; + expect(await a11ySnapshot()).to.have.axRoleInTree('listbox'); }); - it('focuses on option 1', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - const focused = listbox?.children?.find(x => x.focused); - expect(focused?.name).to.equal('Choose a number'); + it('focuses on the placeholder', async function() { + expect(await a11ySnapshot()).axTreeFocusedNode.to.have.axName('Choose a number'); }); - describe('then pressing ArrowUp', function() { + describe('ArrowUp', function() { beforeEach(press('ArrowUp')); beforeEach(updateComplete); it('focuses on the last option', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - const focused = listbox?.children?.find(x => x.focused); - expect(focused?.name).to.equal('8'); + expect(await a11ySnapshot()).axTreeFocusedNode.to.have.axName('8'); }); - describe('then pressing ArrowDown', function() { + describe('ArrowDown', function() { beforeEach(press('ArrowDown')); beforeEach(updateComplete); it('focuses on the placeholder', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - const focused = listbox?.children?.find(x => x.focused); - expect(focused?.name).to.equal('Choose a number'); + expect(await a11ySnapshot()).axTreeFocusedNode.to.have.axName('Choose a number'); }); }); }); - describe('then pressing ArrowDown', function() { + describe('ArrowDown', function() { beforeEach(press('ArrowDown')); beforeEach(updateComplete); it('focuses on option 1', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - const focused = listbox?.children?.find(x => x.focused); - expect(focused?.name).to.equal('1'); + expect(await a11ySnapshot()).axTreeFocusedNode.to.have.axName('1'); }); - describe('then pressing ArrowUp', function() { + describe('ArrowUp', function() { beforeEach(press('ArrowUp')); beforeEach(updateComplete); it('focuses on the placeholder', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - const focused = listbox?.children?.find(x => x.focused); - expect(focused?.name).to.equal('Choose a number'); + expect(await a11ySnapshot()).axTreeFocusedNode.to.have.axName('Choose a number'); }); }); - describe('then pressing Enter', function() { + describe('Enter', function() { beforeEach(press('Enter')); beforeEach(updateComplete); @@ -202,22 +177,16 @@ describe('', function() { beforeEach(press(' ')); beforeEach(updateComplete); - it('closes', function() { - expect(element.expanded).to.be.false; - }); - it('hides the listbox', async function() { - const snapshot = await a11ySnapshot(); - expect(querySnapshot(snapshot, { role: 'listbox' })).to.not.be.ok; + expect(element.expanded).to.be.false; + expect(await a11ySnapshot()).to.not.have.axRoleInTree('listbox'); }); it('focuses the button', async function() { - const snapshot = await a11ySnapshot(); - const focused = querySnapshot(snapshot, { - focused: true, - role: 'combobox', - }); - expect(focused).to.be.ok; + expect(await a11ySnapshot()) + .axTreeFocusedNode + .to.have.axRole('combobox') + .and.to.have.axName('Choose a number'); }); it('does not select anything', async function() { @@ -226,62 +195,52 @@ describe('', function() { }); }); - describe('then pressing Tab', function() { + describe('Tab', function() { beforeEach(press('Tab')); beforeEach(nextFrame); beforeEach(updateComplete); - it('closes', function() { - expect(element.expanded).to.be.false; - }); it('hides the listbox', async function() { - const snapshot = await a11ySnapshot(); - expect(snapshot.children?.at(1)).to.be.undefined; + expect(element.expanded).to.be.false; + expect(await a11ySnapshot()).to.not.have.axRoleInTree('listbox'); }); it('focuses the button', async function() { - const snapshot = await a11ySnapshot(); - const focused = snapshot.children?.find(x => x.focused); - expect(focused?.role).to.equal('combobox'); - expect(focused?.haspopup).to.equal('listbox'); + expect(await a11ySnapshot()) + .axTreeFocusedNode + .to.have.axRole('combobox') + .and.to.have.axName('Choose a number'); }); }); - describe('then pressing Shift+Tab', function() { - beforeEach(shiftHold); + describe('Shift+Tab', function() { + beforeEach(holdShift); beforeEach(press('Tab')); - beforeEach(shiftRelease); + beforeEach(releaseShift); beforeEach(updateComplete); - it('closes', function() { - expect(element.expanded).to.be.false; - }); it('hides the listbox', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - expect(listbox).to.be.undefined; + expect(element.expanded).to.be.false; + expect(await a11ySnapshot()).to.not.have.axRoleInTree('listbox'); }); it('focuses the button', async function() { - const snapshot = await a11ySnapshot(); - const focused = snapshot.children?.find(x => x.focused); - expect(focused?.role).to.equal('combobox'); - expect(focused?.haspopup).to.equal('listbox'); + expect(await a11ySnapshot()) + .axTreeFocusedNode + .to.have.axRole('combobox') + .and.to.have.axName('Choose a number'); }); }); - describe('then pressing Escape', function() { + describe('Escape', function() { beforeEach(press('Escape')); beforeEach(nextFrame); beforeEach(updateComplete); - it('closes', function() { - expect(element.expanded).to.be.false; - }); it('hides the listbox', async function() { - const snapshot = await a11ySnapshot(); - expect(snapshot.children?.at(1)).to.be.undefined; + expect(element.expanded).to.be.false; + expect(await a11ySnapshot()).to.not.have.axRoleInTree('listbox'); }); it('focuses the button', async function() { - const snapshot = await a11ySnapshot(); - const focused = snapshot.children?.find(x => x.focused); - expect(focused?.role).to.equal('combobox'); - expect(focused?.haspopup).to.equal('listbox'); + expect(await a11ySnapshot()) + .axTreeFocusedNode + .to.have.axRole('combobox') + .and.to.have.axName('Choose a number'); }); }); }); @@ -289,9 +248,11 @@ describe('', function() { }); describe('variant="checkbox"', function() { + let items: NodeListOf; beforeEach(async function() { element = await createFixture(html` 1 2 @@ -302,6 +263,7 @@ describe('', function() { 7 8 `); + items = element.querySelectorAll('pf-option'); }); it('is accessible', async function() { @@ -338,7 +300,7 @@ describe('', function() { it('expands', async function() { expect(element.expanded).to.be.true; const snapshot = await a11ySnapshot(); - expect(snapshot).to.have.axRole('listbox'); + expect(snapshot).to.have.axRoleInTree('listbox'); }); }); @@ -348,18 +310,18 @@ describe('', function() { it('expands', async function() { expect(element.expanded).to.be.true; const snapshot = await a11ySnapshot(); - expect(snapshot).to.have.axRole('listbox'); + expect(snapshot).to.have.axRoleInTree('listbox'); }); - it('focuses the first item', async function() { + it('focuses the placeholder', async function() { const snapshot = await a11ySnapshot(); - expect(snapshot).to.have.axTreeFocusOn(document.querySelector('pf-option')); + expect(snapshot).to.have.axTreeFocusedNode.to.have.axName('placeholder'); }); describe('Shift+Tab', function() { - beforeEach(shiftHold); + beforeEach(holdShift); beforeEach(press('Tab')); - beforeEach(shiftRelease); + beforeEach(releaseShift); beforeEach(updateComplete); it('closes', async function() { @@ -412,172 +374,195 @@ describe('', function() { }); }); - describe('Space', function() { - beforeEach(press(' ')); + describe('Ctrl-A', function() { + beforeEach(holdCtrl); + beforeEach(press('A')); + beforeEach(releaseCtrl); beforeEach(updateComplete); - - it('selects option 1', function() { - // because the placeholder was focused - expect(getValues(element)).to.deep.equal(['1']); + it('selects all', function() { + expect(element.selected.length).to.equal(items.length); }); - it('remains expanded', async function() { expect(element.expanded).to.be.true; const snapshot = await a11ySnapshot(); - expect(snapshot).to.have.axRole('listbox'); + expect(snapshot).to.have.axRoleInTree('listbox'); }); - describe('ArrowDown', function() { - beforeEach(press('ArrowDown')); + describe('Ctrl-A', function() { + beforeEach(holdCtrl); + beforeEach(press('A')); + beforeEach(releaseCtrl); + beforeEach(updateComplete); + it('deselects all', function() { + expect(element.selected.length).to.equal(0); + }); + it('remains expanded', async function() { + expect(element.expanded).to.be.true; + expect(await a11ySnapshot()).to.have.axRoleInTree('listbox'); + }); + }); + }); + + describe('Space', function() { + it('does not select anything', function() { + expect(element.selected).to.deep.equal([]); + }); + }); + + describe('ArrowDown', function() { + beforeEach(press('ArrowDown')); + beforeEach(updateComplete); + describe('Space', function() { + beforeEach(press(' ')); beforeEach(updateComplete); - it('focuses option 2', async function() { + it('selects option 1', function() { + // because the placeholder was focused + expect(getValues(element)).to.deep.equal(['1']); + }); + + it('remains expanded', async function() { + expect(element.expanded).to.be.true; const snapshot = await a11ySnapshot(); - expect(snapshot).to.have.axQuery({ - focused: true, - name: '2', - }); + expect(snapshot).to.have.axRoleInTree('listbox'); }); - describe('Enter', function() { - beforeEach(press('Enter')); + describe('ArrowDown', function() { + beforeEach(press('ArrowDown')); beforeEach(updateComplete); - it('adds option 2 to selection', function() { - expect(getValues(element)).to.deep.equal([ - '1', - '2', - ]); - }); - it('remains expanded', async function() { - expect(element.expanded).to.be.true; + it('focuses option 2', async function() { const snapshot = await a11ySnapshot(); - expect(snapshot).to.have.axRole('listbox'); + expect(snapshot).to.have.axQuery({ + focused: true, + name: '2', + }); }); - }); - }); - describe('holding Shift', function() { - beforeEach(shiftHold); - afterEach(shiftRelease); - describe('ArrowDown', function() { - beforeEach(press('ArrowDown')); - beforeEach(nextFrame); - it('adds option 2 to selection', function() { - expect(getValues(element)).to.deep.equal([ - '1', - '2', - ]); - }); describe('Enter', function() { beforeEach(press('Enter')); beforeEach(updateComplete); - it('makes no change', function() { + it('adds option 2 to selection', function() { expect(getValues(element)).to.deep.equal([ '1', '2', ]); }); - beforeEach(updateComplete); - describe('ArrowDown', function() { - beforeEach(press('ArrowDown')); + + it('remains expanded', async function() { + expect(element.expanded).to.be.true; + const snapshot = await a11ySnapshot(); + expect(snapshot).to.have.axRoleInTree('listbox'); + }); + }); + }); + + describe('holding Shift', function() { + beforeEach(holdShift); + afterEach(releaseShift); + describe('ArrowDown', function() { + beforeEach(press('ArrowDown')); + beforeEach(nextFrame); + it('adds option 2 to selection', function() { + expect(getValues(element)).to.deep.equal([ + '1', + '2', + ]); + }); + describe('Enter', function() { + beforeEach(press('Enter')); beforeEach(updateComplete); - it('adds option 3 to the selected list', function() { + it('makes no change', function() { expect(getValues(element)).to.deep.equal([ '1', '2', - '3', ]); }); - describe('ArrowUp', function() { - beforeEach(press('Enter')); + beforeEach(updateComplete); + describe('ArrowDown', function() { + beforeEach(press('ArrowDown')); beforeEach(updateComplete); - it('makes no change to selection', function() { + it('adds option 3 to the selected list', function() { expect(getValues(element)).to.deep.equal([ '1', '2', '3', ]); }); + describe('ArrowUp', function() { + beforeEach(press('Enter')); + beforeEach(updateComplete); + it('makes no change to selection', function() { + expect(getValues(element)).to.deep.equal([ + '1', + '2', + '3', + ]); + }); + }); }); }); }); }); }); }); - }); - }); - }); - describe('in a deep shadow root', function() { - let element: PfSelect; - const focus = () => element.focus(); - const updateComplete = () => element.updateComplete; - beforeEach(async function() { - const fixture = await createFixture(html` - - - `); + describe('clicking the first item', function() { + beforeEach(() => clickElementAtCenter(items[0])); + beforeEach(updateComplete); - function attachShadowRoots(root?: Document | ShadowRoot) { - root?.querySelectorAll('template[shadowrootmode]').forEach(template => { - const mode = template.getAttribute('shadowrootmode') as 'open' | 'closed'; - const shadowRoot = template.parentElement?.attachShadow?.({ mode }); - shadowRoot?.appendChild(template.content); - template.remove(); - attachShadowRoots(shadowRoot); - }); - } - attachShadowRoots(document); + it('selects option 1', function() { + // because the placeholder was focused + expect(getValues(element)).to.deep.equal(['1']); + }); - const select = fixture.shadowRoot?.firstElementChild?.shadowRoot?.querySelector('pf-select'); - if (select) { - element = select; - await element?.updateComplete; - } else { - throw new Error('no element!'); - } - }); - describe('expanding', function() { - beforeEach(focus); - beforeEach(press('Enter')); - describe('pressing ArrowDown', function() { - beforeEach(press('ArrowDown')); - beforeEach(updateComplete); - it('remains expanded', function() { - expect(element.expanded).to.be.true; - }); - describe('pressing ArrowDown', function() { - beforeEach(press('ArrowDown')); - beforeEach(updateComplete); - it('remains expanded', function() { + it('remains expanded', async function() { expect(element.expanded).to.be.true; + const snapshot = await a11ySnapshot(); + expect(snapshot).to.have.axRoleInTree('listbox'); }); - describe('pressing Space', function() { - beforeEach(press(' ')); - beforeEach(updateComplete); - it('closes', function() { - expect(element.expanded).to.be.false; - }); - it('sets value', function() { - expect(element.value).to.equal('2'); + + describe('holding Shift', function() { + beforeEach(holdShift); + afterEach(releaseShift); + describe('clicking the 7th item', function() { + beforeEach(() => clickElementAtCenter(items[6])); + it('remains expanded', async function() { + expect(element.expanded).to.be.true; + const snapshot = await a11ySnapshot(); + expect(snapshot).to.have.axRoleInTree('listbox'); + }); + + it('selects items 1-7', function() { + expect(getValues(element)).to.deep.equal([ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + ]); + }); + + describe('releasing Shift', function() { + beforeEach(releaseShift); + describe('clicking 6th item', function() { + beforeEach(() => clickElementAtCenter(items[5])); + it('deselects item 6', function() { + expect(getValues(element)).to.not.contain('6'); + }); + describe('holding Shift', function() { + beforeEach(holdShift); + describe('clicking 2nd item', function() { + beforeEach(() => clickElementAtCenter(items[1])); + it('deselects items 2-6', function() { + expect(getValues(element)).to.deep.equal(['1', '7']); + }); + }); + }); + }); + }); }); }); }); @@ -585,11 +570,12 @@ describe('', function() { }); }); - // try again when we implement activedescendant - describe.skip('variant="typeahead"', function() { + describe('variant="typeahead"', function() { beforeEach(async function() { element = await createFixture(html` - + Blue Green Magenta @@ -601,7 +587,7 @@ describe('', function() { `); }); - describe('custom filtering', function() { + describe.skip('custom filtering', function() { beforeEach(function() { // @ts-expect-error: we intend to implement this in the next release element.customFilter = option => @@ -613,16 +599,18 @@ describe('', function() { beforeEach(updateComplete); - describe('typing "r"', function() { + describe('r', function() { beforeEach(press('r')); beforeEach(updateComplete); - it('shows options with "r" anywhere in them', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - expect(listbox?.children?.length).to.equal(3); - expect(listbox?.children?.at(0)?.name).to.equal('Green'); - expect(listbox?.children?.at(1)?.name).to.equal('Orange'); - expect(listbox?.children?.at(2)?.name).to.equal('Purple'); + it('shows options that contain "r"', async function() { + expect(Array.from( + document.querySelectorAll('pf-option:not([hidden])'), + x => x.value + )).to.deep.equal([ + 'Green', + 'Orange', + 'Purple', + ]); }); }); @@ -630,11 +618,13 @@ describe('', function() { beforeEach(press('R')); beforeEach(nextFrame); beforeEach(updateComplete); - it('shows options that contain "R"', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - expect(listbox?.children?.length).to.equal(1); - expect(listbox?.children?.at(0)?.name).to.equal('Red'); + it('shows options that start with "r"', async function() { + expect(Array.from( + document.querySelectorAll('pf-option:not([hidden])'), + x => x.value + )).to.deep.equal([ + 'Red', + ]); }); }); }); @@ -644,12 +634,10 @@ describe('', function() { beforeEach(updateComplete); - it('has a text input for typeahead', async function() { - const snapshot = await a11ySnapshot(); - const [typeahead] = snapshot.children ?? []; - expect(typeahead).to.deep.equal({ + it('focuses the combobox input', async function() { + expect(await a11ySnapshot()).axTreeFocusedNode.to.deep.equal({ role: 'combobox', - name: 'Options', + name: 'Colors', focused: true, autocomplete: 'both', haspopup: 'listbox', @@ -661,13 +649,16 @@ describe('', function() { beforeEach(updateComplete); it('only shows options that start with "r" or "R"', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - expect(listbox?.children?.every(x => x.name.toLowerCase().startsWith('r'))).to.be.true; + expect(Array.from( + document.querySelectorAll('pf-option:not([hidden])'), + x => x.value + )).to.deep.equal([ + 'Red', + ]); }); }); - describe('setting filter to "*"', function() { + describe.skip('setting filter to "*"', function() { beforeEach(function() { // @ts-expect-error: todo: add filter feature element.filter = '*'; @@ -680,101 +671,103 @@ describe('', function() { }); }); - describe('changing input value to "p"', function() { + describe('p', function() { beforeEach(press('p')); beforeEach(updateComplete); - it('only shows listbox items starting with the letter p', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - expect(listbox?.children?.length).to.equal(2); - expect(listbox?.children?.at(0)?.name).to.equal('Purple'); - expect(listbox?.children?.at(1)?.name).to.equal('Pink'); + it('shows the listbox and maintains focus', async function() { + expect(await a11ySnapshot()) + .to.have.axRoleInTree('listbox') + .and.axTreeFocusedNode + .to.have.axRole('combobox') + .and.to.have.axProperty('value', 'p'); }); - it('maintains focus on the input', async function() { - const snapshot = await a11ySnapshot(); - const focused = snapshot.children?.find(x => x.focused); - expect(focused?.role).to.equal('combobox'); + it('only shows listbox items starting with the letter p', function() { + // a11yShapshot does not surface the options + expect(Array.from( + document.querySelectorAll('pf-option:not([hidden])'), + x => x.value + )).to.deep.equal([ + 'Purple', + 'Pink', + ]); }); - describe('pressing Backspace so input value is ""', function() { + describe('Backspace so input value is ""', function() { beforeEach(press('Backspace')); beforeEach(updateComplete); + it('shows the listbox and maintains focus', async function() { + expect(await a11ySnapshot()) + .to.have.axRoleInTree('listbox') + .and.axTreeFocusedNode + .to.have.axRole('combobox') + .and.to.not.have.axProperty('value', 'p'); + }); + it('all options are visible', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - expect(listbox?.children?.length).to.equal(8); - expect(listbox?.children?.at(0)?.name).to.equal('Blue'); - expect(listbox?.children?.at(1)?.name).to.equal('Green'); - expect(listbox?.children?.at(2)?.name).to.equal('Magenta'); - expect(listbox?.children?.at(3)?.name).to.equal('Orange'); - expect(listbox?.children?.at(4)?.name).to.equal('Purple'); - expect(listbox?.children?.at(5)?.name).to.equal('Pink'); - expect(listbox?.children?.at(6)?.name).to.equal('Red'); - expect(listbox?.children?.at(7)?.name).to.equal('Yellow'); + // a11yShapshot does not surface the options + expect(Array.from( + document.querySelectorAll('pf-option:not([hidden])'), + x => x.value + )).to.deep.equal([ + 'Blue', + 'Green', + 'Magenta', + 'Orange', + 'Purple', + 'Pink', + 'Red', + 'Yellow', + ]); }); }); }); - describe('pressing ArrowDown', function() { + describe('ArrowDown', function() { beforeEach(press('ArrowDown')); - beforeEach(nextFrame); beforeEach(updateComplete); - it('expands', async function() { + it('shows the listbox', async function() { expect(element.expanded).to.be.true; - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - expect(listbox).to.be.ok; + expect(await a11ySnapshot()).to.have.axRoleInTree('listbox'); }); - it('selects the first item', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - const focused = listbox?.children?.find(x => x.focused); - expect(focused).to.not.be.ok; - const selected = listbox?.children?.find(x => x.selected); - expect(selected).to.be.ok; - expect(listbox?.children?.at(0)).to.equal(selected); + it('focuses the first item', async function() { + expect(await a11ySnapshot()).to.have.axRoleInTree('listbox'); + // a11yShapshot does not surface the options + expect(Array.from( + document.querySelectorAll('pf-option[active]'), + x => x.value + )).to.deep.equal([ + 'Blue', + ]); }); it('does not move keyboard focus', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - const focused = listbox?.children?.find(x => x.focused); - expect(focused).to.not.be.ok; + expect(await a11ySnapshot()).axTreeFocusedNode.to.have.axRole('combobox'); }); - describe('then pressing ArrowDown', function() { + describe('ArrowDown', function() { beforeEach(press('ArrowDown')); beforeEach(updateComplete); - it('focuses the first option', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - const focused = listbox?.children?.find(x => x.focused); - expect(focused).to.be.ok; - expect(listbox?.children?.indexOf(focused!)).to.equal(0); + it('focuses the second option', function() { + // a11yShapshot does not surface the options + const active = document.querySelector('pf-option[active]'); + const [, item] = document.querySelectorAll('pf-option'); + expect(active).to.equal(item); }); - describe('then pressing Enter', function() { + describe('Enter', function() { beforeEach(press('Enter')); beforeEach(updateComplete); it('selects the second option', function() { expect(getValues(element)).to.deep.equal(['Green']); }); it('sets typeahead input to second option value', async function() { - const snapshot = await a11ySnapshot(); - const [combobox] = snapshot.children ?? []; - expect(combobox?.value).to.equal('Green'); + expect(await a11ySnapshot()).axTreeFocusedNode.to.have.axProperty('value', 'Green'); }); - it('focuses on toggle button', async function() { - const snapshot = await a11ySnapshot(); - const focused = snapshot.children?.find(x => x.focused); - expect(focused?.role).to.equal('button'); - expect(focused?.haspopup).to.equal('listbox'); + it('retains focuses on combobox input', async function() { + expect(await a11ySnapshot()).axTreeFocusedNode.to.have.axRole('combobox'); }); - it('closes', async function() { - expect(element.expanded).to.be.false; - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - expect(listbox).to.be.undefined; + it('hides the listbox', async function() { + expect(await a11ySnapshot()).to.not.have.axRoleInTree('listbox'); }); }); }); @@ -832,9 +825,9 @@ describe('', function() { }); describe('then pressing Shift+Tab', function() { - beforeEach(shiftHold); + beforeEach(holdShift); beforeEach(press('Tab')); - beforeEach(shiftRelease); + beforeEach(releaseShift); beforeEach(updateComplete); it('closes', function() { expect(element.expanded).to.be.false; @@ -853,9 +846,9 @@ describe('', function() { }); describe('then pressing Shift+Tab', function() { - beforeEach(shiftHold); + beforeEach(holdShift); beforeEach(press('Tab')); - beforeEach(shiftRelease); + beforeEach(releaseShift); beforeEach(updateComplete); it('focuses the combobox input', async function() { const snapshot = await a11ySnapshot(); @@ -924,9 +917,9 @@ describe('', function() { expect(chip2close?.description).to.equal('Beryl'); }); describe('then pressing Shift+Tab', function() { - beforeEach(shiftHold); + beforeEach(holdShift); beforeEach(press('Tab')); - beforeEach(shiftRelease); + beforeEach(releaseShift); beforeEach(updateComplete); it('focuses the toggle button', async function() { const snapshot = await a11ySnapshot(); @@ -935,9 +928,9 @@ describe('', function() { expect(focused?.haspopup).to.equal('listbox'); }); describe('then pressing Shift+Tab', function() { - beforeEach(shiftHold); + beforeEach(holdShift); beforeEach(press('Tab')); - beforeEach(shiftRelease); + beforeEach(releaseShift); beforeEach(updateComplete); it('focuses the combobox input', async function() { const snapshot = await a11ySnapshot(); @@ -945,9 +938,9 @@ describe('', function() { expect(focused?.role).to.equal('combobox'); }); describe('then pressing Shift+Tab', function() { - beforeEach(shiftHold); + beforeEach(holdShift); beforeEach(press('Tab')); - beforeEach(shiftRelease); + beforeEach(releaseShift); beforeEach(updateComplete); it('focuses the last chip\'s close button', async function() { const snapshot = await a11ySnapshot(); @@ -978,9 +971,9 @@ describe('', function() { expect(focused?.role).to.equal('combobox'); }); describe('then pressing Shift+Tab', function() { - beforeEach(shiftHold); + beforeEach(holdShift); beforeEach(press('Tab')); - beforeEach(shiftRelease); + beforeEach(releaseShift); beforeEach(updateComplete); it('focuses the first chip', async function() { const snapshot = await a11ySnapshot(); @@ -1014,4 +1007,80 @@ describe('', function() { }); }); }); + + describe('in a deep shadow root', function() { + let element: PfSelect; + const focus = () => element.focus(); + const updateComplete = () => element.updateComplete; + beforeEach(async function() { + const fixture = await createFixture(html` + + + `); + + function attachShadowRoots(root?: Document | ShadowRoot) { + root?.querySelectorAll('template[shadowrootmode]').forEach(template => { + const mode = template.getAttribute('shadowrootmode') as 'open' | 'closed'; + const shadowRoot = template.parentElement?.attachShadow?.({ mode }); + shadowRoot?.appendChild(template.content); + template.remove(); + attachShadowRoots(shadowRoot); + }); + } + attachShadowRoots(document); + + const select = fixture.shadowRoot?.firstElementChild?.shadowRoot?.querySelector('pf-select'); + if (select) { + element = select; + await element?.updateComplete; + } else { + throw new Error('no element!'); + } + }); + describe('expanding', function() { + beforeEach(focus); + beforeEach(press('Enter')); + describe('pressing ArrowDown', function() { + beforeEach(press('ArrowDown')); + beforeEach(updateComplete); + it('remains expanded', function() { + expect(element.expanded).to.be.true; + }); + describe('pressing ArrowDown', function() { + beforeEach(press('ArrowDown')); + beforeEach(updateComplete); + it('remains expanded', function() { + expect(element.expanded).to.be.true; + }); + describe('pressing Space', function() { + beforeEach(press(' ')); + beforeEach(updateComplete); + it('closes', function() { + expect(element.expanded).to.be.false; + }); + it('sets value', function() { + expect(element.value).to.equal('2'); + }); + }); + }); + }); + }); + }); }); From d224379e2c894ca660b234e12be314ebc2622a86 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Fri, 9 Aug 2024 13:07:12 +0300 Subject: [PATCH 056/122] test: reporter in ci --- web-test-runner.config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web-test-runner.config.js b/web-test-runner.config.js index f516b87209..1b6e2d0c36 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -7,6 +7,9 @@ export default pfeTestRunnerConfig({ tsconfig: 'tsconfig.esbuild.json', files: ['!tools/create-element/templates/**/*'], // uncomment to get default wtr reporter + ...!process.env.CI && { + reporter: 'default', + }, importMapOptions: { providers: { 'zero-md': 'nodemodules', @@ -22,7 +25,6 @@ export default pfeTestRunnerConfig({ }, }, }, - reporter: 'default', plugins: [ a11ySnapshotPlugin(), ], From bb627d7ae30573eac86eee9cece93c2de7e4e48e Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Fri, 9 Aug 2024 13:16:42 +0300 Subject: [PATCH 057/122] fix(tools): always junit reporter in ci --- tools/pfe-tools/test/config.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tools/pfe-tools/test/config.ts b/tools/pfe-tools/test/config.ts index 014e192853..80a073ff06 100644 --- a/tools/pfe-tools/test/config.ts +++ b/tools/pfe-tools/test/config.ts @@ -11,7 +11,7 @@ import { getPfeConfig } from '../config.js'; export interface PfeTestRunnerConfigOptions extends PfeDevServerConfigOptions { files?: string[]; - reporter?: 'summary' | 'default'; + reporter?: 'summary' | 'junit' | 'default'; } const isWatchMode = process.argv.some(x => x.match(/-w|--watch/)); @@ -51,14 +51,14 @@ export function pfeTestRunnerConfig(opts: PfeTestRunnerConfigOptions): TestRunne const { elementsDir, tagPrefix } = getPfeConfig(); - const configuredReporter = opts.reporter ?? 'summary'; + const configuredReporter = opts.reporter ?? 'default'; const reporters = []; if (isWatchMode) { if (configuredReporter === 'summary') { reporters.push( summaryReporter({ flatten: false }), - defaultReporter({ reportTestResults: false, reportTestProgress: true }), + defaultReporter(), ); } else { reporters.push( @@ -68,10 +68,14 @@ export function pfeTestRunnerConfig(opts: PfeTestRunnerConfigOptions): TestRunne } else { reporters.push( defaultReporter(), + ); + } + if (process.env.CI) { + reporters.push( junitReporter({ outputPath: './test-results/test-results.xml', reportLogs: true, - }), + }) ); } From 8860715ece96fb79ef20a663d0b13d3475216a56 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Fri, 9 Aug 2024 13:17:04 +0300 Subject: [PATCH 058/122] chore: test runner config --- web-test-runner.config.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/web-test-runner.config.js b/web-test-runner.config.js index 1b6e2d0c36..57e1cd9cea 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -6,10 +6,7 @@ export default pfeTestRunnerConfig({ // workaround for https://github.com/evanw/esbuild/issues/3019 tsconfig: 'tsconfig.esbuild.json', files: ['!tools/create-element/templates/**/*'], - // uncomment to get default wtr reporter - ...!process.env.CI && { - reporter: 'default', - }, + reporter: process.env.CI ? 'summary' : 'default', importMapOptions: { providers: { 'zero-md': 'nodemodules', From 7df77c4a5163a335614b116f1c5ee3ea22bcc0c1 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Fri, 9 Aug 2024 13:21:46 +0300 Subject: [PATCH 059/122] fix(tools): test runner config --- tools/pfe-tools/test/config.ts | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/tools/pfe-tools/test/config.ts b/tools/pfe-tools/test/config.ts index 80a073ff06..08228a2b0f 100644 --- a/tools/pfe-tools/test/config.ts +++ b/tools/pfe-tools/test/config.ts @@ -53,23 +53,15 @@ export function pfeTestRunnerConfig(opts: PfeTestRunnerConfigOptions): TestRunne const configuredReporter = opts.reporter ?? 'default'; - const reporters = []; - if (isWatchMode) { - if (configuredReporter === 'summary') { - reporters.push( - summaryReporter({ flatten: false }), - defaultReporter(), - ); - } else { - reporters.push( - defaultReporter(), - ); - } - } else { - reporters.push( - defaultReporter(), - ); - } + const reporters = configuredReporter === 'summary' && isWatchMode ? [ + summaryReporter({ flatten: false }), + defaultReporter(), + ] : configuredReporter === 'summary' ? [ + summaryReporter({ flatten: false }), + ] : [ + defaultReporter(), + ]; + if (process.env.CI) { reporters.push( junitReporter({ From 94363462f9377e33ab454be0159f704e70121dc6 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Fri, 9 Aug 2024 13:27:41 +0300 Subject: [PATCH 060/122] fix(tools): flatten assertions in ci --- tools/pfe-tools/test/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/pfe-tools/test/config.ts b/tools/pfe-tools/test/config.ts index 08228a2b0f..fa128e7c90 100644 --- a/tools/pfe-tools/test/config.ts +++ b/tools/pfe-tools/test/config.ts @@ -54,10 +54,10 @@ export function pfeTestRunnerConfig(opts: PfeTestRunnerConfigOptions): TestRunne const configuredReporter = opts.reporter ?? 'default'; const reporters = configuredReporter === 'summary' && isWatchMode ? [ - summaryReporter({ flatten: false }), + summaryReporter({ flatten: !!process.env.CI }), defaultReporter(), ] : configuredReporter === 'summary' ? [ - summaryReporter({ flatten: false }), + summaryReporter({ flatten: !!process.env.CI }), ] : [ defaultReporter(), ]; From 5a2df94937babd780f28dde2362c34e0948c3a61 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Fri, 9 Aug 2024 13:41:04 +0300 Subject: [PATCH 061/122] test(select): summaries --- elements/pf-select/test/pf-select.spec.ts | 32 +++++++------- patches/@web+test-runner+0.18.2.patch | 52 +++++++++++++++++++++++ 2 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 patches/@web+test-runner+0.18.2.patch diff --git a/elements/pf-select/test/pf-select.spec.ts b/elements/pf-select/test/pf-select.spec.ts index 4d1858c645..2028b75efd 100644 --- a/elements/pf-select/test/pf-select.spec.ts +++ b/elements/pf-select/test/pf-select.spec.ts @@ -805,7 +805,7 @@ describe('', function() { expect(input.role).to.equal('combobox'); }); - describe('pressing ArrowDown', function() { + describe('ArrowDown', function() { beforeEach(press('ArrowDown')); beforeEach(updateComplete); @@ -824,7 +824,7 @@ describe('', function() { expect(listbox?.children?.find(x => x.focused)?.name).to.equal('Amethyst'); }); - describe('then pressing Shift+Tab', function() { + describe('Shift+Tab', function() { beforeEach(holdShift); beforeEach(press('Tab')); beforeEach(releaseShift); @@ -845,7 +845,7 @@ describe('', function() { expect(focused?.haspopup).to.equal('listbox'); }); - describe('then pressing Shift+Tab', function() { + describe('Shift+Tab', function() { beforeEach(holdShift); beforeEach(press('Tab')); beforeEach(releaseShift); @@ -859,10 +859,10 @@ describe('', function() { }); }); - describe('then pressing ArrowDown', function() { + describe('ArrowDown', function() { beforeEach(press('ArrowDown')); beforeEach(updateComplete); - describe('then pressing Enter', function() { + describe('Enter', function() { beforeEach(press('Enter')); beforeEach(updateComplete); it('selects the second option', function() { @@ -886,7 +886,7 @@ describe('', function() { expect(chip1close?.name).to.equal('Close'); expect(chip1close?.description).to.equal('Beryl'); }); - describe('then pressing ArrowUp', function() { + describe('ArrowUp', function() { beforeEach(press('ArrowUp')); beforeEach(updateComplete); it('focuses the first option', async function() { @@ -895,7 +895,7 @@ describe('', function() { const focused = listbox?.children?.find(x => x.focused); expect(focused?.name).to.equal('Amethyst'); }); - describe('then pressing Enter', function() { + describe('Enter', function() { beforeEach(press('Enter')); beforeEach(updateComplete); it('adds second option to selected values', function() { @@ -916,7 +916,7 @@ describe('', function() { expect(chip2close?.name).to.equal('Close'); expect(chip2close?.description).to.equal('Beryl'); }); - describe('then pressing Shift+Tab', function() { + describe('Shift+Tab', function() { beforeEach(holdShift); beforeEach(press('Tab')); beforeEach(releaseShift); @@ -927,7 +927,7 @@ describe('', function() { expect(focused?.role).to.equal('button'); expect(focused?.haspopup).to.equal('listbox'); }); - describe('then pressing Shift+Tab', function() { + describe('Shift+Tab', function() { beforeEach(holdShift); beforeEach(press('Tab')); beforeEach(releaseShift); @@ -937,7 +937,7 @@ describe('', function() { const focused = snapshot.children?.find(x => x.focused); expect(focused?.role).to.equal('combobox'); }); - describe('then pressing Shift+Tab', function() { + describe('Shift+Tab', function() { beforeEach(holdShift); beforeEach(press('Tab')); beforeEach(releaseShift); @@ -949,7 +949,7 @@ describe('', function() { expect(focused?.name).to.equal('Close'); expect(focused?.description).to.equal('Beryl'); }); - describe('then pressing Space', function() { + describe('Space', function() { beforeEach(updateComplete); beforeEach(press(' ')); beforeEach(updateComplete); @@ -970,7 +970,7 @@ describe('', function() { const focused = snapshot.children?.find(x => x.focused); expect(focused?.role).to.equal('combobox'); }); - describe('then pressing Shift+Tab', function() { + describe('Shift+Tab', function() { beforeEach(holdShift); beforeEach(press('Tab')); beforeEach(releaseShift); @@ -981,7 +981,7 @@ describe('', function() { expect(focused?.role).to.equal('button'); expect(focused?.description).to.equal('Amethyst'); }); - describe('then pressing Space', function() { + describe('Space', function() { beforeEach(press(' ')); beforeEach(updateComplete); it('removes all chips', async function() { @@ -1057,19 +1057,19 @@ describe('', function() { describe('expanding', function() { beforeEach(focus); beforeEach(press('Enter')); - describe('pressing ArrowDown', function() { + describe('ArrowDown', function() { beforeEach(press('ArrowDown')); beforeEach(updateComplete); it('remains expanded', function() { expect(element.expanded).to.be.true; }); - describe('pressing ArrowDown', function() { + describe('ArrowDown', function() { beforeEach(press('ArrowDown')); beforeEach(updateComplete); it('remains expanded', function() { expect(element.expanded).to.be.true; }); - describe('pressing Space', function() { + describe('Space', function() { beforeEach(press(' ')); beforeEach(updateComplete); it('closes', function() { diff --git a/patches/@web+test-runner+0.18.2.patch b/patches/@web+test-runner+0.18.2.patch new file mode 100644 index 0000000000..ed97869385 --- /dev/null +++ b/patches/@web+test-runner+0.18.2.patch @@ -0,0 +1,52 @@ +diff --git a/node_modules/@web/test-runner/dist/reporter/summaryReporter.js b/node_modules/@web/test-runner/dist/reporter/summaryReporter.js +index 1e35853..b88bee5 100644 +--- a/node_modules/@web/test-runner/dist/reporter/summaryReporter.js ++++ b/node_modules/@web/test-runner/dist/reporter/summaryReporter.js +@@ -25,7 +25,14 @@ function summaryReporter(opts) { + var _a, _b; + const browserName = (browser === null || browser === void 0 ? void 0 : browser.name) ? ` ${dim(`[${browser.name}]`)}` : ''; + for (const result of (_a = results === null || results === void 0 ? void 0 : results.tests) !== null && _a !== void 0 ? _a : []) { +- log(logger, result.name, result.passed, result.skipped, prefix, browserName); ++ log( ++ logger, ++ flatten ? `${prefix ?? ''} ${result.name}` : result.name, ++ result.passed, ++ result.skipped, ++ prefix, ++ browserName, ++ ); + } + for (const suite of (_b = results === null || results === void 0 ? void 0 : results.suites) !== null && _b !== void 0 ? _b : []) { + logSuite(logger, suite, prefix, browser); +@@ -34,9 +41,9 @@ function summaryReporter(opts) { + function logSuite(logger, suite, parent, browser) { + const browserName = (browser === null || browser === void 0 ? void 0 : browser.name) ? ` ${dim(`[${browser.name}]`)}` : ''; + let pref = parent ? `${parent} ` : ' '; +- if (flatten) ++ if (flatten) { + pref += `${suite.name}`; +- else ++ } else + logger.log(`${pref}${suite.name}${browserName}`); + logResults(logger, suite, pref, browser); + } +diff --git a/node_modules/@web/test-runner/src/reporter/summaryReporter.ts b/node_modules/@web/test-runner/src/reporter/summaryReporter.ts +index 7be463b..8c90677 100644 +--- a/node_modules/@web/test-runner/src/reporter/summaryReporter.ts ++++ b/node_modules/@web/test-runner/src/reporter/summaryReporter.ts +@@ -51,7 +51,14 @@ export function summaryReporter(opts: Options): Reporter { + ) { + const browserName = browser?.name ? ` ${dim(`[${browser.name}]`)}` : ''; + for (const result of results?.tests ?? []) { +- log(logger, result.name, result.passed, result.skipped, prefix, browserName); ++ log( ++ logger, ++ flatten ? `${prefix ?? ''} ${result.name}` : result.name, ++ result.passed, ++ result.skipped, ++ prefix, ++ browserName, ++ ); + } + + for (const suite of results?.suites ?? []) { From 1c9dfef761df8f32243ea85061da5d195d658008 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Fri, 9 Aug 2024 16:06:11 +0300 Subject: [PATCH 062/122] chore: update deps --- package-lock.json | 8 ++--- patches/@web+test-runner+0.18.2.patch | 52 --------------------------- tools/pfe-tools/package.json | 2 +- 3 files changed, 5 insertions(+), 57 deletions(-) delete mode 100644 patches/@web+test-runner+0.18.2.patch diff --git a/package-lock.json b/package-lock.json index b7cc7f6995..4121c03c19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5597,9 +5597,9 @@ "peer": true }, "node_modules/@web/test-runner": { - "version": "0.18.2", - "resolved": "https://registry.npmjs.org/@web/test-runner/-/test-runner-0.18.2.tgz", - "integrity": "sha512-jA+957ic31aG/f1mr1b+HYzf/uTu4QsvFhyVgTKi2s5YQYGBbtfzx9PnYi47MVC9K9OHRbW8cq2Urds9qwSU3w==", + "version": "0.18.3", + "resolved": "https://registry.npmjs.org/@web/test-runner/-/test-runner-0.18.3.tgz", + "integrity": "sha512-QkVK8Qguw3Zhyu8SYR7F4VdcjyXBeJNr8W8L++s4zO/Ok7DR/Wu7+rLswn3H7OH3xYoCHRmwteehcFejefz6ew==", "peer": true, "dependencies": { "@web/browser-logs": "^0.4.0", @@ -17927,7 +17927,7 @@ "@web/dev-server-esbuild": "^1.0.2", "@web/dev-server-import-maps": "^0.2.0", "@web/dev-server-rollup": "^0.6.1", - "@web/test-runner": "^0.18.1", + "@web/test-runner": "^0.18.3", "@web/test-runner-commands": "^0.9.0", "@web/test-runner-junit-reporter": "^0.7.1", "@web/test-runner-playwright": "^0.11.0", diff --git a/patches/@web+test-runner+0.18.2.patch b/patches/@web+test-runner+0.18.2.patch deleted file mode 100644 index ed97869385..0000000000 --- a/patches/@web+test-runner+0.18.2.patch +++ /dev/null @@ -1,52 +0,0 @@ -diff --git a/node_modules/@web/test-runner/dist/reporter/summaryReporter.js b/node_modules/@web/test-runner/dist/reporter/summaryReporter.js -index 1e35853..b88bee5 100644 ---- a/node_modules/@web/test-runner/dist/reporter/summaryReporter.js -+++ b/node_modules/@web/test-runner/dist/reporter/summaryReporter.js -@@ -25,7 +25,14 @@ function summaryReporter(opts) { - var _a, _b; - const browserName = (browser === null || browser === void 0 ? void 0 : browser.name) ? ` ${dim(`[${browser.name}]`)}` : ''; - for (const result of (_a = results === null || results === void 0 ? void 0 : results.tests) !== null && _a !== void 0 ? _a : []) { -- log(logger, result.name, result.passed, result.skipped, prefix, browserName); -+ log( -+ logger, -+ flatten ? `${prefix ?? ''} ${result.name}` : result.name, -+ result.passed, -+ result.skipped, -+ prefix, -+ browserName, -+ ); - } - for (const suite of (_b = results === null || results === void 0 ? void 0 : results.suites) !== null && _b !== void 0 ? _b : []) { - logSuite(logger, suite, prefix, browser); -@@ -34,9 +41,9 @@ function summaryReporter(opts) { - function logSuite(logger, suite, parent, browser) { - const browserName = (browser === null || browser === void 0 ? void 0 : browser.name) ? ` ${dim(`[${browser.name}]`)}` : ''; - let pref = parent ? `${parent} ` : ' '; -- if (flatten) -+ if (flatten) { - pref += `${suite.name}`; -- else -+ } else - logger.log(`${pref}${suite.name}${browserName}`); - logResults(logger, suite, pref, browser); - } -diff --git a/node_modules/@web/test-runner/src/reporter/summaryReporter.ts b/node_modules/@web/test-runner/src/reporter/summaryReporter.ts -index 7be463b..8c90677 100644 ---- a/node_modules/@web/test-runner/src/reporter/summaryReporter.ts -+++ b/node_modules/@web/test-runner/src/reporter/summaryReporter.ts -@@ -51,7 +51,14 @@ export function summaryReporter(opts: Options): Reporter { - ) { - const browserName = browser?.name ? ` ${dim(`[${browser.name}]`)}` : ''; - for (const result of results?.tests ?? []) { -- log(logger, result.name, result.passed, result.skipped, prefix, browserName); -+ log( -+ logger, -+ flatten ? `${prefix ?? ''} ${result.name}` : result.name, -+ result.passed, -+ result.skipped, -+ prefix, -+ browserName, -+ ); - } - - for (const suite of results?.suites ?? []) { diff --git a/tools/pfe-tools/package.json b/tools/pfe-tools/package.json index 487b8b3d04..c0098d6516 100644 --- a/tools/pfe-tools/package.json +++ b/tools/pfe-tools/package.json @@ -77,7 +77,7 @@ "@web/dev-server-esbuild": "^1.0.2", "@web/dev-server-import-maps": "^0.2.0", "@web/dev-server-rollup": "^0.6.1", - "@web/test-runner": "^0.18.1", + "@web/test-runner": "^0.18.3", "@web/test-runner-commands": "^0.9.0", "@web/test-runner-junit-reporter": "^0.7.1", "@web/test-runner-playwright": "^0.11.0", From 45f4b1eccc18404ee7534af3e51475760e6cadcd Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Fri, 9 Aug 2024 17:24:15 +0300 Subject: [PATCH 063/122] test: refactor ax helpers --- .changeset/a11y-snapshot-chai.md | 5 +- .../pf-accordion/test/pf-accordion.spec.ts | 2 +- elements/pf-select/test/pf-select.spec.ts | 284 +++++++----------- tools/pfe-tools/test/a11y-snapshot.ts | 95 +++--- 4 files changed, 177 insertions(+), 209 deletions(-) diff --git a/.changeset/a11y-snapshot-chai.md b/.changeset/a11y-snapshot-chai.md index 300ac514cb..1137fa3f94 100644 --- a/.changeset/a11y-snapshot-chai.md +++ b/.changeset/a11y-snapshot-chai.md @@ -16,14 +16,15 @@ describe('', function() { beforeEach(clickFirstHeading); it('expands the first panel', async function() { expect(await a11ySnapshot()) - .to.have.axTreeNodeWithName('panel-1'); + .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.have.axRole('button'); + expect(await a11ySnapshot()) + .to.axContainRole('button'); }); }) }) diff --git a/elements/pf-accordion/test/pf-accordion.spec.ts b/elements/pf-accordion/test/pf-accordion.spec.ts index 0ab04c3d31..dae0f0ee09 100644 --- a/elements/pf-accordion/test/pf-accordion.spec.ts +++ b/elements/pf-accordion/test/pf-accordion.spec.ts @@ -1203,7 +1203,7 @@ describe('', function() { beforeEach(press(' ')); beforeEach(nextFrame); it('expands the panel containing the nested ', async function() { - expect(await a11ySnapshot()).to.have.axTreeNodeWithName('nest-2-header-1'); + expect(await a11ySnapshot()).to.have.axContainName('nest-2-header-1'); }); describe('Tab', function() { beforeEach(press('Tab')); diff --git a/elements/pf-select/test/pf-select.spec.ts b/elements/pf-select/test/pf-select.spec.ts index 2028b75efd..552bec6968 100644 --- a/elements/pf-select/test/pf-select.spec.ts +++ b/elements/pf-select/test/pf-select.spec.ts @@ -32,6 +32,16 @@ function getValues(element: PfSelect): string[] { return element.selected.filter(x => !!x).map(x => x!.value); } +// a11yShapshot does not surface the options +function getVisibleOptionValues() { + return Array.from(document.querySelectorAll('pf-option:not([hidden])'), x => x.value); +} + +// a11yShapshot does not surface the options +function getActiveOption() { + return document.querySelector('pf-option[active]'); +} + describe('', function() { let element: PfSelect; @@ -85,10 +95,8 @@ describe('', function() { }); }); - describe('calling focus())', function() { - beforeEach(function() { - element.focus(); - }); + describe('focus()', function() { + beforeEach(focus); beforeEach(updateComplete); @@ -98,7 +106,7 @@ describe('', function() { it('expands', async function() { expect(element.expanded).to.be.true; - expect(await a11ySnapshot()).to.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.axContainRole('listbox'); }); it('focuses on the placeholder', async function() { @@ -111,7 +119,7 @@ describe('', function() { beforeEach(updateComplete); it('expands', async function() { - expect(await a11ySnapshot()).to.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.axContainRole('listbox'); }); it('focuses on the placeholder', async function() { @@ -125,7 +133,7 @@ describe('', function() { it('expands', async function() { expect(element.expanded).to.be.true; - expect(await a11ySnapshot()).to.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.axContainRole('listbox'); }); it('focuses on the placeholder', async function() { @@ -179,7 +187,7 @@ describe('', function() { it('hides the listbox', async function() { expect(element.expanded).to.be.false; - expect(await a11ySnapshot()).to.not.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.not.axContainRole('listbox'); }); it('focuses the button', async function() { @@ -201,7 +209,7 @@ describe('', function() { beforeEach(updateComplete); it('hides the listbox', async function() { expect(element.expanded).to.be.false; - expect(await a11ySnapshot()).to.not.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.not.axContainRole('listbox'); }); it('focuses the button', async function() { expect(await a11ySnapshot()) @@ -218,7 +226,7 @@ describe('', function() { beforeEach(updateComplete); it('hides the listbox', async function() { expect(element.expanded).to.be.false; - expect(await a11ySnapshot()).to.not.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.not.axContainRole('listbox'); }); it('focuses the button', async function() { expect(await a11ySnapshot()) @@ -234,7 +242,7 @@ describe('', function() { beforeEach(updateComplete); it('hides the listbox', async function() { expect(element.expanded).to.be.false; - expect(await a11ySnapshot()).to.not.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.not.axContainRole('listbox'); }); it('focuses the button', async function() { expect(await a11ySnapshot()) @@ -271,9 +279,7 @@ describe('', function() { }); describe('focus()', function() { - beforeEach(function() { - element.focus(); - }); + beforeEach(focus); beforeEach(updateComplete); describe('Enter', function() { @@ -299,8 +305,7 @@ describe('', function() { beforeEach(updateComplete); it('expands', async function() { expect(element.expanded).to.be.true; - const snapshot = await a11ySnapshot(); - expect(snapshot).to.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.axContainRole('listbox'); }); }); @@ -309,13 +314,11 @@ describe('', function() { beforeEach(updateComplete); it('expands', async function() { expect(element.expanded).to.be.true; - const snapshot = await a11ySnapshot(); - expect(snapshot).to.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.axContainRole('listbox'); }); it('focuses the placeholder', async function() { - const snapshot = await a11ySnapshot(); - expect(snapshot).to.have.axTreeFocusedNode.to.have.axName('placeholder'); + expect(await a11ySnapshot()).to.have.axTreeFocusedNode.to.have.axName('placeholder'); }); describe('Shift+Tab', function() { @@ -324,19 +327,15 @@ describe('', function() { beforeEach(releaseShift); beforeEach(updateComplete); - it('closes', async function() { - expect(element.expanded).to.be.false; - }); - it('hides the listbox', async function() { - const snapshot = await a11ySnapshot(); - expect(snapshot.children?.at(1)).to.be.undefined; + expect(element.expanded).to.be.false; + expect(await a11ySnapshot()).to.not.axContainRole('listbox'); }); it('focuses the button', async function() { - const snapshot = await a11ySnapshot(); - expect(snapshot.children?.at(0)?.role).to.equal('combobox'); - expect(snapshot.children?.at(0)?.focused).to.be.true; + expect(await a11ySnapshot()) + .axTreeFocusedNode + .to.have.axRole('combobox'); }); }); @@ -384,8 +383,7 @@ describe('', function() { }); it('remains expanded', async function() { expect(element.expanded).to.be.true; - const snapshot = await a11ySnapshot(); - expect(snapshot).to.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.axContainRole('listbox'); }); describe('Ctrl-A', function() { @@ -398,7 +396,7 @@ describe('', function() { }); it('remains expanded', async function() { expect(element.expanded).to.be.true; - expect(await a11ySnapshot()).to.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.axContainRole('listbox'); }); }); }); @@ -423,8 +421,7 @@ describe('', function() { it('remains expanded', async function() { expect(element.expanded).to.be.true; - const snapshot = await a11ySnapshot(); - expect(snapshot).to.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.axContainRole('listbox'); }); describe('ArrowDown', function() { @@ -451,8 +448,7 @@ describe('', function() { it('remains expanded', async function() { expect(element.expanded).to.be.true; - const snapshot = await a11ySnapshot(); - expect(snapshot).to.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.axContainRole('listbox'); }); }); }); @@ -518,8 +514,7 @@ describe('', function() { it('remains expanded', async function() { expect(element.expanded).to.be.true; - const snapshot = await a11ySnapshot(); - expect(snapshot).to.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.axContainRole('listbox'); }); describe('holding Shift', function() { @@ -529,8 +524,7 @@ describe('', function() { beforeEach(() => clickElementAtCenter(items[6])); it('remains expanded', async function() { expect(element.expanded).to.be.true; - const snapshot = await a11ySnapshot(); - expect(snapshot).to.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.axContainRole('listbox'); }); it('selects items 1-7', function() { @@ -603,10 +597,7 @@ describe('', function() { beforeEach(press('r')); beforeEach(updateComplete); it('shows options that contain "r"', async function() { - expect(Array.from( - document.querySelectorAll('pf-option:not([hidden])'), - x => x.value - )).to.deep.equal([ + expect(getVisibleOptionValues()).to.deep.equal([ 'Green', 'Orange', 'Purple', @@ -619,17 +610,14 @@ describe('', function() { beforeEach(nextFrame); beforeEach(updateComplete); it('shows options that start with "r"', async function() { - expect(Array.from( - document.querySelectorAll('pf-option:not([hidden])'), - x => x.value - )).to.deep.equal([ + expect(getVisibleOptionValues()).to.deep.equal([ 'Red', ]); }); }); }); - describe('calling focus()', function() { + describe('focus()', function() { beforeEach(focus); beforeEach(updateComplete); @@ -649,10 +637,7 @@ describe('', function() { beforeEach(updateComplete); it('only shows options that start with "r" or "R"', async function() { - expect(Array.from( - document.querySelectorAll('pf-option:not([hidden])'), - x => x.value - )).to.deep.equal([ + expect(getVisibleOptionValues()).to.deep.equal([ 'Red', ]); }); @@ -677,18 +662,14 @@ describe('', function() { it('shows the listbox and maintains focus', async function() { expect(await a11ySnapshot()) - .to.have.axRoleInTree('listbox') + .to.axContainRole('listbox') .and.axTreeFocusedNode .to.have.axRole('combobox') .and.to.have.axProperty('value', 'p'); }); it('only shows listbox items starting with the letter p', function() { - // a11yShapshot does not surface the options - expect(Array.from( - document.querySelectorAll('pf-option:not([hidden])'), - x => x.value - )).to.deep.equal([ + expect(getVisibleOptionValues()).to.deep.equal([ 'Purple', 'Pink', ]); @@ -700,18 +681,14 @@ describe('', function() { it('shows the listbox and maintains focus', async function() { expect(await a11ySnapshot()) - .to.have.axRoleInTree('listbox') + .to.axContainRole('listbox') .and.axTreeFocusedNode .to.have.axRole('combobox') .and.to.not.have.axProperty('value', 'p'); }); it('all options are visible', async function() { - // a11yShapshot does not surface the options - expect(Array.from( - document.querySelectorAll('pf-option:not([hidden])'), - x => x.value - )).to.deep.equal([ + expect(getVisibleOptionValues()).to.deep.equal([ 'Blue', 'Green', 'Magenta', @@ -730,17 +707,11 @@ describe('', function() { beforeEach(updateComplete); it('shows the listbox', async function() { expect(element.expanded).to.be.true; - expect(await a11ySnapshot()).to.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.axContainRole('listbox'); }); it('focuses the first item', async function() { - expect(await a11ySnapshot()).to.have.axRoleInTree('listbox'); - // a11yShapshot does not surface the options - expect(Array.from( - document.querySelectorAll('pf-option[active]'), - x => x.value - )).to.deep.equal([ - 'Blue', - ]); + expect(await a11ySnapshot()).to.axContainRole('listbox'); + expect(getActiveOption()).to.have.value('Blue'); }); it('does not move keyboard focus', async function() { expect(await a11ySnapshot()).axTreeFocusedNode.to.have.axRole('combobox'); @@ -749,10 +720,8 @@ describe('', function() { beforeEach(press('ArrowDown')); beforeEach(updateComplete); it('focuses the second option', function() { - // a11yShapshot does not surface the options - const active = document.querySelector('pf-option[active]'); const [, item] = document.querySelectorAll('pf-option'); - expect(active).to.equal(item); + expect(getActiveOption()).to.equal(item); }); describe('Enter', function() { beforeEach(press('Enter')); @@ -767,7 +736,7 @@ describe('', function() { expect(await a11ySnapshot()).axTreeFocusedNode.to.have.axRole('combobox'); }); it('hides the listbox', async function() { - expect(await a11ySnapshot()).to.not.have.axRoleInTree('listbox'); + expect(await a11ySnapshot()).to.not.axContainRole('listbox'); }); }); }); @@ -792,36 +761,25 @@ describe('', function() { `); }); - describe('calling focus()', function() { - beforeEach(function() { - element.focus(); - }); + describe('focus()', function() { + beforeEach(focus); beforeEach(updateComplete); it('focuses the typeahead input', async function() { - const snapshot = await a11ySnapshot(); - const [input] = snapshot.children ?? []; - expect(input.focused).to.be.true; - expect(input.role).to.equal('combobox'); + expect(await a11ySnapshot()).axTreeFocusedNode.to.have.axRole('combobox'); }); describe('ArrowDown', function() { beforeEach(press('ArrowDown')); beforeEach(updateComplete); - it('expands', function() { - expect(element.expanded).to.be.true; - }); - it('shows the listbox', async function() { - const snapshot = await a11ySnapshot(); - expect(snapshot.children?.find(x => x.role === 'listbox')).to.be.ok; + expect(element.expanded).to.be.true; + expect(await a11ySnapshot()).to.axContainRole('listbox'); }); it('focuses the first option', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - expect(listbox?.children?.find(x => x.focused)?.name).to.equal('Amethyst'); + expect(getActiveOption()).to.have.property('value', 'Amethyst'); }); describe('Shift+Tab', function() { @@ -834,15 +792,12 @@ describe('', function() { }); it('hides the listbox', async function() { - const snapshot = await a11ySnapshot(); - expect(snapshot.children?.find(x => x.role === 'listbox')).to.be.undefined; + expect(element.expanded).to.be.false; + expect(await a11ySnapshot()).to.not.axContainRole('listbox'); }); it('focuses the toggle button', async function() { - const snapshot = await a11ySnapshot(); - const focused = snapshot?.children?.find(x => x.focused); - expect(focused?.role).to.equal('button'); - expect(focused?.haspopup).to.equal('listbox'); + expect(await a11ySnapshot()).axTreeFocusedNode.to.have.axRole('combobox'); }); describe('Shift+Tab', function() { @@ -851,10 +806,9 @@ describe('', function() { beforeEach(releaseShift); beforeEach(updateComplete); it('focuses the combobox input', async function() { - const snapshot = await a11ySnapshot(); - const focused = snapshot?.children?.find(x => x.focused); - expect(focused?.role).to.equal('combobox'); - expect(focused?.haspopup).to.equal('listbox'); + expect(await a11ySnapshot()) + .axTreeFocusedNode + .to.have.axRole('combobox'); }); }); }); @@ -869,63 +823,59 @@ describe('', function() { expect(getValues(element)).to.deep.equal(['Beryl']); }); it('focuses on second option', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - expect(listbox?.children?.find(x => x.focused)?.name).to.equal('Beryl'); + expect(getActiveOption()).to.have.property('value', 'Beryl'); }); it('remains expanded', async function() { expect(element.expanded).to.be.true; - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - expect(listbox).to.be.ok; + expect(await a11ySnapshot()).to.axContainRole('listbox'); }); it('shows 1 chip', async function() { - const snapshot = await a11ySnapshot(); - const [, chip1close] = snapshot.children ?? []; - expect(chip1close?.role).to.equal('button'); - expect(chip1close?.name).to.equal('Close'); - expect(chip1close?.description).to.equal('Beryl'); + expect(await a11ySnapshot()) + .to.axContainQuery({ + role: 'button', + name: 'Close', + description: 'Beryl', + }); }); describe('ArrowUp', function() { beforeEach(press('ArrowUp')); beforeEach(updateComplete); it('focuses the first option', async function() { - const snapshot = await a11ySnapshot(); - const listbox = snapshot.children?.find(x => x.role === 'listbox'); - const focused = listbox?.children?.find(x => x.focused); - expect(focused?.name).to.equal('Amethyst'); + expect(getActiveOption()).to.equal('Amethyst'); + expect(await a11ySnapshot()) + .axTreeFocusedNode.to.have.axName('Amethyst'); }); describe('Enter', function() { beforeEach(press('Enter')); beforeEach(updateComplete); it('adds second option to selected values', function() { - expect(getValues(element)).to.deep.equal(['Amethyst', 'Beryl']); + expect(getValues(element)).to.deep.equal([ + 'Amethyst', + 'Beryl', + ]); }); it('accessible combo button label should be "2 items selected"', async function() { - const snapshot = await a11ySnapshot(); - const button = snapshot.children?.find(x => x.role === 'combobox'); - expect(button?.name).to.equal('2 items selected'); + expect(await a11ySnapshot()) + .axTreeFocusedNode + .to.have.axRole('combobox') + .and + .to.have.axName('2 items selected'); }); it('shows 2 chips', async function() { - const snapshot = await a11ySnapshot(); - const [, chip1close, , chip2close] = snapshot.children ?? []; - expect(chip1close?.role).to.equal('button'); - expect(chip1close?.name).to.equal('Close'); - expect(chip1close?.description).to.equal('Amethyst'); - expect(chip2close?.role).to.equal('button'); - expect(chip2close?.name).to.equal('Close'); - expect(chip2close?.description).to.equal('Beryl'); + expect(await a11ySnapshot()) + .to.axContainQuery({ role: 'button', name: 'Close', description: 'Amethyst' }) + .and + .to.axContainQuery({ role: 'button', name: 'Close', description: 'Beryl' }); }); describe('Shift+Tab', function() { beforeEach(holdShift); beforeEach(press('Tab')); beforeEach(releaseShift); beforeEach(updateComplete); - it('focuses the toggle button', async function() { - const snapshot = await a11ySnapshot(); - const focused = snapshot.children?.find(x => x.focused); - expect(focused?.role).to.equal('button'); - expect(focused?.haspopup).to.equal('listbox'); + it('focuses the combobox input', async function() { + expect(await a11ySnapshot()) + .axTreeFocusedNode + .to.have.axRole('combobox'); }); describe('Shift+Tab', function() { beforeEach(holdShift); @@ -933,9 +883,9 @@ describe('', function() { beforeEach(releaseShift); beforeEach(updateComplete); it('focuses the combobox input', async function() { - const snapshot = await a11ySnapshot(); - const focused = snapshot.children?.find(x => x.focused); - expect(focused?.role).to.equal('combobox'); + expect(await a11ySnapshot()) + .axTreeFocusedNode + .to.have.axRole('combobox'); }); describe('Shift+Tab', function() { beforeEach(holdShift); @@ -943,11 +893,13 @@ describe('', function() { beforeEach(releaseShift); beforeEach(updateComplete); it('focuses the last chip\'s close button', async function() { - const snapshot = await a11ySnapshot(); - const focused = snapshot.children?.find(x => x.focused); - expect(focused?.role).to.equal('button'); - expect(focused?.name).to.equal('Close'); - expect(focused?.description).to.equal('Beryl'); + expect(await a11ySnapshot()) + .axTreeFocusedNode + .to.have.axRole('button') + .and + .to.have.axName('Close') + .and + .to.have.axDescription('Beryl'); }); describe('Space', function() { beforeEach(updateComplete); @@ -955,20 +907,17 @@ describe('', function() { beforeEach(updateComplete); beforeEach(updateComplete); it('removes the second chip', async function() { - const snapshot = await a11ySnapshot(); - const [, chip1close, ...rest] = snapshot.children ?? []; - expect(chip1close?.role).to.equal('button'); - expect(chip1close?.name).to.equal('Close'); - expect(chip1close?.description).to.equal('Amethyst'); - expect(rest.filter(x => 'description' in x)?.length).to.equal(0); + expect(await a11ySnapshot()).to.not.have.axDescription('Beryl'); }); it('removes the second option from the selected values', function() { - expect(getValues(element)).to.deep.equal(['Amethyst']); + expect(getValues(element)).to.deep.equal([ + 'Amethyst', + ]); }); it('focuses the combobox', async function() { - const snapshot = await a11ySnapshot(); - const focused = snapshot.children?.find(x => x.focused); - expect(focused?.role).to.equal('combobox'); + expect(await a11ySnapshot()) + .axTreeFocusedNode + .to.have.axRole('combobox'); }); describe('Shift+Tab', function() { beforeEach(holdShift); @@ -976,23 +925,22 @@ describe('', function() { beforeEach(releaseShift); beforeEach(updateComplete); it('focuses the first chip', async function() { - const snapshot = await a11ySnapshot(); - const focused = snapshot.children?.find(x => x.focused); - expect(focused?.role).to.equal('button'); - expect(focused?.description).to.equal('Amethyst'); + expect(await a11ySnapshot()) + .axTreeFocusedNode + .to.have.axRole('combobox') + .and + .to.have.axDescription('Amethyst'); }); describe('Space', function() { beforeEach(press(' ')); beforeEach(updateComplete); it('removes all chips', async function() { - const snapshot = await a11ySnapshot(); - expect(snapshot.children?.find(x => x.role === 'button' && x.name === 'Close')) - .to.be.undefined; + expect(await a11ySnapshot()).to.not.axContainRole('button'); }); - it('focuses the typeahead input', async function() { - const snapshot = await a11ySnapshot(); - const focused = snapshot.children?.find(x => x.focused); - expect(focused?.role).to.equal('combobox'); + it('focuses the combobox', async function() { + expect(await a11ySnapshot()) + .axTreeFocusedNode + .to.have.axRole('combobox'); }); }); }); @@ -1019,8 +967,8 @@ describe('', function() {