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,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `BarAction` — the one typed vocabulary for an action in a home (`docs/CONTROLS-SPEC.md` §2.1),
|
|
3
|
+
* plus the two components that project it: `ActionGroup` (a home's `actions` slot) and
|
|
4
|
+
* `OverflowMenu` (the `More` / kebab fold).
|
|
5
|
+
*
|
|
6
|
+
* The point of typing actions as DATA rather than taking a `ReactNode` row is law C7: a home never
|
|
7
|
+
* scrolls horizontally and never wraps, so the overflow fold has to be COMPUTED by basalt. A
|
|
8
|
+
* `ReactNode` row can only be clipped or scrolled; a `BarAction[]` can be split into "inline" and
|
|
9
|
+
* "in the More menu" without the caller knowing the breakpoint.
|
|
10
|
+
*
|
|
11
|
+
* The desktop/mobile swap belongs to this control, never to the caller (law C9): both variants are
|
|
12
|
+
* rendered, once each, and Mantine's `visibleFrom`/`hiddenFrom` decides which one paints. No JS
|
|
13
|
+
* media query, so there is no first-paint flash and no hook that re-renders on resize.
|
|
14
|
+
*/
|
|
15
|
+
import { ActionIcon, Button, Group, Menu } from '@mantine/core'
|
|
16
|
+
import { createContext, useContext, useEffect } from 'react'
|
|
17
|
+
import type { ReactNode } from 'react'
|
|
18
|
+
import type { NavAnchor } from '../nav/types'
|
|
19
|
+
import type { AnyNavLink } from '../router-tanstack/nav'
|
|
20
|
+
import { IconSlot } from '../theme/icon-slot'
|
|
21
|
+
import { ControlGroup } from './control-group'
|
|
22
|
+
import classes from './actions.module.css'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A button-shaped action. `Anchor` is the router seam basalt HOSTS (the same seam `SidebarItem`
|
|
26
|
+
* uses — pass the one `useNav` builds, or your router's `Link`); `link` is the no-router fallback
|
|
27
|
+
* and only its `to` is honoured, since resolving `search`/`params` needs a router. Supply `Anchor`
|
|
28
|
+
* whenever the destination carries either.
|
|
29
|
+
*/
|
|
30
|
+
export type BarActionItem = {
|
|
31
|
+
key: string
|
|
32
|
+
label: string
|
|
33
|
+
kind?: undefined
|
|
34
|
+
icon?: ReactNode
|
|
35
|
+
onClick?: () => void
|
|
36
|
+
link?: AnyNavLink
|
|
37
|
+
Anchor?: NavAnchor
|
|
38
|
+
disabled?: boolean
|
|
39
|
+
loading?: boolean
|
|
40
|
+
danger?: boolean
|
|
41
|
+
/**
|
|
42
|
+
* Joins this action to its adjacent `group: true` siblings into ONE `ControlGroup` — shared
|
|
43
|
+
* borders, radius on the outer ends only, no gap. Declare it on every member of the run: a set of
|
|
44
|
+
* three where the middle one omits it is two runs of one, which is the same row it was before.
|
|
45
|
+
*
|
|
46
|
+
* It is an opt-in because "these act on the same thing" is a fact only the caller has. A `‹ Today ›`
|
|
47
|
+
* stepper is the shape; three independent actions that happen to sit beside each other are not, and
|
|
48
|
+
* joining those would claim a relationship the reader would then have to un-learn.
|
|
49
|
+
*/
|
|
50
|
+
group?: true
|
|
51
|
+
/** Mobile placement. @default 'bar' for `primary`, `'more'` for everything else */
|
|
52
|
+
mobile?: 'bar' | 'more' | 'hidden'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A nested group. Always folds into the `More` menu on both viewports — it never takes bar width. */
|
|
56
|
+
export type BarActionMenu = {
|
|
57
|
+
key: string
|
|
58
|
+
kind: 'menu'
|
|
59
|
+
label: string
|
|
60
|
+
icon?: ReactNode
|
|
61
|
+
items: BarAction[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** An escape hatch for a control basalt does not model (a live chip, a consumer's own widget). */
|
|
65
|
+
export type BarActionCustom = {
|
|
66
|
+
key: string
|
|
67
|
+
kind: 'custom'
|
|
68
|
+
node: ReactNode
|
|
69
|
+
mobile?: 'bar' | 'more' | 'hidden'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type BarAction = BarActionItem | BarActionMenu | BarActionCustom
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A home's `actions` slot. `primary` is SINGULAR by type — that is the whole enforcement of law
|
|
76
|
+
* C6's "exactly one primary"; there is no runtime check because a second one cannot be written.
|
|
77
|
+
*/
|
|
78
|
+
export type ActionGroupProps = {
|
|
79
|
+
primary?: BarAction
|
|
80
|
+
secondary?: BarAction[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A shell-owned, persistent header action (timer, notifications, a global `SyncButton`).
|
|
85
|
+
* `node` is rendered as-is — basalt owns only the placement.
|
|
86
|
+
*
|
|
87
|
+
* Below `sm` a `'more'` action renders inside the header's single kebab, which means its `node` is
|
|
88
|
+
* mounted a second time there (lazily — a Mantine `Menu.Dropdown` mounts its children on open).
|
|
89
|
+
* Keep `'more'` for self-contained nodes and give anything holding live state `mobile: 'bar'`,
|
|
90
|
+
* which renders exactly once.
|
|
91
|
+
*/
|
|
92
|
+
export type GlobalAction = {
|
|
93
|
+
key: string
|
|
94
|
+
node: ReactNode
|
|
95
|
+
/** @default 'bar' for the first two, `'more'` for the rest */
|
|
96
|
+
mobile?: 'bar' | 'more' | 'hidden'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Secondaries rendered inline on desktop; the rest fold into `More` (`docs/CONTROLS-SPEC.md` §2.1). */
|
|
100
|
+
export const DESKTOP_SECONDARY_MAX = 3
|
|
101
|
+
|
|
102
|
+
/** `GlobalAction`s without an explicit `mobile` that still land on the mobile bar. */
|
|
103
|
+
export const MOBILE_GLOBAL_BAR_MAX = 2
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* What the shell hands the page's `ActionGroup` so the header ends up with ONE kebab instead of
|
|
107
|
+
* two: the shell's `mobile: 'more'` global actions (already mapped to `kind: 'custom'` rows), and
|
|
108
|
+
* a claim the mobile kebab takes on mount. While a page's kebab holds the claim, the shell renders
|
|
109
|
+
* no kebab of its own.
|
|
110
|
+
*
|
|
111
|
+
* Lives here, in the lowest layer, so `src/shell/**` imports downward into `src/controls/**` and
|
|
112
|
+
* never the reverse — a context declared in the shell would make `./controls` pull the shell in.
|
|
113
|
+
*/
|
|
114
|
+
export type BarExtras = {
|
|
115
|
+
mobileMoreActions: readonly BarAction[]
|
|
116
|
+
/** Claims the single mobile kebab. Returns the release, so it works straight out of an effect. */
|
|
117
|
+
claimKebab: () => () => void
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const NO_EXTRAS: BarExtras = {
|
|
121
|
+
mobileMoreActions: [],
|
|
122
|
+
claimKebab: () => () => {},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const BarExtrasContext = createContext<BarExtras>(NO_EXTRAS)
|
|
126
|
+
|
|
127
|
+
/** Internal — mounted by `BasaltShell` (via `PageBarProvider`), never by a consumer. */
|
|
128
|
+
export function BarExtrasProvider({
|
|
129
|
+
value,
|
|
130
|
+
children,
|
|
131
|
+
}: {
|
|
132
|
+
value: BarExtras
|
|
133
|
+
children: ReactNode
|
|
134
|
+
}): ReactNode {
|
|
135
|
+
return <BarExtrasContext.Provider value={value}>{children}</BarExtrasContext.Provider>
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Resolved mobile placement of one action. A `kind: 'menu'` group is always a `More` row. */
|
|
139
|
+
export function barActionMobile(action: BarAction, isPrimary: boolean): 'bar' | 'more' | 'hidden' {
|
|
140
|
+
if (action.kind === 'menu') return 'more'
|
|
141
|
+
return action.mobile ?? (isPrimary ? 'bar' : 'more')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `GlobalAction[]` → the same placement law, with "the first two ride the bar" as the default. */
|
|
145
|
+
export function globalActionMobile(action: GlobalAction, index: number): 'bar' | 'more' | 'hidden' {
|
|
146
|
+
return action.mobile ?? (index < MOBILE_GLOBAL_BAR_MAX ? 'bar' : 'more')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A `GlobalAction` reaching a `BarAction` slot — the kebab renders one row shape, not two. */
|
|
150
|
+
export function globalActionAsBarAction(action: GlobalAction): BarActionCustom {
|
|
151
|
+
return { key: action.key, kind: 'custom', node: action.node, mobile: 'more' }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function DotsGlyph() {
|
|
155
|
+
return (
|
|
156
|
+
<svg width={18} height={18} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
|
157
|
+
<circle cx={12} cy={5} r={1.75} />
|
|
158
|
+
<circle cx={12} cy={12} r={1.75} />
|
|
159
|
+
<circle cx={12} cy={19} r={1.75} />
|
|
160
|
+
</svg>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function ChevronGlyph() {
|
|
165
|
+
return (
|
|
166
|
+
<svg width={14} height={14} viewBox="0 0 24 24" fill="none" aria-hidden>
|
|
167
|
+
<path
|
|
168
|
+
d="M6 9l6 6 6-6"
|
|
169
|
+
stroke="currentColor"
|
|
170
|
+
strokeWidth={2}
|
|
171
|
+
strokeLinecap="round"
|
|
172
|
+
strokeLinejoin="round"
|
|
173
|
+
/>
|
|
174
|
+
</svg>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The compact labelled mobile bar entry's horizontal inset — see {@link MobileBarEntry}. */
|
|
179
|
+
const MOBILE_PRIMARY_PADDING_X = 10
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The gap between two adjacent controls in a bar row — `SPACE_STEP.controlGap`'s level-0 value, the
|
|
183
|
+
* same 6px `--vx-space-control-gap` gives the pill row. A NUMBER and not `gap="xs"`: Mantine's `xs`
|
|
184
|
+
* is 11px, the BODY tier's gap, which made the header's own row looser than the filter row directly
|
|
185
|
+
* under it. `Group`'s `gap` takes a raw px number, so there is no spacing key that resolves to 6.
|
|
186
|
+
*/
|
|
187
|
+
const BAR_GAP = 6
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One action as a button. No `size` prop anywhere: the enclosing `CtlSlot` (mounted by the home,
|
|
191
|
+
* never by the action) resolves the `ctl` tier — law C5.
|
|
192
|
+
*/
|
|
193
|
+
function BarButton({
|
|
194
|
+
action,
|
|
195
|
+
variant,
|
|
196
|
+
px,
|
|
197
|
+
}: {
|
|
198
|
+
action: BarActionItem
|
|
199
|
+
variant: 'filled' | 'default'
|
|
200
|
+
/** Overrides the tier's horizontal inset — only the compact mobile primary passes it. */
|
|
201
|
+
px?: number
|
|
202
|
+
}): ReactNode {
|
|
203
|
+
const shared = {
|
|
204
|
+
variant,
|
|
205
|
+
...(px !== undefined && { px }),
|
|
206
|
+
disabled: action.disabled === true,
|
|
207
|
+
loading: action.loading === true,
|
|
208
|
+
// EVERY icon in this file goes through `IconSlot` — the box is the framework's, never the
|
|
209
|
+
// caller's glyph (see `theme/icon-slot.tsx`). Mantine's `leftSection` is a bare flex box with no
|
|
210
|
+
// size of its own, so a consumer's `width="24"` SVG used to set this row's height.
|
|
211
|
+
...(action.icon !== undefined && { leftSection: <IconSlot>{action.icon}</IconSlot> }),
|
|
212
|
+
...(action.danger === true && { color: 'red' }),
|
|
213
|
+
...(action.onClick !== undefined && { onClick: action.onClick }),
|
|
214
|
+
}
|
|
215
|
+
if (action.Anchor !== undefined) {
|
|
216
|
+
return (
|
|
217
|
+
<Button component={action.Anchor} {...shared}>
|
|
218
|
+
{action.label}
|
|
219
|
+
</Button>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
if (action.link !== undefined) {
|
|
223
|
+
return (
|
|
224
|
+
<Button component="a" href={action.link.to} {...shared}>
|
|
225
|
+
{action.label}
|
|
226
|
+
</Button>
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
return <Button {...shared}>{action.label}</Button>
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The mobile form of a bar action: icon only, the label demoted to the accessible name. */
|
|
233
|
+
function BarIconButton({
|
|
234
|
+
action,
|
|
235
|
+
variant,
|
|
236
|
+
}: {
|
|
237
|
+
action: BarActionItem
|
|
238
|
+
/** `default` is the bordered desktop form — a joined `ControlGroup` member (see {@link BarEntry}). */
|
|
239
|
+
variant: 'filled' | 'subtle' | 'default'
|
|
240
|
+
}): ReactNode {
|
|
241
|
+
const shared = {
|
|
242
|
+
variant,
|
|
243
|
+
'aria-label': action.label,
|
|
244
|
+
disabled: action.disabled === true,
|
|
245
|
+
loading: action.loading === true,
|
|
246
|
+
...(action.danger === true && { color: 'red' }),
|
|
247
|
+
...(action.onClick !== undefined && { onClick: action.onClick }),
|
|
248
|
+
}
|
|
249
|
+
const body = <IconSlot>{action.icon}</IconSlot>
|
|
250
|
+
if (action.Anchor !== undefined) {
|
|
251
|
+
return (
|
|
252
|
+
<ActionIcon component={action.Anchor} {...shared}>
|
|
253
|
+
{body}
|
|
254
|
+
</ActionIcon>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
if (action.link !== undefined) {
|
|
258
|
+
return (
|
|
259
|
+
<ActionIcon component="a" href={action.link.to} {...shared}>
|
|
260
|
+
{body}
|
|
261
|
+
</ActionIcon>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
return <ActionIcon {...shared}>{body}</ActionIcon>
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function MenuRow({ action }: { action: BarActionItem }): ReactNode {
|
|
268
|
+
const shared = {
|
|
269
|
+
className:
|
|
270
|
+
action.danger === true ? `${classes.menuItem} ${classes.menuItemDanger}` : classes.menuItem,
|
|
271
|
+
disabled: action.disabled === true,
|
|
272
|
+
...(action.icon !== undefined && { leftSection: <IconSlot>{action.icon}</IconSlot> }),
|
|
273
|
+
...(action.onClick !== undefined && { onClick: action.onClick }),
|
|
274
|
+
}
|
|
275
|
+
if (action.Anchor !== undefined && action.disabled !== true) {
|
|
276
|
+
return (
|
|
277
|
+
<Menu.Item component={action.Anchor} {...shared}>
|
|
278
|
+
{action.label}
|
|
279
|
+
</Menu.Item>
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
if (action.link !== undefined && action.disabled !== true) {
|
|
283
|
+
return (
|
|
284
|
+
<Menu.Item component="a" href={action.link.to} {...shared}>
|
|
285
|
+
{action.label}
|
|
286
|
+
</Menu.Item>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
return <Menu.Item {...shared}>{action.label}</Menu.Item>
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Depth-first: a `kind: 'menu'` group becomes a label plus its items, never a nested dropdown. */
|
|
293
|
+
function menuRows(actions: readonly BarAction[]): ReactNode[] {
|
|
294
|
+
return actions.flatMap((action): ReactNode[] => {
|
|
295
|
+
if (action.kind === 'menu') {
|
|
296
|
+
return [
|
|
297
|
+
<Menu.Label key={`${action.key}-label`}>{action.label}</Menu.Label>,
|
|
298
|
+
...menuRows(action.items),
|
|
299
|
+
]
|
|
300
|
+
}
|
|
301
|
+
if (action.kind === 'custom') {
|
|
302
|
+
return [
|
|
303
|
+
<div key={action.key} className={classes.customRow}>
|
|
304
|
+
{action.node}
|
|
305
|
+
</div>,
|
|
306
|
+
]
|
|
307
|
+
}
|
|
308
|
+
return [<MenuRow key={action.key} action={action} />]
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export type OverflowMenuProps = {
|
|
313
|
+
actions: readonly BarAction[]
|
|
314
|
+
/**
|
|
315
|
+
* `more` — the labelled desktop `More` button. `kebab` — the mobile ⋯ icon button.
|
|
316
|
+
* @default 'more'
|
|
317
|
+
*/
|
|
318
|
+
trigger?: 'more' | 'kebab'
|
|
319
|
+
/** Trigger label (and the kebab's accessible name). @default 'More' */
|
|
320
|
+
label?: string
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The overflow fold — the only place an action that doesn't fit goes (law C7). Rows are 44px below
|
|
325
|
+
* `sm` (`--vx-space-sheet-row-height`), so the mobile kebab is already a touch surface without a
|
|
326
|
+
* second, sheet-shaped component.
|
|
327
|
+
*/
|
|
328
|
+
export function OverflowMenu({
|
|
329
|
+
actions,
|
|
330
|
+
trigger = 'more',
|
|
331
|
+
label = 'More',
|
|
332
|
+
}: OverflowMenuProps): ReactNode {
|
|
333
|
+
if (actions.length === 0) return null
|
|
334
|
+
return (
|
|
335
|
+
<Menu
|
|
336
|
+
position="bottom-end"
|
|
337
|
+
offset={6}
|
|
338
|
+
withinPortal
|
|
339
|
+
// AppShell itself sits at z-index 100 (`getDefaultZIndex('app')`), so the menu must clear it.
|
|
340
|
+
zIndex={400}
|
|
341
|
+
closeOnItemClick
|
|
342
|
+
closeOnClickOutside
|
|
343
|
+
closeOnEscape
|
|
344
|
+
trapFocus
|
|
345
|
+
returnFocus
|
|
346
|
+
// Required for a menu following the WAI-ARIA disclosure pattern — Mantine's own note on the
|
|
347
|
+
// prop. Without it every row is `tabindex="-1"`.
|
|
348
|
+
menuItemTabIndex={0}
|
|
349
|
+
>
|
|
350
|
+
<Menu.Target>
|
|
351
|
+
{trigger === 'kebab' ? (
|
|
352
|
+
// `size="ctl"` explicitly, not inherited: the kebab is rendered by `HeaderGlobalActions`
|
|
353
|
+
// too, which is shell chrome rather than a home's slot, so there is no `CtlSlot` above it
|
|
354
|
+
// there. 28px beside 30px buttons is the mismatch the header read as.
|
|
355
|
+
<ActionIcon variant="subtle" size="ctl" aria-label={label}>
|
|
356
|
+
<IconSlot className={classes.kebabIcon}>
|
|
357
|
+
<DotsGlyph />
|
|
358
|
+
</IconSlot>
|
|
359
|
+
</ActionIcon>
|
|
360
|
+
) : (
|
|
361
|
+
<Button
|
|
362
|
+
variant="default"
|
|
363
|
+
rightSection={
|
|
364
|
+
<IconSlot className={classes.chevronIcon}>
|
|
365
|
+
<ChevronGlyph />
|
|
366
|
+
</IconSlot>
|
|
367
|
+
}
|
|
368
|
+
>
|
|
369
|
+
{label}
|
|
370
|
+
</Button>
|
|
371
|
+
)}
|
|
372
|
+
</Menu.Target>
|
|
373
|
+
<Menu.Dropdown>{menuRows(actions)}</Menu.Dropdown>
|
|
374
|
+
</Menu>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Internal — the shared projection behind `ActionGroup` (and `PageBar`'s two rows).
|
|
380
|
+
*
|
|
381
|
+
* `host` is the whole reason this is not just `ActionGroup`. The shell's `mobile: 'more'` global
|
|
382
|
+
* actions must land in EXACTLY ONE kebab, and the only instance entitled to them is `PageBar`'s
|
|
383
|
+
* row 1. When every `ActionGroup` read `BarExtrasContext`, a page with `filtersEnd` (or any
|
|
384
|
+
* consumer composing an `ActionGroup` into a `Section`/`ChartCard` `actions` slot) grew a SECOND
|
|
385
|
+
* kebab that re-mounted the shell's global nodes and stole the claim from the shell's own fallback.
|
|
386
|
+
* `host: 'slot'` — the public `ActionGroup`'s value — gets `NO_EXTRAS`: no global rows, no claim.
|
|
387
|
+
*/
|
|
388
|
+
type BarActionRowProps = ActionGroupProps & {
|
|
389
|
+
/** `page` = `PageBar` row 1, the one instance that reads the shell extras and takes the claim. */
|
|
390
|
+
host: 'page' | 'slot'
|
|
391
|
+
/**
|
|
392
|
+
* `desktop` renders the desktop variant only — `PageBar` row 2, whose items live in the row-1
|
|
393
|
+
* kebab below `sm` (spec §2.1: mobile row 2 is tabs + the first pill + `Filters (n)`, nothing
|
|
394
|
+
* else). @default 'both'
|
|
395
|
+
*/
|
|
396
|
+
viewport?: 'both' | 'desktop'
|
|
397
|
+
/**
|
|
398
|
+
* Actions projected onto the MOBILE variant only, never the desktop row — `PageBar.filtersEnd`,
|
|
399
|
+
* whose desktop home is row 2. Each still obeys its own `mobile` placement, so a `'bar'` one
|
|
400
|
+
* becomes an inline icon button and a `'hidden'` one stays desktop-only.
|
|
401
|
+
*/
|
|
402
|
+
mobileOnly?: readonly BarAction[]
|
|
403
|
+
/**
|
|
404
|
+
* The home's `SyncButton`, mounted ONCE between the secondaries and the primary — see the ordering
|
|
405
|
+
* note in the body. Only `PageBar` row 1 passes it; a home with no sync passes nothing and the
|
|
406
|
+
* desktop row stays a single group.
|
|
407
|
+
*/
|
|
408
|
+
syncNode?: ReactNode
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Splits a bar row into runs, where a run of length > 1 is rendered as one `ControlGroup`.
|
|
413
|
+
*
|
|
414
|
+
* Two triggers, and they are the same rule read on two viewports (`docs/CONTROLS-SPEC.md` §3):
|
|
415
|
+
*
|
|
416
|
+
* - **`group: true`** — declared adjacency. The caller states that these act on one thing, so they
|
|
417
|
+
* are joined on both viewports.
|
|
418
|
+
* - **Adjacent ICON-ONLY entries on the mobile bar** (`viewport: 'mobile'`). Below `sm` a
|
|
419
|
+
* `BarActionItem` carrying an icon renders as an `ActionIcon` and nothing else (`MobileBarEntry`),
|
|
420
|
+
* so two of them in a row are two 30px squares with a 6px gap — three boxes' worth of border for
|
|
421
|
+
* two actions, in the one place width is scarce. This half is DERIVED from what is actually
|
|
422
|
+
* rendered, not declared, which is why it cannot be a prop: the caller does not know which of its
|
|
423
|
+
* actions the phone form draws icon-only.
|
|
424
|
+
*
|
|
425
|
+
* `excludeKey` is the primary's, and it always breaks the run: a `filled` primary joined to a
|
|
426
|
+
* `default`/`subtle` sibling puts a fill edge against a border edge, which paints a rim in neither
|
|
427
|
+
* colour. A `kind: 'menu'`/`kind: 'custom'` entry breaks it for its own reason — the first is a
|
|
428
|
+
* dropdown trigger with its own overlay, the second is a node basalt does not draw, and neither can
|
|
429
|
+
* be promised the shared-border geometry.
|
|
430
|
+
*
|
|
431
|
+
* **A joined member that ships an icon renders ICON-ONLY, on desktop too**, with its `label` demoted
|
|
432
|
+
* to the accessible name. That is not a saving, it is what a joined set MEANS: `‹ Today ›` is one
|
|
433
|
+
* control whose middle segment names it, and the arrows' content is their direction. Rendered
|
|
434
|
+
* labelled, the same three actions measured 304px against 118px — nearly a quarter of the bar for a
|
|
435
|
+
* word already implied by the arrow. A joined member with no icon (`Absolute | Rate`) keeps its
|
|
436
|
+
* label, so a labelled joined set is unaffected; the rule reads what the caller actually supplied
|
|
437
|
+
* rather than adding a prop to ask.
|
|
438
|
+
*/
|
|
439
|
+
function joinRuns(
|
|
440
|
+
actions: readonly BarAction[],
|
|
441
|
+
opts: { viewport: 'desktop' | 'mobile'; excludeKey?: string | undefined },
|
|
442
|
+
): BarAction[][] {
|
|
443
|
+
const joinable = (action: BarAction): boolean =>
|
|
444
|
+
action.kind === undefined &&
|
|
445
|
+
action.key !== opts.excludeKey &&
|
|
446
|
+
(action.group === true || (opts.viewport === 'mobile' && action.icon !== undefined))
|
|
447
|
+
|
|
448
|
+
const runs: BarAction[][] = []
|
|
449
|
+
for (const action of actions) {
|
|
450
|
+
const tail = runs.at(-1)
|
|
451
|
+
if (joinable(action) && tail !== undefined && tail.every(joinable)) tail.push(action)
|
|
452
|
+
else runs.push([action])
|
|
453
|
+
}
|
|
454
|
+
return runs
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function BarActionRow({
|
|
458
|
+
primary,
|
|
459
|
+
secondary,
|
|
460
|
+
host,
|
|
461
|
+
viewport = 'both',
|
|
462
|
+
mobileOnly = [],
|
|
463
|
+
syncNode,
|
|
464
|
+
}: BarActionRowProps): ReactNode {
|
|
465
|
+
const context = useContext(BarExtrasContext)
|
|
466
|
+
// Read unconditionally (hook order), scoped afterwards — see this type's doc.
|
|
467
|
+
const extras = host === 'page' ? context : NO_EXTRAS
|
|
468
|
+
const list = secondary ?? []
|
|
469
|
+
|
|
470
|
+
const flat = list.filter((a): a is BarActionItem | BarActionCustom => a.kind !== 'menu')
|
|
471
|
+
const menus = list.filter((a): a is BarActionMenu => a.kind === 'menu')
|
|
472
|
+
const inline = flat.slice(0, DESKTOP_SECONDARY_MAX)
|
|
473
|
+
const desktopOverflow = [...flat.slice(DESKTOP_SECONDARY_MAX), ...menus]
|
|
474
|
+
|
|
475
|
+
const wantsMobile = viewport === 'both'
|
|
476
|
+
const mobileList = [...list, ...mobileOnly]
|
|
477
|
+
const mobileBar = wantsMobile
|
|
478
|
+
? [
|
|
479
|
+
...(primary !== undefined && barActionMobile(primary, true) === 'bar' ? [primary] : []),
|
|
480
|
+
...mobileList.filter((a) => barActionMobile(a, false) === 'bar'),
|
|
481
|
+
]
|
|
482
|
+
: []
|
|
483
|
+
const mobileMore = wantsMobile
|
|
484
|
+
? [
|
|
485
|
+
...(primary !== undefined && barActionMobile(primary, true) === 'more' ? [primary] : []),
|
|
486
|
+
...mobileList.filter((a) => barActionMobile(a, false) === 'more'),
|
|
487
|
+
...extras.mobileMoreActions,
|
|
488
|
+
]
|
|
489
|
+
: []
|
|
490
|
+
|
|
491
|
+
// The claim is what makes "one kebab per header" true: while this group owns a kebab, the shell
|
|
492
|
+
// renders none, so its `mobile: 'more'` global actions have exactly one home. Only a `page` host
|
|
493
|
+
// can claim — `NO_EXTRAS.claimKebab` is a no-op, and its `mobileMoreActions` is empty.
|
|
494
|
+
const claimsKebab = mobileMore.length > 0
|
|
495
|
+
const claimKebab = extras.claimKebab
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
if (!claimsKebab) return
|
|
498
|
+
return claimKebab()
|
|
499
|
+
}, [claimsKebab, claimKebab])
|
|
500
|
+
|
|
501
|
+
// The PRIMARY is the row's LAST desktop entry, not its first (`docs/CONTROLS-SPEC.md` §2.1):
|
|
502
|
+
// custom chips · secondaries · `More` · sync · primary. Reading order runs weakest-to-strongest and
|
|
503
|
+
// ends at the header's right edge, where the commit action belongs; leading with it put the one
|
|
504
|
+
// filled button furthest from the edge and left `More` and `Sync` reading as the row's conclusion.
|
|
505
|
+
//
|
|
506
|
+
// `syncNode` is what splits the desktop row in two, and it splits it rather than joining it
|
|
507
|
+
// because a `SyncButton` must be mounted EXACTLY ONCE (law C9 — it holds a live relative age and
|
|
508
|
+
// its own interval). Rendering it inside both the `visibleFrom` and the `hiddenFrom` group would
|
|
509
|
+
// double that. So it sits BETWEEN them, unwrapped, and the primary follows it in a second
|
|
510
|
+
// desktop-only group. With no `syncNode` there is nothing to sit between and the desktop row stays
|
|
511
|
+
// one group — which is also what keeps a plain `ActionGroup` a single element to query.
|
|
512
|
+
const desktopPrimary =
|
|
513
|
+
primary !== undefined ? <BarEntry action={primary} emphasis="primary" /> : null
|
|
514
|
+
const hasDesktop = primary !== undefined || list.length > 0
|
|
515
|
+
const hasMobile = mobileBar.length > 0 || claimsKebab
|
|
516
|
+
if (!hasDesktop && !hasMobile && syncNode === undefined) return null
|
|
517
|
+
|
|
518
|
+
const desktopLead = inline.length > 0 || desktopOverflow.length > 0 || syncNode === undefined
|
|
519
|
+
|
|
520
|
+
return (
|
|
521
|
+
<>
|
|
522
|
+
{hasDesktop && desktopLead && (
|
|
523
|
+
<Group gap={BAR_GAP} wrap="nowrap" visibleFrom="sm">
|
|
524
|
+
{joinRuns(inline, { viewport: 'desktop' }).map((run) =>
|
|
525
|
+
run.length === 1 ? (
|
|
526
|
+
<BarEntry key={run[0]!.key} action={run[0]!} emphasis="secondary" />
|
|
527
|
+
) : (
|
|
528
|
+
<ControlGroup key={`join-${run[0]!.key}`}>
|
|
529
|
+
{run.map((action) => (
|
|
530
|
+
<BarEntry key={action.key} action={action} emphasis="secondary" iconOnly />
|
|
531
|
+
))}
|
|
532
|
+
</ControlGroup>
|
|
533
|
+
),
|
|
534
|
+
)}
|
|
535
|
+
<OverflowMenu actions={desktopOverflow} />
|
|
536
|
+
{syncNode === undefined && desktopPrimary}
|
|
537
|
+
</Group>
|
|
538
|
+
)}
|
|
539
|
+
{syncNode}
|
|
540
|
+
{syncNode !== undefined && desktopPrimary !== null && (
|
|
541
|
+
<Group gap={BAR_GAP} wrap="nowrap" visibleFrom="sm">
|
|
542
|
+
{desktopPrimary}
|
|
543
|
+
</Group>
|
|
544
|
+
)}
|
|
545
|
+
{hasMobile && (
|
|
546
|
+
<Group gap={BAR_GAP} wrap="nowrap" hiddenFrom="sm">
|
|
547
|
+
{joinRuns(mobileBar, { viewport: 'mobile', excludeKey: primary?.key }).map((run) =>
|
|
548
|
+
run.length === 1 ? (
|
|
549
|
+
<BarEntry
|
|
550
|
+
key={run[0]!.key}
|
|
551
|
+
action={run[0]!}
|
|
552
|
+
emphasis={run[0]!.key === primary?.key ? 'mobile-primary' : 'mobile-secondary'}
|
|
553
|
+
/>
|
|
554
|
+
) : (
|
|
555
|
+
<ControlGroup key={`join-${run[0]!.key}`}>
|
|
556
|
+
{run.map((action) => (
|
|
557
|
+
<BarEntry
|
|
558
|
+
key={action.key}
|
|
559
|
+
action={action}
|
|
560
|
+
emphasis={action.key === primary?.key ? 'mobile-primary' : 'mobile-secondary'}
|
|
561
|
+
/>
|
|
562
|
+
))}
|
|
563
|
+
</ControlGroup>
|
|
564
|
+
),
|
|
565
|
+
)}
|
|
566
|
+
{claimsKebab && (
|
|
567
|
+
<OverflowMenu actions={mobileMore} trigger="kebab" label="More actions" />
|
|
568
|
+
)}
|
|
569
|
+
</Group>
|
|
570
|
+
)}
|
|
571
|
+
</>
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* A home's `actions` slot, projected for both viewports.
|
|
577
|
+
*
|
|
578
|
+
* Desktop: `primary` as a `filled` button · up to `DESKTOP_SECONDARY_MAX` secondaries as `default`
|
|
579
|
+
* buttons · everything past that, plus every `kind: 'menu'`, in one `More` menu.
|
|
580
|
+
* Mobile: `primary` as an icon button when it ships an icon and a compact labelled button when it
|
|
581
|
+
* does not · one kebab holding every `mobile: 'more'` action · `mobile: 'hidden'` drops the action
|
|
582
|
+
* entirely.
|
|
583
|
+
*
|
|
584
|
+
* The shell's `mobile: 'more'` global actions are folded in by `PageBar`'s ROW-1 group only, not by
|
|
585
|
+
* this component — see `BarActionRowProps.host` for why every other home has to stay out of them.
|
|
586
|
+
*
|
|
587
|
+
* @example
|
|
588
|
+
* <PageBar actions={{ primary: { key: 'new', label: 'New run', onClick: start },
|
|
589
|
+
* secondary: [{ key: 'export', label: 'Export', onClick: exportCsv }] }} />
|
|
590
|
+
*/
|
|
591
|
+
export function ActionGroup(props: ActionGroupProps): ReactNode {
|
|
592
|
+
return <BarActionRow {...props} host="slot" />
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* One bar entry, resolved by kind: a custom node renders itself, a menu group folds to `More`.
|
|
597
|
+
*
|
|
598
|
+
* A `kind: 'custom'` node goes in a `ctl`-height slot rather than straight into the row. It is the
|
|
599
|
+
* one entry basalt does not draw, so it is also the one entry that can arrive at any height — a
|
|
600
|
+
* `Badge` is 20px, a bare `<span>` is its line box — and a 20px chip beside three 30px buttons is
|
|
601
|
+
* exactly the "buttons and badges have different sizes" the header read as. The slot pins the tier's
|
|
602
|
+
* height and centres whatever it holds; it does not restyle the node.
|
|
603
|
+
*/
|
|
604
|
+
function BarEntry({
|
|
605
|
+
action,
|
|
606
|
+
emphasis,
|
|
607
|
+
iconOnly,
|
|
608
|
+
}: {
|
|
609
|
+
action: BarAction
|
|
610
|
+
emphasis: 'primary' | 'secondary' | 'mobile-primary' | 'mobile-secondary'
|
|
611
|
+
/** Set by a JOINED run — see {@link joinRuns}. The label survives as the accessible name. */
|
|
612
|
+
iconOnly?: boolean
|
|
613
|
+
}): ReactNode {
|
|
614
|
+
if (action.kind === 'custom') return <span className={classes.customSlot}>{action.node}</span>
|
|
615
|
+
if (action.kind === 'menu') return <OverflowMenu actions={[action]} label={action.label} />
|
|
616
|
+
if (emphasis === 'mobile-primary') return <MobileBarEntry action={action} variant="filled" />
|
|
617
|
+
if (emphasis === 'mobile-secondary') return <MobileBarEntry action={action} variant="subtle" />
|
|
618
|
+
if (iconOnly === true && action.icon !== undefined) {
|
|
619
|
+
return <BarIconButton action={action} variant="default" />
|
|
620
|
+
}
|
|
621
|
+
return <BarButton action={action} variant={emphasis === 'primary' ? 'filled' : 'default'} />
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* One action on the mobile bar. With an icon it is an `ActionIcon`; WITHOUT one it is a compact
|
|
626
|
+
* `Button` carrying the label. Both emphases go through it, so the rule is the same for the primary
|
|
627
|
+
* and for a `mobile: 'bar'` secondary.
|
|
628
|
+
*
|
|
629
|
+
* The first-grapheme fallback this replaced (`initial(label)`) drew `S` for `Save as report` and `E`
|
|
630
|
+
* for `Export`, which is an avatar — a glyph a reader has to already know the meaning of. A label is
|
|
631
|
+
* longer and says what the button does, and the breadcrumb beside it now truncates
|
|
632
|
+
* (`shell/app-header.module.css`), so the width is available. Dropping the fallback WITHOUT this
|
|
633
|
+
* branch would have been worse than either: `BarIconButton` renders `action.icon`, so an icon-less
|
|
634
|
+
* action would have become an empty 30px box.
|
|
635
|
+
*
|
|
636
|
+
* `px` is the phone form's own inset — the `ctl` default is sized for a labelled desktop button.
|
|
637
|
+
*/
|
|
638
|
+
function MobileBarEntry({
|
|
639
|
+
action,
|
|
640
|
+
variant,
|
|
641
|
+
}: {
|
|
642
|
+
action: BarActionItem
|
|
643
|
+
variant: 'filled' | 'subtle'
|
|
644
|
+
}): ReactNode {
|
|
645
|
+
if (action.icon !== undefined) return <BarIconButton action={action} variant={variant} />
|
|
646
|
+
return (
|
|
647
|
+
<BarButton
|
|
648
|
+
action={action}
|
|
649
|
+
// `subtle` is an ActionIcon variant, not a Button one — the labelled form of a non-primary bar
|
|
650
|
+
// action is a `default` button, which is what a secondary reads as on desktop too.
|
|
651
|
+
variant={variant === 'filled' ? 'filled' : 'default'}
|
|
652
|
+
px={MOBILE_PRIMARY_PADDING_X}
|
|
653
|
+
/>
|
|
654
|
+
)
|
|
655
|
+
}
|