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.
Files changed (107) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +8 -0
  3. package/MIGRATING.md +299 -33
  4. package/agent/rules/basalt-controls.md +17 -12
  5. package/agent/rules/basalt-state.md +5 -0
  6. package/configs/oxlint-plugin.js +251 -66
  7. package/configs/oxlint-plugin.test.ts +243 -0
  8. package/configs/oxlint.json +5 -5
  9. package/dist/controls/enum-filter.d.ts +11 -2
  10. package/dist/controls/enum-filter.d.ts.map +1 -1
  11. package/dist/controls/enum-filter.js +4 -2
  12. package/dist/controls/filter-context.d.ts +7 -2
  13. package/dist/controls/filter-context.d.ts.map +1 -1
  14. package/dist/controls/index.d.ts +1 -0
  15. package/dist/controls/index.d.ts.map +1 -1
  16. package/dist/controls/index.js +2 -0
  17. package/dist/controls/multi-select-filter.js +2 -2
  18. package/dist/controls/number-filter.d.ts +26 -0
  19. package/dist/controls/number-filter.d.ts.map +1 -0
  20. package/dist/controls/number-filter.js +143 -0
  21. package/dist/controls/range-filter.d.ts +19 -5
  22. package/dist/controls/range-filter.d.ts.map +1 -1
  23. package/dist/controls/range-filter.js +22 -7
  24. package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
  25. package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
  26. package/dist/controls/search-filter.js +1 -1
  27. package/dist/controls/toggle-filter.js +1 -1
  28. package/dist/dashboard/index.d.ts +1 -1
  29. package/dist/dashboard/index.d.ts.map +1 -1
  30. package/dist/dashboard/index.js +3 -1
  31. package/dist/dashboard/stat-card.d.ts +52 -1
  32. package/dist/dashboard/stat-card.d.ts.map +1 -1
  33. package/dist/dashboard/stat-card.js +42 -15
  34. package/dist/dashboard/stat-card.module.css +71 -0
  35. package/dist/dashboard/stat-card.module.css.d.ts +4 -0
  36. package/dist/data/data-table.d.ts.map +1 -1
  37. package/dist/data/data-table.js +8 -0
  38. package/dist/guard/grace.test.d.ts.map +1 -1
  39. package/dist/guard/index.d.ts +1 -1
  40. package/dist/guard/index.d.ts.map +1 -1
  41. package/dist/guard/index.js +6 -10
  42. package/dist/index.d.ts +2 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/router-tanstack/field.d.ts +1 -1
  45. package/dist/router-tanstack/field.d.ts.map +1 -1
  46. package/dist/router-tanstack/index.d.ts +1 -1
  47. package/dist/router-tanstack/index.d.ts.map +1 -1
  48. package/dist/router-tanstack/search-store.d.ts +4 -1
  49. package/dist/router-tanstack/search-store.d.ts.map +1 -1
  50. package/dist/router-tanstack/search-store.js +91 -7
  51. package/dist/shell/page-bar.d.ts +1 -1
  52. package/dist/state/fields.d.ts +209 -36
  53. package/dist/state/fields.d.ts.map +1 -1
  54. package/dist/state/fields.js +68 -32
  55. package/dist/state/persisted.d.ts +12 -0
  56. package/dist/state/persisted.d.ts.map +1 -1
  57. package/dist/state/persisted.js +36 -18
  58. package/dist/state/persisted.test.d.ts +2 -0
  59. package/dist/state/persisted.test.d.ts.map +1 -0
  60. package/dist/state.d.ts +1 -1
  61. package/dist/state.d.ts.map +1 -1
  62. package/dist/surfaces.d.ts +1 -1
  63. package/dist/surfaces.d.ts.map +1 -1
  64. package/dist/surfaces.js +4 -3
  65. package/dist/widget-header/widget-header.d.ts +31 -1
  66. package/dist/widget-header/widget-header.d.ts.map +1 -1
  67. package/dist/widget-header/widget-header.js +13 -1
  68. package/dist/widget-header/widget-header.module.css +15 -0
  69. package/dist/widget-header/widget-header.module.css.d.ts +1 -0
  70. package/llms.txt +3 -3
  71. package/package.json +1 -1
  72. package/src/controls/controls.router.test.tsx +416 -0
  73. package/src/controls/enum-filter.tsx +14 -3
  74. package/src/controls/filter-context.tsx +7 -2
  75. package/src/controls/index.ts +1 -0
  76. package/src/controls/multi-select-filter.tsx +2 -2
  77. package/src/controls/number-filter.tsx +291 -0
  78. package/src/controls/props-contract.test.tsx +31 -0
  79. package/src/controls/range-filter.tsx +64 -11
  80. package/src/controls/range-filter.type-guard.test.tsx +122 -0
  81. package/src/controls/search-filter.tsx +1 -1
  82. package/src/controls/toggle-filter.tsx +1 -1
  83. package/src/dashboard/index.ts +6 -1
  84. package/src/dashboard/stat-card.module.css +71 -0
  85. package/src/dashboard/stat-card.module.css.d.ts +4 -0
  86. package/src/dashboard/stat-card.test.tsx +231 -0
  87. package/src/dashboard/stat-card.tsx +81 -0
  88. package/src/data/data-table.tsx +8 -0
  89. package/src/guard/check-source.test.ts +35 -2
  90. package/src/guard/grace.test.ts +14 -5
  91. package/src/guard/index.ts +30 -17
  92. package/src/index.ts +5 -0
  93. package/src/router-tanstack/field.ts +4 -0
  94. package/src/router-tanstack/index.ts +4 -0
  95. package/src/router-tanstack/search-store.router.test.tsx +232 -11
  96. package/src/router-tanstack/search-store.test.ts +160 -0
  97. package/src/router-tanstack/search-store.ts +177 -11
  98. package/src/shell/page-bar.tsx +1 -1
  99. package/src/state/fields.test.tsx +254 -0
  100. package/src/state/fields.ts +328 -70
  101. package/src/state/persisted.test.ts +91 -0
  102. package/src/state/persisted.ts +76 -26
  103. package/src/state.ts +6 -0
  104. package/src/surfaces.ts +4 -3
  105. package/src/widget-header/widget-header.module.css +15 -0
  106. package/src/widget-header/widget-header.module.css.d.ts +1 -0
  107. 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
- export type RangeFilterProps<P extends string> = {
55
- readonly field: FieldHandle<RangeField<P>>
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 function RangeFilter<P extends string>({
66
- field,
67
- icon,
68
- customPicker,
69
- label = 'Range',
70
- }: RangeFilterProps<P>): ReactNode {
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
- setValue(field.fallback)
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
- setValue(field.fallback)
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
- setValue(field.fallback)
28
+ field.clear()
29
29
  })
30
30
 
31
31
  if (surface === 'sheet') {
@@ -1,5 +1,10 @@
1
1
  export { DeltaBadge, type DeltaBadgeProps } from './delta-badge'
2
- export { StatCard, type StatCardProps, type StatCardTone } from './stat-card'
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,