@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.
Files changed (32) hide show
  1. package/README.md +2 -2
  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/mask-input/index.tsx +1 -1
  9. package/src/components/forms/segmented-input/index.tsx +1 -1
  10. package/src/components/forms/tags-input/index.tsx +1 -1
  11. package/src/components/index.ts +4 -0
  12. package/src/components/layout/filter-bar/index.tsx +48 -0
  13. package/src/components/navigation/command/index.tsx +19 -40
  14. package/src/components/navigation/tabs/index.tsx +102 -17
  15. package/src/components/navigation/tabs/use-tab-indicator.ts +68 -0
  16. package/src/components/overlay/drawer/index.tsx +113 -15
  17. package/src/components/overlay/popover/index.tsx +8 -2
  18. package/src/components/overlay/side-panel/index.tsx +77 -8
  19. package/src/components/select/combobox-async.tsx +26 -32
  20. package/src/components/select/combobox.tsx +36 -40
  21. package/src/components/select/country-select.tsx +21 -9
  22. package/src/components/select/language-select.tsx +21 -9
  23. package/src/components/select/multi-select-pro-async.tsx +3 -2
  24. package/src/components/select/multi-select-pro.tsx +3 -2
  25. package/src/components/select/multi-select.tsx +9 -30
  26. package/src/components/select/select.tsx +1 -1
  27. package/src/components/select/trigger.ts +23 -0
  28. package/src/components/select/use-highlight.ts +68 -0
  29. package/src/styles/css/utilities/filter-bar.css +61 -0
  30. package/src/styles/css/utilities/overlay.css +78 -0
  31. package/src/styles/css/utilities/tabs.css +53 -0
  32. 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
- "w-full justify-between",
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
- <Command shouldFilter={false} className="flex flex-col">
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
- <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' }}>
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 className="!overflow-visible" style={{ pointerEvents: 'auto' }}>
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
- <span className="truncate">{option.label}</span>
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
- "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 './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
- "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";
@@ -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
+ }