@react-md/utils
Version:
General utils for react-md.
172 lines (156 loc) • 5.5 kB
text/typescript
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import { scrollIntoView } from "../../scrollIntoView";
import { DEFAULT_GET_ITEM_VALUE, DEFAULT_VALUE_KEY } from "../../search/utils";
import type {
BaseKeyboardMovementOptions,
ItemRefList,
MovementHandler,
} from "./useKeyboardMovement";
import { useKeyboardMovement } from "./useKeyboardMovement";
import { getItemId } from "./utils";
export type ActiveDescendantId = string;
/**
*
* @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 interface ActiveDescendantMovementProviders<
CE extends HTMLElement,
IE extends HTMLElement
> {
itemRefs: ItemRefList<IE>;
onKeyDown: MovementHandler<CE>;
activeId: ActiveDescendantId;
focusedIndex: number;
setFocusedIndex: Dispatch<SetStateAction<number>>;
}
type KeyHandler<IE extends HTMLElement = HTMLElement> = (
focusedIndex: number,
itemRef: IE | null
) => void;
interface ActiveDescendantOptions<
D = unknown,
CE extends HTMLElement = HTMLElement,
IE extends HTMLElement = HTMLElement
> extends BaseKeyboardMovementOptions<D, CE, IE> {
/**
* The base id that should be used to generate the `aria-activedescendant`
* value id. This will be passed into the `getId` option.
*/
baseId: string;
/**
* The function that should generate an id based on the provided `id` and
* `index` of the item.
*/
getId?(id: string, index: number): string;
/**
* The default index that should be "focused" when the component mounts. This
* is set to `-1` by default so that it only gains a new "focused" index when
* the container element is focused.
*/
defaultFocusedIndex?: (() => number) | number;
/**
* An optional function to call when the enter key has been pressed while the
* container element has keyboard focus. This is normally used for triggering
* click events for that specific item.
*/
onEnter?: KeyHandler<IE>;
/**
* An optional function to call when the space key has been pressed while the
* container element has keyboard focus. This is normally used for triggering
* click events for that specific item and will always call
* `event.preventDefault()` to prevent the page scrolling behavior.
*/
onSpace?: KeyHandler<IE>;
}
/**
* This hook allows for custom keyboard "focus" movement using the
* `aria-activedescendant` movement pattern. This is generally used when the DOM
* focus shouldn't actually change from the container element (like listboxes)
* but you still need to indicate that another element is "focused" due to a key
* press.
*
* To use this hook, you'll want to update the container element of all the
* items to have an `aria-activedescendant={activeId}` attribute and
* `onKeyDown={onKeyDown}` that are provided by this hook. The
* `aria-activedescendant` will help screen readers known what element is
* "focused" since the container element should never really lose focus during
* these keyboard movement events. Finally, you'll want to update each item
* have an id that is the result of `getItem(baseId, index)` so that it matches
* the `aria-activedescendant` value and then apply `ref={itemRefs[i]}`.
* Unfortunately, this means that all the child items **must** either be an
* HTMLElement or the ref is forwarded down to the HTMLElement.
*
* The `itemRefs` **must** be applied so that a new "focused" item can be
* scrolled into view 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 useActiveDescendantMovement<
D = unknown,
CE extends HTMLElement = HTMLElement,
IE extends HTMLElement = HTMLElement
>({
baseId,
getId = getItemId,
defaultFocusedIndex = -1,
items,
onChange,
getItemValue = DEFAULT_GET_ITEM_VALUE,
valueKey = DEFAULT_VALUE_KEY,
onKeyDown,
onEnter,
onSpace,
...options
}: ActiveDescendantOptions<D, CE, IE>): ActiveDescendantMovementProviders<
CE,
IE
> {
const [focusedIndex, setFocusedIndex] = useState(defaultFocusedIndex);
const activeId = focusedIndex !== -1 ? getId(baseId, focusedIndex) : "";
const [itemRefs, handleKeyDown] = useKeyboardMovement<D, CE, IE>({
...options,
valueKey,
getItemValue,
focusedIndex,
items,
onChange(data, itemRefs) {
if (onChange) {
onChange(data, itemRefs);
}
const { index, target } = data;
const item = itemRefs[index] && itemRefs[index].current;
if (item && target && target.scrollHeight > target.offsetHeight) {
scrollIntoView(target, item);
}
setFocusedIndex(index);
},
onKeyDown(event) {
if (onKeyDown) {
onKeyDown(event);
}
const ref =
(itemRefs[focusedIndex] && itemRefs[focusedIndex].current) || null;
if (onEnter && event.key === "Enter") {
onEnter(focusedIndex, ref);
} else if (onSpace && event.key === " ") {
event.preventDefault();
onSpace(focusedIndex, ref);
}
},
});
return {
activeId,
itemRefs,
onKeyDown: handleKeyDown,
focusedIndex,
setFocusedIndex,
};
}