Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(select): typeahead #2809

Merged
merged 123 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
123 commits
Select commit Hold shift + click to select a range
ccdaf1d
fix(select): display value from attribute
bennypowers Jul 11, 2024
f3d2053
feat(select): typeahead
bennypowers Jul 11, 2024
32f312b
fix: wip activedescendantcontroller clone nodes
bennypowers Jul 17, 2024
30a2c82
fix(select): slightly less janky clonenode path
bennypowers Jul 17, 2024
2fdb4bf
refactor: iterative improvements
bennypowers Jul 17, 2024
a3a670e
fix(core)!: a11y controller options
bennypowers Jul 18, 2024
2f7f48e
fix: elements usage of a11y controllers
bennypowers Jul 18, 2024
243bb6b
Merge branch 'staging/4.0' into feat/select/typeahead
bennypowers Jul 18, 2024
10f2d49
fix!: abstract ATFocusController
bennypowers Jul 21, 2024
a8f9ae8
fix!: inching towards correctness
bennypowers Jul 22, 2024
b813d79
fix: give listbox controller access to control element
bennypowers Jul 23, 2024
bd0ff4a
fix: inching closer
bennypowers Jul 23, 2024
9c4e1fd
fix(core): index at focus item by number
bennypowers Jul 24, 2024
2a82612
fix(select): typeahead, focus, filter
bennypowers Jul 25, 2024
d2c0a80
fix(select): roles
bennypowers Jul 25, 2024
d383af3
fix(select): combobox controller
bennypowers Aug 1, 2024
665473e
fix(select): combobox controller
bennypowers Aug 1, 2024
ede734c
fix(core): combobox
bennypowers Aug 1, 2024
5087bd2
fix(select): better vo support
bennypowers Aug 5, 2024
3a708c7
fix(select): activedescentant
bennypowers Aug 5, 2024
c45325f
fix(select): dropdown orientation
bennypowers Aug 5, 2024
c01d046
fix(core): listbox select
bennypowers Aug 5, 2024
3100c99
fix: select, core jsdoc, cleanup
bennypowers Aug 6, 2024
3d1d02c
fix(core): a more general combobox interface
bennypowers Aug 6, 2024
a4d0bd5
fix(core): more nitpicks
bennypowers Aug 6, 2024
dc9b0bc
fix(core): oopsies
bennypowers Aug 7, 2024
b0ef3e3
refactor(select): polish
bennypowers Aug 7, 2024
3fe2e04
fix(tabs): update to use new rtic stuff
bennypowers Aug 7, 2024
c0f2310
fix(core): initial focus for rti
bennypowers Aug 7, 2024
6e2dec4
fix(accordion): wip rtic migration
bennypowers Aug 7, 2024
e6cb3f3
test(accordion): update tests
bennypowers Aug 7, 2024
0ae0a32
refactor(accordion): whitespace
bennypowers Aug 8, 2024
7781dbc
docs(accordion): focusable content in panel
bennypowers Aug 8, 2024
95cb272
docs(accordion): demo formatting
bennypowers Aug 8, 2024
15782d9
test(accordion): fix and refactor tests
bennypowers Aug 8, 2024
44bef46
fix(core): off-by-one error in RTIC controller
bennypowers Aug 8, 2024
2f703b4
fix(core): nested rtic
bennypowers Aug 8, 2024
0b79d6a
feat(tools): chai a11y snapshot assertions
bennypowers Aug 8, 2024
1dab99c
fix(core): more ssr-able controllers
bennypowers Aug 8, 2024
83fde84
fix(core): more ssr-able controllers
bennypowers Aug 8, 2024
0c01ad5
fix(core): more ssr-able controllers
bennypowers Aug 8, 2024
300fe39
fix(icon): more ssr-able icon
bennypowers Aug 8, 2024
b45f186
fix(core): remove unused ax controller apis
bennypowers Aug 8, 2024
953f3d6
test(core): observes decorator
bennypowers Aug 8, 2024
9fb9030
fix(chip): rtic apis, tests
bennypowers Aug 8, 2024
159b003
feat(tools): a11yShapshot queries can match regex
bennypowers Aug 8, 2024
77c5501
chore: import maps in tests
bennypowers Aug 8, 2024
719f88b
refactor(select): type assertion
bennypowers Aug 8, 2024
b9f1866
feat(tools): more ax assertions
bennypowers Aug 8, 2024
71bf140
fix(core): listbox/combobox selection state
bennypowers Aug 8, 2024
3393ba2
fix(select): no placeholder label
bennypowers Aug 8, 2024
8eb2678
fix(select): checkboxes
bennypowers Aug 8, 2024
a7ab3c9
fix(core): listbox select behaviour
bennypowers Aug 9, 2024
7782577
feat(tools): more ax chai helpers
bennypowers Aug 9, 2024
cd5bf36
docs(select): checkbox demo padding
bennypowers Aug 9, 2024
f4a3c51
test(select): all green
bennypowers Aug 9, 2024
d224379
test: reporter in ci
bennypowers Aug 9, 2024
bb627d7
fix(tools): always junit reporter in ci
bennypowers Aug 9, 2024
8860715
chore: test runner config
bennypowers Aug 9, 2024
7df77c4
fix(tools): test runner config
bennypowers Aug 9, 2024
9436346
fix(tools): flatten assertions in ci
bennypowers Aug 9, 2024
5a2df94
test(select): summaries
bennypowers Aug 9, 2024
1c9dfef
chore: update deps
bennypowers Aug 9, 2024
45f4b1e
test: refactor ax helpers
bennypowers Aug 9, 2024
076477a
test(select): reformat test file
bennypowers Aug 11, 2024
150c9eb
test(select): taborder when bluring listbox
bennypowers Aug 11, 2024
6e77354
test(select): format test file
bennypowers Aug 11, 2024
0052d61
test(select): home/end should expand listbox
bennypowers Aug 11, 2024
9ae09da
test(select): show+home after selecting
bennypowers Aug 11, 2024
2783e55
test(select): no scroll on space
bennypowers Aug 11, 2024
f6a8a3f
docs(select): demo containers
bennypowers Aug 11, 2024
aa57e21
fix(core): aria-multiselectable
bennypowers Aug 11, 2024
852382f
test(select): format test file
bennypowers Aug 11, 2024
4d49124
fix(select): redundant button role
bennypowers Aug 11, 2024
1adce91
fix: visually-hidden styles
bennypowers Aug 11, 2024
3391808
test(select): provisional home/end typeahead
bennypowers Aug 11, 2024
8d5ed6a
test(select): aria-posinset
bennypowers Aug 11, 2024
6172485
fix(core): listbox aria-posinset
bennypowers Aug 11, 2024
a4bdeea
test(select): format file
bennypowers Aug 11, 2024
937920e
test(select): dont expand listbox on type space
bennypowers Aug 11, 2024
d33471f
test(select): space on button no scroll
bennypowers Aug 11, 2024
7362960
fix(core): combobox prevent scroll
bennypowers Aug 11, 2024
5d4c2c9
test(select): tabbing away does not focus button
bennypowers Aug 11, 2024
d786199
fix(core): combobox dont focus button on blur
bennypowers Aug 11, 2024
f9760cd
refactor(core): combobox listeners
bennypowers Aug 11, 2024
719372e
fix(core): home/end for combobox
bennypowers Aug 11, 2024
9ad7375
test(select): format file
bennypowers Aug 11, 2024
533202b
fix(select): ghost placeholder
bennypowers Aug 13, 2024
27eafbb
test(select): refactor
bennypowers Aug 14, 2024
ab73c28
test(select): cases involving labels and placeholders
bennypowers Aug 14, 2024
88b99d2
fix(core): placeholder/label/carat
bennypowers Aug 14, 2024
c97654f
fix(select): focus styles
bennypowers Aug 14, 2024
a4bf10e
fix(select): fallback label to placeholder
bennypowers Aug 14, 2024
ce63bb8
fix(select): workaround for safari
bennypowers Aug 14, 2024
241ba30
test(select): cases
bennypowers Aug 14, 2024
b4b99c7
fix(select): inert instead of aria-hidden
bennypowers Aug 14, 2024
25e909a
fix(core): safari workaround for activedescendant
bennypowers Aug 14, 2024
d939b47
fix(core): wip single-vs-multiselect on click
bennypowers Aug 14, 2024
9eb6719
fix(core): multiselect click
bennypowers Aug 14, 2024
be83da2
fix(select): checkbox label
bennypowers Aug 14, 2024
f430191
test(select): refactor tests
bennypowers Aug 14, 2024
64ec1d6
feat(tools): test utils: allow clicking out of element bounds
bennypowers Aug 14, 2024
e0d35ea
test(select): clicking items
bennypowers Aug 14, 2024
fcb4ec1
fix(core): clicking shadow ad items
bennypowers Aug 14, 2024
b26afc7
test(select): more better selected tests
bennypowers Aug 14, 2024
8a7022a
fix(core): clicking shadow items
bennypowers Aug 14, 2024
e77581a
fix(core): more x-root aria shenanigans
bennypowers Aug 14, 2024
3c42af5
test(select): add lightdom options slotted test
zeroedin Aug 14, 2024
0748da8
refactor(core): unused var
bennypowers Aug 15, 2024
7400dc8
test(core): test shadow-root-only combobox-controller
bennypowers Aug 15, 2024
83591e7
test(core): combobox works even with no user-set ids
bennypowers Aug 15, 2024
b0383f3
fix(core): propertly detect rotten apples
bennypowers Aug 15, 2024
1a234a1
refactor(core): rename support boolean
bennypowers Aug 15, 2024
4c51a38
fix(core): ad controller works without preset ids
bennypowers Aug 15, 2024
0110a44
fix(core): default isItemDisabled predicate
bennypowers Aug 15, 2024
402a497
test: try to deflake
bennypowers Aug 15, 2024
fcdcaef
test(select): greg's issues when an item is selected
bennypowers Aug 15, 2024
9a28e9f
refactor(core): controller field privacy
bennypowers Aug 15, 2024
dd66cba
fix(core): correct order of operations in combobox listeners
bennypowers Aug 15, 2024
7917a9c
refactor(core): override onKeydown
bennypowers Aug 15, 2024
c2e5a2f
fix(core): ensure compatibility in combobox controller
bennypowers Aug 15, 2024
cc177ff
test(core): combobox tests
bennypowers Aug 15, 2024
46442ed
refactor(core): don't bind this in controller options
bennypowers Aug 15, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading