@hilum/ui 3.8.5 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +32 -1
  2. package/dist/{chunk-RI6QJR2L.js → chunk-R576DV2A.js} +120 -14
  3. package/dist/chunk-R576DV2A.js.map +1 -0
  4. package/dist/create-theme.js +2 -1
  5. package/dist/create-theme.js.map +1 -1
  6. package/dist/index.d.ts +733 -68
  7. package/dist/index.js +3097 -1619
  8. package/dist/index.js.map +1 -1
  9. package/dist/tokens.css +163 -19
  10. package/dist/tokens.d.ts +69 -5
  11. package/dist/tokens.js +1 -1
  12. package/llms.txt +11 -4
  13. package/package.json +11 -10
  14. package/src/components/accordion.tsx +2 -2
  15. package/src/components/account-menu.tsx +1 -1
  16. package/src/components/alert-dialog.tsx +107 -0
  17. package/src/components/ask-user-questions.tsx +24 -18
  18. package/src/components/badge.tsx +36 -3
  19. package/src/components/breadcrumb.tsx +4 -2
  20. package/src/components/button-group.tsx +1 -1
  21. package/src/components/button.tsx +31 -23
  22. package/src/components/calendar.tsx +1 -1
  23. package/src/components/card.tsx +4 -2
  24. package/src/components/checkbox-card.tsx +1 -1
  25. package/src/components/checkbox-group.tsx +2 -1
  26. package/src/components/code-block.tsx +99 -0
  27. package/src/components/color-input.tsx +9 -6
  28. package/src/components/color-picker.tsx +38 -26
  29. package/src/components/combobox.tsx +59 -12
  30. package/src/components/command-palette.tsx +150 -42
  31. package/src/components/command.tsx +5 -3
  32. package/src/components/context-menu.tsx +18 -15
  33. package/src/components/contextual-save-bar.tsx +156 -0
  34. package/src/components/data-table.tsx +34 -5
  35. package/src/components/date-picker.tsx +275 -28
  36. package/src/components/date-text.tsx +90 -0
  37. package/src/components/dropdown-menu.tsx +11 -8
  38. package/src/components/dropdown.tsx +6 -3
  39. package/src/components/empty-state.tsx +85 -20
  40. package/src/components/file-dropzone.tsx +2 -1
  41. package/src/components/file-thumbnail.tsx +84 -20
  42. package/src/components/filter-bar.tsx +91 -0
  43. package/src/components/grid-list.tsx +2 -2
  44. package/src/components/help-tooltip.tsx +1 -1
  45. package/src/components/input-copy.tsx +1 -1
  46. package/src/components/input-group.tsx +1 -1
  47. package/src/components/input-message.tsx +5 -4
  48. package/src/components/input-number.tsx +103 -11
  49. package/src/components/input.tsx +5 -3
  50. package/src/components/label.tsx +1 -0
  51. package/src/components/media-asset-card.tsx +1 -1
  52. package/src/components/menu-item.tsx +37 -3
  53. package/src/components/menubar.tsx +21 -18
  54. package/src/components/native-select.tsx +1 -1
  55. package/src/components/navigation-menu.tsx +2 -2
  56. package/src/components/notification.tsx +1 -1
  57. package/src/components/pagination.tsx +97 -3
  58. package/src/components/popover.tsx +2 -0
  59. package/src/components/property-row.tsx +7 -4
  60. package/src/components/radio-card.tsx +1 -1
  61. package/src/components/radio-group.tsx +2 -1
  62. package/src/components/rating.tsx +136 -0
  63. package/src/components/resizable.tsx +1 -1
  64. package/src/components/resource-item.tsx +133 -0
  65. package/src/components/rich-text-editor.tsx +1 -0
  66. package/src/components/search-input.tsx +98 -0
  67. package/src/components/searchable-table.tsx +10 -11
  68. package/src/components/select.tsx +28 -9
  69. package/src/components/sidebar.tsx +4 -4
  70. package/src/components/slider.tsx +78 -36
  71. package/src/components/sonner.tsx +18 -4
  72. package/src/components/stat-card.tsx +121 -37
  73. package/src/components/status-badge.tsx +142 -47
  74. package/src/components/status-tile.tsx +9 -0
  75. package/src/components/switch.tsx +6 -5
  76. package/src/components/tabs-subtle.tsx +10 -2
  77. package/src/components/tabs.tsx +23 -3
  78. package/src/components/textarea.tsx +2 -3
  79. package/src/components/thinking-steps.tsx +3 -3
  80. package/src/components/toggle.tsx +14 -13
  81. package/src/components/tooltip.tsx +11 -8
  82. package/src/components/url-redirect-prompt.tsx +0 -1
  83. package/src/index.ts +15 -0
  84. package/src/lib/density-context.tsx +65 -0
  85. package/src/lib/format.tsx +301 -0
  86. package/src/lib/interaction.ts +12 -4
  87. package/src/lib/scroll-fade.tsx +39 -0
  88. package/src/tokens/tokens.test.ts +77 -0
  89. package/src/tokens/tokens.ts +114 -5
  90. package/dist/chunk-RI6QJR2L.js.map +0 -1
