| 1 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 2 | import { Check, ChevronsUpDown, Gauge } from "lucide-react"; |
| 3 | import { asArray } from "../lib/array"; |
| 4 | import type { EffortInfo } from "../lib/types"; |
| 5 | import { ANCHORED_POPOVER_CLOSE_MS, AnchoredPopover } from "./AnchoredPopover"; |
| 6 | |
| 7 | export function EffortSwitcher({ |
| 8 | effort, |
| 9 | disabled, |
| 10 | onPick, |
| 11 | }: { |
| 12 | effort?: EffortInfo; |
| 13 | disabled: boolean; |
| 14 | onPick: (level: string) => void; |
| 15 | }) { |
| 16 | const [open, setOpen] = useState(false); |
| 17 | const [closing, setClosing] = useState(false); |
| 18 | const triggerRef = useRef<HTMLButtonElement>(null); |
| 19 | const closeTimerRef = useRef<number | null>(null); |
| 20 | const levels = asArray(effort?.levels); |
| 21 | const current = effort?.current || "auto"; |
| 22 | |
| 23 | const clearCloseTimer = useCallback(() => { |
| 24 | if (closeTimerRef.current === null) return; |
| 25 | window.clearTimeout(closeTimerRef.current); |
| 26 | closeTimerRef.current = null; |
| 27 | }, []); |
| 28 | |
| 29 | const openMenu = useCallback(() => { |
| 30 | clearCloseTimer(); |
| 31 | setClosing(false); |
| 32 | setOpen(true); |
| 33 | }, [clearCloseTimer]); |
| 34 | |
| 35 | const closeMenu = useCallback((afterClose?: () => void) => { |
| 36 | clearCloseTimer(); |
| 37 | setClosing(true); |
| 38 | window.requestAnimationFrame(() => setOpen(false)); |
| 39 | const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 40 | closeTimerRef.current = window.setTimeout(() => { |
| 41 | closeTimerRef.current = null; |
| 42 | setClosing(false); |
| 43 | afterClose?.(); |
| 44 | }, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS); |
| 45 | }, [clearCloseTimer]); |
| 46 | |
| 47 | useEffect(() => () => clearCloseTimer(), [clearCloseTimer]); |
| 48 | |
| 49 | const pick = (level: string) => { |
| 50 | closeMenu(() => { |
| 51 | if (level !== current) onPick(level); |
| 52 | }); |
| 53 | }; |
| 54 | |
| 55 | if (!effort?.supported || levels.length === 0) return null; |
| 56 | |
| 57 | return ( |
| 58 | <div className="modelsw effortsw"> |
| 59 | <button |
| 60 | ref={triggerRef} |
| 61 | type="button" |
| 62 | className={`modelsw__trigger effortsw__trigger ${current !== "auto" ? "effortsw__trigger--explicit" : ""}`} |
| 63 | disabled={disabled} |
| 64 | aria-expanded={open && !closing} |
| 65 | onClick={() => (open || closing ? closeMenu() : openMenu())} |
| 66 | > |
| 67 | <Gauge size={14} className="modelsw__kind" /> |
| 68 | <span className="modelsw__label">{current}</span> |
| 69 | <ChevronsUpDown size={11} /> |
| 70 | </button> |
| 71 | <AnchoredPopover |
| 72 | open={open && !disabled} |
| 73 | closing={closing} |
| 74 | anchorRef={triggerRef} |
| 75 | onClose={() => closeMenu()} |
| 76 | className="modelsw__menu modelsw__menu--portal effortsw__menu" |
| 77 | align="end" |
| 78 | > |
| 79 | <div role="listbox"> |
| 80 | {levels.map((level) => ( |
| 81 | <button |
| 82 | key={level} |
| 83 | type="button" |
| 84 | role="option" |
| 85 | aria-selected={level === current} |
| 86 | className={`modelsw__item ${level === current ? "modelsw__item--current" : ""}`} |
| 87 | onClick={() => pick(level)} |
| 88 | > |
| 89 | <span className="modelsw__model">{level}</span> |
| 90 | {level === current && <Check size={13} className="modelsw__check" />} |
| 91 | </button> |
| 92 | ))} |
| 93 | </div> |
| 94 | </AnchoredPopover> |
| 95 | </div> |
| 96 | ); |
| 97 | } |
| 98 |