@djangocfg/ui-core 2.1.347 → 2.1.349

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 CHANGED
@@ -31,7 +31,7 @@ Organized in `components/` by category — everything re-exported from the root
31
31
  | Category | Examples |
32
32
  |---|---|
33
33
  | **Forms** | `Button`, `ButtonLink`, `ButtonGroup`, `Input`, `Textarea`, `Checkbox`, `RadioGroup`, `Switch`, `Slider`, `Label`, `Form`, `Field`, `InputOTP`, `PhoneInput`, `InputGroup`, `DownloadButton` |
34
- | **Select** | `Select`, `Combobox`, `MultiSelect`, `MultiSelectPro`, `MultiSelectProAsync`, `CountrySelect`, `LanguageSelect` (all support icons + badges; `Select` accepts empty-string values) |
34
+ | **Select** | `Select`, `Combobox`, `ComboboxAsync`, `MultiSelect`, `MultiSelectPro`, `MultiSelectProAsync`, `CountrySelect`, `LanguageSelect` (all support icons + badges; `Select` accepts empty-string values; `ComboboxAsync` is the single-select counterpart to `MultiSelectProAsync` for server-side typeahead) |
35
35
  | **Overlay** | `Dialog`, `AlertDialog`, `Sheet`, `Drawer`, `Popover`, `Tooltip`, `HoverCard`, `ResponsiveSheet`, `SidePanel` |
36
36
  | **Navigation** | `Link`, `Breadcrumb`, `BreadcrumbNavigation`, `Pagination`, `StaticPagination`, `SSRPagination`, `Sidebar` (full shadcn primitives), `Tabs`, `Accordion`, `Collapsible`, `Command`, `DropdownMenu`, `ContextMenu`, `Menubar`, `NavigationMenu` |
37
37
  | **Layout** | `Card`, `Section`, `Sticky`, `ScrollArea`, `Resizable`, `Separator`, `Skeleton`, `AspectRatio` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.347",
3
+ "version": "2.1.349",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -91,7 +91,7 @@
91
91
  "playground": "playground dev"
92
92
  },
93
93
  "peerDependencies": {
94
- "@djangocfg/i18n": "^2.1.347",
94
+ "@djangocfg/i18n": "^2.1.349",
95
95
  "consola": "^3.4.2",
96
96
  "lucide-react": "^0.545.0",
97
97
  "moment": "^2.30.1",
@@ -150,6 +150,7 @@
150
150
  "i18n-iso-countries": "^7.14.0",
151
151
  "input-otp": "1.4.2",
152
152
  "libphonenumber-js": "^1.12.24",
153
+ "nextjs-toploader": "^3.9.17",
153
154
  "react-day-picker": "9.11.1",
154
155
  "react-hotkeys-hook": "^4.6.1",
155
156
  "react-resizable-panels": "3.0.6",
@@ -160,9 +161,9 @@
160
161
  "vaul": "1.1.2"
161
162
  },
