UNPKG

@clayui/shared

Version:
266 lines (265 loc) 9.52 kB
import { useCallback, useEffect, useRef, useState } from "react"; import { Keys } from "./Keys"; import { FOCUSABLE_ELEMENTS, isFocusable } from "./useFocusManagement"; const verticalKeys = [Keys.Up, Keys.Down, Keys.Home, Keys.End]; const horizontalKeys = [Keys.Left, Keys.Right, Keys.Home, Keys.End]; function useNavigation({ activation = "manual", active, collection, containerRef, focusableElements = FOCUSABLE_ELEMENTS, loop = false, onNavigate, orientation = "horizontal", typeahead = false, visible = false }) { const [focusedElement, setFocusedElement] = useState( null ); const timeoutIdRef = useRef(); const stringRef = useRef(""); const prevIndexRef = useRef(-1); const matchIndexRef = useRef(null); const pendingEventStackRef = useRef([]); useEffect(() => { if (!visible) { clearTimeout(timeoutIdRef.current); matchIndexRef.current = null; stringRef.current = ""; } }, [visible]); const focusElement = (element) => { element.focus(); setFocusedElement(element); }; const accessibilityFocus = useCallback( (item, items) => { const index = items ? items.indexOf(item) : null; const element = item instanceof HTMLElement ? item : document.getElementById(String(item)); if (onNavigate) { onNavigate(item, index); } if (collection?.virtualize) { const isEnd = collection.UNSAFE_virtualizer.options.count - 1 === index; const isStart = index === 0; collection.UNSAFE_virtualizer.scrollToIndex(index, { align: "auto", behavior: isStart || isEnd ? "auto" : "smooth" }); if (!onNavigate && !element) { setTimeout(() => { const nextFocus = containerRef.current.querySelector( `[data-focus="${item}"]` ); if (nextFocus) { focusElement(nextFocus); } }, 20); } return; } const child = isScrollable(containerRef.current) ? containerRef.current : containerRef.current.firstElementChild; if (isScrollable(child)) { maintainScrollVisibility(element, child); } if (!isElementInView(element)) { element.scrollIntoView({ behavior: "smooth", block: "nearest" }); } }, [] ); const onKeyDown = useCallback( (event) => { if (!containerRef.current) { event.persist(); pendingEventStackRef.current.push(event); return; } const keys = orientation === "vertical" ? verticalKeys : horizontalKeys; const alternativeKeys = orientation === "vertical" ? horizontalKeys : verticalKeys; if (keys.includes(event.key) || typeahead && !alternativeKeys.includes(event.key)) { const items = collection ? collection.getItems() : getFocusableList(containerRef, focusableElements); let item; switch (event.key) { case Keys.Left: case Keys.Right: case Keys.Down: case Keys.Up: { let position; const key = orientation === "vertical" ? Keys.Up : Keys.Left; if (collection && typeof active === "string") { position = items.indexOf( active ); if (position === -1) { item = event.key === key ? collection.getLastItem().key : collection.getFirstItem().key; } } else if (collection) { const activeElement = document.activeElement; const focusKey = activeElement.getAttribute("data-focus"); position = items.indexOf( focusKey ); if (position === -1) { item = event.key === key ? collection.getLastItem().key : collection.getFirstItem().key; } } else { const activeElement = document.activeElement; position = items.indexOf( activeElement ); if (typeof active === "string") { position = items.findIndex( (element) => element.getAttribute("id") === active ); } } if (position === -1) { break; } item = items[event.key === key ? position - 1 : position + 1]; if (loop && !item) { item = items[event.key === key ? items.length - 1 : 0]; } break; } case Keys.Home: case Keys.End: item = items[event.key === Keys.Home ? 0 : items.length - 1]; break; default: { const target = event.target; if (!typeahead || target.tagName === "INPUT" || event.key === Keys.Tab) { return; } if (event.currentTarget && !event.currentTarget.contains(target)) { return; } if (!!stringRef.current.length && stringRef.current[0] !== Keys.Spacebar) { if (event.key === Keys.Spacebar) { event.preventDefault(); event.stopPropagation(); } } if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) { return; } event.stopPropagation(); if (stringRef.current === event.key) { stringRef.current = ""; prevIndexRef.current = matchIndexRef.current; } stringRef.current += event.key; clearTimeout(timeoutIdRef.current); timeoutIdRef.current = setTimeout(() => { stringRef.current = ""; prevIndexRef.current = matchIndexRef.current; }, 1e3); const prevIndex = prevIndexRef.current; const orderedList = [ ...items.slice((prevIndex ?? 0) + 1), ...items.slice(0, (prevIndex ?? 0) + 1) ]; item = orderedList.find((item2) => { const value = item2 instanceof HTMLElement ? item2.innerText ?? item2.textContent : collection?.getItem(item2).value; return value?.toLowerCase().indexOf( stringRef.current.toLocaleLowerCase() ) === 0; }); if (item) { matchIndexRef.current = items.indexOf(item); } break; } } if (item) { event.preventDefault(); const element = item instanceof HTMLElement ? item : document.getElementById(String(item)); if (onNavigate || !element) { accessibilityFocus(item, items); } else { focusElement(element); } if (activation === "automatic") { element.click(); } } } }, [active] ); useEffect(() => { if (visible && containerRef.current && active && onNavigate && !collection?.virtualize) { const child = isScrollable(containerRef.current) ? containerRef.current : containerRef.current.firstElementChild; const activeElement = document.getElementById(String(active)); if (activeElement && isScrollable(child)) { maintainScrollVisibility(activeElement, child); } } else if (visible && active && collection?.virtualize) { collection.UNSAFE_virtualizer.scrollToIndex( collection.getItem(active).index, { align: "center", behavior: "auto" } ); } }, [visible]); useEffect(() => { if (visible && pendingEventStackRef.current.length !== 0) { for (let index = 0; index < pendingEventStackRef.current.length; index++) { const event = pendingEventStackRef.current.shift(); onKeyDown(event); } } }, [visible]); return { accessibilityFocus, navigationFocusedElement: focusedElement, navigationProps: { onKeyDown } }; } function getFocusableList(containeRef, focusableElements = FOCUSABLE_ELEMENTS) { if (!containeRef.current) { return []; } return Array.from( containeRef.current.querySelectorAll(focusableElements.join(",")) ).filter( (element) => isFocusable({ contentEditable: element.contentEditable, disabled: element.getAttribute("disabled") !== null, offsetParent: element.offsetParent, tabIndex: 0, tagName: element.tagName }) ); } function isTypeahead(event) { return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey; } function isElementInView(element) { const bounding = element.getBoundingClientRect(); return bounding.top >= 0 && bounding.left >= 0 && bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) && bounding.right <= (window.innerWidth || document.documentElement.clientWidth); } function isScrollable(element) { return element && element.clientHeight < element.scrollHeight; } function maintainScrollVisibility(activeElement, scrollParent) { const { offsetHeight, offsetTop } = activeElement; const { offsetHeight: parentOffsetHeight, scrollTop } = scrollParent; const isAbove = offsetTop < scrollTop; const isBelow = offsetTop + offsetHeight > scrollTop + parentOffsetHeight; if (isAbove) { scrollParent.scrollTo(0, offsetTop); } else if (isBelow) { scrollParent.scrollTo(0, offsetTop - parentOffsetHeight + offsetHeight); } } export { getFocusableList, isTypeahead, useNavigation };