@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 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.557",
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.557",
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.557",
210
- "@djangocfg/i18n": "^2.1.557",
211
- "@djangocfg/typescript-config": "^2.1.557",
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'
@@ -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 './use-highlight';
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 './use-highlight';
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 './use-highlight';
20
+ import { useHighlight } from '../../hooks';
21
21
 
22
22
  export interface LanguageOption {
23
23
  code: TLanguageCode;
@@ -16,3 +16,4 @@ export { useResizeObserver } from './useResizeObserver';
16
16
  export type { Size } from './useResizeObserver';
17
17
  export { useFormReset } from './useFormReset';
18
18
  export type { UseFormResetParams } from './useFormReset';
19
+ export { useHighlight } from './useHighlight';
@@ -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 {