@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
package/README.md CHANGED
@@ -51,11 +51,11 @@ import { UiProviders, Button, Card } from '@djangocfg/ui-core';
51
51
  | Group | Examples |
52
52
  |---|---|
53
53
  | `components/data/` | Avatar · Badge · Card · Table · BalancedText · Skeleton |
54
- | `components/forms/` | Button · Input · Textarea · Select · Switch · Checkbox · Slider · Form · DateField · TimeField · DateTimeField · MoneyField |
54
+ | `components/forms/` | Button · Input · Textarea · Select · Switch · Checkbox · Slider · Form · DateField · TimeField · DateTimeField · MoneyField · FilterButton · FilterMenu |
55
55
  | `components/feedback/` | Alert · Toast · Banner · Progress · Spinner |
56
56
  | `components/overlay/` | Dialog · Drawer · Popover · Tooltip · HoverCard · Sheet · ContextMenu · DropdownMenu |
57
57
  | `components/navigation/` | Sidebar · Tabs · Breadcrumb · Pagination · NavigationMenu · Command · Disclosure |
58
- | `components/layout/` | Container · Grid · Stack · Separator · ScrollArea · Sticky |
58
+ | `components/layout/` | Container · Grid · Stack · Separator · ScrollArea · Sticky · FilterBar |
59
59
  | `components/select/` | Combobox · MultiSelect |
60
60
  | `components/effects/` | Glass · Marquee · Backdrop |
61
61
  | `components/specialized/` | Accordion · Collapsible · Toggle · Calendar · DatePicker (legacy — prefer forms/DateField) |
@@ -71,7 +71,7 @@ fields (`DateField` / `TimeField` / `DateTimeField`) have their own reference in
71
71
 
72
72
  | Topic | Hooks |
73
73
  |---|---|
74
- | `dom/` | `useSize` · `useResizeObserver` · `useMeasure` · `useMutationObserver` · `useIntersection` |
74
+ | `dom/` | `useSize` · `useResizeObserver` · `useMeasure` · `useMutationObserver` · `useIntersection` · `useHighlight` |
75
75
  | `device/` | `useIsMobile` · `useIsTouch` · `useMediaQuery` · `useOnline` · `useViewportSize` · `useOrientation` |
76
76
  | `state/` | `useLocalStorage` · `useSessionStorage` · `useToggle` · `useCounter` · `useDebouncedValue` |
77
77
  | `events/` | `useEventListener` · `useClickOutside` · `useKeyPress` · `useFocusWithin` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.556",
3
+ "version": "2.1.558",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -130,7 +130,7 @@
130
130
  "check:contrast": "node scripts/check-preset-contrast.mjs"
131
131
  },
132
132
  "peerDependencies": {
133
- "@djangocfg/i18n": "^2.1.556",
133
+ "@djangocfg/i18n": "^2.1.558",
134
134
  "consola": "^3.4.2",
135
135
  "lucide-react": "^0.545.0",
136
136
  "moment": "^2.30.1",
@@ -206,9 +206,9 @@
206
206
  "vaul": "1.1.2"
207
207
  },
