@blurrow/ui 0.2.0 → 0.2.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/README.md +48 -36
- package/dist/Accordion.d.ts +17 -0
- package/dist/Accordion.js +138 -0
- package/dist/Accordion.js.map +1 -0
- package/dist/Alert.d.ts +13 -0
- package/dist/Alert.js +69 -0
- package/dist/Alert.js.map +1 -0
- package/dist/AuthorCard.d.ts +12 -0
- package/dist/AuthorCard.js +57 -0
- package/dist/AuthorCard.js.map +1 -0
- package/dist/Avatar.d.ts +21 -0
- package/dist/Avatar.js +96 -0
- package/dist/Avatar.js.map +1 -0
- package/dist/Badge.d.ts +12 -0
- package/dist/Badge.js +46 -0
- package/dist/Badge.js.map +1 -0
- package/dist/Banner.d.ts +15 -0
- package/dist/Banner.js +68 -0
- package/dist/Banner.js.map +1 -0
- package/dist/Breadcrumb.d.ts +13 -0
- package/dist/Breadcrumb.js +45 -0
- package/dist/Breadcrumb.js.map +1 -0
- package/dist/Button.d.ts +19 -0
- package/dist/Button.js +282 -0
- package/dist/Button.js.map +1 -0
- package/dist/ButtonGroup.d.ts +19 -0
- package/dist/ButtonGroup.js +62 -0
- package/dist/ButtonGroup.js.map +1 -0
- package/dist/Card.d.ts +40 -0
- package/dist/Card.js +175 -0
- package/dist/Card.js.map +1 -0
- package/dist/CardShell.d.ts +18 -0
- package/dist/CardShell.js +58 -0
- package/dist/CardShell.js.map +1 -0
- package/dist/Carousel.d.ts +22 -0
- package/dist/Carousel.js +247 -0
- package/dist/Carousel.js.map +1 -0
- package/dist/Checkbox.d.ts +15 -0
- package/dist/Checkbox.js +84 -0
- package/dist/Checkbox.js.map +1 -0
- package/dist/CloseButton.d.ts +12 -0
- package/dist/CloseButton.js +63 -0
- package/dist/CloseButton.js.map +1 -0
- package/dist/CodeBlock.d.ts +12 -0
- package/dist/CodeBlock.js +221 -0
- package/dist/CodeBlock.js.map +1 -0
- package/dist/ColorPicker.d.ts +24 -0
- package/dist/ColorPicker.js +366 -0
- package/dist/ColorPicker.js.map +1 -0
- package/dist/Combobox.d.ts +34 -0
- package/dist/Combobox.js +255 -0
- package/dist/Combobox.js.map +1 -0
- package/dist/CommandPalette.d.ts +25 -0
- package/dist/CommandPalette.js +304 -0
- package/dist/CommandPalette.js.map +1 -0
- package/dist/ContextMenu.d.ts +16 -0
- package/dist/ContextMenu.js +209 -0
- package/dist/ContextMenu.js.map +1 -0
- package/dist/DataList.d.ts +15 -0
- package/dist/DataList.js +42 -0
- package/dist/DataList.js.map +1 -0
- package/dist/DatePicker.d.ts +65 -0
- package/dist/DatePicker.js +391 -0
- package/dist/DatePicker.js.map +1 -0
- package/dist/DateRangePicker.d.ts +34 -0
- package/dist/DateRangePicker.js +180 -0
- package/dist/DateRangePicker.js.map +1 -0
- package/dist/Drawer.d.ts +17 -0
- package/dist/Drawer.js +132 -0
- package/dist/Drawer.js.map +1 -0
- package/dist/DropdownMenu.d.ts +23 -0
- package/dist/DropdownMenu.js +185 -0
- package/dist/DropdownMenu.js.map +1 -0
- package/dist/EmptyState.d.ts +13 -0
- package/dist/EmptyState.js +55 -0
- package/dist/EmptyState.js.map +1 -0
- package/dist/FieldMessage.d.ts +26 -0
- package/dist/FieldMessage.js +95 -0
- package/dist/FieldMessage.js.map +1 -0
- package/dist/FileUpload.d.ts +32 -0
- package/dist/FileUpload.js +253 -0
- package/dist/FileUpload.js.map +1 -0
- package/dist/Floating.d.ts +32 -0
- package/dist/Floating.js +95 -0
- package/dist/Floating.js.map +1 -0
- package/dist/HoverCard.d.ts +18 -0
- package/dist/HoverCard.js +90 -0
- package/dist/HoverCard.js.map +1 -0
- package/dist/Input.d.ts +17 -0
- package/dist/Input.js +96 -0
- package/dist/Input.js.map +1 -0
- package/dist/Kbd.d.ts +9 -0
- package/dist/Kbd.js +24 -0
- package/dist/Kbd.js.map +1 -0
- package/dist/Modal.d.ts +15 -0
- package/dist/Modal.js +109 -0
- package/dist/Modal.js.map +1 -0
- package/dist/NumberInput.d.ts +23 -0
- package/dist/NumberInput.js +162 -0
- package/dist/NumberInput.js.map +1 -0
- package/dist/OtpInput.d.ts +26 -0
- package/dist/OtpInput.js +173 -0
- package/dist/OtpInput.js.map +1 -0
- package/dist/Pagination.d.ts +11 -0
- package/dist/Pagination.js +148 -0
- package/dist/Pagination.js.map +1 -0
- package/dist/Popover.d.ts +13 -0
- package/dist/Popover.js +98 -0
- package/dist/Popover.js.map +1 -0
- package/dist/Progress.d.ts +12 -0
- package/dist/Progress.js +61 -0
- package/dist/Progress.js.map +1 -0
- package/dist/QuoteCard.d.ts +13 -0
- package/dist/QuoteCard.js +92 -0
- package/dist/QuoteCard.js.map +1 -0
- package/dist/Radio.d.ts +19 -0
- package/dist/Radio.js +161 -0
- package/dist/Radio.js.map +1 -0
- package/dist/RadioCards.d.ts +27 -0
- package/dist/RadioCards.js +205 -0
- package/dist/RadioCards.js.map +1 -0
- package/dist/ReactionBar.d.ts +15 -0
- package/dist/ReactionBar.js +92 -0
- package/dist/ReactionBar.js.map +1 -0
- package/dist/ReadingProgress.d.ts +8 -0
- package/dist/ReadingProgress.js +51 -0
- package/dist/ReadingProgress.js.map +1 -0
- package/dist/ReadingTime.d.ts +9 -0
- package/dist/ReadingTime.js +39 -0
- package/dist/ReadingTime.js.map +1 -0
- package/dist/Select.d.ts +20 -0
- package/dist/Select.js +244 -0
- package/dist/Select.js.map +1 -0
- package/dist/Separator.d.ts +14 -0
- package/dist/Separator.js +33 -0
- package/dist/Separator.js.map +1 -0
- package/dist/ShareButtons.d.ts +10 -0
- package/dist/ShareButtons.js +107 -0
- package/dist/ShareButtons.js.map +1 -0
- package/dist/Sidebar.d.ts +30 -0
- package/dist/Sidebar.js +140 -0
- package/dist/Sidebar.js.map +1 -0
- package/dist/Skeleton.d.ts +15 -0
- package/dist/Skeleton.js +67 -0
- package/dist/Skeleton.js.map +1 -0
- package/dist/Slider.d.ts +27 -0
- package/dist/Slider.js +182 -0
- package/dist/Slider.js.map +1 -0
- package/dist/Spinner.d.ts +12 -0
- package/dist/Spinner.js +24 -0
- package/dist/Spinner.js.map +1 -0
- package/dist/SpotlightCard.d.ts +17 -0
- package/dist/SpotlightCard.js +87 -0
- package/dist/SpotlightCard.js.map +1 -0
- package/dist/Stat.d.ts +19 -0
- package/dist/Stat.js +71 -0
- package/dist/Stat.js.map +1 -0
- package/dist/StatCard.d.ts +24 -0
- package/dist/StatCard.js +212 -0
- package/dist/StatCard.js.map +1 -0
- package/dist/Steps.d.ts +14 -0
- package/dist/Steps.js +94 -0
- package/dist/Steps.js.map +1 -0
- package/dist/Table.d.ts +21 -0
- package/dist/Table.js +93 -0
- package/dist/Table.js.map +1 -0
- package/dist/TableOfContents.d.ts +17 -0
- package/dist/TableOfContents.js +92 -0
- package/dist/TableOfContents.js.map +1 -0
- package/dist/Tabs.d.ts +17 -0
- package/dist/Tabs.js +123 -0
- package/dist/Tabs.js.map +1 -0
- package/dist/TagInput.d.ts +16 -0
- package/dist/TagInput.js +169 -0
- package/dist/TagInput.js.map +1 -0
- package/dist/Textarea.d.ts +13 -0
- package/dist/Textarea.js +74 -0
- package/dist/Textarea.js.map +1 -0
- package/dist/TimePicker.d.ts +28 -0
- package/dist/TimePicker.js +264 -0
- package/dist/TimePicker.js.map +1 -0
- package/dist/Timeline.d.ts +19 -0
- package/dist/Timeline.js +76 -0
- package/dist/Timeline.js.map +1 -0
- package/dist/Toast.d.ts +18 -0
- package/dist/Toast.js +318 -0
- package/dist/Toast.js.map +1 -0
- package/dist/Toggle.d.ts +13 -0
- package/dist/Toggle.js +84 -0
- package/dist/Toggle.js.map +1 -0
- package/dist/Tooltip.d.ts +13 -0
- package/dist/Tooltip.js +138 -0
- package/dist/Tooltip.js.map +1 -0
- package/dist/cli.js +172 -5
- package/dist/index.d.ts +68 -631
- package/dist/index.js +67 -5558
- package/dist/index.js.map +1 -1
- package/dist/registry/Accordion.tsx +6 -6
- package/dist/registry/Alert.tsx +13 -10
- package/dist/registry/AuthorCard.tsx +7 -7
- package/dist/registry/Avatar.tsx +5 -5
- package/dist/registry/Badge.tsx +12 -15
- package/dist/registry/Banner.tsx +15 -18
- package/dist/registry/Breadcrumb.tsx +3 -3
- package/dist/registry/Button.tsx +58 -69
- package/dist/registry/ButtonGroup.tsx +9 -9
- package/dist/registry/Card.tsx +15 -15
- package/dist/registry/CardShell.tsx +6 -6
- package/dist/registry/Carousel.tsx +19 -15
- package/dist/registry/Checkbox.tsx +14 -9
- package/dist/registry/CloseButton.tsx +2 -2
- package/dist/registry/CodeBlock.tsx +15 -15
- package/dist/registry/ColorPicker.tsx +404 -0
- package/dist/registry/Combobox.tsx +318 -0
- package/dist/registry/CommandPalette.tsx +31 -24
- package/dist/registry/ContextMenu.tsx +241 -0
- package/dist/registry/DataList.tsx +4 -4
- package/dist/registry/DatePicker.tsx +494 -0
- package/dist/registry/DateRangePicker.tsx +232 -0
- package/dist/registry/Drawer.tsx +17 -15
- package/dist/registry/DropdownMenu.tsx +39 -77
- package/dist/registry/EmptyState.tsx +8 -8
- package/dist/registry/FieldMessage.tsx +123 -0
- package/dist/registry/FileUpload.tsx +318 -0
- package/dist/registry/Floating.tsx +134 -0
- package/dist/registry/HoverCard.tsx +113 -0
- package/dist/registry/Input.tsx +54 -43
- package/dist/registry/Kbd.tsx +4 -5
- package/dist/registry/Modal.tsx +17 -15
- package/dist/registry/NumberInput.tsx +183 -0
- package/dist/registry/OtpInput.tsx +219 -0
- package/dist/registry/Pagination.tsx +15 -18
- package/dist/registry/Popover.tsx +40 -45
- package/dist/registry/Progress.tsx +4 -4
- package/dist/registry/QuoteCard.tsx +7 -7
- package/dist/registry/Radio.tsx +24 -21
- package/dist/registry/RadioCards.tsx +247 -0
- package/dist/registry/ReactionBar.tsx +5 -5
- package/dist/registry/ReadingProgress.tsx +2 -2
- package/dist/registry/ReadingTime.tsx +2 -2
- package/dist/registry/Select.tsx +28 -53
- package/dist/registry/Separator.tsx +52 -0
- package/dist/registry/ShareButtons.tsx +2 -2
- package/dist/registry/Sidebar.tsx +18 -18
- package/dist/registry/Skeleton.tsx +1 -1
- package/dist/registry/Slider.tsx +229 -0
- package/dist/registry/Spinner.tsx +32 -0
- package/dist/registry/SpotlightCard.tsx +6 -6
- package/dist/registry/Stat.tsx +4 -4
- package/dist/registry/StatCard.tsx +19 -19
- package/dist/registry/Steps.tsx +7 -7
- package/dist/registry/Table.tsx +12 -10
- package/dist/registry/TableOfContents.tsx +8 -8
- package/dist/registry/Tabs.tsx +6 -6
- package/dist/registry/TagInput.tsx +9 -9
- package/dist/registry/Textarea.tsx +5 -5
- package/dist/registry/TimePicker.tsx +315 -0
- package/dist/registry/Timeline.tsx +14 -14
- package/dist/registry/Toast.tsx +15 -12
- package/dist/registry/Toggle.tsx +7 -10
- package/dist/registry/Tooltip.tsx +21 -73
- package/dist/registry/styles.css +868 -62
- package/dist/registry/useFloatingPanel.ts +112 -0
- package/dist/styles.css +868 -62
- package/dist/useFloatingPanel.d.ts +24 -0
- package/dist/useFloatingPanel.js +89 -0
- package/dist/useFloatingPanel.js.map +1 -0
- package/dist/useModalBehavior.d.ts +15 -0
- package/dist/useModalBehavior.js +57 -0
- package/dist/useModalBehavior.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { Portal } from "./Floating";
|
|
5
|
+
import { FieldLabel, FieldMessage } from "./FieldMessage";
|
|
6
|
+
import { useFloatingPanel } from "./useFloatingPanel";
|
|
7
|
+
|
|
8
|
+
export interface ComboboxOption {
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
/** A second, quieter line under the label */
|
|
12
|
+
description?: string;
|
|
13
|
+
/** Extra words to match on that aren't shown */
|
|
14
|
+
keywords?: string[];
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ComboboxProps {
|
|
19
|
+
options: ComboboxOption[];
|
|
20
|
+
value?: string | null;
|
|
21
|
+
defaultValue?: string | null;
|
|
22
|
+
onChange?: (value: string | null) => void;
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
label?: string;
|
|
25
|
+
hint?: string;
|
|
26
|
+
error?: string;
|
|
27
|
+
/** Shown when nothing matches */
|
|
28
|
+
emptyText?: string;
|
|
29
|
+
/** Replace the default "contains" match */
|
|
30
|
+
filter?: (option: ComboboxOption, query: string) => boolean;
|
|
31
|
+
/** Show a key that empties the field */
|
|
32
|
+
clearable?: boolean;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
fullWidth?: boolean;
|
|
35
|
+
name?: string;
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const defaultFilter = (o: ComboboxOption, q: string) => {
|
|
40
|
+
const hay = [o.label, o.description ?? "", ...(o.keywords ?? [])].join(" ").toLowerCase();
|
|
41
|
+
return q
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
.split(/\s+/)
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.every((word) => hay.includes(word));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/* Bolds the part of the label that matched what was typed */
|
|
49
|
+
function Highlight({ text, query }: { text: string; query: string }) {
|
|
50
|
+
const q = query.trim().toLowerCase();
|
|
51
|
+
const at = q ? text.toLowerCase().indexOf(q) : -1;
|
|
52
|
+
if (at < 0) return <>{text}</>;
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
{text.slice(0, at)}
|
|
56
|
+
<mark className="bg-transparent font-semibold text-ink">{text.slice(at, at + q.length)}</mark>
|
|
57
|
+
{text.slice(at + q.length)}
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* A Select you can type into. The field filters the list as you go,
|
|
63
|
+
arrows walk the matches, Enter picks one, and leaving the field puts
|
|
64
|
+
the chosen label back if what you typed didn't match anything. */
|
|
65
|
+
export function Combobox({
|
|
66
|
+
options,
|
|
67
|
+
value,
|
|
68
|
+
defaultValue = null,
|
|
69
|
+
onChange,
|
|
70
|
+
placeholder = "search…",
|
|
71
|
+
label,
|
|
72
|
+
hint,
|
|
73
|
+
error,
|
|
74
|
+
emptyText = "nothing matches",
|
|
75
|
+
filter = defaultFilter,
|
|
76
|
+
clearable = false,
|
|
77
|
+
disabled = false,
|
|
78
|
+
fullWidth = false,
|
|
79
|
+
name,
|
|
80
|
+
className = "",
|
|
81
|
+
}: ComboboxProps) {
|
|
82
|
+
const uid = useId();
|
|
83
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
84
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
85
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
86
|
+
const [internal, setInternal] = useState<string | null>(defaultValue);
|
|
87
|
+
const selectedValue = value !== undefined ? value : internal;
|
|
88
|
+
const selected = options.find((o) => o.value === selectedValue) ?? null;
|
|
89
|
+
const [query, setQuery] = useState<string | null>(null); // null: not typing, show the selection
|
|
90
|
+
const [active, setActive] = useState(0);
|
|
91
|
+
const { open, mounted, show, close, setFloating, panelStyle } = useFloatingPanel(ref, {
|
|
92
|
+
matchWidth: true,
|
|
93
|
+
onEscape: () => setQuery(null),
|
|
94
|
+
});
|
|
95
|
+
const messageId = error || hint ? `${uid}-message` : undefined;
|
|
96
|
+
|
|
97
|
+
const matches = useMemo(
|
|
98
|
+
() => (query ? options.filter((o) => filter(o, query)) : options),
|
|
99
|
+
[options, query, filter],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!open) return;
|
|
104
|
+
listRef.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" });
|
|
105
|
+
}, [open, active]);
|
|
106
|
+
|
|
107
|
+
const pick = (o: ComboboxOption | null) => {
|
|
108
|
+
if (o?.disabled) return;
|
|
109
|
+
setInternal(o?.value ?? null);
|
|
110
|
+
onChange?.(o?.value ?? null);
|
|
111
|
+
setQuery(null);
|
|
112
|
+
close();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const openList = () => {
|
|
116
|
+
if (open || disabled) return;
|
|
117
|
+
const i = matches.findIndex((o) => o.value === selectedValue);
|
|
118
|
+
setActive(Math.max(0, i));
|
|
119
|
+
show();
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const step = (dir: 1 | -1) => {
|
|
123
|
+
if (!matches.length) return;
|
|
124
|
+
let i = active;
|
|
125
|
+
for (let n = 0; n < matches.length; n++) {
|
|
126
|
+
i = (i + dir + matches.length) % matches.length;
|
|
127
|
+
if (!matches[i].disabled) break;
|
|
128
|
+
}
|
|
129
|
+
setActive(i);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
133
|
+
switch (e.key) {
|
|
134
|
+
case "ArrowDown":
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
if (!open) openList();
|
|
137
|
+
else step(1);
|
|
138
|
+
break;
|
|
139
|
+
case "ArrowUp":
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
if (!open) openList();
|
|
142
|
+
else step(-1);
|
|
143
|
+
break;
|
|
144
|
+
case "Enter":
|
|
145
|
+
if (open && matches[active]) {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
pick(matches[active]);
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
case "Tab":
|
|
151
|
+
if (open) close();
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const shownText = query ?? selected?.label ?? "";
|
|
157
|
+
const showClear = clearable && !!selected && !disabled;
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
|
|
161
|
+
{label && (
|
|
162
|
+
<FieldLabel id={`${uid}-label`} htmlFor={`${uid}-input`}>
|
|
163
|
+
{label}
|
|
164
|
+
</FieldLabel>
|
|
165
|
+
)}
|
|
166
|
+
{/* Closed, it reads exactly like Select: the chosen label and a
|
|
167
|
+
chevron. Focus it and it turns into a field you type into. */}
|
|
168
|
+
<div ref={ref} className={["group/combo relative isolate", fullWidth ? "w-full" : "w-60", className].join(" ")}>
|
|
169
|
+
<input
|
|
170
|
+
ref={inputRef}
|
|
171
|
+
id={`${uid}-input`}
|
|
172
|
+
role="combobox"
|
|
173
|
+
aria-expanded={open}
|
|
174
|
+
aria-controls={`${uid}-list`}
|
|
175
|
+
aria-autocomplete="list"
|
|
176
|
+
aria-activedescendant={open && matches[active] ? `${uid}-opt-${active}` : undefined}
|
|
177
|
+
aria-invalid={error ? true : undefined}
|
|
178
|
+
aria-describedby={messageId}
|
|
179
|
+
autoComplete="off"
|
|
180
|
+
spellCheck={false}
|
|
181
|
+
disabled={disabled}
|
|
182
|
+
placeholder={selected?.label ?? placeholder}
|
|
183
|
+
value={shownText}
|
|
184
|
+
onChange={(e) => {
|
|
185
|
+
setQuery(e.target.value);
|
|
186
|
+
setActive(0);
|
|
187
|
+
if (!open) show();
|
|
188
|
+
}}
|
|
189
|
+
onClick={openList}
|
|
190
|
+
// typing replaces the chosen label instead of adding to it
|
|
191
|
+
onFocus={(e) => e.target.select()}
|
|
192
|
+
onKeyDown={onKeyDown}
|
|
193
|
+
onBlur={() => {
|
|
194
|
+
setQuery(null);
|
|
195
|
+
if (open) close();
|
|
196
|
+
}}
|
|
197
|
+
data-open={open || undefined}
|
|
198
|
+
className={[
|
|
199
|
+
"field relative z-10 w-full rounded-xl py-3 pl-4 text-[13.5px] font-medium text-ink caret-ink",
|
|
200
|
+
"placeholder:font-normal placeholder:text-ink-faint focus:placeholder:text-ink-ghost",
|
|
201
|
+
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
202
|
+
open ? "cursor-text" : "cursor-pointer",
|
|
203
|
+
showClear ? "pr-[60px]" : "pr-10",
|
|
204
|
+
].join(" ")}
|
|
205
|
+
/>
|
|
206
|
+
|
|
207
|
+
{/* Input's sweep of light as the field comes up */}
|
|
208
|
+
<span aria-hidden className="pointer-events-none absolute inset-0 z-15 overflow-hidden rounded-xl">
|
|
209
|
+
<span
|
|
210
|
+
className="absolute inset-y-0 -left-[30%] block w-[30%] opacity-0 group-focus-within/combo:animate-input-shimmer"
|
|
211
|
+
style={{ background: "linear-gradient(90deg, transparent 0%, var(--shimmer) 50%, transparent 100%)" }}
|
|
212
|
+
/>
|
|
213
|
+
</span>
|
|
214
|
+
|
|
215
|
+
{showClear && (
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
aria-label="clear"
|
|
219
|
+
tabIndex={-1}
|
|
220
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
221
|
+
onClick={() => pick(null)}
|
|
222
|
+
className={[
|
|
223
|
+
"absolute right-9 top-1/2 z-20 grid h-[18px] w-[18px] -translate-y-1/2 cursor-pointer place-items-center rounded-full",
|
|
224
|
+
"text-ink-faint transition-[opacity,color,background-color] duration-150 hover:bg-surface-active hover:text-ink",
|
|
225
|
+
"opacity-0 group-hover/combo:opacity-100 group-focus-within/combo:opacity-100",
|
|
226
|
+
].join(" ")}
|
|
227
|
+
>
|
|
228
|
+
<svg aria-hidden width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2.75" viewBox="0 0 24 24">
|
|
229
|
+
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
|
|
230
|
+
</svg>
|
|
231
|
+
</button>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
<svg
|
|
235
|
+
aria-hidden
|
|
236
|
+
width="14"
|
|
237
|
+
height="14"
|
|
238
|
+
fill="none"
|
|
239
|
+
stroke="currentColor"
|
|
240
|
+
strokeWidth="2"
|
|
241
|
+
viewBox="0 0 24 24"
|
|
242
|
+
className={[
|
|
243
|
+
"pointer-events-none absolute right-4 top-1/2 z-20 -mt-[7px] transition-[transform,color] duration-300 ease-[var(--ease-spring)]",
|
|
244
|
+
open ? "rotate-180 text-ink" : "text-ink-faint group-hover/combo:text-ink-label",
|
|
245
|
+
].join(" ")}
|
|
246
|
+
>
|
|
247
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
248
|
+
</svg>
|
|
249
|
+
{name && <input type="hidden" name={name} value={selectedValue ?? ""} />}
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
{mounted && (
|
|
253
|
+
<Portal>
|
|
254
|
+
<div
|
|
255
|
+
ref={setFloating}
|
|
256
|
+
className="rounded-[16px] p-1.5"
|
|
257
|
+
style={{ ...panelStyle, maxWidth: "min(420px, calc(100vw - 16px))" }}
|
|
258
|
+
>
|
|
259
|
+
<div
|
|
260
|
+
ref={listRef}
|
|
261
|
+
id={`${uid}-list`}
|
|
262
|
+
role="listbox"
|
|
263
|
+
aria-labelledby={label ? `${uid}-label` : undefined}
|
|
264
|
+
// keeps focus in the field, even when the scrollbar is dragged
|
|
265
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
266
|
+
className="flex max-h-72 flex-col gap-0.5 overflow-y-auto rounded-[11px] bg-panel-face p-1 scroll-thin"
|
|
267
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
268
|
+
>
|
|
269
|
+
{matches.length === 0 && (
|
|
270
|
+
<div role="presentation" className="px-2.5 py-2 text-[13px] text-ink-faint">
|
|
271
|
+
{emptyText}
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
274
|
+
{matches.map((o, i) => {
|
|
275
|
+
const isSel = o.value === selectedValue;
|
|
276
|
+
const isActive = i === active;
|
|
277
|
+
return (
|
|
278
|
+
<div
|
|
279
|
+
key={o.value}
|
|
280
|
+
id={`${uid}-opt-${i}`}
|
|
281
|
+
data-index={i}
|
|
282
|
+
role="option"
|
|
283
|
+
aria-selected={isSel}
|
|
284
|
+
aria-disabled={o.disabled || undefined}
|
|
285
|
+
onClick={() => pick(o)}
|
|
286
|
+
onMouseMove={() => !o.disabled && setActive(i)}
|
|
287
|
+
className={[
|
|
288
|
+
"flex w-full items-center justify-between gap-3 rounded-[8px] px-2.5 py-2 text-[13px] transition-colors duration-100",
|
|
289
|
+
o.disabled ? "cursor-not-allowed opacity-40" : "cursor-pointer",
|
|
290
|
+
isSel ? "font-medium" : "",
|
|
291
|
+
// one ink per state, as in Select
|
|
292
|
+
isSel || isActive ? "text-ink" : "text-ink-body",
|
|
293
|
+
isActive ? "bg-surface-active/80" : "",
|
|
294
|
+
].join(" ")}
|
|
295
|
+
style={{ animation: `select-option-in 300ms var(--ease-out-soft) ${40 + Math.min(i, 8) * 22}ms both` }}
|
|
296
|
+
>
|
|
297
|
+
<span className="flex min-w-0 flex-col gap-px">
|
|
298
|
+
<span className="truncate">
|
|
299
|
+
<Highlight text={o.label} query={query ?? ""} />
|
|
300
|
+
</span>
|
|
301
|
+
{o.description && <span className="truncate text-[11.5px] font-normal text-ink-faint">{o.description}</span>}
|
|
302
|
+
</span>
|
|
303
|
+
{isSel && (
|
|
304
|
+
<svg aria-hidden className="shrink-0 text-ink" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
|
305
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
306
|
+
</svg>
|
|
307
|
+
)}
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
})}
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
</Portal>
|
|
314
|
+
)}
|
|
315
|
+
<FieldMessage id={messageId} error={error} hint={hint} />
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
@@ -9,6 +9,7 @@ import React, {
|
|
|
9
9
|
useCallback,
|
|
10
10
|
useId,
|
|
11
11
|
} from "react";
|
|
12
|
+
import { Portal } from "./Floating";
|
|
12
13
|
import { Kbd } from "./Kbd";
|
|
13
14
|
import { useModalBehavior } from "./useModalBehavior";
|
|
14
15
|
|
|
@@ -75,19 +76,23 @@ export function CommandPalette({
|
|
|
75
76
|
[filtered],
|
|
76
77
|
);
|
|
77
78
|
|
|
78
|
-
// Mount / unmount lifecycle (matches Modal pattern)
|
|
79
|
-
|
|
79
|
+
// Mount / unmount lifecycle (matches Modal pattern); every opening starts fresh
|
|
80
|
+
const [wasOpen, setWasOpen] = useState(false);
|
|
81
|
+
if (open !== wasOpen) {
|
|
82
|
+
setWasOpen(open);
|
|
80
83
|
if (open) {
|
|
81
84
|
setMounted(true);
|
|
82
85
|
setQuery("");
|
|
83
86
|
setActiveIndex(0);
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
if (mounted) {
|
|
87
|
+
} else {
|
|
87
88
|
setVisible(false);
|
|
88
|
-
const t = setTimeout(() => setMounted(false), CLOSE_MS);
|
|
89
|
-
return () => clearTimeout(t);
|
|
90
89
|
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (open || !mounted) return;
|
|
94
|
+
const t = setTimeout(() => setMounted(false), CLOSE_MS);
|
|
95
|
+
return () => clearTimeout(t);
|
|
91
96
|
}, [open, mounted]);
|
|
92
97
|
|
|
93
98
|
// Enter animation (matches Modal pattern)
|
|
@@ -165,7 +170,8 @@ export function CommandPalette({
|
|
|
165
170
|
let itemIndex = 0;
|
|
166
171
|
|
|
167
172
|
return (
|
|
168
|
-
<
|
|
173
|
+
<Portal>
|
|
174
|
+
<div className="fixed inset-0 z-90 flex items-start justify-center pt-[min(20vh,140px)] px-4">
|
|
169
175
|
{/* Backdrop */}
|
|
170
176
|
<div
|
|
171
177
|
className={[
|
|
@@ -197,13 +203,13 @@ export function CommandPalette({
|
|
|
197
203
|
style={{ background: "var(--panel-bezel)", boxShadow: "var(--modal-shadow)" }}
|
|
198
204
|
>
|
|
199
205
|
<div
|
|
200
|
-
className="bg-
|
|
206
|
+
className="bg-panel-face rounded-[14px] overflow-hidden"
|
|
201
207
|
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
202
208
|
>
|
|
203
209
|
{/* Search input */}
|
|
204
210
|
<div className="flex items-center gap-3 px-4 py-3.5">
|
|
205
211
|
<svg
|
|
206
|
-
className="shrink-0 text-
|
|
212
|
+
className="shrink-0 text-ink-faint"
|
|
207
213
|
width="14"
|
|
208
214
|
height="14"
|
|
209
215
|
fill="none"
|
|
@@ -228,13 +234,13 @@ export function CommandPalette({
|
|
|
228
234
|
aria-controls={`${uid}-list`}
|
|
229
235
|
aria-activedescendant={flatItems[activeIndex] ? `${uid}-item-${activeIndex}` : undefined}
|
|
230
236
|
aria-autocomplete="list"
|
|
231
|
-
className="flex-1 min-w-0 text-[14.5px] font-medium text-
|
|
237
|
+
className="flex-1 min-w-0 text-[14.5px] font-medium text-ink placeholder:text-ink-faint placeholder:font-normal bg-transparent border-none outline-none"
|
|
232
238
|
/>
|
|
233
239
|
<Kbd className="shrink-0">esc</Kbd>
|
|
234
240
|
</div>
|
|
235
241
|
|
|
236
242
|
{/* Separator */}
|
|
237
|
-
<div className="border-b border-
|
|
243
|
+
<div className="border-b border-line" />
|
|
238
244
|
|
|
239
245
|
{/* Results */}
|
|
240
246
|
<div
|
|
@@ -245,15 +251,15 @@ export function CommandPalette({
|
|
|
245
251
|
>
|
|
246
252
|
{filtered.length === 0 ? (
|
|
247
253
|
<div className="py-10 flex flex-col items-center gap-1 text-center">
|
|
248
|
-
<p className="text-[13px] font-medium text-
|
|
254
|
+
<p className="text-[13px] font-medium text-ink-label">
|
|
249
255
|
nothing for “{query}”
|
|
250
256
|
</p>
|
|
251
|
-
<p className="text-[12px] text-
|
|
257
|
+
<p className="text-[12px] text-ink-faint">try a shorter or different word.</p>
|
|
252
258
|
</div>
|
|
253
259
|
) : (
|
|
254
260
|
filtered.map((section, si) => (
|
|
255
261
|
<div key={section.title} role="group" aria-label={section.title} className={si > 0 ? "mt-3" : ""}>
|
|
256
|
-
<div aria-hidden className="px-3 py-1.5 text-[11.5px] font-medium text-
|
|
262
|
+
<div aria-hidden className="px-3 py-1.5 text-[11.5px] font-medium text-ink-faint lowercase select-none">
|
|
257
263
|
{section.title}
|
|
258
264
|
</div>
|
|
259
265
|
<div className="flex flex-col gap-0.5">
|
|
@@ -272,7 +278,7 @@ export function CommandPalette({
|
|
|
272
278
|
className={[
|
|
273
279
|
"group/cmd flex items-center gap-3 w-full text-left px-3 py-2 rounded-[10px] cursor-pointer",
|
|
274
280
|
"transition-[background,box-shadow] duration-100",
|
|
275
|
-
isActive ? "bg-
|
|
281
|
+
isActive ? "bg-surface-hover" : "",
|
|
276
282
|
].join(" ")}
|
|
277
283
|
style={{ boxShadow: isActive ? "var(--panel-face-shadow)" : undefined }}
|
|
278
284
|
>
|
|
@@ -280,19 +286,19 @@ export function CommandPalette({
|
|
|
280
286
|
<span
|
|
281
287
|
className={[
|
|
282
288
|
"shrink-0 w-7 h-7 rounded-[8px] flex items-center justify-center transition-colors duration-100",
|
|
283
|
-
isActive ? "text-
|
|
289
|
+
isActive ? "text-ink" : "text-ink-muted",
|
|
284
290
|
].join(" ")}
|
|
285
|
-
style={{ background: "var(--face
|
|
291
|
+
style={{ background: "var(--face)", boxShadow: "var(--face-shadow)" }}
|
|
286
292
|
>
|
|
287
293
|
{item.icon}
|
|
288
294
|
</span>
|
|
289
295
|
)}
|
|
290
296
|
<div className="flex-1 min-w-0 flex flex-col">
|
|
291
|
-
<span className="text-[13px] font-medium text-
|
|
297
|
+
<span className="text-[13px] font-medium text-ink truncate">
|
|
292
298
|
{item.label}
|
|
293
299
|
</span>
|
|
294
300
|
{item.description && (
|
|
295
|
-
<span className="text-[12px] text-
|
|
301
|
+
<span className="text-[12px] text-ink-muted truncate">
|
|
296
302
|
{item.description}
|
|
297
303
|
</span>
|
|
298
304
|
)}
|
|
@@ -308,7 +314,7 @@ export function CommandPalette({
|
|
|
308
314
|
strokeWidth="2"
|
|
309
315
|
viewBox="0 0 24 24"
|
|
310
316
|
className={[
|
|
311
|
-
"shrink-0 text-
|
|
317
|
+
"shrink-0 text-ink-faint transition-all duration-150",
|
|
312
318
|
isActive ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-1",
|
|
313
319
|
].join(" ")}
|
|
314
320
|
>
|
|
@@ -326,8 +332,8 @@ export function CommandPalette({
|
|
|
326
332
|
|
|
327
333
|
{/* Key hints — teach the palette while it's open */}
|
|
328
334
|
<div
|
|
329
|
-
className="flex items-center gap-4 px-4 py-2.5 border-t border-
|
|
330
|
-
style={{ background: "
|
|
335
|
+
className="flex items-center gap-4 px-4 py-2.5 border-t border-line text-[11.5px] text-ink-muted select-none"
|
|
336
|
+
style={{ background: "var(--palette-tray)" }}
|
|
331
337
|
>
|
|
332
338
|
<span className="inline-flex items-center gap-1.5">
|
|
333
339
|
<Kbd>↑</Kbd>
|
|
@@ -338,12 +344,13 @@ export function CommandPalette({
|
|
|
338
344
|
<Kbd>↵</Kbd>
|
|
339
345
|
Open
|
|
340
346
|
</span>
|
|
341
|
-
<span className="ml-auto tabular-nums text-
|
|
347
|
+
<span className="ml-auto tabular-nums text-ink-faint">
|
|
342
348
|
{flatItems.length} {flatItems.length === 1 ? "result" : "results"}
|
|
343
349
|
</span>
|
|
344
350
|
</div>
|
|
345
351
|
</div>
|
|
346
352
|
</div>
|
|
347
353
|
</div>
|
|
354
|
+
</Portal>
|
|
348
355
|
);
|
|
349
356
|
}
|