162
163
  "devDependencies": {
163
- "@djangocfg/i18n": "^2.1.347",
164
+ "@djangocfg/i18n": "^2.1.349",
164
165
  "@djangocfg/playground": "workspace:*",
165
- "@djangocfg/typescript-config": "^2.1.347",
166
+ "@djangocfg/typescript-config": "^2.1.349",
166
167
  "@types/node": "^24.7.2",
167
168
  "@types/react": "^19.1.0",
168
169
  "@types/react-dom": "^19.1.0",
@@ -0,0 +1,215 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+
3
+ import { defineStory, useBoolean } from '@djangocfg/playground';
4
+
5
+ import { ComboboxAsync, type ComboboxAsyncOption } from '.';
6
+ import { Label } from '../forms/label';
7
+
8
+ export default defineStory({
9
+ title: 'Core/ComboboxAsync',
10
+ component: ComboboxAsync,
11
+ description:
12
+ 'Single-select combobox where the parent owns search + loading. Pairs with `MultiSelectProAsync` for the "pick one" case against a server-side typeahead endpoint.',
13
+ });
14
+
15
+ // ─── Fake "server" ──────────────────────────────────────────────────
16
+ //
17
+ // In real usage, ``options`` would come from an SWR / RQ hook keyed off
18
+ // the debounced search value. For the playground we simulate latency
19
+ // with a setTimeout so the loading affordance and seedOptions story
20
+ // can be exercised without a real backend.
21
+
22
+ interface Person {
23
+ id: string;
24
+ name: string;
25
+ email: string;
26
+ team: string;
27
+ }
28
+
29
+ const PEOPLE: Person[] = [
30
+ { id: 'p-1', name: 'Alice Lee', email: 'alice@x.co', team: 'Platform' },
31
+ { id: 'p-2', name: 'Bob Schmidt', email: 'bob@x.co', team: 'Platform' },
32
+ { id: 'p-3', name: 'Carla Romero', email: 'carla@x.co', team: 'Growth' },
33
+ { id: 'p-4', name: 'Dmitri Volkov', email: 'dmitri@x.co', team: 'Growth' },
34
+ { id: 'p-5', name: 'Evelyn Park', email: 'evelyn@x.co', team: 'Design' },
35
+ { id: 'p-6', name: 'Farah Patel', email: 'farah@x.co', team: 'Design' },
36
+ { id: 'p-7', name: 'Greta Hahn', email: 'greta@x.co', team: 'Engineering' },
37
+ { id: 'p-8', name: 'Hiro Tanaka', email: 'hiro@x.co', team: 'Engineering' },
38
+ ];
39
+
40
+ function fakeSearch(query: string): Promise<Person[]> {
41
+ return new Promise((resolve) => {
42
+ const q = query.trim().toLowerCase();
43
+ const matches = q
44
+ ? PEOPLE.filter(
45
+ (p) =>
46
+ p.name.toLowerCase().includes(q) ||
47
+ p.email.toLowerCase().includes(q) ||
48
+ p.team.toLowerCase().includes(q),
49
+ )
50
+ : PEOPLE.slice(0, 6);
51
+ setTimeout(() => resolve(matches), 350);
52
+ });
53
+ }
54
+
55
+ function toOption(p: Person): ComboboxAsyncOption {
56
+ return {
57
+ value: p.id,
58
+ label: p.name,
59
+ description: `${p.email} · ${p.team}`,
60
+ };
61
+ }
62
+
63
+ // ─── Stories ────────────────────────────────────────────────────────
64
+
65
+ export const Interactive = () => {
66
+ const [value, setValue] = useState<string | null>(null);
67
+ const [search, setSearch] = useState('');
68
+ const [options, setOptions] = useState<ComboboxAsyncOption[]>([]);
69
+ const [isLoading, setIsLoading] = useState(false);
70
+ const [disabled] = useBoolean('disabled', {
71
+ defaultValue: false,
72
+ label: 'Disabled',
73
+ description: 'Disable picker',
74
+ });
75
+ const [clearable] = useBoolean('clearable', {
76
+ defaultValue: true,
77
+ label: 'Clearable',
78
+ description: 'Show inline × to reset selection',
79
+ });
80
+
81
+ useEffect(() => {
82
+ let cancelled = false;
83
+ setIsLoading(true);
84
+ void fakeSearch(search).then((rows) => {
85
+ if (cancelled) return;
86
+ setOptions(rows.map(toOption));
87
+ setIsLoading(false);
88
+ });
89
+ return () => {
90
+ cancelled = true;
91
+ };
92
+ }, [search]);
93
+
94
+ return (
95
+ <div className="max-w-sm space-y-2">
96
+ <Label>Assignee</Label>
97
+ <ComboboxAsync
98
+ options={options}
99
+ value={value}
100
+ onValueChange={setValue}
101
+ searchValue={search}
102
+ onSearchChange={setSearch}
103
+ isLoading={isLoading}
104
+ placeholder="Pick a teammate…"
105
+ searchPlaceholder="Search by name, email, team…"
106
+ emptyText="No teammates found"
107
+ loadingText="Searching…"
108
+ disabled={disabled}
109
+ clearable={clearable}
110
+ />
111
+ <p className="text-xs text-muted-foreground">
112
+ Selected: {value ?? '∅'}
113
+ </p>
114
+ </div>
115
+ );
116
+ };
117
+
118
+ export const Default = Interactive;
119
+
120
+ export const Loading = () => {
121
+ // Force ``isLoading`` true and an empty options list to show the
122
+ // initial spinner state — the same row that renders before the
123
+ // first server response lands.
124
+ const [value, setValue] = useState<string | null>(null);
125
+ const [search, setSearch] = useState('');
126
+
127
+ return (
128
+ <div className="max-w-sm space-y-2">
129
+ <Label>Assignee</Label>
130
+ <ComboboxAsync
131
+ options={[]}
132
+ value={value}
133
+ onValueChange={setValue}
134
+ searchValue={search}
135
+ onSearchChange={setSearch}
136
+ isLoading
137
+ placeholder="Pick a teammate…"
138
+ />
139
+ </div>
140
+ );
141
+ };
142
+
143
+ export const Empty = () => {
144
+ // Loaded but no matches — distinct visual from the loading row above.
145
+ const [value, setValue] = useState<string | null>(null);
146
+
147
+ return (
148
+ <div className="max-w-sm space-y-2">
149
+ <Label>Assignee</Label>
150
+ <ComboboxAsync
151
+ options={[]}
152
+ value={value}
153
+ onValueChange={setValue}
154
+ searchValue="zzznoresult"
155
+ onSearchChange={() => {}}
156
+ isLoading={false}
157
+ emptyText="No teammates found"
158
+ />
159
+ </div>
160
+ );
161
+ };
162
+
163
+ export const SeedOption = () => {
164
+ // The dialog opens with a pre-existing value whose row isn't part of
165
+ // the live results. ``seedOptions`` keeps the trigger from rendering
166
+ // a raw id while the user starts typing.
167
+ const [value, setValue] = useState<string | null>('p-stale');
168
+ const [search, setSearch] = useState('');
169
+ const [options, setOptions] = useState<ComboboxAsyncOption[]>([]);
170
+ const [isLoading, setIsLoading] = useState(false);
171
+
172
+ useEffect(() => {
173
+ let cancelled = false;
174
+ setIsLoading(true);
175
+ void fakeSearch(search).then((rows) => {
176
+ if (cancelled) return;
177
+ setOptions(rows.map(toOption));
178
+ setIsLoading(false);
179
+ });
180
+ return () => {
181
+ cancelled = true;
182
+ };
183
+ }, [search]);
184
+
185
+ const seedOptions = useMemo<ComboboxAsyncOption[]>(
186
+ () => [
187
+ {
188
+ value: 'p-stale',
189
+ label: 'Pre-selected (offline)',
190
+ description: 'Fetched once, kept around as a seed',
191
+ },
192
+ ],
193
+ [],
194
+ );
195
+
196
+ return (
197
+ <div className="max-w-sm space-y-2">
198
+ <Label>Assignee</Label>
199
+ <ComboboxAsync
200
+ options={options}
201
+ value={value}
202
+ onValueChange={setValue}
203
+ searchValue={search}
204
+ onSearchChange={setSearch}
205
+ isLoading={isLoading}
206
+ seedOptions={seedOptions}
207
+ placeholder="Pick a teammate…"
208
+ />
209
+ <p className="text-xs text-muted-foreground">
210
+ Selected id: <code>{value ?? '∅'}</code> — note the trigger shows the
211
+ seed label even though <code>p-stale</code> isn't in the search results.
212
+ </p>
213
+ </div>
214
+ );
215
+ };
@@ -0,0 +1,287 @@
1
+ "use client"
2
+
3
+ import { Check, ChevronsUpDown, Loader2, X } from 'lucide-react';
4
+ import * as React from 'react';
5
+
6
+ import { useAppT } from '@djangocfg/i18n';
7
+ import { cn } from '../../lib/utils';
8
+ import { Badge } from '../data/badge';
9
+ import { Button } from '../forms/button';
10
+ import {
11
+ Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList
12
+ } from '../navigation/command';
13
+ import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover';
14
+
15
+ export interface ComboboxAsyncOption {
16
+ value: string
17
+ label: string
18
+ description?: string
19
+ icon?: React.ComponentType<{ className?: string }>
20
+ badge?: string | React.ReactNode
21
+ disabled?: boolean
22
+ }
23
+
24
+ export interface ComboboxAsyncProps {
25
+ /**
26
+ * Options currently visible in the dropdown.
27
+ *
28
+ * The parent owns this list — typically a memoised projection of the
29
+ * latest server response keyed off ``searchValue``. The component does
30
+ * NOT filter ``options`` further; whatever you pass in is what shows.
31
+ */
32
+ options: ComboboxAsyncOption[]
33
+
34
+ /**
35
+ * Selected option id, or ``null`` when nothing is selected.
36
+ * Always controlled — there's no internal state, because the canonical
37
+ * value lives upstream (a form, a parent context, query string, …).
38
+ */
39
+ value: string | null
40
+
41
+ /** Fires with ``null`` when the user clears the selection. */
42
+ onValueChange: (value: string | null) => void
43
+
44
+ /**
45
+ * Controlled search input. Parent debounces and feeds it back into the
46
+ * data hook that produces ``options`` — same shape as
47
+ * ``MultiSelectProAsync``.
48
+ */
49
+ searchValue: string
50
+ onSearchChange: (value: string) => void
51
+
52
+ /** Show a spinner row + "Searching…" affordance while the parent fetch is in flight. */
53
+ isLoading?: boolean
54
+
55
+ /**
56
+ * Labels for ids that are selected but might not be present in
57
+ * ``options`` right now (e.g. dialog opened with a pre-filled value
58
+ * whose row isn't in the latest search response).
59
+ *
60
+ * Without this the trigger would show the raw id. Provide one entry per
61
+ * id you'd like resolved; the component falls back to the id only if
62
+ * neither ``options`` nor ``seedOptions`` contain it.
63
+ */
64
+ seedOptions?: ComboboxAsyncOption[]
65
+
66
+ placeholder?: string
67
+ searchPlaceholder?: string
68
+ emptyText?: string
69
+ loadingText?: string
70
+ className?: string
71
+ disabled?: boolean
72
+ /** Whether to render an inline ``×`` button on the trigger to clear. */
73
+ clearable?: boolean
74
+
75
+ renderOption?: (option: ComboboxAsyncOption) => React.ReactNode
76
+ renderValue?: (option: ComboboxAsyncOption | undefined) => React.ReactNode
77
+ }
78
+
79
+ /**
80
+ * Single-select combobox with parent-owned async search.
81
+ *
82
+ * Pairs with ``MultiSelectProAsync`` — same controlled-search /
83
+ * isLoading / seedOptions contract, just for "pick one" cases. Use
84
+ * this when the dataset is too large to ship to the browser up-front
85
+ * (typeaheads against a server-side search endpoint).
86
+ *
87
+ * For static / pre-loaded option lists use the plain ``Combobox`` —
88
+ * its built-in client-side filtering is the right tool there.
89
+ */
90
+ export function ComboboxAsync({
91
+ options,
92
+ value,
93
+ onValueChange,
94
+ searchValue,
95
+ onSearchChange,
96
+ isLoading = false,
97
+ seedOptions,
98
+ placeholder,
99
+ searchPlaceholder,
100
+ emptyText,
101
+ loadingText,
102
+ className,
103
+ disabled = false,
104
+ clearable = true,
105
+ renderOption,
106
+ renderValue,
107
+ }: ComboboxAsyncProps) {
108
+ const t = useAppT()
109
+ const [open, setOpen] = React.useState(false)
110
+ const scrollRef = React.useRef<HTMLDivElement>(null)
111
+
112
+ const resolvedPlaceholder = placeholder ?? t('ui.select.placeholder')
113
+ const resolvedSearchPlaceholder = searchPlaceholder ?? t('ui.select.search')
114
+ const resolvedEmptyText = emptyText ?? t('ui.select.noResults')
115
+ const resolvedLoadingText = loadingText ?? t('ui.select.loading')
116
+
117
+ // Resolve the selected option from options first (freshest label),
118
+ // fall back to seedOptions for the case where the value's row isn't
119
+ // in the current search response.
120
+ const selectedOption = React.useMemo<ComboboxAsyncOption | undefined>(() => {
121
+ if (!value) return undefined
122
+ return (
123
+ options.find((o) => o.value === value) ??
124
+ seedOptions?.find((o) => o.value === value)
125
+ )
126
+ }, [options, seedOptions, value])
127
+
128
+ React.useEffect(() => {
129
+ if (scrollRef.current && open) {
130
+ const el = scrollRef.current
131
+ el.style.cssText = `
132
+ max-height: 300px !important;
133
+ overflow-y: auto !important;
134
+ overflow-x: hidden !important;
135
+ -webkit-overflow-scrolling: touch !important;
136
+ overscroll-behavior: contain !important;
137
+ `
138
+ }
139
+ }, [open])
140
+
141
+ const handleSelect = React.useCallback(
142
+ (currentValue: string) => {
143
+ // Click again on the selected row → clear.
144
+ const next = currentValue === value ? null : currentValue
145
+ onValueChange(next)
146
+ setOpen(false)
147
+ },
148
+ [value, onValueChange],
149
+ )
150
+
151
+ const handleClear = React.useCallback(
152
+ (e: React.MouseEvent) => {
153
+ e.preventDefault()
154
+ e.stopPropagation()
155
+ onValueChange(null)
156
+ },
157
+ [onValueChange],
158
+ )
159
+
160
+ return (
161
+ <Popover
162
+ open={open}
163
+ onOpenChange={(isOpen) => {
164
+ setOpen(isOpen)
165
+ if (!isOpen) onSearchChange('')
166
+ }}
167
+ >
168
+ <PopoverTrigger asChild>
169
+ <Button
170
+ variant="outline"
171
+ role="combobox"
172
+ aria-expanded={open}
173
+ className={cn(
174
+ "w-full justify-between",
175
+ !selectedOption && "text-muted-foreground",
176
+ className
177
+ )}
178
+ disabled={disabled}
179
+ >
180
+ {renderValue && selectedOption
181
+ ? renderValue(selectedOption)
182
+ : selectedOption
183
+ ? <SelectedTriggerLabel option={selectedOption} />
184
+ : <span className="truncate">{resolvedPlaceholder}</span>}
185
+ <div className="ml-2 flex shrink-0 items-center gap-1">
186
+ {clearable && selectedOption && !disabled ? (
187
+ <span
188
+ role="button"
189
+ tabIndex={-1}
190
+ aria-label="Clear selection"
191
+ onClick={handleClear}
192
+ onMouseDown={(e) => e.stopPropagation()}
193
+ className="rounded-sm p-0.5 opacity-50 transition-opacity hover:opacity-100"
194
+ >
195
+ <X className="h-4 w-4" />
196
+ </span>
197
+ ) : null}
198
+ <ChevronsUpDown className="h-4 w-4 opacity-50" />
199
+ </div>
200
+ </Button>
201
+ </PopoverTrigger>
202
+ <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
203
+ <Command shouldFilter={false} className="flex flex-col">
204
+ <CommandInput
205
+ placeholder={resolvedSearchPlaceholder}
206
+ className="shrink-0"
207
+ value={searchValue}
208
+ onValueChange={onSearchChange}
209
+ />
210
+ <div
211
+ ref={scrollRef}
212
+ tabIndex={-1}
213
+ className="overflow-y-auto overflow-x-hidden"
214
+ style={{ maxHeight: '300px' }}
215
+ onWheel={(e) => e.stopPropagation()}
216
+ >
217
+ <CommandList className="!max-h-none !overflow-visible" style={{ pointerEvents: 'auto' }}>
218
+ {isLoading && options.length === 0 ? (
219
+ <div className="flex items-center gap-2 px-3 py-6 text-sm text-muted-foreground">
220
+ <Loader2 className="h-4 w-4 animate-spin" />
221
+ <span>{resolvedLoadingText}</span>
222
+ </div>
223
+ ) : options.length === 0 ? (
224
+ <CommandEmpty>{resolvedEmptyText}</CommandEmpty>
225
+ ) : (
226
+ <CommandGroup className="!overflow-visible" style={{ pointerEvents: 'auto' }}>
227
+ {options.map((option) => (
228
+ <CommandItem
229
+ key={option.value}
230
+ value={option.value}
231
+ onSelect={(currentValue) => {
232
+ if (!option.disabled) handleSelect(currentValue)
233
+ }}
234
+ disabled={option.disabled}
235
+ >
236
+ <Check
237
+ className={cn(
238
+ "mr-2 h-4 w-4 shrink-0",
239
+ value === option.value ? "opacity-100" : "opacity-0"
240
+ )}
241
+ />
242
+ {option.icon && <option.icon className="mr-2 h-4 w-4 shrink-0" />}
243
+ {renderOption ? (
244
+ renderOption(option)
245
+ ) : (
246
+ <div className="flex flex-col flex-1 min-w-0">
247
+ <div className="flex items-center gap-2 truncate">
248
+ <span className="truncate">{option.label}</span>
249
+ {option.badge && (
250
+ <Badge variant="outline" className="text-xs shrink-0">
251
+ {option.badge}
252
+ </Badge>
253
+ )}
254
+ </div>
255
+ {option.description && (
256
+ <span className="text-xs text-muted-foreground truncate">
257
+ {option.description}
258
+ </span>
259
+ )}
260
+ </div>
261
+ )}
262
+ </CommandItem>
263
+ ))}
264
+ </CommandGroup>
265
+ )}
266
+ </CommandList>
267
+ </div>
268
+ </Command>
269
+ </PopoverContent>
270
+ </Popover>
271
+ )
272
+ }
273
+
274
+ function SelectedTriggerLabel({ option }: { option: ComboboxAsyncOption }) {
275
+ const Icon = option.icon
276
+ return (
277
+ <div className="flex min-w-0 items-center gap-2">
278
+ {Icon && <Icon className="h-4 w-4 shrink-0" />}
279
+ <span className="truncate">{option.label}</span>
280
+ {option.badge && (
281
+ <Badge variant="secondary" className="shrink-0 text-xs">
282
+ {option.badge}
283
+ </Badge>
284
+ )}
285
+ </div>
286
+ )
287
+ }
@@ -26,6 +26,10 @@ export {
26
26
  export { Combobox } from './combobox';
27
27
  export type { ComboboxOption, ComboboxProps } from './combobox';
28
28
 
29
+ // Combobox Async (single-select with parent-owned async search)
30
+ export { ComboboxAsync } from './combobox-async';
31
+ export type { ComboboxAsyncOption, ComboboxAsyncProps } from './combobox-async';
32
+
29
33
  // MultiSelect
30
34
  export { MultiSelect } from './multi-select';
31
35
  export type { MultiSelectOption, MultiSelectProps } from './multi-select';
@@ -40,7 +40,19 @@ import {
40
40
  type ReactNode,
41
41
  type Ref,
42
42
  } from 'react';
43
- import { useRouter as useNextRouter } from 'next/navigation';
43
+ // Use the toploader-wrapped router so that EVERY programmatic
44
+ // navigation through ui-core hooks (`useNavigate`, `useQueryState`,
45
+ // `useRouter` from `@djangocfg/ui-core/hooks`) starts the
46
+ // `nextjs-toploader` progress bar.
47
+ //
48
+ // Why not `next/navigation` directly: <NextTopLoader> only attaches a
49
+ // document-level click listener for <a> tags and instruments
50
+ // `history.pushState` to call `nprogress.done()` (NOT `.start()`). So
51
+ // `router.push()` from `next/navigation` mutates history but never
52
+ // starts the bar — only `<Link>` clicks do. The `nextjs-toploader/app`
53
+ // entry exposes a thin wrapper around the same `useRouter` whose
54
+ // `push`/`replace` call `nprogress.start()` first. Drop-in shape.
55
+ import { useRouter as useNextRouter } from 'nextjs-toploader/app';
44
56
  import NextLink from 'next/link';
45
57
 
46
58
  import {