@hilum/ui 3.8.6 → 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.
- package/README.md +32 -1
- package/dist/{chunk-RI6QJR2L.js → chunk-R576DV2A.js} +120 -14
- package/dist/chunk-R576DV2A.js.map +1 -0
- package/dist/create-theme.js +2 -1
- package/dist/create-theme.js.map +1 -1
- package/dist/index.d.ts +733 -68
- package/dist/index.js +3097 -1619
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +163 -19
- package/dist/tokens.d.ts +69 -5
- package/dist/tokens.js +1 -1
- package/llms.txt +11 -4
- package/package.json +11 -10
- package/src/components/accordion.tsx +2 -2
- package/src/components/account-menu.tsx +1 -1
- package/src/components/alert-dialog.tsx +107 -0
- package/src/components/ask-user-questions.tsx +24 -18
- package/src/components/badge.tsx +36 -3
- package/src/components/breadcrumb.tsx +4 -2
- package/src/components/button-group.tsx +1 -1
- package/src/components/button.tsx +31 -23
- package/src/components/calendar.tsx +1 -1
- package/src/components/card.tsx +4 -2
- package/src/components/checkbox-card.tsx +1 -1
- package/src/components/checkbox-group.tsx +2 -1
- package/src/components/code-block.tsx +99 -0
- package/src/components/color-input.tsx +9 -6
- package/src/components/color-picker.tsx +38 -26
- package/src/components/combobox.tsx +59 -12
- package/src/components/command-palette.tsx +150 -42
- package/src/components/command.tsx +5 -3
- package/src/components/context-menu.tsx +18 -15
- package/src/components/contextual-save-bar.tsx +156 -0
- package/src/components/data-table.tsx +34 -5
- package/src/components/date-picker.tsx +275 -28
- package/src/components/date-text.tsx +90 -0
- package/src/components/dropdown-menu.tsx +11 -8
- package/src/components/dropdown.tsx +6 -3
- package/src/components/empty-state.tsx +85 -20
- package/src/components/file-dropzone.tsx +2 -1
- package/src/components/file-thumbnail.tsx +84 -20
- package/src/components/filter-bar.tsx +91 -0
- package/src/components/grid-list.tsx +2 -2
- package/src/components/help-tooltip.tsx +1 -1
- package/src/components/input-copy.tsx +1 -1
- package/src/components/input-group.tsx +1 -1
- package/src/components/input-message.tsx +5 -4
- package/src/components/input-number.tsx +103 -11
- package/src/components/input.tsx +5 -3
- package/src/components/label.tsx +1 -0
- package/src/components/media-asset-card.tsx +1 -1
- package/src/components/menu-item.tsx +37 -3
- package/src/components/menubar.tsx +21 -18
- package/src/components/native-select.tsx +1 -1
- package/src/components/navigation-menu.tsx +2 -2
- package/src/components/notification.tsx +1 -1
- package/src/components/pagination.tsx +97 -3
- package/src/components/popover.tsx +2 -0
- package/src/components/property-row.tsx +7 -4
- package/src/components/radio-card.tsx +1 -1
- package/src/components/radio-group.tsx +2 -1
- package/src/components/rating.tsx +136 -0
- package/src/components/resizable.tsx +1 -1
- package/src/components/resource-item.tsx +133 -0
- package/src/components/rich-text-editor.tsx +1 -0
- package/src/components/search-input.tsx +98 -0
- package/src/components/searchable-table.tsx +10 -11
- package/src/components/select.tsx +28 -9
- package/src/components/sidebar.tsx +4 -4
- package/src/components/slider.tsx +78 -36
- package/src/components/sonner.tsx +18 -4
- package/src/components/stat-card.tsx +121 -37
- package/src/components/status-badge.tsx +142 -47
- package/src/components/status-tile.tsx +9 -0
- package/src/components/switch.tsx +6 -5
- package/src/components/tabs-subtle.tsx +10 -2
- package/src/components/tabs.tsx +23 -3
- package/src/components/textarea.tsx +2 -3
- package/src/components/thinking-steps.tsx +3 -3
- package/src/components/toggle.tsx +14 -13
- package/src/components/tooltip.tsx +11 -8
- package/src/components/url-redirect-prompt.tsx +0 -1
- package/src/index.ts +15 -0
- package/src/lib/density-context.tsx +65 -0
- package/src/lib/format.tsx +301 -0
- package/src/lib/interaction.ts +12 -4
- package/src/lib/scroll-fade.tsx +39 -0
- package/src/tokens/tokens.test.ts +77 -0
- package/src/tokens/tokens.ts +114 -5
- 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
|
|
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.
|
|
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.
|
|
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-
|
|
864
|
-
|
|
865
|
-
|
|
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={
|
|
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
|
|
1079
|
-
|
|
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-[
|
|
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
|
|
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={
|
|
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
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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="
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
{
|
|
100
|
-
<p className="px-4 py-8 text-center body text-muted-foreground">
|
|
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
|
-
<
|
|
103
|
-
{categories.map((cat) =>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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 };
|