@djangocfg/ui-core 2.1.556 → 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
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 |
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) |
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.557",
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.557",
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.557",
210
+ "@djangocfg/i18n": "^2.1.557",
211
+ "@djangocfg/typescript-config": "^2.1.557",
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'
@@ -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,10 @@ 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 type { FilterButtonProps } from './forms/filter-button';
210
214
  export { ToggleGroup, ToggleGroupItem } from './data/toggle-group';
211
215
  export { AvatarGroup } from './data/avatar-group';
212
216
  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
 
@@ -8,7 +8,9 @@ import * as TabsPrimitive from '@radix-ui/react-tabs';
8
8
  import { useIsMobile } from '../../../hooks';
9
9
  import { useStoredValue, type StorageType } from '../../../hooks';
10
10
  import { useAppT } from '@djangocfg/i18n';
11
+ import { useComposedRefs } from '../../../lib/compose-refs';
11
12
  import { cn } from '../../../lib/utils';
13
+ import { useTabIndicator } from './use-tab-indicator';
12
14
  import { Button } from '../../forms/button';
13
15
  import { ScrollArea, ScrollBar } from '../../layout/scroll-area';
14
16
  import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../../overlay/sheet';
@@ -210,43 +212,110 @@ Tabs.displayName = "Tabs"
210
212
  // Tabs variant context — lets TabsTrigger inherit variant from TabsList
211
213
  // ─────────────────────────────────────────────────────────────────────────
212
214
 
213
- type TabsVariant = 'default' | 'underline'
214
- const TabsVariantContext = React.createContext<TabsVariant>('default')
215
+ type TabsVariant = 'default' | 'underline' | 'pill'
216
+ /** Control height. `auto` picks `sm` on a phone and `md` elsewhere. */
217
+ type TabsSize = 'sm' | 'md' | 'auto'
218
+
219
+ const TabsVariantContext = React.createContext<{
220
+ variant: TabsVariant
221
+ size: Exclude<TabsSize, 'auto'>
222
+ }>({ variant: 'default', size: 'md' })
215
223
 
216
224
  const TabsList = React.forwardRef<
217
225
  React.ElementRef<typeof TabsPrimitive.List>,
218
226
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
219
227
  /**
220
228
  * Visual variant.
221
- * - `default` — pill/card style (bg-muted container)
222
- * - `underline` borderless, underline indicator on active tab
229
+ * - `default` — segmented control on a `bg-muted` track, rounded corners
230
+ * - `pill` the same control drawn as a capsule, with the active
231
+ * indicator SLIDING between tabs instead of popping
232
+ * - `underline` — borderless, a rule under the active tab
223
233
  * @default 'default'
224
234
  */
225
235
  variant?: TabsVariant
226
236
  /**
227
- * Full width mode - tabs will stretch equally to fill the container
237
+ * Control height.
238
+ * - `md` — 36px, the desktop default
239
+ * - `sm` — 28px, for a toolbar or a phone
240
+ * - `auto` — `sm` under the mobile breakpoint, `md` above it
241
+ *
242
+ * `auto` exists because consumers were already doing this by hand
243
+ * (`isSm ? 'h-7 p-0.5 gap-0.5' : 'h-9 p-0.5'` in a widget's own file),
244
+ * which puts a media query and a set of magic numbers in every caller.
245
+ * @default 'md'
246
+ */
247
+ size?: TabsSize
248
+ /**
249
+ * Stretch the tabs to fill their container.
250
+ *
251
+ * `'mobile'` does it only below the mobile breakpoint, which is the shape
252
+ * a toolbar actually wants: a switch that hugs its labels beside other
253
+ * controls on a desktop row, and spans the width once it is alone on a
254
+ * phone. Consumers were expressing this by setting `fullWidth` and then
255
+ * undoing it in CSS (`width: auto` plus a media query back to `100%`).
228
256
  * @default false
229
257
  */
230
- fullWidth?: boolean
258
+ fullWidth?: boolean | 'mobile'
231
259
  /**
232
260
  * Enable horizontal scrolling when tabs overflow
233
261
  * @default false
234
262
  */
235
263
  scrollable?: boolean
236
264
  }
