UNPKG

@react-md/utils

Version:
261 lines (239 loc) 8.25 kB
import type { MutableRefObject } from "react"; import { useCallback, useMemo } from "react"; import { loop } from "../../loop"; import type { BaseKeyboardSearchOptions, SearchData, } from "../../search/useKeyboardSearch"; import { useKeyboardSearch } from "../../search/useKeyboardSearch"; import { DEFAULT_GET_ITEM_VALUE, DEFAULT_VALUE_KEY } from "../../search/utils"; import type { MovementConfig } from "./types"; import { getKeyboardConfig, getStringifiedKeyConfig, transformKeys, } from "./utils"; export type MovementHandler<E extends HTMLElement> = React.KeyboardEventHandler<E>; /** * A mutable ref object that must be applied to each DOM node within the * "focusable"/"searchable" list of elements so that custom focus behavior can * be triggered. * * @typeParam E - the element type of each item within the focusable list. */ export type ItemRef<E extends HTMLElement> = MutableRefObject<E | null>; export type ItemRefList<E extends HTMLElement = HTMLElement> = readonly ItemRef<E>[]; export interface BaseKeyboardMovementOptions< D = unknown, CE extends HTMLElement = HTMLElement, IE extends HTMLElement = HTMLElement > extends Omit<BaseKeyboardSearchOptions<D, CE>, "onChange">, MovementConfig { /** * Boolean if the event should trigger `event.stopPropagation()` when the * custom keyboard movement is triggered. This should generally be kept as * `false` or `undefined` by default, but enabled when creating more complex * 2-dimensional movement cases such as grids. */ stopPropagation?: boolean; /** * A required change event handler that will be called whenever a user types a * letter and it causes a new item to be "found". This should normally be * something that either updates the `aria-activedescendant` id to the new * found item's id or manually focus the item's DOM node. */ onChange?: (data: SearchData<D, CE>, itemRefs: ItemRefList<IE>) => void; } /** * The options for custom keyboard movement. * * @typeParam D - the type of each item within the item list * @typeParam CE - the type of the DOM element for the keyboard event handler. * @typeParam IE - the type of the DOM element for the keyboard event handler. */ export interface KeyboardMovementOptions< D = unknown, CE extends HTMLElement = HTMLElement, IE extends HTMLElement = HTMLElement > extends BaseKeyboardMovementOptions<D, CE, IE> { /** * The currently focused index within the item list. This will need to be * updated due to the `onChange` callback being called for this hook to work * as it is fully "controlled" by a parent hook/component. */ focusedIndex: number; /** * A required change event handler that will be called whenever a user types a * letter and it causes a new item to be "found". This should normally be * something that either updates the `aria-activedescendant` id to the new * found item's id or manually focus the item's DOM node. */ onChange: (data: SearchData<D, CE>, itemRefs: ItemRefList<IE>) => void; } /** * Returns an ordered list with two items: * * - itemRefs * - onKeyDown event handler * * @typeParam CE - The HTMLElement type of the container element that handles * the custom keyboard movement. * @typeParam IE - The HTMLElement type of each item within the container * element that can be focusable. */ export type KeyboardMovementProviders< CE extends HTMLElement, IE extends HTMLElement > = [ /** * A list of mutable ref objects that must be applied to each focusable item * within the list. This list will automatically be generated based on the * number of items provided to the `useKeyboardMovement` hook */ ItemRefList<IE>, /** * The keydown event handler to apply to a "container" element that has custom * keyboard focus. */ MovementHandler<CE> ]; /** * This is a low-level hook for providing custom keyboard movement based on key * configurations. This normally shouldn't really be used externally since * you'll most likely want to use the "presets" of `useFocusMovement` and * `useActiveDescendantMovement` that implement the main movement types already * for you. * * The way this works is that it will general a list of mutable item refs that * should be applied to each DOM node for the corresponding `item` within the * `items` list. This list will change and regenerate itself each time the * `items` array changes so it'll always be in-sync with the DOM nodes. This * means that if you have some items that **should not be rendered**, they * should not be included within the items list. The main reason these item refs * are required is so that the `aria-activedescendant` movement can scroll the * new "focused" element into view if needed while the "true" focus movement can * trigger a `ref.current.focus()` on the new item as needed. * * Finally, this will create a keydown event handler that will merge in the * optionally provided `onKeyDown` prop and check if the pressed key should * trigger a custom keyboard movement event. If it does, an `onChange` event * will be fired with the matching data and allows for custom movement with * `target.focus()` or updating the `aria-activedescendant` attribute as needed. * * @typeParam D - The type of each data item within the items list. * @typeParam CE - The HTMLElement type of the container element that handles * the custom keyboard movement. * @typeParam IE - The HTMLElement type of each item within the container * element that can be focusable. */ export function useKeyboardMovement< D = unknown, CE extends HTMLElement = HTMLElement, IE extends HTMLElement = HTMLElement >({ onKeyDown, incrementKeys, decrementKeys, jumpToFirstKeys, jumpToLastKeys, stopPropagation = true, onChange, items, resetTime, findMatchIndex, focusedIndex, loopable = true, searchable = true, valueKey = DEFAULT_VALUE_KEY, getItemValue = DEFAULT_GET_ITEM_VALUE, }: KeyboardMovementOptions<D, CE, IE>): KeyboardMovementProviders<CE, IE> { const keys = useMemo( () => [ ...transformKeys(incrementKeys, "increment"), ...transformKeys(decrementKeys, "decrement"), ...transformKeys(jumpToFirstKeys, "first"), ...transformKeys(jumpToLastKeys, "last"), ], [incrementKeys, decrementKeys, jumpToFirstKeys, jumpToLastKeys] ); const itemRefs = useMemo<ItemRefList<IE>>( () => Array.from(items, () => ({ current: null })), [items] ); const handleSearch = useKeyboardSearch<D, CE>({ items, valueKey, getItemValue, onChange(data) { onChange(data, itemRefs); }, searchIndex: focusedIndex, resetTime, findMatchIndex, }); const handleKeyDown = useCallback<MovementHandler<CE>>( (event) => { if (searchable) { handleSearch(event); } if (onKeyDown) { onKeyDown(event); } const target = event.target as HTMLElement; const keyConfig = getKeyboardConfig(event, keys); if (!keyConfig || !target) { return; } // implementing custom behavior, so prevent default of scrolling or other // things event.preventDefault(); if (stopPropagation) { event.stopPropagation(); } const { type } = keyConfig; const lastIndex = items.length - 1; let index: number; switch (type) { case "first": index = 0; break; case "last": index = lastIndex; break; default: index = loop({ value: focusedIndex, max: lastIndex, increment: type === "increment", minmax: !loopable, }); } if (index === focusedIndex) { return; } const data: SearchData<D, CE> = { index, item: items[index], items, query: getStringifiedKeyConfig(keyConfig), target: event.currentTarget, }; onChange(data, itemRefs); }, [ onKeyDown, stopPropagation, focusedIndex, keys, items, handleSearch, loopable, searchable, onChange, itemRefs, ] ); return [itemRefs, handleKeyDown]; }