@blurrow/ui 0.1.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 +60 -20
- 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 +1644 -0
- 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 +140 -0
- package/dist/registry/Alert.tsx +95 -0
- package/dist/registry/AuthorCard.tsx +67 -0
- package/dist/registry/Avatar.tsx +116 -0
- package/dist/registry/Badge.tsx +55 -0
- package/dist/registry/Banner.tsx +86 -0
- package/dist/registry/Breadcrumb.tsx +60 -0
- package/dist/registry/Button.tsx +361 -0
- package/dist/registry/ButtonGroup.tsx +82 -0
- package/dist/registry/Card.tsx +241 -0
- package/dist/registry/CardShell.tsx +78 -0
- package/dist/registry/Carousel.tsx +296 -0
- package/dist/registry/Checkbox.tsx +104 -0
- package/dist/registry/CloseButton.tsx +68 -0
- package/dist/registry/CodeBlock.tsx +202 -0
- package/dist/registry/ColorPicker.tsx +404 -0
- package/dist/registry/Combobox.tsx +318 -0
- package/dist/registry/CommandPalette.tsx +356 -0
- package/dist/registry/ContextMenu.tsx +241 -0
- package/dist/registry/DataList.tsx +49 -0
- package/dist/registry/DatePicker.tsx +494 -0
- package/dist/registry/DateRangePicker.tsx +232 -0
- package/dist/registry/Drawer.tsx +166 -0
- package/dist/registry/DropdownMenu.tsx +219 -0
- package/dist/registry/EmptyState.tsx +55 -0
- 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 +108 -0
- package/dist/registry/Kbd.tsx +23 -0
- package/dist/registry/Modal.tsx +130 -0
- package/dist/registry/NumberInput.tsx +183 -0
- package/dist/registry/OtpInput.tsx +219 -0
- package/dist/registry/Pagination.tsx +160 -0
- package/dist/registry/Popover.tsx +116 -0
- package/dist/registry/Progress.tsx +66 -0
- package/dist/registry/QuoteCard.tsx +93 -0
- package/dist/registry/Radio.tsx +205 -0
- package/dist/registry/RadioCards.tsx +247 -0
- package/dist/registry/ReactionBar.tsx +101 -0
- package/dist/registry/ReadingProgress.tsx +51 -0
- package/dist/registry/ReadingTime.tsx +35 -0
- package/dist/registry/Select.tsx +265 -0
- package/dist/registry/Separator.tsx +52 -0
- package/dist/registry/ShareButtons.tsx +134 -0
- package/dist/registry/Sidebar.tsx +182 -0
- package/dist/registry/Skeleton.tsx +83 -0
- package/dist/registry/Slider.tsx +229 -0
- package/dist/registry/Spinner.tsx +32 -0
- package/dist/registry/SpotlightCard.tsx +106 -0
- package/dist/registry/Stat.tsx +82 -0
- package/dist/registry/StatCard.tsx +256 -0
- package/dist/registry/Steps.tsx +109 -0
- package/dist/registry/Table.tsx +123 -0
- package/dist/registry/TableOfContents.tsx +116 -0
- package/dist/registry/Tabs.tsx +136 -0
- package/dist/registry/TagInput.tsx +191 -0
- package/dist/registry/Textarea.tsx +84 -0
- package/dist/registry/TimePicker.tsx +315 -0
- package/dist/registry/Timeline.tsx +102 -0
- package/dist/registry/Toast.tsx +388 -0
- package/dist/registry/Toggle.tsx +96 -0
- package/dist/registry/Tooltip.tsx +160 -0
- package/dist/registry/styles.css +1207 -0
- package/dist/registry/useFloatingPanel.ts +112 -0
- package/dist/registry/useModalBehavior.ts +77 -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 +23 -6
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
useMemo,
|
|
9
|
+
useCallback,
|
|
10
|
+
useId,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { Portal } from "./Floating";
|
|
13
|
+
import { Kbd } from "./Kbd";
|
|
14
|
+
import { useModalBehavior } from "./useModalBehavior";
|
|
15
|
+
|
|
16
|
+
interface CommandItem {
|
|
17
|
+
key: string;
|
|
18
|
+
label: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
icon?: React.ReactNode;
|
|
21
|
+
shortcut?: string;
|
|
22
|
+
onSelect?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface CommandSection {
|
|
26
|
+
title: string;
|
|
27
|
+
items: CommandItem[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface CommandPaletteProps {
|
|
31
|
+
open: boolean;
|
|
32
|
+
onClose: () => void;
|
|
33
|
+
sections: CommandSection[];
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CLOSE_MS = 240;
|
|
39
|
+
|
|
40
|
+
export function CommandPalette({
|
|
41
|
+
open,
|
|
42
|
+
onClose,
|
|
43
|
+
sections,
|
|
44
|
+
placeholder = "search commands…",
|
|
45
|
+
className = "",
|
|
46
|
+
}: CommandPaletteProps) {
|
|
47
|
+
const [mounted, setMounted] = useState(false);
|
|
48
|
+
const [visible, setVisible] = useState(false);
|
|
49
|
+
const [query, setQuery] = useState("");
|
|
50
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
51
|
+
|
|
52
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
53
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
54
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
55
|
+
const uid = useId();
|
|
56
|
+
|
|
57
|
+
// Filter sections based on query
|
|
58
|
+
const filtered = useMemo(() => {
|
|
59
|
+
const q = query.toLowerCase().trim();
|
|
60
|
+
if (!q) return sections;
|
|
61
|
+
return sections
|
|
62
|
+
.map((section) => ({
|
|
63
|
+
...section,
|
|
64
|
+
items: section.items.filter(
|
|
65
|
+
(item) =>
|
|
66
|
+
item.label.toLowerCase().includes(q) ||
|
|
67
|
+
item.description?.toLowerCase().includes(q),
|
|
68
|
+
),
|
|
69
|
+
}))
|
|
70
|
+
.filter((section) => section.items.length > 0);
|
|
71
|
+
}, [sections, query]);
|
|
72
|
+
|
|
73
|
+
// Flat list of all visible items for keyboard nav
|
|
74
|
+
const flatItems = useMemo(
|
|
75
|
+
() => filtered.flatMap((s) => s.items),
|
|
76
|
+
[filtered],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Mount / unmount lifecycle (matches Modal pattern); every opening starts fresh
|
|
80
|
+
const [wasOpen, setWasOpen] = useState(false);
|
|
81
|
+
if (open !== wasOpen) {
|
|
82
|
+
setWasOpen(open);
|
|
83
|
+
if (open) {
|
|
84
|
+
setMounted(true);
|
|
85
|
+
setQuery("");
|
|
86
|
+
setActiveIndex(0);
|
|
87
|
+
} else {
|
|
88
|
+
setVisible(false);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (open || !mounted) return;
|
|
94
|
+
const t = setTimeout(() => setMounted(false), CLOSE_MS);
|
|
95
|
+
return () => clearTimeout(t);
|
|
96
|
+
}, [open, mounted]);
|
|
97
|
+
|
|
98
|
+
// Enter animation (matches Modal pattern)
|
|
99
|
+
useLayoutEffect(() => {
|
|
100
|
+
if (!mounted || !open || visible) return;
|
|
101
|
+
|
|
102
|
+
const el = dialogRef.current;
|
|
103
|
+
if (el) {
|
|
104
|
+
void el.getBoundingClientRect();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const id = requestAnimationFrame(() => {
|
|
108
|
+
setVisible(true);
|
|
109
|
+
});
|
|
110
|
+
return () => cancelAnimationFrame(id);
|
|
111
|
+
}, [mounted, open, visible]);
|
|
112
|
+
|
|
113
|
+
// Reset active index when filter changes
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
setActiveIndex(0);
|
|
116
|
+
}, [query]);
|
|
117
|
+
|
|
118
|
+
// Scroll active item into view
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (!listRef.current) return;
|
|
121
|
+
const activeEl = listRef.current.querySelector(
|
|
122
|
+
`[data-command-index="${activeIndex}"]`,
|
|
123
|
+
);
|
|
124
|
+
if (activeEl) {
|
|
125
|
+
activeEl.scrollIntoView({ block: "nearest" });
|
|
126
|
+
}
|
|
127
|
+
}, [activeIndex]);
|
|
128
|
+
|
|
129
|
+
// Handle selection
|
|
130
|
+
const handleSelect = useCallback(
|
|
131
|
+
(item: CommandItem) => {
|
|
132
|
+
item.onSelect?.();
|
|
133
|
+
onClose();
|
|
134
|
+
},
|
|
135
|
+
[onClose],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Keyboard navigation
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (!open) return;
|
|
141
|
+
const onKey = (e: KeyboardEvent) => {
|
|
142
|
+
switch (e.key) {
|
|
143
|
+
case "ArrowDown":
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
setActiveIndex((i) => (i + 1 >= flatItems.length ? 0 : i + 1));
|
|
146
|
+
break;
|
|
147
|
+
case "ArrowUp":
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
setActiveIndex((i) =>
|
|
150
|
+
i - 1 < 0 ? Math.max(flatItems.length - 1, 0) : i - 1,
|
|
151
|
+
);
|
|
152
|
+
break;
|
|
153
|
+
case "Enter":
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
if (flatItems[activeIndex]) {
|
|
156
|
+
handleSelect(flatItems[activeIndex]);
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
document.addEventListener("keydown", onKey);
|
|
162
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
163
|
+
}, [open, onClose, flatItems, activeIndex, handleSelect]);
|
|
164
|
+
|
|
165
|
+
// Escape, focus trap + restore, scroll lock
|
|
166
|
+
useModalBehavior({ open, ready: visible, ref: dialogRef, onClose, initialFocus: inputRef });
|
|
167
|
+
|
|
168
|
+
if (!mounted) return null;
|
|
169
|
+
|
|
170
|
+
let itemIndex = 0;
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<Portal>
|
|
174
|
+
<div className="fixed inset-0 z-90 flex items-start justify-center pt-[min(20vh,140px)] px-4">
|
|
175
|
+
{/* Backdrop */}
|
|
176
|
+
<div
|
|
177
|
+
className={[
|
|
178
|
+
"absolute inset-0 backdrop-blur-[6px]",
|
|
179
|
+
"transition-opacity ease-out",
|
|
180
|
+
visible ? "opacity-100 duration-300" : "opacity-0 duration-200",
|
|
181
|
+
].join(" ")}
|
|
182
|
+
style={{ background: "var(--scrim)" }}
|
|
183
|
+
aria-hidden
|
|
184
|
+
onClick={onClose}
|
|
185
|
+
/>
|
|
186
|
+
|
|
187
|
+
{/* Palette panel */}
|
|
188
|
+
<div
|
|
189
|
+
ref={dialogRef}
|
|
190
|
+
role="dialog"
|
|
191
|
+
aria-modal="true"
|
|
192
|
+
aria-label="Command palette"
|
|
193
|
+
className={[
|
|
194
|
+
"relative z-10 w-full max-w-lg rounded-[20px] p-1.5 overflow-hidden",
|
|
195
|
+
"transition-[opacity,transform,scale,translate] ease-[var(--ease-spring)]",
|
|
196
|
+
visible
|
|
197
|
+
? "opacity-100 scale-100 translate-y-0 duration-300"
|
|
198
|
+
: "opacity-0 scale-[0.94] translate-y-3 duration-200",
|
|
199
|
+
className,
|
|
200
|
+
]
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.join(" ")}
|
|
203
|
+
style={{ background: "var(--panel-bezel)", boxShadow: "var(--modal-shadow)" }}
|
|
204
|
+
>
|
|
205
|
+
<div
|
|
206
|
+
className="bg-panel-face rounded-[14px] overflow-hidden"
|
|
207
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
208
|
+
>
|
|
209
|
+
{/* Search input */}
|
|
210
|
+
<div className="flex items-center gap-3 px-4 py-3.5">
|
|
211
|
+
<svg
|
|
212
|
+
className="shrink-0 text-ink-faint"
|
|
213
|
+
width="14"
|
|
214
|
+
height="14"
|
|
215
|
+
fill="none"
|
|
216
|
+
stroke="currentColor"
|
|
217
|
+
strokeWidth="1.75"
|
|
218
|
+
viewBox="0 0 24 24"
|
|
219
|
+
>
|
|
220
|
+
<path
|
|
221
|
+
strokeLinecap="round"
|
|
222
|
+
strokeLinejoin="round"
|
|
223
|
+
d="M21 21l-4.35-4.35M11 19a8 8 0 100-16 8 8 0 000 16z"
|
|
224
|
+
/>
|
|
225
|
+
</svg>
|
|
226
|
+
<input
|
|
227
|
+
ref={inputRef}
|
|
228
|
+
type="text"
|
|
229
|
+
value={query}
|
|
230
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
231
|
+
placeholder={placeholder}
|
|
232
|
+
role="combobox"
|
|
233
|
+
aria-expanded="true"
|
|
234
|
+
aria-controls={`${uid}-list`}
|
|
235
|
+
aria-activedescendant={flatItems[activeIndex] ? `${uid}-item-${activeIndex}` : undefined}
|
|
236
|
+
aria-autocomplete="list"
|
|
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"
|
|
238
|
+
/>
|
|
239
|
+
<Kbd className="shrink-0">esc</Kbd>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
{/* Separator */}
|
|
243
|
+
<div className="border-b border-line" />
|
|
244
|
+
|
|
245
|
+
{/* Results */}
|
|
246
|
+
<div
|
|
247
|
+
ref={listRef}
|
|
248
|
+
id={`${uid}-list`}
|
|
249
|
+
role="listbox"
|
|
250
|
+
className="max-h-[340px] overflow-y-auto scroll-thin py-2 px-2"
|
|
251
|
+
>
|
|
252
|
+
{filtered.length === 0 ? (
|
|
253
|
+
<div className="py-10 flex flex-col items-center gap-1 text-center">
|
|
254
|
+
<p className="text-[13px] font-medium text-ink-label">
|
|
255
|
+
nothing for “{query}”
|
|
256
|
+
</p>
|
|
257
|
+
<p className="text-[12px] text-ink-faint">try a shorter or different word.</p>
|
|
258
|
+
</div>
|
|
259
|
+
) : (
|
|
260
|
+
filtered.map((section, si) => (
|
|
261
|
+
<div key={section.title} role="group" aria-label={section.title} className={si > 0 ? "mt-3" : ""}>
|
|
262
|
+
<div aria-hidden className="px-3 py-1.5 text-[11.5px] font-medium text-ink-faint lowercase select-none">
|
|
263
|
+
{section.title}
|
|
264
|
+
</div>
|
|
265
|
+
<div className="flex flex-col gap-0.5">
|
|
266
|
+
{section.items.map((item) => {
|
|
267
|
+
const currentIndex = itemIndex++;
|
|
268
|
+
const isActive = currentIndex === activeIndex;
|
|
269
|
+
return (
|
|
270
|
+
<div
|
|
271
|
+
key={item.key}
|
|
272
|
+
id={`${uid}-item-${currentIndex}`}
|
|
273
|
+
role="option"
|
|
274
|
+
aria-selected={isActive}
|
|
275
|
+
data-command-index={currentIndex}
|
|
276
|
+
onClick={() => handleSelect(item)}
|
|
277
|
+
onMouseMove={() => setActiveIndex(currentIndex)}
|
|
278
|
+
className={[
|
|
279
|
+
"group/cmd flex items-center gap-3 w-full text-left px-3 py-2 rounded-[10px] cursor-pointer",
|
|
280
|
+
"transition-[background,box-shadow] duration-100",
|
|
281
|
+
isActive ? "bg-surface-hover" : "",
|
|
282
|
+
].join(" ")}
|
|
283
|
+
style={{ boxShadow: isActive ? "var(--panel-face-shadow)" : undefined }}
|
|
284
|
+
>
|
|
285
|
+
{item.icon && (
|
|
286
|
+
<span
|
|
287
|
+
className={[
|
|
288
|
+
"shrink-0 w-7 h-7 rounded-[8px] flex items-center justify-center transition-colors duration-100",
|
|
289
|
+
isActive ? "text-ink" : "text-ink-muted",
|
|
290
|
+
].join(" ")}
|
|
291
|
+
style={{ background: "var(--face)", boxShadow: "var(--face-shadow)" }}
|
|
292
|
+
>
|
|
293
|
+
{item.icon}
|
|
294
|
+
</span>
|
|
295
|
+
)}
|
|
296
|
+
<div className="flex-1 min-w-0 flex flex-col">
|
|
297
|
+
<span className="text-[13px] font-medium text-ink truncate">
|
|
298
|
+
{item.label}
|
|
299
|
+
</span>
|
|
300
|
+
{item.description && (
|
|
301
|
+
<span className="text-[12px] text-ink-muted truncate">
|
|
302
|
+
{item.description}
|
|
303
|
+
</span>
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
{item.shortcut && <Kbd className="shrink-0">{item.shortcut}</Kbd>}
|
|
307
|
+
{!item.shortcut && (
|
|
308
|
+
<svg
|
|
309
|
+
aria-hidden
|
|
310
|
+
width="12"
|
|
311
|
+
height="12"
|
|
312
|
+
fill="none"
|
|
313
|
+
stroke="currentColor"
|
|
314
|
+
strokeWidth="2"
|
|
315
|
+
viewBox="0 0 24 24"
|
|
316
|
+
className={[
|
|
317
|
+
"shrink-0 text-ink-faint transition-all duration-150",
|
|
318
|
+
isActive ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-1",
|
|
319
|
+
].join(" ")}
|
|
320
|
+
>
|
|
321
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
322
|
+
</svg>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
})}
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
))
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
{/* Key hints — teach the palette while it's open */}
|
|
334
|
+
<div
|
|
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)" }}
|
|
337
|
+
>
|
|
338
|
+
<span className="inline-flex items-center gap-1.5">
|
|
339
|
+
<Kbd>↑</Kbd>
|
|
340
|
+
<Kbd>↓</Kbd>
|
|
341
|
+
Navigate
|
|
342
|
+
</span>
|
|
343
|
+
<span className="inline-flex items-center gap-1.5">
|
|
344
|
+
<Kbd>↵</Kbd>
|
|
345
|
+
Open
|
|
346
|
+
</span>
|
|
347
|
+
<span className="ml-auto tabular-nums text-ink-faint">
|
|
348
|
+
{flatItems.length} {flatItems.length === 1 ? "result" : "results"}
|
|
349
|
+
</span>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</Portal>
|
|
355
|
+
);
|
|
356
|
+
}
|