@@ -460,7 +460,7 @@ const PANEL_WIDTH = 280;
460
460
  const SQUARE_HEIGHT = 156;
461
461
  const CHECKER_BG: CSSProperties = {
462
462
  backgroundImage:
463
- "conic-gradient(var(--checker-a) 0 25%, var(--checker-b) 0 50%, var(--checker-a) 0 75%, var(--checker-b) 0)",
463
+ "conic-gradient(var(--checker-a, #ffffff) 0 25%, var(--checker-b, #e5e5e5) 0 50%, var(--checker-a, #ffffff) 0 75%, var(--checker-b, #e5e5e5) 0)",
464
464
  backgroundSize: "8px 8px",
465
465
  };
466
466
 
@@ -553,10 +553,12 @@ function SaturationSquare({ h, s, v, onChange }: SaturationSquareProps) {
553
553
  const thumbColor = `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
554
554
 
555
555
  return (
556
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- 2D saturation/brightness area: role=application + tabIndex with full arrow-key support (onKeyDown); no native 2D control exists
556
557
  <div
557
558
  ref={ref}
558
559
  role="application"
559
560
  aria-label="Saturation and brightness"
561
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- keyboard-operable 2D picker (see above)
560
562
  tabIndex={0}
561
563
  onFocus={(e) => {
562
564
  if (e.currentTarget.matches(":focus-visible")) setFocused(true);
@@ -576,7 +578,7 @@ function SaturationSquare({ h, s, v, onChange }: SaturationSquareProps) {
576
578
  className={cn("relative w-full select-none touch-none cursor-none outline-none", shape.bg)}
577
579
  style={{
578
580
  height: SQUARE_HEIGHT,
579
- boxShadow: focused ? "0 0 0 2px #6B97FF" : undefined,
581
+ boxShadow: focused ? "0 0 0 2px var(--ring)" : undefined,
580
582
  }}
581
583
  >
582
584
  <div
@@ -639,7 +641,8 @@ function HueSlider({ h, onChange }: { h: number; onChange: (h: number) => void }
639
641
  showValue={false}
640
642
  hideFill
641
643
  thumbColor={hueColor}
642
- thumbBorderColor="rgba(255,255,255,0.9)"
644
+ thumbBorderColor="rgba(255,255,255,0.95)"
645
+ trackSize={12}
643
646
  trackStyle={{
644
647
  background:
645
648
  "linear-gradient(to right, hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%), hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%), hsl(360,100%,50%))",
@@ -682,11 +685,11 @@ function AlphaSlider({
682
685
  showValue={false}
683
686
  hideFill
684
687
  thumbColor={solidColor}
685
- thumbBorderColor="rgba(255,255,255,0.9)"
688
+ thumbBorderColor="rgba(255,255,255,0.95)"
689
+ trackSize={12}
686
690
  trackStyle={{
687
- backgroundImage: `linear-gradient(to right, ${transparentColor} 0%, ${solidColor} 98%), conic-gradient(var(--checker-a) 0 25%, var(--checker-b) 0 50%, var(--checker-a) 0 75%, var(--checker-b) 0)`,
691
+ backgroundImage: `linear-gradient(to right, ${transparentColor} 0%, ${solidColor} 98%), conic-gradient(var(--checker-a, #ffffff) 0 25%, var(--checker-b, #e5e5e5) 0 50%, var(--checker-a, #ffffff) 0 75%, var(--checker-b, #e5e5e5) 0)`,
688
692
  backgroundSize: "100% 100%, 8px 8px",
689
- borderWidth: 0,
690
693
  }}
691
694
  aria-label="Alpha"
692
695
  />
@@ -790,7 +793,6 @@ function FormatDropdown({
790
793
  };
791
794
  const triggerRef = useRef<HTMLButtonElement>(null);
792
795
  const panelRef = useRef<HTMLDivElement>(null);
793
- const shape = useShape();
794
796
  const portalContainer = useContext(ColorPickerPortalContainerContext);
795
797
  const [pos, setPos] = useState<
796
798
  | { mode: "fixed"; top: number; left: number; width: number }
@@ -860,15 +862,15 @@ function FormatDropdown({
860
862
  aria-haspopup="menu"
861
863
  aria-expanded={open}
862
864
  className={cn(
863
- "flex items-center justify-between gap-2 h-9 px-3 text-[13px] bg-transparent hover:bg-hover hover:text-foreground transition-colors duration-80 outline-none focus-visible:ring-1 focus-visible:ring-[#6B97FF] cursor-pointer",
864
- open ? "bg-active text-foreground" : "text-muted-foreground active:bg-active",
865
- shape.input,
865
+ "flex items-center justify-between gap-1.5 h-6 min-w-[4.5rem] pl-2 pr-1.5 text-[12px] rounded-[5px] border border-border bg-background text-foreground",
866
+ "hover:border-border-strong transition-colors duration-80 outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer",
867
+ open && "border-border-strong bg-active",
866
868
  )}
867
869
  style={{ fontVariationSettings: fontWeights.medium }}
868
870
  >
869
871
  <span>{FORMAT_LABELS[value]}</span>
870
872
  <ChevronDownIcon
871
- size={14}
873
+ size={12}
872
874
  strokeWidth={1.5}
873
875
  className={cn(
874
876
  "text-muted-foreground transition-transform duration-150",
@@ -976,7 +978,6 @@ const ColorInput = forwardRef<HTMLInputElement, ColorInputProps>(
976
978
  scrubbing: boolean;
977
979
  pointerId: number;
978
980
  } | null>(null);
979
- const shape = useShape();
980
981
 
981
982
  useEffect(() => {
982
983
  if (!interactingRef.current) setDraft(value);
@@ -1075,15 +1076,16 @@ const ColorInput = forwardRef<HTMLInputElement, ColorInputProps>(
1075
1076
  onPointerUp={onWrapperPointerUp}
1076
1077
  onPointerCancel={onWrapperPointerUp}
1077
1078
  className={cn(
1078
- "flex items-center h-9 px-2 bg-transparent hover:bg-hover active:bg-active transition-colors duration-80 focus-within:ring-1 focus-within:ring-[#6B97FF] select-none",
1079
- shape.input,
1079
+ "flex h-6 min-w-0 items-center rounded-[5px] border border-border bg-background px-1.5 text-[12px] tabular-nums select-none",
1080
+ "transition-[border-color,box-shadow] duration-80 hover:border-border-strong",
1081
+ "focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/35",
1080
1082
  scrubbable && !editing && "cursor-ew-resize",
1081
1083
  className,
1082
1084
  )}
1083
1085
  style={{ width }}
1084
1086
  >
1085
1087
  {prefix && (
1086
- <span className="text-[12px] text-muted-foreground mr-1 select-none">{prefix}</span>
1088
+ <span className="text-[11px] text-muted-foreground mr-1 select-none">{prefix}</span>
1087
1089
  )}
1088
1090
  <input
1089
1091
  ref={setInputRef}
@@ -1148,7 +1150,6 @@ interface EyeDropperGlobal {
1148
1150
 
1149
1151
  function EyeDropperButton({ onPick }: { onPick: (hex: string) => void }) {
1150
1152
  const [supported, setSupported] = useState(false);
1151
- const shape = useShape();
1152
1153
  const PipetteIcon = useIcon("pipette");
1153
1154
 
1154
1155
  useEffect(() => {
@@ -1174,11 +1175,10 @@ function EyeDropperButton({ onPick }: { onPick: (hex: string) => void }) {
1174
1175
  onClick={handleClick}
1175
1176
  aria-label="Pick color from screen"
1176
1177
  className={cn(
1177
- "flex items-center justify-center h-9 px-3 text-muted-foreground bg-transparent hover:bg-hover hover:text-foreground active:bg-active transition-colors duration-80 outline-none focus-visible:ring-1 focus-visible:ring-[#6B97FF] cursor-pointer",
1178
- shape.input,
1178
+ "flex size-6 shrink-0 items-center justify-center rounded-[5px] text-muted-foreground bg-transparent hover:bg-hover hover:text-foreground active:bg-active transition-colors duration-80 outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer",
1179
1179
  )}
1180
1180
  >
1181
- <PipetteIcon size={16} strokeWidth={1.5} />
1181
+ <PipetteIcon size={14} strokeWidth={1.5} />
1182
1182
  </button>
1183
1183
  );
1184
1184
  }
@@ -1221,7 +1221,7 @@ const ColorSwatch = forwardRef<HTMLButtonElement, ColorSwatchProps>(
1221
1221
  const shape = useShape();
1222
1222
  const [hovered, setHovered] = useState(false);
1223
1223
  const ring = selected
1224
- ? "inset 0 0 0 1px rgba(127,127,127,0.25), 0 0 0 2px var(--background), 0 0 0 4px #6B97FF"
1224
+ ? "inset 0 0 0 1px rgba(127,127,127,0.25), 0 0 0 2px var(--background), 0 0 0 4px var(--ring)"
1225
1225
  : hovered
1226
1226
  ? "inset 0 0 0 1px rgba(127,127,127,0.25), 0 0 0 2px var(--background), 0 0 0 4px rgba(127,127,127,0.4)"
1227
1227
  : "inset 0 0 0 1px rgba(127,127,127,0.25)";
@@ -1278,7 +1278,8 @@ function SwatchStrip({
1278
1278
  }, [current]);
1279
1279
 
1280
1280
  return (
1281
- <div className="flex flex-wrap gap-2">
1281
+ // Fixed 8-column grid (20px swatches) so rows never end in a lone orphan.
1282
+ <div className="grid grid-cols-[repeat(8,20px)] justify-between gap-y-2 pt-1">
1282
1283
  {swatches.map((sw, i) => {
1283
1284
  const parsed = parseColor(sw);
1284
1285
  const normalized = parsed
@@ -1289,7 +1290,7 @@ function SwatchStrip({
1289
1290
  <ColorSwatch
1290
1291
  key={`${sw}-${i}`}
1291
1292
  color={sw}
1292
- size={28}
1293
+ size={20}
1293
1294
  selected={isSelected}
1294
1295
  onClick={() => onPick(sw)}
1295
1296
  />
@@ -1319,6 +1320,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
1319
1320
  hideEyedropper,
1320
1321
  formatOpen,
1321
1322
  defaultFormatOpen,
1323
+ disabled = false,
1322
1324
  className,
1323
1325
  ...props
1324
1326
  },
@@ -1456,10 +1458,15 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
1456
1458
  <div
1457
1459
  ref={ref}
1458
1460
  aria-label={ariaLabel}
1461
+ aria-disabled={disabled || undefined}
1462
+ data-disabled={disabled ? "" : undefined}
1463
+ // `inert` removes the whole panel from pointer + keyboard interaction.
1464
+ inert={disabled || undefined}
1459
1465
  className={cn(
1460
1466
  "flex flex-col gap-2 p-3",
1461
1467
  surfaceClasses(pickerLevel, 1),
1462
1468
  shape.container,
1469
+ disabled && "pointer-events-none select-none opacity-50",
1463
1470
  className,
1464
1471
  )}
1465
1472
  style={{ width: PANEL_WIDTH }}
@@ -1472,7 +1479,9 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
1472
1479
  onChange={(s, v) => updateHsv({ s, v })}
1473
1480
  />
1474
1481
 
1475
- <div className="flex flex-col [&>*]:mb-0 [&>*+*]:-mt-px">
1482
+ {/* Hue + alpha strips: slider boxes carry 8px hit slop top/bottom; the
1483
+ negative margins collapse that so the strips sit 12px apart. */}
1484
+ <div className="-my-2 flex flex-col [&>*]:mb-0 [&>*+*]:-mt-3">
1476
1485
  <HueSlider
1477
1486
  h={hsv.h}
1478
1487
  onChange={(h) => {
@@ -1490,7 +1499,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
1490
1499
  />
1491
1500
  </div>
1492
1501
 
1493
- <div className="grid grid-cols-2 gap-2">
1502
+ <div className="flex items-center justify-between gap-2">
1494
1503
  <FormatDropdown
1495
1504
  value={currentFormat}
1496
1505
  onChange={handleFormatChange}
@@ -1620,7 +1629,7 @@ function ColorInputsRow({
1620
1629
  if (format === "hex") {
1621
1630
  const hexNoHash = parsed.hex.replace(/^#/, "").toUpperCase();
1622
1631
  return (
1623
- <div className="grid grid-cols-2 gap-2">
1632
+ <div className="grid grid-cols-[minmax(0,1fr)_4rem] gap-1.5">
1624
1633
  <ChannelTooltip label="Hex">
1625
1634
  <ColorInput
1626
1635
  value={hexNoHash}
@@ -1925,11 +1934,14 @@ const ColorPickerPopover = forwardRef<HTMLDivElement, ColorPickerPopoverProps>(
1925
1934
  ref={triggerRef}
1926
1935
  type="button"
1927
1936
  onClick={() => setOpen((o) => !o)}
1937
+ disabled={pickerProps.disabled}
1928
1938
  aria-haspopup="dialog"
1929
1939
  aria-expanded={open}
1930
1940
  className={cn(
1931
- "flex items-center gap-2 h-9 px-2 border border-border bg-transparent hover:bg-hover transition-colors duration-80 outline-none focus-visible:ring-1 focus-visible:ring-[#6B97FF] cursor-pointer",
1941
+ "flex items-center gap-2 h-9 px-2 border border-border bg-transparent hover:bg-hover hover:border-border-strong transition-colors duration-80 outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer",
1942
+ "disabled:pointer-events-none disabled:opacity-50",
1932
1943
  shape.input,
1944
+ "compact:h-6 compact:gap-1.5 compact:px-1 compact:rounded-[5px]",
1933
1945
  triggerClassName,
1934
1946
  )}
1935
1947
  style={{ fontVariationSettings: fontWeights.medium }}
@@ -13,7 +13,15 @@ export interface ComboboxOption {
13
13
  avatar?: string;
14
14
  }
15
15
 
16
- interface ComboboxProps {
16
+ /** ARIA attributes the combobox manages itself and therefore doesn't accept. */
17
+ type ManagedAria =
18
+ | "aria-expanded"
19
+ | "aria-controls"
20
+ | "aria-activedescendant"
21
+ | "aria-autocomplete"
22
+ | "aria-haspopup";
23
+
24
+ interface ComboboxProps extends Omit<React.AriaAttributes, ManagedAria> {
17
25
  options: ComboboxOption[];
18
26
  value?: string;
19
27
  onValueChange?: (value: string) => void;
@@ -21,17 +29,31 @@ interface ComboboxProps {
21
29
  searchPlaceholder?: string;
22
30
  emptyText?: string;
23
31
  className?: string;
32
+ /** id of the text input (e.g. for a `<label htmlFor>`). */
33
+ id?: string;
34
+ /** Form field name. Submits the selected option's `value` via a hidden input. */
35
+ name?: string;
36
+ disabled?: boolean;
37
+ onBlur?: React.FocusEventHandler<HTMLInputElement>;
24
38
  }
25
39
 
26
- function Combobox({
27
- options,
28
- value,
29
- onValueChange,
30
- placeholder = "Select...",
31
- searchPlaceholder = "Search...",
32
- emptyText = "No results found.",
33
- className,
34
- }: ComboboxProps) {
40
+ const Combobox = React.forwardRef<HTMLInputElement, ComboboxProps>(function Combobox(
41
+ {
42
+ options,
43
+ value,
44
+ onValueChange,
45
+ placeholder = "Select...",
46
+ searchPlaceholder = "Search...",
47
+ emptyText = "No results found.",
48
+ className,
49
+ id,
50
+ name,
51
+ disabled = false,
52
+ onBlur,
53
+ ...ariaProps
54
+ },
55
+ ref,
56
+ ) {
35
57
  const shape = useShape();
36
58
  const [open, setOpen] = React.useState(false);
37
59
  const [query, setQuery] = React.useState("");
@@ -39,6 +61,7 @@ function Combobox({
39
61
  const containerRef = React.useRef<HTMLDivElement>(null);
40
62
  const inputRef = React.useRef<HTMLInputElement>(null);
41
63
  const listboxId = React.useId();
64
+ React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []);
42
65
 
43
66
  const selectedOption = options.find((o) => o.value === value);
44
67
 
@@ -56,6 +79,22 @@ function Combobox({
56
79
  setActiveIndex(-1);
57
80
  }, [filtered.length, open]);
58
81
 
82
+ // Keep the keyboard-active option visible in the scrollable list.
83
+ React.useEffect(() => {
84
+ if (!open || activeIndex < 0) return;
85
+ document
86
+ .getElementById(`${listboxId}-option-${activeIndex}`)
87
+ ?.scrollIntoView?.({ block: "nearest" });
88
+ }, [open, activeIndex, listboxId]);
89
+
90
+ // Close when disabled while open.
91
+ React.useEffect(() => {
92
+ if (disabled) {
93
+ setOpen(false);
94
+ setQuery("");
95
+ }
96
+ }, [disabled]);
97
+
59
98
  // Close on outside click
60
99
  React.useEffect(() => {
61
100
  function onMouseDown(e: MouseEvent) {
@@ -123,9 +162,12 @@ function Combobox({
123
162
  />
124
163
  )}
125
164
  <input
165
+ {...ariaProps}
126
166
  ref={inputRef}
167
+ id={id}
127
168
  type="text"
128
169
  role="combobox"
170
+ disabled={disabled}
129
171
  aria-expanded={open}
130
172
  aria-haspopup="listbox"
131
173
  aria-controls={open ? listboxId : undefined}
@@ -137,7 +179,7 @@ function Combobox({
137
179
  "flex h-10 w-full border border-border bg-background pr-10 body text-foreground",
138
180
  shape.input,
139
181
  "placeholder:text-muted-foreground",
140
- "focus-visible:border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30",
182
+ "focus-visible:border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
141
183
  "disabled:cursor-not-allowed disabled:opacity-50",
142
184
  selectedOption?.avatar && !open
143
185
  ? "pl-8"
@@ -155,11 +197,14 @@ function Combobox({
155
197
  setOpen(true);
156
198
  setQuery("");
157
199
  }}
200
+ onBlur={onBlur}
158
201
  onKeyDown={handleInputKeyDown}
159
202
  />
203
+ {name !== undefined && <input type="hidden" name={name} value={value ?? ""} />}
160
204
  <button
161
205
  type="button"
162
206
  tabIndex={-1}
207
+ disabled={disabled}
163
208
  aria-label={open ? "Close" : "Open"}
164
209
  className="absolute inset-y-0 right-0 flex w-10 items-center justify-center text-muted-foreground hover:text-muted-foreground transition-colors"
165
210
  onClick={() => {
@@ -204,6 +249,7 @@ function Combobox({
204
249
  const isSelected = option.value === value;
205
250
  const isActive = idx === activeIndex;
206
251
  return (
252
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events -- aria-activedescendant listbox: keyboard selection is handled by the input (Arrow/Enter), options are pointer targets only
207
253
  <li
208
254
  key={option.value}
209
255
  id={`${listboxId}-option-${idx}`}
@@ -267,8 +313,9 @@ function Combobox({
267
313
  )}
268
314
  </div>
269
315
  );
270
- }
316
+ });
271
317
 
272
318
  Combobox.displayName = "Combobox";
273
319
 
274
320
  export { Combobox };
321
+ export type { ComboboxProps };
@@ -2,7 +2,8 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import { Search, ArrowRight } from "lucide-react";
5
- import { Dialog, DialogContent } from "./dialog";
5
+ import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./dialog";
6
+ import { cn } from "../lib/utils";
6
7
 
7
8
  export interface CommandPaletteItem {
8
9
  id?: string | number;
@@ -20,6 +21,16 @@ interface CommandPaletteProps {
20
21
  items: CommandPaletteItem[];
21
22
  placeholder?: string;
22
23
  emptyText?: string;
24
+ /** Accessible name of the dialog (visually hidden). */
25
+ title?: string;
26
+ /** Accessible description of the dialog (visually hidden). */
27
+ description?: string;
28
+ /**
29
+ * Called for items with an `href` (instead of a full page load via
30
+ * `window.location`). Pass your router's navigate function for client-side
31
+ * routing. As before, an item's `href` takes precedence over its `onSelect`.
32
+ */
33
+ onNavigate?: (href: string) => void;
23
34
  }
24
35
 
25
36
  function CommandPalette({
@@ -28,15 +39,23 @@ function CommandPalette({
28
39
  items,
29
40
  placeholder = "Search...",
30
41
  emptyText = "No results found.",
42
+ title = "Command palette",
43
+ description = "Search for a command or page. Use the arrow keys to move and Enter to select.",
44
+ onNavigate,
31
45
  }: CommandPaletteProps) {
32
46
  const [query, setQuery] = React.useState("");
47
+ const [activeIndex, setActiveIndex] = React.useState(0);
33
48
  const inputRef = React.useRef<HTMLInputElement>(null);
49
+ const listRef = React.useRef<HTMLDivElement>(null);
50
+ const listboxId = React.useId();
51
+ const optionId = (index: number) => `${listboxId}-option-${index}`;
34
52
 
35
53
  // Focus input when opened
36
54
  React.useEffect(() => {
37
55
  if (open) {
38
56
  setTimeout(() => inputRef.current?.focus(), 50);
39
57
  setQuery("");
58
+ setActiveIndex(0);
40
59
  }
41
60
  }, [open]);
42
61
 
@@ -50,42 +69,88 @@ function CommandPalette({
50
69
  item.category?.toLowerCase().includes(query.toLowerCase()),
51
70
  );
52
71
 
53
- // Group by category
72
+ // Group by category. `ordered` is the visual (grouped) order, which is the
73
+ // order arrow keys walk through and what activeIndex indexes into.
54
74
  const grouped = filtered.reduce<Record<string, CommandPaletteItem[]>>((acc, item) => {
55
75
  const cat = item.category ?? "";
56
76
  if (!acc[cat]) acc[cat] = [];
57
77
  acc[cat].push(item);
58
78
  return acc;
59
79
  }, {});
60
-
61
80
  const categories = Object.keys(grouped);
81
+ const ordered = categories.flatMap((cat) => grouped[cat]);
82
+ const safeActive = ordered.length === 0 ? -1 : Math.min(activeIndex, ordered.length - 1);
83
+
84
+ // Keep the active option in view while arrowing through a long list.
85
+ React.useEffect(() => {
86
+ if (safeActive < 0) return;
87
+ const el = listRef.current?.querySelector<HTMLElement>(`[data-index="${safeActive}"]`);
88
+ el?.scrollIntoView?.({ block: "nearest" });
89
+ }, [safeActive]);
62
90
 
63
91
  function handleSelect(item: CommandPaletteItem) {
64
92
  if (item.href) {
65
- window.location.href = item.href;
93
+ if (onNavigate) onNavigate(item.href);
94
+ else if (typeof window !== "undefined") window.location.assign(item.href);
66
95
  } else {
67
96
  item.onSelect?.();
68
97
  }
69
98
  onClose();
70
99
  }
71
100
 
101
+ function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
102
+ if (e.key === "Escape") {
103
+ onClose();
104
+ return;
105
+ }
106
+ if (ordered.length === 0) return;
107
+ if (e.key === "ArrowDown") {
108
+ e.preventDefault();
109
+ setActiveIndex((safeActive + 1) % ordered.length);
110
+ } else if (e.key === "ArrowUp") {
111
+ e.preventDefault();
112
+ setActiveIndex((safeActive - 1 + ordered.length) % ordered.length);
113
+ } else if (e.key === "Home" && e.ctrlKey) {
114
+ e.preventDefault();
115
+ setActiveIndex(0);
116
+ } else if (e.key === "End" && e.ctrlKey) {
117
+ e.preventDefault();
118
+ setActiveIndex(ordered.length - 1);
119
+ } else if (e.key === "Enter") {
120
+ e.preventDefault();
121
+ const item = ordered[safeActive];
122
+ if (item) handleSelect(item);
123
+ }
124
+ }
125
+
126
+ let runningIndex = 0;
127
+
72
128
  return (
73
129
  <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
74
130
  <DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-xl">
131
+ <DialogTitle className="sr-only">{title}</DialogTitle>
132
+ <DialogDescription className="sr-only">{description}</DialogDescription>
75
133
  {/* Search input */}
76
134
  <div className="flex items-center gap-3 border-b border-border px-4">
77
- <Search size={16} className="shrink-0 text-muted-foreground" />
135
+ <Search size={16} className="shrink-0 text-muted-foreground" aria-hidden="true" />
78
136
  <input
79
137
  ref={inputRef}
80
138
  type="text"
139
+ role="combobox"
140
+ aria-label={title}
141
+ aria-expanded={ordered.length > 0}
142
+ aria-controls={listboxId}
143
+ aria-autocomplete="list"
144
+ aria-activedescendant={safeActive >= 0 ? optionId(safeActive) : undefined}
145
+ autoComplete="off"
81
146
  className="h-12 flex-1 bg-transparent body text-foreground placeholder:text-muted-foreground focus:outline-none"
82
147
  placeholder={placeholder}
83
148
  value={query}
84
- onChange={(e) => setQuery(e.target.value)}
85
- onKeyDown={(e) => {
86
- if (e.key === "Escape") onClose();
87
- if (e.key === "Enter" && filtered.length > 0) handleSelect(filtered[0]);
149
+ onChange={(e) => {
150
+ setQuery(e.target.value);
151
+ setActiveIndex(0);
88
152
  }}
153
+ onKeyDown={handleKeyDown}
89
154
  />
90
155
  <kbd className="hidden items-center gap-0.5 sm:flex">
91
156
  <span className="caption-xs font-medium text-muted-foreground rounded border border-border px-1.5 py-0.5">
@@ -95,40 +160,82 @@ function CommandPalette({
95
160
  </div>
96
161
 
97
162
  {/* Results */}
98
- <div className="max-h-80 overflow-y-auto">
99
- {filtered.length === 0 ? (
100
- <p className="px-4 py-8 text-center body text-muted-foreground">{emptyText}</p>
163
+ <div ref={listRef} className="max-h-80 overflow-y-auto">
164
+ {ordered.length === 0 ? (
165
+ <p role="status" className="px-4 py-8 text-center body text-muted-foreground">
166
+ {emptyText}
167
+ </p>
101
168
  ) : (
102
- <ul role="list" className="py-2">
103
- {categories.map((cat) => (
104
- <li key={cat}>
105
- {cat && <p className="label px-4 py-2 text-muted-foreground">{cat}</p>}
106
- {grouped[cat].map((item, i) => (
107
- <button
108
- key={item.id ?? i}
109
- type="button"
110
- className="flex min-h-10 w-full items-center gap-3 px-4 py-2.5 body text-left transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
111
- onClick={() => handleSelect(item)}
112
- >
113
- {item.icon && (
114
- <span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-card text-muted-foreground">
115
- {item.icon}
116
- </span>
117
- )}
118
- <div className="min-w-0 flex-1">
119
- <p className="font-medium text-foreground truncate">{item.label}</p>
120
- {item.description && (
121
- <p className="caption text-muted-foreground truncate">
122
- {item.description}
123
- </p>
124
- )}
125
- </div>
126
- <ArrowRight size={13} className="shrink-0 text-muted-foreground" />
127
- </button>
128
- ))}
129
- </li>
130
- ))}
131
- </ul>
169
+ <div id={listboxId} role="listbox" aria-label={title} className="py-2">
170
+ {categories.map((cat) => {
171
+ const headingId = `${listboxId}-group-${cat}`;
172
+ return (
173
+ <div
174
+ key={cat}
175
+ role="group"
176
+ {...(cat ? { "aria-labelledby": headingId } : {})}
177
+ >
178
+ {cat && (
179
+ <p
180
+ id={headingId}
181
+ role="presentation"
182
+ className="label px-4 py-2 text-muted-foreground"
183
+ >
184
+ {cat}
185
+ </p>
186
+ )}
187
+ {grouped[cat].map((item, i) => {
188
+ const index = runningIndex++;
189
+ const isActive = index === safeActive;
190
+ return (
191
+ // Options aren't focusable: focus stays in the input and
192
+ // the active option is conveyed via aria-activedescendant.
193
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus -- aria-activedescendant pattern: keyboard selection handled by the input (Arrow/Enter)
194
+ <div
195
+ key={item.id ?? `${cat}-${i}`}
196
+ id={optionId(index)}
197
+ role="option"
198
+ aria-selected={isActive}
199
+ data-index={index}
200
+ data-active={isActive || undefined}
201
+ className={cn(
202
+ "flex min-h-10 w-full cursor-pointer items-center gap-3 px-4 py-2.5 body text-left transition-colors",
203
+ isActive ? "bg-muted" : "hover:bg-muted",
204
+ )}
205
+ onMouseDown={(e) => e.preventDefault()}
206
+ onMouseMove={() => {
207
+ if (!isActive) setActiveIndex(index);
208
+ }}
209
+ onClick={() => handleSelect(item)}
210
+ >
211
+ {item.icon && (
212
+ <span
213
+ aria-hidden="true"
214
+ className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-card text-muted-foreground"
215
+ >
216
+ {item.icon}
217
+ </span>
218
+ )}
219
+ <div className="min-w-0 flex-1">
220
+ <p className="font-medium text-foreground truncate">{item.label}</p>
221
+ {item.description && (
222
+ <p className="caption text-muted-foreground truncate">
223
+ {item.description}
224
+ </p>
225
+ )}
226
+ </div>
227
+ <ArrowRight
228
+ size={13}
229
+ className="shrink-0 text-muted-foreground"
230
+ aria-hidden="true"
231
+ />
232
+ </div>
233
+ );
234
+ })}
235
+ </div>
236
+ );
237
+ })}
238
+ </div>
132
239
  )}
133
240
  </div>
134
241
  </DialogContent>
@@ -139,3 +246,4 @@ function CommandPalette({
139
246
  CommandPalette.displayName = "CommandPalette";
140
247
 
141
248
  export { CommandPalette };
249
+ export type { CommandPaletteProps };