@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.
Files changed (37) hide show
  1. package/README.md +3 -3
  2. package/package.json +5 -5
  3. package/src/components/data/calendar/calendar.tsx +1 -1
  4. package/src/components/data/toggle/index.tsx +1 -1
  5. package/src/components/forms/button/index.tsx +6 -1
  6. package/src/components/forms/checkbox/index.tsx +1 -1
  7. package/src/components/forms/filter-button/index.tsx +145 -0
  8. package/src/components/forms/filter-menu/index.tsx +203 -0
  9. package/src/components/forms/mask-input/index.tsx +1 -1
  10. package/src/components/forms/segmented-input/index.tsx +1 -1
  11. package/src/components/forms/tags-input/index.tsx +1 -1
  12. package/src/components/index.ts +6 -0
  13. package/src/components/layout/filter-bar/index.tsx +48 -0
  14. package/src/components/navigation/command/index.tsx +19 -40
  15. package/src/components/navigation/tabs/index.tsx +102 -17
  16. package/src/components/navigation/tabs/use-tab-indicator.ts +68 -0
  17. package/src/components/overlay/drawer/index.tsx +113 -15
  18. package/src/components/overlay/popover/index.tsx +8 -2
  19. package/src/components/overlay/side-panel/index.tsx +77 -8
  20. package/src/components/select/combobox-async.tsx +26 -32
  21. package/src/components/select/combobox.tsx +36 -41
  22. package/src/components/select/country-select.tsx +21 -9
  23. package/src/components/select/language-select.tsx +21 -9
  24. package/src/components/select/multi-select-pro-async.tsx +3 -2
  25. package/src/components/select/multi-select-pro.tsx +3 -2
  26. package/src/components/select/multi-select.tsx +9 -30
  27. package/src/components/select/select.tsx +1 -1
  28. package/src/components/select/trigger.ts +23 -0
  29. package/src/hooks/dom/index.ts +1 -0
  30. package/src/hooks/dom/useHighlight.ts +68 -0
  31. package/src/styles/css/presets/dense.css +12 -0
  32. package/src/styles/css/presets/ios.css +12 -0
  33. package/src/styles/css/presets/soft.css +12 -0
  34. package/src/styles/css/utilities/filter-bar.css +61 -0
  35. package/src/styles/css/utilities/overlay.css +78 -0
  36. package/src/styles/css/utilities/tabs.css +53 -0
  37. 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 scrollRef = React.useRef<HTMLDivElement>(null)
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
- "w-full justify-between",
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
- <Command shouldFilter={false} className="flex flex-col">
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
- <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' }}>
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 className="!overflow-visible" style={{ pointerEvents: 'auto' }}>
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
- "w-full justify-between",
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
- <Command shouldFilter={false} className="flex flex-col">
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
- <div
301
- ref={scrollRef}
302
- tabIndex={-1}
303
- className="overflow-y-auto overflow-x-hidden"
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 className="!overflow-visible" style={{ pointerEvents: 'auto' }}>
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
- <span className="truncate">{option.label}</span>
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
- "w-full justify-between min-h-10 h-auto py-2",
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
- <Command shouldFilter={false} className="flex flex-col">
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
- "w-full justify-between",
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
- <Command shouldFilter={false} className="flex flex-col">
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
- "w-full justify-between min-h-10 h-auto py-2",
109
+ SELECT_TRIGGER_MULTI_CLASS,
109
110
  {
110
111
  variants: {
111
112
  variant: {
112
- default: "border-input bg-background hover:bg-accent/50",
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
- "w-full justify-between min-h-10 h-auto py-2",
61
+ SELECT_TRIGGER_MULTI_CLASS,
61
62
  {
62
63
  variants: {
63
64
  variant: {
64
- default: "border-input bg-background hover:bg-accent/50",
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
- "w-full justify-between min-h-10 h-auto py-2",
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
- <div
258
- ref={scrollRef}
259
- tabIndex={-1}
260
- className="overflow-y-scroll overflow-x-hidden"
261
- style={{
262
- maxHeight: '300px',
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 className="!overflow-visible" style={{ pointerEvents: 'auto' }}>
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-input bg-transparent px-3 py-2 shadow-sm focus:ring-1 focus:ring-ring",
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";
@@ -16,3 +16,4 @@ export { useResizeObserver } from './useResizeObserver';
16
16
  export type { Size } from './useResizeObserver';
17
17
  export { useFormReset } from './useFormReset';
18
18
  export type { UseFormResetParams } from './useFormReset';
19
+ export { useHighlight } from './useHighlight';