Created
May 21, 2021 09:19
-
-
Save jpzwarte/ab71c87fa9464b63023224a24fb22810 to your computer and use it in GitHub Desktop.
Lit2 controllers for keyboard interaction
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment