UNPKG

@clayui/shared

Version:

ClayShared component

251 lines (249 loc) 9.48 kB
/** * SPDX-FileCopyrightText: © 2022 Liferay, Inc. <https://liferay.com> * SPDX-License-Identifier: BSD-3-Clause */ import { useCallback, useEffect, useRef } from 'react'; import { Keys } from "./Keys.js"; import { FOCUSABLE_ELEMENTS, isFocusable } from "./useFocusManagement.js"; // TODO: To avoid circular dependency we are just copying but we must remove this // when moving this into the core package. const verticalKeys = [Keys.Up, Keys.Down, Keys.Home, Keys.End]; const horizontalKeys = [Keys.Left, Keys.Right, Keys.Home, Keys.End]; export function useNavigation(_ref) { let { activation = 'manual', active, collection, containerRef, focusableElements = FOCUSABLE_ELEMENTS, loop = false, onNavigate, orientation = 'horizontal', typeahead = false, visible = false } = _ref; const timeoutIdRef = useRef(); const stringRef = useRef(''); const prevIndexRef = useRef(-1); const matchIndexRef = useRef(null); // An event can be scheduled when the content is not visible in the DOM, it // will be executed in sequence after the element is visible in the DOM. const pendingEventStack = useRef([]); useEffect(() => { if (!visible) { clearTimeout(timeoutIdRef.current); matchIndexRef.current = null; stringRef.current = ''; } }, [visible]); 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) { nextFocus.focus(); } }, 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(); pendingEventStack.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 > 0 && 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; }, 1000); const prevIndex = prevIndexRef.current; const orderedList = [...items.slice((prevIndex ?? 0) + 1), ...items.slice(0, (prevIndex ?? 0) + 1)]; item = orderedList.find(item => { const value = item instanceof HTMLElement ? item.innerText ?? item.textContent : collection?.getItem(item).value; return value?.toLowerCase().indexOf(stringRef.current.toLocaleLowerCase()) === 0; }); if (item) { // @ts-ignore 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 { element.focus(); } if (activation === 'automatic') { element.click(); } } } }, [active]); useEffect(() => { // Moves the scroll to the element with visual "focus" if it exists. 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 && pendingEventStack.current.length !== 0) { for (let index = 0; index < pendingEventStack.current.length; index++) { const event = pendingEventStack.current.shift(); onKeyDown(event); } } }, [visible]); const navigationProps = { onKeyDown }; return { accessibilityFocus, navigationProps }; } export function getFocusableList(containeRef) { let focusableElements = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 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 })); } export 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); } }