@lobehub/ui
Version:
Lobe UI is an open-source UI component library for building AIGC web apps
111 lines (110 loc) • 4.02 kB
JavaScript
import { getActiveScopeId, setLastFocusedItem } from "./store.mjs";
import { hasModifier, isItemVisible, isTextInputTarget, scopeSelector } from "./domUtils.mjs";
import { useEffect, useRef } from "react";
//#region src/base-ui/FocusScope/useScopeArrowNav.ts
let lastHandledEvent = null;
const focusListeners = /* @__PURE__ */ new Map();
const registerItemFocus = (scopeId, listener) => {
const set = focusListeners.get(scopeId) ?? /* @__PURE__ */ new Set();
set.add(listener);
focusListeners.set(scopeId, set);
return () => {
set.delete(listener);
if (set.size === 0) focusListeners.delete(scopeId);
};
};
const notifyScopeItemFocus = (scopeId, el) => {
const id = el.dataset.id;
if (id) setLastFocusedItem(scopeId, id);
focusListeners.get(scopeId)?.forEach((listener) => listener(el));
};
const getScopeRoot = (scopeId) => {
const selector = scopeSelector(scopeId);
const active = document.activeElement;
if (active instanceof HTMLElement) {
const owned = active.closest(selector);
if (owned) return owned;
}
const candidates = [...document.querySelectorAll(selector)];
return candidates.find(isItemVisible) ?? candidates[0] ?? null;
};
const focusScopeItem = (scopeId, el) => {
el.focus({ preventScroll: true });
el.scrollIntoView?.({ block: "nearest" });
notifyScopeItemFocus(scopeId, el);
};
const useScopeArrowNav = ({ scopeId, itemSelector = "[data-scope-item]", enabled = true, vimKeys = false, onItemFocus, extra }) => {
const optionsRef = useRef({
extra,
itemSelector,
onItemFocus,
scopeId
});
optionsRef.current = {
extra,
itemSelector,
onItemFocus,
scopeId
};
useEffect(() => {
if (!onItemFocus) return;
return registerItemFocus(scopeId, (el) => optionsRef.current.onItemFocus?.(el));
}, [scopeId, !!onItemFocus]);
useEffect(() => {
if (!enabled || typeof window === "undefined") return;
const getItems = () => {
const root = getScopeRoot(optionsRef.current.scopeId);
if (!root) return [];
return [...root.querySelectorAll(optionsRef.current.itemSelector)].filter(isItemVisible);
};
const currentIndex = (items) => {
const active = document.activeElement;
if (!(active instanceof HTMLElement)) return -1;
const item = active.closest(optionsRef.current.itemSelector);
return item ? items.indexOf(item) : -1;
};
const move = (direction) => {
const items = getItems();
if (items.length === 0) return;
const index = currentIndex(items);
const next = index === -1 ? direction === 1 ? 0 : items.length - 1 : (index + direction + items.length) % items.length;
focusScopeItem(optionsRef.current.scopeId, items[next]);
};
const edge = (which) => {
const items = getItems();
if (items.length === 0) return;
focusScopeItem(optionsRef.current.scopeId, items[which === "first" ? 0 : items.length - 1]);
};
const isReachable = () => {
const id = optionsRef.current.scopeId;
if (getActiveScopeId() === id) return true;
const active = document.activeElement;
return active instanceof HTMLElement && !!active.closest(scopeSelector(id));
};
const onKeyDown = (event) => {
if (event.defaultPrevented || lastHandledEvent === event) return;
if (hasModifier(event) || isTextInputTarget(event.target) || !isReachable()) return;
const builtin = {
ArrowDown: () => move(1),
ArrowUp: () => move(-1),
End: () => edge("last"),
Home: () => edge("first"),
...vimKeys && {
j: () => move(1),
k: () => move(-1)
}
};
const extraHandler = optionsRef.current.extra?.[event.key];
const handler = builtin[event.key] ?? (extraHandler && (() => extraHandler(event)));
if (!handler) return;
lastHandledEvent = event;
event.preventDefault();
handler();
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [enabled, vimKeys]);
};
//#endregion
export { focusScopeItem, getScopeRoot, notifyScopeItemFocus, useScopeArrowNav };
//# sourceMappingURL=useScopeArrowNav.mjs.map