@lobehub/ui
Version:
Lobe UI is an open-source UI component library for building AIGC web apps
256 lines (255 loc) • 9.35 kB
JavaScript
"use client";
import { usePointerScrollGuard } from "./VirtualScrollArea.mjs";
import { Children, cloneElement, isValidElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
//#region src/base-ui/virtual/useMenuVirtualList.tsx
const LIST_ITEM_SELECTOR = "[role^=\"menuitem\"], [role=\"option\"]";
const INDEX_ATTRIBUTE = "data-virtual-index";
const MAX_JUMP_FRAMES = 60;
const flattenText = (node) => {
if (node == null || typeof node === "boolean") return "";
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(flattenText).join("");
if (isValidElement(node)) return flattenText(node.props.children);
return "";
};
const readEntry = (child, index, getItemLabel) => {
if (!isValidElement(child)) return {
disabled: true,
label: ""
};
const props = child.props;
const label = getItemLabel?.(child, index) ?? props.label ?? props["aria-label"] ?? flattenText(props.children);
return {
disabled: Boolean(props.disabled) || props["aria-disabled"] === true || props["aria-disabled"] === "true",
label: label.trim()
};
};
const findMatch = (entries, query, startIndex) => {
if (entries.length === 0) return -1;
const lowerQuery = query.toLowerCase();
const start = (startIndex % entries.length + entries.length) % entries.length;
for (let offset = 0; offset < entries.length; offset += 1) {
const index = (start + offset) % entries.length;
const entry = entries[index];
if (!entry.disabled && entry.label.toLowerCase().startsWith(lowerQuery)) return index;
}
return -1;
};
const findEdge = (entries, fromEnd) => {
if (fromEnd) {
for (let index = entries.length - 1; index >= 0; index -= 1) if (!entries[index].disabled) return index;
return -1;
}
return entries.findIndex((entry) => !entry.disabled);
};
function useMenuVirtualList({ children, enabled, getItemLabel, keepMounted, typeaheadResetMs = 500 }) {
const viewportRef = useRef(null);
const virtualizerRef = useRef(null);
const focusedItemRef = useRef(null);
const activeIndexRef = useRef(null);
const [activeIndex, setActiveIndex] = useState(null);
const jumpTargetRef = useRef(null);
const [jumpTarget, setJumpTarget] = useState(null);
const { pointerScrollRef, scrollGuardProps } = usePointerScrollGuard();
const entries = useMemo(() => enabled ? Children.map(children, (child, index) => readEntry(child, index, getItemLabel)) ?? [] : [], [
children,
enabled,
getItemLabel
]);
const guardScrollIntoView = useCallback((item) => {
if (Object.hasOwn(item, "scrollIntoView")) return;
item.scrollIntoView = (...args) => {
if (!pointerScrollRef.current) HTMLElement.prototype.scrollIntoView.call(item, ...args);
};
}, [pointerScrollRef]);
const parkFocus = useCallback(() => {
const viewport = viewportRef.current;
if (viewport && !viewport.contains(document.activeElement)) viewport.focus({ preventScroll: true });
}, []);
const settleScrollRef = useRef(-1);
const focusJumpTarget = useCallback(() => {
const index = jumpTargetRef.current;
const viewport = viewportRef.current;
if (index === null || !viewport) return false;
parkFocus();
const scrollTop = viewport.scrollTop;
const settled = scrollTop === settleScrollRef.current;
settleScrollRef.current = scrollTop;
if (!settled) return false;
const child = viewport.querySelector(`[${INDEX_ATTRIBUTE}="${index}"]`);
const target = child && (child.matches(LIST_ITEM_SELECTOR) ? child : child.querySelector(LIST_ITEM_SELECTOR));
if (!target) return false;
guardScrollIntoView(target);
focusedItemRef.current = target;
target.focus({ preventScroll: true });
return true;
}, [guardScrollIntoView, parkFocus]);
useEffect(() => {
const viewport = viewportRef.current;
if (!enabled || !viewport) return;
const observer = new MutationObserver(() => {
if (jumpTargetRef.current !== null) {
focusJumpTarget();
return;
}
const focusedItem = focusedItemRef.current;
const active = document.activeElement;
if (focusedItem && !focusedItem.isConnected && active === document.body) {
focusedItemRef.current = null;
viewport.focus({ preventScroll: true });
return;
}
if (focusedItem && active === focusedItem) focusedItem.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
});
observer.observe(viewport, {
childList: true,
subtree: true
});
return () => observer.disconnect();
}, [enabled, focusJumpTarget]);
const jumpFrameRef = useRef(null);
useEffect(() => {
return () => {
if (jumpFrameRef.current !== null) cancelAnimationFrame(jumpFrameRef.current);
};
}, []);
const jumpTo = useCallback((index) => {
activeIndexRef.current = index;
jumpTargetRef.current = index;
setActiveIndex(index);
setJumpTarget(index);
viewportRef.current?.focus({ preventScroll: true });
settleScrollRef.current = -1;
virtualizerRef.current?.scrollToIndex(index, { align: "nearest" });
if (jumpFrameRef.current !== null) cancelAnimationFrame(jumpFrameRef.current);
let frames = 0;
const kick = () => {
jumpFrameRef.current = null;
focusJumpTarget();
frames += 1;
if (jumpTargetRef.current !== null && frames < MAX_JUMP_FRAMES) jumpFrameRef.current = requestAnimationFrame(kick);
else if (jumpTargetRef.current !== null) {
jumpTargetRef.current = null;
setJumpTarget(null);
}
};
jumpFrameRef.current = requestAnimationFrame(kick);
}, [focusJumpTarget]);
const queryRef = useRef("");
const lastMatchRef = useRef(null);
const resetTimeoutRef = useRef(null);
const resetQuery = useCallback(() => {
queryRef.current = "";
if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
resetTimeoutRef.current = null;
}, []);
useEffect(() => {
return () => {
if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
};
}, []);
const handleKeyDownCapture = useCallback((event) => {
if (!enabled || entries.length === 0) return;
if (event.target instanceof HTMLElement && event.target.matches("input, textarea")) return;
if (event.key === "Home" || event.key === "End") {
const fromEnd = event.key === "End";
const index = findEdge(entries, fromEnd);
if (index === -1) return;
event.preventDefault();
event.stopPropagation();
resetQuery();
jumpTo(index);
return;
}
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return;
if (event.key === " " && queryRef.current === "") return;
event.preventDefault();
event.stopPropagation();
const isNewSession = queryRef.current === "";
if (entries.every((entry) => entry.disabled || entry.label[0]?.toLowerCase() !== entry.label[1]?.toLowerCase()) && queryRef.current === event.key) queryRef.current = "";
queryRef.current += event.key;
if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
resetTimeoutRef.current = setTimeout(resetQuery, typeaheadResetMs);
const previous = isNewSession || queryRef.current.length === 1 ? activeIndexRef.current ?? lastMatchRef.current ?? -1 : lastMatchRef.current ?? activeIndexRef.current ?? -1;
const startIndex = queryRef.current.length === 1 ? previous + 1 : previous;
const index = findMatch(entries, queryRef.current, startIndex);
if (index !== -1) {
lastMatchRef.current = index;
jumpTo(index);
} else if (event.key !== " ") resetQuery();
}, [
enabled,
entries,
jumpTo,
resetQuery,
typeaheadResetMs
]);
const virtualChildren = useMemo(() => {
if (!enabled) return children;
return Children.map(children, (child, index) => {
if (!isValidElement(child)) return child;
const { onFocus } = child.props;
return cloneElement(child, {
[INDEX_ATTRIBUTE]: index,
onFocus: (event) => {
onFocus?.(event);
const item = event.target.closest(LIST_ITEM_SELECTOR);
if (item && event.currentTarget.contains(item)) {
focusedItemRef.current = item;
guardScrollIntoView(item);
}
activeIndexRef.current = index;
setActiveIndex(index);
if (jumpTargetRef.current === index) {
jumpTargetRef.current = null;
setJumpTarget(null);
}
}
});
});
}, [
children,
enabled,
guardScrollIntoView
]);
const keepMountedIndices = useMemo(() => {
if (!enabled) return void 0;
const count = Children.count(children);
const indices = new Set(keepMounted);
if (activeIndex !== null) indices.add(activeIndex);
if (jumpTarget !== null) indices.add(jumpTarget);
const inRange = [...indices].filter((index) => index >= 0 && index < count);
return inRange.length ? inRange : void 0;
}, [
activeIndex,
children,
enabled,
jumpTarget,
keepMounted
]);
const handleBlur = useCallback((event) => {
if (jumpTargetRef.current === null) return;
const viewport = viewportRef.current;
if (!viewport) return;
if (event.relatedTarget && viewport.contains(event.relatedTarget)) return;
viewport.focus({ preventScroll: true });
}, []);
return {
keepMountedIndices,
viewportProps: useMemo(() => ({
...scrollGuardProps,
onBlur: handleBlur,
onKeyDownCapture: handleKeyDownCapture
}), [
handleBlur,
handleKeyDownCapture,
scrollGuardProps
]),
viewportRef,
virtualChildren,
virtualizerRef
};
}
//#endregion
export { findEdge, findMatch, flattenText, useMenuVirtualList };
//# sourceMappingURL=useMenuVirtualList.mjs.map