@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.
|
|
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.
|
|
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.
|
|
164
|
+
"@djangocfg/i18n": "^2.1.349",
|
|
164
165
|
"@djangocfg/playground": "workspace:*",
|
|
165
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
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
|
-
|
|
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 {
|