basalt-ui 1.25.0 → 1.26.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 +18 -0
- package/MIGRATING.md +546 -20
- 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 +155 -0
- package/agent/rules/basalt-mantine.md +146 -439
- package/agent/rules/basalt-state.md +113 -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 +1275 -31
- package/configs/oxlint-plugin.test.ts +947 -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 +30 -0
- package/dist/controls/enum-filter.d.ts.map +1 -0
- package/dist/controls/enum-filter.js +91 -0
- package/dist/controls/filter-context.d.ts +55 -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 +19 -0
- package/dist/controls/index.d.ts.map +1 -0
- package/dist/controls/index.js +44 -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/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 +29 -0
- package/dist/controls/range-filter.d.ts.map +1 -0
- package/dist/controls/range-filter.js +120 -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/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 +44 -9
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +80 -48
- package/dist/dashboard/stat-card.module.css +82 -0
- package/dist/dashboard/stat-card.module.css.d.ts +11 -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 +88 -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 +82 -7
- package/dist/guard/types.d.ts +3 -3
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/index.d.ts +12 -2
- 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 +81 -0
- package/dist/router-tanstack/search-store.d.ts.map +1 -0
- package/dist/router-tanstack/search-store.js +187 -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 +392 -0
- package/dist/state/fields.d.ts.map +1 -0
- package/dist/state/fields.js +369 -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 +48 -0
- package/dist/state/persisted.d.ts.map +1 -0
- package/dist/state/persisted.js +105 -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 +155 -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 +37 -0
- package/dist/widget-header/widget-header.d.ts.map +1 -0
- package/dist/widget-header/widget-header.js +77 -0
- package/dist/widget-header/widget-header.module.css +335 -0
- package/dist/widget-header/widget-header.module.css.d.ts +24 -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 +637 -0
- package/src/controls/enum-filter.tsx +159 -0
- package/src/controls/filter-context.tsx +153 -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 +48 -0
- package/src/controls/multi-select-filter.tsx +140 -0
- package/src/controls/peer-boundary.test.ts +85 -0
- package/src/controls/props-contract.test.tsx +106 -0
- package/src/controls/range-filter.tsx +184 -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/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 +82 -0
- package/src/dashboard/stat-card.module.css.d.ts +11 -0
- package/src/dashboard/stat-card.test.tsx +67 -1
- package/src/dashboard/stat-card.tsx +209 -68
- package/src/data/data-table.test.tsx +96 -11
- package/src/data/data-table.tsx +220 -84
- package/src/guard/check-source.test.ts +126 -13
- package/src/guard/grace.test.ts +149 -0
- package/src/guard/index.ts +172 -29
- package/src/guard/types.ts +4 -2
- package/src/index.ts +46 -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 +31 -0
- package/src/router-tanstack/index.ts +24 -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 +661 -0
- package/src/router-tanstack/search-store.test.ts +418 -0
- package/src/router-tanstack/search-store.ts +380 -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 +258 -0
- package/src/state/fields.ts +892 -0
- package/src/state/persisted.ts +211 -0
- package/src/state.ts +46 -240
- package/src/styles.css +13 -0
- package/src/surfaces.ts +203 -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 +335 -0
- package/src/widget-header/widget-header.module.css.d.ts +24 -0
- package/src/widget-header/widget-header.test.tsx +187 -0
- package/src/widget-header/widget-header.tsx +168 -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,380 @@
|
|
|
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 { AnyField, FieldHandle, SearchValues, StoreEntry, StoredValues } from '../state'
|
|
35
|
+
|
|
36
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export type CreateSearchStoreOptions<S extends Record<string, AnyField>> = {
|
|
39
|
+
/** localStorage key (namespaced `basalt:<key>` automatically). One entry per store. */
|
|
40
|
+
key: string
|
|
41
|
+
/** The fields, built with `field.*`. The key of each is its default URL param name. */
|
|
42
|
+
fields: S
|
|
43
|
+
/** Envelope version — bump when the field set changes to discard stale localStorage. */
|
|
44
|
+
version?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type SearchStore<S extends Record<string, AnyField>> = {
|
|
48
|
+
/**
|
|
49
|
+
* Pass directly to `createFileRoute({ validateSearch })`. Returns every URL-lane param, resolved
|
|
50
|
+
* URL ⊳ localStorage ⊳ fallback — a route wired to it therefore always has the params, which is
|
|
51
|
+
* what makes `use()`'s "does this route own the field" check trivial and exact.
|
|
52
|
+
*/
|
|
53
|
+
validateSearch: (raw: Record<string, unknown>) => SearchValues<S>
|
|
54
|
+
/**
|
|
55
|
+
* The click-time `search:` value for a nav link. Pass it BY REFERENCE
|
|
56
|
+
* (`search: store.linkSearch`) inside `defineNav` — never a literal object, which pins the
|
|
57
|
+
* fallback on every click and is why one consumer's reader had zero call sites (C10, A2).
|
|
58
|
+
*/
|
|
59
|
+
linkSearch: () => SearchValues<S>
|
|
60
|
+
/** Plain read for non-React contexts (a redirect, a loader, a guard, a test). */
|
|
61
|
+
readStored: () => Partial<StoredValues<S>>
|
|
62
|
+
/** The per-field handles every basalt control takes instead of `value`/`onChange` (C2). */
|
|
63
|
+
field: { [K in keyof S]: FieldHandle<S[K]> }
|
|
64
|
+
/** Option labels for enum / multi / range fields. Chainable — call it once, at definition. */
|
|
65
|
+
labels: (map: Partial<{ [K in keyof S]: Record<string, string> }>) => SearchStore<S>
|
|
66
|
+
/** Every URL-lane param, resolved — the object a page hands to its query. */
|
|
67
|
+
useValues: () => SearchValues<S>
|
|
68
|
+
/** How many fields differ from their fallback — the `n` in a `Filters (n)` pill. */
|
|
69
|
+
useActiveCount: () => number
|
|
70
|
+
/** Resets every field to its fallback: one navigate, one storage write. */
|
|
71
|
+
useReset: () => () => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Dev-only, once per store: the URL is pinning `fallback` while something else is persisted, and
|
|
76
|
+
* neither reader has ever been called. That combination has exactly one realistic cause — a nav
|
|
77
|
+
* link declaring the param as a literal — and it is invisible otherwise, because every individual
|
|
78
|
+
* piece looks correct and the feature merely never remembers anything.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately NOT a timer over "was this store ever read": that fires in every test that builds a
|
|
81
|
+
* store, and it cannot say what to do about it. This fires only in the broken state, and names the
|
|
82
|
+
* fix.
|
|
83
|
+
*/
|
|
84
|
+
function warnLinkPinsFallback(input: {
|
|
85
|
+
key: string
|
|
86
|
+
param: string
|
|
87
|
+
urlValue: string
|
|
88
|
+
storedValue: string
|
|
89
|
+
}): void {
|
|
90
|
+
const { key, param, urlValue, storedValue } = input
|
|
91
|
+
// oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
|
|
92
|
+
console.warn(
|
|
93
|
+
`[basalt-ui] createSearchStore('${key}'): the URL pinned \`${param}=${urlValue}\` ` +
|
|
94
|
+
'(the fallback) ' +
|
|
95
|
+
`while '${storedValue}' was persisted, and nothing has read this store back. A nav link is ` +
|
|
96
|
+
`almost certainly declaring \`search: { ${param}: '${urlValue}' }\` at module scope, which ` +
|
|
97
|
+
"overrides the stored value on every click — pass the store's own reader instead: " +
|
|
98
|
+
'`search: <store>.linkSearch`. (dev only)',
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Implementation ─────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/** Reads the merged search of every matched route, so a child or sibling needs no `from` (A3). */
|
|
105
|
+
function useCurrentSearch(): Record<string, unknown> {
|
|
106
|
+
const search = useSearch({ strict: false }) as unknown
|
|
107
|
+
return typeof search === 'object' && search !== null ? (search as Record<string, unknown>) : {}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function describeValue(value: unknown): string {
|
|
111
|
+
return typeof value === 'string' ? value : JSON.stringify(value)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Dev-only, once per field: a `persist: false` field was written from a route that does not
|
|
116
|
+
* validate it, so the write had nowhere to go — no URL (the route owns no such param) and no mirror
|
|
117
|
+
* (the field opted out of one). Silence is the wrong answer here: every other lane combination does
|
|
118
|
+
* something, so a control that appears to work and changes nothing reads as a basalt bug.
|
|
119
|
+
*/
|
|
120
|
+
function warnWriteHasNowhereToGo(input: { key: string; name: string; param: string }): void {
|
|
121
|
+
const { key, name, param } = input
|
|
122
|
+
// oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
|
|
123
|
+
console.warn(
|
|
124
|
+
`[basalt-ui] createSearchStore('${key}'): wrote field '${name}' from a route that does not ` +
|
|
125
|
+
`validate \`${param}\`, and the field is \`persist: false\` — so the write was dropped. ` +
|
|
126
|
+
'Either render this control on the route wired to `validateSearch`, or give the field the ' +
|
|
127
|
+
'localStorage lane (drop `persist: false`) so a write from elsewhere survives until the ' +
|
|
128
|
+
'owning route reads it. (dev only)',
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The public store plus the one member only the deprecated wrappers need: `markReaderWired`, so a
|
|
134
|
+
* consumer whose only reader is the wrapper's own `useStore()` still counts as wired and never sees
|
|
135
|
+
* the pinned-link warning it never used to see.
|
|
136
|
+
*
|
|
137
|
+
* @internal — absent from `SearchStore`, so it is invisible on `createSearchStore`'s return type.
|
|
138
|
+
*/
|
|
139
|
+
export type InternalSearchStore<S extends Record<string, AnyField>> = SearchStore<S> & {
|
|
140
|
+
markReaderWired: () => void
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The memory-only lane: neither the URL nor the mirror, so the value lives in the store's own
|
|
144
|
+
* session-scoped external store (`StoreCore.memoryUse`) — the lane `createLocalStore` also uses. */
|
|
145
|
+
function isMemoryLane(entry: StoreEntry): boolean {
|
|
146
|
+
return !entry.codec.lane.url && !entry.codec.lane.persist
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The store, plus the one knob the deprecated wrappers need: `legacyValueField` keeps the
|
|
151
|
+
* enum-only stores' single-value storage layout so an already-persisted selection still resolves.
|
|
152
|
+
* Not exported from the barrel — `createSearchStore` is the public door.
|
|
153
|
+
*/
|
|
154
|
+
export function buildSearchStore<const S extends Record<string, AnyField>>(
|
|
155
|
+
o: CreateSearchStoreOptions<S> & { legacyValueField?: string },
|
|
156
|
+
): InternalSearchStore<S> {
|
|
157
|
+
const core = createStoreCore(o)
|
|
158
|
+
|
|
159
|
+
// `validateSearch` reads storage on every navigation, which says nothing about whether the
|
|
160
|
+
// CONSUMER wired a reader. Everything a consumer can read THROUGH sets this: `linkSearch`,
|
|
161
|
+
// `readStored`, and `field.<name>.use()` — the documented reader, and the one a page that never
|
|
162
|
+
// touches the other two uses exclusively. Missing it there made the pinned-link warning fire on
|
|
163
|
+
// correctly wired pages.
|
|
164
|
+
let readerWired = false
|
|
165
|
+
let warned = false
|
|
166
|
+
const markReaderWired = (): void => {
|
|
167
|
+
readerWired = true
|
|
168
|
+
}
|
|
169
|
+
const nowhereWarned = new Set<string>()
|
|
170
|
+
|
|
171
|
+
const flatten = (
|
|
172
|
+
search: Record<string, unknown> | null,
|
|
173
|
+
record: Record<string, unknown>,
|
|
174
|
+
): Record<string, unknown> => {
|
|
175
|
+
const out: Record<string, unknown> = {}
|
|
176
|
+
for (const entry of core.urlEntries) {
|
|
177
|
+
Object.assign(out, entry.codec.toSearch(core.resolve(entry, search, record)))
|
|
178
|
+
}
|
|
179
|
+
return out
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const maybeWarn = (raw: Record<string, unknown>, record: Record<string, unknown>): void => {
|
|
183
|
+
if (warned || readerWired || process.env['NODE_ENV'] === 'production') return
|
|
184
|
+
for (const entry of core.urlEntries) {
|
|
185
|
+
const fromUrl = entry.codec.fromSearch(raw)
|
|
186
|
+
if (fromUrl === null || !entry.codec.equals(fromUrl, entry.codec.fallback)) continue
|
|
187
|
+
const stored = entry.codec.decode(record[entry.name])
|
|
188
|
+
if (stored === null || entry.codec.equals(stored, fromUrl)) continue
|
|
189
|
+
// Describe both sides as the URL spells them (a range prints `30d`, not its whole value
|
|
190
|
+
// object) — the message quotes a `search:` literal, so it has to be URL-shaped to be advice.
|
|
191
|
+
warned = true
|
|
192
|
+
warnLinkPinsFallback({
|
|
193
|
+
key: o.key,
|
|
194
|
+
param: entry.codec.primary,
|
|
195
|
+
urlValue: describeValue(entry.codec.toSearch(fromUrl)[entry.codec.primary]),
|
|
196
|
+
storedValue: describeValue(entry.codec.toSearch(stored)[entry.codec.primary]),
|
|
197
|
+
})
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const validateSearch = (raw: Record<string, unknown>): SearchValues<S> => {
|
|
203
|
+
const record = core.readRecord()
|
|
204
|
+
maybeWarn(raw, record)
|
|
205
|
+
return flatten(raw, record) as SearchValues<S>
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const linkSearch = (): SearchValues<S> => {
|
|
209
|
+
readerWired = true
|
|
210
|
+
return flatten(null, core.readRecord()) as SearchValues<S>
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const readStored = (): Partial<StoredValues<S>> => {
|
|
214
|
+
readerWired = true
|
|
215
|
+
return core.readStoredFlat() as Partial<StoredValues<S>>
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const urlUse = (entry: StoreEntry) => (): readonly [unknown, (next: unknown) => void] => {
|
|
219
|
+
const search = useCurrentSearch()
|
|
220
|
+
const navigate = useNavigate()
|
|
221
|
+
const [record, setRecord] = core.usePersistedRecord()
|
|
222
|
+
markReaderWired()
|
|
223
|
+
const value = core.resolve(entry, search, record)
|
|
224
|
+
// The matched route validates this field iff its primary param is present (see the module
|
|
225
|
+
// header). A foreign route gets the persist-only lane.
|
|
226
|
+
const validated = Object.hasOwn(search, entry.codec.primary)
|
|
227
|
+
|
|
228
|
+
return [
|
|
229
|
+
value,
|
|
230
|
+
(next: unknown) => {
|
|
231
|
+
if (validated) {
|
|
232
|
+
navigate({
|
|
233
|
+
to: '.',
|
|
234
|
+
search: (prev: Record<string, unknown>) => ({ ...prev, ...entry.codec.toSearch(next) }),
|
|
235
|
+
replace: entry.codec.lane.history === 'replace',
|
|
236
|
+
// A filter lives halfway down a page as often as it lives in the bar, and the router's
|
|
237
|
+
// default scroll restoration treats this same-route search write as a navigation — so
|
|
238
|
+
// without this a select two screens down jumps the reader back to the top on every
|
|
239
|
+
// change. Never a prop: there is no filter write that WANTS the page to scroll.
|
|
240
|
+
resetScroll: false,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
if (entry.codec.lane.persist) {
|
|
244
|
+
core.writeField(setRecord, entry.name, next)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
if (validated || nowhereWarned.has(entry.name)) return
|
|
248
|
+
if (process.env['NODE_ENV'] === 'production') return
|
|
249
|
+
nowhereWarned.add(entry.name)
|
|
250
|
+
warnWriteHasNowhereToGo({ key: o.key, name: entry.name, param: entry.codec.primary })
|
|
251
|
+
},
|
|
252
|
+
] as const
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const localUse = (entry: StoreEntry) => (): readonly [unknown, (next: unknown) => void] => {
|
|
256
|
+
const [record, setRecord] = core.usePersistedRecord()
|
|
257
|
+
markReaderWired()
|
|
258
|
+
return [
|
|
259
|
+
core.resolve(entry, null, record),
|
|
260
|
+
(next: unknown) => core.writeField(setRecord, entry.name, next),
|
|
261
|
+
] as const
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Three lanes, decided once per field at definition: URL (+ optional mirror), mirror-only, and
|
|
265
|
+
// memory-only. The third used to be the one combination that dropped its write.
|
|
266
|
+
const memoryEntries = core.entries.filter(isMemoryLane)
|
|
267
|
+
|
|
268
|
+
const field = {} as { [K in keyof S]: FieldHandle<S[K]> }
|
|
269
|
+
for (const entry of core.entries) {
|
|
270
|
+
const use = entry.codec.lane.url
|
|
271
|
+
? urlUse(entry)
|
|
272
|
+
: entry.codec.lane.persist
|
|
273
|
+
? localUse(entry)
|
|
274
|
+
: core.memoryUse(entry)
|
|
275
|
+
field[entry.name as keyof S] = core.handle(entry, use) as unknown as FieldHandle<S[keyof S]>
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const useValues = (): SearchValues<S> => {
|
|
279
|
+
const search = useCurrentSearch()
|
|
280
|
+
const [record] = core.usePersistedRecord()
|
|
281
|
+
markReaderWired()
|
|
282
|
+
return flatten(search, record) as SearchValues<S>
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const useActiveCount = (): number => {
|
|
286
|
+
const search = useCurrentSearch()
|
|
287
|
+
const [record] = core.usePersistedRecord()
|
|
288
|
+
// A memory field is as much a filter as any other, so the `Filters (n)` pill has to count it —
|
|
289
|
+
// which means subscribing to the lane, or the count would go stale the moment one is written.
|
|
290
|
+
core.useMemoryVersion()
|
|
291
|
+
let count = 0
|
|
292
|
+
for (const entry of core.entries) {
|
|
293
|
+
const value = isMemoryLane(entry)
|
|
294
|
+
? core.readMemoryValue(entry)
|
|
295
|
+
: core.resolve(entry, entry.codec.lane.url ? search : null, record)
|
|
296
|
+
if (!entry.codec.equals(value, entry.codec.fallback)) count += 1
|
|
297
|
+
}
|
|
298
|
+
return count
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const useReset = (): (() => void) => {
|
|
302
|
+
const search = useCurrentSearch()
|
|
303
|
+
const navigate = useNavigate()
|
|
304
|
+
const [, setRecord] = core.usePersistedRecord()
|
|
305
|
+
|
|
306
|
+
return () => {
|
|
307
|
+
// Same "does this route own the field" gate as `urlUse` — only reset the params a foreign
|
|
308
|
+
// route's `validateSearch` actually validates, or the reset navigates there and pollutes it.
|
|
309
|
+
const validatedEntries = core.urlEntries.filter((entry) =>
|
|
310
|
+
Object.hasOwn(search, entry.codec.primary),
|
|
311
|
+
)
|
|
312
|
+
if (validatedEntries.length > 0) {
|
|
313
|
+
const patch: Record<string, unknown> = {}
|
|
314
|
+
for (const entry of validatedEntries) {
|
|
315
|
+
Object.assign(patch, entry.codec.toSearch(entry.codec.fallback))
|
|
316
|
+
}
|
|
317
|
+
navigate({
|
|
318
|
+
to: '.',
|
|
319
|
+
search: (prev: Record<string, unknown>) => ({ ...prev, ...patch }),
|
|
320
|
+
replace: true,
|
|
321
|
+
// Same reason as a single field's write above — a `Reset all` pressed from the mobile
|
|
322
|
+
// sheet must not also scroll the page it was pressed on.
|
|
323
|
+
resetScroll: false,
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
if (memoryEntries.length > 0) core.resetMemory()
|
|
327
|
+
if (!core.anyPersisted) return
|
|
328
|
+
const next = { ...core.readRecord() }
|
|
329
|
+
for (const entry of core.entries) {
|
|
330
|
+
if (entry.codec.lane.persist) next[entry.name] = entry.codec.fallback
|
|
331
|
+
}
|
|
332
|
+
setRecord(next)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const store: InternalSearchStore<S> = {
|
|
337
|
+
validateSearch,
|
|
338
|
+
linkSearch,
|
|
339
|
+
readStored,
|
|
340
|
+
field,
|
|
341
|
+
labels: (map) => {
|
|
342
|
+
core.setLabels(map as Record<string, Record<string, string> | undefined>)
|
|
343
|
+
return store
|
|
344
|
+
},
|
|
345
|
+
useValues,
|
|
346
|
+
useActiveCount,
|
|
347
|
+
useReset,
|
|
348
|
+
markReaderWired,
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return store
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* One store per page (or per feature), over typed fields. `key` is the localStorage key; each
|
|
356
|
+
* field's map key is its default URL param name.
|
|
357
|
+
*
|
|
358
|
+
* @example
|
|
359
|
+
* export const analytics = createSearchStore({
|
|
360
|
+
* key: 'analytics',
|
|
361
|
+
* fields: {
|
|
362
|
+
* range: field.range({ presets: ['7d', '30d', '90d'], fallback: '30d', custom: true }),
|
|
363
|
+
* compare: field.enum(['none', 'previous'], 'none'),
|
|
364
|
+
* channels: field.multi(CHANNELS, []),
|
|
365
|
+
* },
|
|
366
|
+
* }).labels({ range: { '7d': 'Last 7 days', '30d': 'Last 30 days', '90d': 'Last 90 days' } })
|
|
367
|
+
*
|
|
368
|
+
* export const Route = createFileRoute('/analytics')({
|
|
369
|
+
* validateSearch: analytics.validateSearch,
|
|
370
|
+
* loaderDeps: ({ search }) => search,
|
|
371
|
+
* })
|
|
372
|
+
*
|
|
373
|
+
* // in the page: <RangeFilter field={analytics.field.range} />
|
|
374
|
+
* // in the nav: linkOptions({ to: '/analytics', search: analytics.linkSearch })
|
|
375
|
+
*/
|
|
376
|
+
export function createSearchStore<const S extends Record<string, AnyField>>(
|
|
377
|
+
o: CreateSearchStoreOptions<S>,
|
|
378
|
+
): SearchStore<S> {
|
|
379
|
+
return buildSearchStore(o)
|
|
380
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useNav`'s active-resolution pass, under a REAL TanStack memory router — the half that cannot be
|
|
3
|
+
* tested headlessly (`matchRoute`/`useRouterState` both need a live router). The fixture below
|
|
4
|
+
* mirrors every collision shape the playground's own `demo/nav-model.tsx` actually has: a root `/`
|
|
5
|
+
* item, a `/dashboard` parent with three children, flat siblings, a same-path duplicate
|
|
6
|
+
* (`reports`/`components`, mirroring the playground's disabled-placeholder shape), and a
|
|
7
|
+
* search-bearing link (mirroring `dashboardFilters.linkSearch`-style destinations).
|
|
8
|
+
*
|
|
9
|
+
* The harness is modeled on `controls/controls.router.test.tsx`'s `mountPage`, narrowed to a
|
|
10
|
+
* headless probe: `useNav` renders no JSX itself (`./router-tanstack` is a no-JSX barrel), so the
|
|
11
|
+
* probe component only reads its return value into a sink — no `MantineProvider` needed.
|
|
12
|
+
*/
|
|
13
|
+
import { cleanup, render, waitFor } from '@testing-library/react'
|
|
14
|
+
import { describe, expect, test } from 'bun:test'
|
|
15
|
+
import type { ReactNode } from 'react'
|
|
16
|
+
import {
|
|
17
|
+
Outlet,
|
|
18
|
+
RouterProvider,
|
|
19
|
+
createMemoryHistory,
|
|
20
|
+
createRootRoute,
|
|
21
|
+
createRoute,
|
|
22
|
+
createRouter,
|
|
23
|
+
} from '@tanstack/react-router'
|
|
24
|
+
import type { SidebarItem, SidebarSection } from '../nav/types'
|
|
25
|
+
import type { AnyNavItem } from './nav'
|
|
26
|
+
import { defineNav, navGroup } from './nav'
|
|
27
|
+
import { useNav } from './use-nav'
|
|
28
|
+
|
|
29
|
+
const NAV = defineNav({
|
|
30
|
+
groups: [
|
|
31
|
+
navGroup({ id: 'main', label: 'Main' }, [
|
|
32
|
+
{ id: 'home', label: 'Home', link: { to: '/' } },
|
|
33
|
+
{
|
|
34
|
+
id: 'dashboard',
|
|
35
|
+
label: 'Dashboard',
|
|
36
|
+
// A store-backed destination (law C10's shape) — `search` rides the link like every
|
|
37
|
+
// other field; `matchOf` below never reads it, so it must not affect matching either.
|
|
38
|
+
link: { to: '/dashboard', search: { window: '30d' } },
|
|
39
|
+
children: [
|
|
40
|
+
{ id: 'dashboard-sessions', label: 'Sessions', link: { to: '/dashboard/sessions' } },
|
|
41
|
+
{ id: 'dashboard-traffic', label: 'Traffic', link: { to: '/dashboard/traffic' } },
|
|
42
|
+
{ id: 'dashboard-revenue', label: 'Revenue', link: { to: '/dashboard/revenue' } },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{ id: 'mobile-nav-pill', label: 'Mobile nav pill', link: { to: '/mobile-nav-pill' } },
|
|
46
|
+
{
|
|
47
|
+
id: 'controls-mobile',
|
|
48
|
+
label: 'Controls (mobile)',
|
|
49
|
+
link: { to: '/controls-mobile' },
|
|
50
|
+
},
|
|
51
|
+
{ id: 'components', label: 'Components', link: { to: '/components' } },
|
|
52
|
+
{ id: 'charts-page', label: 'Charts', link: { to: '/charts' } },
|
|
53
|
+
// The playground's `reports` shape: a disabled placeholder that deliberately borrows an
|
|
54
|
+
// already-covered route rather than inventing one. Declared AFTER `components`, so the
|
|
55
|
+
// definition-order tie-break keeps `components` the winner at `/components`.
|
|
56
|
+
{ id: 'reports', label: 'Reports', disabled: true, link: { to: '/components' } },
|
|
57
|
+
]),
|
|
58
|
+
],
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const ROUTE_PATHS = [
|
|
62
|
+
'/',
|
|
63
|
+
'/dashboard',
|
|
64
|
+
'/dashboard/sessions',
|
|
65
|
+
'/dashboard/traffic',
|
|
66
|
+
'/dashboard/revenue',
|
|
67
|
+
'/mobile-nav-pill',
|
|
68
|
+
'/controls-mobile',
|
|
69
|
+
'/components',
|
|
70
|
+
'/charts',
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
type NavProbeOpts = { isActive?: (item: AnyNavItem) => boolean }
|
|
74
|
+
type NavResult = { sections: SidebarSection[] }
|
|
75
|
+
type Sink = { current: NavResult | null }
|
|
76
|
+
|
|
77
|
+
/** Depth-first flatten of the rendered `SidebarSection[]` — the same shape every downstream reader
|
|
78
|
+
* (the sidebar, the mobile bar, the breadcrumb) walks. */
|
|
79
|
+
function flattenItems(sections: SidebarSection[]): SidebarItem[] {
|
|
80
|
+
const out: SidebarItem[] = []
|
|
81
|
+
const walk = (items: SidebarItem[]): void => {
|
|
82
|
+
for (const item of items) {
|
|
83
|
+
out.push(item)
|
|
84
|
+
if (item.children) walk(item.children)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const section of sections) walk(section.items)
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function mountNav(entry: string, opts?: NavProbeOpts): Promise<Sink> {
|
|
92
|
+
const sink: Sink = { current: null }
|
|
93
|
+
|
|
94
|
+
function NavProbe(): ReactNode {
|
|
95
|
+
const nav = useNav(NAV, opts)
|
|
96
|
+
sink.current = { sections: nav.sections }
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const rootRoute = createRootRoute({
|
|
101
|
+
component: () => (
|
|
102
|
+
<>
|
|
103
|
+
<NavProbe />
|
|
104
|
+
<Outlet />
|
|
105
|
+
</>
|
|
106
|
+
),
|
|
107
|
+
})
|
|
108
|
+
const children = ROUTE_PATHS.map((path) =>
|
|
109
|
+
createRoute({ getParentRoute: () => rootRoute, path, component: () => null }),
|
|
110
|
+
)
|
|
111
|
+
const router = createRouter({
|
|
112
|
+
routeTree: rootRoute.addChildren(children),
|
|
113
|
+
history: createMemoryHistory({ initialEntries: [entry] }),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
render(<RouterProvider router={router} />)
|
|
117
|
+
await waitFor(() => {
|
|
118
|
+
expect(router.state.status).toBe('idle')
|
|
119
|
+
})
|
|
120
|
+
return sink
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
describe('useNav — exclusivity', () => {
|
|
124
|
+
test('exactly one row is active at every route in the definition', async () => {
|
|
125
|
+
for (const path of ROUTE_PATHS) {
|
|
126
|
+
const sink = await mountNav(path)
|
|
127
|
+
const items = flattenItems(sink.current!.sections)
|
|
128
|
+
const activeCount = items.filter((item) => item.active).length
|
|
129
|
+
expect(activeCount).toBe(1)
|
|
130
|
+
cleanup()
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('the deepest match wins — a child route does not also light its parent', async () => {
|
|
135
|
+
const sink = await mountNav('/dashboard/sessions')
|
|
136
|
+
const items = flattenItems(sink.current!.sections)
|
|
137
|
+
const dashboard = items.find((item) => item.key === 'dashboard')
|
|
138
|
+
const sessions = items.find((item) => item.key === 'dashboard-sessions')
|
|
139
|
+
expect(dashboard?.active).toBe(false)
|
|
140
|
+
expect(sessions?.active).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('the parent of the active row is an ancestor, not active', async () => {
|
|
144
|
+
const sink = await mountNav('/dashboard/sessions')
|
|
145
|
+
const items = flattenItems(sink.current!.sections)
|
|
146
|
+
const dashboard = items.find((item) => item.key === 'dashboard')
|
|
147
|
+
expect(dashboard?.ancestor).toBe(true)
|
|
148
|
+
expect(dashboard?.active).toBe(false)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('a same-path duplicate destination is neither active nor an ancestor', async () => {
|
|
152
|
+
const sink = await mountNav('/components')
|
|
153
|
+
const items = flattenItems(sink.current!.sections)
|
|
154
|
+
const components = items.find((item) => item.key === 'components')
|
|
155
|
+
const reports = items.find((item) => item.key === 'reports')
|
|
156
|
+
expect(components?.active).toBe(true)
|
|
157
|
+
expect(reports?.active).toBe(false)
|
|
158
|
+
expect(reports?.ancestor).toBeFalsy()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test("'/' stays exact — the root never lights on a nested route", async () => {
|
|
162
|
+
const sink = await mountNav('/dashboard')
|
|
163
|
+
const items = flattenItems(sink.current!.sections)
|
|
164
|
+
const home = items.find((item) => item.key === 'home')
|
|
165
|
+
expect(home?.active).toBe(false)
|
|
166
|
+
expect(home?.ancestor).toBeFalsy()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('an item whose exact route is never visited still lights on a descendant route', async () => {
|
|
170
|
+
// `/dashboard` itself is never the visited entry in this test — only its descendant is —
|
|
171
|
+
// proving the fix kept `fuzzy: true` as the default rather than flipping every item to exact.
|
|
172
|
+
const sink = await mountNav('/dashboard/traffic')
|
|
173
|
+
const items = flattenItems(sink.current!.sections)
|
|
174
|
+
const dashboard = items.find((item) => item.key === 'dashboard')
|
|
175
|
+
expect(dashboard?.ancestor).toBe(true)
|
|
176
|
+
expect(dashboard?.active).toBe(false)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('an isActive override cannot light two rows either', async () => {
|
|
180
|
+
const sink = await mountNav('/', {
|
|
181
|
+
isActive: (item) => item.id === 'components' || item.id === 'charts-page',
|
|
182
|
+
})
|
|
183
|
+
const items = flattenItems(sink.current!.sections)
|
|
184
|
+
const activeCount = items.filter((item) => item.active).length
|
|
185
|
+
expect(activeCount).toBe(1)
|
|
186
|
+
})
|
|
187
|
+
})
|