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
@@ -22,7 +22,9 @@ import {
22
22
  import { field } from '../router-tanstack/field'
23
23
  import { createSearchStore } from '../router-tanstack/search-store'
24
24
  import { COMPARE_LABELS, COMPARE_VALUES, CompareFilter } from './compare-filter'
25
+ import { FilterSet } from './filter-set'
25
26
  import { MultiSelectFilter } from './multi-select-filter'
27
+ import { NumberFilter } from './number-filter'
26
28
  import { RangeFilter } from './range-filter'
27
29
  import type { RangeCustomPickerProps } from './range-filter'
28
30
  import { SearchFilter } from './search-filter'
@@ -152,6 +154,97 @@ describe('SelectFilter', () => {
152
154
  })
153
155
  })
154
156
 
157
+ /**
158
+ * Every reset a control owns — the `FilterSet` registration and the popover's own `Clear` — calls
159
+ * `field.clear()`, never `setValue(field.fallback)`.
160
+ *
161
+ * On the URL lane the two look identical (`clear()` navigates back to the fallback params, so the
162
+ * search object reads the same either way) and they differ in the MIRROR: writing the fallback
163
+ * PERSISTS it, as if the user had chosen the default, which then outranks a later change to the
164
+ * field's own default and pins a thunk fallback outright. Clearing removes the key.
165
+ */
166
+ describe('a control resets by CLEARING its field, never by writing the fallback back', () => {
167
+ const store = createSearchStore({
168
+ key: 'c-reset-clear',
169
+ fields: {
170
+ currency: field.enum(['USD', 'EUR'], 'USD'),
171
+ // A SECOND child, so one folds on mobile and `FilterSet` renders the sheet that owns
172
+ // `Reset all` at all — with a single child there is nothing to fold and no sheet.
173
+ region: field.enum(['all', 'eu'], 'all'),
174
+ },
175
+ })
176
+
177
+ /** Did the mirror keep a value for `currency`? */
178
+ function mirrored(): boolean {
179
+ return Object.hasOwn(store.readStored(), 'currency')
180
+ }
181
+
182
+ test("Reset all drops the mirror key and leaves the URL on the field's fallback", async () => {
183
+ const router = await mountPage({
184
+ validateSearch: store.validateSearch,
185
+ entry: '/dashboard',
186
+ Page: () => (
187
+ <FilterSet>
188
+ <SelectFilter field={store.field.currency} label="Currency" />
189
+ <SelectFilter field={store.field.region} label="Region" />
190
+ </FilterSet>
191
+ ),
192
+ })
193
+
194
+ await openPill('USD')
195
+ await act(async () => {
196
+ fireEvent.click(screen.getByRole('radio', { name: 'EUR', hidden: true }))
197
+ })
198
+ await waitFor(() => {
199
+ expect(search(router)['currency']).toBe('EUR')
200
+ })
201
+ // The write persisted it — which is what makes the reset's effect on the mirror observable.
202
+ expect(mirrored()).toBe(true)
203
+
204
+ // `hidden: true` — the currency popover is still open, and its portal leaves the rest of the
205
+ // document out of the accessibility tree (the same harness fact every in-dropdown query here
206
+ // carries). The count is registry state, so it lands a flush after the write.
207
+ const sheetPill = await waitFor(() =>
208
+ screen.getByRole('button', { name: 'Filters (1)', hidden: true }),
209
+ )
210
+ await act(async () => {
211
+ fireEvent.click(sheetPill)
212
+ })
213
+ await act(async () => {
214
+ fireEvent.click(screen.getByRole('button', { name: 'Reset all', hidden: true }))
215
+ })
216
+
217
+ await waitFor(() => {
218
+ expect(search(router)['currency']).toBe('USD')
219
+ })
220
+ expect(mirrored()).toBe(false)
221
+ })
222
+
223
+ test("the popover's own Clear action clears too", async () => {
224
+ const router = await mountPage({
225
+ validateSearch: store.validateSearch,
226
+ entry: '/dashboard',
227
+ Page: () => <SelectFilter field={store.field.currency} label="Currency" clearable />,
228
+ })
229
+
230
+ await openPill('USD')
231
+ fireEvent.click(screen.getByRole('radio', { name: 'EUR', hidden: true }))
232
+ await waitFor(() => {
233
+ expect(mirrored()).toBe(true)
234
+ })
235
+
236
+ await openPill('EUR')
237
+ await act(async () => {
238
+ fireEvent.click(screen.getByRole('button', { name: 'Clear', hidden: true }))
239
+ })
240
+
241
+ await waitFor(() => {
242
+ expect(search(router)['currency']).toBe('USD')
243
+ })
244
+ expect(mirrored()).toBe(false)
245
+ })
246
+ })
247
+
155
248
  /**
156
249
  * The runtime-catalogue shape: `options` overrides the field's own rows, and a `StringField` handle
157
250
  * becomes legal once it does. Both halves are asserted through a REAL `createSearchStore`, because
@@ -635,3 +728,326 @@ describe('ViewTabs', () => {
635
728
  expect(screen.queryAllByRole('radio', { name: 'Train' })).toHaveLength(0)
636
729
  })
637
730
  })
731
+
732
+ /**
733
+ * `NumberFilter` — the numeric lane, and the one control whose two forms are genuinely different
734
+ * components. `options` renders the same radio body every enum filter renders (through
735
+ * `EnumFilter`'s `ChoiceHandle`), so what needs asserting there is that the URL still holds a
736
+ * NUMBER; without `options` it is a stepper, and what needs asserting is WHEN a keystroke becomes a
737
+ * navigation.
738
+ */
739
+ describe('NumberFilter — the options form', () => {
740
+ const store = createSearchStore({
741
+ key: 'c-number-options',
742
+ fields: { nights: field.number({ fallback: 2, min: 1, max: 14, int: true }) },
743
+ })
744
+
745
+ const NIGHTS = [
746
+ { value: 1, label: '1 night' },
747
+ { value: 2, label: '2 nights' },
748
+ { value: 7, label: 'A week' },
749
+ ]
750
+
751
+ test('the pill reads the selected label and the URL keeps a NUMBER, not a numeral string', async () => {
752
+ const router = await mountPage({
753
+ validateSearch: store.validateSearch,
754
+ entry: '/dashboard',
755
+ Page: () => <NumberFilter field={store.field.nights} label="Nights" options={NIGHTS} />,
756
+ })
757
+
758
+ const pill = screen.getByRole('button', { name: '2 nights' })
759
+ expect(pill.hasAttribute('data-active')).toBe(false)
760
+ // Mono, because the values ARE numbers — the same law a numeric SegmentedControl label follows.
761
+ expect(pill.hasAttribute('data-numeric')).toBe(true)
762
+
763
+ await openPill('2 nights')
764
+ fireEvent.click(screen.getByRole('radio', { name: 'A week', hidden: true }))
765
+
766
+ await waitFor(() => {
767
+ // The whole reason this control exists: argo widened `nights` into a string enum and got
768
+ // `'7'` in the URL, which makes every downstream comparison a parse.
769
+ expect(search(router)['nights']).toBe(7)
770
+ })
771
+ expect(typeof search(router)['nights']).toBe('number')
772
+ expect(screen.getByRole('button', { name: 'A week' }).hasAttribute('data-active')).toBe(true)
773
+ })
774
+
775
+ test('the popover names the FILTER — the pill text is the value', async () => {
776
+ await mountPage({
777
+ validateSearch: store.validateSearch,
778
+ entry: '/dashboard?nights=7',
779
+ Page: () => <NumberFilter field={store.field.nights} label="Nights" options={NIGHTS} />,
780
+ })
781
+ await openPill('A week')
782
+ expect(screen.getByRole('radiogroup', { name: 'Nights', hidden: true })).toBeDefined()
783
+ })
784
+
785
+ test('a deep link is what the pill reads, with no interaction', async () => {
786
+ await mountPage({
787
+ validateSearch: store.validateSearch,
788
+ entry: '/dashboard?nights=1',
789
+ Page: () => <NumberFilter field={store.field.nights} label="Nights" options={NIGHTS} />,
790
+ })
791
+ expect(screen.getByRole('button', { name: '1 night' })).toBeDefined()
792
+ })
793
+ })
794
+
795
+ describe('NumberFilter — the stepper form', () => {
796
+ const store = createSearchStore({
797
+ key: 'c-number-stepper',
798
+ fields: { minDuration: field.number({ fallback: 0, min: 0, max: 600 }) },
799
+ })
800
+
801
+ function box(): HTMLInputElement {
802
+ return screen.getByRole('textbox', { name: 'Min duration', hidden: true }) as HTMLInputElement
803
+ }
804
+
805
+ test('typing does not navigate; blur is what commits', async () => {
806
+ const router = await mountPage({
807
+ validateSearch: store.validateSearch,
808
+ entry: '/dashboard',
809
+ Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" step={30} />,
810
+ })
811
+
812
+ await openPill('0')
813
+ fireEvent.change(box(), { target: { value: '120' } })
814
+ // Same tick: the box shows it, the URL does not. An un-gated write would have navigated on
815
+ // `1`, `12` and `120` — three loader runs for one intended threshold, two of them values the
816
+ // user never meant.
817
+ expect(box().value).toBe('120')
818
+ expect(search(router)['minDuration']).toBe(0)
819
+
820
+ fireEvent.blur(box())
821
+ await waitFor(() => {
822
+ expect(search(router)['minDuration']).toBe(120)
823
+ })
824
+ })
825
+
826
+ test('Enter commits without leaving the field', async () => {
827
+ const router = await mountPage({
828
+ validateSearch: store.validateSearch,
829
+ entry: '/dashboard',
830
+ Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" />,
831
+ })
832
+
833
+ await openPill('0')
834
+ fireEvent.change(box(), { target: { value: '45' } })
835
+ fireEvent.keyDown(box(), { key: 'Enter' })
836
+ await waitFor(() => {
837
+ expect(search(router)['minDuration']).toBe(45)
838
+ })
839
+ })
840
+
841
+ test('the field CLAMPS and the box follows it down — never a readout over a different URL', async () => {
842
+ const router = await mountPage({
843
+ validateSearch: store.validateSearch,
844
+ entry: '/dashboard',
845
+ Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" />,
846
+ })
847
+
848
+ await openPill('0')
849
+ fireEvent.change(box(), { target: { value: '9999' } })
850
+ fireEvent.blur(box())
851
+
852
+ await waitFor(() => {
853
+ expect(search(router)['minDuration']).toBe(600)
854
+ })
855
+ // The bound is the FIELD's, applied on write — so the input has to accept the correction rather
856
+ // than keep displaying a value the app is not filtering by.
857
+ await waitFor(() => {
858
+ expect(box().value).toBe('600')
859
+ })
860
+ })
861
+
862
+ /**
863
+ * The SECOND out-of-range commit is the one that used to desync, and it desynced because nothing
864
+ * moved: the store already held `600`, so the write clamped to the value it was already at, the
865
+ * `[value]` effect never fired, and the box went on reading `8888` over a URL filtering at 600 —
866
+ * the exact readout `useCommittedNumber` exists to prevent. `commit` now clamps locally first, so
867
+ * the draft follows the bound whether or not the store value changes.
868
+ */
869
+ test('a REPEATED out-of-range Enter still leaves the box on the clamped value', async () => {
870
+ const router = await mountPage({
871
+ validateSearch: store.validateSearch,
872
+ entry: '/dashboard',
873
+ Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" />,
874
+ })
875
+
876
+ await openPill('0')
877
+ fireEvent.change(box(), { target: { value: '9999' } })
878
+ fireEvent.keyDown(box(), { key: 'Enter' })
879
+ await waitFor(() => {
880
+ expect(search(router)['minDuration']).toBe(600)
881
+ })
882
+ await waitFor(() => {
883
+ expect(box().value).toBe('600')
884
+ })
885
+
886
+ // An intermediate in-range keystroke, then a second out-of-range one: react-number-format dedups
887
+ // an identical string, so re-firing `9999` in happy-dom would not register as a change at all.
888
+ // A real browser needs no such help — the digits arrive one keystroke at a time.
889
+ fireEvent.change(box(), { target: { value: '500' } })
890
+ fireEvent.change(box(), { target: { value: '8888' } })
891
+ fireEvent.keyDown(box(), { key: 'Enter' })
892
+
893
+ expect(box().value).toBe('600')
894
+ expect(search(router)['minDuration']).toBe(600)
895
+ })
896
+
897
+ test('an unparseable draft restores the field rather than committing NaN', async () => {
898
+ const router = await mountPage({
899
+ validateSearch: store.validateSearch,
900
+ entry: '/dashboard?minDuration=90',
901
+ Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" />,
902
+ })
903
+
904
+ await openPill('90')
905
+ fireEvent.change(box(), { target: { value: '' } })
906
+ fireEvent.blur(box())
907
+ expect(box().value).toBe('90')
908
+ expect(search(router)['minDuration']).toBe(90)
909
+ })
910
+
911
+ /**
912
+ * The bounds now come off the HANDLE (`NumberHandleExtras`), so the STEPPER stops at the field's
913
+ * limit instead of letting the codec correct the value one commit later. Asserted through the
914
+ * arrow keys rather than a `min`/`max` DOM attribute: Mantine's `NumberInput` is a text input
915
+ * (`inputmode=decimal`) and clamps in JS, so the attributes do not exist to read — the observable
916
+ * is that stepping cannot leave the range.
917
+ *
918
+ * The clamp test above still stands. It is the backstop for a value that never came through this
919
+ * box at all (a hand-typed URL, a stale deep link).
920
+ */
921
+ test('the field s min stops the stepper — no `min` prop reached the input before this', async () => {
922
+ await mountPage({
923
+ validateSearch: store.validateSearch,
924
+ entry: '/dashboard',
925
+ Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" step={30} />,
926
+ })
927
+ await openPill('0')
928
+ // At the fallback 0 against `min: 0`. Without the handle's bound this would read `-30`, and a
929
+ // negative threshold is a value the codec would then clamp back — visibly, after the fact.
930
+ fireEvent.keyDown(box(), { key: 'ArrowDown' })
931
+ expect(box().value).toBe(String(store.field.minDuration.min))
932
+ })
933
+
934
+ test('the field s max stops it at the other end', async () => {
935
+ await mountPage({
936
+ validateSearch: store.validateSearch,
937
+ entry: '/dashboard?minDuration=590',
938
+ Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" step={30} />,
939
+ })
940
+ await openPill('590')
941
+ // 590 + 30 would be 620; the field declares `max: 600`.
942
+ fireEvent.keyDown(box(), { key: 'ArrowUp' })
943
+ expect(box().value).toBe(String(store.field.minDuration.max))
944
+ })
945
+
946
+ test('the explicit step is the one the stepper uses', async () => {
947
+ await mountPage({
948
+ validateSearch: store.validateSearch,
949
+ entry: '/dashboard',
950
+ Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" step={30} />,
951
+ })
952
+ await openPill('0')
953
+ fireEvent.keyDown(box(), { key: 'ArrowUp' })
954
+ expect(box().value).toBe('30')
955
+ })
956
+
957
+ test('an int field refuses a decimal and steps by 1 with no step prop at all', async () => {
958
+ const ints = createSearchStore({
959
+ key: 'c-number-int',
960
+ fields: { nights: field.number({ fallback: 2, min: 1, max: 14, int: true }) },
961
+ })
962
+ const router = await mountPage({
963
+ validateSearch: ints.validateSearch,
964
+ entry: '/dashboard',
965
+ Page: () => <NumberFilter field={ints.field.nights} label="Nights" />,
966
+ })
967
+
968
+ const input = () =>
969
+ screen.getByRole('textbox', { name: 'Nights', hidden: true }) as HTMLInputElement
970
+ await openPill('2')
971
+
972
+ // `int` also picks the GRAIN: no `step` prop, and the field's own declaration produces 1.
973
+ fireEvent.keyDown(input(), { key: 'ArrowUp' })
974
+ expect(input().value).toBe('3')
975
+
976
+ // `allowDecimal={false}` — Mantine drops the separator rather than accepting a value the codec
977
+ // would decode to `null`, which would silently resurrect the fallback.
978
+ fireEvent.change(input(), { target: { value: '3.5' } })
979
+ expect(input().value).not.toContain('.')
980
+ fireEvent.blur(input())
981
+ await waitFor(() => {
982
+ expect(Number.isInteger(search(router)['nights'])).toBe(true)
983
+ })
984
+ })
985
+
986
+ test('the pill reads the value and marks itself active off the fallback', async () => {
987
+ await mountPage({
988
+ validateSearch: store.validateSearch,
989
+ entry: '/dashboard?minDuration=90',
990
+ Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" />,
991
+ })
992
+ const pill = screen.getByRole('button', { name: '90' })
993
+ expect(pill.hasAttribute('data-active')).toBe(true)
994
+ expect(pill.hasAttribute('data-numeric')).toBe(true)
995
+ })
996
+ })
997
+
998
+ /**
999
+ * The SHEET form of both branches, through a real `FilterSet` — the surface is chosen by context,
1000
+ * never by a media query (C9), so this is the only way to reach it.
1001
+ */
1002
+ describe('NumberFilter — the sheet form', () => {
1003
+ const store = createSearchStore({
1004
+ key: 'c-number-sheet',
1005
+ fields: {
1006
+ nights: field.number({ fallback: 2, min: 1, max: 14, int: true }),
1007
+ minDuration: field.number({ fallback: 0, min: 0, max: 600 }),
1008
+ },
1009
+ })
1010
+
1011
+ async function openSheet(): Promise<void> {
1012
+ await mountPage({
1013
+ validateSearch: store.validateSearch,
1014
+ entry: '/dashboard',
1015
+ Page: () => (
1016
+ <FilterSet>
1017
+ <NumberFilter
1018
+ field={store.field.nights}
1019
+ label="Nights"
1020
+ options={[
1021
+ { value: 1, label: '1 night' },
1022
+ { value: 2, label: '2 nights' },
1023
+ ]}
1024
+ />
1025
+ <NumberFilter field={store.field.minDuration} label="Min duration" />
1026
+ </FilterSet>
1027
+ ),
1028
+ })
1029
+ await act(async () => {
1030
+ fireEvent.click(screen.getByRole('button', { name: 'Filters' }))
1031
+ })
1032
+ }
1033
+
1034
+ test('the options form is a named option list, the stepper form a named input row', async () => {
1035
+ await openSheet()
1036
+ // `group`, not `radiogroup`: the sheet form is a `<fieldset>` of native radios
1037
+ // (`SheetOptionList`), pointed at its own visible `SheetField` heading.
1038
+ expect(screen.getByRole('group', { name: 'Nights', hidden: true })).toBeDefined()
1039
+ // The stepper is the input itself in a `SheetField` — no pill, no popover, one full-width row.
1040
+ // Exactly ONE input in the document: `FilterSet` keeps the bar-row copy of every child mounted
1041
+ // (hidden slots are `display: none`, never unmounted), but the bar copy is a PILL whose popover
1042
+ // is closed, so the only rendered box is the sheet's.
1043
+ expect(screen.getAllByRole('textbox', { name: 'Min duration', hidden: true })).toHaveLength(1)
1044
+ })
1045
+
1046
+ test('a sheet edit writes the field immediately — there is no Apply', async () => {
1047
+ await openSheet()
1048
+ fireEvent.click(screen.getByRole('radio', { name: '1 night', hidden: true }))
1049
+ await waitFor(() => {
1050
+ expect(store.field.nights.isDefault(1)).toBe(false)
1051
+ })
1052
+ })
1053
+ })
@@ -46,13 +46,16 @@ export type ChoiceHandle<T extends string> = {
46
46
  readonly options: readonly FieldOption[]
47
47
  use(): readonly [T, (next: T) => void]
48
48
  isDefault(v: T): boolean
49
+ /** Removes the value on whichever lane the field is on — what both reset paths call, never a write
50
+ * of the fallback back over it. See `useFilterRegistration`'s doc for why. */
51
+ clear(): void
49
52
  }
