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
@@ -1731,6 +1731,46 @@ describe('basalt/control-outside-home', () => {
1731
1731
  )
1732
1732
  expect(rules).not.toContain('control-outside-home')
1733
1733
  })
1734
+
1735
+ /**
1736
+ * The CROSS-FILE case, exempted by a naming convention rather than by ancestry — argo carried 9 of
1737
+ * these, each a control in a modal/form module whose `<Modal>` is rendered by the parent route.
1738
+ * Nothing in the flagged file can see the host, so the ancestry walk never could have.
1739
+ */
1740
+ describe('the overlay filename convention', () => {
1741
+ const SOURCE = `${MANTINE_IMPORT}export const C = () => <div><Select data={[]} /></div>\n`
1742
+
1743
+ it.each([
1744
+ 'src/edit-session-modal.tsx',
1745
+ 'src/filters-drawer.tsx',
1746
+ 'src/column-popover.tsx',
1747
+ 'src/detail-panel.tsx',
1748
+ 'src/booking-form.tsx',
1749
+ ])('does NOT flag %s — its host lives in the parent', (filename) => {
1750
+ const { code, rules } = run(SOURCE, filename)
1751
+ expect(code).toBe(0)
1752
+ expect(rules).not.toContain('control-outside-home')
1753
+ })
1754
+
1755
+ it('still flags a plain page module beside them', () => {
1756
+ const { rules } = run(SOURCE, 'src/bookings-page.tsx')
1757
+ expect(rules).toContain('control-outside-home')
1758
+ })
1759
+
1760
+ // Basename only: a directory of modals holds the page pieces around them too, so a whole
1761
+ // `modal/` tree going silent is the version of this exemption that WOULD switch the rule off.
1762
+ it('is a BASENAME convention, not a directory one', () => {
1763
+ const { rules } = run(SOURCE, 'src/modal/session.tsx')
1764
+ expect(rules).toContain('control-outside-home')
1765
+ })
1766
+
1767
+ // `modal.tsx` with no leading segment is a page module in every consumer that has one; the
1768
+ // convention is `<subject>-modal.tsx`, and the regex says so.
1769
+ it('needs the leading subject — a bare modal.tsx is not the convention', () => {
1770
+ const { rules } = run(SOURCE, 'src/modal.tsx')
1771
+ expect(rules).toContain('control-outside-home')
1772
+ })
1773
+ })
1734
1774
  })
1735
1775
 