237
- >(({ className, variant = 'default', fullWidth = false, scrollable = false, ...props }, ref) => {
265
+ >(({ className, variant = 'default', size = 'md', fullWidth = false, scrollable = false, ...props }, ref) => {
238
266
  const isUnderline = variant === 'underline'
267
+ const isPill = variant === 'pill'
268
+ const isMobile = useIsMobile()
269
+ const resolvedSize = size === 'auto' ? (isMobile ? 'sm' : 'md') : size
270
+ const resolvedFullWidth = fullWidth === 'mobile' ? isMobile : fullWidth
271
+
272
+ // Only the pill variant animates its indicator. The default variant keeps a
273
+ // background on the active trigger, which cannot travel between two
274
+ // elements — moving it is the whole reason `pill` draws the indicator on the
275
+ // list instead.
276
+ const indicator = useTabIndicator<HTMLDivElement>()
277
+ // Both refs always compose: hooks cannot be called conditionally, and the
278
+ // indicator ref is inert for the variants that do not draw one.
279
+ const listRef = useComposedRefs(ref, indicator.ref)
280
+
281
+ // `md` keeps the 36px the default variant has always been: this size scale
282
+ // was added for `pill`, and a taller default would silently reflow every
283
+ // existing tab strip in every consumer.
284
+ const sizing = resolvedSize === 'sm'
285
+ ? { track: 'h-8 p-0.5', gap: 'gap-0.5', inset: '0.125rem' }
286
+ : { track: 'h-9 p-1', gap: 'gap-1', inset: '0.25rem' }
239
287
 
240
288
  const listCls = cn(
241
289
  isUnderline
242
290
  ? "inline-flex items-end gap-0 bg-transparent p-0 h-auto rounded-none"
243
- : "inline-flex h-9 items-center justify-center rounded-[var(--radius)] bg-muted p-1 text-muted-foreground",
244
- fullWidth && "w-full flex",
291
+ : cn(
292
+ "inline-flex items-center justify-center bg-muted text-muted-foreground",
293
+ sizing.track,
294
+ sizing.gap,
295
+ isPill ? "rounded-full" : "rounded-[var(--radius)]",
296
+ isPill && "tabs-sliding",
297
+ ),
298
+ resolvedFullWidth && "w-full flex",
245
299
  className
246
300
  )
247
301
 
248
302
  const list = (
249
- <TabsPrimitive.List ref={ref} className={listCls} {...props} />
303
+ <TabsPrimitive.List
304
+ ref={listRef}
305
+ className={listCls}
306
+ // The pill is hidden until the first measurement lands, so it does not
307
+ // fly in from the left edge of the track on mount.
308
+ data-tab-indicator={isPill ? (indicator.ready ? 'ready' : 'idle') : undefined}
309
+ style={
310
+ isPill
311
+ ? ({
312
+ '--tab-indicator-inset': sizing.inset,
313
+ '--tab-indicator-radius': '9999px',
314
+ } as React.CSSProperties)
315
+ : undefined
316
+ }
317
+ {...props}
318
+ />
250
319
  )
251
320
 
252
321
  // For underline variant — native overflow-x-auto, full-width border via absolute element
@@ -265,8 +334,13 @@ const TabsList = React.forwardRef<
265
334
  </ScrollArea>
266
335
  ) : list
267
336
 
337
+ const contextValue = React.useMemo(
338
+ () => ({ variant, size: resolvedSize }),
339
+ [variant, resolvedSize]
340
+ )
341
+
268
342
  return (
269
- <TabsVariantContext.Provider value={variant}>
343
+ <TabsVariantContext.Provider value={contextValue}>
270
344
  {wrapped}
271
345
  </TabsVariantContext.Provider>
272
346
  )
@@ -284,8 +358,10 @@ const TabsTrigger = React.forwardRef<
284
358
  key?: React.Key
285
359
  }
286
360
  >(({ className, flexEqual = false, ...props }, ref) => {
287
- const variant = React.useContext(TabsVariantContext)
361
+ const { variant, size } = React.useContext(TabsVariantContext)
288
362
  const isUnderline = variant === 'underline'
363
+ const isPill = variant === 'pill'
364
+ const isSm = size === 'sm'
289
365
 
290
366
  return (
291
367
  <TabsPrimitive.Trigger
@@ -294,8 +370,8 @@ const TabsTrigger = React.forwardRef<
294
370
  isUnderline
295
371
  ? [
296
372
  "relative z-10 rounded-none bg-transparent shadow-none whitespace-nowrap",
297
- "px-3 py-2.5 mb-[-1px]",
298
- "text-sm font-medium text-muted-foreground/60",
373
+ isSm ? "px-2.5 py-2 text-xs" : "px-3 py-2.5 text-sm",
374
+ "font-medium text-muted-foreground/60",
299
375
  "border-b-2 border-transparent",
300
376
  "transition-all duration-150",
301
377
  "hover:text-foreground",
@@ -305,11 +381,20 @@ const TabsTrigger = React.forwardRef<
305
381
  "data-[state=active]:border-foreground data-[state=active]:shadow-none",
306
382
  ].join(" ")
307
383
  : [
308
- "inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-sm)] px-3 py-1",
309
- "text-sm font-medium transition-all",
384
+ "inline-flex items-center justify-center gap-1.5 whitespace-nowrap",
385
+ isSm ? "px-2.5 text-xs" : "px-3 text-sm",
386
+ "h-full font-medium",
310
387
  "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
311
388
  "disabled:pointer-events-none disabled:opacity-50",
312
- "data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
389
+ "data-[state=active]:text-foreground",
390
+ // The pill variant's indicator lives on the list and travels, so
391
+ // the trigger carries no background of its own — one would sit on
392
+ // top of the sliding pill and give the movement away. Colour is
393
+ // all that changes here, and it changes fast enough not to lag
394
+ // behind the pill.
395
+ isPill
396
+ ? "relative rounded-full bg-transparent transition-colors duration-150 hover:text-foreground/80 data-[state=active]:bg-transparent data-[state=active]:shadow-none"
397
+ : "rounded-[var(--radius-sm)] transition-all data-[state=active]:bg-background data-[state=active]:shadow",
313
398
  ].join(" "),
314
399
  flexEqual && "flex-1",
315
400
  className