import type { BrowserAction, BrowserActionReceipt, BrowserObservation, InteractionSemanticNode, } from "@opengeni/sdk/interaction"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useOpenGeniText } from "../messages/runtime"; type NativeOption = { value: string; label: string; selected: boolean; disabled: boolean }; type NativeSelect = { kind: "native-select"; multiple: boolean; disabled: boolean; options: NativeOption[]; }; function focusedSelect( observation: BrowserObservation, ): { node: InteractionSemanticNode; control: NativeSelect } | null { if (observation.semantic?.kind !== "snapshot") return null; const pending = [...observation.semantic.roots]; while (pending.length) { const node = pending.pop()!; if (node.children) pending.push(...node.children); if ( node.ref !== observation.focusedRef || !node.actions.includes("select") || node.native?.platform !== "dom" ) continue; const data = node.native.data as Partial | null; if ( !data || data.kind !== "native-select" || typeof data.multiple !== "boolean" || typeof data.disabled !== "boolean" || !Array.isArray(data.options) || data.options.length > 200 ) return null; if ( data.options.some( (option) => !option || typeof option.value !== "string" || typeof option.label !== "string" || typeof option.selected !== "boolean" || typeof option.disabled !== "boolean", ) ) return null; return { node, control: data as NativeSelect }; } return null; } /** Human fallback for native popup windows missing from Chromium page frames. */ export function BrowserSelectControl(props: { /** Older controllers need an explicit read to discover native popup options. */ manualFallback?: boolean; activation?: { observation: BrowserObservation | null; anchor: { x: number; y: number } | null; } | null; onDismiss?: () => void; observe: () => Promise; act: (action: BrowserAction, observation: BrowserObservation) => Promise; }) { const t = useOpenGeniText(); const [snapshot, setSnapshot] = useState(null); const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [values, setValues] = useState([]); const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null); const popupRef = useRef(null); const [position, setPosition] = useState<{ x: number; y: number; height: number } | null>(null); const panelRef = useRef(null); const request = useRef(0); // An immutable observation may contain duplicate values and labels. // Keep each option's identity stable while its selection state rerenders. const optionKeys = useRef(new WeakMap()); const nextOptionKey = useRef(0); const optionKey = (option: NativeOption) => { let key = optionKeys.current.get(option); if (key === undefined) { key = ++nextOptionKey.current; optionKeys.current.set(option, key); } return key; }; useEffect( () => () => { request.current += 1; }, [], ); useEffect(() => { if (props.activation === undefined) return; ++request.current; const observation = props.activation?.observation; const selected = observation ? focusedSelect(observation) : null; setSnapshot(selected && observation ? observation : null); setAnchor(selected ? props.activation!.anchor : null); setOpen(Boolean(selected)); setBusy(false); setError(null); setValues( selected?.control.options.filter((option) => option.selected).map((option) => option.value) ?? [], ); }, [props.activation]); useEffect(() => { if (open && !busy) panelRef.current?.focus({ preventScroll: true }); }, [open, busy]); useLayoutEffect(() => { if (!anchor || !open) { setPosition(null); return; } const place = () => { const popup = popupRef.current; const parent = popup?.parentElement; if (!popup || !parent) return; setPosition({ x: Math.max(12, Math.min(anchor.x, parent.clientWidth - popup.offsetWidth - 12)), y: Math.max(12, Math.min(anchor.y, parent.clientHeight - popup.offsetHeight - 12)), height: Math.max(48, parent.clientHeight - 60), }); }; place(); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(place); if (popupRef.current?.parentElement) observer?.observe(popupRef.current.parentElement); if (popupRef.current) observer?.observe(popupRef.current); return () => observer?.disconnect(); }, [anchor, open, busy, snapshot, error]); const selected = snapshot ? focusedSelect(snapshot) : null; const ambiguousValue = (value: string): boolean => (selected?.control.options.filter((option) => option.value === value || option.label === value) .length ?? 0) !== 1; const load = async () => { const current = ++request.current; setOpen(true); setBusy(true); setSnapshot(null); setError(null); setAnchor(null); try { const observation = await props.observe(); if (current !== request.current) return; setSnapshot(observation); setValues( focusedSelect(observation) ?.control.options.filter((option) => option.selected) .map((option) => option.value) ?? [], ); } catch (cause) { if (current === request.current) setError( cause instanceof Error ? cause.message : t("browserSelectControl.couldNotReadOptions"), ); } finally { if (current === request.current) setBusy(false); } }; const apply = async (next: string[]) => { if (!snapshot || !selected || busy) return; const current = ++request.current; setBusy(true); setError(null); try { const receipt = await props.act( { type: "select", locator: { kind: "ref", ref: selected.node.ref }, values: next }, snapshot, ); if (receipt.state !== "completed") throw new Error( receipt.error?.message ?? t("browserSelectControl.selectionDidNotComplete"), ); if (current === request.current) { setOpen(false); setSnapshot(null); props.onDismiss?.(); } } catch (cause) { if (current === request.current) { setError( cause instanceof Error ? cause.message : t("browserSelectControl.selectionFailedReadTheOptions"), ); setSnapshot(null); // Never replay an uncertain action. } } finally { if (current === request.current) setBusy(false); } }; if (props.manualFallback === false && !open) return null; return (
{(props.manualFallback !== false && (!anchor || !open)) || error ? ( ) : null} {open ? (
{ if (event.key !== "Escape") return; event.preventDefault(); event.stopPropagation(); ++request.current; setOpen(false); setSnapshot(null); setBusy(false); props.onDismiss?.(); }} className="mt-1 max-h-72 w-72 max-w-full overflow-auto rounded-og-sm border border-og-border bg-og-bg p-3 text-og-fg shadow-lg" >
{selected?.node.name || t("browserSelectControl.pageOptions")}
{busy ? (

{t("browserSelectControl.loading")}

) : error ? (

{error}

) : !selected ? (

{t("browserSelectControl.clickADropdownInThe")}

) : selected.control.disabled ? (

{t("browserSelectControl.thisControlIsDisabled")}

) : ( <> {selected.control.options.map((option) => { // Existing select actions accept value OR label. Refuse collisions // rather than choosing an unintended duplicate or another label. const ambiguous = ambiguousValue(option.value); const disabled = busy || option.disabled || ambiguous; return selected.control.multiple ? ( ) : ( ); })} {selected.control.multiple ? ( ) : null} )}
) : null}
); }