Skip to content

Instantly share code, notes, and snippets.

@jpzwarte
Created May 21, 2021 09:19
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jpzwarte/ab71c87fa9464b63023224a24fb22810 to your computer and use it in GitHub Desktop.
Save jpzwarte/ab71c87fa9464b63023224a24fb22810 to your computer and use it in GitHub Desktop.
Lit2 controllers for keyboard interaction
import { ListKeyController, ListKeyControllerOption } from './list-key-controller';
export interface FocusableOption extends ListKeyControllerOption {
/** Focuses the `FocusableOption`. */
focus(): void;
}
export class FocusKeyController<T> extends ListKeyController<FocusableOption & T> {
setActiveItem(index: number): void {
super.setActiveItem(index);
this.activeItem?.focus();
}
}
import { LitElement, ReactiveController } from 'lit';
/** This interface is for items that can be passed to a ListKeyManager. */
export interface ListKeyControllerOption extends Element {
/** Whether the option is disabled. */
disabled?: boolean;
/** Gets the label for this option. */
getLabel?(): string;
}
/** Modifier keys handled by the ListKeyManager. */
export type ListKeyControllerModifierKey = 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey';
export class ListKeyController<T extends ListKeyControllerOption> implements ReactiveController {
#activeItem: T | null = null;
#activeItemIndex = -1;
#allowedModifierKeys: ListKeyControllerModifierKey[] = [];
#homeAndEnd = false;
#horizontal?: 'ltr' | 'rtl' | null;
#items: T[] = [];
#skipPredicateFn = (item: T): boolean => !!item.disabled;
#vertical = true;
#wrap = false;
get activeItem(): T | null {
return this.#activeItem;
}
get activeItemIndex(): number {
return this.#activeItemIndex;
}
constructor(private host: LitElement, private selector: string) {
this.host.addController(this);
}
hostUpdated(): void {
this.#items = Array.from(this.host.querySelectorAll(this.selector));
}
/**
* Sets the predicate function that determines which items should be skipped by the
* list key manager.
* @param predicate Function that determines whether the given item should be skipped.
*/
skipPredicate(predicate: (item: T) => boolean): this {
this.#skipPredicateFn = predicate;
return this;
}
/**
* Modifier keys which are allowed to be held down and whose default actions will be prevented
* as the user is pressing the arrow keys. Defaults to not allowing any modifier keys.
*/
withAllowedModifierKeys(keys: ListKeyControllerModifierKey[]): this {
this.#allowedModifierKeys = keys;
return this;
}
/**
* Configures the key manager to activate the first and last items
* respectively when the Home or End key is pressed.
* @param enabled Whether pressing the Home or End key activates the first/last item.
*/
withHomeAndEnd(enabled = true): this {
this.#homeAndEnd = enabled;
return this;
}
/**
* Configures the key manager to move the selection horizontally.
* Passing in `null` will disable horizontal movement.
* @param direction Direction in which the selection can be moved.
*/
withHorizontalOrientation(direction: 'ltr' | 'rtl' | null): this {
this.#horizontal = direction;
return this;
}
/**
* Configures whether the key manager should be able to move the selection vertically.
* @param enabled Whether vertical selection should be enabled.
*/
withVerticalOrientation(enabled: true): this {
this.#vertical = enabled;
return this;
}
/**
* Configures wrapping mode, which determines whether the active item will wrap to
* the other end of list when there are no more items in the given direction.
* @param shouldWrap Whether the list should wrap when reaching the end.
*/
withWrap(shouldWrap = true): this {
this.#wrap = shouldWrap;
return this;
}
onKeydown(event: KeyboardEvent): void {
const modifiers: ListKeyControllerModifierKey[] = ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'],
isModifierAllowed = modifiers.every(m => !event[m] || this.#allowedModifierKeys.includes(m));
switch (event.key) {
case 'ArrowDown':
if (this.#vertical && isModifierAllowed) {
this.setNextItemActive();
break;
} else {
return;
}
case 'ArrowLeft':
if (this.#horizontal && isModifierAllowed) {
this.setPreviousItemActive();
break;
} else {
return;
}
case 'ArrowRight':
if (this.#horizontal && isModifierAllowed) {
this.setNextItemActive();
break;
} else {
return;
}
case 'ArrowUp':
if (this.#vertical && isModifierAllowed) {
this.setPreviousItemActive();
break;
} else {
return;
}
case 'End':
if (this.#homeAndEnd && isModifierAllowed) {
this.setLastItemActive();
break;
} else {
return;
}
case 'Home':
if (this.#homeAndEnd && isModifierAllowed) {
this.setFirstItemActive();
break;
} else {
return;
}
}
event.preventDefault();
}
/**
* Sets the active item to the specified item.
* @param item The item, or index of the item, to be set as active.
*/
setActiveItem(item: number | T): void {
this.updateActiveItem(item);
this.host.requestUpdate();
}
/** Sets the active item to the first enabled item in the list. */
setFirstItemActive(): void {
this.#setActiveItemByIndex(0, 1);
}
/** Sets the active item to the next enabled item in the list. */
setNextItemActive(): void {
this.activeItemIndex < 0 ? this.setFirstItemActive() : this.#setActiveItemByDelta(1);
}
/** Sets the active item to a previous enabled item in the list. */
setPreviousItemActive(): void {
this.activeItemIndex < 0 && this.#wrap ? this.setLastItemActive() : this.#setActiveItemByDelta(-1);
}
/** Sets the active item to the last enabled item in the list. */
setLastItemActive(): void {
this.#setActiveItemByIndex(this.#items.length - 1, -1);
}
/**
* Allows setting the active item without any other effects.
* @param item Item, or the index of the item, to be set as active.
*/
updateActiveItem(item: number | T): void {
const index = typeof item === 'number' ? item : this.#items.indexOf(item),
activeItem = this.#items[index];
// Explicitly check for `null` and `undefined` because other falsy values are valid.
this.#activeItem = activeItem;
this.#activeItemIndex = index;
}
/**
* Sets the active item properly given the default mode. In other words, it will
* continue to move down the list until it finds an item that is not disabled. If
* it encounters either end of the list, it will stop and not wrap.
*/
#setActiveInDefaultMode(delta: -1 | 1): void {
this.#setActiveItemByIndex(this.activeItemIndex + delta, delta);
}
/**
* Sets the active item properly given "wrap" mode. In other words, it will continue to move
* down the list until it finds an item that is not disabled, and it will wrap if it
* encounters either end of the list.
*/
#setActiveInWrapMode(delta: -1 | 1): void {
for (let i = 1; i <= this.#items.length; i++) {
const index = (this.activeItemIndex + delta * i + this.#items.length) % this.#items.length,
item = this.#items[index];
if (!this.#skipPredicateFn(item)) {
this.setActiveItem(index);
return;
}
}
}
/**
* This method sets the active item, given a list of items and the delta between the
* currently active item and the new active item. It will calculate differently
* depending on whether wrap mode is turned on.
*/
#setActiveItemByDelta(delta: -1 | 1): void {
this.#wrap ? this.#setActiveInWrapMode(delta) : this.#setActiveInDefaultMode(delta);
}
/**
* Sets the active item to the first enabled item starting at the index specified. If the
* item is disabled, it will move in the fallbackDelta direction until it either
* finds an enabled item or encounters the end of the list.
*/
#setActiveItemByIndex(index: number, fallbackDelta: -1 | 1): void {
if (!this.#items[index]) {
return;
}
while (this.#skipPredicateFn(this.#items[index])) {
index += fallbackDelta;
if (!this.#items[index]) {
return;
}
}
this.setActiveItem(index);
}
}
/** Controller that handles keyboard focus. */
#keyController: FocusKeyController<MenuItem>;
#onKeydown = (event: KeyboardEvent): void => {
const { activeItem } = this.#keyController,
{ key } = event;
if ([' ', 'Enter'].includes(key)) {
activeItem?.toggle();
event.preventDefault();
} else {
this.#keyController.onKeydown(event);
}
};
this.#keyController = new FocusKeyController<MenuItem>(this, ':scope > dna-menu-item').withWrap();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment