@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.
- package/README.md +3 -3
- 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/filter-menu/index.tsx +203 -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 +6 -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 -41
- 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/hooks/dom/index.ts +1 -0
- package/src/hooks/dom/useHighlight.ts +68 -0
- package/src/styles/css/presets/dense.css +12 -0
- package/src/styles/css/presets/ios.css +12 -0
- package/src/styles/css/presets/soft.css +12 -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 · 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.
|
|
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.
|
|
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.
|
|
210
|
-
"@djangocfg/i18n": "^2.1.
|
|
211
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
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-
|
|
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'
|
|
@@ -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-
|
|
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,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
|
-
|
|
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
|
|