208
208
  "devDependencies": {
209
- "@djangocfg/eslint-config": "^2.1.556",
210
- "@djangocfg/i18n": "^2.1.556",
211
- "@djangocfg/typescript-config": "^2.1.556",
209
+ "@djangocfg/eslint-config": "^2.1.558",
210
+ "@djangocfg/i18n": "^2.1.558",
211
+ "@djangocfg/typescript-config": "^2.1.558",
212
212
  "@storybook/react-vite": "^10.5.0",
213
213
  "@types/node": "^24.13.3",
214
214
  "@types/react": "19.2.15",
@@ -83,7 +83,7 @@ function Calendar({
83
83
  defaultClassNames.dropdowns
84
84
  ),
85
85
  dropdown_root: cn(
86
- "has-focus:border-ring border-input shadow-xs has-focus:ring-ring has-focus:ring-1 relative rounded-[var(--radius)] border",
86
+ "has-focus:border-ring border-border shadow-xs has-focus:ring-ring has-focus:ring-1 relative rounded-[var(--radius)] border",
87
87
  defaultClassNames.dropdown_root
88
88
  ),
89
89
  dropdown: cn(
@@ -14,7 +14,7 @@ const toggleVariants = cva(
14
14
  variant: {
15
15
  default: "bg-transparent",
16
16
  outline:
17
- "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
17
+ "border border-border bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
18
18
  },
19
19
  size: {
20
20
  default: "h-9 px-2 min-w-9",
@@ -18,8 +18,13 @@ const buttonVariants = cva(
18
18
  "bg-primary text-primary-foreground shadow hover:bg-primary/90",
19
19
  destructive:
20
20
  "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
21
+ // `border-border`, never `border-input`: `--input` is the FILL token
22
+ // for a field's surface, and in the light theme it is the page colour
23
+ // itself (97% L) — an outline button drawn with it had no visible edge
24
+ // at all. `--border` (86% L) is the token for an edge. Same pairing
25
+ // `Input` and `InputGroup` use.
21
26
  outline:
22
- "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
27
+ "border border-border bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
23
28
  secondary:
24
29
  "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
25
30
  ghost: "hover:bg-accent hover:text-accent-foreground",
@@ -16,7 +16,7 @@ const Checkbox = React.forwardRef<
16
16
  className={cn(
17
17
  // `select-none`: the box itself is a click target, and a drag that starts
18
18
  // on it must not paint a text selection across the row it lives in.
19
- "peer h-[1.125rem] w-[1.125rem] shrink-0 select-none rounded-[4px] border border-input bg-background shadow-none",
19
+ "peer h-[1.125rem] w-[1.125rem] shrink-0 select-none rounded-[4px] border border-border bg-background shadow-none",
20
20
  "transition-colors duration-150",
21
21
  "focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
22
22
  "disabled:cursor-not-allowed disabled:opacity-40",
@@ -0,0 +1,145 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+
5
+ import { cn } from '../../../lib/utils'
6
+
7
+ export interface FilterButtonProps
8
+ extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'type' | 'value'> {
9
+ /** Leading icon. Rendered decoratively — the name comes from the label. */
10
+ icon?: React.ComponentType<{ className?: string }>
11
+ /** Trailing icon, typically a chevron for a control that opens a menu. */
12
+ trailingIcon?: React.ComponentType<{ className?: string }>
13
+ /**
14
+ * The chosen option, shown in place of the label.
15
+ *
16
+ * A pill showing an answer keeps it visible when the row is tight, while the
17
+ * ones still asking a question collapse to their icon: "SUV" is worth the
18
+ * space, "Body type" is what the icon already says.
19
+ */
20
+ value?: React.ReactNode
21
+ /** Marks a filter that is applied. Tints the control. */
22
+ active?: boolean
23
+ /**
24
+ * A number worth showing on the control — active filters, or available
25
+ * options. Zero and `undefined` both render nothing, so a caller can pass a
26
+ * count straight through without guarding it.
27
+ */
28
+ count?: number
29
+ /** Filled rather than outlined. For the one control that opens the panel. */
30
+ variant?: 'outline' | 'solid'
31
+ }
32
+
33
+ /**
34
+ * A filter control in a toolbar: a pill with an icon, a label, and optionally a
35
+ * count.
36
+ *
37
+ * Its one piece of behaviour is that the label collapses when the row runs out
38
+ * of room — see `filter-bar.css`, which owns the query. The button keeps its
39
+ * accessible name through that collapse via `aria-label`, which is the part
40
+ * both product frontends lost: they hid the text with `clip-path: inset(50%)`,
41
+ * which removes it from sight while leaving it in the accessibility tree, so
42
+ * the icon-only state reads correctly to a screen reader by accident and only
43
+ * for as long as the text stays in the DOM.
44
+ *
45
+ * Must be rendered inside a `FilterBar`: the height and the container query
46
+ * both come from the row.
47
+ */
48
+ export const FilterButton = React.forwardRef<HTMLButtonElement, FilterButtonProps>(
49
+ (
50
+ {
51
+ className,
52
+ icon: Icon,
53
+ trailingIcon: TrailingIcon,
54
+ children,
55
+ value,
56
+ active = false,
57
+ count,
58
+ variant = 'outline',
59
+ 'aria-label': ariaLabel,
60
+ ...props
61
+ },
62
+ ref
63
+ ) => {
64
+ const answered = value != null && value !== ''
65
+ const label = answered ? value : children
66
+ const showCount = typeof count === 'number' && count > 0
67
+
68
+ // The name has to survive the label collapsing to nothing, and it has to
69
+ // read as the question plus its answer ("Body type: SUV") rather than the
70
+ // answer alone, which says nothing about what was chosen. The count joins
71
+ // it because the badge itself is decorative — a bare "3" announced after a
72
+ // label is a number without a noun.
73
+ const base =
74
+ answered && typeof children === 'string' && typeof value === 'string'
75
+ ? `${children}: ${value}`
76
+ : typeof children === 'string'
77
+ ? children
78
+ : undefined
79
+ const name = ariaLabel ?? (base && showCount ? `${base} (${count})` : base)
80
+
81
+ return (
82
+ <button
83
+ ref={ref}
84
+ type="button"
85
+ data-slot="filter-pill"
86
+ data-active={active || undefined}
87
+ data-answered={answered || undefined}
88
+ aria-label={name}
89
+ className={cn(
90
+ 'filter-pill inline-flex shrink-0 items-center gap-2 rounded-full',
91
+ 'h-[var(--filter-control-h,2.375rem)] px-3.5',
92
+ 'text-sm font-medium whitespace-nowrap',
93
+ 'transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
94
+ 'disabled:pointer-events-none disabled:opacity-50',
95
+ '[&_svg]:size-4 [&_svg]:shrink-0',
96
+ variant === 'solid'
97
+ ? 'bg-primary text-primary-foreground hover:bg-primary/90'
98
+ : cn(
99
+ 'border bg-background hover:bg-accent hover:text-accent-foreground',
100
+ // An applied filter reads as chosen without shouting: the edge
101
+ // carries it, not a fill that would compete with the solid
102
+ // control beside it.
103
+ //
104
+ // Both states keep `text-foreground`. `text-muted-foreground`
105
+ // was the obvious choice for "not applied" and measured 4.21:1
106
+ // on the light theme — under AA for a control label. An unset
107
+ // filter is a normal control, not a de-emphasised one; what
108
+ // marks the applied one is the border.
109
+ 'text-foreground',
110
+ // Weight as well as colour: a border tint alone would make
111
+ // "applied" a colour-only signal, which the accessibility rules
112
+ // rule out. Usually the label also changes — an applied filter
113
+ // shows its `value` — but `active` can be set without one.
114
+ active
115
+ ? 'border-primary/60 font-semibold'
116
+ : 'border-border'
117
+ ),
118
+ className
119
+ )}
120
+ {...props}
121
+ >
122
+ {Icon && <Icon aria-hidden className="opacity-70" />}
123
+ {label != null && <span className="filter-pill-label">{label}</span>}
124
+ {showCount && (
125
+ <span
126
+ aria-hidden
127
+ className={cn(
128
+ 'inline-flex min-w-5 items-center justify-center rounded-full px-1.5',
129
+ 'text-xs font-semibold tabular-nums',
130
+ // On the filled control the badge has to invert, or it disappears
131
+ // into its own background. Both frontends wrote this pair by hand.
132
+ variant === 'solid'
133
+ ? 'bg-primary-foreground text-primary'
134
+ : 'bg-muted text-foreground'
135
+ )}
136
+ >
137
+ {count}
138
+ </span>
139
+ )}
140
+ {TrailingIcon && <TrailingIcon aria-hidden className="opacity-50" />}
141
+ </button>
142
+ )
143
+ }
144
+ )
145
+ FilterButton.displayName = 'FilterButton'
@@ -0,0 +1,203 @@
1
+ "use client"
2
+
3
+ import { Check, ChevronDown } from 'lucide-react'
4
+ import * as React from 'react'
5
+
6
+ import { useAppT } from '@djangocfg/i18n'
7
+ import { cn } from '../../../lib/utils'
8
+ import {
9
+ Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList
10
+ } from '../../navigation/command'
11
+ import { Popover, PopoverContent, PopoverTrigger } from '../../overlay/popover'
12
+ import { useHighlight } from '../../../hooks';
13
+ import { FilterButton, type FilterButtonProps } from '../filter-button'
14
+
15
+ export interface FilterMenuOption {
16
+ value: string
17
+ label: string
18
+ /** Matches on top of the label — a synonym, a code, a native spelling. */
19
+ keywords?: string[]
20
+ /** How many results this option would leave. Shown after the label. */
21
+ count?: number
22
+ disabled?: boolean
23
+ }
24
+
25
+ type TriggerProps = Pick<
26
+ FilterButtonProps,
27
+ 'icon' | 'variant' | 'className' | 'disabled'
28
+ >
29
+
30
+ export interface FilterMenuProps extends TriggerProps {
31
+ /** The question the control asks: "Body type", "Fuel". */
32
+ label: string
33
+ /**
34
+ * The options to choose from. Omit and pass `children` instead to supply a
35
+ * body of your own — a range slider, a date pair, a colour grid.
36
+ */
37
+ options?: FilterMenuOption[]
38
+ /** Selected option, or `null` for none. */
39
+ value?: string | null
40
+ onValueChange?: (value: string | null) => void
41
+ /**
42
+ * When to show the search field.
43
+ *
44
+ * A number is a threshold on the option count; `true` / `false` force it.
45
+ * The default is the point where the list stops fitting its own popover —
46
+ * `.popover-scroll-region` caps at 300px and a row is ~30px, so beyond ten
47
+ * options the reader is scrolling blind and a search field earns its place.
48
+ * Below that it is one more thing between them and four visible answers.
49
+ */
50
+ searchable?: boolean | number
51
+ searchPlaceholder?: string
52
+ emptyText?: string
53
+ /**
54
+ * A body of your own, in place of `options`. The popover, the trigger and
55
+ * its state stay this component's; what goes inside is yours.
56
+ */
57
+ children?: React.ReactNode
58
+ /** Width of the popover. Defaults to the trigger's width, min 220px. */
59
+ contentClassName?: string
60
+ }
61
+
62
+ /** The option count past which a list needs a search field. */
63
+ const SEARCHABLE_FROM = 10
64
+
65
+ /**
66
+ * A filter control and the menu it opens.
67
+ *
68
+ * Pass `options` and it renders the list, adding a search field once the list
69
+ * outgrows its popover. Pass `children` instead and the body is yours — the
70
+ * control still owns the trigger, the popover and the open state, which is the
71
+ * part every caller was rewriting.
72
+ *
73
+ * The selected option replaces the label on the trigger ("SUV", not "Body
74
+ * type"), so a row of these reads as the current query rather than as a row of
75
+ * unanswered questions.
76
+ */
77
+ export function FilterMenu({
78
+ label,
79
+ options,
80
+ value,
81
+ onValueChange,
82
+ searchable,
83
+ searchPlaceholder,
84
+ emptyText,
85
+ children,
86
+ contentClassName,
87
+ icon,
88
+ variant,
89
+ className,
90
+ disabled,
91
+ }: FilterMenuProps) {
92
+ const t = useAppT()
93
+ const [open, setOpen] = React.useState(false)
94
+ const [search, setSearch] = React.useState('')
95
+ // Opens on the current choice and scrolls to it — the same hook the
96
+ // comboboxes use, so a long facet behaves like every other long list here.
97
+ const highlight = useHighlight(open, value)
98
+
99
+ const resolvedSearchPlaceholder = searchPlaceholder ?? t('ui.select.search')
100
+ const resolvedEmptyText = emptyText ?? t('ui.select.noResults')
101
+
102
+ const showSearch =
103
+ typeof searchable === 'number'
104
+ ? (options?.length ?? 0) >= searchable
105
+ : (searchable ?? (options?.length ?? 0) >= SEARCHABLE_FROM)
106
+
107
+ const selected = options?.find((option) => option.value === value)
108
+
109
+ const filtered = React.useMemo(() => {
110
+ if (!options || !search) return options ?? []
111
+ const needle = search.toLowerCase()
112
+ return options.filter(
113
+ (option) =>
114
+ option.label.toLowerCase().includes(needle) ||
115
+ option.value.toLowerCase().includes(needle) ||
116
+ option.keywords?.some((k) => k.toLowerCase().includes(needle))
117
+ )
118
+ }, [options, search])
119
+
120
+ const handleSelect = React.useCallback(
121
+ (next: string) => {
122
+ // Selecting the chosen option again clears it: the trigger is the only
123
+ // affordance a filter row has, and a dead-end "chosen" state would need
124
+ // a second one.
125
+ onValueChange?.(next === value ? null : next)
126
+ setOpen(false)
127
+ },
128
+ [onValueChange, value]
129
+ )
130
+
131
+ const handleOpenChange = React.useCallback((next: boolean) => {
132
+ setOpen(next)
133
+ if (!next) setSearch('')
134
+ }, [])
135
+
136
+ return (
137
+ <Popover open={open} onOpenChange={handleOpenChange}>
138
+ <PopoverTrigger asChild>
139
+ <FilterButton
140
+ icon={icon}
141
+ variant={variant}
142
+ className={className}
143
+ disabled={disabled}
144
+ trailingIcon={ChevronDown}
145
+ value={selected?.label}
146
+ active={Boolean(selected)}
147
+ aria-expanded={open}
148
+ >
149
+ {label}
150
+ </FilterButton>
151
+ </PopoverTrigger>
152
+ <PopoverContent
153
+ align="start"
154
+ className={cn('w-[max(220px,var(--radix-popover-trigger-width))] p-0', contentClassName)}
155
+ >
156
+ {children ?? (
157
+ <Command
158
+ shouldFilter={false}
159
+ value={highlight.value}
160
+ onValueChange={highlight.setValue}
161
+ className="flex min-h-0 flex-col"
162
+ >
163
+ {showSearch && (
164
+ <CommandInput
165
+ placeholder={resolvedSearchPlaceholder}
166
+ className="shrink-0"
167
+ value={search}
168
+ onValueChange={setSearch}
169
+ />
170
+ )}
171
+ <CommandList ref={highlight.listRef}>
172
+ {filtered.length === 0 ? (
173
+ <CommandEmpty>{resolvedEmptyText}</CommandEmpty>
174
+ ) : (
175
+ <CommandGroup>
176
+ {filtered.map((option) => (
177
+ <CommandItem
178
+ key={option.value}
179
+ value={option.value}
180
+ disabled={option.disabled}
181
+ onSelect={handleSelect}
182
+ >
183
+ <span className="truncate">{option.label}</span>
184
+ {option.count != null && (
185
+ <span className="ml-auto text-xs tabular-nums text-muted-foreground">
186
+ {option.count}
187
+ </span>
188
+ )}
189
+ {option.value === value && (
190
+ <Check className={cn('h-4 w-4 shrink-0', option.count != null ? 'ml-2' : 'ml-auto')} />
191
+ )}
192
+ </CommandItem>
193
+ ))}
194
+ </CommandGroup>
195
+ )}
196
+ </CommandList>
197
+ </Command>
198
+ )}
199
+ </PopoverContent>
200
+ </Popover>
201
+ )
202
+ }
203
+ FilterMenu.displayName = 'FilterMenu'
@@ -458,7 +458,7 @@ const MaskInput = React.forwardRef<HTMLInputElement, MaskInputProps>(
458
458
  ref={composedRef}
459
459
  value={displayValue}
460
460
  className={cn(
461
- "flex h-10 w-full rounded-[var(--radius)] border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
461
+ "flex h-10 w-full rounded-[var(--radius)] border border-border bg-transparent px-3 py-2 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
462
462
  className
463
463
  )}
464
464
  onChange={handleChange}
@@ -258,7 +258,7 @@ const SegmentedInput = React.forwardRef<HTMLDivElement, SegmentedInputProps>(
258
258
  data-active={isActive ? "" : undefined}
259
259
  data-filled={char ? "" : undefined}
260
260
  className={cn(
261
- "relative flex items-center justify-center border-y border-r border-input text-center text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
261
+ "relative flex items-center justify-center border-y border-r border-border text-center text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
262
262
  "focus-visible:outline-none focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
263
263
  "disabled:cursor-not-allowed disabled:opacity-50",
264
264
  fluid ? `flex-1 min-w-0 aspect-square ${sizeVariants[size]}` : sizeVariants[size],
@@ -481,7 +481,7 @@ const TagsInput = React.forwardRef<HTMLDivElement, TagsInputRootProps>(
481
481
  data-invalid={isInvalidInput ? "" : undefined}
482
482
  data-readonly={readOnly ? "" : undefined}
483
483
  className={cn(
484
- "flex flex-wrap items-center gap-1.5 rounded-[var(--radius)] border border-input bg-transparent px-2 py-1.5 text-sm shadow-sm transition-colors",
484
+ "flex flex-wrap items-center gap-1.5 rounded-[var(--radius)] border border-border bg-transparent px-2 py-1.5 text-sm shadow-sm transition-colors",
485
485
  "focus-within:ring-1 focus-within:ring-ring",
486
486
  disabled && "cursor-not-allowed opacity-50",
487
487
  className
@@ -207,6 +207,12 @@ export { Calendar, CalendarDayButton } from './data/calendar';
207
207
  export { DatePicker, DateRangePicker } from './data/calendar';
208
208
  export type { DatePickerProps, DateRangePickerProps, DateRange } from './data/calendar';
209
209
  export { Toggle, toggleVariants } from './data/toggle';
210
+ export { FilterBar } from './layout/filter-bar';
211
+ export type { FilterBarProps } from './layout/filter-bar';
212
+ export { FilterButton } from './forms/filter-button';
213
+ export { FilterMenu } from './forms/filter-menu';
214
+ export type { FilterMenuProps, FilterMenuOption } from './forms/filter-menu';
215
+ export type { FilterButtonProps } from './forms/filter-button';
210
216
  export { ToggleGroup, ToggleGroupItem } from './data/toggle-group';
211
217
  export { AvatarGroup } from './data/avatar-group';
212
218
  export type { AvatarGroupProps } from './data/avatar-group';
@@ -0,0 +1,48 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+
5
+ import { cn } from '../../../lib/utils'
6
+
7
+ export interface FilterBarProps extends React.HTMLAttributes<HTMLDivElement> {
8
+ /**
9
+ * What a row that does not fit does.
10
+ *
11
+ * `wrap` (default) drops controls onto a second line — right for a filter row
12
+ * above a result list, where every control should stay reachable.
13
+ * `scroll` keeps one line and scrolls it — right under a header, where a
14
+ * second line would push the content down.
15
+ */
16
+ overflow?: 'wrap' | 'scroll'
17
+ /**
18
+ * Row height, any CSS length. Every control inside reads it, so a toolbar
19
+ * lines up without anyone restyling a neighbour's component.
20
+ * @default '2.375rem'
21
+ */
22
+ height?: string
23
+ }
24
+
25
+ /**
26
+ * The row a set of filter controls sits in.
27
+ *
28
+ * It owns two things the controls cannot own individually: the shared height,
29
+ * and the container the pills measure themselves against so their labels
30
+ * collapse when THIS ROW is tight rather than when the window is.
31
+ *
32
+ * Both product frontends were missing exactly this piece, and paid for it by
33
+ * restyling whatever component happened to be next in the row — `SelectTrigger`
34
+ * in one, `Button size="sm"` in the other — to make the heights agree.
35
+ */
36
+ export const FilterBar = React.forwardRef<HTMLDivElement, FilterBarProps>(
37
+ ({ className, overflow = 'wrap', height, style, ...props }, ref) => (
38
+ <div
39
+ ref={ref}
40
+ data-slot="filter-bar"
41
+ data-overflow={overflow}
42
+ className={cn('filter-bar', className)}
43
+ style={height ? ({ '--filter-control-h': height, ...style } as React.CSSProperties) : style}
44
+ {...props}
45
+ />
46
+ )
47
+ )
48
+ FilterBar.displayName = 'FilterBar'
@@ -16,7 +16,7 @@ const Command = React.forwardRef<
16
16
  <CommandPrimitive
17
17
  ref={ref}
18
18
  className={cn(
19
- "flex h-full w-full flex-col rounded-[var(--radius-popover)] bg-popover text-popover-foreground",
19
+ "flex h-full min-h-0 w-full flex-col rounded-[var(--radius-popover)] bg-popover text-popover-foreground",
20
20
  className
21
21
  )}
22
22
  {...props}
@@ -62,45 +62,24 @@ CommandInput.displayName = CommandPrimitive.Input.displayName
62
62
  const CommandList = React.forwardRef<
63
63
  React.ElementRef<typeof CommandPrimitive.List>,
64
64
  React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
65
- >(({ className, style, ...props }, ref) => {
66
- const listRef = React.useRef<React.ElementRef<typeof CommandPrimitive.List> | null>(null)
67
-
68
- // The element is needed locally (for the cssText fix below) AND by the
69
- // caller, so it is captured and forwarded in one callback. useImperativeHandle
70
- // cannot be used here: it must produce a non-null handle, and the element is
71
- // genuinely null until the primitive mounts.
72
- const setRef = React.useCallback(
73
- (node: React.ElementRef<typeof CommandPrimitive.List> | null) => {
74
- listRef.current = node
75
- if (typeof ref === 'function') {
76
- ref(node)
77
- } else if (ref) {
78
- ref.current = node
79
- }
80
- },
81
- [ref]
82
- )
83
-
84
- React.useEffect(() => {
85
- if (listRef.current) {
86
- listRef.current.style.cssText += 'max-height: 300px !important; overflow-y: auto !important; overflow-x: hidden !important;'
87
- }
88
- }, [])
89
-
90
- return (
91
- <CommandPrimitive.List
92
- ref={setRef}
93
- className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
94
- style={{
95
- maxHeight: '300px',
96
- overflowY: 'auto',
97
- overflowX: 'hidden',
98
- ...style
99
- }}
100
- {...props}
101
- />
102
- )
103
- })
65
+ >(({ className, style, ...props }, ref) => (
66
+ // THIS ELEMENT IS THE SCROLLER, and must stay it: cmdk moves the highlight
67
+ // with `scrollIntoView({ block: 'nearest' })`, which acts on the item's
68
+ // nearest scrollable ancestor. Cap the height here `.popover-scroll-region`
69
+ // owns the rule never on a wrapper, or the arrow keys walk the highlight
70
+ // out of view.
71
+ //
72
+ // Raise or lower the comfortable ceiling with `--popover-scroll-max`. Staying
73
+ // inside the WINDOW is the panel's job (`.popover-panel`), and this element
74
+ // shrinks to whatever the panel has left. (`--cmdk-list-height` is cmdk's
75
+ // measurement of the content, not a constraint — it caps nothing.)
76
+ <CommandPrimitive.List
77
+ ref={ref}
78
+ className={cn("popover-scroll-region", className)}
79
+ style={style}
80
+ {...props}
81
+ />
82
+ ))
104
83
 
105
84
  CommandList.displayName = CommandPrimitive.List.displayName
106
85