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,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createSearchStore` under a REAL router — the half that cannot be tested headlessly: what
|
|
3
|
+
* `use()` reads on a deep link, whether a write navigates, and from where.
|
|
4
|
+
*
|
|
5
|
+
* A memory-history router with three routes covers the three positions a control can render in:
|
|
6
|
+
* the route that validates the params, a CHILD of it (A3 — no `from`, the param still resolves),
|
|
7
|
+
* and a sibling that knows nothing about the store (A1 — the write persists and does not navigate).
|
|
8
|
+
*/
|
|
9
|
+
import { beforeEach, describe, expect, spyOn, test } from 'bun:test'
|
|
10
|
+
import type { ReactNode } from 'react'
|
|
11
|
+
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
|
12
|
+
import {
|
|
13
|
+
Outlet,
|
|
14
|
+
RouterProvider,
|
|
15
|
+
createMemoryHistory,
|
|
16
|
+
createRootRoute,
|
|
17
|
+
createRoute,
|
|
18
|
+
createRouter,
|
|
19
|
+
} from '@tanstack/react-router'
|
|
20
|
+
import type { AnyField, FieldHandle } from '../state'
|
|
21
|
+
import { field } from './field'
|
|
22
|
+
import { createSearchStore } from './search-store'
|
|
23
|
+
|
|
24
|
+
type Probe = { value: unknown; set: (next: unknown) => void }
|
|
25
|
+
type Sink = { current: Probe | null }
|
|
26
|
+
|
|
27
|
+
function sink(): Sink {
|
|
28
|
+
return { current: null }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Renders nothing; publishes one field's `use()` result so a test can read and drive it. */
|
|
32
|
+
function fieldProbe<F extends AnyField>(handle: FieldHandle<F>, into: Sink) {
|
|
33
|
+
return function FieldProbe(): ReactNode {
|
|
34
|
+
const [value, set] = handle.use()
|
|
35
|
+
into.current = { value, set: set as (next: unknown) => void }
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type AppInput = {
|
|
41
|
+
validateSearch: (raw: Record<string, unknown>) => Record<string, unknown>
|
|
42
|
+
entry: string
|
|
43
|
+
Dashboard?: () => ReactNode
|
|
44
|
+
Detail?: () => ReactNode
|
|
45
|
+
Other?: () => ReactNode
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const nothing = (): ReactNode => null
|
|
49
|
+
|
|
50
|
+
async function mountApp(input: AppInput) {
|
|
51
|
+
const Dashboard = input.Dashboard ?? nothing
|
|
52
|
+
const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
|
53
|
+
const dashboardRoute = createRoute({
|
|
54
|
+
getParentRoute: () => rootRoute,
|
|
55
|
+
path: '/dashboard',
|
|
56
|
+
validateSearch: input.validateSearch,
|
|
57
|
+
component: () => (
|
|
58
|
+
<>
|
|
59
|
+
<Dashboard />
|
|
60
|
+
<Outlet />
|
|
61
|
+
</>
|
|
62
|
+
),
|
|
63
|
+
})
|
|
64
|
+
const detailRoute = createRoute({
|
|
65
|
+
getParentRoute: () => dashboardRoute,
|
|
66
|
+
path: 'detail',
|
|
67
|
+
component: input.Detail ?? nothing,
|
|
68
|
+
})
|
|
69
|
+
const otherRoute = createRoute({
|
|
70
|
+
getParentRoute: () => rootRoute,
|
|
71
|
+
path: '/other',
|
|
72
|
+
component: input.Other ?? nothing,
|
|
73
|
+
})
|
|
74
|
+
const router = createRouter({
|
|
75
|
+
routeTree: rootRoute.addChildren([dashboardRoute.addChildren([detailRoute]), otherRoute]),
|
|
76
|
+
history: createMemoryHistory({ initialEntries: [input.entry] }),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
render(<RouterProvider router={router} />)
|
|
80
|
+
await waitFor(() => {
|
|
81
|
+
expect(router.state.status).toBe('idle')
|
|
82
|
+
})
|
|
83
|
+
return router
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function currentSearch(router: {
|
|
87
|
+
state: { location: { search: unknown } }
|
|
88
|
+
}): Record<string, unknown> {
|
|
89
|
+
return router.state.location.search as Record<string, unknown>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The `{ fieldName: value }` record the store persisted under `basalt:<key>`. */
|
|
93
|
+
function storedRecord(key: string): Record<string, unknown> | null {
|
|
94
|
+
const raw = localStorage.getItem(`basalt:${key}`)
|
|
95
|
+
if (raw === null) return null
|
|
96
|
+
return (JSON.parse(raw) as { value: Record<string, unknown> }).value
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function persist(key: string, record: Record<string, unknown>): void {
|
|
100
|
+
localStorage.setItem(`basalt:${key}`, JSON.stringify({ v: 1, value: record }))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
localStorage.clear()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('use() — reading', () => {
|
|
108
|
+
test('a deep link WINS over the localStorage mirror (A8)', async () => {
|
|
109
|
+
const store = createSearchStore({
|
|
110
|
+
key: 'r-deeplink',
|
|
111
|
+
fields: { range: field.enum(['1d', '7d', '30d'], '1d') },
|
|
112
|
+
})
|
|
113
|
+
persist('r-deeplink', { range: '30d' })
|
|
114
|
+
const probe = sink()
|
|
115
|
+
|
|
116
|
+
await mountApp({
|
|
117
|
+
validateSearch: store.validateSearch,
|
|
118
|
+
entry: '/dashboard?range=7d',
|
|
119
|
+
Dashboard: fieldProbe(store.field.range, probe),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// The enum-only store read its localStorage hook here and rendered '30d' — a shared link
|
|
123
|
+
// opened on the wrong window, and nothing said so.
|
|
124
|
+
expect(probe.current?.value).toBe('7d')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('falls through to the mirror when the URL carries no value', async () => {
|
|
128
|
+
const store = createSearchStore({
|
|
129
|
+
key: 'r-mirror',
|
|
130
|
+
fields: { range: field.enum(['1d', '7d', '30d'], '1d') },
|
|
131
|
+
})
|
|
132
|
+
persist('r-mirror', { range: '30d' })
|
|
133
|
+
const probe = sink()
|
|
134
|
+
|
|
135
|
+
await mountApp({
|
|
136
|
+
validateSearch: store.validateSearch,
|
|
137
|
+
entry: '/dashboard',
|
|
138
|
+
Dashboard: fieldProbe(store.field.range, probe),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
expect(probe.current?.value).toBe('30d')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('falls through to the fallback when neither lane holds a value', async () => {
|
|
145
|
+
const store = createSearchStore({
|
|
146
|
+
key: 'r-fallback',
|
|
147
|
+
fields: { range: field.enum(['1d', '7d'], '7d') },
|
|
148
|
+
})
|
|
149
|
+
const probe = sink()
|
|
150
|
+
|
|
151
|
+
await mountApp({
|
|
152
|
+
validateSearch: store.validateSearch,
|
|
153
|
+
entry: '/dashboard',
|
|
154
|
+
Dashboard: fieldProbe(store.field.range, probe),
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
expect(probe.current?.value).toBe('7d')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('a CHILD route reads the field with no `from` (A3)', async () => {
|
|
161
|
+
const store = createSearchStore({
|
|
162
|
+
key: 'r-child',
|
|
163
|
+
fields: { range: field.enum(['1d', '7d'], '1d') },
|
|
164
|
+
})
|
|
165
|
+
const probe = sink()
|
|
166
|
+
|
|
167
|
+
await mountApp({
|
|
168
|
+
validateSearch: store.validateSearch,
|
|
169
|
+
entry: '/dashboard/detail?range=7d',
|
|
170
|
+
Detail: fieldProbe(store.field.range, probe),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
expect(probe.current?.value).toBe('7d')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('a range field reads all three params back as one value', async () => {
|
|
177
|
+
const store = createSearchStore({
|
|
178
|
+
key: 'r-range',
|
|
179
|
+
fields: { range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }) },
|
|
180
|
+
})
|
|
181
|
+
const probe = sink()
|
|
182
|
+
|
|
183
|
+
await mountApp({
|
|
184
|
+
validateSearch: store.validateSearch,
|
|
185
|
+
entry: '/dashboard?range=custom&from=2026-01-01&to=2026-02-01',
|
|
186
|
+
Dashboard: fieldProbe(store.field.range, probe),
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
expect(probe.current?.value).toEqual({
|
|
190
|
+
preset: 'custom',
|
|
191
|
+
from: '2026-01-01',
|
|
192
|
+
to: '2026-02-01',
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('use() — writing', () => {
|
|
198
|
+
test('a write from the owning route lands in the URL AND in the mirror', async () => {
|
|
199
|
+
const store = createSearchStore({
|
|
200
|
+
key: 'r-write',
|
|
201
|
+
fields: { range: field.enum(['1d', '7d'], '7d') },
|
|
202
|
+
})
|
|
203
|
+
const probe = sink()
|
|
204
|
+
|
|
205
|
+
const router = await mountApp({
|
|
206
|
+
validateSearch: store.validateSearch,
|
|
207
|
+
entry: '/dashboard',
|
|
208
|
+
Dashboard: fieldProbe(store.field.range, probe),
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
await act(async () => {
|
|
212
|
+
probe.current?.set('1d')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
await waitFor(() => {
|
|
216
|
+
expect(currentSearch(router)['range']).toBe('1d')
|
|
217
|
+
})
|
|
218
|
+
expect(storedRecord('r-write')).toEqual({ range: '1d' })
|
|
219
|
+
expect(probe.current?.value).toBe('1d')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('a range write clears a stale from/to when the preset stops being custom', async () => {
|
|
223
|
+
const store = createSearchStore({
|
|
224
|
+
key: 'r-range-clear',
|
|
225
|
+
fields: { range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }) },
|
|
226
|
+
})
|
|
227
|
+
const probe = sink()
|
|
228
|
+
|
|
229
|
+
const router = await mountApp({
|
|
230
|
+
validateSearch: store.validateSearch,
|
|
231
|
+
entry: '/dashboard?range=custom&from=2026-01-01&to=2026-02-01',
|
|
232
|
+
Dashboard: fieldProbe(store.field.range, probe),
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
await act(async () => {
|
|
236
|
+
probe.current?.set({ preset: '7d' })
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
await waitFor(() => {
|
|
240
|
+
expect(currentSearch(router)['range']).toBe('7d')
|
|
241
|
+
})
|
|
242
|
+
expect(currentSearch(router)['from']).toBeUndefined()
|
|
243
|
+
expect(currentSearch(router)['to']).toBeUndefined()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('a write from a FOREIGN route persists only — it never navigates (A1)', async () => {
|
|
247
|
+
const store = createSearchStore({
|
|
248
|
+
key: 'r-foreign',
|
|
249
|
+
fields: { range: field.enum(['1d', '7d'], '7d') },
|
|
250
|
+
})
|
|
251
|
+
const probe = sink()
|
|
252
|
+
|
|
253
|
+
const router = await mountApp({
|
|
254
|
+
validateSearch: store.validateSearch,
|
|
255
|
+
entry: '/other',
|
|
256
|
+
Other: fieldProbe(store.field.range, probe),
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
await act(async () => {
|
|
260
|
+
probe.current?.set('1d')
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
expect(router.state.location.pathname).toBe('/other')
|
|
264
|
+
expect(currentSearch(router)['range']).toBeUndefined()
|
|
265
|
+
// …and `validateSearch` picks it up on the next visit to the owning route.
|
|
266
|
+
expect(storedRecord('r-foreign')).toEqual({ range: '1d' })
|
|
267
|
+
expect(store.validateSearch({})).toEqual({ range: '1d' })
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('`persist: false` writes the URL and never touches storage', async () => {
|
|
271
|
+
const store = createSearchStore({
|
|
272
|
+
key: 'r-nopersist',
|
|
273
|
+
fields: { page: field.number({ fallback: 1, int: true }, { persist: false }) },
|
|
274
|
+
})
|
|
275
|
+
const probe = sink()
|
|
276
|
+
|
|
277
|
+
const router = await mountApp({
|
|
278
|
+
validateSearch: store.validateSearch,
|
|
279
|
+
entry: '/dashboard',
|
|
280
|
+
Dashboard: fieldProbe(store.field.page, probe),
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
await act(async () => {
|
|
284
|
+
probe.current?.set(3)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
await waitFor(() => {
|
|
288
|
+
expect(currentSearch(router)['page']).toBe(3)
|
|
289
|
+
})
|
|
290
|
+
expect(storedRecord('r-nopersist')).toBeNull()
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('`url: false` writes storage and never touches the URL', async () => {
|
|
294
|
+
const store = createSearchStore({
|
|
295
|
+
key: 'r-nourl',
|
|
296
|
+
fields: { compact: field.boolean(false, { url: false }) },
|
|
297
|
+
})
|
|
298
|
+
const probe = sink()
|
|
299
|
+
|
|
300
|
+
const router = await mountApp({
|
|
301
|
+
validateSearch: store.validateSearch,
|
|
302
|
+
entry: '/dashboard',
|
|
303
|
+
Dashboard: fieldProbe(store.field.compact, probe),
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
await act(async () => {
|
|
307
|
+
probe.current?.set(true)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
await waitFor(() => {
|
|
311
|
+
expect(probe.current?.value).toBe(true)
|
|
312
|
+
})
|
|
313
|
+
expect(currentSearch(router)['compact']).toBeUndefined()
|
|
314
|
+
expect(storedRecord('r-nourl')).toEqual({ compact: true })
|
|
315
|
+
})
|
|
316
|
+
test('a `persist: false` write from a foreign route is a silent no-op — so it warns in dev', async () => {
|
|
317
|
+
const store = createSearchStore({
|
|
318
|
+
key: 'r-nowhere',
|
|
319
|
+
fields: { page: field.number({ fallback: 1, int: true }, { persist: false }) },
|
|
320
|
+
})
|
|
321
|
+
const probe = sink()
|
|
322
|
+
const warn = spyOn(console, 'warn').mockImplementation(() => {})
|
|
323
|
+
|
|
324
|
+
const router = await mountApp({
|
|
325
|
+
validateSearch: store.validateSearch,
|
|
326
|
+
entry: '/other',
|
|
327
|
+
Other: fieldProbe(store.field.page, probe),
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
await act(async () => {
|
|
331
|
+
probe.current?.set(3)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
// No URL (this route validates no `page`), no mirror (the field opted out) — nothing happened.
|
|
335
|
+
expect(router.state.location.pathname).toBe('/other')
|
|
336
|
+
expect(currentSearch(router)['page']).toBeUndefined()
|
|
337
|
+
expect(storedRecord('r-nowhere')).toBeNull()
|
|
338
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
339
|
+
expect(String(warn.mock.calls[0]?.[0])).toContain('persist: false')
|
|
340
|
+
|
|
341
|
+
// Once per field, not once per click.
|
|
342
|
+
await act(async () => {
|
|
343
|
+
probe.current?.set(4)
|
|
344
|
+
})
|
|
345
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
346
|
+
warn.mockRestore()
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
test('a persisted field written from a foreign route does NOT warn — it has somewhere to go', async () => {
|
|
350
|
+
const store = createSearchStore({
|
|
351
|
+
key: 'r-nowhere-ok',
|
|
352
|
+
fields: { range: field.enum(['1d', '7d'], '7d') },
|
|
353
|
+
})
|
|
354
|
+
const probe = sink()
|
|
355
|
+
const warn = spyOn(console, 'warn').mockImplementation(() => {})
|
|
356
|
+
|
|
357
|
+
await mountApp({
|
|
358
|
+
validateSearch: store.validateSearch,
|
|
359
|
+
entry: '/other',
|
|
360
|
+
Other: fieldProbe(store.field.range, probe),
|
|
361
|
+
})
|
|
362
|
+
await act(async () => {
|
|
363
|
+
probe.current?.set('1d')
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
expect(storedRecord('r-nowhere-ok')).toEqual({ range: '1d' })
|
|
367
|
+
expect(warn).not.toHaveBeenCalled()
|
|
368
|
+
warn.mockRestore()
|
|
369
|
+
})
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
describe('store hooks', () => {
|
|
373
|
+
test('useValues returns every URL-lane param, resolved', async () => {
|
|
374
|
+
const store = createSearchStore({
|
|
375
|
+
key: 'r-values',
|
|
376
|
+
fields: {
|
|
377
|
+
range: field.enum(['1d', '7d'], '1d'),
|
|
378
|
+
compare: field.enum(['none', 'previous'], 'none'),
|
|
379
|
+
compact: field.boolean(false, { url: false }),
|
|
380
|
+
},
|
|
381
|
+
})
|
|
382
|
+
persist('r-values', { compare: 'previous', compact: true })
|
|
383
|
+
const seen: { current: Record<string, unknown> | null } = { current: null }
|
|
384
|
+
|
|
385
|
+
await mountApp({
|
|
386
|
+
validateSearch: store.validateSearch,
|
|
387
|
+
entry: '/dashboard?range=7d',
|
|
388
|
+
Dashboard: () => {
|
|
389
|
+
seen.current = store.useValues()
|
|
390
|
+
return null
|
|
391
|
+
},
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
expect(seen.current).toEqual({ range: '7d', compare: 'previous' })
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test('useActiveCount counts the fields that differ from their fallback — both lanes', async () => {
|
|
398
|
+
const store = createSearchStore({
|
|
399
|
+
key: 'r-count',
|
|
400
|
+
fields: {
|
|
401
|
+
range: field.enum(['1d', '7d'], '1d'),
|
|
402
|
+
compare: field.enum(['none', 'previous'], 'none'),
|
|
403
|
+
compact: field.boolean(false, { url: false }),
|
|
404
|
+
},
|
|
405
|
+
})
|
|
406
|
+
persist('r-count', { compact: true })
|
|
407
|
+
let count = -1
|
|
408
|
+
|
|
409
|
+
await mountApp({
|
|
410
|
+
validateSearch: store.validateSearch,
|
|
411
|
+
entry: '/dashboard?range=7d',
|
|
412
|
+
Dashboard: () => {
|
|
413
|
+
count = store.useActiveCount()
|
|
414
|
+
return null
|
|
415
|
+
},
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
// range (URL) + compact (local); compare is still its fallback.
|
|
419
|
+
expect(count).toBe(2)
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
test('useReset returns every field to its fallback in ONE navigate call', async () => {
|
|
423
|
+
const store = createSearchStore({
|
|
424
|
+
key: 'r-reset',
|
|
425
|
+
fields: {
|
|
426
|
+
range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }),
|
|
427
|
+
compare: field.enum(['none', 'previous'], 'none'),
|
|
428
|
+
compact: field.boolean(false, { url: false }),
|
|
429
|
+
},
|
|
430
|
+
})
|
|
431
|
+
persist('r-reset', { compact: true })
|
|
432
|
+
let reset: (() => void) | null = null
|
|
433
|
+
|
|
434
|
+
const router = await mountApp({
|
|
435
|
+
validateSearch: store.validateSearch,
|
|
436
|
+
entry: '/dashboard?range=custom&from=2026-01-01&to=2026-02-01&compare=previous',
|
|
437
|
+
Dashboard: () => {
|
|
438
|
+
reset = store.useReset()
|
|
439
|
+
return null
|
|
440
|
+
},
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
const navigate = spyOn(router, 'navigate')
|
|
444
|
+
await act(async () => {
|
|
445
|
+
reset?.()
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
expect(navigate).toHaveBeenCalledTimes(1)
|
|
449
|
+
await waitFor(() => {
|
|
450
|
+
expect(currentSearch(router)['range']).toBe('30d')
|
|
451
|
+
})
|
|
452
|
+
expect(currentSearch(router)['compare']).toBe('none')
|
|
453
|
+
expect(currentSearch(router)['from']).toBeUndefined()
|
|
454
|
+
expect(storedRecord('r-reset')).toEqual({
|
|
455
|
+
range: { preset: '30d' },
|
|
456
|
+
compare: 'none',
|
|
457
|
+
compact: false,
|
|
458
|
+
})
|
|
459
|
+
navigate.mockRestore()
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
test('useReset from a FOREIGN route persists only — it never navigates (A1)', async () => {
|
|
463
|
+
const store = createSearchStore({
|
|
464
|
+
key: 'r-reset-foreign',
|
|
465
|
+
fields: {
|
|
466
|
+
range: field.enum(['1d', '7d'], '7d'),
|
|
467
|
+
compare: field.enum(['none', 'prev'], 'none'),
|
|
468
|
+
},
|
|
469
|
+
})
|
|
470
|
+
let reset: (() => void) | null = null
|
|
471
|
+
const probe = sink()
|
|
472
|
+
|
|
473
|
+
const router = await mountApp({
|
|
474
|
+
validateSearch: store.validateSearch,
|
|
475
|
+
entry: '/other',
|
|
476
|
+
Other: () => {
|
|
477
|
+
reset = store.useReset()
|
|
478
|
+
const [value, set] = store.field.range.use()
|
|
479
|
+
probe.current = { value, set: set as (next: unknown) => void }
|
|
480
|
+
return null
|
|
481
|
+
},
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
const navigate = spyOn(router, 'navigate')
|
|
485
|
+
await act(async () => {
|
|
486
|
+
reset?.()
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
expect(navigate).not.toHaveBeenCalled()
|
|
490
|
+
expect(router.state.location.pathname).toBe('/other')
|
|
491
|
+
expect(currentSearch(router)['range']).toBeUndefined()
|
|
492
|
+
expect(currentSearch(router)['compare']).toBeUndefined()
|
|
493
|
+
|
|
494
|
+
// …and a subsequent field write on that same foreign route still persists only — the reset
|
|
495
|
+
// did not leak the params into the URL and flip the route into looking like an owner.
|
|
496
|
+
await act(async () => {
|
|
497
|
+
probe.current?.set('1d')
|
|
498
|
+
})
|
|
499
|
+
expect(navigate).not.toHaveBeenCalled()
|
|
500
|
+
expect(currentSearch(router)['range']).toBeUndefined()
|
|
501
|
+
navigate.mockRestore()
|
|
502
|
+
})
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Every navigate a store issues carries `resetScroll: false`. A filter is as often halfway down a
|
|
507
|
+
* page as it is in the bar, and the router treats a same-route search write as a navigation — so
|
|
508
|
+
* the default would scroll the reader back to the top on every change. Asserted on the OPTION and
|
|
509
|
+
* not on a scroll position: happy-dom evaluates no layout, so the scroll itself is invisible here.
|
|
510
|
+
*/
|
|
511
|
+
describe('store writes never scroll the page', () => {
|
|
512
|
+
test('a field write passes resetScroll: false', async () => {
|
|
513
|
+
const store = createSearchStore({
|
|
514
|
+
key: 'r-scroll-field',
|
|
515
|
+
fields: { range: field.enum(['1d', '7d'], '7d') },
|
|
516
|
+
})
|
|
517
|
+
const probe = sink()
|
|
518
|
+
|
|
519
|
+
const router = await mountApp({
|
|
520
|
+
validateSearch: store.validateSearch,
|
|
521
|
+
entry: '/dashboard',
|
|
522
|
+
Dashboard: fieldProbe(store.field.range, probe),
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
const navigate = spyOn(router, 'navigate')
|
|
526
|
+
await act(async () => {
|
|
527
|
+
probe.current?.set('1d')
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
expect(navigate).toHaveBeenCalledTimes(1)
|
|
531
|
+
expect(navigate.mock.calls[0]?.[0]).toMatchObject({ resetScroll: false })
|
|
532
|
+
navigate.mockRestore()
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
test('useReset passes resetScroll: false', async () => {
|
|
536
|
+
const store = createSearchStore({
|
|
537
|
+
key: 'r-scroll-reset',
|
|
538
|
+
fields: { range: field.enum(['1d', '7d'], '7d') },
|
|
539
|
+
})
|
|
540
|
+
let reset: (() => void) | null = null
|
|
541
|
+
|
|
542
|
+
const router = await mountApp({
|
|
543
|
+
validateSearch: store.validateSearch,
|
|
544
|
+
entry: '/dashboard?range=1d',
|
|
545
|
+
Dashboard: () => {
|
|
546
|
+
reset = store.useReset()
|
|
547
|
+
return null
|
|
548
|
+
},
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
const navigate = spyOn(router, 'navigate')
|
|
552
|
+
await act(async () => {
|
|
553
|
+
reset?.()
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
expect(navigate).toHaveBeenCalledTimes(1)
|
|
557
|
+
expect(navigate.mock.calls[0]?.[0]).toMatchObject({ resetScroll: false })
|
|
558
|
+
navigate.mockRestore()
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* `{ url: false, persist: false }` — the third lane. It used to be the one combination that dropped
|
|
564
|
+
* its write (no URL to put it in, no mirror to keep it in); it is now the same session-shared
|
|
565
|
+
* in-memory lane `createLocalStore` gives such a field, through the same store-core helper.
|
|
566
|
+
*/
|
|
567
|
+
const memoryStore = () =>
|
|
568
|
+
createSearchStore({
|
|
569
|
+
key: 'r-memory',
|
|
570
|
+
fields: {
|
|
571
|
+
range: field.enum(['1d', '7d'], '7d'),
|
|
572
|
+
scratch: field.enum(['a', 'b', 'c'], 'a', { url: false, persist: false }),
|
|
573
|
+
},
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
describe('the memory-only lane — url: false, persist: false', () => {
|
|
577
|
+
test('a write survives a remount, and touches neither the URL nor localStorage', async () => {
|
|
578
|
+
const store = memoryStore()
|
|
579
|
+
const probe = sink()
|
|
580
|
+
const warn = spyOn(console, 'warn').mockImplementation(() => {})
|
|
581
|
+
|
|
582
|
+
const router = await mountApp({
|
|
583
|
+
validateSearch: store.validateSearch,
|
|
584
|
+
entry: '/dashboard',
|
|
585
|
+
Dashboard: fieldProbe(store.field.scratch, probe),
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
await act(async () => {
|
|
589
|
+
probe.current?.set('c')
|
|
590
|
+
})
|
|
591
|
+
expect(probe.current?.value).toBe('c')
|
|
592
|
+
|
|
593
|
+
// No URL param (the field declares none, so `validateSearch` never emits one) and no mirror.
|
|
594
|
+
expect(currentSearch(router)['scratch']).toBeUndefined()
|
|
595
|
+
expect(storedRecord('r-memory')).toBeNull()
|
|
596
|
+
// …and no dev warning: the write went somewhere.
|
|
597
|
+
expect(warn).not.toHaveBeenCalled()
|
|
598
|
+
warn.mockRestore()
|
|
599
|
+
|
|
600
|
+
// A remount inside the same session reads it back — the lane is the store's, not the mount's.
|
|
601
|
+
cleanup()
|
|
602
|
+
const second = sink()
|
|
603
|
+
await mountApp({
|
|
604
|
+
validateSearch: store.validateSearch,
|
|
605
|
+
entry: '/dashboard',
|
|
606
|
+
Dashboard: fieldProbe(store.field.scratch, second),
|
|
607
|
+
})
|
|
608
|
+
expect(second.current?.value).toBe('c')
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
test('useActiveCount counts it, and useReset returns it to its fallback', async () => {
|
|
612
|
+
const store = memoryStore()
|
|
613
|
+
const probe = sink()
|
|
614
|
+
let count = -1
|
|
615
|
+
let reset: (() => void) | null = null
|
|
616
|
+
|
|
617
|
+
await mountApp({
|
|
618
|
+
validateSearch: store.validateSearch,
|
|
619
|
+
entry: '/dashboard',
|
|
620
|
+
Dashboard: () => {
|
|
621
|
+
count = store.useActiveCount()
|
|
622
|
+
reset = store.useReset()
|
|
623
|
+
return fieldProbe(store.field.scratch, probe)()
|
|
624
|
+
},
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
expect(count).toBe(0)
|
|
628
|
+
|
|
629
|
+
await act(async () => {
|
|
630
|
+
probe.current?.set('b')
|
|
631
|
+
})
|
|
632
|
+
expect(count).toBe(1)
|
|
633
|
+
|
|
634
|
+
await act(async () => {
|
|
635
|
+
reset?.()
|
|
636
|
+
})
|
|
637
|
+
expect(probe.current?.value).toBe('a')
|
|
638
|
+
expect(count).toBe(0)
|
|
639
|
+
// `useReset` wrote the MIRROR for the persisted field beside it; the memory field is not in it.
|
|
640
|
+
expect(storedRecord('r-memory')).toEqual({ range: '7d' })
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
test('two mounts of the same field see one value', async () => {
|
|
644
|
+
const store = memoryStore()
|
|
645
|
+
const a = sink()
|
|
646
|
+
const b = sink()
|
|
647
|
+
|
|
648
|
+
await mountApp({
|
|
649
|
+
validateSearch: store.validateSearch,
|
|
650
|
+
entry: '/dashboard/detail',
|
|
651
|
+
Dashboard: fieldProbe(store.field.scratch, a),
|
|
652
|
+
Detail: fieldProbe(store.field.scratch, b),
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
await act(async () => {
|
|
656
|
+
a.current?.set('b')
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
expect(b.current?.value).toBe('b')
|
|
660
|
+
})
|
|
661
|
+
})
|