@djangocfg/ui-core 2.1.555 → 2.1.557
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 +2 -2
- 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/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 +4 -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 -40
- 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/components/select/use-highlight.ts +68 -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
|
@@ -12,6 +12,8 @@ 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';
|
|
16
|
+
import { useHighlight } from './use-highlight';
|
|
15
17
|
|
|
16
18
|
export interface ComboboxOption {
|
|
17
19
|
value: string
|
|
@@ -109,7 +111,6 @@ export function Combobox({
|
|
|
109
111
|
const t = useAppT()
|
|
110
112
|
const [open, setOpen] = React.useState(false)
|
|
111
113
|
const [search, setSearch] = React.useState("")
|
|
112
|
-
const scrollRef = React.useRef<HTMLDivElement>(null)
|
|
113
114
|
|
|
114
115
|
const storageOptions: UseStoredValueOptions | undefined =
|
|
115
116
|
storageKey ? { storage: storageType ?? 'local', ttl: storageTtl } : undefined;
|
|
@@ -129,6 +130,8 @@ export function Combobox({
|
|
|
129
130
|
// The effective selected value: prefer controlled, fall back to internal
|
|
130
131
|
const value = controlledValue !== undefined ? controlledValue : internalValue;
|
|
131
132
|
|
|
133
|
+
const highlight = useHighlight(open, value)
|
|
134
|
+
|
|
132
135
|
// autoSelectFromOptions: when options become available, validate stored value.
|
|
133
136
|
// Runs whenever options change (async loads, filters, etc.)
|
|
134
137
|
// Only acts when: storageKey is set, autoSelectFromOptions=true, options non-empty.
|
|
@@ -177,22 +180,6 @@ export function Combobox({
|
|
|
177
180
|
const resolvedSearchPlaceholder = searchPlaceholder ?? t('ui.select.search')
|
|
178
181
|
const resolvedEmptyText = emptyText ?? t('ui.select.noResults')
|
|
179
182
|
|
|
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
183
|
const selectedOption = React.useMemo(
|
|
197
184
|
() => options.find((option) => option.value === value),
|
|
198
185
|
[options, value]
|
|
@@ -270,7 +257,7 @@ export function Combobox({
|
|
|
270
257
|
role="combobox"
|
|
271
258
|
aria-expanded={open}
|
|
272
259
|
className={cn(
|
|
273
|
-
|
|
260
|
+
SELECT_TRIGGER_CLASS,
|
|
274
261
|
!value && "text-muted-foreground",
|
|
275
262
|
className
|
|
276
263
|
)}
|
|
@@ -290,29 +277,33 @@ export function Combobox({
|
|
|
290
277
|
style={contentStyle}
|
|
291
278
|
align="start"
|
|
292
279
|
>
|
|
293
|
-
|
|
280
|
+
{/* The highlight is controlled so it STARTS on the current selection —
|
|
281
|
+
cmdk otherwise highlights the first item, and its open-time
|
|
282
|
+
`scrollIntoView` then parks an 80-entry list at the top with the
|
|
283
|
+
selected row far below the fold.
|
|
284
|
+
`onValueChange` is mandatory here, not decorative: for a controlled
|
|
285
|
+
`value` cmdk delegates every move to it and stores nothing itself,
|
|
286
|
+
so without it the arrow keys would not move the highlight at all. */}
|
|
287
|
+
<Command
|
|
288
|
+
shouldFilter={false}
|
|
289
|
+
value={highlight.value}
|
|
290
|
+
onValueChange={highlight.setValue}
|
|
291
|
+
className="flex flex-col"
|
|
292
|
+
>
|
|
294
293
|
<CommandInput
|
|
295
294
|
placeholder={resolvedSearchPlaceholder}
|
|
296
295
|
className="shrink-0"
|
|
297
296
|
value={search}
|
|
298
297
|
onValueChange={setSearch}
|
|
299
298
|
/>
|
|
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' }}>
|
|
299
|
+
{/* CommandList is the scroller — see its own note. It caps itself to
|
|
300
|
+
the popover's available height, so no wrapper is needed here and
|
|
301
|
+
none may be added: one would take the scrolling away from cmdk. */}
|
|
302
|
+
<CommandList ref={highlight.listRef}>
|
|
312
303
|
{filteredOptions.length === 0 ? (
|
|
313
304
|
<CommandEmpty>{resolvedEmptyText}</CommandEmpty>
|
|
314
305
|
) : (
|
|
315
|
-
<CommandGroup
|
|
306
|
+
<CommandGroup>
|
|
316
307
|
{filteredOptions.map((option) => (
|
|
317
308
|
<CommandItem
|
|
318
309
|
key={option.value}
|
|
@@ -324,19 +315,16 @@ export function Combobox({
|
|
|
324
315
|
}}
|
|
325
316
|
disabled={option.disabled}
|
|
326
317
|
>
|
|
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
318
|
{option.icon && <option.icon className="mr-2 h-4 w-4 shrink-0" />}
|
|
334
319
|
{renderOption ? (
|
|
335
320
|
renderOption(option)
|
|
336
321
|
) : (
|
|
337
322
|
<div className="flex flex-col flex-1 min-w-0">
|
|
338
323
|
<div className="flex items-center gap-2 truncate">
|
|
339
|
-
|
|
324
|
+
{/* `title` rather than wrapping: a row is a label to
|
|
325
|
+
scan, and a two-line one breaks both the vertical
|
|
326
|
+
rhythm and the constant step of the arrow keys. */}
|
|
327
|
+
<span className="truncate" title={option.label}>{option.label}</span>
|
|
340
328
|
{option.badge && (
|
|
341
329
|
<Badge variant="outline" className="text-xs shrink-0">
|
|
342
330
|
{option.badge}
|
|
@@ -350,12 +338,20 @@ export function Combobox({
|
|
|
350
338
|
)}
|
|
351
339
|
</div>
|
|
352
340
|
)}
|
|
341
|
+
{/* Trailing, and rendered ONLY when selected — an
|
|
342
|
+
always-present `opacity-0` placeholder indents every row
|
|
343
|
+
by 24px to reserve space for a mark at most one of them
|
|
344
|
+
ever shows. Absent, the label gets the full width;
|
|
345
|
+
present, `shrink-0` makes the label's truncation stop
|
|
346
|
+
exactly at the mark. */}
|
|
347
|
+
{value === option.value && (
|
|
348
|
+
<Check className="ml-2 h-4 w-4 shrink-0" />
|
|
349
|
+
)}
|
|
353
350
|
</CommandItem>
|
|
354
351
|
))}
|
|
355
352
|
</CommandGroup>
|
|
356
353
|
)}
|
|
357
354
|
</CommandList>
|
|
358
|
-
</div>
|
|
359
355
|
</Command>
|
|
360
356
|
</PopoverContent>
|
|
361
357
|
</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 './use-highlight';
|
|
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 './use-highlight';
|
|
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";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The active-row state for a cmdk list inside a popover, and the scroll that
|
|
7
|
+
* has to accompany it.
|
|
8
|
+
*
|
|
9
|
+
* Two things are going on, and neither works alone:
|
|
10
|
+
*
|
|
11
|
+
* 1. **The highlight is controlled.** cmdk otherwise activates the FIRST row,
|
|
12
|
+
* so a picker with a selection opens showing the top of the list rather than
|
|
13
|
+
* the value it holds. `onValueChange` is mandatory with a controlled value —
|
|
14
|
+
* cmdk then stores nothing itself and delegates every move to the caller, so
|
|
15
|
+
* omitting it freezes the arrow keys.
|
|
16
|
+
*
|
|
17
|
+
* 2. **Opening has to scroll.** cmdk schedules its own `scrollIntoView` when
|
|
18
|
+
* the value changes, but the popover keeps its list MOUNTED between opens
|
|
19
|
+
* (`sameNodeAcrossOpens: true`), so on the second open nothing re-mounts and
|
|
20
|
+
* nothing re-scrolls; on the first, the value is set before 252 rows have
|
|
21
|
+
* finished registering and there is nothing to scroll to yet. Measured both
|
|
22
|
+
* ways: selection at index 84, `scrollTop` 0. So the scroll is driven from
|
|
23
|
+
* the OPEN transition, after a paint, rather than left to the library.
|
|
24
|
+
*
|
|
25
|
+
* Ongoing arrow-key movement stays cmdk's own job; this only handles the jump
|
|
26
|
+
* that opening owes the user.
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* const highlight = useHighlight(open, value)
|
|
30
|
+
* <Command value={highlight.value} onValueChange={highlight.setValue}>
|
|
31
|
+
* <CommandList ref={highlight.listRef}>…</CommandList>
|
|
32
|
+
* </Command>
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function useHighlight(open: boolean, selected: string | null | undefined) {
|
|
36
|
+
const [value, setValue] = React.useState("")
|
|
37
|
+
const listRef = React.useRef<HTMLDivElement | null>(null)
|
|
38
|
+
|
|
39
|
+
// `selected` is read through a ref so that changing the selection does not
|
|
40
|
+
// re-run the open effect — only the open transition itself should scroll.
|
|
41
|
+
const selectedRef = React.useRef(selected)
|
|
42
|
+
selectedRef.current = selected
|
|
43
|
+
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
if (!open) return
|
|
46
|
+
setValue(selectedRef.current ?? "")
|
|
47
|
+
}, [open])
|
|
48
|
+
|
|
49
|
+
// Deliberately NOT `requestAnimationFrame`: a background tab never runs one,
|
|
50
|
+
// so a popover opened in an inactive window would stay parked at the top
|
|
51
|
+
// (this is how the bug first showed up — `document.hidden` and no scroll).
|
|
52
|
+
// A layout effect keyed on the resolved highlight runs as soon as React has
|
|
53
|
+
// committed the rows, in any tab, and before the browser paints.
|
|
54
|
+
React.useLayoutEffect(() => {
|
|
55
|
+
if (!open || !value) return
|
|
56
|
+
const list = listRef.current
|
|
57
|
+
if (!list) return
|
|
58
|
+
|
|
59
|
+
// The row is found by VALUE rather than by `aria-selected`, which cmdk sets
|
|
60
|
+
// in its own scheduled pass — by then this effect has already run.
|
|
61
|
+
const row = list.querySelector(
|
|
62
|
+
`[cmdk-item][data-value="${CSS.escape(value)}"]`
|
|
63
|
+
)
|
|
64
|
+
row?.scrollIntoView({ block: 'nearest' })
|
|
65
|
+
}, [open, value])
|
|
66
|
+
|
|
67
|
+
return { value, setValue, listRef }
|
|
68
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A row of filter controls, and the pills in it.
|
|
3
|
+
*
|
|
4
|
+
* The row owns the height. Every control in it — a pill, a select, a sort menu —
|
|
5
|
+
* reads `--filter-control-h`, which is what stops a caller restyling somebody
|
|
6
|
+
* else's component to make one row line up. Both product frontends were doing
|
|
7
|
+
* exactly that: one overrode `SelectTrigger`, the other `Button size="sm"`.
|
|
8
|
+
*/
|
|
9
|
+
.filter-bar {
|
|
10
|
+
/* Named so a host can set the row's density once, here, rather than per
|
|
11
|
+
control. 38px is the height both frontends independently arrived at. */
|
|
12
|
+
--filter-control-h: 2.375rem;
|
|
13
|
+
|
|
14
|
+
container-type: inline-size;
|
|
15
|
+
container-name: filter-bar;
|
|
16
|
+
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-wrap: wrap;
|
|
19
|
+
align-items: center;
|
|
20
|
+
gap: 0.5rem;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* Horizontal scroll instead of wrapping, for a row that must stay one line. */
|
|
24
|
+
.filter-bar[data-overflow="scroll"] {
|
|
25
|
+
flex-wrap: nowrap;
|
|
26
|
+
overflow-x: auto;
|
|
27
|
+
overscroll-behavior-x: contain;
|
|
28
|
+
scrollbar-width: none;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.filter-bar[data-overflow="scroll"]::-webkit-scrollbar {
|
|
32
|
+
display: none;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.filter-bar[data-overflow="scroll"] > * {
|
|
36
|
+
flex: none;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The label inside a pill, and the rule that hides it.
|
|
41
|
+
*
|
|
42
|
+
* It collapses when the ROW runs out of room, not when the window does: a
|
|
43
|
+
* toolbar in a narrow column on a wide monitor has the same problem as a phone,
|
|
44
|
+
* and a viewport media query answers neither. Hiding it is `display: none`
|
|
45
|
+
* rather than the `clip-path: inset(50%)` both projects used — that trick keeps
|
|
46
|
+
* the text in the accessibility tree while removing it from sight, which reads
|
|
47
|
+
* fine and measures wrong. The button carries an `aria-label` instead, so the
|
|
48
|
+
* name survives the collapse by design rather than by accident.
|
|
49
|
+
*
|
|
50
|
+
* A pill showing a CHOSEN value keeps its label: "SUV" is the answer, and an
|
|
51
|
+
* icon alone cannot say which question it answered.
|
|
52
|
+
*/
|
|
53
|
+
@container filter-bar (max-width: 32rem) {
|
|
54
|
+
.filter-pill:not([data-answered="true"]) .filter-pill-label {
|
|
55
|
+
display: none;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.filter-pill:not([data-answered="true"]) {
|
|
59
|
+
padding-inline: 0.625rem;
|
|
60
|
+
}
|
|
61
|
+
}
|