@hilum/ui 0.1.1
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/LICENSE +21 -0
- package/README.md +42 -0
- package/dist/icons.d.ts +1 -0
- package/dist/icons.js +3 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +992 -0
- package/dist/index.js +4433 -0
- package/dist/index.js.map +1 -0
- package/dist/tokens.css +265 -0
- package/dist/tokens.d.ts +189 -0
- package/dist/tokens.js +193 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +80 -0
- package/src/components/__tests__/data.test.tsx +180 -0
- package/src/components/__tests__/disclosure.test.tsx +198 -0
- package/src/components/__tests__/display.test.tsx +262 -0
- package/src/components/__tests__/feedback.test.tsx +138 -0
- package/src/components/__tests__/form.test.tsx +297 -0
- package/src/components/__tests__/layout.test.tsx +388 -0
- package/src/components/__tests__/overlay.test.tsx +90 -0
- package/src/components/__tests__/primitives.test.tsx +248 -0
- package/src/components/accordion.tsx +68 -0
- package/src/components/action-panel.tsx +112 -0
- package/src/components/activity-feed.tsx +69 -0
- package/src/components/alert-dialog.tsx +132 -0
- package/src/components/alert.tsx +44 -0
- package/src/components/aspect-ratio.tsx +19 -0
- package/src/components/avatar-stack.tsx +70 -0
- package/src/components/avatar.tsx +103 -0
- package/src/components/badge.tsx +33 -0
- package/src/components/breadcrumb.tsx +76 -0
- package/src/components/button-group.tsx +45 -0
- package/src/components/button.tsx +60 -0
- package/src/components/calendar.tsx +61 -0
- package/src/components/card-heading.tsx +60 -0
- package/src/components/card.tsx +120 -0
- package/src/components/carousel.tsx +222 -0
- package/src/components/chart.tsx +77 -0
- package/src/components/checkbox.tsx +35 -0
- package/src/components/collapsible.tsx +9 -0
- package/src/components/color-input.tsx +90 -0
- package/src/components/color-picker.tsx +111 -0
- package/src/components/combobox.tsx +207 -0
- package/src/components/command-palette.tsx +137 -0
- package/src/components/command.tsx +248 -0
- package/src/components/context-menu.tsx +201 -0
- package/src/components/data-table.tsx +151 -0
- package/src/components/date-picker.tsx +56 -0
- package/src/components/description-list.tsx +70 -0
- package/src/components/dialog.tsx +97 -0
- package/src/components/drawer.tsx +90 -0
- package/src/components/dropdown-menu.tsx +202 -0
- package/src/components/empty-state.tsx +43 -0
- package/src/components/field.tsx +45 -0
- package/src/components/grid-list.tsx +159 -0
- package/src/components/hover-card.tsx +34 -0
- package/src/components/input-group.tsx +134 -0
- package/src/components/input-number.tsx +142 -0
- package/src/components/input-otp.tsx +68 -0
- package/src/components/input.tsx +23 -0
- package/src/components/kbd.tsx +18 -0
- package/src/components/label.tsx +23 -0
- package/src/components/media-object.tsx +49 -0
- package/src/components/menubar.tsx +235 -0
- package/src/components/native-select.tsx +44 -0
- package/src/components/navigation-menu.tsx +158 -0
- package/src/components/notification.tsx +109 -0
- package/src/components/page-heading.tsx +125 -0
- package/src/components/pagination.tsx +89 -0
- package/src/components/popover.tsx +35 -0
- package/src/components/progress.tsx +23 -0
- package/src/components/property-row.tsx +56 -0
- package/src/components/radio-card.tsx +105 -0
- package/src/components/radio-group.tsx +38 -0
- package/src/components/resizable.tsx +74 -0
- package/src/components/scroll-area.tsx +42 -0
- package/src/components/section-heading.tsx +64 -0
- package/src/components/select.tsx +130 -0
- package/src/components/separator.tsx +25 -0
- package/src/components/sheet.tsx +117 -0
- package/src/components/sidebar.tsx +636 -0
- package/src/components/skeleton.tsx +14 -0
- package/src/components/slider.tsx +24 -0
- package/src/components/sonner.tsx +34 -0
- package/src/components/spinner.tsx +37 -0
- package/src/components/stacked-list.tsx +64 -0
- package/src/components/stat-card.tsx +54 -0
- package/src/components/steps.tsx +179 -0
- package/src/components/switch.tsx +33 -0
- package/src/components/table.tsx +101 -0
- package/src/components/tabs.tsx +55 -0
- package/src/components/textarea.tsx +21 -0
- package/src/components/toggle-group.tsx +49 -0
- package/src/components/toggle.tsx +66 -0
- package/src/components/tooltip.tsx +43 -0
- package/src/icons.ts +158 -0
- package/src/index.ts +84 -0
- package/src/lib/utils.ts +6 -0
- package/src/tokens/tokens.ts +194 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Check, ChevronsUpDown } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
export interface ComboboxOption {
|
|
8
|
+
value: string;
|
|
9
|
+
label: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
statusColor?: string;
|
|
12
|
+
avatar?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ComboboxProps {
|
|
16
|
+
options: ComboboxOption[];
|
|
17
|
+
value?: string;
|
|
18
|
+
onValueChange?: (value: string) => void;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
searchPlaceholder?: string;
|
|
21
|
+
emptyText?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function Combobox({
|
|
26
|
+
options,
|
|
27
|
+
value,
|
|
28
|
+
onValueChange,
|
|
29
|
+
placeholder = "Select...",
|
|
30
|
+
searchPlaceholder = "Search...",
|
|
31
|
+
emptyText = "No results found.",
|
|
32
|
+
className,
|
|
33
|
+
}: ComboboxProps) {
|
|
34
|
+
const [open, setOpen] = React.useState(false);
|
|
35
|
+
const [query, setQuery] = React.useState("");
|
|
36
|
+
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
37
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
38
|
+
|
|
39
|
+
const selectedOption = options.find((o) => o.value === value);
|
|
40
|
+
|
|
41
|
+
const filtered =
|
|
42
|
+
query === ""
|
|
43
|
+
? options
|
|
44
|
+
: options.filter(
|
|
45
|
+
(o) =>
|
|
46
|
+
o.label.toLowerCase().includes(query.toLowerCase()) ||
|
|
47
|
+
o.description?.toLowerCase().includes(query.toLowerCase()),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Close on outside click
|
|
51
|
+
React.useEffect(() => {
|
|
52
|
+
function onMouseDown(e: MouseEvent) {
|
|
53
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
54
|
+
setOpen(false);
|
|
55
|
+
setQuery("");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
document.addEventListener("mousedown", onMouseDown);
|
|
59
|
+
return () => document.removeEventListener("mousedown", onMouseDown);
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
// Close on Escape
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
if (!open) return;
|
|
65
|
+
function onKey(e: KeyboardEvent) {
|
|
66
|
+
if (e.key === "Escape") {
|
|
67
|
+
setOpen(false);
|
|
68
|
+
setQuery("");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
document.addEventListener("keydown", onKey);
|
|
72
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
73
|
+
}, [open]);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div ref={containerRef} className={cn("relative", className)}>
|
|
77
|
+
{/* Trigger */}
|
|
78
|
+
<div className="relative flex items-center">
|
|
79
|
+
{selectedOption?.avatar && !open && (
|
|
80
|
+
<div className="pointer-events-none absolute left-2.5 flex size-5 items-center justify-center rounded-full bg-taupe-200 caption-xs font-semibold text-taupe-700 shrink-0">
|
|
81
|
+
{selectedOption.avatar}
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
{selectedOption?.statusColor && !open && (
|
|
85
|
+
<div
|
|
86
|
+
className="pointer-events-none absolute left-3 size-2 rounded-full shrink-0"
|
|
87
|
+
style={{ backgroundColor: selectedOption.statusColor }}
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
<input
|
|
91
|
+
ref={inputRef}
|
|
92
|
+
type="text"
|
|
93
|
+
role="combobox"
|
|
94
|
+
aria-expanded={open}
|
|
95
|
+
className={cn(
|
|
96
|
+
"flex h-9 w-full rounded-lg border border-taupe-200 bg-white pr-8 body text-taupe-900",
|
|
97
|
+
"placeholder:text-taupe-400",
|
|
98
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-taupe-400/30 focus-visible:border-taupe-400",
|
|
99
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
100
|
+
selectedOption?.avatar && !open
|
|
101
|
+
? "pl-8"
|
|
102
|
+
: selectedOption?.statusColor && !open
|
|
103
|
+
? "pl-7"
|
|
104
|
+
: "pl-3",
|
|
105
|
+
)}
|
|
106
|
+
placeholder={open ? searchPlaceholder : (selectedOption?.label ?? placeholder)}
|
|
107
|
+
value={open ? query : (selectedOption?.label ?? "")}
|
|
108
|
+
onChange={(e) => {
|
|
109
|
+
setQuery(e.target.value);
|
|
110
|
+
if (!open) setOpen(true);
|
|
111
|
+
}}
|
|
112
|
+
onFocus={() => {
|
|
113
|
+
setOpen(true);
|
|
114
|
+
setQuery("");
|
|
115
|
+
}}
|
|
116
|
+
/>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
tabIndex={-1}
|
|
120
|
+
className="absolute inset-y-0 right-0 flex items-center px-2 text-taupe-400 hover:text-taupe-700 transition-colors"
|
|
121
|
+
onClick={() => {
|
|
122
|
+
if (open) {
|
|
123
|
+
setOpen(false);
|
|
124
|
+
setQuery("");
|
|
125
|
+
} else {
|
|
126
|
+
setOpen(true);
|
|
127
|
+
inputRef.current?.focus();
|
|
128
|
+
}
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
<ChevronsUpDown size={14} />
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Dropdown */}
|
|
136
|
+
{open && (
|
|
137
|
+
<div className="absolute z-50 mt-1 w-full overflow-hidden rounded-lg border border-taupe-200 bg-white shadow-elevated">
|
|
138
|
+
<ul role="listbox" className="max-h-60 overflow-auto py-1">
|
|
139
|
+
{filtered.length === 0 ? (
|
|
140
|
+
<li className="px-3 py-2 body text-taupe-400">{emptyText}</li>
|
|
141
|
+
) : (
|
|
142
|
+
filtered.map((option) => {
|
|
143
|
+
const isSelected = option.value === value;
|
|
144
|
+
return (
|
|
145
|
+
<li
|
|
146
|
+
key={option.value}
|
|
147
|
+
role="option"
|
|
148
|
+
aria-selected={isSelected}
|
|
149
|
+
className={cn(
|
|
150
|
+
"flex cursor-pointer select-none items-center gap-2.5 px-3 py-2 body transition-colors",
|
|
151
|
+
isSelected
|
|
152
|
+
? "bg-brand-primary text-white"
|
|
153
|
+
: "text-taupe-900 hover:bg-taupe-50",
|
|
154
|
+
)}
|
|
155
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
156
|
+
onClick={() => {
|
|
157
|
+
onValueChange?.(option.value);
|
|
158
|
+
setQuery("");
|
|
159
|
+
setOpen(false);
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
{/* Avatar */}
|
|
163
|
+
{option.avatar && (
|
|
164
|
+
<div
|
|
165
|
+
className={cn(
|
|
166
|
+
"flex size-6 shrink-0 items-center justify-center rounded-full caption-xs font-semibold",
|
|
167
|
+
isSelected ? "bg-white/20 text-white" : "bg-taupe-200 text-taupe-700",
|
|
168
|
+
)}
|
|
169
|
+
>
|
|
170
|
+
{option.avatar}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
{/* Status dot */}
|
|
174
|
+
{option.statusColor && !option.avatar && (
|
|
175
|
+
<div
|
|
176
|
+
className="size-2 shrink-0 rounded-full"
|
|
177
|
+
style={{ backgroundColor: option.statusColor }}
|
|
178
|
+
/>
|
|
179
|
+
)}
|
|
180
|
+
<div className="min-w-0 flex-1">
|
|
181
|
+
<p className={cn("truncate", isSelected && "font-semibold")}>
|
|
182
|
+
{option.label}
|
|
183
|
+
</p>
|
|
184
|
+
{option.description && (
|
|
185
|
+
<p
|
|
186
|
+
className={cn(
|
|
187
|
+
"caption truncate",
|
|
188
|
+
isSelected ? "text-white/70" : "text-taupe-400",
|
|
189
|
+
)}
|
|
190
|
+
>
|
|
191
|
+
{option.description}
|
|
192
|
+
</p>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
{isSelected && <Check size={14} className="shrink-0" />}
|
|
196
|
+
</li>
|
|
197
|
+
);
|
|
198
|
+
})
|
|
199
|
+
)}
|
|
200
|
+
</ul>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export { Combobox };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Search, ArrowRight } from "lucide-react";
|
|
5
|
+
import { Dialog, DialogContent } from "./dialog";
|
|
6
|
+
|
|
7
|
+
export interface CommandPaletteItem {
|
|
8
|
+
id?: string | number;
|
|
9
|
+
label: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
icon?: React.ReactNode;
|
|
12
|
+
category?: string;
|
|
13
|
+
href?: string;
|
|
14
|
+
onSelect?: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface CommandPaletteProps {
|
|
18
|
+
open: boolean;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
items: CommandPaletteItem[];
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
emptyText?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function CommandPalette({
|
|
26
|
+
open,
|
|
27
|
+
onClose,
|
|
28
|
+
items,
|
|
29
|
+
placeholder = "Search...",
|
|
30
|
+
emptyText = "No results found.",
|
|
31
|
+
}: CommandPaletteProps) {
|
|
32
|
+
const [query, setQuery] = React.useState("");
|
|
33
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
34
|
+
|
|
35
|
+
// Focus input when opened
|
|
36
|
+
React.useEffect(() => {
|
|
37
|
+
if (open) {
|
|
38
|
+
setTimeout(() => inputRef.current?.focus(), 50);
|
|
39
|
+
setQuery("");
|
|
40
|
+
}
|
|
41
|
+
}, [open]);
|
|
42
|
+
|
|
43
|
+
const filtered =
|
|
44
|
+
query === ""
|
|
45
|
+
? items
|
|
46
|
+
: items.filter(
|
|
47
|
+
(item) =>
|
|
48
|
+
item.label.toLowerCase().includes(query.toLowerCase()) ||
|
|
49
|
+
item.description?.toLowerCase().includes(query.toLowerCase()) ||
|
|
50
|
+
item.category?.toLowerCase().includes(query.toLowerCase()),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Group by category
|
|
54
|
+
const grouped = filtered.reduce<Record<string, CommandPaletteItem[]>>((acc, item) => {
|
|
55
|
+
const cat = item.category ?? "";
|
|
56
|
+
if (!acc[cat]) acc[cat] = [];
|
|
57
|
+
acc[cat].push(item);
|
|
58
|
+
return acc;
|
|
59
|
+
}, {});
|
|
60
|
+
|
|
61
|
+
const categories = Object.keys(grouped);
|
|
62
|
+
|
|
63
|
+
function handleSelect(item: CommandPaletteItem) {
|
|
64
|
+
if (item.href) {
|
|
65
|
+
window.location.href = item.href;
|
|
66
|
+
} else {
|
|
67
|
+
item.onSelect?.();
|
|
68
|
+
}
|
|
69
|
+
onClose();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
74
|
+
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-xl">
|
|
75
|
+
{/* Search input */}
|
|
76
|
+
<div className="flex items-center gap-3 border-b border-taupe-100 px-4">
|
|
77
|
+
<Search size={16} className="shrink-0 text-taupe-400" />
|
|
78
|
+
<input
|
|
79
|
+
ref={inputRef}
|
|
80
|
+
type="text"
|
|
81
|
+
className="h-12 flex-1 bg-transparent body text-taupe-900 placeholder:text-taupe-400 focus:outline-none"
|
|
82
|
+
placeholder={placeholder}
|
|
83
|
+
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]);
|
|
88
|
+
}}
|
|
89
|
+
/>
|
|
90
|
+
<kbd className="hidden items-center gap-0.5 sm:flex">
|
|
91
|
+
<span className="caption-xs font-medium text-taupe-400 rounded border border-taupe-200 px-1.5 py-0.5">
|
|
92
|
+
esc
|
|
93
|
+
</span>
|
|
94
|
+
</kbd>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* 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-taupe-400">{emptyText}</p>
|
|
101
|
+
) : (
|
|
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-taupe-400">{cat}</p>}
|
|
106
|
+
{grouped[cat].map((item, i) => (
|
|
107
|
+
<button
|
|
108
|
+
key={item.id ?? i}
|
|
109
|
+
type="button"
|
|
110
|
+
className="flex w-full items-center gap-3 px-4 py-2.5 body text-left transition-colors hover:bg-taupe-50 focus-visible:bg-taupe-50 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-taupe-200 bg-white text-taupe-500">
|
|
115
|
+
{item.icon}
|
|
116
|
+
</span>
|
|
117
|
+
)}
|
|
118
|
+
<div className="min-w-0 flex-1">
|
|
119
|
+
<p className="font-medium text-taupe-900 truncate">{item.label}</p>
|
|
120
|
+
{item.description && (
|
|
121
|
+
<p className="caption text-taupe-400 truncate">{item.description}</p>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
<ArrowRight size={13} className="shrink-0 text-taupe-300" />
|
|
125
|
+
</button>
|
|
126
|
+
))}
|
|
127
|
+
</li>
|
|
128
|
+
))}
|
|
129
|
+
</ul>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
</DialogContent>
|
|
133
|
+
</Dialog>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export { CommandPalette };
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Search } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
interface CommandContextValue {
|
|
8
|
+
query: string;
|
|
9
|
+
setQuery: React.Dispatch<React.SetStateAction<string>>;
|
|
10
|
+
onSelect?: (value: string) => void;
|
|
11
|
+
reportVisible: (id: string, visible: boolean) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const CommandCtx = React.createContext<CommandContextValue>({
|
|
15
|
+
query: "",
|
|
16
|
+
setQuery: () => {},
|
|
17
|
+
reportVisible: () => {},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const CommandVisibleCtx = React.createContext(0);
|
|
21
|
+
|
|
22
|
+
interface CommandProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
|
|
23
|
+
onSelect?: (value: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Command({ className, children, onSelect, ...props }: CommandProps) {
|
|
27
|
+
const [query, setQuery] = React.useState("");
|
|
28
|
+
const visibleIds = React.useRef(new Set<string>());
|
|
29
|
+
const [visibleCount, setVisibleCount] = React.useState(0);
|
|
30
|
+
|
|
31
|
+
const reportVisible = React.useCallback((id: string, visible: boolean) => {
|
|
32
|
+
const had = visibleIds.current.has(id);
|
|
33
|
+
if (visible && !had) {
|
|
34
|
+
visibleIds.current.add(id);
|
|
35
|
+
setVisibleCount(visibleIds.current.size);
|
|
36
|
+
} else if (!visible && had) {
|
|
37
|
+
visibleIds.current.delete(id);
|
|
38
|
+
setVisibleCount(visibleIds.current.size);
|
|
39
|
+
}
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<CommandCtx.Provider value={{ query, setQuery, onSelect, reportVisible }}>
|
|
44
|
+
<CommandVisibleCtx.Provider value={visibleCount}>
|
|
45
|
+
<div
|
|
46
|
+
className={cn(
|
|
47
|
+
"flex flex-col overflow-hidden rounded-xl border border-taupe-200 bg-white",
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
</CommandVisibleCtx.Provider>
|
|
55
|
+
</CommandCtx.Provider>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CommandInput = React.forwardRef<
|
|
60
|
+
HTMLInputElement,
|
|
61
|
+
Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange">
|
|
62
|
+
>(({ className, ...props }, ref) => {
|
|
63
|
+
const { query, setQuery } = React.useContext(CommandCtx);
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex items-center gap-2 border-b border-taupe-100 px-3">
|
|
66
|
+
<Search size={14} className="shrink-0 text-taupe-400" />
|
|
67
|
+
<input
|
|
68
|
+
ref={ref}
|
|
69
|
+
type="text"
|
|
70
|
+
autoComplete="off"
|
|
71
|
+
autoCorrect="off"
|
|
72
|
+
spellCheck={false}
|
|
73
|
+
className={cn(
|
|
74
|
+
"flex h-10 w-full bg-transparent body text-taupe-900",
|
|
75
|
+
"placeholder:text-taupe-400 focus:outline-none",
|
|
76
|
+
className,
|
|
77
|
+
)}
|
|
78
|
+
value={query}
|
|
79
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
80
|
+
{...props}
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
CommandInput.displayName = "CommandInput";
|
|
86
|
+
|
|
87
|
+
const CommandList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
88
|
+
({ className, ...props }, ref) => {
|
|
89
|
+
const listRef = React.useRef<HTMLDivElement | null>(null);
|
|
90
|
+
|
|
91
|
+
const combinedRef = React.useCallback(
|
|
92
|
+
(el: HTMLDivElement | null) => {
|
|
93
|
+
listRef.current = el;
|
|
94
|
+
if (typeof ref === "function") ref(el);
|
|
95
|
+
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
|
96
|
+
},
|
|
97
|
+
[ref],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
101
|
+
if (!["ArrowDown", "ArrowUp"].includes(e.key)) return;
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
const el = listRef.current;
|
|
104
|
+
if (!el) return;
|
|
105
|
+
const items = Array.from(
|
|
106
|
+
el.querySelectorAll<HTMLElement>("[data-cmd-item]:not([aria-disabled='true'])"),
|
|
107
|
+
);
|
|
108
|
+
if (!items.length) return;
|
|
109
|
+
const focused = el.querySelector<HTMLElement>("[data-cmd-item]:focus");
|
|
110
|
+
const idx = focused ? items.indexOf(focused) : -1;
|
|
111
|
+
const next =
|
|
112
|
+
e.key === "ArrowDown"
|
|
113
|
+
? items[Math.min(idx + 1, items.length - 1)]
|
|
114
|
+
: items[Math.max(idx - 1, 0)];
|
|
115
|
+
next?.focus();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div
|
|
120
|
+
ref={combinedRef}
|
|
121
|
+
role="listbox"
|
|
122
|
+
onKeyDown={handleKeyDown}
|
|
123
|
+
className={cn("max-h-[300px] overflow-y-auto py-1", className)}
|
|
124
|
+
{...props}
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
CommandList.displayName = "CommandList";
|
|
130
|
+
|
|
131
|
+
const CommandEmpty = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
132
|
+
({ className, children = "No results found.", ...props }, ref) => {
|
|
133
|
+
const visibleCount = React.useContext(CommandVisibleCtx);
|
|
134
|
+
if (visibleCount > 0) return null;
|
|
135
|
+
return (
|
|
136
|
+
<div
|
|
137
|
+
ref={ref}
|
|
138
|
+
className={cn("py-6 text-center caption text-taupe-400", className)}
|
|
139
|
+
{...props}
|
|
140
|
+
>
|
|
141
|
+
{children}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
CommandEmpty.displayName = "CommandEmpty";
|
|
147
|
+
|
|
148
|
+
interface CommandGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
149
|
+
heading?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const CommandGroup = React.forwardRef<HTMLDivElement, CommandGroupProps>(
|
|
153
|
+
({ className, heading, children, ...props }, ref) => (
|
|
154
|
+
<div ref={ref} role="group" className={cn("py-1", className)} {...props}>
|
|
155
|
+
{heading && <p className="px-3 pb-1 pt-0.5 label text-taupe-400">{heading}</p>}
|
|
156
|
+
{children}
|
|
157
|
+
</div>
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
CommandGroup.displayName = "CommandGroup";
|
|
161
|
+
|
|
162
|
+
interface CommandItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
|
|
163
|
+
value?: string;
|
|
164
|
+
keywords?: string[];
|
|
165
|
+
disabled?: boolean;
|
|
166
|
+
onSelect?: (value: string) => void;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const CommandItem = React.forwardRef<HTMLDivElement, CommandItemProps>(
|
|
170
|
+
({ className, value = "", keywords, disabled, onSelect, children, ...props }, ref) => {
|
|
171
|
+
const { query, onSelect: ctxOnSelect, reportVisible } = React.useContext(CommandCtx);
|
|
172
|
+
const id = React.useId();
|
|
173
|
+
|
|
174
|
+
const searchText = (keywords ?? [value]).join(" ").toLowerCase();
|
|
175
|
+
const isVisible = !query || searchText.includes(query.toLowerCase());
|
|
176
|
+
|
|
177
|
+
React.useLayoutEffect(() => {
|
|
178
|
+
reportVisible(id, isVisible && !disabled);
|
|
179
|
+
return () => reportVisible(id, false);
|
|
180
|
+
}, [id, isVisible, disabled, reportVisible]);
|
|
181
|
+
|
|
182
|
+
if (!isVisible) return null;
|
|
183
|
+
|
|
184
|
+
const handleSelect = () => {
|
|
185
|
+
if (disabled) return;
|
|
186
|
+
onSelect?.(value);
|
|
187
|
+
ctxOnSelect?.(value);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div
|
|
192
|
+
ref={ref}
|
|
193
|
+
role="option"
|
|
194
|
+
aria-selected={false}
|
|
195
|
+
aria-disabled={disabled || undefined}
|
|
196
|
+
data-cmd-item
|
|
197
|
+
tabIndex={disabled ? undefined : 0}
|
|
198
|
+
className={cn(
|
|
199
|
+
"relative flex cursor-default select-none items-center gap-2 rounded-md px-2.5 py-1.5 mx-1",
|
|
200
|
+
"body text-taupe-700 outline-none transition-colors",
|
|
201
|
+
"hover:bg-taupe-50 hover:text-taupe-900",
|
|
202
|
+
"focus:bg-taupe-50 focus:text-taupe-900",
|
|
203
|
+
disabled && "pointer-events-none opacity-40",
|
|
204
|
+
className,
|
|
205
|
+
)}
|
|
206
|
+
onClick={handleSelect}
|
|
207
|
+
onKeyDown={(e) => {
|
|
208
|
+
if (e.key === "Enter") {
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
handleSelect();
|
|
211
|
+
}
|
|
212
|
+
}}
|
|
213
|
+
{...props}
|
|
214
|
+
>
|
|
215
|
+
{children}
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
CommandItem.displayName = "CommandItem";
|
|
221
|
+
|
|
222
|
+
const CommandSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
223
|
+
({ className, ...props }, ref) => (
|
|
224
|
+
<div ref={ref} className={cn("my-1 h-px bg-taupe-100", className)} {...props} />
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
CommandSeparator.displayName = "CommandSeparator";
|
|
228
|
+
|
|
229
|
+
function CommandShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
|
|
230
|
+
return (
|
|
231
|
+
<span
|
|
232
|
+
className={cn("ml-auto caption text-taupe-300 tracking-widest font-medium", className)}
|
|
233
|
+
{...props}
|
|
234
|
+
/>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
CommandShortcut.displayName = "CommandShortcut";
|
|
238
|
+
|
|
239
|
+
export {
|
|
240
|
+
Command,
|
|
241
|
+
CommandInput,
|
|
242
|
+
CommandList,
|
|
243
|
+
CommandEmpty,
|
|
244
|
+
CommandGroup,
|
|
245
|
+
CommandItem,
|
|
246
|
+
CommandSeparator,
|
|
247
|
+
CommandShortcut,
|
|
248
|
+
};
|