50
53
 
51
54
  export type EnumFilterProps<T extends string> = {
52
55
  readonly field: ChoiceHandle<T>
53
56
  readonly label: string
54
57
  readonly icon?: ReactNode
55
- /** Adds a `Clear` action that writes the field back to its fallback. */
58
+ /** Adds a `Clear` action that clears the field — the same `field.clear()` `Reset all` calls. */
56
59
  readonly clearable?: boolean
57
60
  /**
58
61
  * Overrides `field.options` at render — a runtime catalogue whose labels carry live data, or the
@@ -60,6 +63,12 @@ export type EnumFilterProps<T extends string> = {
60
63
  * into, and `exactOptionalPropertyTypes` rejects a possibly-undefined member on a bare optional.
61
64
  */
62
65
  readonly options?: readonly FilterOption[] | undefined
66
+ /**
67
+ * Mono pill label — forwarded to `FilterPill.numeric`. Set by `NumberFilter`, whose values ARE
68
+ * numbers (`1` / `2` / `7`), and by nothing else: the same law `[data-numeric]` applies to a
69
+ * numeric SegmentedControl label. A word-valued filter never sets it.
70
+ */
71
+ readonly numeric?: boolean | undefined
63
72
  }
64
73
 
65
74
  export function EnumFilter<T extends string>({
@@ -67,13 +76,14 @@ export function EnumFilter<T extends string>({
67
76
  label,
68
77
  icon,
69
78
  clearable,
79
+ numeric,
70
80
  options: optionsProp,
71
81
  }: EnumFilterProps<T>): ReactNode {
72
82
  const [value, setValue] = field.use()
73
83
  const surface = useFilterSurface()
74
84
  const isDefault = field.isDefault(value)
75
85
  useFilterRegistration(!isDefault, () => {
76
- setValue(field.fallback)
86
+ field.clear()
77
87
  })
78
88
 
79
89
  // The prop wins whole, never merged: a catalogue that has dropped a value must be able to drop
@@ -138,7 +148,7 @@ export function EnumFilter<T extends string>({
138
148
  variant="subtle"
139
149
  size="ctl"
140
150
  onClick={() => {
141
- setValue(field.fallback)
151
+ field.clear()
142
152
  }}
143
153
  >
144
154
  Clear
@@ -151,6 +161,7 @@ export function EnumFilter<T extends string>({
151
161
  <FilterPill
152
162
  label={selected?.label ?? label}
153
163
  active={!isDefault}
164
+ {...(numeric === true && { numeric: true })}
154
165
  {...(icon !== undefined && { icon })}
155
166
  >
156
167
  <div className={classes.optionList}>{body}</div>
@@ -80,9 +80,14 @@ export function FilterSetScope({ surface, registry, children }: FilterSetScopePr
80
80
  * it no-ops outside a registering scope (a bare filter with no `FilterSet`, the fold dropdown, the
81
81
  * sheet), which is what keeps it hook-safe in every control.
82
82
  *
83
+ * The reset is `field.clear()`, never `setValue(field.fallback)`: writing the fallback back is a
84
+ * WRITE, so it persists the default as if the user had chosen it — the URL keeps `?window=7d`, the
85
+ * localStorage mirror keeps its key, and a later change to the field's fallback no longer reaches
86
+ * either. `clear()` removes the value instead, on whichever lane the field is on.
87
+ *
83
88
  * @example
84
- * const [value, setValue] = field.use()
85
- * useFilterRegistration(!field.isDefault(value), () => setValue(field.fallback))
89
+ * const [value] = field.use()
90
+ * useFilterRegistration(!field.isDefault(value), () => field.clear())
86
91
  */
87
92
  export function useFilterRegistration(isActive: boolean, reset: () => void): void {
88
93
  const registry = useContext(FilterRegistryContext)
@@ -43,6 +43,7 @@ export {
43
43
  type SelectFilterStringProps,
44
44
  } from './select-filter'
45
45
  export { MultiSelectFilter, type MultiSelectFilterProps } from './multi-select-filter'
46
+ export { NumberFilter, type NumberFilterOption, type NumberFilterProps } from './number-filter'
46
47
  export { SearchFilter, type SearchFilterProps } from './search-filter'
47
48
  export { ToggleFilter, type ToggleFilterProps } from './toggle-filter'
48
49
  export { ViewTabs, type ViewTabsOption, type ViewTabsProps } from './view-tabs'
@@ -56,7 +56,7 @@ export function MultiSelectFilter<T extends string>({
56
56
  const surface = useFilterSurface()
57
57
  const isDefault = field.isDefault(value)
58
58
  useFilterRegistration(!isDefault, () => {
59
- setValue(field.fallback)
59
+ field.clear()
60
60
  })
61
61
 
62
62
  // The prop wins whole, never merged — same rule as `EnumFilter`: a catalogue that dropped a row
@@ -119,7 +119,7 @@ export function MultiSelectFilter<T extends string>({
119
119
  variant="subtle"
120
120
  size="ctl"
121
121
  onClick={() => {
122
- setValue(field.fallback)
122
+ field.clear()
123
123
  }}
124
124
  >
125
125
  Clear