basalt-ui 1.26.0 → 1.27.0
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/AGENTS.md +1 -1
- package/CHANGELOG.md +8 -0
- package/MIGRATING.md +299 -33
- package/agent/rules/basalt-controls.md +17 -12
- package/agent/rules/basalt-state.md +5 -0
- package/configs/oxlint-plugin.js +251 -66
- package/configs/oxlint-plugin.test.ts +243 -0
- package/configs/oxlint.json +5 -5
- package/dist/controls/enum-filter.d.ts +11 -2
- package/dist/controls/enum-filter.d.ts.map +1 -1
- package/dist/controls/enum-filter.js +4 -2
- package/dist/controls/filter-context.d.ts +7 -2
- package/dist/controls/filter-context.d.ts.map +1 -1
- package/dist/controls/index.d.ts +1 -0
- package/dist/controls/index.d.ts.map +1 -1
- package/dist/controls/index.js +2 -0
- package/dist/controls/multi-select-filter.js +2 -2
- package/dist/controls/number-filter.d.ts +26 -0
- package/dist/controls/number-filter.d.ts.map +1 -0
- package/dist/controls/number-filter.js +143 -0
- package/dist/controls/range-filter.d.ts +19 -5
- package/dist/controls/range-filter.d.ts.map +1 -1
- package/dist/controls/range-filter.js +22 -7
- package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
- package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
- package/dist/controls/search-filter.js +1 -1
- package/dist/controls/toggle-filter.js +1 -1
- package/dist/dashboard/index.d.ts +1 -1
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +3 -1
- package/dist/dashboard/stat-card.d.ts +52 -1
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +42 -15
- package/dist/dashboard/stat-card.module.css +71 -0
- package/dist/dashboard/stat-card.module.css.d.ts +4 -0
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +8 -0
- package/dist/guard/grace.test.d.ts.map +1 -1
- package/dist/guard/index.d.ts +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +6 -10
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/router-tanstack/field.d.ts +1 -1
- package/dist/router-tanstack/field.d.ts.map +1 -1
- package/dist/router-tanstack/index.d.ts +1 -1
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/search-store.d.ts +4 -1
- package/dist/router-tanstack/search-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-store.js +91 -7
- package/dist/shell/page-bar.d.ts +1 -1
- package/dist/state/fields.d.ts +209 -36
- package/dist/state/fields.d.ts.map +1 -1
- package/dist/state/fields.js +68 -32
- package/dist/state/persisted.d.ts +12 -0
- package/dist/state/persisted.d.ts.map +1 -1
- package/dist/state/persisted.js +36 -18
- package/dist/state/persisted.test.d.ts +2 -0
- package/dist/state/persisted.test.d.ts.map +1 -0
- package/dist/state.d.ts +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +4 -3
- package/dist/widget-header/widget-header.d.ts +31 -1
- package/dist/widget-header/widget-header.d.ts.map +1 -1
- package/dist/widget-header/widget-header.js +13 -1
- package/dist/widget-header/widget-header.module.css +15 -0
- package/dist/widget-header/widget-header.module.css.d.ts +1 -0
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/controls/controls.router.test.tsx +416 -0
- package/src/controls/enum-filter.tsx +14 -3
- package/src/controls/filter-context.tsx +7 -2
- package/src/controls/index.ts +1 -0
- package/src/controls/multi-select-filter.tsx +2 -2
- package/src/controls/number-filter.tsx +291 -0
- package/src/controls/props-contract.test.tsx +31 -0
- package/src/controls/range-filter.tsx +64 -11
- package/src/controls/range-filter.type-guard.test.tsx +122 -0
- package/src/controls/search-filter.tsx +1 -1
- package/src/controls/toggle-filter.tsx +1 -1
- package/src/dashboard/index.ts +6 -1
- package/src/dashboard/stat-card.module.css +71 -0
- package/src/dashboard/stat-card.module.css.d.ts +4 -0
- package/src/dashboard/stat-card.test.tsx +231 -0
- package/src/dashboard/stat-card.tsx +81 -0
- package/src/data/data-table.tsx +8 -0
- package/src/guard/check-source.test.ts +35 -2
- package/src/guard/grace.test.ts +14 -5
- package/src/guard/index.ts +30 -17
- package/src/index.ts +5 -0
- package/src/router-tanstack/field.ts +4 -0
- package/src/router-tanstack/index.ts +4 -0
- package/src/router-tanstack/search-store.router.test.tsx +232 -11
- package/src/router-tanstack/search-store.test.ts +160 -0
- package/src/router-tanstack/search-store.ts +177 -11
- package/src/shell/page-bar.tsx +1 -1
- package/src/state/fields.test.tsx +254 -0
- package/src/state/fields.ts +328 -70
- package/src/state/persisted.test.ts +91 -0
- package/src/state/persisted.ts +76 -26
- package/src/state.ts +6 -0
- package/src/surfaces.ts +4 -3
- package/src/widget-header/widget-header.module.css +15 -0
- package/src/widget-header/widget-header.module.css.d.ts +1 -0
- package/src/widget-header/widget-header.tsx +46 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `NumberFilter` — one number, one pill (`docs/CONTROLS-SPEC.md` §3), over a `field.number`. Takes
|
|
3
|
+
* `field`, never `value`/`onChange`/`size` (C2/C5), like every other control on this subpath.
|
|
4
|
+
*
|
|
5
|
+
* **Two forms, and `options` is the whole switch.** With `options` the filter is a pill plus a radio
|
|
6
|
+
* list — the same body `SelectFilter` renders, reached through `EnumFilter`'s `ChoiceHandle`, so a
|
|
7
|
+
* numeric preset set (`1` / `2` / `7` nights) is one idiom with the enum filters rather than a
|
|
8
|
+
* second spelling of the same choice. Without `options` it is a pill whose popover holds a `ctl`
|
|
9
|
+
* `NumberInput` with its steppers, and the sheet form is that input in a full-width row.
|
|
10
|
+
*
|
|
11
|
+
* **A threshold is a NUMBER, not a string enum, and that is why this control exists.** Both
|
|
12
|
+
* consumers that needed one wrote around its absence in opposite directions: linewatch kept a raw
|
|
13
|
+
* `SegmentedControl` over `minDuration` (a `control-outside-home` warn, law C1), and argo widened
|
|
14
|
+
* `nights` into a string enum — which puts `'3'` in the URL, makes every comparison a parse, and
|
|
15
|
+
* closes a set that was never closed. `field.number` already carries the codec, the clamp and both
|
|
16
|
+
* lanes; the only thing missing was the control.
|
|
17
|
+
*
|
|
18
|
+
* **The stepper applies on blur or Enter, never per keystroke.** A number is typed digit by digit,
|
|
19
|
+
* so a live write navigates on `4`, `42`, `420` and lands three values in the loader for one
|
|
20
|
+
* intended threshold — and the middle two are real values the store would clamp and persist.
|
|
21
|
+
* `SearchFilter` debounces for the same reason; a number has an explicit commit point (leaving the
|
|
22
|
+
* field, pressing Enter) and a phrase does not, so this one commits rather than waits.
|
|
23
|
+
*
|
|
24
|
+
* **`min`/`max`/`int` come off the HANDLE, never the call site.** `field.number({ min, max, int })`
|
|
25
|
+
* declares them, the codec clamps to them on write, and the handle republishes all three — so the
|
|
26
|
+
* `NumberInput` bounds its own stepper and `int` refuses decimals outright. They are not props here
|
|
27
|
+
* on purpose: they belong to the field, which is the thing that validates the URL, and a second copy
|
|
28
|
+
* at the call site is a second answer to the same question that stops matching the moment the field
|
|
29
|
+
* moves. The clamp is still the backstop for a value that arrives from outside the input (a
|
|
30
|
+
* hand-typed URL, a stale deep link), and the draft follows the field down when it fires (see
|
|
31
|
+
* {@link useCommittedNumber}).
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* // A preset set — a pill plus a radio list, exactly like SelectFilter.
|
|
35
|
+
* // nights: field.number({ fallback: 2, min: 1, max: 14, int: true })
|
|
36
|
+
* <NumberFilter
|
|
37
|
+
* field={booking.field.nights}
|
|
38
|
+
* label="Nights"
|
|
39
|
+
* options={[
|
|
40
|
+
* { value: 1, label: '1 night' },
|
|
41
|
+
* { value: 2, label: '2 nights' },
|
|
42
|
+
* { value: 7, label: 'A week' },
|
|
43
|
+
* ]}
|
|
44
|
+
* />
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* // An open threshold — a pill holding a stepper, applied on blur or Enter.
|
|
48
|
+
* // minDuration: field.number({ fallback: 0, min: 0, max: 600 })
|
|
49
|
+
* <NumberFilter field={lines.field.minDuration} label="Min duration" step={30} />
|
|
50
|
+
*/
|
|
51
|
+
import { NumberInput } from '@mantine/core'
|
|
52
|
+
import { useEffect, useRef, useState } from 'react'
|
|
53
|
+
import type { ReactNode } from 'react'
|
|
54
|
+
import type { FieldHandle, NumberField } from '../state'
|
|
55
|
+
import classes from './controls.module.css'
|
|
56
|
+
import { EnumFilter } from './enum-filter'
|
|
57
|
+
import type { ChoiceHandle } from './enum-filter'
|
|
58
|
+
import { useFilterRegistration, useFilterSurface } from './filter-context'
|
|
59
|
+
import { FilterPill } from './filter-pill'
|
|
60
|
+
import { SheetField, useControlName } from './filter-sheet'
|
|
61
|
+
|
|
62
|
+
/** One row of a numeric preset set. Narrower than `FilterOption` — a preset is never `disabled`:
|
|
63
|
+
* the set is declared at the call site, so a row that should not be offered is left out. */
|
|
64
|
+
export type NumberFilterOption = {
|
|
65
|
+
readonly value: number
|
|
66
|
+
readonly label: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type NumberFilterProps = {
|
|
70
|
+
readonly field: FieldHandle<NumberField>
|
|
71
|
+
/** The popover/sheet heading and the accessible name — never the pill text, which reads the
|
|
72
|
+
* VALUE (see `EnumFilter`'s doc for why a pill may only print what is selected). */
|
|
73
|
+
readonly label: string
|
|
74
|
+
readonly icon?: ReactNode
|
|
75
|
+
/** Present → the radio-list form. Absent → the stepper form. */
|
|
76
|
+
readonly options?: readonly NumberFilterOption[]
|
|
77
|
+
/**
|
|
78
|
+
* The stepper's increment. Ignored in the `options` form, which has no stepper.
|
|
79
|
+
*
|
|
80
|
+
* @default 1 — and for an `int: true` field that is also the only sane grain, so it is what the
|
|
81
|
+
* field's own declaration produces without this prop.
|
|
82
|
+
*/
|
|
83
|
+
readonly step?: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function NumberFilter({ field, label, icon, options, step }: NumberFilterProps): ReactNode {
|
|
87
|
+
// Two BRANCHES, two components, and never a conditional hook: the radio form's state lives in
|
|
88
|
+
// `EnumFilter` and the stepper's in `NumberStepper`, so which one mounts is decided once by a prop
|
|
89
|
+
// that does not change across a mount's life.
|
|
90
|
+
if (options !== undefined) {
|
|
91
|
+
return (
|
|
92
|
+
<NumberChoice
|
|
93
|
+
field={field}
|
|
94
|
+
label={label}
|
|
95
|
+
options={options}
|
|
96
|
+
{...(icon !== undefined && { icon })}
|
|
97
|
+
/>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
return (
|
|
101
|
+
<NumberStepper
|
|
102
|
+
field={field}
|
|
103
|
+
label={label}
|
|
104
|
+
{...(icon !== undefined && { icon })}
|
|
105
|
+
{...(step !== undefined && { step })}
|
|
106
|
+
/>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The `options` form. It renders no radio list of its own — it ADAPTS the number handle into
|
|
112
|
+
* `EnumFilter`'s `ChoiceHandle<string>` and hands it over, so the popover's `Radio.Group`, the
|
|
113
|
+
* sheet's `SheetOptionList`, the registration and the pill readout are the ones already tested for
|
|
114
|
+
* `SelectFilter`/`CompareFilter` rather than a numeric copy of all four.
|
|
115
|
+
*
|
|
116
|
+
* The adapter is a projection, not a store: the URL still holds a NUMBER (`nights=3`, not `'3'`),
|
|
117
|
+
* because every write goes back through `setValue(Number(next))` into the same field codec.
|
|
118
|
+
*
|
|
119
|
+
* `field.use()` is called HERE, at a component's top level, and the adapter's own `use()` just
|
|
120
|
+
* returns what it read. The other way round — a `use()` that calls the real hook when `EnumFilter`
|
|
121
|
+
* invokes it — works at runtime (the call site is stable) but makes a plain object method a hook,
|
|
122
|
+
* which is a thing neither `react/rules-of-hooks` nor a reader should have to reason about.
|
|
123
|
+
*/
|
|
124
|
+
function NumberChoice({
|
|
125
|
+
field,
|
|
126
|
+
label,
|
|
127
|
+
icon,
|
|
128
|
+
options,
|
|
129
|
+
}: {
|
|
130
|
+
readonly field: FieldHandle<NumberField>
|
|
131
|
+
readonly label: string
|
|
132
|
+
readonly icon?: ReactNode
|
|
133
|
+
readonly options: readonly NumberFilterOption[]
|
|
134
|
+
}): ReactNode {
|
|
135
|
+
const [value, setValue] = field.use()
|
|
136
|
+
const rows = options.map((option) => ({ value: String(option.value), label: option.label }))
|
|
137
|
+
const choice: ChoiceHandle<string> = {
|
|
138
|
+
fallback: String(field.fallback),
|
|
139
|
+
options: rows,
|
|
140
|
+
use: () =>
|
|
141
|
+
[
|
|
142
|
+
String(value),
|
|
143
|
+
(next: string) => {
|
|
144
|
+
setValue(Number(next))
|
|
145
|
+
},
|
|
146
|
+
] as const,
|
|
147
|
+
isDefault: (v) => field.isDefault(Number(v)),
|
|
148
|
+
clear: () => {
|
|
149
|
+
field.clear()
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// `options` passed explicitly as well as through the handle: `EnumFilter` reads the PROP first
|
|
154
|
+
// (the runtime-catalogue override), and passing the same rows both ways keeps the two agreeing
|
|
155
|
+
// whichever branch of that lookup a future edit takes.
|
|
156
|
+
return (
|
|
157
|
+
<EnumFilter
|
|
158
|
+
field={choice}
|
|
159
|
+
label={label}
|
|
160
|
+
numeric
|
|
161
|
+
options={rows}
|
|
162
|
+
{...(icon !== undefined && { icon })}
|
|
163
|
+
/>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The stepper form — a pill holding a `ctl` `NumberInput`, or a full-width sheet row. */
|
|
168
|
+
function NumberStepper({
|
|
169
|
+
field,
|
|
170
|
+
label,
|
|
171
|
+
icon,
|
|
172
|
+
step,
|
|
173
|
+
}: {
|
|
174
|
+
readonly field: FieldHandle<NumberField>
|
|
175
|
+
readonly label: string
|
|
176
|
+
readonly icon?: ReactNode
|
|
177
|
+
readonly step?: number
|
|
178
|
+
}): ReactNode {
|
|
179
|
+
const [value, setValue] = field.use()
|
|
180
|
+
const surface = useFilterSurface()
|
|
181
|
+
const inSheet = surface === 'sheet'
|
|
182
|
+
const isDefault = field.isDefault(value)
|
|
183
|
+
useFilterRegistration(!isDefault, () => {
|
|
184
|
+
field.clear()
|
|
185
|
+
})
|
|
186
|
+
const { labelId, nameProps } = useControlName(label, inSheet)
|
|
187
|
+
const draft = useCommittedNumber(value, setValue, { min: field.min, max: field.max })
|
|
188
|
+
|
|
189
|
+
const input = (
|
|
190
|
+
<NumberInput
|
|
191
|
+
{...nameProps}
|
|
192
|
+
size="ctl"
|
|
193
|
+
// The FIELD's bounds, straight off the handle — so the stepper stops at the limit and a typed
|
|
194
|
+
// value cannot exceed it, instead of being silently corrected one commit later. The codec's
|
|
195
|
+
// clamp stays the backstop for everything that does not come through this box.
|
|
196
|
+
{...(field.min !== undefined && { min: field.min })}
|
|
197
|
+
{...(field.max !== undefined && { max: field.max })}
|
|
198
|
+
// `int: true` is refused by the codec, not rounded, so the input must refuse it too — a `.5`
|
|
199
|
+
// typed into an integer field would otherwise decode to `null` and resurrect the fallback.
|
|
200
|
+
{...(field.int && { allowDecimal: false })}
|
|
201
|
+
{...(step !== undefined ? { step } : field.int && { step: 1 })}
|
|
202
|
+
value={draft.value}
|
|
203
|
+
onChange={draft.set}
|
|
204
|
+
onBlur={draft.commit}
|
|
205
|
+
onKeyDown={(event) => {
|
|
206
|
+
if (event.key !== 'Enter') return
|
|
207
|
+
// A filter is not inside a form here, but a consumer's page may be — an Enter that submits
|
|
208
|
+
// the page instead of committing the threshold is the one failure mode worth pre-empting.
|
|
209
|
+
event.preventDefault()
|
|
210
|
+
draft.commit()
|
|
211
|
+
}}
|
|
212
|
+
/>
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if (inSheet) {
|
|
216
|
+
return (
|
|
217
|
+
<SheetField label={label} labelId={labelId}>
|
|
218
|
+
{input}
|
|
219
|
+
</SheetField>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<FilterPill
|
|
225
|
+
label={String(value)}
|
|
226
|
+
numeric
|
|
227
|
+
active={!isDefault}
|
|
228
|
+
{...(icon !== undefined && { icon })}
|
|
229
|
+
>
|
|
230
|
+
<div className={classes.optionList}>{input}</div>
|
|
231
|
+
</FilterPill>
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A local draft that lands on the field only when the user is DONE with it (blur, Enter), and that
|
|
237
|
+
* follows the field when it changes from outside the input — `Reset all`, a back navigation, a deep
|
|
238
|
+
* link, and the codec's own clamp.
|
|
239
|
+
*
|
|
240
|
+
* The clamp is the interesting direction, and it is why `bounds` is a parameter rather than left to
|
|
241
|
+
* the codec. `commit` clamps to the field's OWN limits before it compares, so the readout follows
|
|
242
|
+
* the clamp even when the store does not move: a second out-of-range `9999` against `max: 600` over
|
|
243
|
+
* a stored `600` writes nothing (the value is already `600`), which means `[value]` never changes and
|
|
244
|
+
* the effect below never runs — and the input went on displaying `9999` over a URL holding `600`,
|
|
245
|
+
* exactly the readout this hook exists to prevent. Only the FIRST out-of-range commit was ever
|
|
246
|
+
* healed.
|
|
247
|
+
*
|
|
248
|
+
* `seen` (the number this hook last committed) plus the effect stay as the backstop for a change that
|
|
249
|
+
* did not come through this box at all — `Reset all`, a deep link, a back navigation — and for a
|
|
250
|
+
* codec clamp the bounds here cannot predict.
|
|
251
|
+
*/
|
|
252
|
+
function useCommittedNumber(
|
|
253
|
+
value: number,
|
|
254
|
+
setValue: (next: number) => void,
|
|
255
|
+
bounds: { min?: number | undefined; max?: number | undefined },
|
|
256
|
+
): { value: number | string; set: (next: number | string) => void; commit: () => void } {
|
|
257
|
+
const [draft, setDraft] = useState<number | string>(value)
|
|
258
|
+
const seen = useRef(value)
|
|
259
|
+
const commitRef = useRef(setValue)
|
|
260
|
+
commitRef.current = setValue
|
|
261
|
+
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
if (seen.current === value) return
|
|
264
|
+
seen.current = value
|
|
265
|
+
setDraft(value)
|
|
266
|
+
}, [value])
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
value: draft,
|
|
270
|
+
set: setDraft,
|
|
271
|
+
commit: () => {
|
|
272
|
+
const parsed = typeof draft === 'number' ? draft : Number.parseFloat(draft)
|
|
273
|
+
// An empty or half-typed box ('', '-', '1e') is not a value — restore the field's, rather
|
|
274
|
+
// than committing `NaN` for the codec to fall back on and calling that the user's intent.
|
|
275
|
+
if (!Number.isFinite(parsed)) {
|
|
276
|
+
setDraft(value)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
// Clamp to what the codec will actually store, then show THAT — a draft the store is about to
|
|
280
|
+
// refuse is a lie whether or not the store's value moves. `int` needs no handling: the input
|
|
281
|
+
// runs `allowDecimal: false` on an integer field, so a decimal never reaches here.
|
|
282
|
+
let next = parsed
|
|
283
|
+
if (bounds.min !== undefined && next < bounds.min) next = bounds.min
|
|
284
|
+
if (bounds.max !== undefined && next > bounds.max) next = bounds.max
|
|
285
|
+
setDraft(next)
|
|
286
|
+
if (next === value) return
|
|
287
|
+
seen.current = next
|
|
288
|
+
commitRef.current(next)
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -12,6 +12,7 @@ import type { ReactNode } from 'react'
|
|
|
12
12
|
import { createLocalStore, field } from '../state'
|
|
13
13
|
import { CompareFilter } from './compare-filter'
|
|
14
14
|
import { MultiSelectFilter } from './multi-select-filter'
|
|
15
|
+
import { NumberFilter } from './number-filter'
|
|
15
16
|
import { RangeFilter } from './range-filter'
|
|
16
17
|
import { SearchFilter } from './search-filter'
|
|
17
18
|
import { SelectFilter } from './select-filter'
|
|
@@ -27,6 +28,7 @@ const local = createLocalStore({
|
|
|
27
28
|
range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }),
|
|
28
29
|
errorsOnly: field.boolean(false),
|
|
29
30
|
q: field.string(),
|
|
31
|
+
nights: field.number({ fallback: 2, min: 1, max: 14, int: true }),
|
|
30
32
|
},
|
|
31
33
|
})
|
|
32
34
|
|
|
@@ -70,6 +72,25 @@ function Rejected(): ReactNode {
|
|
|
70
72
|
<SelectFilter field={local.field.q} label="Project" />
|
|
71
73
|
{/* @ts-expect-error `options` does not widen a MULTI field into a single-select either. */}
|
|
72
74
|
<SelectFilter field={local.field.channels} label="Channels" options={PROJECTS} />
|
|
75
|
+
{/* @ts-expect-error C2 — a number filter is store-bound too; there is no `value`. */}
|
|
76
|
+
<NumberFilter field={local.field.nights} label="Nights" value={3} />
|
|
77
|
+
{/* @ts-expect-error C5 — the home sets the tier. `step` is the stepper's increment, not a size. */}
|
|
78
|
+
<NumberFilter field={local.field.nights} label="Nights" size="xs" />
|
|
79
|
+
{/* @ts-expect-error an enum field is not a number field — the whole point of the control. */}
|
|
80
|
+
<NumberFilter field={local.field.currency} label="Nights" />
|
|
81
|
+
{/* @ts-expect-error `label` is required — the popover and the sheet both need a heading. */}
|
|
82
|
+
<NumberFilter field={local.field.nights} />
|
|
83
|
+
{/* NumberFilter's options are NUMBERS. A string here is the exact shape argo reached for when
|
|
84
|
+
it widened `nights` into an enum, and it has to be a type error rather than a silent
|
|
85
|
+
`Number('7')` at the boundary. */}
|
|
86
|
+
<NumberFilter
|
|
87
|
+
field={local.field.nights}
|
|
88
|
+
label="Nights"
|
|
89
|
+
// @ts-expect-error a numeric preset set takes `value: number`, never a numeral string.
|
|
90
|
+
options={[{ value: '7', label: 'A week' }]}
|
|
91
|
+
/>
|
|
92
|
+
{/* @ts-expect-error `min`/`max` belong to the FIELD, which is what validates the URL. */}
|
|
93
|
+
<NumberFilter field={local.field.nights} label="Nights" min={1} />
|
|
73
94
|
</>
|
|
74
95
|
)
|
|
75
96
|
}
|
|
@@ -94,6 +115,16 @@ function Accepted(): ReactNode {
|
|
|
94
115
|
<ToggleFilter field={local.field.errorsOnly} label="Errors only" />
|
|
95
116
|
<SearchFilter field={local.field.q} placeholder="Search" />
|
|
96
117
|
<ViewTabs field={local.field.currency} />
|
|
118
|
+
{/* Both forms, spelled correctly: the preset set, and the open stepper. */}
|
|
119
|
+
<NumberFilter
|
|
120
|
+
field={local.field.nights}
|
|
121
|
+
label="Nights"
|
|
122
|
+
options={[
|
|
123
|
+
{ value: 1, label: '1 night' },
|
|
124
|
+
{ value: 7, label: 'A week' },
|
|
125
|
+
]}
|
|
126
|
+
/>
|
|
127
|
+
<NumberFilter field={local.field.nights} label="Nights" step={2} />
|
|
97
128
|
</>
|
|
98
129
|
)
|
|
99
130
|
}
|
|
@@ -41,6 +41,34 @@ const VERTICAL_FROM = 5
|
|
|
41
41
|
/** `7d`, `30d`, `24h`, `90` — a label the mono numeric treatment is for. */
|
|
42
42
|
const NUMERIC_LABEL = /^\d/
|
|
43
43
|
|
|
44
|
+
/** The house dev gate — `basaltViteConfig` defines `process.env.NODE_ENV`, so a production bundle
|
|
45
|
+
* constant-folds this to `false` and drops the warning. Read per call, never hoisted. */
|
|
46
|
+
function isDev(): boolean {
|
|
47
|
+
return process.env['NODE_ENV'] !== 'production'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Once per label — a filter renders on every navigation, and the wiring is fixed at definition. */
|
|
51
|
+
const noPickerWarned = new Set<string>()
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Dev-only: the field declares `custom: true` and no picker was injected, so the custom window is
|
|
55
|
+
* unreachable — the popover shows presets only and the sheet's `Custom range…` row never renders.
|
|
56
|
+
* A warning rather than a type error: `custom: true` with presets only is a legal (and tested)
|
|
57
|
+
* configuration, and the picker is INJECTED precisely because `@mantine/dates` may be absent, so
|
|
58
|
+
* the type cannot tell "deliberately preset-only" from "forgot the import".
|
|
59
|
+
*/
|
|
60
|
+
function warnCustomWithoutPicker(label: string, unreachable: boolean): void {
|
|
61
|
+
if (!unreachable || !isDev() || noPickerWarned.has(label)) return
|
|
62
|
+
noPickerWarned.add(label)
|
|
63
|
+
// oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
|
|
64
|
+
console.warn(
|
|
65
|
+
`[basalt-ui] RangeFilter('${label}'): the field declares \`custom: true\` but no ` +
|
|
66
|
+
'`customPicker` was injected, so the custom window cannot be reached. Pass ' +
|
|
67
|
+
'`customPicker={DateRangePicker}` (`basalt-ui/controls-dates`), or drop `custom: true` from ' +
|
|
68
|
+
'the field. (dev only)',
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
44
72
|
/**
|
|
45
73
|
* The contract a custom-range picker satisfies — `basalt-ui/controls-dates`' `DateRangePicker`, or a
|
|
46
74
|
* consumer's own. Dates are ISO (`YYYY-MM-DD`), the same shape `field.range` stores and
|
|
@@ -51,28 +79,52 @@ export type RangeCustomPickerProps = {
|
|
|
51
79
|
readonly onChange: (next: { from: string; to: string }) => void
|
|
52
80
|
}
|
|
53
81
|
|
|
54
|
-
|
|
55
|
-
|
|
82
|
+
/** Everything about the props that does not depend on the field's `custom` flag. */
|
|
83
|
+
type RangeFilterBase<P extends string, C extends boolean> = {
|
|
84
|
+
/**
|
|
85
|
+
* A range handle from either store factory. Generic over the field's `custom` flag so a
|
|
86
|
+
* `field.range({ ... })` WITHOUT `custom` (whose handle is `RangeField<P, false>`, and whose
|
|
87
|
+
* values therefore never include `'custom'`) binds with no cast — pinning `C` to its default
|
|
88
|
+
* `boolean` here made the setter contravariantly incompatible and forced one at every call site.
|
|
89
|
+
*/
|
|
90
|
+
readonly field: FieldHandle<RangeField<P, C>>
|
|
56
91
|
/** Leading pill icon. Defaults to a calendar glyph — a range filter reads as a date control, so
|
|
57
92
|
* the glyph is part of the control's identity, not a per-call-site decision. */
|
|
58
93
|
readonly icon?: ReactNode
|
|
59
|
-
/** Rendered when the field declares `custom: true`. Omitted → presets only. */
|
|
60
|
-
readonly customPicker?: ComponentType<RangeCustomPickerProps>
|
|
61
94
|
/** Sheet-form heading. The pill's own label is the VALUE, which is what a bar reads. @default 'Range' */
|
|
62
95
|
readonly label?: string
|
|
63
96
|
}
|
|
64
97
|
|
|
65
|
-
export
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
98
|
+
export type RangeFilterProps<P extends string, C extends boolean = boolean> = RangeFilterBase<
|
|
99
|
+
P,
|
|
100
|
+
C
|
|
101
|
+
> & {
|
|
102
|
+
/**
|
|
103
|
+
* Rendered when the field declares `custom: true`. Omitted → presets only, which stays LEGAL
|
|
104
|
+
* (a range that only ever shows presets is a real configuration) but warns once in dev when the
|
|
105
|
+
* field allows a custom window: the affordance is then unreachable, and nothing else says so.
|
|
106
|
+
*/
|
|
107
|
+
readonly customPicker?: ComponentType<RangeCustomPickerProps>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function RangeFilter<P extends string, C extends boolean = boolean>(
|
|
111
|
+
props: RangeFilterProps<P, C>,
|
|
112
|
+
): ReactNode {
|
|
113
|
+
const {
|
|
114
|
+
field,
|
|
115
|
+
icon,
|
|
116
|
+
customPicker,
|
|
117
|
+
label = 'Range',
|
|
118
|
+
// One cast, at the one boundary a generic `C` cannot be read through: `C` only ever describes
|
|
119
|
+
// the FIELD's custom flag, and the body handles both.
|
|
120
|
+
} = props as unknown as RangeFilterBase<P, boolean> & {
|
|
121
|
+
readonly customPicker?: ComponentType<RangeCustomPickerProps>
|
|
122
|
+
}
|
|
71
123
|
const [value, setValue] = field.use()
|
|
72
124
|
const surface = useFilterSurface()
|
|
73
125
|
const isDefault = field.isDefault(value)
|
|
74
126
|
useFilterRegistration(!isDefault, () => {
|
|
75
|
-
|
|
127
|
+
field.clear()
|
|
76
128
|
})
|
|
77
129
|
|
|
78
130
|
const options = field.options
|
|
@@ -81,6 +133,7 @@ export function RangeFilter<P extends string>({
|
|
|
81
133
|
const numeric = presets.length > 0 && presets.every((option) => NUMERIC_LABEL.test(option.label))
|
|
82
134
|
const inSheet = surface === 'sheet'
|
|
83
135
|
const Picker = customPicker
|
|
136
|
+
warnCustomWithoutPicker(label, allowsCustom && Picker === undefined)
|
|
84
137
|
// The track is a `radiogroup`; without this it announces unnamed, and the pill it hangs off reads
|
|
85
138
|
// the VALUE (`30d`), not the filter.
|
|
86
139
|
const { labelId, nameProps } = useControlName(label, inSheet)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RangeFilterProps` is generic over the FIELD's `custom` flag — the compile-time half, plus the one
|
|
3
|
+
* runtime consequence of leaving `customPicker` out.
|
|
4
|
+
*
|
|
5
|
+
* Why a file of its own (the `actions.type-guard.test.ts` convention): pinning `C` to its default
|
|
6
|
+
* `boolean` made `RangeFilter field={...}` reject a `field.range({ … })` that never declared
|
|
7
|
+
* `custom: true`. The handle's setter is contravariant, so a `RangeValue<P>` setter cannot stand in
|
|
8
|
+
* for a `RangeValue<P | 'custom'>` one — two consumers cast at the call site instead, which is
|
|
9
|
+
* exactly the diagnostic the cast was hiding.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, spyOn, test } from 'bun:test'
|
|
12
|
+
import type { ReactNode } from 'react'
|
|
13
|
+
import { MantineProvider } from '@mantine/core'
|
|
14
|
+
import { render, screen } from '@testing-library/react'
|
|
15
|
+
import type { FieldHandle, RangeField } from '../state'
|
|
16
|
+
import { createLocalStore, field } from '../state'
|
|
17
|
+
import { RangeFilter } from './range-filter'
|
|
18
|
+
import type { RangeCustomPickerProps } from './range-filter'
|
|
19
|
+
|
|
20
|
+
// Two stores, not two range fields in one: every range defaults its `from`/`to` param names, and
|
|
21
|
+
// the store core rejects the collision at definition (see `assertNoParamCollision`).
|
|
22
|
+
const store = createLocalStore({
|
|
23
|
+
key: 'tg-range',
|
|
24
|
+
fields: {
|
|
25
|
+
presetsOnly: field.range({ presets: ['7d', '30d'], fallback: '30d' }),
|
|
26
|
+
view: field.enum(['chart', 'table'], 'chart'),
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const customStore = createLocalStore({
|
|
31
|
+
key: 'tg-range-custom',
|
|
32
|
+
fields: { withCustom: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }) },
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
function Picker({ onChange }: RangeCustomPickerProps): ReactNode {
|
|
36
|
+
return (
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
onClick={() => {
|
|
40
|
+
onChange({ from: '2026-03-01', to: '2026-03-14' })
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
pick march
|
|
44
|
+
</button>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A control that took a range handle from ITS OWN props — `C` widened, which must still bind. */
|
|
49
|
+
function Wrapper({ handle }: { handle: FieldHandle<RangeField<'7d' | '30d'>> }): ReactNode {
|
|
50
|
+
return <RangeFilter field={handle} customPicker={Picker} />
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Valid — must type-check with no error ─────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function Accepted(): ReactNode {
|
|
56
|
+
return (
|
|
57
|
+
<>
|
|
58
|
+
{/* The regression: no `custom`, therefore `RangeField<P, false>` — and no cast. */}
|
|
59
|
+
<RangeFilter field={store.field.presetsOnly} />
|
|
60
|
+
<RangeFilter field={store.field.presetsOnly} label="Window" />
|
|
61
|
+
<RangeFilter field={customStore.field.withCustom} customPicker={Picker} />
|
|
62
|
+
<Wrapper handle={customStore.field.withCustom} />
|
|
63
|
+
</>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Invalid — each MUST be a tsc error, one directive per bad line ────────────
|
|
68
|
+
|
|
69
|
+
function Rejected(): ReactNode {
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
{/* @ts-expect-error an enum handle is not a range handle, whatever `C` is */}
|
|
73
|
+
<RangeFilter field={store.field.view} />
|
|
74
|
+
{/* @ts-expect-error `value`/`onChange` is not the binding a basalt control takes (C2) */}
|
|
75
|
+
<RangeFilter value={{ preset: '7d' }} onChange={() => {}} />
|
|
76
|
+
</>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The first argument of every captured warn call. */
|
|
81
|
+
function lines(warn: { mock: { calls: unknown[][] } }): string[] {
|
|
82
|
+
return warn.mock.calls.map((call) => String(call[0]))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('RangeFilterProps (type-guard)', () => {
|
|
86
|
+
test('both fixtures exist — tsc, not bun, is the assertion for the cases above', () => {
|
|
87
|
+
expect(typeof Accepted).toBe('function')
|
|
88
|
+
expect(typeof Rejected).toBe('function')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('a custom-capable field with no picker warns once in dev — the affordance is unreachable', () => {
|
|
92
|
+
const warn = spyOn(console, 'warn').mockImplementation(() => {})
|
|
93
|
+
try {
|
|
94
|
+
render(
|
|
95
|
+
<MantineProvider>
|
|
96
|
+
<RangeFilter field={customStore.field.withCustom} label="Unreachable" />
|
|
97
|
+
</MantineProvider>,
|
|
98
|
+
)
|
|
99
|
+
expect(screen.getByRole('button', { name: '30d' })).toBeDefined()
|
|
100
|
+
expect(
|
|
101
|
+
lines(warn).filter((line) => line.includes("RangeFilter('Unreachable')")),
|
|
102
|
+
).toHaveLength(1)
|
|
103
|
+
expect(lines(warn)[0]).toContain('customPicker')
|
|
104
|
+
} finally {
|
|
105
|
+
warn.mockRestore()
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('a preset-only field is silent — presets only is a configuration, not a mistake', () => {
|
|
110
|
+
const warn = spyOn(console, 'warn').mockImplementation(() => {})
|
|
111
|
+
try {
|
|
112
|
+
render(
|
|
113
|
+
<MantineProvider>
|
|
114
|
+
<RangeFilter field={store.field.presetsOnly} label="Silent" />
|
|
115
|
+
</MantineProvider>,
|
|
116
|
+
)
|
|
117
|
+
expect(lines(warn).filter((line) => line.includes("RangeFilter('Silent')"))).toHaveLength(0)
|
|
118
|
+
} finally {
|
|
119
|
+
warn.mockRestore()
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -38,7 +38,7 @@ export function SearchFilter({
|
|
|
38
38
|
const surface = useFilterSurface()
|
|
39
39
|
const draft = useDebouncedField(value, setValue)
|
|
40
40
|
useFilterRegistration(!field.isDefault(value), () => {
|
|
41
|
-
|
|
41
|
+
field.clear()
|
|
42
42
|
})
|
|
43
43
|
|
|
44
44
|
// `placeholder` is optional and, even when set, stops being the name the moment the user types —
|
|
@@ -25,7 +25,7 @@ export function ToggleFilter({ field, label, icon }: ToggleFilterProps): ReactNo
|
|
|
25
25
|
const [value, setValue] = field.use()
|
|
26
26
|
const surface = useFilterSurface()
|
|
27
27
|
useFilterRegistration(!field.isDefault(value), () => {
|
|
28
|
-
|
|
28
|
+
field.clear()
|
|
29
29
|
})
|
|
30
30
|
|
|
31
31
|
if (surface === 'sheet') {
|
package/src/dashboard/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export { DeltaBadge, type DeltaBadgeProps } from './delta-badge'
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
StatCard,
|
|
4
|
+
type StatCardBreakdownRow,
|
|
5
|
+
type StatCardProps,
|
|
6
|
+
type StatCardTone,
|
|
7
|
+
} from './stat-card'
|
|
3
8
|
export { EmptyState, type EmptyStateProps } from './empty-state'
|
|
4
9
|
export {
|
|
5
10
|
SettingsSection,
|