basalt-ui 1.25.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 +8 -6
- package/CHANGELOG.md +26 -0
- package/MIGRATING.md +815 -23
- package/README.md +45 -32
- package/agent/rules/basalt-batteries.md +205 -0
- package/agent/rules/basalt-charts.md +125 -555
- package/agent/rules/basalt-controls.md +160 -0
- package/agent/rules/basalt-mantine.md +146 -439
- package/agent/rules/basalt-state.md +118 -96
- package/agent/rules/basalt-tokens.md +126 -288
- package/agent/skills/basalt-app/SKILL.md +70 -195
- package/agent/skills/basalt-charts/SKILL.md +54 -234
- package/agent/skills/basalt-design/SKILL.md +56 -248
- package/agent/templates/CLAUDE-block.md.tpl +31 -40
- package/agent/templates/DESIGN.md.tpl +22 -51
- package/configs/oxlint-plugin.js +1465 -36
- package/configs/oxlint-plugin.test.ts +1190 -11
- package/configs/oxlint.json +11 -3
- package/dist/charts/cursor/resolve.d.ts +18 -0
- package/dist/charts/cursor/resolve.d.ts.map +1 -1
- package/dist/charts/cursor/resolve.js +15 -6
- package/dist/charts/cursor/scope.d.ts +6 -3
- package/dist/charts/cursor/scope.d.ts.map +1 -1
- package/dist/charts/cursor/store.d.ts +5 -1
- package/dist/charts/cursor/store.d.ts.map +1 -1
- package/dist/charts/cursor/store.js +4 -4
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
- package/dist/charts/hooks/useChartCursor.js +9 -8
- package/dist/charts/index.d.ts +2 -2
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.d.ts +34 -9
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +29 -114
- package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
- package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
- package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
- package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
- package/dist/charts/sparklines/BarSparkline.js +18 -4
- package/dist/cli/check-coverage.test.d.ts +2 -0
- package/dist/cli/check-coverage.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts +67 -4
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +225 -7
- package/dist/connectivity/connectivity-indicator.d.ts +4 -1
- package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
- package/dist/connectivity/connectivity-indicator.js +1 -1
- package/dist/content/index.d.ts +5 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +4 -2
- package/dist/content/prose.module.css +7 -28
- package/dist/controls/actions.d.ts +169 -0
- package/dist/controls/actions.d.ts.map +1 -0
- package/dist/controls/actions.js +287 -0
- package/dist/controls/actions.module.css +62 -0
- package/dist/controls/actions.module.css.d.ts +13 -0
- package/dist/controls/actions.test.d.ts +2 -0
- package/dist/controls/actions.test.d.ts.map +1 -0
- package/dist/controls/actions.type-guard.test.d.ts +2 -0
- package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
- package/dist/controls/compare-filter.d.ts +41 -0
- package/dist/controls/compare-filter.d.ts.map +1 -0
- package/dist/controls/compare-filter.js +23 -0
- package/dist/controls/control-group.d.ts +19 -0
- package/dist/controls/control-group.d.ts.map +1 -0
- package/dist/controls/control-group.js +20 -0
- package/dist/controls/control-group.test.d.ts +2 -0
- package/dist/controls/control-group.test.d.ts.map +1 -0
- package/dist/controls/controls.module.css +542 -0
- package/dist/controls/controls.module.css.d.ts +35 -0
- package/dist/controls/controls.router.test.d.ts +2 -0
- package/dist/controls/controls.router.test.d.ts.map +1 -0
- package/dist/controls/enum-filter.d.ts +39 -0
- package/dist/controls/enum-filter.d.ts.map +1 -0
- package/dist/controls/enum-filter.js +93 -0
- package/dist/controls/filter-context.d.ts +60 -0
- package/dist/controls/filter-context.d.ts.map +1 -0
- package/dist/controls/filter-context.js +69 -0
- package/dist/controls/filter-pill.d.ts +28 -0
- package/dist/controls/filter-pill.d.ts.map +1 -0
- package/dist/controls/filter-pill.js +70 -0
- package/dist/controls/filter-set.d.ts +13 -0
- package/dist/controls/filter-set.d.ts.map +1 -0
- package/dist/controls/filter-set.js +125 -0
- package/dist/controls/filter-set.test.d.ts +2 -0
- package/dist/controls/filter-set.test.d.ts.map +1 -0
- package/dist/controls/filter-sheet.d.ts +117 -0
- package/dist/controls/filter-sheet.d.ts.map +1 -0
- package/dist/controls/filter-sheet.js +146 -0
- package/dist/controls/glyphs.d.ts +27 -0
- package/dist/controls/glyphs.d.ts.map +1 -0
- package/dist/controls/glyphs.js +49 -0
- package/dist/controls/index.d.ts +20 -0
- package/dist/controls/index.d.ts.map +1 -0
- package/dist/controls/index.js +46 -0
- package/dist/controls/multi-select-filter.d.ts +23 -0
- package/dist/controls/multi-select-filter.d.ts.map +1 -0
- package/dist/controls/multi-select-filter.js +89 -0
- 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/peer-boundary.test.d.ts +2 -0
- package/dist/controls/peer-boundary.test.d.ts.map +1 -0
- package/dist/controls/props-contract.test.d.ts +2 -0
- package/dist/controls/props-contract.test.d.ts.map +1 -0
- package/dist/controls/range-filter.d.ts +43 -0
- package/dist/controls/range-filter.d.ts.map +1 -0
- package/dist/controls/range-filter.js +135 -0
- 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.d.ts +10 -0
- package/dist/controls/search-filter.d.ts.map +1 -0
- package/dist/controls/search-filter.js +81 -0
- package/dist/controls/select-filter.d.ts +74 -0
- package/dist/controls/select-filter.d.ts.map +1 -0
- package/dist/controls/select-filter.js +10 -0
- package/dist/controls/sync-button.d.ts +38 -0
- package/dist/controls/sync-button.d.ts.map +1 -0
- package/dist/controls/sync-button.js +119 -0
- package/dist/controls/sync-button.module.css +63 -0
- package/dist/controls/sync-button.module.css.d.ts +11 -0
- package/dist/controls/sync-button.test.d.ts +2 -0
- package/dist/controls/sync-button.test.d.ts.map +1 -0
- package/dist/controls/toggle-filter.d.ts +9 -0
- package/dist/controls/toggle-filter.d.ts.map +1 -0
- package/dist/controls/toggle-filter.js +42 -0
- package/dist/controls/view-tabs.d.ts +23 -0
- package/dist/controls/view-tabs.d.ts.map +1 -0
- package/dist/controls/view-tabs.js +59 -0
- package/dist/controls-dates/date-range-picker.d.ts +4 -0
- package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
- package/dist/controls-dates/date-range-picker.js +18 -0
- package/dist/controls-dates/index.d.ts +9 -0
- package/dist/controls-dates/index.d.ts.map +1 -0
- package/dist/controls-dates/index.js +4 -0
- package/dist/dashboard/delta-badge.d.ts +6 -12
- package/dist/dashboard/delta-badge.d.ts.map +1 -1
- package/dist/dashboard/delta-badge.js +1 -50
- 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/settings-section.d.ts +6 -4
- package/dist/dashboard/settings-section.d.ts.map +1 -1
- package/dist/dashboard/settings-section.js +23 -16
- package/dist/dashboard/settings-section.module.css +10 -0
- package/dist/dashboard/settings-section.module.css.d.ts +1 -0
- package/dist/dashboard/settings-section.test.d.ts +2 -0
- package/dist/dashboard/settings-section.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.d.ts +95 -9
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +108 -49
- package/dist/dashboard/stat-card.module.css +153 -0
- package/dist/dashboard/stat-card.module.css.d.ts +15 -0
- package/dist/data/data-table.d.ts +37 -15
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +96 -58
- package/dist/guard/grace.test.d.ts +20 -0
- package/dist/guard/grace.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +66 -3
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +78 -7
- package/dist/guard/types.d.ts +3 -3
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -10
- package/dist/nav/types.d.ts +78 -2
- package/dist/nav/types.d.ts.map +1 -1
- package/dist/notifications/bell.d.ts +4 -1
- package/dist/notifications/bell.d.ts.map +1 -1
- package/dist/notifications/index.d.ts +1 -1
- package/dist/router-tanstack/field.d.ts +14 -0
- package/dist/router-tanstack/field.d.ts.map +1 -0
- package/dist/router-tanstack/field.js +4 -0
- package/dist/router-tanstack/index.d.ts +6 -2
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/index.js +4 -0
- package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.js +19 -27
- package/dist/router-tanstack/search-param-store.d.ts +3 -87
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-param-store.js +16 -45
- package/dist/router-tanstack/search-store.d.ts +84 -0
- package/dist/router-tanstack/search-store.d.ts.map +1 -0
- package/dist/router-tanstack/search-store.js +271 -0
- package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
- package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
- package/dist/router-tanstack/search-store.test.d.ts +2 -0
- package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
- package/dist/router-tanstack/use-nav.d.ts +0 -2
- package/dist/router-tanstack/use-nav.d.ts.map +1 -1
- package/dist/router-tanstack/use-nav.js +34 -12
- package/dist/router-tanstack/use-nav.test.d.ts +2 -0
- package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
- package/dist/section/index.d.ts +2 -0
- package/dist/section/index.d.ts.map +1 -0
- package/dist/section/index.js +4 -0
- package/dist/section/section.d.ts +24 -0
- package/dist/section/section.d.ts.map +1 -0
- package/dist/section/section.js +126 -0
- package/dist/section/section.module.css +104 -0
- package/dist/section/section.module.css.d.ts +11 -0
- package/dist/section/section.test.d.ts +2 -0
- package/dist/section/section.test.d.ts.map +1 -0
- package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
- package/dist/shell/app-breadcrumbs.js +18 -15
- package/dist/shell/app-header.module.css +77 -37
- package/dist/shell/app-header.module.css.d.ts +2 -1
- package/dist/shell/app-mobile-nav.d.ts +9 -2
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +98 -11
- package/dist/shell/app-mobile-nav.module.css +8 -0
- package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/dist/shell/app-sidebar.d.ts +55 -28
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +116 -67
- package/dist/shell/app-sidebar.module.css +303 -20
- package/dist/shell/app-sidebar.module.css.d.ts +19 -1
- package/dist/shell/index.d.ts +55 -21
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +40 -18
- package/dist/shell/mobile-nav-model.d.ts +13 -2
- package/dist/shell/mobile-nav-model.d.ts.map +1 -1
- package/dist/shell/mobile-nav-model.js +15 -1
- package/dist/shell/nav-count-badge.d.ts +1 -1
- package/dist/shell/page-bar.d.ts +51 -0
- package/dist/shell/page-bar.d.ts.map +1 -0
- package/dist/shell/page-bar.js +154 -0
- package/dist/shell/page-bar.module.css +178 -0
- package/dist/shell/page-bar.module.css.d.ts +17 -0
- package/dist/shell/page-bar.test.d.ts +2 -0
- package/dist/shell/page-bar.test.d.ts.map +1 -0
- package/dist/shell/sidebar-block-model.d.ts +48 -0
- package/dist/shell/sidebar-block-model.d.ts.map +1 -0
- package/dist/shell/sidebar-block-model.js +39 -0
- package/dist/shell/sidebar-blocks.d.ts +32 -0
- package/dist/shell/sidebar-blocks.d.ts.map +1 -0
- package/dist/shell/sidebar-blocks.js +169 -0
- package/dist/shell/sidebar-search.d.ts +13 -1
- package/dist/shell/sidebar-search.d.ts.map +1 -1
- package/dist/shell/sidebar-search.js +34 -1
- package/dist/shell/sidebar-search.module.css +26 -0
- package/dist/shell/sidebar-search.module.css.d.ts +2 -0
- package/dist/state/fields.d.ts +565 -0
- package/dist/state/fields.d.ts.map +1 -0
- package/dist/state/fields.js +405 -0
- package/dist/state/fields.test.d.ts +2 -0
- package/dist/state/fields.test.d.ts.map +1 -0
- package/dist/state/persisted.d.ts +60 -0
- package/dist/state/persisted.d.ts.map +1 -0
- package/dist/state/persisted.js +123 -0
- package/dist/state/persisted.test.d.ts +2 -0
- package/dist/state/persisted.test.d.ts.map +1 -0
- package/dist/state.d.ts +11 -56
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +14 -120
- package/dist/styles.css +13 -0
- package/dist/surfaces.d.ts +106 -24
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +156 -19
- package/dist/theme/controls.module.css +16 -1
- package/dist/theme/ctl-theme.d.ts +32 -0
- package/dist/theme/ctl-theme.d.ts.map +1 -0
- package/dist/theme/ctl-theme.js +54 -0
- package/dist/theme/ctl-theme.test.d.ts +2 -0
- package/dist/theme/ctl-theme.test.d.ts.map +1 -0
- package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
- package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
- package/dist/theme/divider-law.test.d.ts +2 -0
- package/dist/theme/divider-law.test.d.ts.map +1 -0
- package/dist/theme/icon-slot.d.ts +44 -0
- package/dist/theme/icon-slot.d.ts.map +1 -0
- package/dist/theme/icon-slot.js +21 -0
- package/dist/theme/icon-slot.module.css +47 -0
- package/dist/theme/icon-slot.module.css.d.ts +8 -0
- package/dist/theme/icon-slot.test.d.ts +2 -0
- package/dist/theme/icon-slot.test.d.ts.map +1 -0
- package/dist/theme/index.d.ts +1 -0
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +82 -5
- package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
- package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
- package/dist/theme/segmented-control.module.css +20 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
- package/dist/tokens/index.d.ts +12 -0
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +38 -8
- package/dist/tokens/palette.d.ts +97 -61
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +76 -38
- package/dist/tokens.css +10 -2
- package/dist/widget-header/delta-badge.d.ts +13 -0
- package/dist/widget-header/delta-badge.d.ts.map +1 -0
- package/dist/widget-header/delta-badge.js +29 -0
- package/dist/widget-header/delta-badge.test.d.ts +2 -0
- package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
- package/dist/widget-header/index.d.ts +3 -0
- package/dist/widget-header/index.d.ts.map +1 -0
- package/dist/widget-header/index.js +6 -0
- package/dist/widget-header/no-mantine.test.d.ts +2 -0
- package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
- package/dist/widget-header/widget-header.d.ts +67 -0
- package/dist/widget-header/widget-header.d.ts.map +1 -0
- package/dist/widget-header/widget-header.js +89 -0
- package/dist/widget-header/widget-header.module.css +350 -0
- package/dist/widget-header/widget-header.module.css.d.ts +25 -0
- package/dist/widget-header/widget-header.test.d.ts +2 -0
- package/dist/widget-header/widget-header.test.d.ts.map +1 -0
- package/llms.txt +30 -17
- package/package.json +14 -1
- package/src/charts/cursor/resolve.test.ts +29 -1
- package/src/charts/cursor/resolve.ts +38 -6
- package/src/charts/cursor/scope.tsx +6 -3
- package/src/charts/cursor/store.test.ts +19 -1
- package/src/charts/cursor/store.ts +10 -5
- package/src/charts/hooks/useChartCursor.ts +16 -8
- package/src/charts/index.ts +2 -2
- package/src/charts/primitives/CartesianChart.test.tsx +124 -0
- package/src/charts/primitives/ChartCard.test.tsx +66 -0
- package/src/charts/primitives/ChartCard.tsx +86 -146
- package/src/charts/sparklines/BarSparkline.tsx +43 -5
- package/src/cli/check-coverage.test.ts +297 -0
- package/src/cli/check-theme.test.ts +38 -14
- package/src/cli/index.ts +392 -16
- package/src/cli/init-sync.test.ts +97 -0
- package/src/connectivity/connectivity-indicator.tsx +16 -9
- package/src/content/index.ts +16 -3
- package/src/content/prose.module.css +7 -28
- package/src/content/prose.module.css.test.ts +8 -24
- package/src/controls/actions.module.css +62 -0
- package/src/controls/actions.module.css.d.ts +13 -0
- package/src/controls/actions.test.tsx +252 -0
- package/src/controls/actions.tsx +655 -0
- package/src/controls/actions.type-guard.test.ts +63 -0
- package/src/controls/compare-filter.tsx +64 -0
- package/src/controls/control-group.test.tsx +229 -0
- package/src/controls/control-group.tsx +63 -0
- package/src/controls/controls.module.css +542 -0
- package/src/controls/controls.module.css.d.ts +35 -0
- package/src/controls/controls.router.test.tsx +1053 -0
- package/src/controls/enum-filter.tsx +170 -0
- package/src/controls/filter-context.tsx +158 -0
- package/src/controls/filter-pill.tsx +120 -0
- package/src/controls/filter-set.test.tsx +472 -0
- package/src/controls/filter-set.tsx +225 -0
- package/src/controls/filter-sheet.tsx +285 -0
- package/src/controls/glyphs.tsx +81 -0
- package/src/controls/index.ts +49 -0
- package/src/controls/multi-select-filter.tsx +140 -0
- package/src/controls/number-filter.tsx +291 -0
- package/src/controls/peer-boundary.test.ts +85 -0
- package/src/controls/props-contract.test.tsx +137 -0
- package/src/controls/range-filter.tsx +237 -0
- package/src/controls/range-filter.type-guard.test.tsx +122 -0
- package/src/controls/search-filter.tsx +123 -0
- package/src/controls/select-filter.tsx +89 -0
- package/src/controls/sync-button.module.css +63 -0
- package/src/controls/sync-button.module.css.d.ts +11 -0
- package/src/controls/sync-button.test.tsx +183 -0
- package/src/controls/sync-button.tsx +197 -0
- package/src/controls/toggle-filter.tsx +60 -0
- package/src/controls/view-tabs.tsx +110 -0
- package/src/controls-dates/date-range-picker.tsx +42 -0
- package/src/controls-dates/index.ts +8 -0
- package/src/dashboard/delta-badge.tsx +4 -74
- package/src/dashboard/index.ts +6 -1
- package/src/dashboard/settings-section.module.css +10 -0
- package/src/dashboard/settings-section.module.css.d.ts +1 -0
- package/src/dashboard/settings-section.test.tsx +56 -0
- package/src/dashboard/settings-section.tsx +33 -30
- package/src/dashboard/stat-card.module.css +153 -0
- package/src/dashboard/stat-card.module.css.d.ts +15 -0
- package/src/dashboard/stat-card.test.tsx +298 -1
- package/src/dashboard/stat-card.tsx +289 -67
- package/src/data/data-table.test.tsx +96 -11
- package/src/data/data-table.tsx +228 -84
- package/src/guard/check-source.test.ts +159 -13
- package/src/guard/grace.test.ts +158 -0
- package/src/guard/index.ts +185 -29
- package/src/guard/types.ts +4 -2
- package/src/index.ts +51 -5
- package/src/nav/types.ts +84 -2
- package/src/notifications/bell.tsx +6 -3
- package/src/notifications/index.ts +1 -1
- package/src/router-tanstack/field.ts +35 -0
- package/src/router-tanstack/index.ts +28 -2
- package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
- package/src/router-tanstack/multi-search-param-store.ts +46 -77
- package/src/router-tanstack/search-param-store.ts +42 -159
- package/src/router-tanstack/search-store.router.test.tsx +882 -0
- package/src/router-tanstack/search-store.test.ts +578 -0
- package/src/router-tanstack/search-store.ts +546 -0
- package/src/router-tanstack/use-nav.test.tsx +187 -0
- package/src/router-tanstack/use-nav.ts +72 -14
- package/src/section/index.ts +1 -0
- package/src/section/section.module.css +104 -0
- package/src/section/section.module.css.d.ts +11 -0
- package/src/section/section.test.tsx +204 -0
- package/src/section/section.tsx +220 -0
- package/src/shell/app-breadcrumbs.tsx +44 -31
- package/src/shell/app-header.module.css +77 -37
- package/src/shell/app-header.module.css.d.ts +2 -1
- package/src/shell/app-mobile-nav.module.css +8 -0
- package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/src/shell/app-mobile-nav.tsx +147 -8
- package/src/shell/app-sidebar-account.tsx +2 -2
- package/src/shell/app-sidebar.module.css +303 -20
- package/src/shell/app-sidebar.module.css.d.ts +19 -1
- package/src/shell/app-sidebar.test.tsx +456 -41
- package/src/shell/app-sidebar.tsx +276 -140
- package/src/shell/index.test.tsx +291 -5
- package/src/shell/index.tsx +137 -38
- package/src/shell/mobile-nav-model.test.ts +228 -7
- package/src/shell/mobile-nav-model.ts +53 -3
- package/src/shell/nav-count-badge.tsx +1 -1
- package/src/shell/page-bar.module.css +178 -0
- package/src/shell/page-bar.module.css.d.ts +17 -0
- package/src/shell/page-bar.test.tsx +316 -0
- package/src/shell/page-bar.tsx +315 -0
- package/src/shell/sidebar-block-model.ts +83 -0
- package/src/shell/sidebar-blocks.tsx +301 -0
- package/src/shell/sidebar-search.module.css +26 -0
- package/src/shell/sidebar-search.module.css.d.ts +2 -0
- package/src/shell/sidebar-search.tsx +71 -2
- package/src/state/fields.test.tsx +512 -0
- package/src/state/fields.ts +1150 -0
- package/src/state/persisted.test.ts +91 -0
- package/src/state/persisted.ts +261 -0
- package/src/state.ts +52 -240
- package/src/styles.css +13 -0
- package/src/surfaces.ts +204 -36
- package/src/theme/controls.module.css +16 -1
- package/src/theme/ctl-theme.test.tsx +275 -0
- package/src/theme/ctl-theme.tsx +138 -0
- package/src/theme/ctl-tier-coverage.test.ts +170 -0
- package/src/theme/density-relations.test.ts +106 -51
- package/src/theme/divider-law.test.ts +259 -0
- package/src/theme/icon-slot.module.css +47 -0
- package/src/theme/icon-slot.module.css.d.ts +8 -0
- package/src/theme/icon-slot.test.tsx +275 -0
- package/src/theme/icon-slot.tsx +59 -0
- package/src/theme/index.ts +165 -3
- package/src/theme/layout-rhythm-css.test.ts +72 -0
- package/src/theme/radius.test.ts +11 -1
- package/src/theme/segmented-control.module.css +20 -0
- package/src/theme/shadow-surfaces.test.ts +20 -7
- package/src/theme/spacing.test.ts +75 -27
- package/src/theme/use-basalt-spacing.ts +2 -2
- package/src/tokens/density.test.ts +7 -25
- package/src/tokens/index.ts +48 -8
- package/src/tokens/palette.ts +107 -73
- package/src/widget-header/delta-badge.test.tsx +60 -0
- package/src/widget-header/delta-badge.tsx +57 -0
- package/src/widget-header/index.ts +2 -0
- package/src/widget-header/no-mantine.test.ts +33 -0
- package/src/widget-header/widget-header.module.css +350 -0
- package/src/widget-header/widget-header.module.css.d.ts +25 -0
- package/src/widget-header/widget-header.test.tsx +187 -0
- package/src/widget-header/widget-header.tsx +213 -0
- package/agent/rules/basalt-agent.md +0 -452
- package/agent/rules/basalt-app.md +0 -246
- package/agent/rules/basalt-commands.md +0 -320
- package/agent/rules/basalt-content.md +0 -409
- package/agent/rules/basalt-data.md +0 -329
- package/agent/rules/basalt-forms.md +0 -186
- package/agent/rules/basalt-notifications.md +0 -264
- package/agent/rules/basalt-query.md +0 -187
- package/agent/rules/basalt-router.md +0 -336
- package/dist/content/article-filter-bar.d.ts +0 -40
- package/dist/content/article-filter-bar.d.ts.map +0 -1
- package/dist/content/article-filter-bar.js +0 -56
- package/dist/shell/page-header.d.ts +0 -13
- package/dist/shell/page-header.d.ts.map +0 -1
- package/dist/shell/page-header.js +0 -23
- package/src/content/article-filter-bar.tsx +0 -88
- package/src/shell/page-header.tsx +0 -35
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createSearchStore` — one factory over typed fields, replacing the enum-only
|
|
3
|
+
* `createSearchParamStore` / `createMultiSearchParamStore` pair.
|
|
4
|
+
*
|
|
5
|
+
* The URL is the truth; the localStorage mirror is a fallback UNDER it. Every field resolves the
|
|
6
|
+
* same way — URL ⊳ localStorage ⊳ fallback (C4) — and declares its lanes once, at definition.
|
|
7
|
+
*
|
|
8
|
+
* Ground truth for the placement/persistence law this implements: `docs/CONTROLS-SPEC.md` §4.
|
|
9
|
+
* What used to be a 100-line recipe in this module's sibling is that spec plus `agent/rules/
|
|
10
|
+
* basalt-state.md`; the API below is what those paragraphs were waiting for.
|
|
11
|
+
*
|
|
12
|
+
* Two behaviours worth knowing before reading the code:
|
|
13
|
+
*
|
|
14
|
+
* - **A deep link wins over the mirror.** `?range=7d` reads back `7d` even when localStorage says
|
|
15
|
+
* `30d`. The enum-only pair could not do this: its reader hook was the localStorage state, so a
|
|
16
|
+
* shared link opened on the wrong window (A8).
|
|
17
|
+
* - **A field with neither lane is in memory, not nowhere.** `{ url: false, persist: false }` is
|
|
18
|
+
* the memory-only lane: session-scoped, shared across every mount of the store, gone on reload —
|
|
19
|
+
* the same lane `createLocalStore` gives such a field, through the same store-core helper. It is
|
|
20
|
+
* the honest home for a value a reader should not be handed back tomorrow.
|
|
21
|
+
* - **A write from outside the owning route persists only.** `use()`'s setter navigates only when
|
|
22
|
+
* the matched route validates the param — detected by the param key being PRESENT in the current
|
|
23
|
+
* search, which a route running this store's `validateSearch` always is (it returns every
|
|
24
|
+
* URL-lane param unconditionally) and a foreign route is not. `validateSearch` then picks the
|
|
25
|
+
* persisted value up on the next visit (A1). The honest limit of that detection: two DIFFERENT
|
|
26
|
+
* stores using the same param name on overlapping routes read as one owner. Presence is what a
|
|
27
|
+
* store can see without the router telling it which route declared what, and the alternative — a
|
|
28
|
+
* context flag — would need a provider per store, which is the thing this API does not have.
|
|
29
|
+
*
|
|
30
|
+
* Headless — no Mantine, no JSX. Same tier as `useBasaltNav` and `useRouterBreadcrumbs`.
|
|
31
|
+
*/
|
|
32
|
+
import { useNavigate, useSearch } from '@tanstack/react-router'
|
|
33
|
+
import { createStoreCore } from '../state'
|
|
34
|
+
import type {
|
|
35
|
+
AnyField,
|
|
36
|
+
FieldHandle,
|
|
37
|
+
FieldSetOptions,
|
|
38
|
+
FieldWrite,
|
|
39
|
+
SearchValues,
|
|
40
|
+
StoreEntry,
|
|
41
|
+
StoredValues,
|
|
42
|
+
} from '../state'
|
|
43
|
+
|
|
44
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export type CreateSearchStoreOptions<S extends Record<string, AnyField>> = {
|
|
47
|
+
/** localStorage key (namespaced `basalt:<key>` automatically). One entry per store. */
|
|
48
|
+
key: string
|
|
49
|
+
/** The fields, built with `field.*`. The key of each is its default URL param name. */
|
|
50
|
+
fields: S
|
|
51
|
+
/** Envelope version — bump when the field set changes to discard stale localStorage. */
|
|
52
|
+
version?: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type SearchStore<S extends Record<string, AnyField>> = {
|
|
56
|
+
/**
|
|
57
|
+
* Pass directly to `createFileRoute({ validateSearch })`. Returns every URL-lane param, resolved
|
|
58
|
+
* URL ⊳ localStorage ⊳ fallback — a route wired to it therefore always has the params, which is
|
|
59
|
+
* what makes `use()`'s "does this route own the field" check trivial and exact.
|
|
60
|
+
*/
|
|
61
|
+
validateSearch: (raw: Record<string, unknown>) => SearchValues<S>
|
|
62
|
+
/**
|
|
63
|
+
* The click-time `search:` value for a nav link. Pass it BY REFERENCE
|
|
64
|
+
* (`search: store.linkSearch`) inside `defineNav` — never a literal object, which pins the
|
|
65
|
+
* fallback on every click and is why one consumer's reader had zero call sites (C10, A2).
|
|
66
|
+
*/
|
|
67
|
+
linkSearch: () => SearchValues<S>
|
|
68
|
+
/** Plain read for non-React contexts (a redirect, a loader, a guard, a test). */
|
|
69
|
+
readStored: () => Partial<StoredValues<S>>
|
|
70
|
+
/** The per-field handles every basalt control takes instead of `value`/`onChange` (C2). */
|
|
71
|
+
field: { [K in keyof S]: FieldHandle<S[K]> }
|
|
72
|
+
/** Option labels for enum / multi / range fields. Chainable — call it once, at definition. */
|
|
73
|
+
labels: (map: Partial<{ [K in keyof S]: Record<string, string> }>) => SearchStore<S>
|
|
74
|
+
/** Every URL-lane param, resolved — the object a page hands to its query. */
|
|
75
|
+
useValues: () => SearchValues<S>
|
|
76
|
+
/** How many fields differ from their fallback — the `n` in a `Filters (n)` pill. */
|
|
77
|
+
useActiveCount: () => number
|
|
78
|
+
/**
|
|
79
|
+
* Returns every field to its fallback: one navigate, one storage write. The mirror is UNSET
|
|
80
|
+
* rather than written — see `FieldHandle.clear()` for why writing the fallback pins a thunk.
|
|
81
|
+
*/
|
|
82
|
+
useReset: () => () => void
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Dev-only, once per store: the URL is pinning `fallback` while something else is persisted, and
|
|
87
|
+
* neither reader has ever been called. That combination has exactly one realistic cause — a nav
|
|
88
|
+
* link declaring the param as a literal — and it is invisible otherwise, because every individual
|
|
89
|
+
* piece looks correct and the feature merely never remembers anything.
|
|
90
|
+
*
|
|
91
|
+
* Deliberately NOT a timer over "was this store ever read": that fires in every test that builds a
|
|
92
|
+
* store, and it cannot say what to do about it. This fires only in the broken state, and names the
|
|
93
|
+
* fix.
|
|
94
|
+
*/
|
|
95
|
+
function warnLinkPinsFallback(input: {
|
|
96
|
+
key: string
|
|
97
|
+
param: string
|
|
98
|
+
urlValue: string
|
|
99
|
+
storedValue: string
|
|
100
|
+
}): void {
|
|
101
|
+
const { key, param, urlValue, storedValue } = input
|
|
102
|
+
// oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
|
|
103
|
+
console.warn(
|
|
104
|
+
`[basalt-ui] createSearchStore('${key}'): the URL pinned \`${param}=${urlValue}\` ` +
|
|
105
|
+
'(the fallback) ' +
|
|
106
|
+
`while '${storedValue}' was persisted, and nothing has read this store back. A nav link is ` +
|
|
107
|
+
`almost certainly declaring \`search: { ${param}: '${urlValue}' }\` at module scope, which ` +
|
|
108
|
+
"overrides the stored value on every click — pass the store's own reader instead: " +
|
|
109
|
+
'`search: <store>.linkSearch`. (dev only)',
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Implementation ─────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/** Reads the merged search of every matched route, so a child or sibling needs no `from` (A3). */
|
|
116
|
+
function useCurrentSearch(): Record<string, unknown> {
|
|
117
|
+
const search = useSearch({ strict: false }) as unknown
|
|
118
|
+
return typeof search === 'object' && search !== null ? (search as Record<string, unknown>) : {}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function describeValue(value: unknown): string {
|
|
122
|
+
return typeof value === 'string' ? value : JSON.stringify(value)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Dev-only, once per field: a `persist: false` field was written from a route that does not
|
|
127
|
+
* validate it, so the write had nowhere to go — no URL (the route owns no such param) and no mirror
|
|
128
|
+
* (the field opted out of one). Silence is the wrong answer here: every other lane combination does
|
|
129
|
+
* something, so a control that appears to work and changes nothing reads as a basalt bug.
|
|
130
|
+
*/
|
|
131
|
+
function warnWriteHasNowhereToGo(input: { key: string; name: string; param: string }): void {
|
|
132
|
+
const { key, name, param } = input
|
|
133
|
+
// oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
|
|
134
|
+
console.warn(
|
|
135
|
+
`[basalt-ui] createSearchStore('${key}'): wrote field '${name}' from a route that does not ` +
|
|
136
|
+
`validate \`${param}\`, and the field is \`persist: false\` — so the write was dropped. ` +
|
|
137
|
+
'Either render this control on the route wired to `validateSearch`, or give the field the ' +
|
|
138
|
+
'localStorage lane (drop `persist: false`) so a write from elsewhere survives until the ' +
|
|
139
|
+
'owning route reads it. (dev only)',
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Dev-only, once per param: a `patch` named a search param ANOTHER field of the same store owns.
|
|
145
|
+
* The navigate would put it in the URL while the store's own write path never touched the mirror,
|
|
146
|
+
* so the next paramless visit and every `linkSearch` link resolve the OLD value — the URL and the
|
|
147
|
+
* mirror disagree from that click on, and nothing looks broken until a reader shares a link.
|
|
148
|
+
*
|
|
149
|
+
* Thrown in dev rather than warned: the call is a mistake with a one-line fix (write the other
|
|
150
|
+
* field through its own setter), and the damage is silent. Production keeps the write — a thrown
|
|
151
|
+
* error there would take a page down over a stale param — and logs it once.
|
|
152
|
+
*/
|
|
153
|
+
function patchOwnsFieldMessage(input: {
|
|
154
|
+
key: string
|
|
155
|
+
name: string
|
|
156
|
+
owner: string
|
|
157
|
+
param: string
|
|
158
|
+
}): string {
|
|
159
|
+
const { key, name, owner, param } = input
|
|
160
|
+
return (
|
|
161
|
+
`basalt-ui: createSearchStore('${key}'): set('${name}', …, { patch }) carries \`${param}\`, ` +
|
|
162
|
+
`which this store's field '${owner}' owns — the URL would take it while the localStorage ` +
|
|
163
|
+
'mirror kept the old value, so the next paramless visit and every `linkSearch` link disagree ' +
|
|
164
|
+
`with the page the write happened on. Write it through \`field.${owner}.use()[1]\` instead; ` +
|
|
165
|
+
'a `patch` is for keys the store does NOT own.'
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Dev-only, once per field: a patched write from a route that does not validate the field. The
|
|
171
|
+
* write persists (A1), but the patch has no navigate to merge into and is dropped — the one case
|
|
172
|
+
* where half of a two-part write silently disappears.
|
|
173
|
+
*/
|
|
174
|
+
function warnPatchDropped(input: { key: string; name: string; param: string }): void {
|
|
175
|
+
const { key, name, param } = input
|
|
176
|
+
// oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
|
|
177
|
+
console.warn(
|
|
178
|
+
`[basalt-ui] createSearchStore('${key}'): wrote field '${name}' with a \`patch\` from a route ` +
|
|
179
|
+
`that does not validate \`${param}\` — the field's value persisted (A1), but the patch was ` +
|
|
180
|
+
'DROPPED: there is no navigate to merge it into. Render the control on the route wired to ' +
|
|
181
|
+
'`validateSearch`, or apply that sibling param on the route that owns it. (dev only)',
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The public store plus the one member only the deprecated wrappers need: `markReaderWired`, so a
|
|
187
|
+
* consumer whose only reader is the wrapper's own `useStore()` still counts as wired and never sees
|
|
188
|
+
* the pinned-link warning it never used to see.
|
|
189
|
+
*
|
|
190
|
+
* @internal — absent from `SearchStore`, so it is invisible on `createSearchStore`'s return type.
|
|
191
|
+
*/
|
|
192
|
+
export type InternalSearchStore<S extends Record<string, AnyField>> = SearchStore<S> & {
|
|
193
|
+
markReaderWired: () => void
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A thunk fallback belongs to the LOCAL and MEMORY lanes only. On the URL lane `validateSearch`
|
|
198
|
+
* would evaluate it on every navigation and write the result into the URL, so a deep link would
|
|
199
|
+
* carry a value nobody picked and two visits a second apart would disagree. Thrown at definition
|
|
200
|
+
* rather than warned at read time: the field set is static, so there is no run in which this is
|
|
201
|
+
* anything but a mistake.
|
|
202
|
+
*/
|
|
203
|
+
function assertNoLazyUrlFallback(key: string, fields: Record<string, AnyField>): void {
|
|
204
|
+
for (const [name, f] of Object.entries(fields)) {
|
|
205
|
+
if (typeof f.fallback !== 'function' || !f.lane.url) continue
|
|
206
|
+
throw new Error(
|
|
207
|
+
`basalt-ui: createSearchStore('${key}'): field '${name}' has a thunk fallback on the URL ` +
|
|
208
|
+
'lane — `validateSearch` would evaluate it on every navigation and pin the result into the ' +
|
|
209
|
+
'URL. Pass a value instead, or move the field off the URL lane (`{ url: false }`, or ' +
|
|
210
|
+
'`createLocalStore`), where a lazy fallback is resolved per read.',
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The memory-only lane: neither the URL nor the mirror, so the value lives in the store's own
|
|
216
|
+
* session-scoped external store (`StoreCore.memoryUse`) — the lane `createLocalStore` also uses. */
|
|
217
|
+
function isMemoryLane(entry: StoreEntry): boolean {
|
|
218
|
+
return !entry.codec.lane.url && !entry.codec.lane.persist
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The store, plus the one knob the deprecated wrappers need: `legacyValueField` keeps the
|
|
223
|
+
* enum-only stores' single-value storage layout so an already-persisted selection still resolves.
|
|
224
|
+
* Not exported from the barrel — `createSearchStore` is the public door.
|
|
225
|
+
*/
|
|
226
|
+
export function buildSearchStore<const S extends Record<string, AnyField>>(
|
|
227
|
+
o: CreateSearchStoreOptions<S> & { legacyValueField?: string },
|
|
228
|
+
): InternalSearchStore<S> {
|
|
229
|
+
assertNoLazyUrlFallback(o.key, o.fields)
|
|
230
|
+
const core = createStoreCore(o)
|
|
231
|
+
|
|
232
|
+
// `validateSearch` reads storage on every navigation, which says nothing about whether the
|
|
233
|
+
// CONSUMER wired a reader. Everything a consumer can read THROUGH sets this: `linkSearch`,
|
|
234
|
+
// `readStored`, and `field.<name>.use()` — the documented reader, and the one a page that never
|
|
235
|
+
// touches the other two uses exclusively. Missing it there made the pinned-link warning fire on
|
|
236
|
+
// correctly wired pages.
|
|
237
|
+
let readerWired = false
|
|
238
|
+
let warned = false
|
|
239
|
+
const markReaderWired = (): void => {
|
|
240
|
+
readerWired = true
|
|
241
|
+
}
|
|
242
|
+
const nowhereWarned = new Set<string>()
|
|
243
|
+
const patchDroppedWarned = new Set<string>()
|
|
244
|
+
const patchOwnerLogged = new Set<string>()
|
|
245
|
+
|
|
246
|
+
/** Every URL param this store owns, mapped to the field that owns it — what a `patch` may not name. */
|
|
247
|
+
const paramOwners = new Map<string, string>()
|
|
248
|
+
for (const entry of core.urlEntries) {
|
|
249
|
+
for (const param of entry.codec.params) paramOwners.set(param, entry.name)
|
|
250
|
+
}
|
|
251
|
+
const persistedNames = new Set(
|
|
252
|
+
core.entries.filter((entry) => entry.codec.lane.persist).map((entry) => entry.name),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The URL lane's `clear()` needs a `navigate`, which only exists inside a render. Each render of
|
|
257
|
+
* `urlUse` therefore leaves the navigate its mount would use here, keyed by field — so `clear()`
|
|
258
|
+
* from an event handler on that mount navigates exactly as its setter would, and a field no mount
|
|
259
|
+
* has read falls back to clearing the mirror alone.
|
|
260
|
+
*/
|
|
261
|
+
const urlClear = new Map<string, () => void>()
|
|
262
|
+
|
|
263
|
+
const assertPatchNotOwned = (entry: StoreEntry, patch: Record<string, unknown>): void => {
|
|
264
|
+
for (const param of Object.keys(patch)) {
|
|
265
|
+
const owner = paramOwners.get(param)
|
|
266
|
+
// The field's OWN params are harmless: its `toSearch` is spread after the patch and wins.
|
|
267
|
+
if (owner === undefined || entry.codec.params.includes(param)) continue
|
|
268
|
+
const message = patchOwnsFieldMessage({ key: o.key, name: entry.name, owner, param })
|
|
269
|
+
if (process.env['NODE_ENV'] !== 'production') throw new Error(message)
|
|
270
|
+
if (patchOwnerLogged.has(param)) continue
|
|
271
|
+
patchOwnerLogged.add(param)
|
|
272
|
+
// oxlint-disable-next-line no-console -- production keeps the write; the log is the record
|
|
273
|
+
console.error(message)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const flatten = (
|
|
278
|
+
search: Record<string, unknown> | null,
|
|
279
|
+
record: Record<string, unknown>,
|
|
280
|
+
): Record<string, unknown> => {
|
|
281
|
+
const out: Record<string, unknown> = {}
|
|
282
|
+
for (const entry of core.urlEntries) {
|
|
283
|
+
Object.assign(out, entry.codec.toSearch(core.resolve(entry, search, record)))
|
|
284
|
+
}
|
|
285
|
+
return out
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const maybeWarn = (raw: Record<string, unknown>, record: Record<string, unknown>): void => {
|
|
289
|
+
if (warned || readerWired || process.env['NODE_ENV'] === 'production') return
|
|
290
|
+
for (const entry of core.urlEntries) {
|
|
291
|
+
const fromUrl = entry.codec.fromSearch(raw)
|
|
292
|
+
if (fromUrl === null || !entry.codec.equals(fromUrl, entry.codec.fallback)) continue
|
|
293
|
+
const stored = entry.codec.decode(record[entry.name])
|
|
294
|
+
if (stored === null || entry.codec.equals(stored, fromUrl)) continue
|
|
295
|
+
// Describe both sides as the URL spells them (a range prints `30d`, not its whole value
|
|
296
|
+
// object) — the message quotes a `search:` literal, so it has to be URL-shaped to be advice.
|
|
297
|
+
warned = true
|
|
298
|
+
warnLinkPinsFallback({
|
|
299
|
+
key: o.key,
|
|
300
|
+
param: entry.codec.primary,
|
|
301
|
+
urlValue: describeValue(entry.codec.toSearch(fromUrl)[entry.codec.primary]),
|
|
302
|
+
storedValue: describeValue(entry.codec.toSearch(stored)[entry.codec.primary]),
|
|
303
|
+
})
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const validateSearch = (raw: Record<string, unknown>): SearchValues<S> => {
|
|
309
|
+
const record = core.readRecord()
|
|
310
|
+
maybeWarn(raw, record)
|
|
311
|
+
return flatten(raw, record) as SearchValues<S>
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const linkSearch = (): SearchValues<S> => {
|
|
315
|
+
readerWired = true
|
|
316
|
+
return flatten(null, core.readRecord()) as SearchValues<S>
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const readStored = (): Partial<StoredValues<S>> => {
|
|
320
|
+
readerWired = true
|
|
321
|
+
return core.readStoredFlat() as Partial<StoredValues<S>>
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const urlUse = (entry: StoreEntry) => (): readonly [unknown, FieldWrite] => {
|
|
325
|
+
const search = useCurrentSearch()
|
|
326
|
+
const navigate = useNavigate()
|
|
327
|
+
const [record, setRecord] = core.usePersistedRecord()
|
|
328
|
+
markReaderWired()
|
|
329
|
+
const value = core.resolve(entry, search, record)
|
|
330
|
+
// The matched route validates this field iff its primary param is present (see the module
|
|
331
|
+
// header). A foreign route gets the persist-only lane.
|
|
332
|
+
const validated = Object.hasOwn(search, entry.codec.primary)
|
|
333
|
+
urlClear.set(entry.name, () => {
|
|
334
|
+
if (!validated) return
|
|
335
|
+
navigate({
|
|
336
|
+
to: '.',
|
|
337
|
+
search: (prev: Record<string, unknown>) => ({
|
|
338
|
+
...prev,
|
|
339
|
+
...entry.codec.toSearch(entry.codec.fallback),
|
|
340
|
+
}),
|
|
341
|
+
replace: true,
|
|
342
|
+
resetScroll: false,
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
return [
|
|
347
|
+
value,
|
|
348
|
+
(next: unknown, opts?: FieldSetOptions) => {
|
|
349
|
+
const patch = opts?.patch
|
|
350
|
+
if (patch !== undefined && Object.keys(patch).length > 0) {
|
|
351
|
+
assertPatchNotOwned(entry, patch)
|
|
352
|
+
if (
|
|
353
|
+
!validated &&
|
|
354
|
+
!patchDroppedWarned.has(entry.name) &&
|
|
355
|
+
process.env['NODE_ENV'] !== 'production'
|
|
356
|
+
) {
|
|
357
|
+
patchDroppedWarned.add(entry.name)
|
|
358
|
+
warnPatchDropped({ key: o.key, name: entry.name, param: entry.codec.primary })
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (validated) {
|
|
362
|
+
navigate({
|
|
363
|
+
to: '.',
|
|
364
|
+
// `patch` FIRST, the field's own params last: a patch is for keys the store does not
|
|
365
|
+
// own (clearing a sibling `detailDate` with `undefined`), so it must never be able to
|
|
366
|
+
// overwrite the value this very call is setting.
|
|
367
|
+
search: (prev: Record<string, unknown>) => ({
|
|
368
|
+
...prev,
|
|
369
|
+
...opts?.patch,
|
|
370
|
+
...entry.codec.toSearch(next),
|
|
371
|
+
}),
|
|
372
|
+
replace: entry.codec.lane.history === 'replace',
|
|
373
|
+
// A filter lives halfway down a page as often as it lives in the bar, and the router's
|
|
374
|
+
// default scroll restoration treats this same-route search write as a navigation — so
|
|
375
|
+
// without this a select two screens down jumps the reader back to the top on every
|
|
376
|
+
// change. Never a prop: there is no filter write that WANTS the page to scroll.
|
|
377
|
+
resetScroll: false,
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
if (entry.codec.lane.persist) {
|
|
381
|
+
core.writeField(setRecord, entry.name, next)
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
if (validated || nowhereWarned.has(entry.name)) return
|
|
385
|
+
if (process.env['NODE_ENV'] === 'production') return
|
|
386
|
+
nowhereWarned.add(entry.name)
|
|
387
|
+
warnWriteHasNowhereToGo({ key: o.key, name: entry.name, param: entry.codec.primary })
|
|
388
|
+
},
|
|
389
|
+
] as const
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// No `patch` here or in the memory lane: neither navigates, so there is no search object to merge
|
|
393
|
+
// into and nothing to silently half-apply.
|
|
394
|
+
const localUse = (entry: StoreEntry) => (): readonly [unknown, FieldWrite] => {
|
|
395
|
+
const [record, setRecord] = core.usePersistedRecord()
|
|
396
|
+
markReaderWired()
|
|
397
|
+
return [
|
|
398
|
+
core.resolve(entry, null, record),
|
|
399
|
+
(next: unknown) => core.writeField(setRecord, entry.name, next),
|
|
400
|
+
] as const
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Three lanes, decided once per field at definition: URL (+ optional mirror), mirror-only, and
|
|
404
|
+
// memory-only. The third used to be the one combination that dropped its write.
|
|
405
|
+
const memoryEntries = core.entries.filter(isMemoryLane)
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Unsetting one field, per lane: drop the mirror key (never write the fallback — a thunk fallback
|
|
409
|
+
* resolved into localStorage is a value nobody chose), drop the memory value, and on the URL lane
|
|
410
|
+
* navigate back to the fallback params through the last render of `use()`.
|
|
411
|
+
*/
|
|
412
|
+
const clearFor = (entry: StoreEntry) => (): void => {
|
|
413
|
+
if (entry.codec.lane.persist) core.clearField(entry.name)
|
|
414
|
+
else if (!entry.codec.lane.url) core.clearMemory(entry.name)
|
|
415
|
+
if (!entry.codec.lane.url) return
|
|
416
|
+
const clearUrl = urlClear.get(entry.name)
|
|
417
|
+
if (clearUrl !== undefined) clearUrl()
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const field = {} as { [K in keyof S]: FieldHandle<S[K]> }
|
|
421
|
+
for (const entry of core.entries) {
|
|
422
|
+
const use = entry.codec.lane.url
|
|
423
|
+
? urlUse(entry)
|
|
424
|
+
: entry.codec.lane.persist
|
|
425
|
+
? localUse(entry)
|
|
426
|
+
: core.memoryUse(entry)
|
|
427
|
+
field[entry.name as keyof S] = core.handle(
|
|
428
|
+
entry,
|
|
429
|
+
use,
|
|
430
|
+
clearFor(entry),
|
|
431
|
+
) as unknown as FieldHandle<S[keyof S]>
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const useValues = (): SearchValues<S> => {
|
|
435
|
+
const search = useCurrentSearch()
|
|
436
|
+
const [record] = core.usePersistedRecord()
|
|
437
|
+
markReaderWired()
|
|
438
|
+
return flatten(search, record) as SearchValues<S>
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const useActiveCount = (): number => {
|
|
442
|
+
const search = useCurrentSearch()
|
|
443
|
+
const [record] = core.usePersistedRecord()
|
|
444
|
+
// A memory field is as much a filter as any other, so the `Filters (n)` pill has to count it —
|
|
445
|
+
// which means subscribing to the lane, or the count would go stale the moment one is written.
|
|
446
|
+
core.useMemoryVersion()
|
|
447
|
+
let count = 0
|
|
448
|
+
for (const entry of core.entries) {
|
|
449
|
+
const value = isMemoryLane(entry)
|
|
450
|
+
? core.readMemoryValue(entry)
|
|
451
|
+
: core.resolve(entry, entry.codec.lane.url ? search : null, record)
|
|
452
|
+
if (!entry.codec.equals(value, entry.codec.fallback)) count += 1
|
|
453
|
+
}
|
|
454
|
+
return count
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const useReset = (): (() => void) => {
|
|
458
|
+
const search = useCurrentSearch()
|
|
459
|
+
const navigate = useNavigate()
|
|
460
|
+
const [, setRecord] = core.usePersistedRecord()
|
|
461
|
+
|
|
462
|
+
return () => {
|
|
463
|
+
// Same "does this route own the field" gate as `urlUse` — only reset the params a foreign
|
|
464
|
+
// route's `validateSearch` actually validates, or the reset navigates there and pollutes it.
|
|
465
|
+
const validatedEntries = core.urlEntries.filter((entry) =>
|
|
466
|
+
Object.hasOwn(search, entry.codec.primary),
|
|
467
|
+
)
|
|
468
|
+
if (validatedEntries.length > 0) {
|
|
469
|
+
const patch: Record<string, unknown> = {}
|
|
470
|
+
for (const entry of validatedEntries) {
|
|
471
|
+
Object.assign(patch, entry.codec.toSearch(entry.codec.fallback))
|
|
472
|
+
}
|
|
473
|
+
navigate({
|
|
474
|
+
to: '.',
|
|
475
|
+
search: (prev: Record<string, unknown>) => ({ ...prev, ...patch }),
|
|
476
|
+
replace: true,
|
|
477
|
+
// Same reason as a single field's write above — a `Reset all` pressed from the mobile
|
|
478
|
+
// sheet must not also scroll the page it was pressed on.
|
|
479
|
+
resetScroll: false,
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
if (memoryEntries.length > 0) core.resetMemory()
|
|
483
|
+
if (!core.anyPersisted) return
|
|
484
|
+
// Reset means UNSET, not "write the fallback": `codec.fallback` is the RESOLVED value, so a
|
|
485
|
+
// thunk (`() => todayIso()`) would be pinned into the mirror by the very act of resetting and
|
|
486
|
+
// read back tomorrow as a choice nobody made. Deleting the key lets `resolve` fall through to
|
|
487
|
+
// the live fallback — which is what `resetMemory` above has always done for the memory lane.
|
|
488
|
+
const record = core.readRecord()
|
|
489
|
+
const next: Record<string, unknown> = {}
|
|
490
|
+
let dropped = false
|
|
491
|
+
for (const [name, value] of Object.entries(record)) {
|
|
492
|
+
if (persistedNames.has(name)) {
|
|
493
|
+
dropped = true
|
|
494
|
+
continue
|
|
495
|
+
}
|
|
496
|
+
next[name] = value
|
|
497
|
+
}
|
|
498
|
+
if (dropped) setRecord(next)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const store: InternalSearchStore<S> = {
|
|
503
|
+
validateSearch,
|
|
504
|
+
linkSearch,
|
|
505
|
+
readStored,
|
|
506
|
+
field,
|
|
507
|
+
labels: (map) => {
|
|
508
|
+
core.setLabels(map as Record<string, Record<string, string> | undefined>)
|
|
509
|
+
return store
|
|
510
|
+
},
|
|
511
|
+
useValues,
|
|
512
|
+
useActiveCount,
|
|
513
|
+
useReset,
|
|
514
|
+
markReaderWired,
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return store
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* One store per page (or per feature), over typed fields. `key` is the localStorage key; each
|
|
522
|
+
* field's map key is its default URL param name.
|
|
523
|
+
*
|
|
524
|
+
* @example
|
|
525
|
+
* export const analytics = createSearchStore({
|
|
526
|
+
* key: 'analytics',
|
|
527
|
+
* fields: {
|
|
528
|
+
* range: field.range({ presets: ['7d', '30d', '90d'], fallback: '30d', custom: true }),
|
|
529
|
+
* compare: field.enum(['none', 'previous'], 'none'),
|
|
530
|
+
* channels: field.multi(CHANNELS, []),
|
|
531
|
+
* },
|
|
532
|
+
* }).labels({ range: { '7d': 'Last 7 days', '30d': 'Last 30 days', '90d': 'Last 90 days' } })
|
|
533
|
+
*
|
|
534
|
+
* export const Route = createFileRoute('/analytics')({
|
|
535
|
+
* validateSearch: analytics.validateSearch,
|
|
536
|
+
* loaderDeps: ({ search }) => search,
|
|
537
|
+
* })
|
|
538
|
+
*
|
|
539
|
+
* // in the page: <RangeFilter field={analytics.field.range} />
|
|
540
|
+
* // in the nav: linkOptions({ to: '/analytics', search: analytics.linkSearch })
|
|
541
|
+
*/
|
|
542
|
+
export function createSearchStore<const S extends Record<string, AnyField>>(
|
|
543
|
+
o: CreateSearchStoreOptions<S>,
|
|
544
|
+
): SearchStore<S> {
|
|
545
|
+
return buildSearchStore(o)
|
|
546
|
+
}
|