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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
85
|
-
* useFilterRegistration(!field.isDefault(value), () =>
|
|
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)
|
package/src/controls/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
122
|
+
field.clear()
|
|
123
123
|
}}
|
|
124
124
|
>
|
|
125
125
|
Clear
|