basalt-ui 1.24.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 +25 -0
- package/MIGRATING.md +547 -21
- package/README.md +46 -33
- 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/query-state.d.ts +1 -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 +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 +102 -20
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +166 -65
- package/dist/data/table.d.ts +1 -1
- package/dist/data/table.d.ts.map +1 -1
- 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/query-state.tsx +1 -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 +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 +282 -6
- package/src/data/data-table.tsx +429 -94
- package/src/data/table.ts +2 -0
- 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 -269
- 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,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FilterSet`'s four owned behaviours (`docs/CONTROLS-SPEC.md` §2.1/§3), each asserted against real
|
|
3
|
+
* children bound to a real `createSearchStore`: the DERIVED active count, `Reset all` reaching every
|
|
4
|
+
* registered filter, the mobile `inline` budget, and the measured desktop fold.
|
|
5
|
+
*
|
|
6
|
+
* The fold is the one behaviour that needs the DOM stubbed rather than driven — happy-dom lays
|
|
7
|
+
* nothing out, so `offsetWidth`/`clientWidth` are 0 for every element and the no-op
|
|
8
|
+
* `ResizeObserver` from `tests/setup/dom.ts` never fires. Both are replaced here (restored in
|
|
9
|
+
* `afterEach`), which is what lets the fold arithmetic be tested at all instead of only in a
|
|
10
|
+
* browser.
|
|
11
|
+
*/
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import type { ReactNode } from 'react'
|
|
16
|
+
import { MantineProvider } from '@mantine/core'
|
|
17
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
18
|
+
import {
|
|
19
|
+
Outlet,
|
|
20
|
+
RouterProvider,
|
|
21
|
+
createMemoryHistory,
|
|
22
|
+
createRootRoute,
|
|
23
|
+
createRoute,
|
|
24
|
+
createRouter,
|
|
25
|
+
} from '@tanstack/react-router'
|
|
26
|
+
import { field } from '../router-tanstack/field'
|
|
27
|
+
import { createSearchStore } from '../router-tanstack/search-store'
|
|
28
|
+
import { FilterSet } from './filter-set'
|
|
29
|
+
import { SelectFilter } from './select-filter'
|
|
30
|
+
import { ToggleFilter } from './toggle-filter'
|
|
31
|
+
|
|
32
|
+
type Router = { state: { status: string; location: { search: unknown } } }
|
|
33
|
+
|
|
34
|
+
const store = createSearchStore({
|
|
35
|
+
key: 'fs',
|
|
36
|
+
fields: {
|
|
37
|
+
currency: field.enum(['USD', 'EUR'], 'USD'),
|
|
38
|
+
region: field.enum(['all', 'eu', 'us'], 'all'),
|
|
39
|
+
tier: field.enum(['any', 'pro'], 'any'),
|
|
40
|
+
errorsOnly: field.boolean(false),
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
async function mountSet(entry: string, inline?: number): Promise<Router> {
|
|
45
|
+
const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
|
46
|
+
const pageRoute = createRoute({
|
|
47
|
+
getParentRoute: () => rootRoute,
|
|
48
|
+
path: '/dashboard',
|
|
49
|
+
validateSearch: store.validateSearch,
|
|
50
|
+
component: () => (
|
|
51
|
+
<FilterSet {...(inline !== undefined && { inline })}>
|
|
52
|
+
<SelectFilter field={store.field.currency} label="Currency" />
|
|
53
|
+
<SelectFilter field={store.field.region} label="Region" />
|
|
54
|
+
<SelectFilter field={store.field.tier} label="Tier" />
|
|
55
|
+
<ToggleFilter field={store.field.errorsOnly} label="Errors only" />
|
|
56
|
+
</FilterSet>
|
|
57
|
+
),
|
|
58
|
+
})
|
|
59
|
+
const router = createRouter({
|
|
60
|
+
routeTree: rootRoute.addChildren([pageRoute]),
|
|
61
|
+
history: createMemoryHistory({ initialEntries: [entry] }),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
render(
|
|
65
|
+
<MantineProvider>
|
|
66
|
+
<RouterProvider router={router} />
|
|
67
|
+
</MantineProvider>,
|
|
68
|
+
)
|
|
69
|
+
await waitFor(() => {
|
|
70
|
+
expect(router.state.status).toBe('idle')
|
|
71
|
+
})
|
|
72
|
+
return router as unknown as Router
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function slots(): HTMLElement[] {
|
|
76
|
+
return [...document.querySelectorAll<HTMLElement>('[data-filter-slot]')]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function row(): HTMLElement {
|
|
80
|
+
const node = document.querySelector<HTMLElement>('[data-filter-row]')
|
|
81
|
+
if (node === null) throw new Error('no filter row rendered')
|
|
82
|
+
return node
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The stubbed layout: every element's width is whatever the test wrote to `data-test-width`. */
|
|
86
|
+
function widthFromData(this: HTMLElement): number {
|
|
87
|
+
return Number(this.dataset['testWidth'] ?? '0')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
localStorage.clear()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('the active count is derived from the children, never passed', () => {
|
|
95
|
+
test('no filter is off its default → the sheet pill states no count', async () => {
|
|
96
|
+
await mountSet('/dashboard')
|
|
97
|
+
expect(screen.getByRole('button', { name: 'Filters' })).toBeDefined()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('two non-default fields on a deep link → Filters (2), with nothing told to FilterSet', async () => {
|
|
101
|
+
await mountSet('/dashboard?currency=EUR&errorsOnly=true')
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(screen.getByRole('button', { name: 'Filters (2)' })).toBeDefined()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('Reset all reaches every registered filter, including the folded and the inline ones', () => {
|
|
109
|
+
test('one press writes every field back to its fallback', async () => {
|
|
110
|
+
const router = await mountSet('/dashboard?currency=EUR®ion=eu&errorsOnly=true')
|
|
111
|
+
await waitFor(() => {
|
|
112
|
+
expect(screen.getByRole('button', { name: 'Filters (3)' })).toBeDefined()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
await act(async () => {
|
|
116
|
+
fireEvent.click(screen.getByRole('button', { name: 'Filters (3)' }))
|
|
117
|
+
})
|
|
118
|
+
await act(async () => {
|
|
119
|
+
fireEvent.click(screen.getByRole('button', { name: 'Reset all', hidden: true }))
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
await waitFor(() => {
|
|
123
|
+
const search = router.state.location.search as Record<string, unknown>
|
|
124
|
+
expect(search['currency']).toBe('USD')
|
|
125
|
+
expect(search['region']).toBe('all')
|
|
126
|
+
expect(search['errorsOnly']).toBe(false)
|
|
127
|
+
})
|
|
128
|
+
expect(screen.getByRole('button', { name: 'Filters' })).toBeDefined()
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('the mobile inline budget is per-slot data, not a second mount', () => {
|
|
133
|
+
test('inline defaults to 1 — the first slot only', async () => {
|
|
134
|
+
await mountSet('/dashboard')
|
|
135
|
+
expect(slots().map((slot) => slot.hasAttribute('data-inline'))).toEqual([
|
|
136
|
+
true,
|
|
137
|
+
false,
|
|
138
|
+
false,
|
|
139
|
+
false,
|
|
140
|
+
])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('inline={2} keeps the first two', async () => {
|
|
144
|
+
await mountSet('/dashboard', 2)
|
|
145
|
+
expect(slots().map((slot) => slot.hasAttribute('data-inline'))).toEqual([
|
|
146
|
+
true,
|
|
147
|
+
true,
|
|
148
|
+
false,
|
|
149
|
+
false,
|
|
150
|
+
])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('every child stays mounted regardless of the budget — the row is the whole census', async () => {
|
|
154
|
+
await mountSet('/dashboard')
|
|
155
|
+
expect(slots()).toHaveLength(4)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('the desktop fold is measured, and it never scrolls', () => {
|
|
160
|
+
const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')
|
|
161
|
+
const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
|
162
|
+
let observers: (() => void)[] = []
|
|
163
|
+
|
|
164
|
+
function stubLayout(): void {
|
|
165
|
+
observers = []
|
|
166
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
|
167
|
+
configurable: true,
|
|
168
|
+
get: widthFromData,
|
|
169
|
+
})
|
|
170
|
+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
|
171
|
+
configurable: true,
|
|
172
|
+
get: widthFromData,
|
|
173
|
+
})
|
|
174
|
+
class ResizeObserverStub {
|
|
175
|
+
constructor(callback: () => void) {
|
|
176
|
+
observers.push(callback)
|
|
177
|
+
}
|
|
178
|
+
observe(): void {}
|
|
179
|
+
unobserve(): void {}
|
|
180
|
+
disconnect(): void {}
|
|
181
|
+
}
|
|
182
|
+
window.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
afterEach(() => {
|
|
186
|
+
if (offsetWidth !== undefined) {
|
|
187
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidth)
|
|
188
|
+
}
|
|
189
|
+
if (clientWidth !== undefined) {
|
|
190
|
+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', clientWidth)
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
async function resizeTo(available: number, perSlot: number): Promise<void> {
|
|
195
|
+
row().dataset['testWidth'] = String(available)
|
|
196
|
+
for (const slot of slots()) slot.dataset['testWidth'] = String(perSlot)
|
|
197
|
+
await act(async () => {
|
|
198
|
+
for (const notify of observers) notify()
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
test('four 100px pills in 250px: the tail folds into +3, and the row keeps one', async () => {
|
|
203
|
+
stubLayout()
|
|
204
|
+
await mountSet('/dashboard')
|
|
205
|
+
await resizeTo(250, 100)
|
|
206
|
+
|
|
207
|
+
expect(slots().map((slot) => slot.hasAttribute('data-folded'))).toEqual([
|
|
208
|
+
false,
|
|
209
|
+
true,
|
|
210
|
+
true,
|
|
211
|
+
true,
|
|
212
|
+
])
|
|
213
|
+
const fold = screen.getByRole('button', { name: '3 more filters' })
|
|
214
|
+
expect(fold.hasAttribute('data-shown')).toBe(true)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('slots of DIFFERENT widths fold at the right index, not at a uniform one', async () => {
|
|
218
|
+
// The uniform-100px case can be satisfied by arithmetic that ignores which slot is which. Here
|
|
219
|
+
// the widths are 40/40/300/40: the wide third pill is what pushes the row over, so the fold has
|
|
220
|
+
// to land at 2 rather than at some average-derived index.
|
|
221
|
+
stubLayout()
|
|
222
|
+
await mountSet('/dashboard')
|
|
223
|
+
row().dataset['testWidth'] = '250'
|
|
224
|
+
const widths = ['40', '40', '300', '40']
|
|
225
|
+
slots().forEach((slot, index) => {
|
|
226
|
+
slot.dataset['testWidth'] = widths[index] ?? '40'
|
|
227
|
+
})
|
|
228
|
+
await act(async () => {
|
|
229
|
+
for (const notify of observers) notify()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
expect(slots().map((slot) => slot.hasAttribute('data-folded'))).toEqual([
|
|
233
|
+
false,
|
|
234
|
+
false,
|
|
235
|
+
true,
|
|
236
|
+
true,
|
|
237
|
+
])
|
|
238
|
+
expect(screen.getByRole('button', { name: '2 more filters' }).hasAttribute('data-shown')).toBe(
|
|
239
|
+
true,
|
|
240
|
+
)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('the same four pills in 900px: nothing folds and the +N pill stays unshown', async () => {
|
|
244
|
+
stubLayout()
|
|
245
|
+
await mountSet('/dashboard')
|
|
246
|
+
await resizeTo(900, 100)
|
|
247
|
+
|
|
248
|
+
expect(slots().some((slot) => slot.hasAttribute('data-folded'))).toBe(false)
|
|
249
|
+
expect(screen.getByRole('button', { name: '0 more filters' }).hasAttribute('data-shown')).toBe(
|
|
250
|
+
false,
|
|
251
|
+
)
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('a folded filter is still operable from inside the +N dropdown', () => {
|
|
256
|
+
const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')
|
|
257
|
+
const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
|
258
|
+
|
|
259
|
+
afterEach(() => {
|
|
260
|
+
if (offsetWidth !== undefined) {
|
|
261
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidth)
|
|
262
|
+
}
|
|
263
|
+
if (clientWidth !== undefined) {
|
|
264
|
+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', clientWidth)
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test('opening +N and changing a folded field writes the URL, and +N stays open', async () => {
|
|
269
|
+
// The regression this pins: rendering the fold body in PILL form gave each folded filter its own
|
|
270
|
+
// `withinPortal` Popover, whose mousedown is outside the +N dropdown's `composedPath()` — so +N
|
|
271
|
+
// closed, the pill unmounted, and the click never landed. The sheet form has no nested overlay.
|
|
272
|
+
let observers: (() => void)[] = []
|
|
273
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
|
274
|
+
configurable: true,
|
|
275
|
+
get: widthFromData,
|
|
276
|
+
})
|
|
277
|
+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
|
278
|
+
configurable: true,
|
|
279
|
+
get: widthFromData,
|
|
280
|
+
})
|
|
281
|
+
class ResizeObserverStub {
|
|
282
|
+
constructor(callback: () => void) {
|
|
283
|
+
observers.push(callback)
|
|
284
|
+
}
|
|
285
|
+
observe(): void {}
|
|
286
|
+
unobserve(): void {}
|
|
287
|
+
disconnect(): void {}
|
|
288
|
+
}
|
|
289
|
+
window.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver
|
|
290
|
+
|
|
291
|
+
const router = await mountSet('/dashboard')
|
|
292
|
+
row().dataset['testWidth'] = '250'
|
|
293
|
+
for (const slot of slots()) slot.dataset['testWidth'] = '100'
|
|
294
|
+
await act(async () => {
|
|
295
|
+
for (const notify of observers) notify()
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
await act(async () => {
|
|
299
|
+
fireEvent.click(screen.getByRole('button', { name: '3 more filters' }))
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
// The folded children render their sheet form — a named option list, no second pill. `group`,
|
|
303
|
+
// not `radiogroup`: the sheet form is a `<fieldset>` of native radios (`SheetOptionList`), which
|
|
304
|
+
// is the grouping role a fieldset carries. `Radio.Group` is the POPOVER form's shape.
|
|
305
|
+
const group = screen.getByRole('group', { name: 'Region', hidden: true })
|
|
306
|
+
expect(group).toBeDefined()
|
|
307
|
+
|
|
308
|
+
await act(async () => {
|
|
309
|
+
fireEvent.click(screen.getByRole('radio', { name: 'eu', hidden: true }))
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
await waitFor(() => {
|
|
313
|
+
expect((router.state.location.search as Record<string, unknown>)['region']).toBe('eu')
|
|
314
|
+
})
|
|
315
|
+
// Still open: the write did not tear its own container down.
|
|
316
|
+
expect(screen.getByRole('group', { name: 'Region', hidden: true })).toBeDefined()
|
|
317
|
+
observers = []
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
describe('the sheet names every control it holds', () => {
|
|
322
|
+
test('the option lists and the Switch row carry the filter name, not the pill text', async () => {
|
|
323
|
+
await mountSet('/dashboard?currency=EUR')
|
|
324
|
+
await act(async () => {
|
|
325
|
+
fireEvent.click(screen.getByRole('button', { name: 'Filters (1)' }))
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// `Currency`, not `EUR` — the pill reads the VALUE once set, which names the selection rather
|
|
329
|
+
// than the filter. Each `<fieldset>` points at its own visible SheetField heading.
|
|
330
|
+
for (const name of ['Currency', 'Region', 'Tier']) {
|
|
331
|
+
expect(screen.getByRole('group', { name, hidden: true })).toBeDefined()
|
|
332
|
+
}
|
|
333
|
+
expect(screen.getByRole('switch', { name: 'Errors only', hidden: true })).toBeDefined()
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The 44px touch target, asserted at the SOURCE. Neither half is checkable in the DOM here:
|
|
339
|
+
* happy-dom lays nothing out (so no bounding box), and a `.module.css` import resolves to `undefined`
|
|
340
|
+
* under `bun test` (so the emitted `class` attribute carries only Mantine's own names). What IS
|
|
341
|
+
* checkable is the pair that produces it — the CSS stretching the body and label to the row, and
|
|
342
|
+
* every row-rendering control actually passing those classes. A row whose height sits on a
|
|
343
|
+
* non-interactive wrapper is C15's number without C15's behaviour, which is the defect this pins.
|
|
344
|
+
*/
|
|
345
|
+
describe('sheet rows are tappable across their full height (C15)', () => {
|
|
346
|
+
const css = readFileSync(join(import.meta.dir, 'controls.module.css'), 'utf8')
|
|
347
|
+
|
|
348
|
+
function block(selector: string): string {
|
|
349
|
+
const start = css.indexOf(`${selector} {`)
|
|
350
|
+
expect(start).toBeGreaterThan(-1)
|
|
351
|
+
return css.slice(start, css.indexOf('}', start))
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
test('both row primitives pin the height to the sheet-row token, not a literal', () => {
|
|
355
|
+
expect(block('.sheetRow')).toContain('var(--vx-space-sheet-row-height')
|
|
356
|
+
expect(block('.sheetOption')).toContain('var(--vx-space-sheet-row-height')
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test('the option row IS the hit target — the height sits on the label, never on a wrapper', () => {
|
|
360
|
+
// `.sheetOption` is the `<label>` (and, in a disclosure, the `<button>`) itself, so the 44px is
|
|
361
|
+
// on the interactive element by construction rather than by a classNames hand-off.
|
|
362
|
+
const rule = block('.sheetOption')
|
|
363
|
+
expect(rule).toContain('width: 100%')
|
|
364
|
+
expect(rule).toContain('cursor: pointer')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('.sheetRowBody and .sheetRowLabel stretch the interactive element to that height', () => {
|
|
368
|
+
for (const selector of ['.sheetRowBody', '.sheetRowLabel']) {
|
|
369
|
+
const rule = block(selector)
|
|
370
|
+
expect(rule).toContain('flex: 1')
|
|
371
|
+
expect(rule).toContain('min-height: inherit')
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test('every control that renders a sheet row goes through a 44px row primitive', () => {
|
|
376
|
+
// Two primitives, one law. `ToggleFilter` renders a Mantine `Switch` AS the row, so it needs
|
|
377
|
+
// `sheetRowClassNames` to stretch the control's own `<label>` across the 44px. The enum/multi/
|
|
378
|
+
// range filters render `SheetOptionList`, which owns the `<label>`-per-row box itself — the
|
|
379
|
+
// classNames escape exists for controls basalt does not draw the row for.
|
|
380
|
+
expect(readFileSync(join(import.meta.dir, 'toggle-filter.tsx'), 'utf8')).toContain(
|
|
381
|
+
'sheetRowClassNames',
|
|
382
|
+
)
|
|
383
|
+
for (const file of ['enum-filter.tsx', 'multi-select-filter.tsx', 'range-filter.tsx']) {
|
|
384
|
+
const source = readFileSync(join(import.meta.dir, file), 'utf8')
|
|
385
|
+
expect(source).toContain('SheetOptionList')
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
test('no control wraps its sheet row in a plain div — the height must sit on the label', () => {
|
|
390
|
+
// `SheetRow` puts `min-height` on a non-interactive wrapper, which is exactly the 20px-strip
|
|
391
|
+
// defect. It stays exported for a future non-interactive row; no filter may use it.
|
|
392
|
+
for (const file of ['enum-filter.tsx', 'multi-select-filter.tsx', 'toggle-filter.tsx']) {
|
|
393
|
+
const source = readFileSync(join(import.meta.dir, file), 'utf8')
|
|
394
|
+
expect(source).not.toContain('<SheetRow>')
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
describe('an empty home renders nothing (C14)', () => {
|
|
400
|
+
test('no children → no row, so no route pays for a reserved bar', async () => {
|
|
401
|
+
const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
|
402
|
+
const pageRoute = createRoute({
|
|
403
|
+
getParentRoute: () => rootRoute,
|
|
404
|
+
path: '/dashboard',
|
|
405
|
+
component: (): ReactNode => <FilterSet>{null}</FilterSet>,
|
|
406
|
+
})
|
|
407
|
+
const router = createRouter({
|
|
408
|
+
routeTree: rootRoute.addChildren([pageRoute]),
|
|
409
|
+
history: createMemoryHistory({ initialEntries: ['/dashboard'] }),
|
|
410
|
+
})
|
|
411
|
+
render(
|
|
412
|
+
<MantineProvider>
|
|
413
|
+
<RouterProvider router={router} />
|
|
414
|
+
</MantineProvider>,
|
|
415
|
+
)
|
|
416
|
+
await waitFor(() => {
|
|
417
|
+
expect(router.state.status).toBe('idle')
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
expect(document.querySelector('[data-filter-row]')).toBeNull()
|
|
421
|
+
})
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The `Filters (n)` pill is the mobile SHEET TRIGGER, and the sheet only ever holds what the
|
|
426
|
+
* `inline` budget folded away. With nothing folded it opens a drawer showing a copy of the pills
|
|
427
|
+
* already on screen — while costing the mobile row the width of a pill to say so.
|
|
428
|
+
*/
|
|
429
|
+
describe('the mobile sheet trigger appears only when a child is folded', () => {
|
|
430
|
+
async function mountOne(inline?: number): Promise<void> {
|
|
431
|
+
const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
|
432
|
+
const pageRoute = createRoute({
|
|
433
|
+
getParentRoute: () => rootRoute,
|
|
434
|
+
path: '/dashboard',
|
|
435
|
+
validateSearch: store.validateSearch,
|
|
436
|
+
component: (): ReactNode => (
|
|
437
|
+
<FilterSet {...(inline !== undefined && { inline })}>
|
|
438
|
+
<SelectFilter field={store.field.currency} label="Currency" />
|
|
439
|
+
</FilterSet>
|
|
440
|
+
),
|
|
441
|
+
})
|
|
442
|
+
const router = createRouter({
|
|
443
|
+
routeTree: rootRoute.addChildren([pageRoute]),
|
|
444
|
+
history: createMemoryHistory({ initialEntries: ['/dashboard'] }),
|
|
445
|
+
})
|
|
446
|
+
render(
|
|
447
|
+
<MantineProvider>
|
|
448
|
+
<RouterProvider router={router} />
|
|
449
|
+
</MantineProvider>,
|
|
450
|
+
)
|
|
451
|
+
await waitFor(() => {
|
|
452
|
+
expect(router.state.status).toBe('idle')
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
test('one child at the default budget → no Filters pill at all', async () => {
|
|
457
|
+
await mountOne()
|
|
458
|
+
expect(screen.queryByRole('button', { name: 'Filters' })).toBeNull()
|
|
459
|
+
// …and the filter itself is still there, inline.
|
|
460
|
+
expect(slots()).toHaveLength(1)
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
test('inline={0} folds that same child → the pill is back', async () => {
|
|
464
|
+
await mountOne(0)
|
|
465
|
+
expect(screen.getByRole('button', { name: 'Filters' })).toBeDefined()
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
test('four children at the default budget keep the pill', async () => {
|
|
469
|
+
await mountSet('/dashboard')
|
|
470
|
+
expect(screen.getByRole('button', { name: 'Filters' })).toBeDefined()
|
|
471
|
+
})
|
|
472
|
+
})
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FilterSet` — the filter home's container (`docs/CONTROLS-SPEC.md` §2.1 row 2, §3). It owns the
|
|
3
|
+
* three things a consumer used to hand-roll per page: the nowrap row, the overflow fold, and the
|
|
4
|
+
* mobile sheet.
|
|
5
|
+
*
|
|
6
|
+
* **Desktop.** One row, `wrap: nowrap`, and it NEVER scrolls (C7). When the measured pill widths
|
|
7
|
+
* exceed the container the tail folds into a `+N` menu pill — a `ResizeObserver` on the row plus
|
|
8
|
+
* per-slot `offsetWidth`, because the fold point depends on the labels actually painted (the same
|
|
9
|
+
* reasoning as the charts' `autoMargin`), not on a guessed pill width.
|
|
10
|
+
*
|
|
11
|
+
* **Mobile (`< sm`).** The first `inline` children stay pills; every child — inline ones included —
|
|
12
|
+
* also renders its full-width sheet form inside the `Filters (n)` Drawer — which exists only when
|
|
13
|
+
* the budget folded at least one child away (one filter at `inline: 1` gets no pill at all). `n` is
|
|
14
|
+
* DERIVED: each filter registers `{ isActive, reset }` with this component (`filter-context.tsx`),
|
|
15
|
+
* so `FilterSet` never needs to be told which fields its children hold, and `Reset all` reaches all
|
|
16
|
+
* of them.
|
|
17
|
+
*
|
|
18
|
+
* **Two mounts of the same children, one census.** The row keeps every child mounted on every
|
|
19
|
+
* viewport (hidden slots are `display: none`, never unmounted), so it is the single registration
|
|
20
|
+
* surface; the fold dropdown and the sheet mount a second copy under a `null` registry. That is why
|
|
21
|
+
* a folded filter still counts toward `n` and still answers `Reset all`.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* <PageBar
|
|
25
|
+
* filters={
|
|
26
|
+
* <FilterSet>
|
|
27
|
+
* <RangeFilter field={analytics.field.range} customPicker={DateRangePicker} />
|
|
28
|
+
* <CompareFilter field={analytics.field.compare} />
|
|
29
|
+
* <SelectFilter field={analytics.field.currency} label="Currency" />
|
|
30
|
+
* </FilterSet>
|
|
31
|
+
* }
|
|
32
|
+
* />
|
|
33
|
+
*/
|
|
34
|
+
import { Children, isValidElement, useCallback, useEffect, useRef, useState } from 'react'
|
|
35
|
+
import type { ReactNode, RefObject } from 'react'
|
|
36
|
+
import classes from './controls.module.css'
|
|
37
|
+
import { FilterSetScope, useFilterCensus } from './filter-context'
|
|
38
|
+
import { FilterPill } from './filter-pill'
|
|
39
|
+
import { FilterSheet } from './filter-sheet'
|
|
40
|
+
import { FunnelGlyph } from './glyphs'
|
|
41
|
+
|
|
42
|
+
/** Width reserved for the `+N` pill once folding is known to be necessary. */
|
|
43
|
+
const FOLD_PILL_WIDTH = 52
|
|
44
|
+
/** `--vx-space-control-gap`'s level-0 value — the fallback when the row has no computed style. */
|
|
45
|
+
const FALLBACK_GAP = 6
|
|
46
|
+
|
|
47
|
+
export type FilterSetProps = {
|
|
48
|
+
readonly children: ReactNode
|
|
49
|
+
/**
|
|
50
|
+
* How many children stay inline pills below `sm`. The rest are reachable only through the
|
|
51
|
+
* `Filters (n)` sheet — which always holds every child, inline ones included.
|
|
52
|
+
*
|
|
53
|
+
* @default 1
|
|
54
|
+
*/
|
|
55
|
+
readonly inline?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function FilterSet({ children, inline = 1 }: FilterSetProps): ReactNode {
|
|
59
|
+
const items = Children.toArray(children)
|
|
60
|
+
const { registry, activeCount, resetAll } = useFilterCensus()
|
|
61
|
+
const [sheetOpened, setSheetOpened] = useState(false)
|
|
62
|
+
const rowRef = useRef<HTMLDivElement | null>(null)
|
|
63
|
+
const fit = useOverflowFit(rowRef, items.length)
|
|
64
|
+
|
|
65
|
+
const openSheet = useCallback(() => {
|
|
66
|
+
setSheetOpened(true)
|
|
67
|
+
}, [])
|
|
68
|
+
const closeSheet = useCallback(() => {
|
|
69
|
+
setSheetOpened(false)
|
|
70
|
+
}, [])
|
|
71
|
+
|
|
72
|
+
// C14 — an empty home renders nothing, so no route pays for a reserved row.
|
|
73
|
+
if (items.length === 0) return null
|
|
74
|
+
|
|
75
|
+
const folded = items.slice(fit)
|
|
76
|
+
// Below `sm` the sheet is the ONLY way to reach a child past the `inline` budget — so with every
|
|
77
|
+
// child already inline (one filter at the default budget) it opens a drawer holding a copy of
|
|
78
|
+
// what is on screen, under a pill that costs the row its width for nothing. The trigger exists
|
|
79
|
+
// when something is actually folded away, and not before (law C14's reading of the mobile row).
|
|
80
|
+
const anyFoldedOnMobile = items.length > inline
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
<div className={classes.pillRow} ref={rowRef} data-filter-row>
|
|
85
|
+
<FilterSetScope surface="pill" registry={registry}>
|
|
86
|
+
{items.map((child, index) => (
|
|
87
|
+
// `Children.toArray` already stamped a stable `.0`/`.1`-style key on every child, so
|
|
88
|
+
// the slot wrapper reuses it rather than inventing an array-index key.
|
|
89
|
+
<div
|
|
90
|
+
key={slotKey(child, index)}
|
|
91
|
+
className={classes.pillSlot}
|
|
92
|
+
data-filter-slot
|
|
93
|
+
{...(index < inline && { 'data-inline': true })}
|
|
94
|
+
{...(index >= fit && { 'data-folded': true })}
|
|
95
|
+
>
|
|
96
|
+
{child}
|
|
97
|
+
</div>
|
|
98
|
+
))}
|
|
99
|
+
</FilterSetScope>
|
|
100
|
+
|
|
101
|
+
<FilterPill
|
|
102
|
+
label={`+${folded.length}`}
|
|
103
|
+
ariaLabel={`${folded.length} more filters`}
|
|
104
|
+
className={classes.foldPill}
|
|
105
|
+
hideGlyph
|
|
106
|
+
shown={folded.length > 0}
|
|
107
|
+
>
|
|
108
|
+
<div className={classes.optionList}>
|
|
109
|
+
{/* SHEET form, not pill form. A folded pill would open a SECOND `withinPortal`
|
|
110
|
+
Popover, which is not a DOM descendant of this one — Mantine's click-outside check
|
|
111
|
+
walks `composedPath()` against the outer target/dropdown only, so the inner pill's
|
|
112
|
+
own mousedown reads as "outside", closes `+N`, unmounts the pill, and the click
|
|
113
|
+
never lands. The sheet form has no nested overlay at all, and it is the same form
|
|
114
|
+
the mobile `Filters (n)` Drawer already uses — one behaviour, not two. */}
|
|
115
|
+
<FilterSetScope surface="sheet" registry={null}>
|
|
116
|
+
{folded}
|
|
117
|
+
</FilterSetScope>
|
|
118
|
+
</div>
|
|
119
|
+
</FilterPill>
|
|
120
|
+
|
|
121
|
+
{anyFoldedOnMobile && (
|
|
122
|
+
<FilterPill
|
|
123
|
+
label={activeCount > 0 ? `Filters (${activeCount})` : 'Filters'}
|
|
124
|
+
active={activeCount > 0}
|
|
125
|
+
icon={<FunnelGlyph />}
|
|
126
|
+
className={classes.sheetPill}
|
|
127
|
+
hideGlyph
|
|
128
|
+
onClick={openSheet}
|
|
129
|
+
/>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{anyFoldedOnMobile && (
|
|
134
|
+
<FilterSheet opened={sheetOpened} onClose={closeSheet} onResetAll={resetAll}>
|
|
135
|
+
<FilterSetScope surface="sheet" registry={null}>
|
|
136
|
+
{items}
|
|
137
|
+
</FilterSetScope>
|
|
138
|
+
</FilterSheet>
|
|
139
|
+
)}
|
|
140
|
+
</>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* How many of `count` slots fit the row at its current width. Measures the slots' own
|
|
146
|
+
* `offsetWidth` (remembering the last non-zero value per slot, since a folded or `inline`-budgeted
|
|
147
|
+
* slot measures 0) and re-runs on every `ResizeObserver` tick.
|
|
148
|
+
*
|
|
149
|
+
* The measurement is only honest because `.pillSlot` is `flex-shrink: 0` — see that rule's comment
|
|
150
|
+
* in `controls.module.css`. Every SLOT is observed alongside the row, not just the row: a label
|
|
151
|
+
* that grows (a preset becoming `Mar 1 – Mar 14`) changes no container width, so observing the row
|
|
152
|
+
* alone would miss exactly the case that pushes a fitting row over its budget.
|
|
153
|
+
*/
|
|
154
|
+
function useOverflowFit(rowRef: RefObject<HTMLDivElement | null>, count: number): number {
|
|
155
|
+
const [fit, setFit] = useState(count)
|
|
156
|
+
const widths = useRef<number[]>([])
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
const row = rowRef.current
|
|
160
|
+
if (row === null) return
|
|
161
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
162
|
+
setFit(count)
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const measure = (): void => {
|
|
167
|
+
const slots = [...row.querySelectorAll<HTMLElement>('[data-filter-slot]')]
|
|
168
|
+
slots.forEach((slot, index) => {
|
|
169
|
+
const width = slot.offsetWidth
|
|
170
|
+
if (width > 0) widths.current[index] = width
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const available = row.clientWidth
|
|
174
|
+
if (available <= 0) {
|
|
175
|
+
setFit(count)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const gap = readGap(row)
|
|
180
|
+
const bare = fitCount(widths.current, count, gap, available, 0)
|
|
181
|
+
// Reserve the `+N` pill only once folding is known to be needed — reserving it up front
|
|
182
|
+
// would fold a set that fits exactly.
|
|
183
|
+
setFit(
|
|
184
|
+
bare === count ? count : fitCount(widths.current, count, gap, available, FOLD_PILL_WIDTH),
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
measure()
|
|
189
|
+
const observer = new ResizeObserver(measure)
|
|
190
|
+
observer.observe(row)
|
|
191
|
+
for (const slot of row.querySelectorAll<HTMLElement>('[data-filter-slot]')) {
|
|
192
|
+
observer.observe(slot)
|
|
193
|
+
}
|
|
194
|
+
return () => {
|
|
195
|
+
observer.disconnect()
|
|
196
|
+
}
|
|
197
|
+
}, [rowRef, count])
|
|
198
|
+
|
|
199
|
+
return fit
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function fitCount(
|
|
203
|
+
widths: readonly number[],
|
|
204
|
+
count: number,
|
|
205
|
+
gap: number,
|
|
206
|
+
available: number,
|
|
207
|
+
reserve: number,
|
|
208
|
+
): number {
|
|
209
|
+
let used = reserve === 0 ? 0 : reserve + gap
|
|
210
|
+
for (let index = 0; index < count; index += 1) {
|
|
211
|
+
used += (index === 0 ? 0 : gap) + (widths[index] ?? 0)
|
|
212
|
+
if (used > available) return index
|
|
213
|
+
}
|
|
214
|
+
return count
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function slotKey(child: ReactNode, index: number): string {
|
|
218
|
+
if (isValidElement(child) && child.key !== null) return child.key
|
|
219
|
+
return String(index)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function readGap(row: HTMLElement): number {
|
|
223
|
+
const parsed = Number.parseFloat(getComputedStyle(row).columnGap)
|
|
224
|
+
return Number.isFinite(parsed) ? parsed : FALLBACK_GAP
|
|
225
|
+
}
|