@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.
- package/README.md +2 -2
- package/package.json +5 -5
- package/src/components/data/calendar/calendar.tsx +1 -1
- package/src/components/data/toggle/index.tsx +1 -1
- package/src/components/forms/button/index.tsx +6 -1
- package/src/components/forms/checkbox/index.tsx +1 -1
- package/src/components/forms/filter-button/index.tsx +145 -0
- package/src/components/forms/mask-input/index.tsx +1 -1
- package/src/components/forms/segmented-input/index.tsx +1 -1
- package/src/components/forms/tags-input/index.tsx +1 -1
- package/src/components/index.ts +4 -0
- package/src/components/layout/filter-bar/index.tsx +48 -0
- package/src/components/navigation/command/index.tsx +19 -40
- package/src/components/navigation/tabs/index.tsx +102 -17
- package/src/components/navigation/tabs/use-tab-indicator.ts +68 -0
- package/src/components/overlay/drawer/index.tsx +113 -15
- package/src/components/overlay/popover/index.tsx +8 -2
- package/src/components/overlay/side-panel/index.tsx +77 -8
- package/src/components/select/combobox-async.tsx +26 -32
- package/src/components/select/combobox.tsx +36 -40
- package/src/components/select/country-select.tsx +21 -9
- package/src/components/select/language-select.tsx +21 -9
- package/src/components/select/multi-select-pro-async.tsx +3 -2
- package/src/components/select/multi-select-pro.tsx +3 -2
- package/src/components/select/multi-select.tsx +9 -30
- package/src/components/select/select.tsx +1 -1
- package/src/components/select/trigger.ts +23 -0
- package/src/components/select/use-highlight.ts +68 -0
- package/src/styles/css/utilities/filter-bar.css +61 -0
- package/src/styles/css/utilities/overlay.css +78 -0
- package/src/styles/css/utilities/tabs.css +53 -0
- package/src/styles/css/utilities.css +2 -0
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.
|
|
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.
|
|
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.
|
|
210
|
-
"@djangocfg/i18n": "^2.1.
|
|
211
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
-
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
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
|
-
|
|
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` —
|
|
222
|
-
* - `
|
|
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
|
-
*
|
|
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
|
-
:
|
|
244
|
-
|
|
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
|
|
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={
|
|
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
|
|
298
|
-
"
|
|
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
|
|
309
|
-
"text-
|
|
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]:
|
|
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
|