basalt-ui 1.25.0 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -6
- package/CHANGELOG.md +26 -0
- package/MIGRATING.md +815 -23
- package/README.md +45 -32
- package/agent/rules/basalt-batteries.md +205 -0
- package/agent/rules/basalt-charts.md +125 -555
- package/agent/rules/basalt-controls.md +160 -0
- package/agent/rules/basalt-mantine.md +146 -439
- package/agent/rules/basalt-state.md +118 -96
- package/agent/rules/basalt-tokens.md +126 -288
- package/agent/skills/basalt-app/SKILL.md +70 -195
- package/agent/skills/basalt-charts/SKILL.md +54 -234
- package/agent/skills/basalt-design/SKILL.md +56 -248
- package/agent/templates/CLAUDE-block.md.tpl +31 -40
- package/agent/templates/DESIGN.md.tpl +22 -51
- package/configs/oxlint-plugin.js +1465 -36
- package/configs/oxlint-plugin.test.ts +1190 -11
- package/configs/oxlint.json +11 -3
- package/dist/charts/cursor/resolve.d.ts +18 -0
- package/dist/charts/cursor/resolve.d.ts.map +1 -1
- package/dist/charts/cursor/resolve.js +15 -6
- package/dist/charts/cursor/scope.d.ts +6 -3
- package/dist/charts/cursor/scope.d.ts.map +1 -1
- package/dist/charts/cursor/store.d.ts +5 -1
- package/dist/charts/cursor/store.d.ts.map +1 -1
- package/dist/charts/cursor/store.js +4 -4
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
- package/dist/charts/hooks/useChartCursor.js +9 -8
- package/dist/charts/index.d.ts +2 -2
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.d.ts +34 -9
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +29 -114
- package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
- package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
- package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
- package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
- package/dist/charts/sparklines/BarSparkline.js +18 -4
- package/dist/cli/check-coverage.test.d.ts +2 -0
- package/dist/cli/check-coverage.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts +67 -4
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +225 -7
- package/dist/connectivity/connectivity-indicator.d.ts +4 -1
- package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
- package/dist/connectivity/connectivity-indicator.js +1 -1
- package/dist/content/index.d.ts +5 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +4 -2
- package/dist/content/prose.module.css +7 -28
- package/dist/controls/actions.d.ts +169 -0
- package/dist/controls/actions.d.ts.map +1 -0
- package/dist/controls/actions.js +287 -0
- package/dist/controls/actions.module.css +62 -0
- package/dist/controls/actions.module.css.d.ts +13 -0
- package/dist/controls/actions.test.d.ts +2 -0
- package/dist/controls/actions.test.d.ts.map +1 -0
- package/dist/controls/actions.type-guard.test.d.ts +2 -0
- package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
- package/dist/controls/compare-filter.d.ts +41 -0
- package/dist/controls/compare-filter.d.ts.map +1 -0
- package/dist/controls/compare-filter.js +23 -0
- package/dist/controls/control-group.d.ts +19 -0
- package/dist/controls/control-group.d.ts.map +1 -0
- package/dist/controls/control-group.js +20 -0
- package/dist/controls/control-group.test.d.ts +2 -0
- package/dist/controls/control-group.test.d.ts.map +1 -0
- package/dist/controls/controls.module.css +542 -0
- package/dist/controls/controls.module.css.d.ts +35 -0
- package/dist/controls/controls.router.test.d.ts +2 -0
- package/dist/controls/controls.router.test.d.ts.map +1 -0
- package/dist/controls/enum-filter.d.ts +39 -0
- package/dist/controls/enum-filter.d.ts.map +1 -0
- package/dist/controls/enum-filter.js +93 -0
- package/dist/controls/filter-context.d.ts +60 -0
- package/dist/controls/filter-context.d.ts.map +1 -0
- package/dist/controls/filter-context.js +69 -0
- package/dist/controls/filter-pill.d.ts +28 -0
- package/dist/controls/filter-pill.d.ts.map +1 -0
- package/dist/controls/filter-pill.js +70 -0
- package/dist/controls/filter-set.d.ts +13 -0
- package/dist/controls/filter-set.d.ts.map +1 -0
- package/dist/controls/filter-set.js +125 -0
- package/dist/controls/filter-set.test.d.ts +2 -0
- package/dist/controls/filter-set.test.d.ts.map +1 -0
- package/dist/controls/filter-sheet.d.ts +117 -0
- package/dist/controls/filter-sheet.d.ts.map +1 -0
- package/dist/controls/filter-sheet.js +146 -0
- package/dist/controls/glyphs.d.ts +27 -0
- package/dist/controls/glyphs.d.ts.map +1 -0
- package/dist/controls/glyphs.js +49 -0
- package/dist/controls/index.d.ts +20 -0
- package/dist/controls/index.d.ts.map +1 -0
- package/dist/controls/index.js +46 -0
- package/dist/controls/multi-select-filter.d.ts +23 -0
- package/dist/controls/multi-select-filter.d.ts.map +1 -0
- package/dist/controls/multi-select-filter.js +89 -0
- package/dist/controls/number-filter.d.ts +26 -0
- package/dist/controls/number-filter.d.ts.map +1 -0
- package/dist/controls/number-filter.js +143 -0
- package/dist/controls/peer-boundary.test.d.ts +2 -0
- package/dist/controls/peer-boundary.test.d.ts.map +1 -0
- package/dist/controls/props-contract.test.d.ts +2 -0
- package/dist/controls/props-contract.test.d.ts.map +1 -0
- package/dist/controls/range-filter.d.ts +43 -0
- package/dist/controls/range-filter.d.ts.map +1 -0
- package/dist/controls/range-filter.js +135 -0
- package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
- package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
- package/dist/controls/search-filter.d.ts +10 -0
- package/dist/controls/search-filter.d.ts.map +1 -0
- package/dist/controls/search-filter.js +81 -0
- package/dist/controls/select-filter.d.ts +74 -0
- package/dist/controls/select-filter.d.ts.map +1 -0
- package/dist/controls/select-filter.js +10 -0
- package/dist/controls/sync-button.d.ts +38 -0
- package/dist/controls/sync-button.d.ts.map +1 -0
- package/dist/controls/sync-button.js +119 -0
- package/dist/controls/sync-button.module.css +63 -0
- package/dist/controls/sync-button.module.css.d.ts +11 -0
- package/dist/controls/sync-button.test.d.ts +2 -0
- package/dist/controls/sync-button.test.d.ts.map +1 -0
- package/dist/controls/toggle-filter.d.ts +9 -0
- package/dist/controls/toggle-filter.d.ts.map +1 -0
- package/dist/controls/toggle-filter.js +42 -0
- package/dist/controls/view-tabs.d.ts +23 -0
- package/dist/controls/view-tabs.d.ts.map +1 -0
- package/dist/controls/view-tabs.js +59 -0
- package/dist/controls-dates/date-range-picker.d.ts +4 -0
- package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
- package/dist/controls-dates/date-range-picker.js +18 -0
- package/dist/controls-dates/index.d.ts +9 -0
- package/dist/controls-dates/index.d.ts.map +1 -0
- package/dist/controls-dates/index.js +4 -0
- package/dist/dashboard/delta-badge.d.ts +6 -12
- package/dist/dashboard/delta-badge.d.ts.map +1 -1
- package/dist/dashboard/delta-badge.js +1 -50
- package/dist/dashboard/index.d.ts +1 -1
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +3 -1
- package/dist/dashboard/settings-section.d.ts +6 -4
- package/dist/dashboard/settings-section.d.ts.map +1 -1
- package/dist/dashboard/settings-section.js +23 -16
- package/dist/dashboard/settings-section.module.css +10 -0
- package/dist/dashboard/settings-section.module.css.d.ts +1 -0
- package/dist/dashboard/settings-section.test.d.ts +2 -0
- package/dist/dashboard/settings-section.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.d.ts +95 -9
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +108 -49
- package/dist/dashboard/stat-card.module.css +153 -0
- package/dist/dashboard/stat-card.module.css.d.ts +15 -0
- package/dist/data/data-table.d.ts +37 -15
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +96 -58
- package/dist/guard/grace.test.d.ts +20 -0
- package/dist/guard/grace.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +66 -3
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +78 -7
- package/dist/guard/types.d.ts +3 -3
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -10
- package/dist/nav/types.d.ts +78 -2
- package/dist/nav/types.d.ts.map +1 -1
- package/dist/notifications/bell.d.ts +4 -1
- package/dist/notifications/bell.d.ts.map +1 -1
- package/dist/notifications/index.d.ts +1 -1
- package/dist/router-tanstack/field.d.ts +14 -0
- package/dist/router-tanstack/field.d.ts.map +1 -0
- package/dist/router-tanstack/field.js +4 -0
- package/dist/router-tanstack/index.d.ts +6 -2
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/index.js +4 -0
- package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.js +19 -27
- package/dist/router-tanstack/search-param-store.d.ts +3 -87
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-param-store.js +16 -45
- package/dist/router-tanstack/search-store.d.ts +84 -0
- package/dist/router-tanstack/search-store.d.ts.map +1 -0
- package/dist/router-tanstack/search-store.js +271 -0
- package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
- package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
- package/dist/router-tanstack/search-store.test.d.ts +2 -0
- package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
- package/dist/router-tanstack/use-nav.d.ts +0 -2
- package/dist/router-tanstack/use-nav.d.ts.map +1 -1
- package/dist/router-tanstack/use-nav.js +34 -12
- package/dist/router-tanstack/use-nav.test.d.ts +2 -0
- package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
- package/dist/section/index.d.ts +2 -0
- package/dist/section/index.d.ts.map +1 -0
- package/dist/section/index.js +4 -0
- package/dist/section/section.d.ts +24 -0
- package/dist/section/section.d.ts.map +1 -0
- package/dist/section/section.js +126 -0
- package/dist/section/section.module.css +104 -0
- package/dist/section/section.module.css.d.ts +11 -0
- package/dist/section/section.test.d.ts +2 -0
- package/dist/section/section.test.d.ts.map +1 -0
- package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
- package/dist/shell/app-breadcrumbs.js +18 -15
- package/dist/shell/app-header.module.css +77 -37
- package/dist/shell/app-header.module.css.d.ts +2 -1
- package/dist/shell/app-mobile-nav.d.ts +9 -2
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +98 -11
- package/dist/shell/app-mobile-nav.module.css +8 -0
- package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/dist/shell/app-sidebar.d.ts +55 -28
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +116 -67
- package/dist/shell/app-sidebar.module.css +303 -20
- package/dist/shell/app-sidebar.module.css.d.ts +19 -1
- package/dist/shell/index.d.ts +55 -21
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +40 -18
- package/dist/shell/mobile-nav-model.d.ts +13 -2
- package/dist/shell/mobile-nav-model.d.ts.map +1 -1
- package/dist/shell/mobile-nav-model.js +15 -1
- package/dist/shell/nav-count-badge.d.ts +1 -1
- package/dist/shell/page-bar.d.ts +51 -0
- package/dist/shell/page-bar.d.ts.map +1 -0
- package/dist/shell/page-bar.js +154 -0
- package/dist/shell/page-bar.module.css +178 -0
- package/dist/shell/page-bar.module.css.d.ts +17 -0
- package/dist/shell/page-bar.test.d.ts +2 -0
- package/dist/shell/page-bar.test.d.ts.map +1 -0
- package/dist/shell/sidebar-block-model.d.ts +48 -0
- package/dist/shell/sidebar-block-model.d.ts.map +1 -0
- package/dist/shell/sidebar-block-model.js +39 -0
- package/dist/shell/sidebar-blocks.d.ts +32 -0
- package/dist/shell/sidebar-blocks.d.ts.map +1 -0
- package/dist/shell/sidebar-blocks.js +169 -0
- package/dist/shell/sidebar-search.d.ts +13 -1
- package/dist/shell/sidebar-search.d.ts.map +1 -1
- package/dist/shell/sidebar-search.js +34 -1
- package/dist/shell/sidebar-search.module.css +26 -0
- package/dist/shell/sidebar-search.module.css.d.ts +2 -0
- package/dist/state/fields.d.ts +565 -0
- package/dist/state/fields.d.ts.map +1 -0
- package/dist/state/fields.js +405 -0
- package/dist/state/fields.test.d.ts +2 -0
- package/dist/state/fields.test.d.ts.map +1 -0
- package/dist/state/persisted.d.ts +60 -0
- package/dist/state/persisted.d.ts.map +1 -0
- package/dist/state/persisted.js +123 -0
- package/dist/state/persisted.test.d.ts +2 -0
- package/dist/state/persisted.test.d.ts.map +1 -0
- package/dist/state.d.ts +11 -56
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +14 -120
- package/dist/styles.css +13 -0
- package/dist/surfaces.d.ts +106 -24
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +156 -19
- package/dist/theme/controls.module.css +16 -1
- package/dist/theme/ctl-theme.d.ts +32 -0
- package/dist/theme/ctl-theme.d.ts.map +1 -0
- package/dist/theme/ctl-theme.js +54 -0
- package/dist/theme/ctl-theme.test.d.ts +2 -0
- package/dist/theme/ctl-theme.test.d.ts.map +1 -0
- package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
- package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
- package/dist/theme/divider-law.test.d.ts +2 -0
- package/dist/theme/divider-law.test.d.ts.map +1 -0
- package/dist/theme/icon-slot.d.ts +44 -0
- package/dist/theme/icon-slot.d.ts.map +1 -0
- package/dist/theme/icon-slot.js +21 -0
- package/dist/theme/icon-slot.module.css +47 -0
- package/dist/theme/icon-slot.module.css.d.ts +8 -0
- package/dist/theme/icon-slot.test.d.ts +2 -0
- package/dist/theme/icon-slot.test.d.ts.map +1 -0
- package/dist/theme/index.d.ts +1 -0
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +82 -5
- package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
- package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
- package/dist/theme/segmented-control.module.css +20 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
- package/dist/tokens/index.d.ts +12 -0
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +38 -8
- package/dist/tokens/palette.d.ts +97 -61
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +76 -38
- package/dist/tokens.css +10 -2
- package/dist/widget-header/delta-badge.d.ts +13 -0
- package/dist/widget-header/delta-badge.d.ts.map +1 -0
- package/dist/widget-header/delta-badge.js +29 -0
- package/dist/widget-header/delta-badge.test.d.ts +2 -0
- package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
- package/dist/widget-header/index.d.ts +3 -0
- package/dist/widget-header/index.d.ts.map +1 -0
- package/dist/widget-header/index.js +6 -0
- package/dist/widget-header/no-mantine.test.d.ts +2 -0
- package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
- package/dist/widget-header/widget-header.d.ts +67 -0
- package/dist/widget-header/widget-header.d.ts.map +1 -0
- package/dist/widget-header/widget-header.js +89 -0
- package/dist/widget-header/widget-header.module.css +350 -0
- package/dist/widget-header/widget-header.module.css.d.ts +25 -0
- package/dist/widget-header/widget-header.test.d.ts +2 -0
- package/dist/widget-header/widget-header.test.d.ts.map +1 -0
- package/llms.txt +30 -17
- package/package.json +14 -1
- package/src/charts/cursor/resolve.test.ts +29 -1
- package/src/charts/cursor/resolve.ts +38 -6
- package/src/charts/cursor/scope.tsx +6 -3
- package/src/charts/cursor/store.test.ts +19 -1
- package/src/charts/cursor/store.ts +10 -5
- package/src/charts/hooks/useChartCursor.ts +16 -8
- package/src/charts/index.ts +2 -2
- package/src/charts/primitives/CartesianChart.test.tsx +124 -0
- package/src/charts/primitives/ChartCard.test.tsx +66 -0
- package/src/charts/primitives/ChartCard.tsx +86 -146
- package/src/charts/sparklines/BarSparkline.tsx +43 -5
- package/src/cli/check-coverage.test.ts +297 -0
- package/src/cli/check-theme.test.ts +38 -14
- package/src/cli/index.ts +392 -16
- package/src/cli/init-sync.test.ts +97 -0
- package/src/connectivity/connectivity-indicator.tsx +16 -9
- package/src/content/index.ts +16 -3
- package/src/content/prose.module.css +7 -28
- package/src/content/prose.module.css.test.ts +8 -24
- package/src/controls/actions.module.css +62 -0
- package/src/controls/actions.module.css.d.ts +13 -0
- package/src/controls/actions.test.tsx +252 -0
- package/src/controls/actions.tsx +655 -0
- package/src/controls/actions.type-guard.test.ts +63 -0
- package/src/controls/compare-filter.tsx +64 -0
- package/src/controls/control-group.test.tsx +229 -0
- package/src/controls/control-group.tsx +63 -0
- package/src/controls/controls.module.css +542 -0
- package/src/controls/controls.module.css.d.ts +35 -0
- package/src/controls/controls.router.test.tsx +1053 -0
- package/src/controls/enum-filter.tsx +170 -0
- package/src/controls/filter-context.tsx +158 -0
- package/src/controls/filter-pill.tsx +120 -0
- package/src/controls/filter-set.test.tsx +472 -0
- package/src/controls/filter-set.tsx +225 -0
- package/src/controls/filter-sheet.tsx +285 -0
- package/src/controls/glyphs.tsx +81 -0
- package/src/controls/index.ts +49 -0
- package/src/controls/multi-select-filter.tsx +140 -0
- package/src/controls/number-filter.tsx +291 -0
- package/src/controls/peer-boundary.test.ts +85 -0
- package/src/controls/props-contract.test.tsx +137 -0
- package/src/controls/range-filter.tsx +237 -0
- package/src/controls/range-filter.type-guard.test.tsx +122 -0
- package/src/controls/search-filter.tsx +123 -0
- package/src/controls/select-filter.tsx +89 -0
- package/src/controls/sync-button.module.css +63 -0
- package/src/controls/sync-button.module.css.d.ts +11 -0
- package/src/controls/sync-button.test.tsx +183 -0
- package/src/controls/sync-button.tsx +197 -0
- package/src/controls/toggle-filter.tsx +60 -0
- package/src/controls/view-tabs.tsx +110 -0
- package/src/controls-dates/date-range-picker.tsx +42 -0
- package/src/controls-dates/index.ts +8 -0
- package/src/dashboard/delta-badge.tsx +4 -74
- package/src/dashboard/index.ts +6 -1
- package/src/dashboard/settings-section.module.css +10 -0
- package/src/dashboard/settings-section.module.css.d.ts +1 -0
- package/src/dashboard/settings-section.test.tsx +56 -0
- package/src/dashboard/settings-section.tsx +33 -30
- package/src/dashboard/stat-card.module.css +153 -0
- package/src/dashboard/stat-card.module.css.d.ts +15 -0
- package/src/dashboard/stat-card.test.tsx +298 -1
- package/src/dashboard/stat-card.tsx +289 -67
- package/src/data/data-table.test.tsx +96 -11
- package/src/data/data-table.tsx +228 -84
- package/src/guard/check-source.test.ts +159 -13
- package/src/guard/grace.test.ts +158 -0
- package/src/guard/index.ts +185 -29
- package/src/guard/types.ts +4 -2
- package/src/index.ts +51 -5
- package/src/nav/types.ts +84 -2
- package/src/notifications/bell.tsx +6 -3
- package/src/notifications/index.ts +1 -1
- package/src/router-tanstack/field.ts +35 -0
- package/src/router-tanstack/index.ts +28 -2
- package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
- package/src/router-tanstack/multi-search-param-store.ts +46 -77
- package/src/router-tanstack/search-param-store.ts +42 -159
- package/src/router-tanstack/search-store.router.test.tsx +882 -0
- package/src/router-tanstack/search-store.test.ts +578 -0
- package/src/router-tanstack/search-store.ts +546 -0
- package/src/router-tanstack/use-nav.test.tsx +187 -0
- package/src/router-tanstack/use-nav.ts +72 -14
- package/src/section/index.ts +1 -0
- package/src/section/section.module.css +104 -0
- package/src/section/section.module.css.d.ts +11 -0
- package/src/section/section.test.tsx +204 -0
- package/src/section/section.tsx +220 -0
- package/src/shell/app-breadcrumbs.tsx +44 -31
- package/src/shell/app-header.module.css +77 -37
- package/src/shell/app-header.module.css.d.ts +2 -1
- package/src/shell/app-mobile-nav.module.css +8 -0
- package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/src/shell/app-mobile-nav.tsx +147 -8
- package/src/shell/app-sidebar-account.tsx +2 -2
- package/src/shell/app-sidebar.module.css +303 -20
- package/src/shell/app-sidebar.module.css.d.ts +19 -1
- package/src/shell/app-sidebar.test.tsx +456 -41
- package/src/shell/app-sidebar.tsx +276 -140
- package/src/shell/index.test.tsx +291 -5
- package/src/shell/index.tsx +137 -38
- package/src/shell/mobile-nav-model.test.ts +228 -7
- package/src/shell/mobile-nav-model.ts +53 -3
- package/src/shell/nav-count-badge.tsx +1 -1
- package/src/shell/page-bar.module.css +178 -0
- package/src/shell/page-bar.module.css.d.ts +17 -0
- package/src/shell/page-bar.test.tsx +316 -0
- package/src/shell/page-bar.tsx +315 -0
- package/src/shell/sidebar-block-model.ts +83 -0
- package/src/shell/sidebar-blocks.tsx +301 -0
- package/src/shell/sidebar-search.module.css +26 -0
- package/src/shell/sidebar-search.module.css.d.ts +2 -0
- package/src/shell/sidebar-search.tsx +71 -2
- package/src/state/fields.test.tsx +512 -0
- package/src/state/fields.ts +1150 -0
- package/src/state/persisted.test.ts +91 -0
- package/src/state/persisted.ts +261 -0
- package/src/state.ts +52 -240
- package/src/styles.css +13 -0
- package/src/surfaces.ts +204 -36
- package/src/theme/controls.module.css +16 -1
- package/src/theme/ctl-theme.test.tsx +275 -0
- package/src/theme/ctl-theme.tsx +138 -0
- package/src/theme/ctl-tier-coverage.test.ts +170 -0
- package/src/theme/density-relations.test.ts +106 -51
- package/src/theme/divider-law.test.ts +259 -0
- package/src/theme/icon-slot.module.css +47 -0
- package/src/theme/icon-slot.module.css.d.ts +8 -0
- package/src/theme/icon-slot.test.tsx +275 -0
- package/src/theme/icon-slot.tsx +59 -0
- package/src/theme/index.ts +165 -3
- package/src/theme/layout-rhythm-css.test.ts +72 -0
- package/src/theme/radius.test.ts +11 -1
- package/src/theme/segmented-control.module.css +20 -0
- package/src/theme/shadow-surfaces.test.ts +20 -7
- package/src/theme/spacing.test.ts +75 -27
- package/src/theme/use-basalt-spacing.ts +2 -2
- package/src/tokens/density.test.ts +7 -25
- package/src/tokens/index.ts +48 -8
- package/src/tokens/palette.ts +107 -73
- package/src/widget-header/delta-badge.test.tsx +60 -0
- package/src/widget-header/delta-badge.tsx +57 -0
- package/src/widget-header/index.ts +2 -0
- package/src/widget-header/no-mantine.test.ts +33 -0
- package/src/widget-header/widget-header.module.css +350 -0
- package/src/widget-header/widget-header.module.css.d.ts +25 -0
- package/src/widget-header/widget-header.test.tsx +187 -0
- package/src/widget-header/widget-header.tsx +213 -0
- package/agent/rules/basalt-agent.md +0 -452
- package/agent/rules/basalt-app.md +0 -246
- package/agent/rules/basalt-commands.md +0 -320
- package/agent/rules/basalt-content.md +0 -409
- package/agent/rules/basalt-data.md +0 -329
- package/agent/rules/basalt-forms.md +0 -186
- package/agent/rules/basalt-notifications.md +0 -264
- package/agent/rules/basalt-query.md +0 -187
- package/agent/rules/basalt-router.md +0 -336
- package/dist/content/article-filter-bar.d.ts +0 -40
- package/dist/content/article-filter-bar.d.ts.map +0 -1
- package/dist/content/article-filter-bar.js +0 -56
- package/dist/shell/page-header.d.ts +0 -13
- package/dist/shell/page-header.d.ts.map +0 -1
- package/dist/shell/page-header.js +0 -23
- package/src/content/article-filter-bar.tsx +0 -88
- package/src/shell/page-header.tsx +0 -35
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every control, bound to a REAL `createSearchStore` under a REAL memory-history router — the half
|
|
3
|
+
* that cannot be tested headlessly. What each control reads on a deep link, what its pill says, and
|
|
4
|
+
* what lands on the URL when it is operated (C2/C3: there is no `value`/`onChange` to stub, so
|
|
5
|
+
* nothing here can pass against a control that only pretends to write).
|
|
6
|
+
*
|
|
7
|
+
* The harness is the one from `router-tanstack/search-store.router.test.tsx`, narrowed to one route
|
|
8
|
+
* (the position questions are that file's job) and wrapped in a `MantineProvider`.
|
|
9
|
+
*/
|
|
10
|
+
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
11
|
+
import type { ReactNode } from 'react'
|
|
12
|
+
import { MantineProvider } from '@mantine/core'
|
|
13
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
14
|
+
import {
|
|
15
|
+
Outlet,
|
|
16
|
+
RouterProvider,
|
|
17
|
+
createMemoryHistory,
|
|
18
|
+
createRootRoute,
|
|
19
|
+
createRoute,
|
|
20
|
+
createRouter,
|
|
21
|
+
} from '@tanstack/react-router'
|
|
22
|
+
import { field } from '../router-tanstack/field'
|
|
23
|
+
import { createSearchStore } from '../router-tanstack/search-store'
|
|
24
|
+
import { COMPARE_LABELS, COMPARE_VALUES, CompareFilter } from './compare-filter'
|
|
25
|
+
import { FilterSet } from './filter-set'
|
|
26
|
+
import { MultiSelectFilter } from './multi-select-filter'
|
|
27
|
+
import { NumberFilter } from './number-filter'
|
|
28
|
+
import { RangeFilter } from './range-filter'
|
|
29
|
+
import type { RangeCustomPickerProps } from './range-filter'
|
|
30
|
+
import { SearchFilter } from './search-filter'
|
|
31
|
+
import { SelectFilter } from './select-filter'
|
|
32
|
+
import { ToggleFilter } from './toggle-filter'
|
|
33
|
+
import { ViewTabs } from './view-tabs'
|
|
34
|
+
|
|
35
|
+
type Router = { state: { status: string; location: { search: unknown } } }
|
|
36
|
+
|
|
37
|
+
async function mountPage(input: {
|
|
38
|
+
validateSearch: (raw: Record<string, unknown>) => Record<string, unknown>
|
|
39
|
+
entry: string
|
|
40
|
+
Page: () => ReactNode
|
|
41
|
+
}): Promise<Router> {
|
|
42
|
+
const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
|
43
|
+
const pageRoute = createRoute({
|
|
44
|
+
getParentRoute: () => rootRoute,
|
|
45
|
+
path: '/dashboard',
|
|
46
|
+
validateSearch: input.validateSearch,
|
|
47
|
+
component: input.Page,
|
|
48
|
+
})
|
|
49
|
+
const router = createRouter({
|
|
50
|
+
routeTree: rootRoute.addChildren([pageRoute]),
|
|
51
|
+
history: createMemoryHistory({ initialEntries: [input.entry] }),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
render(
|
|
55
|
+
<MantineProvider>
|
|
56
|
+
<RouterProvider router={router} />
|
|
57
|
+
</MantineProvider>,
|
|
58
|
+
)
|
|
59
|
+
await waitFor(() => {
|
|
60
|
+
expect(router.state.status).toBe('idle')
|
|
61
|
+
})
|
|
62
|
+
return router as unknown as Router
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function search(router: Router): Record<string, unknown> {
|
|
66
|
+
return router.state.location.search as Record<string, unknown>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Press a pill and let its popover mount. The `act` wrapper is load-bearing: Mantine's `Popover` is
|
|
71
|
+
* a controlled surface behind a `Transition`, so the dropdown appears one flushed effect cycle after
|
|
72
|
+
* the click — a bare `fireEvent.click` leaves the body holding the button and nothing else.
|
|
73
|
+
*
|
|
74
|
+
* Queries INSIDE a dropdown pass `{ hidden: true }`: the dropdown is a portal still carrying its
|
|
75
|
+
* transition styles, which Testing Library reads as outside the accessibility tree. That is a
|
|
76
|
+
* harness fact, not a claim about the control — where visibility IS the assertion (`ViewTabs`'
|
|
77
|
+
* CSS swap below) the bare query is used deliberately.
|
|
78
|
+
*/
|
|
79
|
+
async function openPill(name: string | RegExp): Promise<void> {
|
|
80
|
+
await act(async () => {
|
|
81
|
+
fireEvent.click(screen.getByRole('button', { name }))
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A stand-in for `basalt-ui/controls-dates`' `DateRangePicker` — the injected-seam contract, and
|
|
86
|
+
* nothing else, so the range tests need no `@mantine/dates` at all. */
|
|
87
|
+
function Picker({ onChange }: RangeCustomPickerProps): ReactNode {
|
|
88
|
+
return (
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
onClick={() => {
|
|
92
|
+
onChange({ from: '2026-03-01', to: '2026-03-14' })
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
pick march
|
|
96
|
+
</button>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
localStorage.clear()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('SelectFilter', () => {
|
|
105
|
+
const store = createSearchStore({
|
|
106
|
+
key: 'c-select',
|
|
107
|
+
fields: { currency: field.enum(['USD', 'EUR'], 'USD') },
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('reads the SELECTED option at the fallback too, and writes the URL', async () => {
|
|
111
|
+
// The pill is a READOUT, so it prints the value at every value — including the field's default.
|
|
112
|
+
// It used to print the filter's NAME while `isDefault` held, which is how the playground's bar
|
|
113
|
+
// read `Compare` over a field holding `'previous'`. `data-active` is the channel that says
|
|
114
|
+
// "touched", and it is still the only one.
|
|
115
|
+
const router = await mountPage({
|
|
116
|
+
validateSearch: store.validateSearch,
|
|
117
|
+
entry: '/dashboard',
|
|
118
|
+
Page: () => <SelectFilter field={store.field.currency} label="Currency" />,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const pill = screen.getByRole('button', { name: 'USD' })
|
|
122
|
+
expect(pill.hasAttribute('data-active')).toBe(false)
|
|
123
|
+
expect(screen.queryByRole('button', { name: 'Currency' })).toBeNull()
|
|
124
|
+
|
|
125
|
+
await openPill('USD')
|
|
126
|
+
fireEvent.click(screen.getByRole('radio', { name: 'EUR', hidden: true }))
|
|
127
|
+
|
|
128
|
+
await waitFor(() => {
|
|
129
|
+
expect(search(router)['currency']).toBe('EUR')
|
|
130
|
+
})
|
|
131
|
+
const active = screen.getByRole('button', { name: 'EUR' })
|
|
132
|
+
expect(active.hasAttribute('data-active')).toBe(true)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('the popover names the FILTER, not the value the pill happens to read', async () => {
|
|
136
|
+
// Without this the dropdown announces 'EUR, radio button, 1 of 2' and never the word Currency:
|
|
137
|
+
// the pill's visible text is the VALUE once set, so it cannot be the group's name.
|
|
138
|
+
await mountPage({
|
|
139
|
+
validateSearch: store.validateSearch,
|
|
140
|
+
entry: '/dashboard?currency=EUR',
|
|
141
|
+
Page: () => <SelectFilter field={store.field.currency} label="Currency" />,
|
|
142
|
+
})
|
|
143
|
+
await openPill('EUR')
|
|
144
|
+
expect(screen.getByRole('radiogroup', { name: 'Currency', hidden: true })).toBeDefined()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('a deep link is what the pill reads, with no interaction at all', async () => {
|
|
148
|
+
await mountPage({
|
|
149
|
+
validateSearch: store.validateSearch,
|
|
150
|
+
entry: '/dashboard?currency=EUR',
|
|
151
|
+
Page: () => <SelectFilter field={store.field.currency} label="Currency" />,
|
|
152
|
+
})
|
|
153
|
+
expect(screen.getByRole('button', { name: 'EUR' })).toBeDefined()
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Every reset a control owns — the `FilterSet` registration and the popover's own `Clear` — calls
|
|
159
|
+
* `field.clear()`, never `setValue(field.fallback)`.
|
|
160
|
+
*
|
|
161
|
+
* On the URL lane the two look identical (`clear()` navigates back to the fallback params, so the
|
|
162
|
+
* search object reads the same either way) and they differ in the MIRROR: writing the fallback
|
|
163
|
+
* PERSISTS it, as if the user had chosen the default, which then outranks a later change to the
|
|
164
|
+
* field's own default and pins a thunk fallback outright. Clearing removes the key.
|
|
165
|
+
*/
|
|
166
|
+
describe('a control resets by CLEARING its field, never by writing the fallback back', () => {
|
|
167
|
+
const store = createSearchStore({
|
|
168
|
+
key: 'c-reset-clear',
|
|
169
|
+
fields: {
|
|
170
|
+
currency: field.enum(['USD', 'EUR'], 'USD'),
|
|
171
|
+
// A SECOND child, so one folds on mobile and `FilterSet` renders the sheet that owns
|
|
172
|
+
// `Reset all` at all — with a single child there is nothing to fold and no sheet.
|
|
173
|
+
region: field.enum(['all', 'eu'], 'all'),
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
/** Did the mirror keep a value for `currency`? */
|
|
178
|
+
function mirrored(): boolean {
|
|
179
|
+
return Object.hasOwn(store.readStored(), 'currency')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
test("Reset all drops the mirror key and leaves the URL on the field's fallback", async () => {
|
|
183
|
+
const router = await mountPage({
|
|
184
|
+
validateSearch: store.validateSearch,
|
|
185
|
+
entry: '/dashboard',
|
|
186
|
+
Page: () => (
|
|
187
|
+
<FilterSet>
|
|
188
|
+
<SelectFilter field={store.field.currency} label="Currency" />
|
|
189
|
+
<SelectFilter field={store.field.region} label="Region" />
|
|
190
|
+
</FilterSet>
|
|
191
|
+
),
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
await openPill('USD')
|
|
195
|
+
await act(async () => {
|
|
196
|
+
fireEvent.click(screen.getByRole('radio', { name: 'EUR', hidden: true }))
|
|
197
|
+
})
|
|
198
|
+
await waitFor(() => {
|
|
199
|
+
expect(search(router)['currency']).toBe('EUR')
|
|
200
|
+
})
|
|
201
|
+
// The write persisted it — which is what makes the reset's effect on the mirror observable.
|
|
202
|
+
expect(mirrored()).toBe(true)
|
|
203
|
+
|
|
204
|
+
// `hidden: true` — the currency popover is still open, and its portal leaves the rest of the
|
|
205
|
+
// document out of the accessibility tree (the same harness fact every in-dropdown query here
|
|
206
|
+
// carries). The count is registry state, so it lands a flush after the write.
|
|
207
|
+
const sheetPill = await waitFor(() =>
|
|
208
|
+
screen.getByRole('button', { name: 'Filters (1)', hidden: true }),
|
|
209
|
+
)
|
|
210
|
+
await act(async () => {
|
|
211
|
+
fireEvent.click(sheetPill)
|
|
212
|
+
})
|
|
213
|
+
await act(async () => {
|
|
214
|
+
fireEvent.click(screen.getByRole('button', { name: 'Reset all', hidden: true }))
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
await waitFor(() => {
|
|
218
|
+
expect(search(router)['currency']).toBe('USD')
|
|
219
|
+
})
|
|
220
|
+
expect(mirrored()).toBe(false)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test("the popover's own Clear action clears too", async () => {
|
|
224
|
+
const router = await mountPage({
|
|
225
|
+
validateSearch: store.validateSearch,
|
|
226
|
+
entry: '/dashboard',
|
|
227
|
+
Page: () => <SelectFilter field={store.field.currency} label="Currency" clearable />,
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
await openPill('USD')
|
|
231
|
+
fireEvent.click(screen.getByRole('radio', { name: 'EUR', hidden: true }))
|
|
232
|
+
await waitFor(() => {
|
|
233
|
+
expect(mirrored()).toBe(true)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
await openPill('EUR')
|
|
237
|
+
await act(async () => {
|
|
238
|
+
fireEvent.click(screen.getByRole('button', { name: 'Clear', hidden: true }))
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
await waitFor(() => {
|
|
242
|
+
expect(search(router)['currency']).toBe('USD')
|
|
243
|
+
})
|
|
244
|
+
expect(mirrored()).toBe(false)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The runtime-catalogue shape: `options` overrides the field's own rows, and a `StringField` handle
|
|
250
|
+
* becomes legal once it does. Both halves are asserted through a REAL `createSearchStore`, because
|
|
251
|
+
* the string lane is where a hand-rolled `value`/`onChange` would look identical and write nothing.
|
|
252
|
+
*/
|
|
253
|
+
describe('SelectFilter — options as a runtime catalogue', () => {
|
|
254
|
+
const store = createSearchStore({
|
|
255
|
+
key: 'c-select-options',
|
|
256
|
+
fields: { currency: field.enum(['USD', 'EUR'], 'USD'), projectId: field.string() },
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
const RATES = [
|
|
260
|
+
{ value: 'USD', label: 'USD · 1.00' },
|
|
261
|
+
{ value: 'EUR', label: 'EUR · 1.08' },
|
|
262
|
+
]
|
|
263
|
+
const PROJECTS = [
|
|
264
|
+
{ value: 'argo', label: 'Argo' },
|
|
265
|
+
{ value: 'linewatch', label: 'Linewatch' },
|
|
266
|
+
{ value: 'retired', label: 'Retired app', disabled: true },
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
test('the rows and their labels come from the prop, and the field is still what is written', async () => {
|
|
270
|
+
const router = await mountPage({
|
|
271
|
+
validateSearch: store.validateSearch,
|
|
272
|
+
entry: '/dashboard',
|
|
273
|
+
Page: () => <SelectFilter field={store.field.currency} label="Currency" options={RATES} />,
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
// The pill reads the catalogue's label for the CURRENT value, fallback included — `USD · 1.00`,
|
|
277
|
+
// never `Currency`.
|
|
278
|
+
await openPill('USD · 1.00')
|
|
279
|
+
// The override is whole, not a merge: the field's own bare `EUR` row is GONE, which is the
|
|
280
|
+
// property a merge would silently break.
|
|
281
|
+
expect(screen.queryByRole('radio', { name: 'EUR', hidden: true })).toBeNull()
|
|
282
|
+
fireEvent.click(screen.getByRole('radio', { name: 'EUR · 1.08', hidden: true }))
|
|
283
|
+
|
|
284
|
+
await waitFor(() => {
|
|
285
|
+
expect(search(router)['currency']).toBe('EUR')
|
|
286
|
+
})
|
|
287
|
+
expect(screen.getByRole('button', { name: 'EUR · 1.08' })).toBeDefined()
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('a string field plus options round-trips an id no enum could have declared', async () => {
|
|
291
|
+
const router = await mountPage({
|
|
292
|
+
validateSearch: store.validateSearch,
|
|
293
|
+
entry: '/dashboard',
|
|
294
|
+
Page: () => <SelectFilter field={store.field.projectId} label="Project" options={PROJECTS} />,
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
expect(screen.getByRole('button', { name: 'Project' })).toBeDefined()
|
|
298
|
+
await openPill('Project')
|
|
299
|
+
fireEvent.click(screen.getByRole('radio', { name: 'Argo', hidden: true }))
|
|
300
|
+
|
|
301
|
+
await waitFor(() => {
|
|
302
|
+
expect(search(router)['projectId']).toBe('argo')
|
|
303
|
+
})
|
|
304
|
+
expect(screen.getByRole('button', { name: 'Argo' })).toBeDefined()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('a deep link into that id set is read with no interaction at all', async () => {
|
|
308
|
+
await mountPage({
|
|
309
|
+
validateSearch: store.validateSearch,
|
|
310
|
+
entry: '/dashboard?projectId=linewatch',
|
|
311
|
+
Page: () => <SelectFilter field={store.field.projectId} label="Project" options={PROJECTS} />,
|
|
312
|
+
})
|
|
313
|
+
expect(screen.getByRole('button', { name: 'Linewatch' })).toBeDefined()
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test('a disabled catalogue row is offered and refused — it still labels the value it names', async () => {
|
|
317
|
+
await mountPage({
|
|
318
|
+
validateSearch: store.validateSearch,
|
|
319
|
+
entry: '/dashboard?projectId=retired',
|
|
320
|
+
Page: () => <SelectFilter field={store.field.projectId} label="Project" options={PROJECTS} />,
|
|
321
|
+
})
|
|
322
|
+
// A live catalogue cannot express "archived" by omitting the row — the URL may already point at
|
|
323
|
+
// it, and a missing row would read as the filter's default.
|
|
324
|
+
expect(screen.getByRole('button', { name: 'Retired app' })).toBeDefined()
|
|
325
|
+
await openPill('Retired app')
|
|
326
|
+
expect(
|
|
327
|
+
screen.getByRole('radio', { name: 'Retired app', hidden: true }).hasAttribute('disabled'),
|
|
328
|
+
).toBe(true)
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
describe('CompareFilter', () => {
|
|
333
|
+
const store = createSearchStore({
|
|
334
|
+
key: 'c-compare',
|
|
335
|
+
fields: { compare: field.enum(['none', 'previous', 'year'], 'none') },
|
|
336
|
+
}).labels({ compare: { none: 'No comparison', previous: 'Previous period', year: 'Last year' } })
|
|
337
|
+
|
|
338
|
+
test('reads the selected basis at the fallback too, and writes the chosen one', async () => {
|
|
339
|
+
const router = await mountPage({
|
|
340
|
+
validateSearch: store.validateSearch,
|
|
341
|
+
entry: '/dashboard',
|
|
342
|
+
Page: () => <CompareFilter field={store.field.compare} />,
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
// At the fallback the pill reads `No comparison`, not `Compare` — the exact regression the
|
|
346
|
+
// playground's bar showed, where the pill said `Compare` while the URL said `previous`.
|
|
347
|
+
await openPill('No comparison')
|
|
348
|
+
fireEvent.click(screen.getByRole('radio', { name: 'Previous period', hidden: true }))
|
|
349
|
+
|
|
350
|
+
await waitFor(() => {
|
|
351
|
+
expect(search(router)['compare']).toBe('previous')
|
|
352
|
+
})
|
|
353
|
+
expect(screen.getByRole('button', { name: 'Previous period' })).toBeDefined()
|
|
354
|
+
expect(screen.queryByRole('button', { name: 'Compare' })).toBeNull()
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
test('a consumer label wins over basalt’s default for the same value', async () => {
|
|
358
|
+
// This store labels `year` as `Last year`. basalt's default is `Same period last year`; a
|
|
359
|
+
// deliberate label is not something the framework overrides.
|
|
360
|
+
await mountPage({
|
|
361
|
+
validateSearch: store.validateSearch,
|
|
362
|
+
entry: '/dashboard?compare=year',
|
|
363
|
+
Page: () => <CompareFilter field={store.field.compare} />,
|
|
364
|
+
})
|
|
365
|
+
expect(screen.getByRole('button', { name: 'Last year' })).toBeDefined()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
test('label= still names the POPOVER, which is the only thing it names now', async () => {
|
|
369
|
+
await mountPage({
|
|
370
|
+
validateSearch: store.validateSearch,
|
|
371
|
+
entry: '/dashboard',
|
|
372
|
+
Page: () => <CompareFilter field={store.field.compare} />,
|
|
373
|
+
})
|
|
374
|
+
await openPill('No comparison')
|
|
375
|
+
expect(screen.getByRole('radiogroup', { name: 'Compare', hidden: true })).toBeDefined()
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
describe('CompareFilter — the three option labels are basalt’s, not each app’s', () => {
|
|
380
|
+
// An UNLABELLED field: `field.enum` labels each option with the raw value until `store.labels()`
|
|
381
|
+
// is called, so this popover used to read `none` / `previous` / `year` and every consumer wrote
|
|
382
|
+
// the same three strings itself. `COMPARE_LABELS` is the default the control now supplies.
|
|
383
|
+
const store = createSearchStore({
|
|
384
|
+
key: 'c-compare-unlabelled',
|
|
385
|
+
fields: { compare: field.enum(['none', 'previous', 'year'], 'none') },
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
test('an unlabelled field still renders the three spec’d strings', async () => {
|
|
389
|
+
await mountPage({
|
|
390
|
+
validateSearch: store.validateSearch,
|
|
391
|
+
entry: '/dashboard',
|
|
392
|
+
Page: () => <CompareFilter field={store.field.compare} />,
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
expect(COMPARE_LABELS).toEqual({
|
|
396
|
+
none: 'No comparison',
|
|
397
|
+
previous: 'Previous period',
|
|
398
|
+
year: 'Same period last year',
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
await openPill('No comparison')
|
|
402
|
+
for (const value of COMPARE_VALUES) {
|
|
403
|
+
expect(screen.getByRole('radio', { name: COMPARE_LABELS[value], hidden: true })).toBeDefined()
|
|
404
|
+
}
|
|
405
|
+
expect(screen.queryByRole('radio', { name: 'previous', hidden: true })).toBeNull()
|
|
406
|
+
})
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
describe('MultiSelectFilter', () => {
|
|
410
|
+
const store = createSearchStore({
|
|
411
|
+
key: 'c-multi',
|
|
412
|
+
fields: { channels: field.multi(['web', 'email', 'social'], []) },
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test('reads the label while the selection carries no information, then a count', async () => {
|
|
416
|
+
const router = await mountPage({
|
|
417
|
+
validateSearch: store.validateSearch,
|
|
418
|
+
entry: '/dashboard',
|
|
419
|
+
Page: () => (
|
|
420
|
+
<MultiSelectFilter field={store.field.channels} label="All channels" noun="channels" />
|
|
421
|
+
),
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
expect(screen.getByRole('button', { name: 'All channels' })).toBeDefined()
|
|
425
|
+
|
|
426
|
+
await openPill('All channels')
|
|
427
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'web', hidden: true }))
|
|
428
|
+
|
|
429
|
+
await waitFor(() => {
|
|
430
|
+
expect(search(router)['channels']).toEqual(['web'])
|
|
431
|
+
})
|
|
432
|
+
expect(screen.getByRole('button', { name: '1 channels' })).toBeDefined()
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
test('every option selected is the same information as none — the label comes back', async () => {
|
|
436
|
+
await mountPage({
|
|
437
|
+
validateSearch: store.validateSearch,
|
|
438
|
+
entry: '/dashboard?channels=web&channels=email&channels=social',
|
|
439
|
+
Page: () => <MultiSelectFilter field={store.field.channels} label="All channels" />,
|
|
440
|
+
})
|
|
441
|
+
expect(screen.getByRole('button', { name: 'All channels' })).toBeDefined()
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
test("options relabels the boxes while the values stay the field's own", async () => {
|
|
445
|
+
const router = await mountPage({
|
|
446
|
+
validateSearch: store.validateSearch,
|
|
447
|
+
entry: '/dashboard',
|
|
448
|
+
Page: () => (
|
|
449
|
+
<MultiSelectFilter
|
|
450
|
+
field={store.field.channels}
|
|
451
|
+
label="All channels"
|
|
452
|
+
noun="channels"
|
|
453
|
+
options={[
|
|
454
|
+
{ value: 'web', label: 'web · 1.2k' },
|
|
455
|
+
{ value: 'email', label: 'email · 340' },
|
|
456
|
+
]}
|
|
457
|
+
/>
|
|
458
|
+
),
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
await openPill('All channels')
|
|
462
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'web · 1.2k', hidden: true }))
|
|
463
|
+
|
|
464
|
+
await waitFor(() => {
|
|
465
|
+
expect(search(router)['channels']).toEqual(['web'])
|
|
466
|
+
})
|
|
467
|
+
// Two rows shown, not the field's three — so `1 channels` is counted against what is offered.
|
|
468
|
+
expect(screen.getByRole('button', { name: '1 channels' })).toBeDefined()
|
|
469
|
+
})
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
describe('ToggleFilter', () => {
|
|
473
|
+
const store = createSearchStore({
|
|
474
|
+
key: 'c-toggle',
|
|
475
|
+
fields: { errorsOnly: field.boolean(false) },
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
test('the pill IS the control — one press writes the field, no popover', async () => {
|
|
479
|
+
const router = await mountPage({
|
|
480
|
+
validateSearch: store.validateSearch,
|
|
481
|
+
entry: '/dashboard',
|
|
482
|
+
Page: () => <ToggleFilter field={store.field.errorsOnly} label="Errors only" />,
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
fireEvent.click(screen.getByRole('button', { name: 'Errors only' }))
|
|
486
|
+
await waitFor(() => {
|
|
487
|
+
expect(search(router)['errorsOnly']).toBe(true)
|
|
488
|
+
})
|
|
489
|
+
expect(screen.getByRole('button', { name: 'Errors only' }).hasAttribute('data-active')).toBe(
|
|
490
|
+
true,
|
|
491
|
+
)
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
test('the on/off state is announced, not only drawn as a border colour', async () => {
|
|
495
|
+
const router = await mountPage({
|
|
496
|
+
validateSearch: store.validateSearch,
|
|
497
|
+
entry: '/dashboard',
|
|
498
|
+
Page: () => <ToggleFilter field={store.field.errorsOnly} label="Errors only" />,
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
expect(screen.getByRole('button', { name: 'Errors only', pressed: false })).toBeDefined()
|
|
502
|
+
fireEvent.click(screen.getByRole('button', { name: 'Errors only' }))
|
|
503
|
+
await waitFor(() => {
|
|
504
|
+
expect(search(router)['errorsOnly']).toBe(true)
|
|
505
|
+
})
|
|
506
|
+
expect(screen.getByRole('button', { name: 'Errors only', pressed: true })).toBeDefined()
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
describe('RangeFilter', () => {
|
|
511
|
+
const store = createSearchStore({
|
|
512
|
+
key: 'c-range',
|
|
513
|
+
fields: {
|
|
514
|
+
range: field.range({ presets: ['7d', '30d', '90d'], fallback: '30d', custom: true }),
|
|
515
|
+
},
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
test('the pill reads the preset label and a press writes the preset param', async () => {
|
|
519
|
+
const router = await mountPage({
|
|
520
|
+
validateSearch: store.validateSearch,
|
|
521
|
+
entry: '/dashboard',
|
|
522
|
+
Page: () => <RangeFilter field={store.field.range} customPicker={Picker} />,
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
expect(screen.getByRole('button', { name: '30d' })).toBeDefined()
|
|
526
|
+
|
|
527
|
+
await openPill('30d')
|
|
528
|
+
fireEvent.click(screen.getByRole('radio', { name: '7d', hidden: true }))
|
|
529
|
+
|
|
530
|
+
await waitFor(() => {
|
|
531
|
+
expect(search(router)['range']).toBe('7d')
|
|
532
|
+
})
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
test('the pill never reads `label`, at the fallback or anywhere else', async () => {
|
|
536
|
+
// Same law as `SelectFilter`/`CompareFilter`: `label` is the popover heading and the accessible
|
|
537
|
+
// name, and the pill text is the value. `30d` here IS the fallback.
|
|
538
|
+
await mountPage({
|
|
539
|
+
validateSearch: store.validateSearch,
|
|
540
|
+
entry: '/dashboard',
|
|
541
|
+
Page: () => <RangeFilter field={store.field.range} label="Window" customPicker={Picker} />,
|
|
542
|
+
})
|
|
543
|
+
expect(screen.getByRole('button', { name: '30d' })).toBeDefined()
|
|
544
|
+
expect(screen.queryByRole('button', { name: 'Window' })).toBeNull()
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
test('the preset track carries the filter name — a radiogroup must not be anonymous', async () => {
|
|
548
|
+
await mountPage({
|
|
549
|
+
validateSearch: store.validateSearch,
|
|
550
|
+
entry: '/dashboard',
|
|
551
|
+
Page: () => <RangeFilter field={store.field.range} label="Window" />,
|
|
552
|
+
})
|
|
553
|
+
await openPill('30d')
|
|
554
|
+
expect(screen.getByRole('radiogroup', { name: 'Window', hidden: true })).toBeDefined()
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
test('numeric preset labels get the mono treatment via data-numeric', async () => {
|
|
558
|
+
await mountPage({
|
|
559
|
+
validateSearch: store.validateSearch,
|
|
560
|
+
entry: '/dashboard',
|
|
561
|
+
Page: () => <RangeFilter field={store.field.range} />,
|
|
562
|
+
})
|
|
563
|
+
expect(screen.getByRole('button', { name: '30d' }).hasAttribute('data-numeric')).toBe(true)
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
test('the injected picker writes preset=custom plus both ISO dates, and the pill reads the span', async () => {
|
|
567
|
+
const router = await mountPage({
|
|
568
|
+
validateSearch: store.validateSearch,
|
|
569
|
+
entry: '/dashboard',
|
|
570
|
+
Page: () => <RangeFilter field={store.field.range} customPicker={Picker} />,
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
await openPill('30d')
|
|
574
|
+
fireEvent.click(screen.getByRole('button', { name: 'pick march', hidden: true }))
|
|
575
|
+
|
|
576
|
+
await waitFor(() => {
|
|
577
|
+
expect(search(router)['range']).toBe('custom')
|
|
578
|
+
})
|
|
579
|
+
expect(search(router)['from']).toBe('2026-03-01')
|
|
580
|
+
expect(search(router)['to']).toBe('2026-03-14')
|
|
581
|
+
expect(screen.getByRole('button', { name: /Mar 1/ }).textContent).toContain('Mar 1 – Mar 14')
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
test('no picker is injected → no custom affordance, presets only', async () => {
|
|
585
|
+
await mountPage({
|
|
586
|
+
validateSearch: store.validateSearch,
|
|
587
|
+
entry: '/dashboard',
|
|
588
|
+
Page: () => <RangeFilter field={store.field.range} />,
|
|
589
|
+
})
|
|
590
|
+
await openPill('30d')
|
|
591
|
+
expect(screen.queryByRole('button', { name: 'pick march', hidden: true })).toBeNull()
|
|
592
|
+
expect(screen.queryByRole('radio', { name: 'custom', hidden: true })).toBeNull()
|
|
593
|
+
})
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
describe('SearchFilter', () => {
|
|
597
|
+
const store = createSearchStore({
|
|
598
|
+
key: 'c-search',
|
|
599
|
+
fields: { q: field.string({ max: 40 }) },
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
test('keystrokes land on the field only after the debounce', async () => {
|
|
603
|
+
const router = await mountPage({
|
|
604
|
+
validateSearch: store.validateSearch,
|
|
605
|
+
entry: '/dashboard',
|
|
606
|
+
Page: () => <SearchFilter field={store.field.q} placeholder="Search" />,
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
fireEvent.change(screen.getByPlaceholderText('Search'), { target: { value: 'invo' } })
|
|
610
|
+
// Same tick: the box shows it, the URL does not.
|
|
611
|
+
expect((screen.getByPlaceholderText('Search') as HTMLInputElement).value).toBe('invo')
|
|
612
|
+
expect(search(router)['q']).toBe('')
|
|
613
|
+
|
|
614
|
+
await waitFor(() => {
|
|
615
|
+
expect(search(router)['q']).toBe('invo')
|
|
616
|
+
})
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
test('the box is named even with no placeholder — and stays named while typing', async () => {
|
|
620
|
+
await mountPage({
|
|
621
|
+
validateSearch: store.validateSearch,
|
|
622
|
+
entry: '/dashboard',
|
|
623
|
+
Page: () => <SearchFilter field={store.field.q} />,
|
|
624
|
+
})
|
|
625
|
+
expect(screen.getByRole('textbox', { name: 'Search' })).toBeDefined()
|
|
626
|
+
})
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* `ViewTabs` — the CSS swap, asserted rather than assumed. `MantineProvider` injects the
|
|
631
|
+
* `visible-from`/`hidden-from` media rules as a real stylesheet, and happy-dom evaluates them at its
|
|
632
|
+
* default (desktop) viewport — so the DESKTOP form is in the accessibility tree and the phone form is
|
|
633
|
+
* `display: none`. `{ hidden: true }` counts what is MOUNTED, the bare query counts what is
|
|
634
|
+
* REACHABLE, and the pair is what proves "one mount each, switched by CSS" (C9).
|
|
635
|
+
*/
|
|
636
|
+
describe('ViewTabs', () => {
|
|
637
|
+
test('three options or fewer: both forms are tracks, one mounted each, only one reachable', async () => {
|
|
638
|
+
const store = createSearchStore({
|
|
639
|
+
key: 'c-tabs-3',
|
|
640
|
+
fields: { tab: field.enum(['a', 'b', 'c'], 'a') },
|
|
641
|
+
})
|
|
642
|
+
const router = await mountPage({
|
|
643
|
+
validateSearch: store.validateSearch,
|
|
644
|
+
entry: '/dashboard',
|
|
645
|
+
Page: () => <ViewTabs field={store.field.tab} />,
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
expect(screen.getAllByRole('radio', { name: 'b', hidden: true })).toHaveLength(2)
|
|
649
|
+
expect(screen.getAllByRole('radio', { name: 'b' })).toHaveLength(1)
|
|
650
|
+
expect(screen.queryByRole('combobox', { hidden: true })).toBeNull()
|
|
651
|
+
|
|
652
|
+
fireEvent.click(screen.getByRole('radio', { name: 'b' }))
|
|
653
|
+
await waitFor(() => {
|
|
654
|
+
expect(search(router)['tab']).toBe('b')
|
|
655
|
+
})
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
test('past three options the phone form becomes a Select, not a track', async () => {
|
|
659
|
+
const store = createSearchStore({
|
|
660
|
+
key: 'c-tabs-4',
|
|
661
|
+
fields: { tab: field.enum(['a', 'b', 'c', 'd'], 'a') },
|
|
662
|
+
})
|
|
663
|
+
await mountPage({
|
|
664
|
+
validateSearch: store.validateSearch,
|
|
665
|
+
entry: '/dashboard',
|
|
666
|
+
Page: () => <ViewTabs field={store.field.tab} />,
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
expect(screen.getAllByRole('radio', { name: 'b', hidden: true })).toHaveLength(1)
|
|
670
|
+
expect(screen.getByRole('combobox', { hidden: true })).toBeDefined()
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
test('both forms carry an accessible name — neither is an anonymous radiogroup/combobox', async () => {
|
|
674
|
+
const store = createSearchStore({
|
|
675
|
+
key: 'c-tabs-named',
|
|
676
|
+
fields: { tab: field.enum(['a', 'b', 'c', 'd'], 'a') },
|
|
677
|
+
})
|
|
678
|
+
await mountPage({
|
|
679
|
+
validateSearch: store.validateSearch,
|
|
680
|
+
entry: '/dashboard',
|
|
681
|
+
Page: () => <ViewTabs field={store.field.tab} label="Section" />,
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
expect(screen.getByRole('radiogroup', { name: 'Section', hidden: true })).toBeDefined()
|
|
685
|
+
expect(screen.getByRole('combobox', { name: 'Section', hidden: true })).toBeDefined()
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
test("the name defaults to 'View' rather than being absent", async () => {
|
|
689
|
+
const store = createSearchStore({
|
|
690
|
+
key: 'c-tabs-default-name',
|
|
691
|
+
fields: { tab: field.enum(['a', 'b'], 'a') },
|
|
692
|
+
})
|
|
693
|
+
await mountPage({
|
|
694
|
+
validateSearch: store.validateSearch,
|
|
695
|
+
entry: '/dashboard',
|
|
696
|
+
Page: () => <ViewTabs field={store.field.tab} />,
|
|
697
|
+
})
|
|
698
|
+
// Read off the attribute rather than through the accessible-name query: the phone twin is
|
|
699
|
+
// `display: none` on this viewport, and `dom-accessibility-api` declines to compute a name for a
|
|
700
|
+
// hidden `radiogroup` — which would make the assertion pass for the wrong reason.
|
|
701
|
+
const groups = screen.getAllByRole('radiogroup', { hidden: true })
|
|
702
|
+
expect(groups).toHaveLength(2)
|
|
703
|
+
expect(groups.map((group) => group.getAttribute('aria-label'))).toEqual(['View', 'View'])
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
test("only: 'sm-down' keeps an option off the desktop form entirely", async () => {
|
|
707
|
+
const store = createSearchStore({
|
|
708
|
+
key: 'c-tabs-only',
|
|
709
|
+
fields: { tab: field.enum(['overview', 'train'], 'overview') },
|
|
710
|
+
})
|
|
711
|
+
await mountPage({
|
|
712
|
+
validateSearch: store.validateSearch,
|
|
713
|
+
entry: '/dashboard',
|
|
714
|
+
Page: () => (
|
|
715
|
+
<ViewTabs
|
|
716
|
+
field={store.field.tab}
|
|
717
|
+
options={[
|
|
718
|
+
{ value: 'overview', label: 'Overview' },
|
|
719
|
+
{ value: 'train', label: 'Train', only: 'sm-down' },
|
|
720
|
+
]}
|
|
721
|
+
/>
|
|
722
|
+
),
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
expect(screen.getAllByRole('radio', { name: 'Overview', hidden: true })).toHaveLength(2)
|
|
726
|
+
expect(screen.getAllByRole('radio', { name: 'Train', hidden: true })).toHaveLength(1)
|
|
727
|
+
// And the one that exists is the PHONE one — unreachable on this viewport.
|
|
728
|
+
expect(screen.queryAllByRole('radio', { name: 'Train' })).toHaveLength(0)
|
|
729
|
+
})
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* `NumberFilter` — the numeric lane, and the one control whose two forms are genuinely different
|
|
734
|
+
* components. `options` renders the same radio body every enum filter renders (through
|
|
735
|
+
* `EnumFilter`'s `ChoiceHandle`), so what needs asserting there is that the URL still holds a
|
|
736
|
+
* NUMBER; without `options` it is a stepper, and what needs asserting is WHEN a keystroke becomes a
|
|
737
|
+
* navigation.
|
|
738
|
+
*/
|
|
739
|
+
describe('NumberFilter — the options form', () => {
|
|
740
|
+
const store = createSearchStore({
|
|
741
|
+
key: 'c-number-options',
|
|
742
|
+
fields: { nights: field.number({ fallback: 2, min: 1, max: 14, int: true }) },
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
const NIGHTS = [
|
|
746
|
+
{ value: 1, label: '1 night' },
|
|
747
|
+
{ value: 2, label: '2 nights' },
|
|
748
|
+
{ value: 7, label: 'A week' },
|
|
749
|
+
]
|
|
750
|
+
|
|
751
|
+
test('the pill reads the selected label and the URL keeps a NUMBER, not a numeral string', async () => {
|
|
752
|
+
const router = await mountPage({
|
|
753
|
+
validateSearch: store.validateSearch,
|
|
754
|
+
entry: '/dashboard',
|
|
755
|
+
Page: () => <NumberFilter field={store.field.nights} label="Nights" options={NIGHTS} />,
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
const pill = screen.getByRole('button', { name: '2 nights' })
|
|
759
|
+
expect(pill.hasAttribute('data-active')).toBe(false)
|
|
760
|
+
// Mono, because the values ARE numbers — the same law a numeric SegmentedControl label follows.
|
|
761
|
+
expect(pill.hasAttribute('data-numeric')).toBe(true)
|
|
762
|
+
|
|
763
|
+
await openPill('2 nights')
|
|
764
|
+
fireEvent.click(screen.getByRole('radio', { name: 'A week', hidden: true }))
|
|
765
|
+
|
|
766
|
+
await waitFor(() => {
|
|
767
|
+
// The whole reason this control exists: argo widened `nights` into a string enum and got
|
|
768
|
+
// `'7'` in the URL, which makes every downstream comparison a parse.
|
|
769
|
+
expect(search(router)['nights']).toBe(7)
|
|
770
|
+
})
|
|
771
|
+
expect(typeof search(router)['nights']).toBe('number')
|
|
772
|
+
expect(screen.getByRole('button', { name: 'A week' }).hasAttribute('data-active')).toBe(true)
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
test('the popover names the FILTER — the pill text is the value', async () => {
|
|
776
|
+
await mountPage({
|
|
777
|
+
validateSearch: store.validateSearch,
|
|
778
|
+
entry: '/dashboard?nights=7',
|
|
779
|
+
Page: () => <NumberFilter field={store.field.nights} label="Nights" options={NIGHTS} />,
|
|
780
|
+
})
|
|
781
|
+
await openPill('A week')
|
|
782
|
+
expect(screen.getByRole('radiogroup', { name: 'Nights', hidden: true })).toBeDefined()
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
test('a deep link is what the pill reads, with no interaction', async () => {
|
|
786
|
+
await mountPage({
|
|
787
|
+
validateSearch: store.validateSearch,
|
|
788
|
+
entry: '/dashboard?nights=1',
|
|
789
|
+
Page: () => <NumberFilter field={store.field.nights} label="Nights" options={NIGHTS} />,
|
|
790
|
+
})
|
|
791
|
+
expect(screen.getByRole('button', { name: '1 night' })).toBeDefined()
|
|
792
|
+
})
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
describe('NumberFilter — the stepper form', () => {
|
|
796
|
+
const store = createSearchStore({
|
|
797
|
+
key: 'c-number-stepper',
|
|
798
|
+
fields: { minDuration: field.number({ fallback: 0, min: 0, max: 600 }) },
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
function box(): HTMLInputElement {
|
|
802
|
+
return screen.getByRole('textbox', { name: 'Min duration', hidden: true }) as HTMLInputElement
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
test('typing does not navigate; blur is what commits', async () => {
|
|
806
|
+
const router = await mountPage({
|
|
807
|
+
validateSearch: store.validateSearch,
|
|
808
|
+
entry: '/dashboard',
|
|
809
|
+
Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" step={30} />,
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
await openPill('0')
|
|
813
|
+
fireEvent.change(box(), { target: { value: '120' } })
|
|
814
|
+
// Same tick: the box shows it, the URL does not. An un-gated write would have navigated on
|
|
815
|
+
// `1`, `12` and `120` — three loader runs for one intended threshold, two of them values the
|
|
816
|
+
// user never meant.
|
|
817
|
+
expect(box().value).toBe('120')
|
|
818
|
+
expect(search(router)['minDuration']).toBe(0)
|
|
819
|
+
|
|
820
|
+
fireEvent.blur(box())
|
|
821
|
+
await waitFor(() => {
|
|
822
|
+
expect(search(router)['minDuration']).toBe(120)
|
|
823
|
+
})
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
test('Enter commits without leaving the field', async () => {
|
|
827
|
+
const router = await mountPage({
|
|
828
|
+
validateSearch: store.validateSearch,
|
|
829
|
+
entry: '/dashboard',
|
|
830
|
+
Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" />,
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
await openPill('0')
|
|
834
|
+
fireEvent.change(box(), { target: { value: '45' } })
|
|
835
|
+
fireEvent.keyDown(box(), { key: 'Enter' })
|
|
836
|
+
await waitFor(() => {
|
|
837
|
+
expect(search(router)['minDuration']).toBe(45)
|
|
838
|
+
})
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
test('the field CLAMPS and the box follows it down — never a readout over a different URL', async () => {
|
|
842
|
+
const router = await mountPage({
|
|
843
|
+
validateSearch: store.validateSearch,
|
|
844
|
+
entry: '/dashboard',
|
|
845
|
+
Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" />,
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
await openPill('0')
|
|
849
|
+
fireEvent.change(box(), { target: { value: '9999' } })
|
|
850
|
+
fireEvent.blur(box())
|
|
851
|
+
|
|
852
|
+
await waitFor(() => {
|
|
853
|
+
expect(search(router)['minDuration']).toBe(600)
|
|
854
|
+
})
|
|
855
|
+
// The bound is the FIELD's, applied on write — so the input has to accept the correction rather
|
|
856
|
+
// than keep displaying a value the app is not filtering by.
|
|
857
|
+
await waitFor(() => {
|
|
858
|
+
expect(box().value).toBe('600')
|
|
859
|
+
})
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* The SECOND out-of-range commit is the one that used to desync, and it desynced because nothing
|
|
864
|
+
* moved: the store already held `600`, so the write clamped to the value it was already at, the
|
|
865
|
+
* `[value]` effect never fired, and the box went on reading `8888` over a URL filtering at 600 —
|
|
866
|
+
* the exact readout `useCommittedNumber` exists to prevent. `commit` now clamps locally first, so
|
|
867
|
+
* the draft follows the bound whether or not the store value changes.
|
|
868
|
+
*/
|
|
869
|
+
test('a REPEATED out-of-range Enter still leaves the box on the clamped value', async () => {
|
|
870
|
+
const router = await mountPage({
|
|
871
|
+
validateSearch: store.validateSearch,
|
|
872
|
+
entry: '/dashboard',
|
|
873
|
+
Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" />,
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
await openPill('0')
|
|
877
|
+
fireEvent.change(box(), { target: { value: '9999' } })
|
|
878
|
+
fireEvent.keyDown(box(), { key: 'Enter' })
|
|
879
|
+
await waitFor(() => {
|
|
880
|
+
expect(search(router)['minDuration']).toBe(600)
|
|
881
|
+
})
|
|
882
|
+
await waitFor(() => {
|
|
883
|
+
expect(box().value).toBe('600')
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
// An intermediate in-range keystroke, then a second out-of-range one: react-number-format dedups
|
|
887
|
+
// an identical string, so re-firing `9999` in happy-dom would not register as a change at all.
|
|
888
|
+
// A real browser needs no such help — the digits arrive one keystroke at a time.
|
|
889
|
+
fireEvent.change(box(), { target: { value: '500' } })
|
|
890
|
+
fireEvent.change(box(), { target: { value: '8888' } })
|
|
891
|
+
fireEvent.keyDown(box(), { key: 'Enter' })
|
|
892
|
+
|
|
893
|
+
expect(box().value).toBe('600')
|
|
894
|
+
expect(search(router)['minDuration']).toBe(600)
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
test('an unparseable draft restores the field rather than committing NaN', async () => {
|
|
898
|
+
const router = await mountPage({
|
|
899
|
+
validateSearch: store.validateSearch,
|
|
900
|
+
entry: '/dashboard?minDuration=90',
|
|
901
|
+
Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" />,
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
await openPill('90')
|
|
905
|
+
fireEvent.change(box(), { target: { value: '' } })
|
|
906
|
+
fireEvent.blur(box())
|
|
907
|
+
expect(box().value).toBe('90')
|
|
908
|
+
expect(search(router)['minDuration']).toBe(90)
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* The bounds now come off the HANDLE (`NumberHandleExtras`), so the STEPPER stops at the field's
|
|
913
|
+
* limit instead of letting the codec correct the value one commit later. Asserted through the
|
|
914
|
+
* arrow keys rather than a `min`/`max` DOM attribute: Mantine's `NumberInput` is a text input
|
|
915
|
+
* (`inputmode=decimal`) and clamps in JS, so the attributes do not exist to read — the observable
|
|
916
|
+
* is that stepping cannot leave the range.
|
|
917
|
+
*
|
|
918
|
+
* The clamp test above still stands. It is the backstop for a value that never came through this
|
|
919
|
+
* box at all (a hand-typed URL, a stale deep link).
|
|
920
|
+
*/
|
|
921
|
+
test('the field s min stops the stepper — no `min` prop reached the input before this', async () => {
|
|
922
|
+
await mountPage({
|
|
923
|
+
validateSearch: store.validateSearch,
|
|
924
|
+
entry: '/dashboard',
|
|
925
|
+
Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" step={30} />,
|
|
926
|
+
})
|
|
927
|
+
await openPill('0')
|
|
928
|
+
// At the fallback 0 against `min: 0`. Without the handle's bound this would read `-30`, and a
|
|
929
|
+
// negative threshold is a value the codec would then clamp back — visibly, after the fact.
|
|
930
|
+
fireEvent.keyDown(box(), { key: 'ArrowDown' })
|
|
931
|
+
expect(box().value).toBe(String(store.field.minDuration.min))
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
test('the field s max stops it at the other end', async () => {
|
|
935
|
+
await mountPage({
|
|
936
|
+
validateSearch: store.validateSearch,
|
|
937
|
+
entry: '/dashboard?minDuration=590',
|
|
938
|
+
Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" step={30} />,
|
|
939
|
+
})
|
|
940
|
+
await openPill('590')
|
|
941
|
+
// 590 + 30 would be 620; the field declares `max: 600`.
|
|
942
|
+
fireEvent.keyDown(box(), { key: 'ArrowUp' })
|
|
943
|
+
expect(box().value).toBe(String(store.field.minDuration.max))
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
test('the explicit step is the one the stepper uses', async () => {
|
|
947
|
+
await mountPage({
|
|
948
|
+
validateSearch: store.validateSearch,
|
|
949
|
+
entry: '/dashboard',
|
|
950
|
+
Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" step={30} />,
|
|
951
|
+
})
|
|
952
|
+
await openPill('0')
|
|
953
|
+
fireEvent.keyDown(box(), { key: 'ArrowUp' })
|
|
954
|
+
expect(box().value).toBe('30')
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
test('an int field refuses a decimal and steps by 1 with no step prop at all', async () => {
|
|
958
|
+
const ints = createSearchStore({
|
|
959
|
+
key: 'c-number-int',
|
|
960
|
+
fields: { nights: field.number({ fallback: 2, min: 1, max: 14, int: true }) },
|
|
961
|
+
})
|
|
962
|
+
const router = await mountPage({
|
|
963
|
+
validateSearch: ints.validateSearch,
|
|
964
|
+
entry: '/dashboard',
|
|
965
|
+
Page: () => <NumberFilter field={ints.field.nights} label="Nights" />,
|
|
966
|
+
})
|
|
967
|
+
|
|
968
|
+
const input = () =>
|
|
969
|
+
screen.getByRole('textbox', { name: 'Nights', hidden: true }) as HTMLInputElement
|
|
970
|
+
await openPill('2')
|
|
971
|
+
|
|
972
|
+
// `int` also picks the GRAIN: no `step` prop, and the field's own declaration produces 1.
|
|
973
|
+
fireEvent.keyDown(input(), { key: 'ArrowUp' })
|
|
974
|
+
expect(input().value).toBe('3')
|
|
975
|
+
|
|
976
|
+
// `allowDecimal={false}` — Mantine drops the separator rather than accepting a value the codec
|
|
977
|
+
// would decode to `null`, which would silently resurrect the fallback.
|
|
978
|
+
fireEvent.change(input(), { target: { value: '3.5' } })
|
|
979
|
+
expect(input().value).not.toContain('.')
|
|
980
|
+
fireEvent.blur(input())
|
|
981
|
+
await waitFor(() => {
|
|
982
|
+
expect(Number.isInteger(search(router)['nights'])).toBe(true)
|
|
983
|
+
})
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
test('the pill reads the value and marks itself active off the fallback', async () => {
|
|
987
|
+
await mountPage({
|
|
988
|
+
validateSearch: store.validateSearch,
|
|
989
|
+
entry: '/dashboard?minDuration=90',
|
|
990
|
+
Page: () => <NumberFilter field={store.field.minDuration} label="Min duration" />,
|
|
991
|
+
})
|
|
992
|
+
const pill = screen.getByRole('button', { name: '90' })
|
|
993
|
+
expect(pill.hasAttribute('data-active')).toBe(true)
|
|
994
|
+
expect(pill.hasAttribute('data-numeric')).toBe(true)
|
|
995
|
+
})
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* The SHEET form of both branches, through a real `FilterSet` — the surface is chosen by context,
|
|
1000
|
+
* never by a media query (C9), so this is the only way to reach it.
|
|
1001
|
+
*/
|
|
1002
|
+
describe('NumberFilter — the sheet form', () => {
|
|
1003
|
+
const store = createSearchStore({
|
|
1004
|
+
key: 'c-number-sheet',
|
|
1005
|
+
fields: {
|
|
1006
|
+
nights: field.number({ fallback: 2, min: 1, max: 14, int: true }),
|
|
1007
|
+
minDuration: field.number({ fallback: 0, min: 0, max: 600 }),
|
|
1008
|
+
},
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
async function openSheet(): Promise<void> {
|
|
1012
|
+
await mountPage({
|
|
1013
|
+
validateSearch: store.validateSearch,
|
|
1014
|
+
entry: '/dashboard',
|
|
1015
|
+
Page: () => (
|
|
1016
|
+
<FilterSet>
|
|
1017
|
+
<NumberFilter
|
|
1018
|
+
field={store.field.nights}
|
|
1019
|
+
label="Nights"
|
|
1020
|
+
options={[
|
|
1021
|
+
{ value: 1, label: '1 night' },
|
|
1022
|
+
{ value: 2, label: '2 nights' },
|
|
1023
|
+
]}
|
|
1024
|
+
/>
|
|
1025
|
+
<NumberFilter field={store.field.minDuration} label="Min duration" />
|
|
1026
|
+
</FilterSet>
|
|
1027
|
+
),
|
|
1028
|
+
})
|
|
1029
|
+
await act(async () => {
|
|
1030
|
+
fireEvent.click(screen.getByRole('button', { name: 'Filters' }))
|
|
1031
|
+
})
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
test('the options form is a named option list, the stepper form a named input row', async () => {
|
|
1035
|
+
await openSheet()
|
|
1036
|
+
// `group`, not `radiogroup`: the sheet form is a `<fieldset>` of native radios
|
|
1037
|
+
// (`SheetOptionList`), pointed at its own visible `SheetField` heading.
|
|
1038
|
+
expect(screen.getByRole('group', { name: 'Nights', hidden: true })).toBeDefined()
|
|
1039
|
+
// The stepper is the input itself in a `SheetField` — no pill, no popover, one full-width row.
|
|
1040
|
+
// Exactly ONE input in the document: `FilterSet` keeps the bar-row copy of every child mounted
|
|
1041
|
+
// (hidden slots are `display: none`, never unmounted), but the bar copy is a PILL whose popover
|
|
1042
|
+
// is closed, so the only rendered box is the sheet's.
|
|
1043
|
+
expect(screen.getAllByRole('textbox', { name: 'Min duration', hidden: true })).toHaveLength(1)
|
|
1044
|
+
})
|
|
1045
|
+
|
|
1046
|
+
test('a sheet edit writes the field immediately — there is no Apply', async () => {
|
|
1047
|
+
await openSheet()
|
|
1048
|
+
fireEvent.click(screen.getByRole('radio', { name: '1 night', hidden: true }))
|
|
1049
|
+
await waitFor(() => {
|
|
1050
|
+
expect(store.field.nights.isDefault(1)).toBe(false)
|
|
1051
|
+
})
|
|
1052
|
+
})
|
|
1053
|
+
})
|