import { useCallback, useEffect, useRef, useState, type RefObject, } from "react"; import { createPortal } from "react-dom"; import { cn } from "@arronqzy/ui"; import { applyScopeAutocompleteSelection, buildScopeExpression, getScopeAutocompleteSuggestions, parseScopeAutocomplete, type ScopeAutocompleteState, } from "@arronqzy/blueprint-dsl"; function isAutocompleteTarget( target: EventTarget | null ): target is HTMLInputElement | HTMLTextAreaElement { if (target instanceof HTMLTextAreaElement) { return target.dataset.scopeAutocomplete !== "off"; } if (target instanceof HTMLInputElement) { if (target.dataset.scopeAutocomplete === "off") return false; const type = target.type; return ( !type || type === "text" || type === "search" || type === "url" || type === "password" ); } return false; } function setNativeInputValue( element: HTMLInputElement | HTMLTextAreaElement, value: string ) { const proto = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const descriptor = Object.getOwnPropertyDescriptor(proto, "value"); descriptor?.set?.call(element, value); element.dispatchEvent(new Event("input", { bubbles: true })); } type DropdownState = { input: HTMLInputElement | HTMLTextAreaElement; state: ScopeAutocompleteState; suggestions: string[]; activeIndex: number; rect: DOMRect; }; export function ScopeTemplateAutocompleteHost({ scope, container, containerRef, }: { scope: unknown; container?: HTMLElement | null; containerRef?: RefObject; }) { const [dropdown, setDropdown] = useState(null); const dropdownRef = useRef(null); dropdownRef.current = dropdown; const root = container ?? containerRef?.current ?? null; const closeDropdown = useCallback(() => { setDropdown(null); }, []); const refreshForInput = useCallback( (input: HTMLInputElement | HTMLTextAreaElement) => { if (scope === undefined) { closeDropdown(); return; } const cursor = input.selectionStart ?? input.value.length; const parsed = parseScopeAutocomplete(input.value, cursor, scope); if (!parsed) { closeDropdown(); return; } const suggestions = getScopeAutocompleteSuggestions(scope, parsed); if (suggestions.length === 0) { closeDropdown(); return; } setDropdown({ input, state: parsed, suggestions, activeIndex: 0, rect: input.getBoundingClientRect(), }); }, [closeDropdown, scope] ); const applySuggestion = useCallback( (index: number) => { const current = dropdownRef.current; if (!current) return; const selected = current.suggestions[index]; if (!selected) return; const { value, cursor } = applyScopeAutocompleteSelection( current.input.value, current.state, selected ); setNativeInputValue(current.input, value); current.input.focus(); current.input.setSelectionRange(cursor, cursor); closeDropdown(); window.requestAnimationFrame(() => { refreshForInput(current.input); }); }, [closeDropdown, refreshForInput] ); useEffect(() => { if (!root || scope === undefined) return; const onFocusIn = (event: FocusEvent) => { if (!isAutocompleteTarget(event.target)) return; refreshForInput(event.target); }; const onInput = (event: Event) => { if (!isAutocompleteTarget(event.target)) return; refreshForInput(event.target); }; const onKeyDown = (event: KeyboardEvent) => { const current = dropdownRef.current; if (!current || event.target !== current.input) return; if (event.key === "ArrowDown") { event.preventDefault(); setDropdown((prev) => prev ? { ...prev, activeIndex: Math.min( prev.activeIndex + 1, prev.suggestions.length - 1 ), } : prev ); return; } if (event.key === "ArrowUp") { event.preventDefault(); setDropdown((prev) => prev ? { ...prev, activeIndex: Math.max(prev.activeIndex - 1, 0) } : prev ); return; } if (event.key === "Enter" || event.key === "Tab") { if (current.suggestions.length > 0) { event.preventDefault(); applySuggestion(current.activeIndex); } return; } if (event.key === "Escape") { event.preventDefault(); closeDropdown(); } }; const onPointerDown = (event: MouseEvent) => { const target = event.target as Node | null; if ( dropdownRef.current?.input.contains(target ?? null) || (target instanceof Element && target.closest("[data-scope-ac-list]")) ) { return; } if (!root.contains(target)) { closeDropdown(); } }; root.addEventListener("focusin", onFocusIn); root.addEventListener("input", onInput, true); root.addEventListener("keydown", onKeyDown, true); window.addEventListener("pointerdown", onPointerDown, true); window.addEventListener("scroll", closeDropdown, true); window.addEventListener("resize", closeDropdown); return () => { root.removeEventListener("focusin", onFocusIn); root.removeEventListener("input", onInput, true); root.removeEventListener("keydown", onKeyDown, true); window.removeEventListener("pointerdown", onPointerDown, true); window.removeEventListener("scroll", closeDropdown, true); window.removeEventListener("resize", closeDropdown); }; }, [applySuggestion, closeDropdown, refreshForInput, root, scope]); if (!dropdown || scope === undefined) return null; return createPortal(
{dropdown.suggestions.map((key, index) => { const preview = buildScopeExpression(dropdown.state.path, key); return ( ); })}
, document.body ); }