@clayui/shared
Version:
ClayShared component
251 lines (249 loc) • 9.48 kB
JavaScript
/**
* 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);
}
}