Elizabeth Mitchell c6ffd70fc8 feat(menu): add no-navigation-wrap to fix select accessibility
PiperOrigin-RevId: 610514684
2024-02-26 13:46:47 -08:00

318 lines
8.2 KiB
TypeScript

/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
activateFirstItem,
activateLastItem,
activateNextItem,
activatePreviousItem,
getActiveItem,
getFirstActivatableItem,
ListItem,
} from './list-navigation-helpers.js';
// TODO: move this file to List and make List use this
/**
* Default keys that trigger navigation.
*/
// tslint:disable:enforce-name-casing Following Enum style
export const NavigableKeys = {
ArrowDown: 'ArrowDown',
ArrowLeft: 'ArrowLeft',
ArrowUp: 'ArrowUp',
ArrowRight: 'ArrowRight',
Home: 'Home',
End: 'End',
} as const;
// tslint:enable:enforce-name-casing
/**
* Default set of navigable keys.
*/
export type NavigableKeys = (typeof NavigableKeys)[keyof typeof NavigableKeys];
/**
* The configuration object to customize the behavior of the List Controller
*/
export interface ListControllerConfig<Item extends ListItem> {
/**
* A function that determines whether or not the given element is an Item
*/
isItem: (item: HTMLElement) => item is Item;
/**
* A function that returns an array of elements to consider as items. For
* example, all the slotted elements.
*/
getPossibleItems: () => HTMLElement[];
/**
* A function that returns whether or not the list is in an RTL context.
*/
isRtl: () => boolean;
/**
* Deactivates an item such as setting the tabindex to -1 and or sets selected
* to false.
*/
deactivateItem: (item: Item) => void;
/**
* Activates an item such as setting the tabindex to 1 and or sets selected to
* true (but does not focus).
*/
activateItem: (item: Item) => void;
/**
* Whether or not the key should be handled by the list for navigation.
*/
isNavigableKey: (key: string) => boolean;
/**
* Whether or not the item can be activated. Defaults to items that are not
* disabled.
*/
isActivatable?: (item: Item) => boolean;
/**
* Whether or not navigating past the end of the list wraps to the beginning
* and vice versa. Defaults to true.
*/
wrapNavigation?: () => boolean;
}
/**
* A controller that handles list keyboard navigation and item management.
*/
export class ListController<Item extends ListItem> {
isItem: (item: HTMLElement) => item is Item;
private readonly getPossibleItems: () => HTMLElement[];
private readonly isRtl: () => boolean;
private readonly deactivateItem: (item: Item) => void;
private readonly activateItem: (item: Item) => void;
private readonly isNavigableKey: (key: string) => boolean;
private readonly isActivatable?: (item: Item) => boolean;
private readonly wrapNavigation: () => boolean;
constructor(config: ListControllerConfig<Item>) {
const {
isItem,
getPossibleItems,
isRtl,
deactivateItem,
activateItem,
isNavigableKey,
isActivatable,
wrapNavigation,
} = config;
this.isItem = isItem;
this.getPossibleItems = getPossibleItems;
this.isRtl = isRtl;
this.deactivateItem = deactivateItem;
this.activateItem = activateItem;
this.isNavigableKey = isNavigableKey;
this.isActivatable = isActivatable;
this.wrapNavigation = wrapNavigation ?? (() => true);
}
/**
* The items being managed by the list. Additionally, attempts to see if the
* object has a sub-item in the `.item` property.
*/
get items(): Item[] {
const maybeItems = this.getPossibleItems();
const items: Item[] = [];
for (const itemOrParent of maybeItems) {
const isItem = this.isItem(itemOrParent);
// if the item is a list item, add it to the list of items
if (isItem) {
items.push(itemOrParent);
continue;
}
// If the item exposes an `item` property check if it is a list item.
const subItem = (itemOrParent as HTMLElement & {item?: Item}).item;
if (subItem && this.isItem(subItem)) {
items.push(subItem);
}
}
return items;
}
/**
* Handles keyboard navigation. Should be bound to the node that will act as
* the List.
*/
handleKeydown = (event: KeyboardEvent) => {
const key = event.key;
if (event.defaultPrevented || !this.isNavigableKey(key)) {
return;
}
// do not use this.items directly in upcoming calculations so we don't
// re-query the DOM unnecessarily
const items = this.items;
if (!items.length) {
return;
}
const activeItemRecord = getActiveItem(items, this.isActivatable);
event.preventDefault();
const isRtl = this.isRtl();
const inlinePrevious = isRtl
? NavigableKeys.ArrowRight
: NavigableKeys.ArrowLeft;
const inlineNext = isRtl
? NavigableKeys.ArrowLeft
: NavigableKeys.ArrowRight;
let nextActiveItem: Item | null = null;
switch (key) {
// Activate the next item
case NavigableKeys.ArrowDown:
case inlineNext:
nextActiveItem = activateNextItem(
items,
activeItemRecord,
this.isActivatable,
this.wrapNavigation(),
);
break;
// Activate the previous item
case NavigableKeys.ArrowUp:
case inlinePrevious:
nextActiveItem = activatePreviousItem(
items,
activeItemRecord,
this.isActivatable,
this.wrapNavigation(),
);
break;
// Activate the first item
case NavigableKeys.Home:
nextActiveItem = activateFirstItem(items, this.isActivatable);
break;
// Activate the last item
case NavigableKeys.End:
nextActiveItem = activateLastItem(items, this.isActivatable);
break;
default:
break;
}
if (
nextActiveItem &&
activeItemRecord &&
activeItemRecord.item !== nextActiveItem
) {
// If a new item was activated, remove the tabindex of the previous
// activated item.
activeItemRecord.item.tabIndex = -1;
}
};
/**
* Activates the next item in the list. If at the end of the list, the first
* item will be activated.
*
* @return The activated list item or `null` if there are no items.
*/
activateNextItem(): Item | null {
const items = this.items;
const activeItemRecord = getActiveItem(items, this.isActivatable);
if (activeItemRecord) {
activeItemRecord.item.tabIndex = -1;
}
return activateNextItem(
items,
activeItemRecord,
this.isActivatable,
this.wrapNavigation(),
);
}
/**
* Activates the previous item in the list. If at the start of the list, the
* last item will be activated.
*
* @return The activated list item or `null` if there are no items.
*/
activatePreviousItem(): Item | null {
const items = this.items;
const activeItemRecord = getActiveItem(items, this.isActivatable);
if (activeItemRecord) {
activeItemRecord.item.tabIndex = -1;
}
return activatePreviousItem(
items,
activeItemRecord,
this.isActivatable,
this.wrapNavigation(),
);
}
/**
* Listener to be bound to the `deactivate-items` item event.
*/
onDeactivateItems = () => {
const items = this.items;
for (const item of items) {
this.deactivateItem(item);
}
};
/**
* Listener to be bound to the `request-activation` item event..
*/
onRequestActivation = (event: Event) => {
this.onDeactivateItems();
const target = event.target as Item;
this.activateItem(target);
target.focus();
};
/**
* Listener to be bound to the `slotchange` event for the slot that renders
* the items.
*/
onSlotchange = () => {
const items = this.items;
// Whether we have encountered an item that has been activated
let encounteredActivated = false;
for (const item of items) {
const isActivated = !item.disabled && item.tabIndex > -1;
if (isActivated && !encounteredActivated) {
encounteredActivated = true;
item.tabIndex = 0;
continue;
}
// Deactivate the rest including disabled
item.tabIndex = -1;
}
if (encounteredActivated) {
return;
}
const firstActivatableItem = getFirstActivatableItem(
items,
this.isActivatable,
);
if (!firstActivatableItem) {
return;
}
firstActivatableItem.tabIndex = 0;
};
}