UNPKG

@lobehub/ui

Version:

Lobe UI is an open-source UI component library for building AIGC web apps

256 lines (255 loc) • 9.35 kB
"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