@jinaan/uikit 0.1.51 → 0.1.53

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jinaan/uikit",
3
- "version": "0.1.51",
3
+ "version": "0.1.53",
4
4
  "description": "Reusable React and Tailwind UI components for Jinaan projects.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -42,6 +42,7 @@
42
42
  "@radix-ui/react-dialog": "^1.1.4",
43
43
  "@radix-ui/react-dropdown-menu": "^2.1.4",
44
44
  "@radix-ui/react-label": "^2.1.1",
45
+ "@radix-ui/react-popover": "^1.1.4",
45
46
  "@radix-ui/react-select": "^2.1.4",
46
47
  "@radix-ui/react-separator": "^1.1.1",
47
48
  "@radix-ui/react-slot": "^1.1.1",
@@ -0,0 +1,252 @@
1
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
2
+ import { CaretDown, MagnifyingGlass } from "@phosphor-icons/react";
3
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
4
+
5
+ import { Input } from "./input";
6
+ import { Spinner } from "./spinner";
7
+ import { cn } from "./utils";
8
+
9
+ export interface ComboboxItem {
10
+ value: string;
11
+ label: string;
12
+ /** Secondary line under the label (e.g. an email address). */
13
+ description?: string;
14
+ /** Small pill rendered after the label (e.g. "Default"). */
15
+ badge?: string;
16
+ }
17
+
18
+ export interface ComboboxProps {
19
+ items: ComboboxItem[];
20
+ /** Selected item value; null shows the placeholder. */
21
+ value: string | null;
22
+ onValueChange: (item: ComboboxItem) => void;
23
+ /**
24
+ * Debounced + trimmed search term (fires after `debounceMs` once the term
25
+ * reaches `minSearchLength`). Omit to filter `items` client-side instead.
26
+ */
27
+ onSearchChange?: (term: string) => void;
28
+ /** True while an async search is running; shows a centered spinner. */
29
+ loading?: boolean;
30
+ /** Minimum search term length before searching/filtering. Default 3. */
31
+ minSearchLength?: number;
32
+ /** Debounce window for the search term, in ms. Default 300. */
33
+ debounceMs?: number;
34
+ placeholder?: string;
35
+ searchPlaceholder?: string;
36
+ /** Shown when a completed search yields no rows. Default "No results found". */
37
+ emptyMessage?: string;
38
+ /** Hint below `minSearchLength`; defaults to "Type at least N characters to search". */
39
+ hintMessage?: string;
40
+ /** Custom row content; falls back to label + description + badge. */
41
+ renderItem?: (item: ComboboxItem) => ReactNode;
42
+ /** Custom selected-value content; falls back to the item label. */
43
+ renderValue?: (item: ComboboxItem) => ReactNode;
44
+ className?: string;
45
+ contentClassName?: string;
46
+ disabled?: boolean;
47
+ }
48
+
49
+ /**
50
+ * Searchable select (Clerk "Select…" parity): an inline secondary trigger that
51
+ * opens the standard dropdown surface with a search field and item rows.
52
+ * Searching debounces internally; render `items` either pre-filtered by the
53
+ * parent (async search through `onSearchChange` + `loading`) or let the
54
+ * component filter locally when `onSearchChange` is omitted. The content
55
+ * portals and anchors to the trigger with collision flip, so it overlays —
56
+ * it never grows the surrounding layout, and it nests safely inside dialogs.
57
+ */
58
+ export function Combobox({
59
+ items,
60
+ value,
61
+ onValueChange,
62
+ onSearchChange,
63
+ loading = false,
64
+ minSearchLength = 3,
65
+ debounceMs = 300,
66
+ placeholder = "Select...",
67
+ searchPlaceholder = "Search",
68
+ emptyMessage = "No results found",
69
+ hintMessage,
70
+ renderItem,
71
+ renderValue,
72
+ className,
73
+ contentClassName,
74
+ disabled = false,
75
+ }: ComboboxProps) {
76
+ const [open, setOpen] = useState(false);
77
+ const [query, setQuery] = useState("");
78
+ const [debounced, setDebounced] = useState("");
79
+
80
+ // Debounce the search term.
81
+ useEffect(() => {
82
+ const trimmed = query.trim();
83
+ const id = setTimeout(() => setDebounced(trimmed), debounceMs);
84
+ return () => clearTimeout(id);
85
+ }, [query, debounceMs]);
86
+
87
+ // Report settled debounced terms to async search consumers.
88
+ useEffect(() => {
89
+ if (!onSearchChange) return;
90
+ if (open && debounced.length >= minSearchLength) {
91
+ onSearchChange(debounced);
92
+ }
93
+ }, [debounced, open, minSearchLength, onSearchChange]);
94
+
95
+ const selected = items.find((item) => item.value === value) ?? null;
96
+ const trimmed = query.trim();
97
+ // Empty query → empty message; 1–2 chars → minimum-length hint;
98
+ // ≥3 chars → spinner while the debounce timer (or parent fetch) is pending.
99
+ const showHint = trimmed.length > 0 && trimmed.length < minSearchLength;
100
+ const pending =
101
+ onSearchChange !== undefined &&
102
+ trimmed.length >= minSearchLength &&
103
+ debounced !== trimmed;
104
+ const hint = hintMessage ?? `Type at least ${minSearchLength} characters to search`;
105
+
106
+ // Local mode: filter live; async mode: parent owns items.
107
+ const filtered = useMemo(() => {
108
+ if (onSearchChange || trimmed.length === 0) return items;
109
+ const needle = trimmed.toLowerCase();
110
+ return items.filter(
111
+ (item) =>
112
+ item.label.toLowerCase().includes(needle) ||
113
+ (item.description ?? "").toLowerCase().includes(needle),
114
+ );
115
+ }, [items, trimmed, onSearchChange]);
116
+
117
+ const pick = (item: ComboboxItem) => {
118
+ onValueChange(item);
119
+ setOpen(false);
120
+ setQuery("");
121
+ setDebounced("");
122
+ };
123
+
124
+ const clear = () => {
125
+ setQuery("");
126
+ setDebounced("");
127
+ };
128
+
129
+ return (
130
+ <PopoverPrimitive.Root
131
+ open={open}
132
+ onOpenChange={(next) => {
133
+ setOpen(next);
134
+ if (!next) clear();
135
+ }}
136
+ >
137
+ <PopoverPrimitive.Trigger
138
+ type="button"
139
+ disabled={disabled}
140
+ aria-haspopup="listbox"
141
+ className={cn(
142
+ "group btn-fill btn-inner-border btn-interactive flex h-(--control-h-md) w-full items-center justify-between gap-2 rounded-(--control-radius) bg-(--button-secondary-bg) px-(--control-pad-x-sm) font-medium text-fg hover:bg-(--button-surface-hover) active:bg-(--button-surface-active) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-strong",
143
+ "disabled:cursor-not-allowed disabled:opacity-50",
144
+ className,
145
+ )}
146
+ >
147
+ {selected ? (
148
+ renderValue ? (
149
+ renderValue(selected)
150
+ ) : (
151
+ <span className="min-w-0 truncate">{selected.label}</span>
152
+ )
153
+ ) : (
154
+ <span className="font-normal text-muted">{placeholder}</span>
155
+ )}
156
+ <CaretDown
157
+ aria-hidden
158
+ className="size-3.5 shrink-0 text-muted transition-transform duration-200 ease-out group-data-[state=open]:rotate-180 motion-reduce:transition-none"
159
+ />
160
+ </PopoverPrimitive.Trigger>
161
+ <PopoverPrimitive.Portal>
162
+ <PopoverPrimitive.Content
163
+ role="listbox"
164
+ aria-label={placeholder}
165
+ align="start"
166
+ sideOffset={4}
167
+ className={cn(
168
+ "menu-surface btn-inner-border z-50 overflow-hidden rounded-lg border border-line bg-(--button-secondary-bg) p-1",
169
+ "w-(--radix-popper-anchor-width)",
170
+ "data-[state=open]:animate-in data-[state=closed]:animate-out",
171
+ "data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
172
+ "data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
173
+ "duration-150 ease-out",
174
+ contentClassName,
175
+ )}
176
+ onKeyDown={(event) => {
177
+ // Escape closes the combobox, not a surrounding dialog.
178
+ if (event.key === "Escape") {
179
+ event.stopPropagation();
180
+ setOpen(false);
181
+ clear();
182
+ }
183
+ }}
184
+ >
185
+ <div className="pb-1">
186
+ <Input
187
+ value={query}
188
+ onChange={(event) => setQuery(event.target.value)}
189
+ onClear={() => clear()}
190
+ placeholder={searchPlaceholder}
191
+ aria-label={searchPlaceholder}
192
+ leadingIcon={<MagnifyingGlass className="size-3.5" />}
193
+ size="sm"
194
+ className="h-(--control-h-xs) border-0 bg-(--combobox-search-bg) focus-visible:border-muted focus-visible:ring-0"
195
+ />
196
+ </div>
197
+ <div className="max-h-64 overflow-y-auto" role="presentation">
198
+ {showHint ? (
199
+ <p className="px-2 py-3 text-center text-sm text-muted">{hint}</p>
200
+ ) : filtered.length > 0 ? (
201
+ filtered.map((item) => (
202
+ <button
203
+ key={item.value}
204
+ type="button"
205
+ role="option"
206
+ aria-selected={item.value === value}
207
+ onClick={() => pick(item)}
208
+ className={cn(
209
+ "flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors",
210
+ item.value === value
211
+ ? "bg-(--button-surface-active)"
212
+ : "hover:bg-(--button-surface-hover)",
213
+ )}
214
+ >
215
+ {renderItem ? (
216
+ renderItem(item)
217
+ ) : (
218
+ <span className="min-w-0">
219
+ <span className="flex items-center gap-1.5">
220
+ <span className="truncate text-sm font-medium text-fg">
221
+ {item.label}
222
+ </span>
223
+ {item.badge && (
224
+ <span className="rounded bg-elevated px-1.5 py-0.5 text-[11px] text-muted">
225
+ {item.badge}
226
+ </span>
227
+ )}
228
+ </span>
229
+ {item.description && (
230
+ <span className="block truncate text-[13px] text-muted">
231
+ {item.description}
232
+ </span>
233
+ )}
234
+ </span>
235
+ )}
236
+ </button>
237
+ ))
238
+ ) : loading || pending ? (
239
+ <div className="flex justify-center py-4">
240
+ <Spinner className="size-5 text-muted" />
241
+ </div>
242
+ ) : (
243
+ <p className="px-2 py-3 text-center text-sm text-muted">
244
+ {emptyMessage}
245
+ </p>
246
+ )}
247
+ </div>
248
+ </PopoverPrimitive.Content>
249
+ </PopoverPrimitive.Portal>
250
+ </PopoverPrimitive.Root>
251
+ );
252
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./card";
7
7
  export * from "./checkbox";
8
8
  export * from "./choice-dialog";
9
9
  export * from "./code-block";
10
+ export * from "./combobox";
10
11
  export * from "./data-table";
11
12
  export * from "./dialog";
12
13
  export * from "./dropdown-menu";
package/styles.css CHANGED
@@ -100,6 +100,7 @@
100
100
  --input-border-hover: #c7c7cf;
101
101
  --input-border-focus: var(--primary);
102
102
  --input-bg-disabled: #f6f6f7;
103
+ --combobox-search-bg: rgb(245, 245, 246);
103
104
 
104
105
  /* Button surfaces. */
105
106
  --button-primary-bg: var(--primary);
@@ -213,6 +214,7 @@
213
214
  --input-border-hover: #4d4d5a;
214
215
  --input-border-focus: var(--primary-hover);
215
216
  --input-bg-disabled: #26262c;
217
+ --combobox-search-bg: rgb(32, 32, 35);
216
218
 
217
219
  --button-primary-bg: var(--primary);
218
220
  --button-primary-fg: var(--primary-fg);