@djangocfg/ui-core 2.1.556 → 2.1.558
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 +3 -3
- package/package.json +5 -5
- package/src/components/data/calendar/calendar.tsx +1 -1
- package/src/components/data/toggle/index.tsx +1 -1
- package/src/components/forms/button/index.tsx +6 -1
- package/src/components/forms/checkbox/index.tsx +1 -1
- package/src/components/forms/filter-button/index.tsx +145 -0
- package/src/components/forms/filter-menu/index.tsx +203 -0
- package/src/components/forms/mask-input/index.tsx +1 -1
- package/src/components/forms/segmented-input/index.tsx +1 -1
- package/src/components/forms/tags-input/index.tsx +1 -1
- package/src/components/index.ts +6 -0
- package/src/components/layout/filter-bar/index.tsx +48 -0
- package/src/components/navigation/command/index.tsx +19 -40
- package/src/components/navigation/tabs/index.tsx +102 -17
- package/src/components/navigation/tabs/use-tab-indicator.ts +68 -0
- package/src/components/overlay/drawer/index.tsx +113 -15
- package/src/components/overlay/popover/index.tsx +8 -2
- package/src/components/overlay/side-panel/index.tsx +77 -8
- package/src/components/select/combobox-async.tsx +26 -32
- package/src/components/select/combobox.tsx +36 -41
- package/src/components/select/country-select.tsx +21 -9
- package/src/components/select/language-select.tsx +21 -9
- package/src/components/select/multi-select-pro-async.tsx +3 -2
- package/src/components/select/multi-select-pro.tsx +3 -2
- package/src/components/select/multi-select.tsx +9 -30
- package/src/components/select/select.tsx +1 -1
- package/src/components/select/trigger.ts +23 -0
- package/src/hooks/dom/index.ts +1 -0
- package/src/hooks/dom/useHighlight.ts +68 -0
- package/src/styles/css/presets/dense.css +12 -0
- package/src/styles/css/presets/ios.css +12 -0
- package/src/styles/css/presets/soft.css +12 -0
- package/src/styles/css/utilities/filter-bar.css +61 -0
- package/src/styles/css/utilities/overlay.css +78 -0
- package/src/styles/css/utilities/tabs.css +53 -0
- package/src/styles/css/utilities.css +2 -0
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList
|
|
12
12
|
} from '../navigation/command';
|
|
13
13
|
import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover';
|
|
14
|
+
import { SELECT_TRIGGER_CLASS } from './trigger';
|
|
15
|
+
import { useHighlight } from '../../hooks';
|
|
14
16
|
|
|
15
17
|
export interface ComboboxAsyncOption {
|
|
16
18
|
value: string
|
|
@@ -107,7 +109,7 @@ export function ComboboxAsync({
|
|
|
107
109
|
}: ComboboxAsyncProps) {
|
|
108
110
|
const t = useAppT()
|
|
109
111
|
const [open, setOpen] = React.useState(false)
|
|
110
|
-
const
|
|
112
|
+
const highlight = useHighlight(open, value)
|
|
111
113
|
|
|
112
114
|
const resolvedPlaceholder = placeholder ?? t('ui.select.placeholder')
|
|
113
115
|
const resolvedSearchPlaceholder = searchPlaceholder ?? t('ui.select.search')
|
|
@@ -125,19 +127,6 @@ export function ComboboxAsync({
|
|
|
125
127
|
)
|
|
126
128
|
}, [options, seedOptions, value])
|
|
127
129
|
|
|
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
130
|
const handleSelect = React.useCallback(
|
|
142
131
|
(currentValue: string) => {
|
|
143
132
|
// Click again on the selected row → clear.
|
|
@@ -171,7 +160,7 @@ export function ComboboxAsync({
|
|
|
171
160
|
role="combobox"
|
|
172
161
|
aria-expanded={open}
|
|
173
162
|
className={cn(
|
|
174
|
-
|
|
163
|
+
SELECT_TRIGGER_CLASS,
|
|
175
164
|
!selectedOption && "text-muted-foreground",
|
|
176
165
|
className
|
|
177
166
|
)}
|
|
@@ -200,21 +189,26 @@ export function ComboboxAsync({
|
|
|
200
189
|
</Button>
|
|
201
190
|
</PopoverTrigger>
|
|
202
191
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
203
|
-
|
|
192
|
+
{/* Controlled so the highlight starts on the current selection rather
|
|
193
|
+
than the first row. `onValueChange` is mandatory with a controlled
|
|
194
|
+
`value`: cmdk then stores nothing itself and delegates every move to
|
|
195
|
+
it, so without it the arrow keys would not move the highlight. */}
|
|
196
|
+
<Command
|
|
197
|
+
shouldFilter={false}
|
|
198
|
+
value={highlight.value}
|
|
199
|
+
onValueChange={highlight.setValue}
|
|
200
|
+
className="flex flex-col"
|
|
201
|
+
>
|
|
204
202
|
<CommandInput
|
|
205
203
|
placeholder={resolvedSearchPlaceholder}
|
|
206
204
|
className="shrink-0"
|
|
207
205
|
value={searchValue}
|
|
208
206
|
onValueChange={onSearchChange}
|
|
209
207
|
/>
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
style={{ maxHeight: '300px' }}
|
|
215
|
-
onWheel={(e) => e.stopPropagation()}
|
|
216
|
-
>
|
|
217
|
-
<CommandList className="!max-h-none !overflow-visible" style={{ pointerEvents: 'auto' }}>
|
|
208
|
+
{/* CommandList is the scroller (it caps itself to the popover's
|
|
209
|
+
available height) — do not wrap it in a scrolling div: that hands
|
|
210
|
+
the scrolling to a container cmdk cannot see. */}
|
|
211
|
+
<CommandList ref={highlight.listRef}>
|
|
218
212
|
{isLoading && options.length === 0 ? (
|
|
219
213
|
<div className="flex items-center gap-2 px-3 py-6 text-sm text-muted-foreground">
|
|
220
214
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
@@ -223,7 +217,7 @@ export function ComboboxAsync({
|
|
|
223
217
|
) : options.length === 0 ? (
|
|
224
218
|
<CommandEmpty>{resolvedEmptyText}</CommandEmpty>
|
|
225
219
|
) : (
|
|
226
|
-
<CommandGroup
|
|
220
|
+
<CommandGroup>
|
|
227
221
|
{options.map((option) => (
|
|
228
222
|
<CommandItem
|
|
229
223
|
key={option.value}
|
|
@@ -233,12 +227,6 @@ export function ComboboxAsync({
|
|
|
233
227
|
}}
|
|
234
228
|
disabled={option.disabled}
|
|
235
229
|
>
|
|
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
230
|
{option.icon && <option.icon className="mr-2 h-4 w-4 shrink-0" />}
|
|
243
231
|
{renderOption ? (
|
|
244
232
|
renderOption(option)
|
|
@@ -259,12 +247,18 @@ export function ComboboxAsync({
|
|
|
259
247
|
)}
|
|
260
248
|
</div>
|
|
261
249
|
)}
|
|
250
|
+
|
|
251
|
+
{/* Trailing and only when selected: an always-rendered
|
|
252
|
+
`opacity-0` mark indents every row to hold space one
|
|
253
|
+
row at most ever uses. Absent it, the label gets the
|
|
254
|
+
full width; present, `shrink-0` stops the label's
|
|
255
|
+
truncation exactly at the mark. */}
|
|
256
|
+
{value === option.value && <Check className="ml-2 h-4 w-4 shrink-0" />}
|
|
262
257
|
</CommandItem>
|
|
263
258
|
))}
|
|
264
259
|
</CommandGroup>
|
|
265
260
|
)}
|
|
266
261
|
</CommandList>
|
|
267
|
-
</div>
|
|
268
262
|
</Command>
|
|
269
263
|
</PopoverContent>
|
|
270
264
|
</Popover>
|
|
@@ -4,7 +4,7 @@ import { Check, ChevronsUpDown } from 'lucide-react';
|
|
|
4
4
|
import * as React from 'react';
|
|
5
5
|
|
|
6
6
|
import { useAppT } from '@djangocfg/i18n';
|
|
7
|
-
import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../hooks';
|
|
7
|
+
import { useStoredValue, type StorageType, type UseStoredValueOptions, useHighlight } from '../../hooks';
|
|
8
8
|
import { cn } from '../../lib/utils';
|
|
9
9
|
import { Badge } from '../data/badge';
|
|
10
10
|
import { Button } from '../forms/button';
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList
|
|
13
13
|
} from '../navigation/command';
|
|
14
14
|
import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover';
|
|
15
|
+
import { SELECT_TRIGGER_CLASS } from './trigger';
|
|
15
16
|
|
|
16
17
|
export interface ComboboxOption {
|
|
17
18
|
value: string
|
|
@@ -109,7 +110,6 @@ export function Combobox({
|
|
|
109
110
|
const t = useAppT()
|
|
110
111
|
const [open, setOpen] = React.useState(false)
|
|
111
112
|
const [search, setSearch] = React.useState("")
|
|
112
|
-
const scrollRef = React.useRef<HTMLDivElement>(null)
|
|
113
113
|
|
|
114
114
|
const storageOptions: UseStoredValueOptions | undefined =
|
|
115
115
|
storageKey ? { storage: storageType ?? 'local', ttl: storageTtl } : undefined;
|
|
@@ -129,6 +129,8 @@ export function Combobox({
|
|
|
129
129
|
// The effective selected value: prefer controlled, fall back to internal
|
|
130
130
|
const value = controlledValue !== undefined ? controlledValue : internalValue;
|
|
131
131
|
|
|
132
|
+
const highlight = useHighlight(open, value)
|
|
133
|
+
|
|
132
134
|
// autoSelectFromOptions: when options become available, validate stored value.
|
|
133
135
|
// Runs whenever options change (async loads, filters, etc.)
|
|
134
136
|
// Only acts when: storageKey is set, autoSelectFromOptions=true, options non-empty.
|
|
@@ -177,22 +179,6 @@ export function Combobox({
|
|
|
177
179
|
const resolvedSearchPlaceholder = searchPlaceholder ?? t('ui.select.search')
|
|
178
180
|
const resolvedEmptyText = emptyText ?? t('ui.select.noResults')
|
|
179
181
|
|
|
180
|
-
React.useEffect(() => {
|
|
181
|
-
if (scrollRef.current && open) {
|
|
182
|
-
const el = scrollRef.current
|
|
183
|
-
// No min-height here — list should hug its content when there
|
|
184
|
-
// are only a few options, otherwise an empty strip appears below
|
|
185
|
-
// the last item (reproducible with 2–3 options).
|
|
186
|
-
el.style.cssText = `
|
|
187
|
-
max-height: 300px !important;
|
|
188
|
-
overflow-y: auto !important;
|
|
189
|
-
overflow-x: hidden !important;
|
|
190
|
-
-webkit-overflow-scrolling: touch !important;
|
|
191
|
-
overscroll-behavior: contain !important;
|
|
192
|
-
`
|
|
193
|
-
}
|
|
194
|
-
}, [open])
|
|
195
|
-
|
|
196
182
|
const selectedOption = React.useMemo(
|
|
197
183
|
() => options.find((option) => option.value === value),
|
|
198
184
|
[options, value]
|
|
@@ -270,7 +256,7 @@ export function Combobox({
|
|
|
270
256
|
role="combobox"
|
|
271
257
|
aria-expanded={open}
|
|
272
258
|
className={cn(
|
|
273
|
-
|
|
259
|
+
SELECT_TRIGGER_CLASS,
|
|
274
260
|
!value && "text-muted-foreground",
|
|
275
261
|
className
|
|
276
262
|
)}
|
|
@@ -290,29 +276,33 @@ export function Combobox({
|
|
|
290
276
|
style={contentStyle}
|
|
291
277
|
align="start"
|
|
292
278
|
>
|
|
293
|
-
|
|
279
|
+
{/* The highlight is controlled so it STARTS on the current selection —
|
|
280
|
+
cmdk otherwise highlights the first item, and its open-time
|
|
281
|
+
`scrollIntoView` then parks an 80-entry list at the top with the
|
|
282
|
+
selected row far below the fold.
|
|
283
|
+
`onValueChange` is mandatory here, not decorative: for a controlled
|
|
284
|
+
`value` cmdk delegates every move to it and stores nothing itself,
|
|
285
|
+
so without it the arrow keys would not move the highlight at all. */}
|
|
286
|
+
<Command
|
|
287
|
+
shouldFilter={false}
|
|
288
|
+
value={highlight.value}
|
|
289
|
+
onValueChange={highlight.setValue}
|
|
290
|
+
className="flex flex-col"
|
|
291
|
+
>
|
|
294
292
|
<CommandInput
|
|
295
293
|
placeholder={resolvedSearchPlaceholder}
|
|
296
294
|
className="shrink-0"
|
|
297
295
|
value={search}
|
|
298
296
|
onValueChange={setSearch}
|
|
299
297
|
/>
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
style={{
|
|
305
|
-
maxHeight: '300px',
|
|
306
|
-
}}
|
|
307
|
-
onWheel={(e) => {
|
|
308
|
-
e.stopPropagation()
|
|
309
|
-
}}
|
|
310
|
-
>
|
|
311
|
-
<CommandList className="!max-h-none !overflow-visible" style={{ pointerEvents: 'auto' }}>
|
|
298
|
+
{/* CommandList is the scroller — see its own note. It caps itself to
|
|
299
|
+
the popover's available height, so no wrapper is needed here and
|
|
300
|
+
none may be added: one would take the scrolling away from cmdk. */}
|
|
301
|
+
<CommandList ref={highlight.listRef}>
|
|
312
302
|
{filteredOptions.length === 0 ? (
|
|
313
303
|
<CommandEmpty>{resolvedEmptyText}</CommandEmpty>
|
|
314
304
|
) : (
|
|
315
|
-
<CommandGroup
|
|
305
|
+
<CommandGroup>
|
|
316
306
|
{filteredOptions.map((option) => (
|
|
317
307
|
<CommandItem
|
|
318
308
|
key={option.value}
|
|
@@ -324,19 +314,16 @@ export function Combobox({
|
|
|
324
314
|
}}
|
|
325
315
|
disabled={option.disabled}
|
|
326
316
|
>
|
|
327
|
-
<Check
|
|
328
|
-
className={cn(
|
|
329
|
-
"mr-2 h-4 w-4 shrink-0",
|
|
330
|
-
value === option.value ? "opacity-100" : "opacity-0"
|
|
331
|
-
)}
|
|
332
|
-
/>
|
|
333
317
|
{option.icon && <option.icon className="mr-2 h-4 w-4 shrink-0" />}
|
|
334
318
|
{renderOption ? (
|
|
335
319
|
renderOption(option)
|
|
336
320
|
) : (
|
|
337
321
|
<div className="flex flex-col flex-1 min-w-0">
|
|
338
322
|
<div className="flex items-center gap-2 truncate">
|
|
339
|
-
|
|
323
|
+
{/* `title` rather than wrapping: a row is a label to
|
|
324
|
+
scan, and a two-line one breaks both the vertical
|
|
325
|
+
rhythm and the constant step of the arrow keys. */}
|
|
326
|
+
<span className="truncate" title={option.label}>{option.label}</span>
|
|
340
327
|
{option.badge && (
|
|
341
328
|
<Badge variant="outline" className="text-xs shrink-0">
|
|
342
329
|
{option.badge}
|
|
@@ -350,12 +337,20 @@ export function Combobox({
|
|
|
350
337
|
)}
|
|
351
338
|
</div>
|
|
352
339
|
)}
|
|
340
|
+
{/* Trailing, and rendered ONLY when selected — an
|
|
341
|
+
always-present `opacity-0` placeholder indents every row
|
|
342
|
+
by 24px to reserve space for a mark at most one of them
|
|
343
|
+
ever shows. Absent, the label gets the full width;
|
|
344
|
+
present, `shrink-0` makes the label's truncation stop
|
|
345
|
+
exactly at the mark. */}
|
|
346
|
+
{value === option.value && (
|
|
347
|
+
<Check className="ml-2 h-4 w-4 shrink-0" />
|
|
348
|
+
)}
|
|
353
349
|
</CommandItem>
|
|
354
350
|
))}
|
|
355
351
|
</CommandGroup>
|
|
356
352
|
)}
|
|
357
353
|
</CommandList>
|
|
358
|
-
</div>
|
|
359
354
|
</Command>
|
|
360
355
|
</PopoverContent>
|
|
361
356
|
</Popover>
|
|
@@ -15,6 +15,8 @@ import { Input } from '../forms/input';
|
|
|
15
15
|
import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover';
|
|
16
16
|
import { ScrollArea } from '../layout/scroll-area';
|
|
17
17
|
import { Flag } from '../specialized/flag';
|
|
18
|
+
import { SELECT_TRIGGER_MULTI_CLASS } from './trigger';
|
|
19
|
+
import { useHighlight } from '../../hooks';
|
|
18
20
|
|
|
19
21
|
export interface CountryOption {
|
|
20
22
|
code: TCountryCode;
|
|
@@ -132,6 +134,7 @@ export function CountrySelect({
|
|
|
132
134
|
moreItemsLabel = (count: number) => `+${count} more`,
|
|
133
135
|
}: CountrySelectProps) {
|
|
134
136
|
const [open, setOpen] = React.useState(false)
|
|
137
|
+
const highlight = useHighlight(open, value[0])
|
|
135
138
|
const [search, setSearch] = React.useState("")
|
|
136
139
|
|
|
137
140
|
// Resolve defaults
|
|
@@ -343,7 +346,7 @@ export function CountrySelect({
|
|
|
343
346
|
role="combobox"
|
|
344
347
|
aria-expanded={open}
|
|
345
348
|
className={cn(
|
|
346
|
-
|
|
349
|
+
SELECT_TRIGGER_MULTI_CLASS,
|
|
347
350
|
className
|
|
348
351
|
)}
|
|
349
352
|
disabled={disabled}
|
|
@@ -355,14 +358,22 @@ export function CountrySelect({
|
|
|
355
358
|
</Button>
|
|
356
359
|
</PopoverTrigger>
|
|
357
360
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
358
|
-
|
|
361
|
+
{/* Controlled highlight. `onValueChange` is mandatory alongside it:
|
|
362
|
+
with a controlled `value` cmdk stores nothing itself and delegates
|
|
363
|
+
every move, so without it the arrow keys would not move the row. */}
|
|
364
|
+
<Command
|
|
365
|
+
shouldFilter={false}
|
|
366
|
+
value={highlight.value}
|
|
367
|
+
onValueChange={highlight.setValue}
|
|
368
|
+
className="flex flex-col"
|
|
369
|
+
>
|
|
359
370
|
<CommandInput
|
|
360
371
|
placeholder={resolvedSearchPlaceholder}
|
|
361
372
|
className="shrink-0"
|
|
362
373
|
value={search}
|
|
363
374
|
onValueChange={setSearch}
|
|
364
375
|
/>
|
|
365
|
-
<CommandList className="max-h-[300px]">
|
|
376
|
+
<CommandList ref={highlight.listRef} className="max-h-[300px]">
|
|
366
377
|
{filteredCountries.length === 0 ? (
|
|
367
378
|
<CommandEmpty>{resolvedEmptyText}</CommandEmpty>
|
|
368
379
|
) : (
|
|
@@ -375,12 +386,6 @@ export function CountrySelect({
|
|
|
375
386
|
value={country.code}
|
|
376
387
|
onSelect={() => handleSelect(country.code)}
|
|
377
388
|
>
|
|
378
|
-
<Check
|
|
379
|
-
className={cn(
|
|
380
|
-
"mr-2 h-4 w-4 shrink-0",
|
|
381
|
-
isSelected ? "opacity-100" : "opacity-0"
|
|
382
|
-
)}
|
|
383
|
-
/>
|
|
384
389
|
<Flag countryCode={country.code} rounded className="mr-2 h-4 w-6 shrink-0" />
|
|
385
390
|
<div className="flex flex-col flex-1 min-w-0">
|
|
386
391
|
<span className="truncate">{country.name}</span>
|
|
@@ -390,6 +395,13 @@ export function CountrySelect({
|
|
|
390
395
|
</span>
|
|
391
396
|
)}
|
|
392
397
|
</div>
|
|
398
|
+
|
|
399
|
+
{/* Trailing and only when selected: an always-rendered
|
|
400
|
+
`opacity-0` mark indents every row to hold space one
|
|
401
|
+
row at most ever uses. Absent it, the label gets the
|
|
402
|
+
full width; present, `shrink-0` stops the label's
|
|
403
|
+
truncation exactly at the mark. */}
|
|
404
|
+
{isSelected && <Check className="ml-2 h-4 w-4 shrink-0" />}
|
|
393
405
|
</CommandItem>
|
|
394
406
|
)
|
|
395
407
|
})}
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
import { Input } from '../forms/input';
|
|
17
17
|
import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover';
|
|
18
18
|
import { ScrollArea } from '../layout/scroll-area';
|
|
19
|
+
import { SELECT_TRIGGER_CLASS } from './trigger';
|
|
20
|
+
import { useHighlight } from '../../hooks';
|
|
19
21
|
|
|
20
22
|
export interface LanguageOption {
|
|
21
23
|
code: TLanguageCode;
|
|
@@ -146,6 +148,7 @@ export function LanguageSelect({
|
|
|
146
148
|
moreItemsLabel = (count: number) => `+${count} more`,
|
|
147
149
|
}: LanguageSelectProps) {
|
|
148
150
|
const [open, setOpen] = React.useState(false)
|
|
151
|
+
const highlight = useHighlight(open, value[0])
|
|
149
152
|
const [search, setSearch] = React.useState("")
|
|
150
153
|
|
|
151
154
|
// Resolve defaults
|
|
@@ -377,7 +380,7 @@ export function LanguageSelect({
|
|
|
377
380
|
role="combobox"
|
|
378
381
|
aria-expanded={open}
|
|
379
382
|
className={cn(
|
|
380
|
-
|
|
383
|
+
SELECT_TRIGGER_CLASS,
|
|
381
384
|
// Single-select: a fixed-height pill (the Button size sets height).
|
|
382
385
|
// Multiple: allow growth so wrapped badges aren't clipped — keep the
|
|
383
386
|
// auto-height min behaviour, scaled to the chosen size.
|
|
@@ -401,7 +404,15 @@ export function LanguageSelect({
|
|
|
401
404
|
className="w-auto min-w-[var(--radix-popover-trigger-width)] p-0 [--lang-min:11rem] [min-width:max(var(--radix-popover-trigger-width),var(--lang-min))]"
|
|
402
405
|
align="start"
|
|
403
406
|
>
|
|
404
|
-
|
|
407
|
+
{/* Controlled highlight. `onValueChange` is mandatory alongside it:
|
|
408
|
+
with a controlled `value` cmdk stores nothing itself and delegates
|
|
409
|
+
every move, so without it the arrow keys would not move the row. */}
|
|
410
|
+
<Command
|
|
411
|
+
shouldFilter={false}
|
|
412
|
+
value={highlight.value}
|
|
413
|
+
onValueChange={highlight.setValue}
|
|
414
|
+
className="flex flex-col"
|
|
415
|
+
>
|
|
405
416
|
{showSearch && (
|
|
406
417
|
<CommandInput
|
|
407
418
|
placeholder={resolvedSearchPlaceholder}
|
|
@@ -410,7 +421,7 @@ export function LanguageSelect({
|
|
|
410
421
|
onValueChange={setSearch}
|
|
411
422
|
/>
|
|
412
423
|
)}
|
|
413
|
-
<CommandList className="max-h-[300px] overflow-y-auto">
|
|
424
|
+
<CommandList ref={highlight.listRef} className="max-h-[300px] overflow-y-auto">
|
|
414
425
|
{filteredLanguages.length === 0 ? (
|
|
415
426
|
<CommandEmpty>{resolvedEmptyText}</CommandEmpty>
|
|
416
427
|
) : (
|
|
@@ -423,12 +434,6 @@ export function LanguageSelect({
|
|
|
423
434
|
value={language.code}
|
|
424
435
|
onSelect={() => handleSelect(language.code)}
|
|
425
436
|
>
|
|
426
|
-
<Check
|
|
427
|
-
className={cn(
|
|
428
|
-
"mr-2 h-4 w-4 shrink-0",
|
|
429
|
-
isSelected ? "opacity-100" : "opacity-0"
|
|
430
|
-
)}
|
|
431
|
-
/>
|
|
432
437
|
{showFlag && (
|
|
433
438
|
<LanguageFlag code={language.code} className="mr-2 h-3.5 w-5" rounded />
|
|
434
439
|
)}
|
|
@@ -445,6 +450,13 @@ export function LanguageSelect({
|
|
|
445
450
|
{language.code}
|
|
446
451
|
</span>
|
|
447
452
|
)}
|
|
453
|
+
|
|
454
|
+
{/* Trailing and only when selected: an always-rendered
|
|
455
|
+
`opacity-0` mark indents every row to hold space one
|
|
456
|
+
row at most ever uses. Absent it, the label gets the
|
|
457
|
+
full width; present, `shrink-0` stops the label's
|
|
458
|
+
truncation exactly at the mark. */}
|
|
459
|
+
{isSelected && <Check className="ml-2 h-4 w-4 shrink-0" />}
|
|
448
460
|
</CommandItem>
|
|
449
461
|
)
|
|
450
462
|
})}
|
|
@@ -24,6 +24,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
|
|
24
24
|
import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover';
|
|
25
25
|
import { Separator } from '../layout/separator';
|
|
26
26
|
import { cn } from '../../lib';
|
|
27
|
+
import { SELECT_TRIGGER_MULTI_CLASS } from './trigger';
|
|
27
28
|
|
|
28
29
|
// ==================== TYPES ====================
|
|
29
30
|
|
|
@@ -105,11 +106,11 @@ export interface MultiSelectProAsyncProps {
|
|
|
105
106
|
// ==================== VARIANTS ====================
|
|
106
107
|
|
|
107
108
|
const multiSelectVariants = cva(
|
|
108
|
-
|
|
109
|
+
SELECT_TRIGGER_MULTI_CLASS,
|
|
109
110
|
{
|
|
110
111
|
variants: {
|
|
111
112
|
variant: {
|
|
112
|
-
default: "border-
|
|
113
|
+
default: "border-border bg-background hover:bg-accent/50",
|
|
113
114
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
114
115
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
115
116
|
inverted: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
@@ -11,6 +11,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
|
|
11
11
|
import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover';
|
|
12
12
|
import { Separator } from '../layout/separator';
|
|
13
13
|
import { cn } from '../../lib';
|
|
14
|
+
import { SELECT_TRIGGER_MULTI_CLASS } from './trigger';
|
|
14
15
|
|
|
15
16
|
// ==================== TYPES ====================
|
|
16
17
|
|
|
@@ -57,11 +58,11 @@ export interface MultiSelectProRef {
|
|
|
57
58
|
// ==================== VARIANTS ====================
|
|
58
59
|
|
|
59
60
|
const multiSelectVariants = cva(
|
|
60
|
-
|
|
61
|
+
SELECT_TRIGGER_MULTI_CLASS,
|
|
61
62
|
{
|
|
62
63
|
variants: {
|
|
63
64
|
variant: {
|
|
64
|
-
default: "border-
|
|
65
|
+
default: "border-border bg-background hover:bg-accent/50",
|
|
65
66
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
66
67
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
67
68
|
inverted: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList
|
|
13
13
|
} from '../navigation/command';
|
|
14
14
|
import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover';
|
|
15
|
+
import { SELECT_TRIGGER_MULTI_CLASS } from './trigger';
|
|
15
16
|
|
|
16
17
|
export interface MultiSelectOption {
|
|
17
18
|
value: string
|
|
@@ -78,7 +79,6 @@ export function MultiSelect({
|
|
|
78
79
|
const t = useAppT()
|
|
79
80
|
const [open, setOpen] = React.useState(false)
|
|
80
81
|
const [search, setSearch] = React.useState("")
|
|
81
|
-
const scrollRef = React.useRef<HTMLDivElement>(null)
|
|
82
82
|
|
|
83
83
|
const storageOptions: UseStoredValueOptions | undefined =
|
|
84
84
|
storageKey ? { storage: storageType ?? 'local', ttl: storageTtl } : undefined;
|
|
@@ -137,19 +137,6 @@ export function MultiSelect({
|
|
|
137
137
|
autoSelectApplied.current = false;
|
|
138
138
|
}, [storageKey]);
|
|
139
139
|
|
|
140
|
-
React.useEffect(() => {
|
|
141
|
-
if (scrollRef.current && open) {
|
|
142
|
-
const el = scrollRef.current
|
|
143
|
-
el.style.cssText = `
|
|
144
|
-
max-height: 300px !important;
|
|
145
|
-
overflow-y: scroll !important;
|
|
146
|
-
overflow-x: hidden !important;
|
|
147
|
-
-webkit-overflow-scrolling: touch !important;
|
|
148
|
-
overscroll-behavior: contain !important;
|
|
149
|
-
`
|
|
150
|
-
}
|
|
151
|
-
}, [open])
|
|
152
|
-
|
|
153
140
|
const selectedOptions = React.useMemo(
|
|
154
141
|
() => options.filter((option) => value.includes(option.value)),
|
|
155
142
|
[options, value]
|
|
@@ -235,7 +222,7 @@ export function MultiSelect({
|
|
|
235
222
|
role="combobox"
|
|
236
223
|
aria-expanded={open}
|
|
237
224
|
className={cn(
|
|
238
|
-
|
|
225
|
+
SELECT_TRIGGER_MULTI_CLASS,
|
|
239
226
|
className
|
|
240
227
|
)}
|
|
241
228
|
disabled={disabled}
|
|
@@ -254,23 +241,16 @@ export function MultiSelect({
|
|
|
254
241
|
value={search}
|
|
255
242
|
onValueChange={setSearch}
|
|
256
243
|
/>
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
minHeight: '100px',
|
|
264
|
-
}}
|
|
265
|
-
onWheel={(e) => {
|
|
266
|
-
e.stopPropagation()
|
|
267
|
-
}}
|
|
268
|
-
>
|
|
269
|
-
<CommandList className="!max-h-none !overflow-visible" style={{ pointerEvents: 'auto' }}>
|
|
244
|
+
{/* CommandList is the scroller (it caps itself to the popover's
|
|
245
|
+
available height) — never wrap it in a scrolling div, which hands
|
|
246
|
+
the scrolling to a container cmdk cannot see.
|
|
247
|
+
The min-height keeps the panel from collapsing to a sliver while a
|
|
248
|
+
narrow search still matches one row. */}
|
|
249
|
+
<CommandList className="min-h-[100px]">
|
|
270
250
|
{filteredOptions.length === 0 ? (
|
|
271
251
|
<CommandEmpty>{resolvedEmptyText}</CommandEmpty>
|
|
272
252
|
) : (
|
|
273
|
-
<CommandGroup
|
|
253
|
+
<CommandGroup>
|
|
274
254
|
{filteredOptions.map((option) => {
|
|
275
255
|
const isSelected = value.includes(option.value)
|
|
276
256
|
return (
|
|
@@ -304,7 +284,6 @@ export function MultiSelect({
|
|
|
304
284
|
</CommandGroup>
|
|
305
285
|
)}
|
|
306
286
|
</CommandList>
|
|
307
|
-
</div>
|
|
308
287
|
</Command>
|
|
309
288
|
</PopoverContent>
|
|
310
289
|
</Popover>
|
|
@@ -66,7 +66,7 @@ const SelectTrigger = React.forwardRef<
|
|
|
66
66
|
className={cn(
|
|
67
67
|
"flex items-center justify-between whitespace-nowrap text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
68
68
|
variant === "default" &&
|
|
69
|
-
"h-10 w-full rounded-[var(--radius)] border border-
|
|
69
|
+
"h-10 w-full rounded-[var(--radius)] border border-border bg-transparent px-3 py-2 shadow-sm focus:ring-1 focus:ring-ring",
|
|
70
70
|
variant === "ghost" &&
|
|
71
71
|
"rounded-md px-2 py-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground",
|
|
72
72
|
className
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared class list for a select-like trigger.
|
|
3
|
+
*
|
|
4
|
+
* Every picker in this folder renders its closed state as a `Button` — that is
|
|
5
|
+
* what gives it the focus ring, the border and the disabled handling for free.
|
|
6
|
+
* But a button is an ACTION, and `Button` styles it accordingly with
|
|
7
|
+
* `font-semibold`; a picker's closed state is a FIELD showing a value, and a
|
|
8
|
+
* value has no business being heavier than the label above it. Left alone the
|
|
9
|
+
* trigger rendered "Volkswagen" bolder than its own "Make" label, and the
|
|
10
|
+
* placeholder came out bold too.
|
|
11
|
+
*
|
|
12
|
+
* `font-normal` restores the weight `Input` uses — the control it actually
|
|
13
|
+
* belongs next to in a form. It sits here rather than in seven component files
|
|
14
|
+
* so the next picker inherits the decision instead of re-deciding it.
|
|
15
|
+
*/
|
|
16
|
+
export const SELECT_TRIGGER_CLASS = "w-full justify-between font-normal";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The same, for pickers whose trigger grows with its content (chips, tags,
|
|
20
|
+
* multiple rows) instead of keeping a fixed control height.
|
|
21
|
+
*/
|
|
22
|
+
export const SELECT_TRIGGER_MULTI_CLASS =
|
|
23
|
+
"w-full justify-between font-normal min-h-10 h-auto py-2";
|
package/src/hooks/dom/index.ts
CHANGED