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
|
@@ -11,9 +11,12 @@ export type ChartCursorScopeProps = {
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* ISOLATES its subtree onto a private cursor store — the inverse of the removed `ChartHoverSync`, which
|
|
14
|
-
* had to be mounted to enable sharing at all. Charts share a cursor by default now
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* had to be mounted to enable sharing at all. Charts share a cursor by default now, AND the shared
|
|
15
|
+
* cursor partitions automatically by x-domain kind (`DomainKind`, derived from each chart's own
|
|
16
|
+
* data in `useChartCursor`) — a categorical chart never follows a date chart just because both
|
|
17
|
+
* happen to be on the page, with nothing to configure. `ChartCursorScope` is no longer the tool for
|
|
18
|
+
* that case. What it is still for: isolating two charts that share the same domain KIND but are
|
|
19
|
+
* semantically unrelated — two different tenants' date ranges rendered side by side on one page.
|
|
17
20
|
*/
|
|
18
21
|
export function ChartCursorScope({ children }: ChartCursorScopeProps) {
|
|
19
22
|
const store = useMemo(() => createCursorStore(), [])
|
|
@@ -11,7 +11,7 @@ describe('cursor store', () => {
|
|
|
11
11
|
|
|
12
12
|
store.set('2026-08-18', 'chart-a')
|
|
13
13
|
expect(calls).toBe(1)
|
|
14
|
-
expect(store.get()).toEqual({ key: '2026-08-18', source: 'chart-a' })
|
|
14
|
+
expect(store.get()).toEqual({ key: '2026-08-18', source: 'chart-a', kind: null })
|
|
15
15
|
|
|
16
16
|
unsubscribe()
|
|
17
17
|
store.set('2026-08-19', 'chart-a')
|
|
@@ -43,4 +43,22 @@ describe('cursor store', () => {
|
|
|
43
43
|
scoped.set('2026-08-18', 'chart-a')
|
|
44
44
|
expect(globalCursorStore.get().key).toBeNull()
|
|
45
45
|
})
|
|
46
|
+
|
|
47
|
+
test('a set carrying a different kind notifies — the partition is state, not a filter on the store', () => {
|
|
48
|
+
// The store is dumb: it broadcasts whatever kind it's handed and never compares it against
|
|
49
|
+
// anything. Policing "does this kind match mine" is `useChartCursor`'s job, on read.
|
|
50
|
+
const store = createCursorStore()
|
|
51
|
+
let calls = 0
|
|
52
|
+
store.subscribe(() => {
|
|
53
|
+
calls++
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
store.set('2026-08-18', 'chart-a', 'time')
|
|
57
|
+
expect(calls).toBe(1)
|
|
58
|
+
expect(store.get()).toEqual({ key: '2026-08-18', source: 'chart-a', kind: 'time' })
|
|
59
|
+
|
|
60
|
+
store.set('2026-08-18', 'chart-a', 'band')
|
|
61
|
+
expect(calls).toBe(2)
|
|
62
|
+
expect(store.get()).toEqual({ key: '2026-08-18', source: 'chart-a', kind: 'band' })
|
|
63
|
+
})
|
|
46
64
|
})
|
|
@@ -12,22 +12,27 @@
|
|
|
12
12
|
* on each pointer frame.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import type { DomainKind } from './resolve'
|
|
16
|
+
|
|
15
17
|
/** Broadcast cursor position: an opaque domain key plus the chart that owns it. */
|
|
16
18
|
export type CursorState = {
|
|
17
19
|
/** The hovered x-domain key (a date/category string), or null when nothing is hovered. */
|
|
18
20
|
key: string | null
|
|
19
21
|
/** `chartId` of the chart the pointer is actually over — the one that owns the tooltip. */
|
|
20
22
|
source: string | null
|
|
23
|
+
/** The broadcaster's own x-domain kind. `null` only in the empty state — the store itself does
|
|
24
|
+
* not police this, it just carries it; the partition is enforced by the reading hook. */
|
|
25
|
+
kind: DomainKind | null
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
export type CursorStore = {
|
|
24
29
|
get: () => CursorState
|
|
25
|
-
set: (key: string | null, source: string | null) => void
|
|
30
|
+
set: (key: string | null, source: string | null, kind?: DomainKind | null) => void
|
|
26
31
|
subscribe: (onChange: () => void) => () => void
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/** Stable identity for "nothing hovered" — also the SSR snapshot, so it must never be recreated. */
|
|
30
|
-
export const EMPTY_CURSOR: CursorState = { key: null, source: null }
|
|
35
|
+
export const EMPTY_CURSOR: CursorState = { key: null, source: null, kind: null }
|
|
31
36
|
|
|
32
37
|
export function createCursorStore(): CursorStore {
|
|
33
38
|
let state: CursorState = EMPTY_CURSOR
|
|
@@ -35,9 +40,9 @@ export function createCursorStore(): CursorStore {
|
|
|
35
40
|
|
|
36
41
|
return {
|
|
37
42
|
get: () => state,
|
|
38
|
-
set: (key, source) => {
|
|
39
|
-
if (state.key === key && state.source === source) return
|
|
40
|
-
state = key === null && source === null ? EMPTY_CURSOR : { key, source }
|
|
43
|
+
set: (key, source, kind = null) => {
|
|
44
|
+
if (state.key === key && state.source === source && state.kind === kind) return
|
|
45
|
+
state = key === null && source === null ? EMPTY_CURSOR : { key, source, kind }
|
|
41
46
|
for (const listener of listeners) listener()
|
|
42
47
|
},
|
|
43
48
|
subscribe: (onChange) => {
|
|
@@ -2,7 +2,7 @@ import { localPoint } from '@visx/event'
|
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
3
3
|
import type { KeyboardEvent, PointerEvent } from 'react'
|
|
4
4
|
import type { CursorResolution } from '../cursor/resolve'
|
|
5
|
-
import { buildDomainIndex, resolveCursorPoint } from '../cursor/resolve'
|
|
5
|
+
import { buildDomainIndex, classifyDomain, resolveCursorPoint } from '../cursor/resolve'
|
|
6
6
|
import { useCursorState, useCursorStore } from '../cursor/scope'
|
|
7
7
|
|
|
8
8
|
/** Viewport-space pointer anchor the tooltip positions against. */
|
|
@@ -70,6 +70,9 @@ export function useChartCursor<T>({
|
|
|
70
70
|
() => buildDomainIndex(data, getKeyRef.current, resolution),
|
|
71
71
|
[data, resolution],
|
|
72
72
|
)
|
|
73
|
+
// Mirrors `index`'s memo shape (deps built off `data`, reading the accessor through a ref) —
|
|
74
|
+
// `resolution` is left out because `kind` never reads it, and `exhaustive-deps` enforces that.
|
|
75
|
+
const kind = useMemo(() => classifyDomain(data.map((d) => getKeyRef.current(d))), [data])
|
|
73
76
|
|
|
74
77
|
const frameRef = useRef<number | null>(null)
|
|
75
78
|
const pendingRef = useRef<CursorAnchor | null>(null)
|
|
@@ -80,7 +83,7 @@ export function useChartCursor<T>({
|
|
|
80
83
|
// drops the chart, a tab switches) fires no leave/blur, so the store would keep pointing at
|
|
81
84
|
// a dead chartId — and any sibling whose domain resolves that stale key would paint a ghost
|
|
82
85
|
// crosshair with no way to clear it. Same ownership guard as `clear()`.
|
|
83
|
-
if (store.get().source === chartId) store.set(null, null)
|
|
86
|
+
if (store.get().source === chartId) store.set(null, null, null)
|
|
84
87
|
},
|
|
85
88
|
[store, chartId],
|
|
86
89
|
)
|
|
@@ -117,9 +120,9 @@ export function useChartCursor<T>({
|
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
scheduleAnchor({ x: event.clientX, y: event.clientY })
|
|
120
|
-
store.set(getKeyRef.current(closest), chartId)
|
|
123
|
+
store.set(getKeyRef.current(closest), chartId, kind)
|
|
121
124
|
},
|
|
122
|
-
[data, marginLeft, chartId, store, scheduleAnchor],
|
|
125
|
+
[data, marginLeft, chartId, store, scheduleAnchor, kind],
|
|
123
126
|
)
|
|
124
127
|
|
|
125
128
|
const clear = useCallback(() => {
|
|
@@ -131,7 +134,7 @@ export function useChartCursor<T>({
|
|
|
131
134
|
setAnchor(null)
|
|
132
135
|
// Only clear the SHARED cursor if this chart still owns it: moving fast from chart A to B lets
|
|
133
136
|
// A's leave fire after B's move, and an unconditional clear would wipe B's cursor.
|
|
134
|
-
if (store.get().source === chartId) store.set(null, null)
|
|
137
|
+
if (store.get().source === chartId) store.set(null, null, null)
|
|
135
138
|
}, [store, chartId])
|
|
136
139
|
|
|
137
140
|
const onKeyDown = useCallback(
|
|
@@ -155,12 +158,17 @@ export function useChartCursor<T>({
|
|
|
155
158
|
const rect = event.currentTarget.getBoundingClientRect()
|
|
156
159
|
const x = xScaleRef.current(getKeyRef.current(next)) ?? 0
|
|
157
160
|
scheduleAnchor({ x: rect.left + x, y: rect.top })
|
|
158
|
-
store.set(getKeyRef.current(next), chartId)
|
|
161
|
+
store.set(getKeyRef.current(next), chartId, kind)
|
|
159
162
|
},
|
|
160
|
-
[data, store, chartId, clear, scheduleAnchor],
|
|
163
|
+
[data, store, chartId, clear, scheduleAnchor, kind],
|
|
161
164
|
)
|
|
162
165
|
|
|
163
|
-
|
|
166
|
+
// A broadcast from a domain of a DIFFERENT kind is filtered out here, before resolution is even
|
|
167
|
+
// attempted — a category chart never resolves a date chart's key just because both happen to
|
|
168
|
+
// parse. `isSource` is unaffected: it compares `chartId`, not `kind`, and a chart's own broadcast
|
|
169
|
+
// always carries its own kind by construction.
|
|
170
|
+
const point =
|
|
171
|
+
cursor.key === null || cursor.kind !== kind ? null : resolveCursorPoint(index, cursor.key)
|
|
164
172
|
|
|
165
173
|
return {
|
|
166
174
|
point,
|
package/src/charts/index.ts
CHANGED
|
@@ -76,7 +76,7 @@ export {
|
|
|
76
76
|
} from './primitives/ChartPending'
|
|
77
77
|
export { Crosshair, SeriesDot } from './primitives/Crosshair'
|
|
78
78
|
export { HatchPattern, hatchFill, hatchSizeFor } from './primitives/HatchPattern'
|
|
79
|
-
export { ChartCard } from './primitives/ChartCard'
|
|
79
|
+
export { ChartCard, type ChartCardProps } from './primitives/ChartCard'
|
|
80
80
|
export { ChartLegend, type LegendEntry } from './primitives/ChartLegend'
|
|
81
81
|
export {
|
|
82
82
|
ChartTooltipFloat,
|
|
@@ -98,7 +98,7 @@ export {
|
|
|
98
98
|
type BandTooltipConfig,
|
|
99
99
|
type BandTooltipRowContext,
|
|
100
100
|
} from './hooks/useBandPlot'
|
|
101
|
-
export type { CursorResolution } from './cursor/resolve'
|
|
101
|
+
export type { CursorResolution, DomainKind } from './cursor/resolve'
|
|
102
102
|
export { useChartSize, type UseChartSizeResult, type ChartSize } from './hooks/useChartSize'
|
|
103
103
|
|
|
104
104
|
// ── Utils ────────────────────────────────────────────────────────────────
|
|
@@ -448,6 +448,130 @@ describe('tooltip.onFollow — a follower whose domain never resolves the broadc
|
|
|
448
448
|
})
|
|
449
449
|
})
|
|
450
450
|
|
|
451
|
+
describe('cursor partitions by x-domain kind — a band chart never follows a time chart', () => {
|
|
452
|
+
const categoryRows: Row[] = [
|
|
453
|
+
{ date: 'Visited', a: 100, b: 100 },
|
|
454
|
+
{ date: 'Added to cart', a: 40, b: 40 },
|
|
455
|
+
{ date: 'Purchased', a: 10, b: 10 },
|
|
456
|
+
]
|
|
457
|
+
|
|
458
|
+
const numericRows: Row[] = [
|
|
459
|
+
{ date: '0', a: 5, b: 5 },
|
|
460
|
+
{ date: '1', a: 6, b: 6 },
|
|
461
|
+
{ date: '2', a: 7, b: 7 },
|
|
462
|
+
]
|
|
463
|
+
|
|
464
|
+
test('a category-keyed chart renders no crosshair while a date-keyed sibling owns the cursor', async () => {
|
|
465
|
+
render(
|
|
466
|
+
<ChartCursorScope>
|
|
467
|
+
<CartesianChart<Row>
|
|
468
|
+
data={rows}
|
|
469
|
+
chartId="kind-time-source"
|
|
470
|
+
getX={(d) => d.date}
|
|
471
|
+
series={[seriesFor('a')]}
|
|
472
|
+
ariaLabel="Source"
|
|
473
|
+
legend={false}
|
|
474
|
+
>
|
|
475
|
+
{() => null}
|
|
476
|
+
</CartesianChart>
|
|
477
|
+
<CartesianChart<Row>
|
|
478
|
+
data={categoryRows}
|
|
479
|
+
chartId="kind-band-follower"
|
|
480
|
+
getX={(d) => d.date}
|
|
481
|
+
series={[seriesFor('b')]}
|
|
482
|
+
ariaLabel="Follower"
|
|
483
|
+
legend={false}
|
|
484
|
+
tooltip={{ follow: false, onFollow: true }}
|
|
485
|
+
>
|
|
486
|
+
{() => null}
|
|
487
|
+
</CartesianChart>
|
|
488
|
+
</ChartCursorScope>,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
|
|
492
|
+
|
|
493
|
+
// The SOURCE (time) still renders its own tooltip — the follower (band) never resolves the
|
|
494
|
+
// broadcast at all, because its kind differs, so it stays silent rather than mounting an empty
|
|
495
|
+
// tooltip shell.
|
|
496
|
+
expect(await screen.findByText('a')).toBeTruthy()
|
|
497
|
+
expect(screen.queryByText('b')).toBeNull()
|
|
498
|
+
expect(screen.queryAllByRole('tooltip')).toHaveLength(1)
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
test('a second date-keyed chart DOES render the crosshair at the same key — time charts on a page still share', async () => {
|
|
502
|
+
render(
|
|
503
|
+
<ChartCursorScope>
|
|
504
|
+
<CartesianChart<Row>
|
|
505
|
+
data={rows}
|
|
506
|
+
chartId="kind-time-source-2"
|
|
507
|
+
getX={(d) => d.date}
|
|
508
|
+
series={[seriesFor('a')]}
|
|
509
|
+
ariaLabel="Source"
|
|
510
|
+
legend={false}
|
|
511
|
+
>
|
|
512
|
+
{() => null}
|
|
513
|
+
</CartesianChart>
|
|
514
|
+
<CartesianChart<Row>
|
|
515
|
+
data={rows}
|
|
516
|
+
chartId="kind-time-follower"
|
|
517
|
+
getX={(d) => d.date}
|
|
518
|
+
series={[seriesFor('b')]}
|
|
519
|
+
ariaLabel="Follower"
|
|
520
|
+
legend={false}
|
|
521
|
+
tooltip={{ follow: false, onFollow: true }}
|
|
522
|
+
>
|
|
523
|
+
{() => null}
|
|
524
|
+
</CartesianChart>
|
|
525
|
+
</ChartCursorScope>,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
|
|
529
|
+
|
|
530
|
+
// Both charts are `'time'`-kinded, so the partition never fires and the pair keeps sharing.
|
|
531
|
+
expect(await screen.findByText('a')).toBeTruthy()
|
|
532
|
+
expect(await screen.findByText('b')).toBeTruthy()
|
|
533
|
+
expect(screen.queryAllByRole('tooltip')).toHaveLength(2)
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
// This is the case the OLD `parseKey`-only heuristic got wrong: a numeric domain and a date
|
|
537
|
+
// domain both parse to a number, so `resolveCursorPoint` used to attempt (and by chance often
|
|
538
|
+
// succeed at) resolving one against the other. Without `classifyDomain` gating the read, this
|
|
539
|
+
// test fails — the follower would resolve the source's broadcast key and render 'b'.
|
|
540
|
+
test('a numeric-keyed chart does not follow a date-keyed sibling either', async () => {
|
|
541
|
+
render(
|
|
542
|
+
<ChartCursorScope>
|
|
543
|
+
<CartesianChart<Row>
|
|
544
|
+
data={rows}
|
|
545
|
+
chartId="kind-time-source-3"
|
|
546
|
+
getX={(d) => d.date}
|
|
547
|
+
series={[seriesFor('a')]}
|
|
548
|
+
ariaLabel="Source"
|
|
549
|
+
legend={false}
|
|
550
|
+
>
|
|
551
|
+
{() => null}
|
|
552
|
+
</CartesianChart>
|
|
553
|
+
<CartesianChart<Row>
|
|
554
|
+
data={numericRows}
|
|
555
|
+
chartId="kind-linear-follower"
|
|
556
|
+
getX={(d) => d.date}
|
|
557
|
+
series={[seriesFor('b')]}
|
|
558
|
+
ariaLabel="Follower"
|
|
559
|
+
legend={false}
|
|
560
|
+
tooltip={{ follow: false, onFollow: true }}
|
|
561
|
+
>
|
|
562
|
+
{() => null}
|
|
563
|
+
</CartesianChart>
|
|
564
|
+
</ChartCursorScope>,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
|
|
568
|
+
|
|
569
|
+
expect(await screen.findByText('a')).toBeTruthy()
|
|
570
|
+
expect(screen.queryByText('b')).toBeNull()
|
|
571
|
+
expect(screen.queryAllByRole('tooltip')).toHaveLength(1)
|
|
572
|
+
})
|
|
573
|
+
})
|
|
574
|
+
|
|
451
575
|
/**
|
|
452
576
|
* `xTickValues` — the seam a tick COUNT cannot express.
|
|
453
577
|
*
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ChartCard`'s header — the `''`-as-hidden-header sentinel is gone (docs/CONTROLS-SPEC.md §2.2):
|
|
3
|
+
* the header now renders only when at least one of title/info/value/actions/icon/count is set.
|
|
4
|
+
* Mantine-free (`src/charts/**`), so no `MantineProvider` wrapper is needed — mirrors
|
|
5
|
+
* `ChartFrame.test.tsx`'s rationale.
|
|
6
|
+
*/
|
|
7
|
+
import { render, screen } from '@testing-library/react'
|
|
8
|
+
import { describe, expect, test } from 'bun:test'
|
|
9
|
+
import { ChartCard } from './ChartCard'
|
|
10
|
+
|
|
11
|
+
describe('the header renders only when it has something to show', () => {
|
|
12
|
+
test('nothing set — no heading at all', () => {
|
|
13
|
+
render(
|
|
14
|
+
<ChartCard>
|
|
15
|
+
<svg />
|
|
16
|
+
</ChartCard>,
|
|
17
|
+
)
|
|
18
|
+
expect(screen.queryByRole('heading')).toBeNull()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('title alone renders the h3', () => {
|
|
22
|
+
render(
|
|
23
|
+
<ChartCard title="Revenue over time">
|
|
24
|
+
<svg />
|
|
25
|
+
</ChartCard>,
|
|
26
|
+
)
|
|
27
|
+
expect(screen.getByRole('heading', { level: 3, name: 'Revenue over time' })).toBeDefined()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('count alone (no title) still renders the header, with an empty title', () => {
|
|
31
|
+
render(
|
|
32
|
+
<ChartCard count={4}>
|
|
33
|
+
<svg />
|
|
34
|
+
</ChartCard>,
|
|
35
|
+
)
|
|
36
|
+
expect(screen.getByText('4')).toBeDefined()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('value alone renders the header', () => {
|
|
40
|
+
render(
|
|
41
|
+
<ChartCard value="$12,483">
|
|
42
|
+
<svg />
|
|
43
|
+
</ChartCard>,
|
|
44
|
+
)
|
|
45
|
+
expect(screen.getByText('$12,483')).toBeDefined()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('actions alone renders the header, carrying data-basalt-tier="widget"', () => {
|
|
49
|
+
const { container } = render(
|
|
50
|
+
<ChartCard actions={<button type="button">Export</button>}>
|
|
51
|
+
<svg />
|
|
52
|
+
</ChartCard>,
|
|
53
|
+
)
|
|
54
|
+
expect(screen.getByRole('button', { name: 'Export' })).toBeDefined()
|
|
55
|
+
expect(container.querySelector('[data-basalt-tier="widget"]')).not.toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('subtitle alone does NOT render the header', () => {
|
|
59
|
+
render(
|
|
60
|
+
<ChartCard subtitle="Net revenue per day">
|
|
61
|
+
<svg />
|
|
62
|
+
</ChartCard>,
|
|
63
|
+
)
|
|
64
|
+
expect(screen.queryByText('Net revenue per day')).toBeNull()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -1,92 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard wrapper for every visx chart (docs/CONTROLS-SPEC.md §2.2) — card with a `WidgetHeader
|
|
3
|
+
* tier="widget"` title row (icon/info/value/delta/count/actions) plus an optional subtitle, over a
|
|
4
|
+
* clipped chart body. Do not wrap visx charts in bare divs.
|
|
5
|
+
*
|
|
6
|
+
* `WidgetHeader` lives in `src/widget-header/` and is itself Mantine-free, so composing it here
|
|
7
|
+
* keeps `./charts` resolving with no `@mantine/*` installed (`check-dist-layering.mjs`,
|
|
8
|
+
* `basalt/token-layer-boundary`). The `actions` slot cannot mount `CtlSlot` (Mantine-coupled) for
|
|
9
|
+
* the same reason — it carries only `data-basalt-tier="widget"`; a basalt control placed there
|
|
10
|
+
* sizes itself (`size="ctl"` internally), and a raw Mantine element is not auto-tiered.
|
|
11
|
+
*
|
|
12
|
+
* The header renders only when at least one of title/info/value/actions/icon/count is set — ending
|
|
13
|
+
* the `''`-as-hidden-header sentinel a consumer used to reach for otherwise.
|
|
14
|
+
*/
|
|
1
15
|
import type { CSSProperties, ReactNode } from 'react'
|
|
2
|
-
import { useEffect, useId, useRef, useState } from 'react'
|
|
3
16
|
import { VX } from '../../tokens'
|
|
4
|
-
|
|
5
|
-
const infoWrapStyle: CSSProperties = {
|
|
6
|
-
position: 'relative',
|
|
7
|
-
display: 'inline-flex',
|
|
8
|
-
alignItems: 'center',
|
|
9
|
-
marginLeft: 5,
|
|
10
|
-
}
|
|
11
|
-
const infoTriggerStyle: CSSProperties = {
|
|
12
|
-
appearance: 'none',
|
|
13
|
-
background: 'none',
|
|
14
|
-
border: 'none',
|
|
15
|
-
padding: 0,
|
|
16
|
-
margin: 0,
|
|
17
|
-
cursor: 'help',
|
|
18
|
-
display: 'inline-flex',
|
|
19
|
-
alignItems: 'center',
|
|
20
|
-
}
|
|
21
|
-
// The tooltip bubble reads like every floating surface — panel bg + shadow-card + card radius
|
|
22
|
-
// (docs/DESIGN-SPEC.md section 5). It opens DOWNWARD (top: 100%) by convention, into/over the
|
|
23
|
-
// chart body. The header is intentionally outside the body's clip box (see `cardStyle` /
|
|
24
|
-
// `bodyClipStyle` below), so the bubble can overhang the card edge without ever being clipped.
|
|
25
|
-
// Mantine-free by design (the charts boundary), so this is a plain hover/focus-state bubble, not
|
|
26
|
-
// a Mantine Tooltip.
|
|
27
|
-
const bubbleStyle: CSSProperties = {
|
|
28
|
-
position: 'absolute',
|
|
29
|
-
top: 'calc(100% + 7px)',
|
|
30
|
-
left: 0,
|
|
31
|
-
zIndex: 10,
|
|
32
|
-
maxWidth: 260,
|
|
33
|
-
width: 'max-content',
|
|
34
|
-
padding: '7px 10px',
|
|
35
|
-
borderRadius: VX.radiusCard,
|
|
36
|
-
backgroundColor: VX.surface.panel,
|
|
37
|
-
boxShadow: VX.shadowCard,
|
|
38
|
-
color: VX.muted,
|
|
39
|
-
fontSize: VX.text.sm,
|
|
40
|
-
fontWeight: 400,
|
|
41
|
-
lineHeight: 1.4,
|
|
42
|
-
whiteSpace: 'normal',
|
|
43
|
-
pointerEvents: 'none',
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function InfoIcon({ title }: { title: string }) {
|
|
47
|
-
const [open, setOpen] = useState(false)
|
|
48
|
-
const tipId = useId()
|
|
49
|
-
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
50
|
-
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
if (!open) return
|
|
53
|
-
const handlePointerDown = (event: PointerEvent) => {
|
|
54
|
-
if (triggerRef.current?.contains(event.target as Node)) return
|
|
55
|
-
setOpen(false)
|
|
56
|
-
}
|
|
57
|
-
document.addEventListener('pointerdown', handlePointerDown)
|
|
58
|
-
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
59
|
-
}, [open])
|
|
60
|
-
|
|
61
|
-
return (
|
|
62
|
-
<span style={infoWrapStyle}>
|
|
63
|
-
<button
|
|
64
|
-
ref={triggerRef}
|
|
65
|
-
type="button"
|
|
66
|
-
aria-label="More information"
|
|
67
|
-
aria-describedby={open ? tipId : undefined}
|
|
68
|
-
onMouseEnter={() => setOpen(true)}
|
|
69
|
-
onMouseLeave={() => setOpen(false)}
|
|
70
|
-
onFocus={() => setOpen(true)}
|
|
71
|
-
onBlur={() => setOpen(false)}
|
|
72
|
-
onClick={() => setOpen(true)}
|
|
73
|
-
onKeyDown={(event) => {
|
|
74
|
-
if (event.key === 'Escape') setOpen(false)
|
|
75
|
-
}}
|
|
76
|
-
style={{ ...infoTriggerStyle, color: open ? VX.muted : VX.faint }}
|
|
77
|
-
>
|
|
78
|
-
<svg width={11} height={11} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
79
|
-
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm0 5a1 1 0 0 1 1 1v7a1 1 0 1 1-2 0v-7a1 1 0 0 1 1-1z" />
|
|
80
|
-
</svg>
|
|
81
|
-
</button>
|
|
82
|
-
{open && (
|
|
83
|
-
<span id={tipId} role="tooltip" style={bubbleStyle}>
|
|
84
|
-
{title}
|
|
85
|
-
</span>
|
|
86
|
-
)}
|
|
87
|
-
</span>
|
|
88
|
-
)
|
|
89
|
-
}
|
|
17
|
+
import { WidgetHeader } from '../../widget-header'
|
|
90
18
|
|
|
91
19
|
// Surfaces resolve per theme via CSS vars, so the styles are static (no useMemo/isDark).
|
|
92
20
|
// Depth = `shadow-card` (a whisper shadow + a 1px ring baked into the same value), never a
|
|
@@ -94,14 +22,16 @@ function InfoIcon({ title }: { title: string }) {
|
|
|
94
22
|
// Card/Paper (same shadow token, same radius token, no intrinsic margin; outer spacing comes
|
|
95
23
|
// from the parent Stack/SimpleGrid gap). Shadow + panel background + card radius all live on
|
|
96
24
|
// this OUTER box, which is never clipped — only the chart body below clips its own bottom
|
|
97
|
-
// corners (`bodyClipStyle`). The header is a direct, unclipped child of this box on purpose:
|
|
98
|
-
// info
|
|
99
|
-
|
|
25
|
+
// corners (`bodyClipStyle`). The header is a direct, unclipped child of this box on purpose:
|
|
26
|
+
// `WidgetHeader`'s info bubble (`widget-header.module.css`'s `.infoBubble`, which is where the
|
|
27
|
+
// tooltip this file used to own now lives) opens downward past the header's own box, and must be
|
|
28
|
+
// able to overhang the card edge without being invisibly cut off.
|
|
29
|
+
const cardStyle: CSSProperties = {
|
|
100
30
|
borderRadius: VX.radiusCard,
|
|
101
31
|
boxShadow: VX.shadowCard,
|
|
102
32
|
backgroundColor: VX.surface.panel,
|
|
103
33
|
}
|
|
104
|
-
const bodyClipStyle = {
|
|
34
|
+
const bodyClipStyle: CSSProperties = {
|
|
105
35
|
// Clip the chart body to the card's bottom corners — a chart with its own square edges would
|
|
106
36
|
// otherwise poke past the card's border-radius. Only the bottom corners: the body's top edge is
|
|
107
37
|
// interior, sitting under the (unclipped) header. Trade-off: a chart tooltip that overflows the
|
|
@@ -109,71 +39,81 @@ const bodyClipStyle = {
|
|
|
109
39
|
// practice — a consumer rendering an oversized/edge tooltip should portal it out of the card.
|
|
110
40
|
borderBottomLeftRadius: VX.radiusCard,
|
|
111
41
|
borderBottomRightRadius: VX.radiusCard,
|
|
112
|
-
overflow: 'hidden'
|
|
113
|
-
padding: '2px var(--mantine-spacing-sm, 0.
|
|
114
|
-
}
|
|
115
|
-
const headerStyle = {
|
|
116
|
-
display: 'flex',
|
|
117
|
-
justifyContent: 'space-between',
|
|
118
|
-
alignItems: 'center',
|
|
119
|
-
// Card inset = spacing xs (vertical) / sm (horizontal). The `--mantine-spacing-*` vars carry
|
|
120
|
-
// basalt's OWNED scale (theme.spacing.xs 0.625rem / sm 0.75rem); the rem fallbacks keep a
|
|
121
|
-
// Mantine-free `./charts`-only consumer (no MantineProvider vars) padded identically. No
|
|
122
|
-
// header/body divider — the title block and the plot read as one continuous surface; a rule here
|
|
123
|
-
// only doubled the card's shadow-embedded ring and made the header feel heavier.
|
|
124
|
-
padding: 'var(--mantine-spacing-xs, 0.625rem) var(--mantine-spacing-sm, 0.75rem) 4px',
|
|
125
|
-
}
|
|
126
|
-
const titleColumnStyle = {
|
|
127
|
-
display: 'inline-flex',
|
|
128
|
-
flexDirection: 'column' as const,
|
|
129
|
-
lineHeight: 1.15,
|
|
42
|
+
overflow: 'hidden',
|
|
43
|
+
padding: '2px var(--mantine-spacing-sm, 0.8125rem) var(--mantine-spacing-xs, 0.6875rem)',
|
|
130
44
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
color: VX.ink,
|
|
45
|
+
// Card inset = spacing xs (vertical) / sm (horizontal). The `--mantine-spacing-*` vars carry
|
|
46
|
+
// basalt's OWNED scale (theme.spacing.xs 0.6875rem / sm 0.8125rem); the rem fallbacks keep a
|
|
47
|
+
// Mantine-free `./charts`-only consumer (no MantineProvider vars) padded identically. No
|
|
48
|
+
// header/body divider — the title block and the plot read as one continuous surface; a rule here
|
|
49
|
+
// only doubled the card's shadow-embedded ring and made the header feel heavier.
|
|
50
|
+
const headerWrapStyle: CSSProperties = {
|
|
51
|
+
padding: 'var(--mantine-spacing-xs, 0.6875rem) var(--mantine-spacing-sm, 0.8125rem) 4px',
|
|
139
52
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
53
|
+
|
|
54
|
+
export type ChartCardProps = {
|
|
55
|
+
/** Optional — the header renders only when this or one of info/value/actions/icon/count is set. */
|
|
56
|
+
title?: string
|
|
57
|
+
/** Optional leading icon, forwarded to `WidgetHeader`. */
|
|
58
|
+
icon?: ReactNode
|
|
59
|
+
/** Optional muted line rendered below the title row — does NOT by itself trigger the header. */
|
|
60
|
+
subtitle?: string
|
|
61
|
+
/** Renders `WidgetHeader`'s info glyph beside the title — a `More information` button whose
|
|
62
|
+
* bubble opens on hover, focus and click. Never part of the heading's accessible name. */
|
|
63
|
+
info?: string
|
|
64
|
+
/** Pre-formatted metric value, on the hero-metric row with `delta`. */
|
|
65
|
+
value?: string
|
|
66
|
+
/** Signed delta rendered via `DeltaBadge`, alongside `value`. */
|
|
67
|
+
delta?: number
|
|
68
|
+
/** Comparison timeframe forwarded to `DeltaBadge` (e.g. `MoM`). */
|
|
69
|
+
deltaPeriod?: string
|
|
70
|
+
/** Mono count tag after the title. */
|
|
71
|
+
count?: number
|
|
72
|
+
/** Right-aligned slot — carries `data-basalt-tier="widget"` (no `CtlSlot`; see module doc). */
|
|
73
|
+
actions?: ReactNode
|
|
74
|
+
children: ReactNode
|
|
145
75
|
}
|
|
146
76
|
|
|
147
|
-
/**
|
|
148
|
-
* Standard wrapper for every visx chart — card with info-tooltip title + optional
|
|
149
|
-
* subtitle (the question the chart answers) + optional extra slot (current value
|
|
150
|
-
* badge etc.). Do not wrap visx charts in bare divs.
|
|
151
|
-
*/
|
|
152
77
|
export function ChartCard({
|
|
153
78
|
title,
|
|
79
|
+
icon,
|
|
154
80
|
subtitle,
|
|
155
|
-
|
|
156
|
-
|
|
81
|
+
info,
|
|
82
|
+
value,
|
|
83
|
+
delta,
|
|
84
|
+
deltaPeriod,
|
|
85
|
+
count,
|
|
86
|
+
actions,
|
|
157
87
|
children,
|
|
158
|
-
}: {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
88
|
+
}: ChartCardProps) {
|
|
89
|
+
const hasHeader =
|
|
90
|
+
title !== undefined ||
|
|
91
|
+
info !== undefined ||
|
|
92
|
+
value !== undefined ||
|
|
93
|
+
actions !== undefined ||
|
|
94
|
+
icon !== undefined ||
|
|
95
|
+
count !== undefined
|
|
96
|
+
|
|
165
97
|
return (
|
|
166
98
|
<div style={cardStyle}>
|
|
167
|
-
|
|
168
|
-
<
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
99
|
+
{hasHeader && (
|
|
100
|
+
<div style={headerWrapStyle}>
|
|
101
|
+
<WidgetHeader
|
|
102
|
+
tier="widget"
|
|
103
|
+
title={title ?? ''}
|
|
104
|
+
{...(icon !== undefined && { icon })}
|
|
105
|
+
{...(subtitle !== undefined && { subtitle })}
|
|
106
|
+
{...(info !== undefined && { info })}
|
|
107
|
+
{...(value !== undefined && { value })}
|
|
108
|
+
{...(delta !== undefined && { delta })}
|
|
109
|
+
{...(deltaPeriod !== undefined && { deltaPeriod })}
|
|
110
|
+
{...(count !== undefined && { count })}
|
|
111
|
+
{...(actions !== undefined && {
|
|
112
|
+
actions: <span data-basalt-tier="widget">{actions}</span>,
|
|
113
|
+
})}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
177
117
|
<div style={bodyClipStyle}>{children}</div>
|
|
178
118
|
</div>
|
|
179
119
|
)
|