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
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* StatCard — the KPI atom (docs/DESIGN-SPEC.md §5): a panel +
|
|
3
|
-
* (spacing xs/sm inset,
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* StatCard — the KPI atom (docs/DESIGN-SPEC.md §5, docs/CONTROLS-SPEC.md §2.2): a panel +
|
|
3
|
+
* shadow-card + card-radius card (spacing xs/sm inset, no min-height — the content states the
|
|
4
|
+
* height, ~88-96px for the title/hero/trend shape). The header composes
|
|
5
|
+
* `WidgetHeader tier="widget"` — title/icon/value/delta/deltaPeriod all render through it, on the
|
|
6
|
+
* shared hero-metric row under the title. `actions` (e.g. a ghost "..." menu trigger) is wrapped in
|
|
7
|
+
* `CtlSlot tier="widget"` — the 24px ActionIcon step the 28px widget header row can hold, not the
|
|
8
|
+
* 30px `ctl` tier, which used to grow the row and knock a card with a kebab 2px out of line with the
|
|
9
|
+
* card beside it. `StatCard` is Mantine-coupled (`src/dashboard/`), unlike `ChartCard`, so it can
|
|
10
|
+
* mount the slot at all; `ChartCard` writes the same `data-basalt-tier="widget"` marker by hand.
|
|
11
|
+
*
|
|
12
|
+
* `sparklinePlacement` decides where the trend visual sits: `'bleed'` (default) keeps the historic
|
|
13
|
+
* full-width row beneath the hero value, bled past the card's own inset padding; `'right'` sits it
|
|
14
|
+
* beside the hero row, the reference-design look. Below `sm`, `'right'` collapses back to the
|
|
15
|
+
* `'bleed'` layout — CSS only, no JS branch (`stat-card.module.css`).
|
|
16
|
+
*
|
|
17
|
+
* `info` and `subtitle` are forwarded to that same `WidgetHeader`, and a hero KPI usually wants
|
|
18
|
+
* both: `value` is typed `string`, so the unit and the basis have nowhere else to go — `subtitle`
|
|
19
|
+
* carries them as a muted line, and `info` puts "how this number is computed" behind the glyph
|
|
20
|
+
* beside the title instead of inside the heading's accessible name.
|
|
7
21
|
*
|
|
8
22
|
* `src/dashboard` stays @visx-free — `sparkline` is a plain `ReactNode` slot, never a chart import
|
|
9
23
|
* here. Pass a `LineSparkline`/`BarSparkline` from `basalt-ui/charts` at the call site.
|
|
@@ -24,20 +38,44 @@
|
|
|
24
38
|
* import { StatCard } from 'basalt-ui'
|
|
25
39
|
* import { LineSparkline } from 'basalt-ui/charts'
|
|
26
40
|
*
|
|
41
|
+
* // `sparkline` is a RENDER PROP over the slot's measured box, so full-bleed is genuinely the
|
|
42
|
+
* // card's width — no `useChartSize` wrapper component, no hardcoded pixel value.
|
|
43
|
+
* <StatCard
|
|
44
|
+
* title="Active Users"
|
|
45
|
+
* value="12,483"
|
|
46
|
+
* delta={4.2}
|
|
47
|
+
* sparkline={({ width, height }) => <LineSparkline data={history} width={width} height={height} />}
|
|
48
|
+
* />
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* // The reference-design look — sparkline beside the hero value row instead of bled below it. The
|
|
52
|
+
* // slot is a fixed 72×26 there, so the render prop still receives the box it should draw into.
|
|
27
53
|
* <StatCard
|
|
28
|
-
*
|
|
54
|
+
* title="Active Users"
|
|
29
55
|
* value="12,483"
|
|
30
56
|
* delta={4.2}
|
|
31
|
-
*
|
|
57
|
+
* sparklinePlacement="right"
|
|
58
|
+
* sparkline={({ width, height }) => (
|
|
59
|
+
* <BarSparkline data={history} width={width} height={height} ariaLabel="Active users trend" />
|
|
60
|
+
* )}
|
|
61
|
+
* />
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* // A hero card: the unit on its own muted line, the method behind the info glyph.
|
|
65
|
+
* <StatCard
|
|
66
|
+
* title="Training load"
|
|
67
|
+
* value="412"
|
|
68
|
+
* subtitle="TSS · 7-day rolling"
|
|
69
|
+
* info="Sum of per-session TSS over the last 7 days, Garmin-reported."
|
|
32
70
|
* />
|
|
33
71
|
*
|
|
34
72
|
* @example
|
|
35
73
|
* // Past a threshold — an accent rail down the leading edge, value untouched.
|
|
36
|
-
* <StatCard
|
|
74
|
+
* <StatCard title="Downtime" value="29 min" tone="bad" />
|
|
37
75
|
*
|
|
38
76
|
* @example
|
|
39
77
|
* // The earned zero — a measured value worth asserting, not a card that merely has no reading.
|
|
40
|
-
* <StatCard
|
|
78
|
+
* <StatCard title="Downtime · last 24h" value="0 min" tone="good" />
|
|
41
79
|
*
|
|
42
80
|
* @example
|
|
43
81
|
* // The honest gate. A zero can be synthesized as well as measured — a downtime figure derived
|
|
@@ -46,19 +84,100 @@
|
|
|
46
84
|
* // about a reading, so the gate belongs on whether a reading exists, not on whether the number
|
|
47
85
|
* // happens to be defined — those are separate facts. Gate on measured coverage instead:
|
|
48
86
|
* <StatCard
|
|
49
|
-
*
|
|
87
|
+
* title="Downtime · last 24h"
|
|
50
88
|
* value={`${downtimeMinutes} min`}
|
|
51
89
|
* tone={hasCoverage ? (downtimeMinutes === 0 ? 'good' : 'bad') : undefined}
|
|
52
90
|
* />
|
|
53
91
|
*/
|
|
54
|
-
import { Box, Card,
|
|
55
|
-
import
|
|
56
|
-
import {
|
|
92
|
+
import { Box, Card, VisuallyHidden } from '@mantine/core'
|
|
93
|
+
import { useEffect, useRef, useState } from 'react'
|
|
94
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
95
|
+
import { WidgetHeader } from '../widget-header'
|
|
96
|
+
import { CtlSlot } from '../theme'
|
|
57
97
|
import { VX } from '../tokens'
|
|
98
|
+
import classes from './stat-card.module.css'
|
|
58
99
|
|
|
59
100
|
/** A threshold verdict on the card's value. `undefined` is never tinted — see the module docblock. */
|
|
60
101
|
export type StatCardTone = 'good' | 'warn' | 'bad'
|
|
61
102
|
|
|
103
|
+
/**
|
|
104
|
+
* One row of a {@link StatCardProps.breakdown} — the two or three parts a hero number is made of.
|
|
105
|
+
*
|
|
106
|
+
* `value` is a pre-formatted `string` for the same reason the hero's is: the card cannot know the
|
|
107
|
+
* locale, the currency or the precision the number was measured at. `tone` marks ONE row as past a
|
|
108
|
+
* threshold and is the same vocabulary as the card's own `tone`, so a row saying `bad` and a rail
|
|
109
|
+
* saying `bad` are the same claim at two scales; omitting it is untinted, never `'good'`.
|
|
110
|
+
*/
|
|
111
|
+
export type StatCardBreakdownRow = {
|
|
112
|
+
readonly label: string
|
|
113
|
+
readonly value: string
|
|
114
|
+
readonly tone?: StatCardTone
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The `'right'` placement's slot width, in px — a flex BASIS, not a fixed size. 72 is the reference
|
|
119
|
+
* KPI card's trend width and the slot asks for exactly that; when the hero row needs the space (a
|
|
120
|
+
* long formatted value plus a delta badge in a 4-up grid) the slot is the side that yields, because
|
|
121
|
+
* the number is the card's content and the trend qualifies it. The render prop is handed the
|
|
122
|
+
* MEASURED width either way, so the bars are drawn at the width they actually got — the previous
|
|
123
|
+
* fixed 72 painted over the value instead.
|
|
124
|
+
*/
|
|
125
|
+
const SPARKLINE_RIGHT_WIDTH = 72
|
|
126
|
+
/** The `'right'` placement's slot height — sized to the hero row it sits beside, not to the card. */
|
|
127
|
+
const SPARKLINE_RIGHT_HEIGHT = 26
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The measured box a `sparkline` render prop receives.
|
|
131
|
+
*
|
|
132
|
+
* A `LineSparkline`/`BarSparkline` takes `width`/`height` as NUMBERS (they are SVG attributes — an
|
|
133
|
+
* svg cannot size itself from CSS and then scale its scales), so "full bleed" was never expressible
|
|
134
|
+
* as a `ReactNode`: the caller had to guess a pixel width, or write its own `useChartSize` wrapper
|
|
135
|
+
* component. Four consumers wrote that wrapper; two of them hardcoded a width and drew a sparkline
|
|
136
|
+
* that stopped short of the card edge on every viewport but the one they built on.
|
|
137
|
+
*/
|
|
138
|
+
export type StatCardSparklineSize = {
|
|
139
|
+
width: number
|
|
140
|
+
height: number
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Measures a slot and re-measures on resize. Plain `ResizeObserver`, not `useChartSize`:
|
|
145
|
+
* `src/dashboard/**` is Mantine-coupled and @visx-FREE (see the module doc), and `useChartSize`
|
|
146
|
+
* wraps `@visx/responsive`.
|
|
147
|
+
*
|
|
148
|
+
* The `undefined` guard is for the test DOM — happy-dom ships no `ResizeObserver`, and the one-shot
|
|
149
|
+
* measure above it is what keeps a mounted card rendering its sparkline there.
|
|
150
|
+
*/
|
|
151
|
+
function useSlotWidth(active: boolean): {
|
|
152
|
+
ref: (node: HTMLDivElement | null) => void
|
|
153
|
+
width: number
|
|
154
|
+
} {
|
|
155
|
+
const [width, setWidth] = useState(0)
|
|
156
|
+
const nodeRef = useRef<HTMLDivElement | null>(null)
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
const node = nodeRef.current
|
|
160
|
+
if (!active || node === null) return
|
|
161
|
+
const measure = () => {
|
|
162
|
+
setWidth(node.getBoundingClientRect().width)
|
|
163
|
+
}
|
|
164
|
+
measure()
|
|
165
|
+
if (typeof ResizeObserver === 'undefined') return
|
|
166
|
+
const observer = new ResizeObserver(measure)
|
|
167
|
+
observer.observe(node)
|
|
168
|
+
return () => {
|
|
169
|
+
observer.disconnect()
|
|
170
|
+
}
|
|
171
|
+
}, [active])
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
ref: (node) => {
|
|
175
|
+
nodeRef.current = node
|
|
176
|
+
},
|
|
177
|
+
width,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
62
181
|
/** The rail's width, in px. Wide enough to read as a deliberate mark at a glance, narrow enough
|
|
63
182
|
* that it cannot be mistaken for the card's own edge. */
|
|
64
183
|
const TONE_RAIL_WIDTH = 3
|
|
@@ -70,19 +189,79 @@ const TONE_LABEL: Record<StatCardTone, string> = {
|
|
|
70
189
|
}
|
|
71
190
|
|
|
72
191
|
export type StatCardProps = {
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
/**
|
|
192
|
+
/** Head-font title, rendered via `WidgetHeader tier="widget"`. */
|
|
193
|
+
title: string
|
|
194
|
+
/** Optional leading icon, forwarded to `WidgetHeader`. */
|
|
195
|
+
icon?: ReactNode
|
|
196
|
+
/** Muted line under the hero row, forwarded to `WidgetHeader` — the unit or basis a
|
|
197
|
+
* pre-formatted `value` cannot carry (`per day`, `of 40 planned`). Not a second metric. */
|
|
198
|
+
subtitle?: string
|
|
199
|
+
/** Info tooltip beside the title, forwarded to `WidgetHeader` — how the number is computed. Never
|
|
200
|
+
* part of the heading's accessible name; see `WidgetHeaderProps.info`. */
|
|
201
|
+
info?: string
|
|
202
|
+
/** Pre-formatted KPI value string (mono ~24px, weight 600, ink) — the hero-row value. */
|
|
76
203
|
value: string
|
|
204
|
+
/** The value's unit, muted and mono at `--vx-text-sm`, immediately after it on the hero row —
|
|
205
|
+
* forwarded to `WidgetHeader`. `412` + `TSS`, not `"412 TSS"`: a unit painted at the hero's 24px
|
|
206
|
+
* reads as a second numeral. The BASIS (`7-day rolling`) is still `subtitle`; see
|
|
207
|
+
* `WidgetHeaderProps.unit`. */
|
|
208
|
+
unit?: string
|
|
209
|
+
/**
|
|
210
|
+
* The parts the hero number is made of — compact rows under it, one line each, no hairlines.
|
|
211
|
+
*
|
|
212
|
+
* It exists because three consumers wanted a KPI card that also SPLITS its number (revenue by
|
|
213
|
+
* channel, uptime by probe) and the only shapes basalt offered were a `subtitle` (one muted line,
|
|
214
|
+
* no second column) and a table. Both hand-rolled a card instead — which is the fork
|
|
215
|
+
* `shadow-basalt-export` reports as a `HeroCard`.
|
|
216
|
+
*
|
|
217
|
+
* Deliberately not a table and deliberately unruled: the divider law puts a hairline between
|
|
218
|
+
* OPTION rows and nowhere else (`docs/CONTROLS-SPEC.md` §2.1), and a KPI card that draws three
|
|
219
|
+
* of them stops reading as one card. Keep it to two or three rows — past that the card is a
|
|
220
|
+
* table, and a table is `BasaltDataTable` in a `Section`.
|
|
221
|
+
*/
|
|
222
|
+
breakdown?: readonly StatCardBreakdownRow[]
|
|
77
223
|
/** Signed delta rendered via `DeltaBadge`; omit to hide the trend chip entirely. */
|
|
78
224
|
delta?: number
|
|
79
225
|
/** Optional comparison timeframe shown after the delta (e.g. `MoM`) — forwarded to `DeltaBadge`. */
|
|
80
226
|
deltaPeriod?: string
|
|
81
|
-
/**
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Formats `delta` into the chip's label — forwarded to `WidgetHeader` and on to `DeltaBadge`.
|
|
229
|
+
* Defaults to `${Math.abs(delta).toFixed(1)}%`.
|
|
230
|
+
*
|
|
231
|
+
* A delta is not always a percentage, and the default silently claimed it was: a pace card's trend
|
|
232
|
+
* (`0:12 /km`) and a speed card's (`0.3 km/h`) both rendered as `0.3%` — a wrong unit on a KPI is
|
|
233
|
+
* the one failure worse than no chip, and it is why the consumer that needed it kept the card
|
|
234
|
+
* hand-rolled instead (the `HeroCard` fork `shadow-basalt-export` reports).
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* // An absolute delta that prints its own sign — no percent, no glyph saying it twice.
|
|
238
|
+
* <StatCard title="Pace" value="5:31" unit="/km" delta={-12}
|
|
239
|
+
* deltaFormat={(s) => `${s < 0 ? '−' : '+'}0:${String(Math.abs(s)).padStart(2, '0')} /km`}
|
|
240
|
+
* deltaGlyph={false} />
|
|
241
|
+
*/
|
|
242
|
+
deltaFormat?: (delta: number) => string
|
|
243
|
+
/** Render the ▲/▼ glyph on the delta chip. Defaults to `true`; a zero delta never shows one. Pass
|
|
244
|
+
* `false` when `deltaFormat` prints the sign itself. */
|
|
245
|
+
deltaGlyph?: boolean
|
|
246
|
+
/**
|
|
247
|
+
* Optional trend visual. Either a node, or a RENDER PROP receiving the slot's measured box.
|
|
248
|
+
*
|
|
249
|
+
* Prefer the render prop for anything from `basalt-ui/charts`: those take numeric `width`/`height`
|
|
250
|
+
* (SVG attributes), so a plain node has to hardcode a width, and a hardcoded width is not
|
|
251
|
+
* full-bleed on any viewport but the one it was typed on. With `sparklinePlacement="bleed"` the
|
|
252
|
+
* measured width is the card's own inner width including the bled inset; with `'right'` it is the
|
|
253
|
+
* fixed 72×26 slot (see {@link SPARKLINE_RIGHT_WIDTH} for why that one is not measured).
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* sparkline={({ width, height }) => <BarSparkline data={history} width={width} height={height} />}
|
|
257
|
+
*/
|
|
258
|
+
sparkline?: ReactNode | ((size: StatCardSparklineSize) => ReactNode)
|
|
259
|
+
/** Where `sparkline` sits. `'bleed'` (default) is today's full-width row bled to the card edges;
|
|
260
|
+
* `'right'` sits it beside the hero value row. Collapses to `'bleed'` below `sm`. */
|
|
261
|
+
sparklinePlacement?: 'bleed' | 'right'
|
|
262
|
+
/** Header-right slot (e.g. a ghost "..." menu trigger) — wrapped in `CtlSlot tier="widget"`, so a
|
|
263
|
+
* raw `ActionIcon` with no `size` lands on the 24px step the 28px header row holds (C1/C5). */
|
|
264
|
+
actions?: ReactNode
|
|
86
265
|
/** Threshold verdict — draws an accent rail down the card's leading edge and announces itself to
|
|
87
266
|
* assistive tech. Omitting it is NOT `'good'`: omitted covers a reading that is fine AND one that
|
|
88
267
|
* is absent, and stays untinted so a card with nothing measured can never render green. Pass
|
|
@@ -91,14 +270,28 @@ export type StatCardProps = {
|
|
|
91
270
|
}
|
|
92
271
|
|
|
93
272
|
export function StatCard({
|
|
94
|
-
|
|
273
|
+
title,
|
|
274
|
+
icon,
|
|
275
|
+
subtitle,
|
|
276
|
+
info,
|
|
95
277
|
value,
|
|
278
|
+
unit,
|
|
279
|
+
breakdown,
|
|
96
280
|
delta,
|
|
97
281
|
deltaPeriod,
|
|
282
|
+
deltaFormat,
|
|
283
|
+
deltaGlyph,
|
|
98
284
|
sparkline,
|
|
99
|
-
|
|
285
|
+
sparklinePlacement = 'bleed',
|
|
286
|
+
actions,
|
|
100
287
|
tone,
|
|
101
288
|
}: StatCardProps) {
|
|
289
|
+
const isRender = typeof sparkline === 'function'
|
|
290
|
+
const bleeds = sparklinePlacement === 'bleed'
|
|
291
|
+
// Only a render prop needs a measurement; a plain node sizes itself.
|
|
292
|
+
const slot = useSlotWidth(isRender)
|
|
293
|
+
const measured: StatCardSparklineSize = { width: slot.width, height: SPARKLINE_RIGHT_HEIGHT }
|
|
294
|
+
|
|
102
295
|
return (
|
|
103
296
|
<Card
|
|
104
297
|
style={{
|
|
@@ -108,11 +301,13 @@ export function StatCard({
|
|
|
108
301
|
// shadow-card ring is unaffected (verified — only an ANCESTOR's overflow clips a
|
|
109
302
|
// descendant's shadow). Mantine's Card root already sets `overflow: hidden`; this is
|
|
110
303
|
// explicit for intent.
|
|
304
|
+
//
|
|
305
|
+
// NO `minHeight`. It was 118px, which is ~25px of dead space under a card holding a title
|
|
306
|
+
// row, a hero row and a 26px trend — and it was dead space the card could not use, since the
|
|
307
|
+
// body is `height: 100%` and the sparkline is `margin-top: auto`. The content is what states
|
|
308
|
+
// the height now (~88-96px), and four cards in a `SimpleGrid` still match because a grid row
|
|
309
|
+
// stretches its items.
|
|
111
310
|
padding: 'var(--mantine-spacing-xs) var(--mantine-spacing-sm)',
|
|
112
|
-
minHeight: 118,
|
|
113
|
-
display: 'flex', // theme-allow inline-display — Card root as a flex column; Mantine Card takes no layout props
|
|
114
|
-
flexDirection: 'column',
|
|
115
|
-
gap: 'var(--vx-space-stat-card-gap)',
|
|
116
311
|
overflow: 'hidden',
|
|
117
312
|
// Anchors the tone rail. Set unconditionally so a card's stacking context does not change
|
|
118
313
|
// depending on whether it happens to have crossed a threshold this render.
|
|
@@ -143,48 +338,75 @@ export function StatCard({
|
|
|
143
338
|
</>
|
|
144
339
|
)}
|
|
145
340
|
|
|
146
|
-
<
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
letterSpacing: '-0.02em',
|
|
169
|
-
color: VX.ink,
|
|
170
|
-
}}
|
|
171
|
-
>
|
|
172
|
-
{value}
|
|
173
|
-
</span>
|
|
174
|
-
{delta !== undefined && <DeltaBadge value={delta} period={deltaPeriod} />}
|
|
175
|
-
</Group>
|
|
341
|
+
<div className={classes.body} data-placement={sparklinePlacement}>
|
|
342
|
+
<div className={classes.header}>
|
|
343
|
+
<WidgetHeader
|
|
344
|
+
tier="widget"
|
|
345
|
+
title={title}
|
|
346
|
+
{...(icon !== undefined && { icon })}
|
|
347
|
+
{...(subtitle !== undefined && { subtitle })}
|
|
348
|
+
{...(info !== undefined && { info })}
|
|
349
|
+
value={value}
|
|
350
|
+
{...(unit !== undefined && { unit })}
|
|
351
|
+
{...(delta !== undefined && { delta })}
|
|
352
|
+
{...(deltaPeriod !== undefined && { deltaPeriod })}
|
|
353
|
+
{...(deltaFormat !== undefined && { deltaFormat })}
|
|
354
|
+
{...(deltaGlyph !== undefined && { deltaGlyph })}
|
|
355
|
+
// `tier="widget"`, not the default `ctl`: the header row this slot sits in is 28px
|
|
356
|
+
// (`--vx-space-widget-header-height`) and a 30px `ctl` control grew it to 30, so a card
|
|
357
|
+
// with a kebab sat 2px below a card without one in the same grid row. See
|
|
358
|
+
// `theme/ctl-theme.tsx`'s `WIDGET_THEME`.
|
|
359
|
+
{...(actions !== undefined && {
|
|
360
|
+
actions: <CtlSlot tier="widget">{actions}</CtlSlot>,
|
|
361
|
+
})}
|
|
362
|
+
/>
|
|
176
363
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
364
|
+
{breakdown !== undefined &&
|
|
365
|
+
breakdown.length > 0 && (
|
|
366
|
+
// A `<dl>`, not a table and not a stack of divs: each row IS a term and its value, which
|
|
367
|
+
// is the one semantic that survives a reader meeting the card out of context.
|
|
368
|
+
//
|
|
369
|
+
// INSIDE `.header`, not beside it: with `sparklinePlacement="right"` the body is a flex
|
|
370
|
+
// ROW, so a third child here would sit next to the trend rather than under the number
|
|
371
|
+
// it splits. The header block is the card's text column in both placements.
|
|
372
|
+
<dl className={classes.breakdown}>
|
|
373
|
+
{breakdown.map((row) => (
|
|
374
|
+
<div
|
|
375
|
+
key={row.label}
|
|
376
|
+
className={classes.breakdownRow}
|
|
377
|
+
{...(row.tone !== undefined && { 'data-tone': row.tone })}
|
|
378
|
+
>
|
|
379
|
+
<dt className={classes.breakdownLabel}>{row.label}</dt>
|
|
380
|
+
<dd className={classes.breakdownValue}>{row.value}</dd>
|
|
381
|
+
</div>
|
|
382
|
+
))}
|
|
383
|
+
</dl>
|
|
384
|
+
)}
|
|
186
385
|
</div>
|
|
187
|
-
|
|
386
|
+
|
|
387
|
+
{sparkline !== undefined && (
|
|
388
|
+
<div
|
|
389
|
+
ref={slot.ref}
|
|
390
|
+
className={bleeds ? classes.sparklineBleed : classes.sparklineRight}
|
|
391
|
+
{...(!bleeds && {
|
|
392
|
+
// CUSTOM PROPERTIES, not `flexBasis`/`height` directly. A React inline style beats
|
|
393
|
+
// every stylesheet rule, so an inline `flexBasis: 72` also applied below `sm` — where
|
|
394
|
+
// `'right'` collapses to a COLUMN and flex-basis is therefore the main-axis HEIGHT.
|
|
395
|
+
// The mobile sparkline rendered in a 72px-tall box holding 26px of bars, which is the
|
|
396
|
+
// dead band under every KPI value on a phone. Handed to CSS as values instead, the
|
|
397
|
+
// media query can reset the box and the numbers still live in one place.
|
|
398
|
+
style: {
|
|
399
|
+
'--basalt-stat-sparkline-w': `${SPARKLINE_RIGHT_WIDTH}px`,
|
|
400
|
+
'--basalt-stat-sparkline-h': `${SPARKLINE_RIGHT_HEIGHT}px`,
|
|
401
|
+
} as CSSProperties,
|
|
402
|
+
})}
|
|
403
|
+
>
|
|
404
|
+
{/* A measured slot renders nothing on the first commit (width 0) — an SVG at width 0 is
|
|
405
|
+
a visible 0-width box that then jumps, which is worse than one frame of nothing. */}
|
|
406
|
+
{isRender ? measured.width > 0 && sparkline(measured) : sparkline}
|
|
407
|
+
</div>
|
|
408
|
+
)}
|
|
409
|
+
</div>
|
|
188
410
|
</Card>
|
|
189
411
|
)
|
|
190
412
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* and a silent fallback to left-aligned would look correct in review.
|
|
15
15
|
*/
|
|
16
16
|
import { MantineProvider, Text } from '@mantine/core'
|
|
17
|
-
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
17
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
18
18
|
import { describe, expect, test } from 'bun:test'
|
|
19
19
|
import { BasaltDataTable } from './data-table'
|
|
20
20
|
import { createColumnHelper } from './table'
|
|
@@ -74,15 +74,19 @@ describe('stickyHeader and table chrome', () => {
|
|
|
74
74
|
expect(renderTable().container.querySelector('thead')?.getAttribute('data-sticky')).toBeNull()
|
|
75
75
|
})
|
|
76
76
|
|
|
77
|
-
test('withTableBorder defaults
|
|
77
|
+
test('withTableBorder defaults OFF and can be turned on for a table that needs a frame', () => {
|
|
78
|
+
// It defaulted ON, and the box was three faults at once — its top edge separated the table's own
|
|
79
|
+
// `WidgetHeader` from its content, its bottom edge landed on the last row's hairline (two rules
|
|
80
|
+
// at zero distance), and its side edges divided nothing. See the prop's JSDoc; the head rule and
|
|
81
|
+
// the between-row rules are untouched, so the table still reads as a table.
|
|
78
82
|
expect(
|
|
79
83
|
renderTable().container.querySelector('table')?.getAttribute('data-with-table-border'),
|
|
80
|
-
).
|
|
84
|
+
).toBeNull()
|
|
81
85
|
expect(
|
|
82
|
-
renderTable({ withTableBorder:
|
|
86
|
+
renderTable({ withTableBorder: true })
|
|
83
87
|
.container.querySelector('table')
|
|
84
88
|
?.getAttribute('data-with-table-border'),
|
|
85
|
-
).
|
|
89
|
+
).toBe('true')
|
|
86
90
|
})
|
|
87
91
|
})
|
|
88
92
|
|
|
@@ -158,6 +162,37 @@ describe('regression — the bare table is unchanged', () => {
|
|
|
158
162
|
})
|
|
159
163
|
})
|
|
160
164
|
|
|
165
|
+
describe('title renders a WidgetHeader with the row-model count (C11)', () => {
|
|
166
|
+
test('omitting title renders no heading', () => {
|
|
167
|
+
renderTable()
|
|
168
|
+
expect(screen.queryByRole('heading')).toBeNull()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('title renders an h3 carrying the row count from getRowCount()', () => {
|
|
172
|
+
renderTable({ title: 'Top pages' })
|
|
173
|
+
expect(screen.getByRole('heading', { level: 3, name: 'Top pages' })).toBeDefined()
|
|
174
|
+
expect(screen.getByText('2')).toBeDefined() // ROWS has 2 entries
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('the toolbar has no fixed-width literals and resolves controls to the ctl tier', () => {
|
|
179
|
+
test('search input carries no inline width', () => {
|
|
180
|
+
const { container } = renderTable({ enableGlobalFilter: true })
|
|
181
|
+
const input = container.querySelector('input[placeholder="Search…"]')
|
|
182
|
+
expect(input?.getAttribute('style') ?? '').not.toContain('width')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('the toolbar renders inside a data-basalt-tier="ctl" slot', () => {
|
|
186
|
+
const { container } = renderTable({
|
|
187
|
+
enableGlobalFilter: true,
|
|
188
|
+
actions: <button type="button">Export</button>,
|
|
189
|
+
})
|
|
190
|
+
const slot = container.querySelector('[data-basalt-tier="ctl"]')
|
|
191
|
+
expect(slot).not.toBeNull()
|
|
192
|
+
expect(slot?.querySelector('button')?.textContent).toBe('Export')
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
161
196
|
/**
|
|
162
197
|
* The manual-pagination contract (argo round 10, P1).
|
|
163
198
|
*
|
|
@@ -331,19 +366,69 @@ describe('the empty state tracks the rendered rows, not the raw array', () => {
|
|
|
331
366
|
})
|
|
332
367
|
})
|
|
333
368
|
|
|
369
|
+
// Facets now render as FilterSet pills (EnumFilter/MultiSelectFilter, docs/CONTROLS-SPEC.md §3)
|
|
370
|
+
// instead of a raw Mantine Select. Mirrors `controls/controls.router.test.tsx`'s `openPill`
|
|
371
|
+
// harness fact: Mantine's Popover mounts its dropdown one flushed effect cycle after the click
|
|
372
|
+
// (needs `act`), and the dropdown's own contents read as `{ hidden: true }` to Testing Library
|
|
373
|
+
// while it still carries transition styles.
|
|
374
|
+
async function openPill(name: string | RegExp): Promise<void> {
|
|
375
|
+
await act(async () => {
|
|
376
|
+
fireEvent.click(screen.getByRole('button', { name }))
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
334
380
|
describe('onColumnFiltersChange — the seam server-side faceting needs', () => {
|
|
335
|
-
test('a facet
|
|
381
|
+
test('a single-select facet reports the new columnFilters via its pill popover', async () => {
|
|
336
382
|
const seen: ColumnFiltersState[] = []
|
|
337
|
-
|
|
383
|
+
renderTable({
|
|
338
384
|
facets: [
|
|
339
385
|
{ columnId: 'project', label: 'Project', options: [{ value: 'argo', label: 'argo' }] },
|
|
340
386
|
],
|
|
341
387
|
onColumnFiltersChange: (filters) => seen.push(filters),
|
|
342
388
|
})
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
fireEvent.click(select)
|
|
346
|
-
fireEvent.click(await screen.findByRole('option', { name: 'argo' }))
|
|
389
|
+
await openPill('Any project')
|
|
390
|
+
fireEvent.click(screen.getByRole('radio', { name: 'argo', hidden: true }))
|
|
347
391
|
await waitFor(() => expect(seen).toEqual([[{ id: 'project', value: 'argo' }]]))
|
|
348
392
|
})
|
|
393
|
+
|
|
394
|
+
test('picking the synthetic "Any <facet>" row clears the column filter again', async () => {
|
|
395
|
+
const seen: ColumnFiltersState[] = []
|
|
396
|
+
renderTable({
|
|
397
|
+
facets: [
|
|
398
|
+
{ columnId: 'project', label: 'Project', options: [{ value: 'argo', label: 'argo' }] },
|
|
399
|
+
],
|
|
400
|
+
onColumnFiltersChange: (filters) => seen.push(filters),
|
|
401
|
+
})
|
|
402
|
+
await openPill('Any project')
|
|
403
|
+
fireEvent.click(screen.getByRole('radio', { name: 'argo', hidden: true }))
|
|
404
|
+
await waitFor(() => expect(seen).toEqual([[{ id: 'project', value: 'argo' }]]))
|
|
405
|
+
|
|
406
|
+
await openPill('argo')
|
|
407
|
+
fireEvent.click(screen.getByRole('radio', { name: 'Any project', hidden: true }))
|
|
408
|
+
await waitFor(() => expect(seen[seen.length - 1]).toEqual([]))
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
test('a multi-select facet reports every checked value', async () => {
|
|
412
|
+
const seen: ColumnFiltersState[] = []
|
|
413
|
+
renderTable({
|
|
414
|
+
facets: [
|
|
415
|
+
{
|
|
416
|
+
columnId: 'project',
|
|
417
|
+
label: 'Project',
|
|
418
|
+
multiple: true,
|
|
419
|
+
options: [
|
|
420
|
+
{ value: 'argo', label: 'argo' },
|
|
421
|
+
{ value: 'linewatch', label: 'linewatch' },
|
|
422
|
+
],
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
onColumnFiltersChange: (filters) => seen.push(filters),
|
|
426
|
+
})
|
|
427
|
+
// A MULTI facet keeps the group label while its selection carries no information — that is
|
|
428
|
+
// `MultiSelectFilter`'s own law, and the reason only the single-select facet needed a readable
|
|
429
|
+
// synthetic row (`facetAllLabel`).
|
|
430
|
+
await openPill('Project')
|
|
431
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'argo', hidden: true }))
|
|
432
|
+
await waitFor(() => expect(seen).toEqual([[{ id: 'project', value: ['argo'] }]]))
|
|
433
|
+
})
|
|
349
434
|
})
|