@clayui/shared
Version:
ClayShared component
266 lines (265 loc) • 9.52 kB
JavaScript
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
};