1736
1776
  describe('basalt/control-size-literal', () => {
@@ -1761,6 +1801,82 @@ describe('basalt/control-size-literal', () => {
1761
1801
  expect(rules).not.toContain('control-size-literal')
1762
1802
  })
1763
1803
 
1804
+ /**
1805
+ * `ChartCard` is the one slot owner whose slot cannot mount the tier theme — it lives inside the
1806
+ * Mantine-free chart layer, so it writes `data-basalt-tier` by hand and the control there really
1807
+ * does have to state its own size. The rule fires on the `size` ATTRIBUTE and cannot tell a
1808
+ * correct `ctl` from the `xs` it exists to catch, so this is an exemption, not a waiver.
1809
+ */
1810
+ describe('ChartCard.actions — the one slot with no tier theme', () => {
1811
+ it('does NOT flag a size prop there', () => {
1812
+ const { code, rules } = run(
1813
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <ChartCard title="T" actions={<Button size="ctl">Go</Button>} />
1814
+ `,
1815
+ )
1816
+ expect(code).toBe(0)
1817
+ expect(rules).not.toContain('control-size-literal')
1818
+ })
1819
+
1820
+ // The owner test reads the IMPORTED name, so the alias every wrapper writes still resolves.
1821
+ it('does NOT flag it under an aliased ChartCard import', () => {
1822
+ const { rules } = run(
1823
+ `import { ChartCard as Card } from 'basalt-ui'
1824
+ ${MANTINE_IMPORT}` +
1825
+ `export const C = () => <Card title="T" actions={<Button size="xs">Go</Button>} />
1826
+ `,
1827
+ )
1828
+ expect(rules).not.toContain('control-size-literal')
1829
+ })
1830
+
1831
+ // The positive control: every OTHER home does mount the theme, so the rule still fires there.
1832
+ it('STILL flags the same prop in a Section slot', () => {
1833
+ const { code, rules } = run(
1834
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <Section title="T" actions={<Button size="xs">Go</Button>} />
1835
+ `,
1836
+ )
1837
+ expect(code).toBe(1)
1838
+ expect(rules).toContain('control-size-literal')
1839
+ })
1840
+
1841
+ // The exemption is keyed on the OWNER, and one hoisted binding can be handed to several — so it
1842
+ // holds only when EVERY home it reached is tierless. A last-writer-wins owner map made the
1843
+ // verdict depend on which attribute came later in the file: same code, opposite answer.
1844
+ it('STILL flags a hoisted binding shared with a Section slot — ChartCard last', () => {
1845
+ const { rules } = run(
1846
+ `${BASALT_IMPORT}${MANTINE_IMPORT}const acts = <Button size="xs">Go</Button>\n` +
1847
+ `export const C = () => (\n <>\n <Section title="S" actions={acts} />\n <ChartCard title="T" actions={acts} />\n </>\n)\n`,
1848
+ )
1849
+ expect(rules).toContain('control-size-literal')
1850
+ })
1851
+
1852
+ it('STILL flags a hoisted binding shared with a Section slot — ChartCard first', () => {
1853
+ const { rules } = run(
1854
+ `${BASALT_IMPORT}${MANTINE_IMPORT}const acts = <Button size="xs">Go</Button>\n` +
1855
+ `export const C = () => (\n <>\n <ChartCard title="T" actions={acts} />\n <Section title="S" actions={acts} />\n </>\n)\n`,
1856
+ )
1857
+ expect(rules).toContain('control-size-literal')
1858
+ })
1859
+
1860
+ it('does NOT flag a hoisted binding handed only to ChartCard', () => {
1861
+ const { code, rules } = run(
1862
+ `${BASALT_IMPORT}${MANTINE_IMPORT}const acts = <Button size="ctl">Go</Button>\n` +
1863
+ `export const C = () => <ChartCard title="T" actions={acts} />\n`,
1864
+ )
1865
+ expect(code).toBe(0)
1866
+ expect(rules).not.toContain('control-size-literal')
1867
+ })
1868
+
1869
+ // A consumer's OWN `ChartCard` is not basalt's, so it is not a home at all and nothing fires —
1870
+ // the same provenance rule every other home tag follows.
1871
+ it('hand-rolled-filter is NOT exempted there — the tier is not what makes a filter wrong', () => {
1872
+ const { rules } = run(
1873
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <ChartCard title="T" actions={<Select data={[]} />} />
1874
+ `,
1875
+ )
1876
+ expect(rules).toContain('hand-rolled-filter')
1877
+ })
1878
+ })
1879
+
1764
1880
  it('does NOT flag a size prop on the HOME itself', () => {
1765
1881
  const { code, rules } = run(
1766
1882
  `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <PageBar w={200} actions={<Button>Go</Button>} />\n`,
@@ -2232,6 +2348,133 @@ describe('basalt/shadow-basalt-export — SHADOW_ALIASES', () => {
2232
2348
  })
2233
2349
  })
2234
2350
 
2351
+ /**
2352
+ * The provenance half of the alias table: a file that COMPOSES the export it renames is a wrapper,
2353
+ * not a fork. argo's three hero cards each imported `StatCard`, wrapped it, and each took a warn
2354
+ * telling them to import the thing they were already importing.
2355
+ *
2356
+ * Both directions are asserted, because the exemption is only worth having if the rule still fires
2357
+ * WITHOUT the import — a wrapper is evidence, and silence on every `HeroCard` in the repo would be
2358
+ * the rule switching itself off.
2359
+ */
2360
+ describe('basalt/shadow-basalt-export — an alias that composes what it renames', () => {
2361
+ it('fires on a HeroCard that does not import StatCard', () => {
2362
+ const { rules, output } = run(
2363
+ `export function HeroCard() {\n return null\n}\n`,
2364
+ 'src/hero-card.tsx',
2365
+ )
2366
+ expect(rules).toContain('shadow-basalt-export')
2367
+ expect(output).toContain('StatCard')
2368
+ })
2369
+
2370
+ it('is silent on a HeroCard that composes the imported StatCard', () => {
2371
+ const { code, rules } = run(
2372
+ `import { StatCard } from 'basalt-ui'\n` +
2373
+ `export function HeroCard() {\n return <StatCard title="x" value="1" />\n}\n`,
2374
+ 'src/hero-card.tsx',
2375
+ )
2376
+ expect(code).toBe(0)
2377
+ expect(rules).not.toContain('shadow-basalt-export')
2378
+ })
2379
+
2380
+ // The provenance test reads the IMPORTED name, not the local one — `StatCard as BaseStatCard` is
2381
+ // the canonical way a wrapper avoids shadowing its own import, and a local-name set would answer
2382
+ // `BaseStatCard` and miss it.
2383
+ it('is silent when the composed export is imported under an alias', () => {
2384
+ const { rules } = run(
2385
+ `import { StatCard as BaseStatCard } from 'basalt-ui'\n` +
2386
+ `export function HeroCard() {\n return <BaseStatCard title="x" value="1" />\n}\n`,
2387
+ 'src/hero-card.tsx',
2388
+ )
2389
+ expect(rules).not.toContain('shadow-basalt-export')
2390
+ })
2391
+
2392
+ it('is silent from a basalt SUBPATH too, not just the root barrel', () => {
2393
+ const { rules } = run(
2394
+ `import { RangeFilter } from 'basalt-ui/controls'\n` +
2395
+ `export function WindowSelector() {\n return <RangeFilter field={{}} />\n}\n`,
2396
+ 'src/window-selector.tsx',
2397
+ )
2398
+ expect(rules).not.toContain('shadow-basalt-export')
2399
+ })
2400
+
2401
+ // Importing SOMETHING from basalt is not the exemption — importing the thing you renamed is.
2402
+ it('still fires when the file imports a DIFFERENT basalt export', () => {
2403
+ const { rules } = run(
2404
+ `import { Section } from 'basalt-ui'\n` +
2405
+ `export function HeroCard() {\n return <Section title="x" />\n}\n`,
2406
+ 'src/hero-card.tsx',
2407
+ )
2408
+ expect(rules).toContain('shadow-basalt-export')
2409
+ })
2410
+
2411
+ // The COLLISION half is deliberately not exempted: a local `StatCard` beside an
2412
+ // `import { StatCard as Base }` kept the name AND a piece of the original, which is the fork
2413
+ // shape this rule most wants to see.
2414
+ it('still fires on a name COLLISION even when the export is imported under an alias', () => {
2415
+ const { rules } = run(
2416
+ `import { StatCard as Base } from 'basalt-ui'\n` +
2417
+ `export function StatCard() {\n return <Base title="x" value="1" />\n}\n`,
2418
+ 'src/stat-card.tsx',
2419
+ )
2420
+ expect(rules).toContain('shadow-basalt-export')
2421
+ })
2422
+
2423
+ // COMPOSITION, not import. A type-only import is erased at compile time, so the shape a
2424
+ // props-copying fork actually writes — `ComponentProps<typeof StatCard>` off an
2425
+ // `import type` — composes nothing and used to exempt the whole file.
2426
+ it('still fires on a type-only import of the export it renames', () => {
2427
+ const { rules } = run(
2428
+ `import type { StatCard } from 'basalt-ui'\n` +
2429
+ `export function HeroCard() {\n return <div />\n}\n`,
2430
+ 'src/hero-card.tsx',
2431
+ )
2432
+ expect(rules).toContain('shadow-basalt-export')
2433
+ })
2434
+
2435
+ it('still fires on an inline `type` specifier', () => {
2436
+ const { rules } = run(
2437
+ `import { type StatCard } from 'basalt-ui'\n` +
2438
+ `export function HeroCard() {\n return <div />\n}\n`,
2439
+ 'src/hero-card.tsx',
2440
+ )
2441
+ expect(rules).toContain('shadow-basalt-export')
2442
+ })
2443
+
2444
+ // `typeof StatCard` is a `TSTypeQuery` around a plain Identifier, so a VALUE import used only in
2445
+ // a type position has to be rejected too — otherwise the props-copying fork just drops `type`.
2446
+ it('still fires when a value import is only used in a type position', () => {
2447
+ const { rules } = run(
2448
+ `import { StatCard } from 'basalt-ui'\n` +
2449
+ `type P = { card: typeof StatCard }\n` +
2450
+ `export function HeroCard(props: P) {\n return <div>{String(props)}</div>\n}\n`,
2451
+ 'src/hero-card.tsx',
2452
+ )
2453
+ expect(rules).toContain('shadow-basalt-export')
2454
+ })
2455
+
2456
+ it('still fires on a dead import left behind after the body was re-rolled', () => {
2457
+ const { rules } = run(
2458
+ `import { StatCard } from 'basalt-ui'\n` +
2459
+ `export function HeroCard() {\n return <div />\n}\n`,
2460
+ 'src/hero-card.tsx',
2461
+ )
2462
+ expect(rules).toContain('shadow-basalt-export')
2463
+ })
2464
+
2465
+ // A JSX tag is not the only way to compose: a wrapper that hands the component on as a prop or to
2466
+ // `createElement` composes it just as much, and a JSX-only test would report both.
2467
+ it('is silent when the import is passed on as a prop rather than rendered', () => {
2468
+ const { code, rules } = run(
2469
+ `import { StatCard } from 'basalt-ui'\n` +
2470
+ `export function HeroCard() {\n return <Slot component={StatCard} />\n}\n`,
2471
+ 'src/hero-card.tsx',
2472
+ )
2473
+ expect(code).toBe(0)
2474
+ expect(rules).not.toContain('shadow-basalt-export')
2475
+ })
2476
+ })
2477
+
2235
2478
  // ── SURFACES ↔ the plugin registry ───────────────────────────────────────────
2236
2479
 
2237
2480
  describe('PLUGIN_RULE_ID_LIST ↔ the plugin', () => {
@@ -34,12 +34,12 @@
34
34
  "basalt/raw-scroll-container": "error",
35
35
  "basalt/hand-rolled-filter": "error",
36
36
  "basalt/control-outside-home": "warn",
37
- "basalt/control-size-literal": "warn",
37
+ "basalt/control-size-literal": "error",
38
38
  "basalt/page-bar-budget": "error",
39
- "basalt/in-body-page-title": "warn",
40
- "basalt/responsive-twin": "warn",
41
- "basalt/search-literal-link": "warn",
42
- "basalt/use-search-from-literal": "warn",
39
+ "basalt/in-body-page-title": "error",
40
+ "basalt/responsive-twin": "error",
41
+ "basalt/search-literal-link": "error",
42
+ "basalt/use-search-from-literal": "error",
43
43
  "basalt/visx-boundary": "error",
44
44
  "basalt/visx-tooltip": "error",
45
45
  "basalt/agent-resume-guard": "error",
@@ -12,12 +12,15 @@ export type ChoiceHandle<T extends string> = {
12
12
  readonly options: readonly FieldOption[];
13
13
  use(): readonly [T, (next: T) => void];
14
14
  isDefault(v: T): boolean;
15
+ /** Removes the value on whichever lane the field is on — what both reset paths call, never a write
16
+ * of the fallback back over it. See `useFilterRegistration`'s doc for why. */
17
+ clear(): void;
15
18
  };
16
19
  export type EnumFilterProps<T extends string> = {
17
20
  readonly field: ChoiceHandle<T>;
18
21
  readonly label: string;
19
22
  readonly icon?: ReactNode;
20
- /** Adds a `Clear` action that writes the field back to its fallback. */
23
+ /** Adds a `Clear` action that clears the field — the same `field.clear()` `Reset all` calls. */
21
24
  readonly clearable?: boolean;
22
25
  /**
23
26
  * Overrides `field.options` at render — a runtime catalogue whose labels carry live data, or the
@@ -25,6 +28,12 @@ export type EnumFilterProps<T extends string> = {
25
28
  * into, and `exactOptionalPropertyTypes` rejects a possibly-undefined member on a bare optional.
26
29
  */
27
30
  readonly options?: readonly FilterOption[] | undefined;
31
+ /**
32
+ * Mono pill label — forwarded to `FilterPill.numeric`. Set by `NumberFilter`, whose values ARE
33
+ * numbers (`1` / `2` / `7`), and by nothing else: the same law `[data-numeric]` applies to a
34
+ * numeric SegmentedControl label. A word-valued filter never sets it.
35
+ */
36
+ readonly numeric?: boolean | undefined;
28
37
  };
29
- export declare function EnumFilter<T extends string>({ field, label, icon, clearable, options: optionsProp, }: EnumFilterProps<T>): ReactNode;
38
+ export declare function EnumFilter<T extends string>({ field, label, icon, clearable, numeric, options: optionsProp, }: EnumFilterProps<T>): ReactNode;
30
39
  //# sourceMappingURL=enum-filter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"enum-filter.d.ts","sourceRoot":"","sources":["../../src/controls/enum-filter.tsx"],"names":[],"mappings":"AA6BA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AAK3C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAEnD;;;;;GAKG;AACH,MAAM,MAAM,YAAY,CAAC,CAAC,SAAS,MAAM,IAAI;IAC3C,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAA;IACpB,QAAQ,CAAC,OAAO,EAAE,SAAS,WAAW,EAAE,CAAA;IACxC,GAAG,IAAI,SAAS,CAAC,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC,CAAA;IACtC,SAAS,CAAC,CAAC,EAAE,CAAC,GAAG,OAAO,CAAA;CACzB,CAAA;AAED,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,MAAM,IAAI;IAC9C,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC,CAAC,CAAC,CAAA;IAC/B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAA;IACzB,wEAAwE;IACxE,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,YAAY,EAAE,GAAG,SAAS,CAAA;CACvD,CAAA;AAED,wBAAgB,UAAU,CAAC,CAAC,SAAS,MAAM,EAAE,EAC3C,KAAK,EACL,KAAK,EACL,IAAI,EACJ,SAAS,EACT,OAAO,EAAE,WAAW,GACrB,EAAE,eAAe,CAAC,CAAC,CAAC,GAAG,SAAS,CAwFhC"}
1
+ {"version":3,"file":"enum-filter.d.ts","sourceRoot":"","sources":["../../src/controls/enum-filter.tsx"],"names":[],"mappings":"AA6BA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AAK3C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAEnD;;;;;GAKG;AACH,MAAM,MAAM,YAAY,CAAC,CAAC,SAAS,MAAM,IAAI;IAC3C,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAA;IACpB,QAAQ,CAAC,OAAO,EAAE,SAAS,WAAW,EAAE,CAAA;IACxC,GAAG,IAAI,SAAS,CAAC,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC,CAAA;IACtC,SAAS,CAAC,CAAC,EAAE,CAAC,GAAG,OAAO,CAAA;IACxB;kFAC8E;IAC9E,KAAK,IAAI,IAAI,CAAA;CACd,CAAA;AAED,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,MAAM,IAAI;IAC9C,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC,CAAC,CAAC,CAAA;IAC/B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAA;IACzB,gGAAgG;IAChG,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,YAAY,EAAE,GAAG,SAAS,CAAA;IACtD;;;;OAIG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;CACvC,CAAA;AAED,wBAAgB,UAAU,CAAC,CAAC,SAAS,MAAM,EAAE,EAC3C,KAAK,EACL,KAAK,EACL,IAAI,EACJ,SAAS,EACT,OAAO,EACP,OAAO,EAAE,WAAW,GACrB,EAAE,eAAe,CAAC,CAAC,CAAC,GAAG,SAAS,CAyFhC"}
@@ -9,13 +9,14 @@ function EnumFilter({
9
9
  label,
10
10
  icon,
11
11
  clearable,
12
+ numeric,
12
13
  options: optionsProp
13
14
  }) {
14
15
  const [value, setValue] = field.use();
15
16
  const surface = useFilterSurface();
16
17
  const isDefault = field.isDefault(value);
17
18
  useFilterRegistration(!isDefault, () => {
18
- setValue(field.fallback);
19
+ field.clear();
19
20
  });
20
21
  const options = optionsProp ?? field.options;
21
22
  const selected = options.find((option) => option.value === value);
@@ -67,7 +68,7 @@ function EnumFilter({
67
68
  variant: "subtle",
68
69
  size: "ctl",
69
70
  onClick: () => {
70
- setValue(field.fallback);
71
+ field.clear();
71
72
  },
72
73
  children: "Clear"
73
74
  }
@@ -81,6 +82,7 @@ function EnumFilter({
81
82
  {
82
83
  label: selected?.label ?? label,
83
84
  active: !isDefault,
85
+ ...numeric === true && { numeric: true },
84
86
  ...icon !== void 0 && { icon },
85
87
  children: /* @__PURE__ */ jsx("div", { className: classes.optionList, children: body })
86
88
  }
@@ -33,9 +33,14 @@ export declare function FilterSetScope({ surface, registry, children }: FilterSe
33
33
  * it no-ops outside a registering scope (a bare filter with no `FilterSet`, the fold dropdown, the
34
34
  * sheet), which is what keeps it hook-safe in every control.
35
35
  *
36
+ * The reset is `field.clear()`, never `setValue(field.fallback)`: writing the fallback back is a
37
+ * WRITE, so it persists the default as if the user had chosen it — the URL keeps `?window=7d`, the
38
+ * localStorage mirror keeps its key, and a later change to the field's fallback no longer reaches
39
+ * either. `clear()` removes the value instead, on whichever lane the field is on.
40
+ *
36
41
  * @example
37
- * const [value, setValue] = field.use()
38
- * useFilterRegistration(!field.isDefault(value), () => setValue(field.fallback))
42
+ * const [value] = field.use()
43
+ * useFilterRegistration(!field.isDefault(value), () => field.clear())
39
44
  */
40
45
  export declare function useFilterRegistration(isActive: boolean, reset: () => void): void;
41
46
  export type FilterCensus = {
@@ -1 +1 @@
1
- {"version":3,"file":"filter-context.d.ts","sourceRoot":"","sources":["../../src/controls/filter-context.tsx"],"names":[],"mappings":"AA4BA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEtC,oGAAoG;AACpG,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,CAAA;AAI5C;;;;;;GAMG;AACH,wBAAgB,gBAAgB,IAAI,aAAa,CAEhD;AAED,uFAAuF;AACvF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,gGAAgG;IAChG,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;IAC1B,oEAAoE;IACpE,QAAQ,CAAC,KAAK,EAAE,MAAM,IAAI,CAAA;CAC3B,CAAA;AAED,KAAK,cAAc,GAAG;IACpB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,YAAY,EAAE,kBAAkB,GAAG,IAAI,CAAA;IAC7D,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B,CAAA;AAID,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAA;IAC/B,mFAAmF;IACnF,QAAQ,CAAC,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAA;IACxC,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAA;CAC7B,CAAA;AAED,gGAAgG;AAChG,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,mBAAmB,GAAG,SAAS,CAM9F;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,IAAI,GAAG,IAAI,CAkBhF;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAA;IACjC,mEAAmE;IACnE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,sCAAsC;IACtC,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,CAAA;CAC9B,CAAA;AAED;;;;GAIG;AACH,wBAAgB,eAAe,IAAI,YAAY,CAiC9C"}
1
+ {"version":3,"file":"filter-context.d.ts","sourceRoot":"","sources":["../../src/controls/filter-context.tsx"],"names":[],"mappings":"AA4BA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEtC,oGAAoG;AACpG,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,CAAA;AAI5C;;;;;;GAMG;AACH,wBAAgB,gBAAgB,IAAI,aAAa,CAEhD;AAED,uFAAuF;AACvF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,gGAAgG;IAChG,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;IAC1B,oEAAoE;IACpE,QAAQ,CAAC,KAAK,EAAE,MAAM,IAAI,CAAA;CAC3B,CAAA;AAED,KAAK,cAAc,GAAG;IACpB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,YAAY,EAAE,kBAAkB,GAAG,IAAI,CAAA;IAC7D,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B,CAAA;AAID,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAA;IAC/B,mFAAmF;IACnF,QAAQ,CAAC,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAA;IACxC,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAA;CAC7B,CAAA;AAED,gGAAgG;AAChG,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,mBAAmB,GAAG,SAAS,CAM9F;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,IAAI,GAAG,IAAI,CAkBhF;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAA;IACjC,mEAAmE;IACnE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,sCAAsC;IACtC,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,CAAA;CAC9B,CAAA;AAED;;;;GAIG;AACH,wBAAgB,eAAe,IAAI,YAAY,CAiC9C"}
@@ -13,6 +13,7 @@ export { RangeFilter, type RangeCustomPickerProps, type RangeFilterProps } from
13
13
  export { CompareFilter, COMPARE_VALUES, type CompareFilterProps, type CompareValue, } from './compare-filter.js';
14
14
  export { SelectFilter, type FilterOption, type SelectFilterEnumProps, type SelectFilterProps, type SelectFilterStringProps, } from './select-filter.js';
15
15
  export { MultiSelectFilter, type MultiSelectFilterProps } from './multi-select-filter.js';
16
+ export { NumberFilter, type NumberFilterOption, type NumberFilterProps } from './number-filter.js';
16
17
  export { SearchFilter, type SearchFilterProps } from './search-filter.js';
17
18
  export { ToggleFilter, type ToggleFilterProps } from './toggle-filter.js';
18
19
  export { ViewTabs, type ViewTabsOption, type ViewTabsProps } from './view-tabs.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/controls/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EACL,WAAW,EACX,YAAY,EACZ,qBAAqB,EACrB,qBAAqB,EACrB,eAAe,EACf,kBAAkB,EAClB,KAAK,SAAS,EACd,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,YAAY,EACjB,KAAK,iBAAiB,GACvB,MAAM,WAAW,CAAA;AAClB,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAC3F,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAMtE,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAA;AAC7D,OAAO,EAAE,WAAW,EAAE,KAAK,sBAAsB,EAAE,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAChG,OAAO,EACL,aAAa,EACb,cAAc,EACd,KAAK,kBAAkB,EACvB,KAAK,YAAY,GAClB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,YAAY,EACZ,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,uBAAuB,GAC7B,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,MAAM,uBAAuB,CAAA;AACtF,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AACtE,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AACtE,OAAO,EAAE,QAAQ,EAAE,KAAK,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/controls/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EACL,WAAW,EACX,YAAY,EACZ,qBAAqB,EACrB,qBAAqB,EACrB,eAAe,EACf,kBAAkB,EAClB,KAAK,SAAS,EACd,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,YAAY,EACjB,KAAK,iBAAiB,GACvB,MAAM,WAAW,CAAA;AAClB,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAC3F,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAMtE,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAA;AAC7D,OAAO,EAAE,WAAW,EAAE,KAAK,sBAAsB,EAAE,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAChG,OAAO,EACL,aAAa,EACb,cAAc,EACd,KAAK,kBAAkB,EACvB,KAAK,YAAY,GAClB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,YAAY,EACZ,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,uBAAuB,GAC7B,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,MAAM,uBAAuB,CAAA;AACtF,OAAO,EAAE,YAAY,EAAE,KAAK,kBAAkB,EAAE,KAAK,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAC/F,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AACtE,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AACtE,OAAO,EAAE,QAAQ,EAAE,KAAK,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAA"}
@@ -18,6 +18,7 @@ import {
18
18
  SelectFilter
19
19
  } from "./select-filter.js";
20
20
  import { MultiSelectFilter } from "./multi-select-filter.js";
21
+ import { NumberFilter } from "./number-filter.js";
21
22
  import { SearchFilter } from "./search-filter.js";
22
23
  import { ToggleFilter } from "./toggle-filter.js";
23
24
  import { ViewTabs } from "./view-tabs.js";
@@ -31,6 +32,7 @@ export {
31
32
  FilterSet,
32
33
  MOBILE_GLOBAL_BAR_MAX,
33
34
  MultiSelectFilter,
35
+ NumberFilter,
34
36
  OverflowMenu,
35
37
  RangeFilter,
36
38
  SearchFilter,
@@ -15,7 +15,7 @@ function MultiSelectFilter({
15
15
  const surface = useFilterSurface();
16
16
  const isDefault = field.isDefault(value);
17
17
  useFilterRegistration(!isDefault, () => {
18
- setValue(field.fallback);
18
+ field.clear();
19
19
  });
20
20
  const options = optionsProp ?? field.options;
21
21
  const inSheet = surface === "sheet";
@@ -65,7 +65,7 @@ function MultiSelectFilter({
65
65
  variant: "subtle",
66
66
  size: "ctl",
67
67
  onClick: () => {
68
- setValue(field.fallback);
68
+ field.clear();
69
69
  },
70
70
  children: "Clear"
71
71
  }
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { FieldHandle, NumberField } from '../state.js';
3
+ /** One row of a numeric preset set. Narrower than `FilterOption` — a preset is never `disabled`:
4
+ * the set is declared at the call site, so a row that should not be offered is left out. */
5
+ export type NumberFilterOption = {
6
+ readonly value: number;
7
+ readonly label: string;
8
+ };
9
+ export type NumberFilterProps = {
10
+ readonly field: FieldHandle<NumberField>;
11
+ /** The popover/sheet heading and the accessible name — never the pill text, which reads the
12
+ * VALUE (see `EnumFilter`'s doc for why a pill may only print what is selected). */
13
+ readonly label: string;
14
+ readonly icon?: ReactNode;
15
+ /** Present → the radio-list form. Absent → the stepper form. */
16
+ readonly options?: readonly NumberFilterOption[];
17
+ /**
18
+ * The stepper's increment. Ignored in the `options` form, which has no stepper.
19
+ *
20
+ * @default 1 — and for an `int: true` field that is also the only sane grain, so it is what the
21
+ * field's own declaration produces without this prop.
22
+ */
23
+ readonly step?: number;
24
+ };
25
+ export declare function NumberFilter({ field, label, icon, options, step }: NumberFilterProps): ReactNode;
26
+ //# sourceMappingURL=number-filter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"number-filter.d.ts","sourceRoot":"","sources":["../../src/controls/number-filter.tsx"],"names":[],"mappings":"AAoDA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AAQxD;6FAC6F;AAC7F,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CACvB,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,WAAW,CAAC,CAAA;IACxC;yFACqF;IACrF,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAA;IACzB,gEAAgE;IAChE,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,kBAAkB,EAAE,CAAA;IAChD;;;;;OAKG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CACvB,CAAA;AAED,wBAAgB,YAAY,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,iBAAiB,GAAG,SAAS,CAsBhG"}
@@ -0,0 +1,143 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import { NumberInput } from "@mantine/core";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import classes from "./controls.module.css";
5
+ import { EnumFilter } from "./enum-filter.js";
6
+ import { useFilterRegistration, useFilterSurface } from "./filter-context.js";
7
+ import { FilterPill } from "./filter-pill.js";
8
+ import { SheetField, useControlName } from "./filter-sheet.js";
9
+ function NumberFilter({ field, label, icon, options, step }) {
10
+ if (options !== void 0) {
11
+ return /* @__PURE__ */ jsx(
12
+ NumberChoice,
13
+ {
14
+ field,
15
+ label,
16
+ options,
17
+ ...icon !== void 0 && { icon }
18
+ }
19
+ );
20
+ }
21
+ return /* @__PURE__ */ jsx(
22
+ NumberStepper,
23
+ {
24
+ field,
25
+ label,
26
+ ...icon !== void 0 && { icon },
27
+ ...step !== void 0 && { step }
28
+ }
29
+ );
30
+ }
31
+ function NumberChoice({
32
+ field,
33
+ label,
34
+ icon,
35
+ options
36
+ }) {
37
+ const [value, setValue] = field.use();
38
+ const rows = options.map((option) => ({ value: String(option.value), label: option.label }));
39
+ const choice = {
40
+ fallback: String(field.fallback),
41
+ options: rows,
42
+ use: () => [
43
+ String(value),
44
+ (next) => {
45
+ setValue(Number(next));
46
+ }
47
+ ],
48
+ isDefault: (v) => field.isDefault(Number(v)),
49
+ clear: () => {
50
+ field.clear();
51
+ }
52
+ };
53
+ return /* @__PURE__ */ jsx(
54
+ EnumFilter,
55
+ {
56
+ field: choice,
57
+ label,
58
+ numeric: true,
59
+ options: rows,
60
+ ...icon !== void 0 && { icon }
61
+ }
62
+ );
63
+ }
64
+ function NumberStepper({
65
+ field,
66
+ label,
67
+ icon,
68
+ step
69
+ }) {
70
+ const [value, setValue] = field.use();
71
+ const surface = useFilterSurface();
72
+ const inSheet = surface === "sheet";
73
+ const isDefault = field.isDefault(value);
74
+ useFilterRegistration(!isDefault, () => {
75
+ field.clear();
76
+ });
77
+ const { labelId, nameProps } = useControlName(label, inSheet);
78
+ const draft = useCommittedNumber(value, setValue, { min: field.min, max: field.max });
79
+ const input = /* @__PURE__ */ jsx(
80
+ NumberInput,
81
+ {
82
+ ...nameProps,
83
+ size: "ctl",
84
+ ...field.min !== void 0 && { min: field.min },
85
+ ...field.max !== void 0 && { max: field.max },
86
+ ...field.int && { allowDecimal: false },
87
+ ...step !== void 0 ? { step } : field.int && { step: 1 },
88
+ value: draft.value,
89
+ onChange: draft.set,
90
+ onBlur: draft.commit,
91
+ onKeyDown: (event) => {
92
+ if (event.key !== "Enter") return;
93
+ event.preventDefault();
94
+ draft.commit();
95
+ }
96
+ }
97
+ );
98
+ if (inSheet) {
99
+ return /* @__PURE__ */ jsx(SheetField, { label, labelId, children: input });
100
+ }
101
+ return /* @__PURE__ */ jsx(
102
+ FilterPill,
103
+ {
104
+ label: String(value),
105
+ numeric: true,
106
+ active: !isDefault,
107
+ ...icon !== void 0 && { icon },
108
+ children: /* @__PURE__ */ jsx("div", { className: classes.optionList, children: input })
109
+ }
110
+ );
111
+ }
112
+ function useCommittedNumber(value, setValue, bounds) {
113
+ const [draft, setDraft] = useState(value);
114
+ const seen = useRef(value);
115
+ const commitRef = useRef(setValue);
116
+ commitRef.current = setValue;
117
+ useEffect(() => {
118
+ if (seen.current === value) return;
119
+ seen.current = value;
120
+ setDraft(value);
121
+ }, [value]);
122
+ return {
123
+ value: draft,
124
+ set: setDraft,
125
+ commit: () => {
126
+ const parsed = typeof draft === "number" ? draft : Number.parseFloat(draft);
127
+ if (!Number.isFinite(parsed)) {
128
+ setDraft(value);
129
+ return;
130
+ }
131
+ let next = parsed;
132
+ if (bounds.min !== void 0 && next < bounds.min) next = bounds.min;
133
+ if (bounds.max !== void 0 && next > bounds.max) next = bounds.max;
134
+ setDraft(next);
135
+ if (next === value) return;
136
+ seen.current = next;
137
+ commitRef.current(next);
138
+ }
139
+ };
140
+ }
141
+ export {
142
+ NumberFilter
143
+ };
@@ -15,15 +15,29 @@ export type RangeCustomPickerProps = {
15
15
  to: string;
16
16
  }) => void;
17
17
  };
18
- export type RangeFilterProps<P extends string> = {
19
- readonly field: FieldHandle<RangeField<P>>;
18
+ /** Everything about the props that does not depend on the field's `custom` flag. */
19
+ type RangeFilterBase<P extends string, C extends boolean> = {
20
+ /**
21
+ * A range handle from either store factory. Generic over the field's `custom` flag so a
22
+ * `field.range({ ... })` WITHOUT `custom` (whose handle is `RangeField<P, false>`, and whose
23
+ * values therefore never include `'custom'`) binds with no cast — pinning `C` to its default
24
+ * `boolean` here made the setter contravariantly incompatible and forced one at every call site.
25
+ */
26
+ readonly field: FieldHandle<RangeField<P, C>>;
20
27
  /** Leading pill icon. Defaults to a calendar glyph — a range filter reads as a date control, so
21
28
  * the glyph is part of the control's identity, not a per-call-site decision. */
22
29
  readonly icon?: ReactNode;
23
- /** Rendered when the field declares `custom: true`. Omitted → presets only. */
24
- readonly customPicker?: ComponentType<RangeCustomPickerProps>;
25
30
  /** Sheet-form heading. The pill's own label is the VALUE, which is what a bar reads. @default 'Range' */
26
31
  readonly label?: string;
27
32
  };
28
- export declare function RangeFilter<P extends string>({ field, icon, customPicker, label, }: RangeFilterProps<P>): ReactNode;
33
+ export type RangeFilterProps<P extends string, C extends boolean = boolean> = RangeFilterBase<P, C> & {
34
+ /**
35
+ * Rendered when the field declares `custom: true`. Omitted → presets only, which stays LEGAL
36
+ * (a range that only ever shows presets is a real configuration) but warns once in dev when the
37
+ * field allows a custom window: the affordance is then unreachable, and nothing else says so.
38
+ */
39
+ readonly customPicker?: ComponentType<RangeCustomPickerProps>;
40
+ };
41
+ export declare function RangeFilter<P extends string, C extends boolean = boolean>(props: RangeFilterProps<P, C>): ReactNode;
42
+ export {};
29
43
  //# sourceMappingURL=range-filter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"range-filter.d.ts","sourceRoot":"","sources":["../../src/controls/range-filter.tsx"],"names":[],"mappings":"AA8BA,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAc,MAAM,UAAU,CAAA;AAYnE;;;;GAIG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACnC,QAAQ,CAAC,KAAK,EAAE;QAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,CAAA;IACxF,QAAQ,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;CAChE,CAAA;AAED,MAAM,MAAM,gBAAgB,CAAC,CAAC,SAAS,MAAM,IAAI;IAC/C,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAA;IAC1C;qFACiF;IACjF,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAA;IACzB,+EAA+E;IAC/E,QAAQ,CAAC,YAAY,CAAC,EAAE,aAAa,CAAC,sBAAsB,CAAC,CAAA;IAC7D,yGAAyG;IACzG,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,EAAE,EAC5C,KAAK,EACL,IAAI,EACJ,YAAY,EACZ,KAAe,GAChB,EAAE,gBAAgB,CAAC,CAAC,CAAC,GAAG,SAAS,CA8FjC"}
1
+ {"version":3,"file":"range-filter.d.ts","sourceRoot":"","sources":["../../src/controls/range-filter.tsx"],"names":[],"mappings":"AA8BA,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAc,MAAM,UAAU,CAAA;AAwCnE;;;;GAIG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACnC,QAAQ,CAAC,KAAK,EAAE;QAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,CAAA;IACxF,QAAQ,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;CAChE,CAAA;AAED,oFAAoF;AACpF,KAAK,eAAe,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,IAAI;IAC1D;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAC7C;qFACiF;IACjF,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAA;IACzB,yGAAyG;IACzG,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,gBAAgB,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,GAAG,OAAO,IAAI,eAAe,CAC3F,CAAC,EACD,CAAC,CACF,GAAG;IACF;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,aAAa,CAAC,sBAAsB,CAAC,CAAA;CAC9D,CAAA;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,GAAG,OAAO,EACvE,KAAK,EAAE,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAC,GAC5B,SAAS,CAyGX"}