< Summary

Information
Class: tui-combobox.tsx
Assembly: app.features.items
File(s): /home/runner/work/ClutterStock/ClutterStock/frontend/app/features/items/tui-combobox.tsx
Tag: 58_25416222083
Line coverage
0%
Covered lines: 0
Uncovered lines: 67
Coverable lines: 67
Total lines: 155
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 54
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/ClutterStock/ClutterStock/frontend/app/features/items/tui-combobox.tsx

#LineLine coverage
 1import { useEffect, useId, useRef, useState } from "react";
 2
 3export type TuiComboboxOption =
 4  | { kind: "group"; label: string }
 5  | { kind: "option"; label: string; value: number };
 6
 07export function TuiCombobox({ name, value, options, onChange, disabled, ariaLabel }: {
 8  name: string;
 9  value: number | null;
 10  options: TuiComboboxOption[];
 11  onChange: (v: number) => void;
 12  disabled?: boolean;
 13  ariaLabel?: string;
 14}) {
 015  const id = useId();
 016  const [open, setOpen] = useState(false);
 017  const [highlightedValue, setHighlightedValue] = useState<number | null>(value);
 018  const rootRef = useRef<HTMLDivElement>(null);
 019  const listRef = useRef<HTMLUListElement>(null);
 20
 021  const flatOptions = options.filter((o): o is Extract<TuiComboboxOption, { kind: "option" }> => o.kind === "option");
 022  const current = flatOptions.find(o => o.value === value);
 23
 024  useEffect(() => {
 025    if (!open) return;
 026    function onDocPointer(e: MouseEvent) {
 027      if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
 28    }
 029    document.addEventListener("mousedown", onDocPointer);
 030    return () => document.removeEventListener("mousedown", onDocPointer);
 31  }, [open]);
 32
 033  useEffect(() => {
 034    if (!open || highlightedValue == null) return;
 035    const el = listRef.current?.querySelector<HTMLElement>(`[data-value="${highlightedValue}"]`);
 036    el?.scrollIntoView({ block: "nearest" });
 37  }, [open, highlightedValue]);
 38
 039  function openDropdown() {
 040    setHighlightedValue(value ?? flatOptions[0]?.value ?? null);
 041    setOpen(true);
 42  }
 43
 044  function moveHighlight(delta: number) {
 045    if (flatOptions.length === 0) return;
 046    const idx = flatOptions.findIndex(o => o.value === highlightedValue);
 047    const nextIdx = idx < 0
 48      ? (delta > 0 ? 0 : flatOptions.length - 1)
 49      : (idx + delta + flatOptions.length) % flatOptions.length;
 050    setHighlightedValue(flatOptions[nextIdx]!.value);
 51  }
 52
 053  function onKeyDown(e: React.KeyboardEvent) {
 054    if (disabled) return;
 55
 056    if (!open) {
 057      if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
 058        e.preventDefault();
 059        e.stopPropagation();
 060        openDropdown();
 61      }
 062      return;
 63    }
 64
 65    // Open: trap navigation keys so global shortcuts don't fire
 066    if (e.key === "ArrowDown" || e.key === "j") { e.preventDefault(); e.stopPropagation(); moveHighlight(1); return; }
 067    if (e.key === "ArrowUp"   || e.key === "k") { e.preventDefault(); e.stopPropagation(); moveHighlight(-1); return; }
 068    if (e.key === "Home")    { e.preventDefault(); e.stopPropagation(); setHighlightedValue(flatOptions[0]?.value ?? nul
 069    if (e.key === "End")     { e.preventDefault(); e.stopPropagation(); setHighlightedValue(flatOptions[flatOptions.leng
 070    if (e.key === "Enter")   {
 071      e.preventDefault();
 072      e.stopPropagation();
 073      if (highlightedValue != null) onChange(highlightedValue);
 074      setOpen(false);
 075      return;
 76    }
 077    if (e.key === "Escape") {
 078      e.preventDefault();
 079      e.stopPropagation();
 080      setOpen(false);
 081      return;
 82    }
 083    if (e.key === "Tab") {
 84      // Let tab move focus naturally, but close the list first
 085      setOpen(false);
 086      return;
 87    }
 88  }
 89
 090  return (
 91    <div className="tui-combobox" ref={rootRef} data-open={open ? "true" : undefined}>
 92      <button
 93        type="button"
 94        id={id}
 95        aria-haspopup="listbox"
 96        aria-expanded={open}
 97        aria-label={ariaLabel}
 98        disabled={disabled}
 99        className="tui-combobox-trigger"
 0100        onClick={() => {
 0101          if (disabled) return;
 0102          if (open) setOpen(false); else openDropdown();
 103        }}
 104        onKeyDown={onKeyDown}
 105      >
 106        <span className="tui-edit-bracket">[</span>
 107        <span className="tui-combobox-arrow">{open ? "▲" : "▼"}</span>
 108        <span className="tui-combobox-value">{current?.label ?? "—"}</span>
 109        <span className="tui-edit-bracket">]</span>
 110      </button>
 111
 112      {open && (
 113        <ul
 114          role="listbox"
 115          aria-labelledby={id}
 116          className="tui-combobox-list"
 117          ref={listRef}
 118        >
 0119          {options.map((opt, i) => {
 0120            if (opt.kind === "group") {
 0121              return (
 122                <li key={`g-${i}`} className="tui-combobox-group">── {opt.label} ──</li>
 123              );
 124            }
 0125            const sel = opt.value === value;
 0126            const hi = opt.value === highlightedValue;
 0127            return (
 128              <li
 129                key={`o-${opt.value}`}
 130                role="option"
 131                aria-selected={sel}
 132                data-value={opt.value}
 133                data-highlighted={hi ? "true" : undefined}
 134                data-selected={sel ? "true" : undefined}
 135                className="tui-combobox-option"
 0136                onMouseEnter={() => setHighlightedValue(opt.value)}
 0137                onMouseDown={(e) => {
 138                  // mousedown so focus blur doesn't close before selection
 0139                  e.preventDefault();
 0140                  onChange(opt.value);
 0141                  setOpen(false);
 142                }}
 143              >
 144                <span className="tui-combobox-arrow-cell">{sel ? "▶" : " "}</span>
 145                <span>{opt.label}</span>
 146              </li>
 147            );
 148          })}
 149        </ul>
 150      )}
 151
 152      <input type="hidden" name={name} value={value ?? ""} />
 153    </div>
 154  );
 155}