Skip to content

Commit

Permalink
feat(select): typeahead (#2809)
Browse files Browse the repository at this point in the history
* fix(select): display value from attribute

* feat(select): typeahead

adds `ActivedescendantController` to core

* fix: wip activedescendantcontroller clone nodes

* fix(select): slightly less janky clonenode path

* refactor: iterative improvements

* fix(core)!: a11y controller options

* fix: elements usage of a11y controllers

* fix!: abstract ATFocusController

* fix!: inching towards correctness

* fix: give listbox controller access to control element

* fix: inching closer

* fix(core): index at focus item by number

* fix(select): typeahead, focus, filter

* fix(select): roles

* fix(select): combobox controller

* fix(select): combobox controller

works with orca ff, apparently

* fix(core): combobox

* fix(select): better vo support

* fix(select): activedescentant

* fix(select): dropdown orientation

* fix(core): listbox select

* fix: select, core jsdoc, cleanup

* fix(core): a more general combobox interface

* fix(core): more nitpicks

* fix(core): oopsies

forgot to commit these yesterday

* refactor(select): polish

* fix(tabs): update to use new rtic stuff

* fix(core): initial focus for rti

* fix(accordion): wip rtic migration

* test(accordion): update tests

* refactor(accordion): whitespace

* docs(accordion): focusable content in panel

* docs(accordion): demo formatting

* test(accordion): fix and refactor tests

* fix(core): off-by-one error in RTIC controller

* fix(core): nested rtic

* feat(tools): chai a11y snapshot assertions

* fix(core): more ssr-able controllers

* fix(core): more ssr-able controllers

* fix(core): more ssr-able controllers

* fix(icon): more ssr-able icon

* fix(core): remove unused ax controller apis

* test(core): observes decorator

* fix(chip): rtic apis, tests

* feat(tools): a11yShapshot queries can match regex

* chore: import maps in tests

* refactor(select): type assertion

* feat(tools): more ax assertions

* fix(core): listbox/combobox selection state

* fix(select): no placeholder label

* fix(select): checkboxes

* fix(core): listbox select behaviour

* feat(tools): more ax chai helpers

* docs(select): checkbox demo padding

* test(select): all green

* test: reporter in ci

* fix(tools): always junit reporter in ci

* chore: test runner config

* fix(tools): test runner config

* fix(tools): flatten assertions in ci

* test(select): summaries

* chore: update deps

* test: refactor ax helpers

* test(select): reformat test file

* test(select): taborder when bluring listbox

* test(select): format test file

* test(select): home/end should expand listbox

* test(select): show+home after selecting

* test(select): no scroll on space

* docs(select): demo containers

* fix(core): aria-multiselectable

* test(select): format test file

* fix(select): redundant button role

* fix: visually-hidden styles

* test(select): provisional home/end typeahead

* test(select): aria-posinset

* fix(core): listbox aria-posinset

* test(select): format file

* test(select): dont expand listbox on type space

* test(select): space on button no scroll

* fix(core): combobox prevent scroll

Also prevent listbox from showing on typing space in combobox input

* test(select): tabbing away does not focus button

* fix(core): combobox dont focus button on blur

* refactor(core): combobox listeners

* fix(core): home/end for combobox

* test(select): format file

* fix(select): ghost placeholder

* test(select): refactor

* test(select): cases involving labels and placeholders

* fix(core): placeholder/label/carat

* fix(select): focus styles

* fix(select): fallback label to placeholder

* fix(select): workaround for safari

* test(select): cases

* fix(select): inert instead of aria-hidden

* fix(core): safari workaround for activedescendant

* fix(core): wip single-vs-multiselect on click

* fix(core): multiselect click

* fix(select): checkbox label

* test(select): refactor tests

* feat(tools): test utils: allow clicking out of element bounds

* test(select): clicking items

* fix(core): clicking shadow ad items

* test(select): more better selected tests

* fix(core): clicking shadow items

* fix(core): more x-root aria shenanigans

* test(select): add lightdom options slotted test

* refactor(core): unused var

* test(core): test shadow-root-only combobox-controller

* test(core): combobox works even with no user-set ids

* fix(core): propertly detect rotten apples

* refactor(core): rename support boolean

* fix(core): ad controller works without preset ids

* fix(core): default isItemDisabled predicate

* test: try to deflake

* test(select): greg's issues when an item is selected

* refactor(core): controller field privacy

* fix(core): correct order of operations in combobox listeners

* refactor(core): override onKeydown

* fix(core): ensure compatibility in combobox controller

* test(core): combobox tests

* refactor(core): don't bind this in controller options

---------

Co-authored-by: Steven Spriggs <[email protected]>
  • Loading branch information
bennypowers and zeroedin authored Aug 15, 2024
1 parent aca9130 commit 874f3b6
Show file tree
Hide file tree
Showing 50 changed files with 4,963 additions and 2,247 deletions.
6 changes: 6 additions & 0 deletions .changeset/a11y-controller-opts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@patternfly/pfe-core": major
---
`RovingTabindexController`, `ListboxController`: constructor options were changed

TODO: elaborate, give before-and-after cases
32 changes: 32 additions & 0 deletions .changeset/a11y-snapshot-chai.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@patternfly/pfe-tools": minor
---
`a11ySnapshot`: Added chai assertions for various accessibility-tree scenarios

Examples:
```ts
describe('<pf-accordion>', function() {
beforeEach(() => fixture(html`
<pf-accordion>
<pf-accordion-header id="header1">header-1</pf-accordion-header>
<pf-accordion-panel>panel-1</pf-accordion-panel>
</pf-accordion>
`))
describe('clicking the first heading', function() {
beforeEach(clickFirstHeading);
it('expands the first panel', async function() {
expect(await a11ySnapshot())
.to.axContainName('panel-1');
});
it('focuses the first panel', async function() {
expect(await a11ySnapshot())
.to.have.axTreeFocusOn(document.getElementById('header1'));
});
it('shows the collapse all button', async function() {
expect(await a11ySnapshot())
.to.axContainRole('button');
});
})
})

```
273 changes: 273 additions & 0 deletions core/pfe-core/controllers/activedescendant-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import type { ReactiveControllerHost } from 'lit';

import { type ATFocusControllerOptions, ATFocusController } from './at-focus-controller.js';

import { isServer, nothing } from 'lit';
import { getRandomId } from '../functions/random.js';
import { bound } from '../decorators/bound.js';

export interface ActivedescendantControllerOptions<
Item extends HTMLElement
> extends ATFocusControllerOptions<Item> {
/**
* Returns a reference to the element which acts as the assistive technology container for
* the items. In the case of a combobox, this is the input element.
*/
getActiveDescendantContainer(): HTMLElement | null;
/**
* Optional callback to control the assistive technology focus behavior of items.
* By default, ActivedescendantController will not do anything special to items when they receive
* assistive technology focus, and will only set the `activedescendant` property on the container.
* If you provide this callback, ActivedescendantController will call it on your item with the
* active state. You may use this to set active styles.
*/
setItemActive?(item: Item, active: boolean): void;
/**
* Optional callback to retrieve the value from an option element.
* By default, retrieves the `value` attribute, or the text content.
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLOptionElement
*/
getItemValue?(item: Item): string;
}

/**
* Implements activedescendant pattern, as described in WAI-ARIA practices,
* [Managing Focus in Composites Using aria-activedescendant][ad]
*
* The steps for using the aria-activedescendant method of managing focus are as follows.
*
* - When the container element that has a role that supports aria-activedescendant is loaded
* or created, ensure that:
* - The container element is included in the tab sequence as described in
* Keyboard Navigation Between Components or is a focusable element of a composite
* that implements a roving tabindex.
* - It has aria-activedescendant="IDREF" where IDREF is the ID of the element within
* the container that should be identified as active when the widget receives focus.
* The referenced element needs to meet the DOM relationship requirements described below.
* - When the container element receives DOM focus, draw a visual focus indicator on the active
* element and ensure the active element is scrolled into view.
* - When the composite widget contains focus and the user presses a navigation key that moves
* focus within the widget, such as an arrow key:
* - Change the value of aria-activedescendant on the container to refer to the element
* that should be reported to assistive technologies as active.
* - Move the visual focus indicator and, if necessary, scrolled the active element into view.
* - If the design calls for a specific element to be focused the next time a user moves focus
* into the composite with Tab or Shift+Tab, check if aria-activedescendant is referring to
* that target element when the container loses focus. If it is not, set aria-activedescendant
* to refer to the target element.
*
* The specification for aria-activedescendant places important restrictions on the
* DOM relationship between the focused element that has the aria-activedescendant attribute
* and the element referenced as active by the value of the attribute.
* One of the following three conditions must be met.
*
* 1. The element referenced as active is a DOM descendant of the focused referencing element.
* 2. The focused referencing element has a value specified for the aria-owns property that
* includes the ID of the element referenced as active.
* 3. The focused referencing element has role of combobox, textbox, or searchbox
* and has aria-controls property referring to an element with a role that supports
* aria-activedescendant and either:
* 1. The element referenced as active is a descendant of the controlled element.
* 2. The controlled element has a value specified for the aria-owns property that includes
* the ID of the element referenced as active.
*
* [ad]: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_focus_activedescendant
*/
export class ActivedescendantController<
Item extends HTMLElement = HTMLElement
> extends ATFocusController<Item> {
/**
* 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 supportsCrossRootActiveDescendant(): boolean {
return !isServer && 'ariaActiveDescendantElement' in HTMLElement.prototype;
}

static of<Item extends HTMLElement>(
host: ReactiveControllerHost,
options: ActivedescendantControllerOptions<Item>,
): ActivedescendantController<Item> {
return new ActivedescendantController(host, options);
}

/** Maps from original element to shadow DOM clone */
#lightToShadowMap = new WeakMap<Item, Item>();

/** Maps from shadow DOM clone to original element */
#shadowToLightMap = new WeakMap<Item, Item>();

/** Set of item which should not be cloned */
#noCloneSet = new WeakSet<Item>();

/** Element which controls the list i.e. combobox */
#controlsElements: HTMLElement[] = [];

#observing = false;

#listMO = new MutationObserver(records => this.#onItemsDOMChange(records));

#attrMO = new MutationObserver(records => this.#onItemAttributeChange(records));

#syncAttr(attributeName: string, fromNode: Item) {
const toNode = this.#shadowToLightMap.get(fromNode as Item)
?? this.#lightToShadowMap.get(fromNode as Item);
const newVal = fromNode.getAttribute(attributeName);
const oldVal = toNode?.getAttribute(attributeName);
if (!fromNode.hasAttribute(attributeName)) {
toNode?.removeAttribute(attributeName);
} else if (oldVal !== newVal) {
toNode?.setAttribute(attributeName, newVal!);
}
}

get atFocusedItemIndex(): number {
return super.atFocusedItemIndex;
}

/**
* Rather than setting DOM focus, applies the `aria-activedescendant` attribute,
* using AriaIDLAttributes for cross-root aria, if supported by the browser
* @param item item
*/
set atFocusedItemIndex(index: number) {
super.atFocusedItemIndex = index;
const item = this._items.at(this.atFocusedItemIndex);
for (const _item of this.items) {
this.options.setItemActive?.(_item, _item === item);
}
const container = this.options.getActiveDescendantContainer();
if (!ActivedescendantController.supportsCrossRootActiveDescendant) {
container?.setAttribute('aria-activedescendant', item?.id ?? '');
} else if (container) {
container.ariaActiveDescendantElement = item ?? null;
}
this.host.requestUpdate();
}

protected get controlsElements(): HTMLElement[] {
return this.#controlsElements;
}

protected set controlsElements(elements: HTMLElement[]) {
for (const old of this.#controlsElements) {
old?.removeEventListener('keydown', this.onKeydown);
}
this.#controlsElements = elements;
for (const element of this.#controlsElements) {
element.addEventListener('keydown', this.onKeydown);
}
}

/** All items */
get items() {
return this._items;
}

/**
* Sets the list of items and activates the next activatable item after the current one
* @param items tabindex items
*/
override set items(items: Item[]) {
const container = this.options.getItemsContainer?.() ?? this.host;
if (!(container instanceof HTMLElement)) {
throw new Error('items container must be an HTMLElement');
}
this.itemsContainerElement = container;
const { supportsCrossRootActiveDescendant } = ActivedescendantController;
if (supportsCrossRootActiveDescendant
|| [container] // all nodes are in the same root
.concat(this.controlsElements)
.concat(items)
.every((node, _, a) => node.getRootNode() === a[0].getRootNode())) {
this._items = items.map(x => {
if (!supportsCrossRootActiveDescendant) {
x.id ||= getRandomId();
}
return x;
});
} else {
this._items = items?.map((item: Item) => {
item.removeAttribute('tabindex');
if (container.contains(item)) {
item.id ||= getRandomId();
this.#noCloneSet.add(item);
this.#shadowToLightMap.set(item, item);
return item;
} else {
const clone = item.cloneNode(true) as Item;
clone.id = getRandomId();
this.#lightToShadowMap.set(item, clone);
this.#shadowToLightMap.set(clone, item);
// Though efforts were taken to disconnect
// this observer, it may still be a memory leak
this.#attrMO.observe(clone, { attributes: true });
this.#attrMO.observe(item, { attributes: true });
return clone;
}
});
}
}

private constructor(
public host: ReactiveControllerHost,
protected options: ActivedescendantControllerOptions<Item>,
) {
super(host, options);
this.options.getItemValue ??= function(this: Item) {
return (this as unknown as HTMLOptionElement).value;
};
}

#onItemsDOMChange(records: MutationRecord[]) {
for (const { removedNodes } of records) {
for (const removed of removedNodes as NodeListOf<Item>) {
this.#lightToShadowMap.get(removed)?.remove();
this.#lightToShadowMap.delete(removed);
}
}
};

#onItemAttributeChange(records: MutationRecord[]) {
for (const { target, attributeName } of records) {
if (attributeName) {
this.#syncAttr(attributeName, target as Item);
}
}
};

protected override initItems(): void {
this.#attrMO.disconnect();
super.initItems();
this.controlsElements = this.options.getControlsElements?.() ?? [];
if (!this.#observing && this.itemsContainerElement && this.itemsContainerElement.isConnected) {
this.#listMO.observe(this.itemsContainerElement, { childList: true });
this.#observing = true;
}
}

hostDisconnected(): void {
this.controlsElements = [];
this.#observing = false;
this.#listMO.disconnect();
this.#attrMO.disconnect();
}

@bound
protected override onKeydown(event: KeyboardEvent): void {
if (!event.ctrlKey
&& !event.altKey
&& !event.metaKey
&& !!this.atFocusableItems.length) {
super.onKeydown(event);
};
}

public renderItemsToShadowRoot(): typeof nothing | Node[] {
if (ActivedescendantController.supportsCrossRootActiveDescendant) {
return nothing;
} else {
return this.items?.filter(x => !this.#noCloneSet.has(x));
}
}
}
Loading

0 comments on commit 874f3b6

Please sign in to comment.