@djangocfg/ui-core 2.1.557 → 2.1.559
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/forms/filter-menu/index.tsx +203 -0
- package/src/components/index.ts +2 -0
- package/src/components/select/combobox-async.tsx +1 -1
- package/src/components/select/combobox.tsx +1 -2
- package/src/components/select/country-select.tsx +1 -1
- package/src/components/select/language-select.tsx +1 -1
- package/src/hooks/dom/index.ts +1 -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/{components/select/use-highlight.ts → hooks/dom/useHighlight.ts} +0 -0
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ 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 · FilterButton |
|
|
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 |
|
|
@@ -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.559",
|
|
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.559",
|
|
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.559",
|
|
210
|
+
"@djangocfg/i18n": "^2.1.559",
|
|
211
|
+
"@djangocfg/typescript-config": "^2.1.559",
|
|
212
212
|
"@storybook/react-vite": "^10.5.0",
|
|
213
213
|
"@types/node": "^24.13.3",
|
|
214
214
|
"@types/react": "19.2.15",
|
|
@@ -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'
|
package/src/components/index.ts
CHANGED
|
@@ -210,6 +210,8 @@ export { Toggle, toggleVariants } from './data/toggle';
|
|
|
210
210
|
export { FilterBar } from './layout/filter-bar';
|
|
211
211
|
export type { FilterBarProps } from './layout/filter-bar';
|
|
212
212
|
export { FilterButton } from './forms/filter-button';
|
|
213
|
+
export { FilterMenu } from './forms/filter-menu';
|
|
214
|
+
export type { FilterMenuProps, FilterMenuOption } from './forms/filter-menu';
|
|
213
215
|
export type { FilterButtonProps } from './forms/filter-button';
|
|
214
216
|
export { ToggleGroup, ToggleGroupItem } from './data/toggle-group';
|
|
215
217
|
export { AvatarGroup } from './data/avatar-group';
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from '../navigation/command';
|
|
13
13
|
import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover';
|
|
14
14
|
import { SELECT_TRIGGER_CLASS } from './trigger';
|
|
15
|
-
import { useHighlight } from '
|
|
15
|
+
import { useHighlight } from '../../hooks';
|
|
16
16
|
|
|
17
17
|
export interface ComboboxAsyncOption {
|
|
18
18
|
value: string
|
|
@@ -4,7 +4,7 @@ import { Check, ChevronsUpDown } from 'lucide-react';
|
|
|
4
4
|
import * as React from 'react';
|
|
5
5
|
|
|
6
6
|
import { useAppT } from '@djangocfg/i18n';
|
|
7
|
-
import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../hooks';
|
|
7
|
+
import { useStoredValue, type StorageType, type UseStoredValueOptions, useHighlight } from '../../hooks';
|
|
8
8
|
import { cn } from '../../lib/utils';
|
|
9
9
|
import { Badge } from '../data/badge';
|
|
10
10
|
import { Button } from '../forms/button';
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
} from '../navigation/command';
|
|
14
14
|
import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover';
|
|
15
15
|
import { SELECT_TRIGGER_CLASS } from './trigger';
|
|
16
|
-
import { useHighlight } from './use-highlight';
|
|
17
16
|
|
|
18
17
|
export interface ComboboxOption {
|
|
19
18
|
value: string
|
|
@@ -16,7 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover';
|
|
|
16
16
|
import { ScrollArea } from '../layout/scroll-area';
|
|
17
17
|
import { Flag } from '../specialized/flag';
|
|
18
18
|
import { SELECT_TRIGGER_MULTI_CLASS } from './trigger';
|
|
19
|
-
import { useHighlight } from '
|
|
19
|
+
import { useHighlight } from '../../hooks';
|
|
20
20
|
|
|
21
21
|
export interface CountryOption {
|
|
22
22
|
code: TCountryCode;
|
|
@@ -17,7 +17,7 @@ import { Input } from '../forms/input';
|
|
|
17
17
|
import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover';
|
|
18
18
|
import { ScrollArea } from '../layout/scroll-area';
|
|
19
19
|
import { SELECT_TRIGGER_CLASS } from './trigger';
|
|
20
|
-
import { useHighlight } from '
|
|
20
|
+
import { useHighlight } from '../../hooks';
|
|
21
21
|
|
|
22
22
|
export interface LanguageOption {
|
|
23
23
|
code: TLanguageCode;
|
package/src/hooks/dom/index.ts
CHANGED
|
@@ -15,6 +15,18 @@
|
|
|
15
15
|
--radius-2xl: calc(0.25rem + 8px);
|
|
16
16
|
--radius-3xl: calc(0.25rem + 12px);
|
|
17
17
|
--radius-4xl: calc(0.25rem + 16px);
|
|
18
|
+
/* Type scale — a notch tighter than every other preset, which is the whole
|
|
19
|
+
point of this one. It previously set radii and colours only, so "dense"
|
|
20
|
+
rendered text at exactly the same size as the rest: the name promised a
|
|
21
|
+
density the tokens never delivered.
|
|
22
|
+
In `:root` only; a size does not change with the theme. */
|
|
23
|
+
--font-size-base: 0.75rem;
|
|
24
|
+
--font-size-sm: 0.6875rem;
|
|
25
|
+
--font-size-xs: 0.625rem;
|
|
26
|
+
--font-size-lg: 0.875rem;
|
|
27
|
+
--font-size-xl: 1rem;
|
|
28
|
+
--line-height-base: 1.4;
|
|
29
|
+
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
.dark {
|
|
@@ -57,6 +57,18 @@
|
|
|
57
57
|
--radius-2xl: calc(0.75rem + 8px);
|
|
58
58
|
--radius-3xl: calc(0.75rem + 12px);
|
|
59
59
|
--radius-4xl: calc(0.75rem + 16px);
|
|
60
|
+
/* Type scale. Lives in `:root` only — a size is not a colour and does not
|
|
61
|
+
change with the theme; `macos`/`windows` duplicate it into `.dark`, which
|
|
62
|
+
is two places to edit for one value.
|
|
63
|
+
Without these a preset silently inherits base.css (14px body / 13px
|
|
64
|
+
`text-sm`) while `macos` renders 13/12, so the same component measured a
|
|
65
|
+
pixel apart depending on which preset an app loaded. */
|
|
66
|
+
--font-size-base: 0.8125rem;
|
|
67
|
+
--font-size-sm: 0.75rem;
|
|
68
|
+
--font-size-xs: 0.6875rem;
|
|
69
|
+
--font-size-lg: 0.9375rem;
|
|
70
|
+
--font-size-xl: 1.0625rem;
|
|
71
|
+
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
.dark {
|
|
@@ -23,6 +23,18 @@
|
|
|
23
23
|
--radius-2xl: calc(1rem + 8px);
|
|
24
24
|
--radius-3xl: calc(1rem + 12px);
|
|
25
25
|
--radius-4xl: calc(1rem + 16px);
|
|
26
|
+
/* Type scale. Lives in `:root` only — a size is not a colour and does not
|
|
27
|
+
change with the theme; `macos`/`windows` duplicate it into `.dark`, which
|
|
28
|
+
is two places to edit for one value.
|
|
29
|
+
Without these a preset silently inherits base.css (14px body / 13px
|
|
30
|
+
`text-sm`) while `macos` renders 13/12, so the same component measured a
|
|
31
|
+
pixel apart depending on which preset an app loaded. */
|
|
32
|
+
--font-size-base: 0.8125rem;
|
|
33
|
+
--font-size-sm: 0.75rem;
|
|
34
|
+
--font-size-xs: 0.6875rem;
|
|
35
|
+
--font-size-lg: 0.9375rem;
|
|
36
|
+
--font-size-xl: 1.0625rem;
|
|
37
|
+
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
.dark {
|
|
File without changes
|