basalt-ui 1.24.0 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -6
- package/CHANGELOG.md +25 -0
- package/MIGRATING.md +547 -21
- package/README.md +46 -33
- package/agent/rules/basalt-batteries.md +205 -0
- package/agent/rules/basalt-charts.md +125 -555
- package/agent/rules/basalt-controls.md +155 -0
- package/agent/rules/basalt-mantine.md +146 -439
- package/agent/rules/basalt-state.md +113 -96
- package/agent/rules/basalt-tokens.md +126 -288
- package/agent/skills/basalt-app/SKILL.md +70 -195
- package/agent/skills/basalt-charts/SKILL.md +54 -234
- package/agent/skills/basalt-design/SKILL.md +56 -248
- package/agent/templates/CLAUDE-block.md.tpl +31 -40
- package/agent/templates/DESIGN.md.tpl +22 -51
- package/configs/oxlint-plugin.js +1275 -31
- package/configs/oxlint-plugin.test.ts +947 -11
- package/configs/oxlint.json +11 -3
- package/dist/charts/cursor/resolve.d.ts +18 -0
- package/dist/charts/cursor/resolve.d.ts.map +1 -1
- package/dist/charts/cursor/resolve.js +15 -6
- package/dist/charts/cursor/scope.d.ts +6 -3
- package/dist/charts/cursor/scope.d.ts.map +1 -1
- package/dist/charts/cursor/store.d.ts +5 -1
- package/dist/charts/cursor/store.d.ts.map +1 -1
- package/dist/charts/cursor/store.js +4 -4
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
- package/dist/charts/hooks/useChartCursor.js +9 -8
- package/dist/charts/index.d.ts +2 -2
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.d.ts +34 -9
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +29 -114
- package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
- package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
- package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
- package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
- package/dist/charts/sparklines/BarSparkline.js +18 -4
- package/dist/cli/check-coverage.test.d.ts +2 -0
- package/dist/cli/check-coverage.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts +67 -4
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +225 -7
- package/dist/connectivity/connectivity-indicator.d.ts +4 -1
- package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
- package/dist/connectivity/connectivity-indicator.js +1 -1
- package/dist/content/index.d.ts +5 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +4 -2
- package/dist/content/prose.module.css +7 -28
- package/dist/controls/actions.d.ts +169 -0
- package/dist/controls/actions.d.ts.map +1 -0
- package/dist/controls/actions.js +287 -0
- package/dist/controls/actions.module.css +62 -0
- package/dist/controls/actions.module.css.d.ts +13 -0
- package/dist/controls/actions.test.d.ts +2 -0
- package/dist/controls/actions.test.d.ts.map +1 -0
- package/dist/controls/actions.type-guard.test.d.ts +2 -0
- package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
- package/dist/controls/compare-filter.d.ts +41 -0
- package/dist/controls/compare-filter.d.ts.map +1 -0
- package/dist/controls/compare-filter.js +23 -0
- package/dist/controls/control-group.d.ts +19 -0
- package/dist/controls/control-group.d.ts.map +1 -0
- package/dist/controls/control-group.js +20 -0
- package/dist/controls/control-group.test.d.ts +2 -0
- package/dist/controls/control-group.test.d.ts.map +1 -0
- package/dist/controls/controls.module.css +542 -0
- package/dist/controls/controls.module.css.d.ts +35 -0
- package/dist/controls/controls.router.test.d.ts +2 -0
- package/dist/controls/controls.router.test.d.ts.map +1 -0
- package/dist/controls/enum-filter.d.ts +30 -0
- package/dist/controls/enum-filter.d.ts.map +1 -0
- package/dist/controls/enum-filter.js +91 -0
- package/dist/controls/filter-context.d.ts +55 -0
- package/dist/controls/filter-context.d.ts.map +1 -0
- package/dist/controls/filter-context.js +69 -0
- package/dist/controls/filter-pill.d.ts +28 -0
- package/dist/controls/filter-pill.d.ts.map +1 -0
- package/dist/controls/filter-pill.js +70 -0
- package/dist/controls/filter-set.d.ts +13 -0
- package/dist/controls/filter-set.d.ts.map +1 -0
- package/dist/controls/filter-set.js +125 -0
- package/dist/controls/filter-set.test.d.ts +2 -0
- package/dist/controls/filter-set.test.d.ts.map +1 -0
- package/dist/controls/filter-sheet.d.ts +117 -0
- package/dist/controls/filter-sheet.d.ts.map +1 -0
- package/dist/controls/filter-sheet.js +146 -0
- package/dist/controls/glyphs.d.ts +27 -0
- package/dist/controls/glyphs.d.ts.map +1 -0
- package/dist/controls/glyphs.js +49 -0
- package/dist/controls/index.d.ts +19 -0
- package/dist/controls/index.d.ts.map +1 -0
- package/dist/controls/index.js +44 -0
- package/dist/controls/multi-select-filter.d.ts +23 -0
- package/dist/controls/multi-select-filter.d.ts.map +1 -0
- package/dist/controls/multi-select-filter.js +89 -0
- package/dist/controls/peer-boundary.test.d.ts +2 -0
- package/dist/controls/peer-boundary.test.d.ts.map +1 -0
- package/dist/controls/props-contract.test.d.ts +2 -0
- package/dist/controls/props-contract.test.d.ts.map +1 -0
- package/dist/controls/range-filter.d.ts +29 -0
- package/dist/controls/range-filter.d.ts.map +1 -0
- package/dist/controls/range-filter.js +120 -0
- package/dist/controls/search-filter.d.ts +10 -0
- package/dist/controls/search-filter.d.ts.map +1 -0
- package/dist/controls/search-filter.js +81 -0
- package/dist/controls/select-filter.d.ts +74 -0
- package/dist/controls/select-filter.d.ts.map +1 -0
- package/dist/controls/select-filter.js +10 -0
- package/dist/controls/sync-button.d.ts +38 -0
- package/dist/controls/sync-button.d.ts.map +1 -0
- package/dist/controls/sync-button.js +119 -0
- package/dist/controls/sync-button.module.css +63 -0
- package/dist/controls/sync-button.module.css.d.ts +11 -0
- package/dist/controls/sync-button.test.d.ts +2 -0
- package/dist/controls/sync-button.test.d.ts.map +1 -0
- package/dist/controls/toggle-filter.d.ts +9 -0
- package/dist/controls/toggle-filter.d.ts.map +1 -0
- package/dist/controls/toggle-filter.js +42 -0
- package/dist/controls/view-tabs.d.ts +23 -0
- package/dist/controls/view-tabs.d.ts.map +1 -0
- package/dist/controls/view-tabs.js +59 -0
- package/dist/controls-dates/date-range-picker.d.ts +4 -0
- package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
- package/dist/controls-dates/date-range-picker.js +18 -0
- package/dist/controls-dates/index.d.ts +9 -0
- package/dist/controls-dates/index.d.ts.map +1 -0
- package/dist/controls-dates/index.js +4 -0
- package/dist/dashboard/delta-badge.d.ts +6 -12
- package/dist/dashboard/delta-badge.d.ts.map +1 -1
- package/dist/dashboard/delta-badge.js +1 -50
- package/dist/dashboard/query-state.d.ts +1 -1
- package/dist/dashboard/settings-section.d.ts +6 -4
- package/dist/dashboard/settings-section.d.ts.map +1 -1
- package/dist/dashboard/settings-section.js +23 -16
- package/dist/dashboard/settings-section.module.css +10 -0
- package/dist/dashboard/settings-section.module.css.d.ts +1 -0
- package/dist/dashboard/settings-section.test.d.ts +2 -0
- package/dist/dashboard/settings-section.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.d.ts +44 -9
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +80 -48
- package/dist/dashboard/stat-card.module.css +82 -0
- package/dist/dashboard/stat-card.module.css.d.ts +11 -0
- package/dist/data/data-table.d.ts +102 -20
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +166 -65
- package/dist/data/table.d.ts +1 -1
- package/dist/data/table.d.ts.map +1 -1
- package/dist/guard/grace.test.d.ts +20 -0
- package/dist/guard/grace.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +66 -3
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +82 -7
- package/dist/guard/types.d.ts +3 -3
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -10
- package/dist/nav/types.d.ts +78 -2
- package/dist/nav/types.d.ts.map +1 -1
- package/dist/notifications/bell.d.ts +4 -1
- package/dist/notifications/bell.d.ts.map +1 -1
- package/dist/notifications/index.d.ts +1 -1
- package/dist/router-tanstack/field.d.ts +14 -0
- package/dist/router-tanstack/field.d.ts.map +1 -0
- package/dist/router-tanstack/field.js +4 -0
- package/dist/router-tanstack/index.d.ts +6 -2
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/index.js +4 -0
- package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.js +19 -27
- package/dist/router-tanstack/search-param-store.d.ts +3 -87
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-param-store.js +16 -45
- package/dist/router-tanstack/search-store.d.ts +81 -0
- package/dist/router-tanstack/search-store.d.ts.map +1 -0
- package/dist/router-tanstack/search-store.js +187 -0
- package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
- package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
- package/dist/router-tanstack/search-store.test.d.ts +2 -0
- package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
- package/dist/router-tanstack/use-nav.d.ts +0 -2
- package/dist/router-tanstack/use-nav.d.ts.map +1 -1
- package/dist/router-tanstack/use-nav.js +34 -12
- package/dist/router-tanstack/use-nav.test.d.ts +2 -0
- package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
- package/dist/section/index.d.ts +2 -0
- package/dist/section/index.d.ts.map +1 -0
- package/dist/section/index.js +4 -0
- package/dist/section/section.d.ts +24 -0
- package/dist/section/section.d.ts.map +1 -0
- package/dist/section/section.js +126 -0
- package/dist/section/section.module.css +104 -0
- package/dist/section/section.module.css.d.ts +11 -0
- package/dist/section/section.test.d.ts +2 -0
- package/dist/section/section.test.d.ts.map +1 -0
- package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
- package/dist/shell/app-breadcrumbs.js +18 -15
- package/dist/shell/app-header.module.css +77 -37
- package/dist/shell/app-header.module.css.d.ts +2 -1
- package/dist/shell/app-mobile-nav.d.ts +9 -2
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +98 -11
- package/dist/shell/app-mobile-nav.module.css +8 -0
- package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/dist/shell/app-sidebar.d.ts +55 -28
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +116 -67
- package/dist/shell/app-sidebar.module.css +303 -20
- package/dist/shell/app-sidebar.module.css.d.ts +19 -1
- package/dist/shell/index.d.ts +55 -21
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +40 -18
- package/dist/shell/mobile-nav-model.d.ts +13 -2
- package/dist/shell/mobile-nav-model.d.ts.map +1 -1
- package/dist/shell/mobile-nav-model.js +15 -1
- package/dist/shell/nav-count-badge.d.ts +1 -1
- package/dist/shell/page-bar.d.ts +51 -0
- package/dist/shell/page-bar.d.ts.map +1 -0
- package/dist/shell/page-bar.js +154 -0
- package/dist/shell/page-bar.module.css +178 -0
- package/dist/shell/page-bar.module.css.d.ts +17 -0
- package/dist/shell/page-bar.test.d.ts +2 -0
- package/dist/shell/page-bar.test.d.ts.map +1 -0
- package/dist/shell/sidebar-block-model.d.ts +48 -0
- package/dist/shell/sidebar-block-model.d.ts.map +1 -0
- package/dist/shell/sidebar-block-model.js +39 -0
- package/dist/shell/sidebar-blocks.d.ts +32 -0
- package/dist/shell/sidebar-blocks.d.ts.map +1 -0
- package/dist/shell/sidebar-blocks.js +169 -0
- package/dist/shell/sidebar-search.d.ts +13 -1
- package/dist/shell/sidebar-search.d.ts.map +1 -1
- package/dist/shell/sidebar-search.js +34 -1
- package/dist/shell/sidebar-search.module.css +26 -0
- package/dist/shell/sidebar-search.module.css.d.ts +2 -0
- package/dist/state/fields.d.ts +392 -0
- package/dist/state/fields.d.ts.map +1 -0
- package/dist/state/fields.js +369 -0
- package/dist/state/fields.test.d.ts +2 -0
- package/dist/state/fields.test.d.ts.map +1 -0
- package/dist/state/persisted.d.ts +48 -0
- package/dist/state/persisted.d.ts.map +1 -0
- package/dist/state/persisted.js +105 -0
- package/dist/state.d.ts +11 -56
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +14 -120
- package/dist/styles.css +13 -0
- package/dist/surfaces.d.ts +106 -24
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +155 -19
- package/dist/theme/controls.module.css +16 -1
- package/dist/theme/ctl-theme.d.ts +32 -0
- package/dist/theme/ctl-theme.d.ts.map +1 -0
- package/dist/theme/ctl-theme.js +54 -0
- package/dist/theme/ctl-theme.test.d.ts +2 -0
- package/dist/theme/ctl-theme.test.d.ts.map +1 -0
- package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
- package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
- package/dist/theme/divider-law.test.d.ts +2 -0
- package/dist/theme/divider-law.test.d.ts.map +1 -0
- package/dist/theme/icon-slot.d.ts +44 -0
- package/dist/theme/icon-slot.d.ts.map +1 -0
- package/dist/theme/icon-slot.js +21 -0
- package/dist/theme/icon-slot.module.css +47 -0
- package/dist/theme/icon-slot.module.css.d.ts +8 -0
- package/dist/theme/icon-slot.test.d.ts +2 -0
- package/dist/theme/icon-slot.test.d.ts.map +1 -0
- package/dist/theme/index.d.ts +1 -0
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +82 -5
- package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
- package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
- package/dist/theme/segmented-control.module.css +20 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
- package/dist/tokens/index.d.ts +12 -0
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +38 -8
- package/dist/tokens/palette.d.ts +97 -61
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +76 -38
- package/dist/tokens.css +10 -2
- package/dist/widget-header/delta-badge.d.ts +13 -0
- package/dist/widget-header/delta-badge.d.ts.map +1 -0
- package/dist/widget-header/delta-badge.js +29 -0
- package/dist/widget-header/delta-badge.test.d.ts +2 -0
- package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
- package/dist/widget-header/index.d.ts +3 -0
- package/dist/widget-header/index.d.ts.map +1 -0
- package/dist/widget-header/index.js +6 -0
- package/dist/widget-header/no-mantine.test.d.ts +2 -0
- package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
- package/dist/widget-header/widget-header.d.ts +37 -0
- package/dist/widget-header/widget-header.d.ts.map +1 -0
- package/dist/widget-header/widget-header.js +77 -0
- package/dist/widget-header/widget-header.module.css +335 -0
- package/dist/widget-header/widget-header.module.css.d.ts +24 -0
- package/dist/widget-header/widget-header.test.d.ts +2 -0
- package/dist/widget-header/widget-header.test.d.ts.map +1 -0
- package/llms.txt +30 -17
- package/package.json +14 -1
- package/src/charts/cursor/resolve.test.ts +29 -1
- package/src/charts/cursor/resolve.ts +38 -6
- package/src/charts/cursor/scope.tsx +6 -3
- package/src/charts/cursor/store.test.ts +19 -1
- package/src/charts/cursor/store.ts +10 -5
- package/src/charts/hooks/useChartCursor.ts +16 -8
- package/src/charts/index.ts +2 -2
- package/src/charts/primitives/CartesianChart.test.tsx +124 -0
- package/src/charts/primitives/ChartCard.test.tsx +66 -0
- package/src/charts/primitives/ChartCard.tsx +86 -146
- package/src/charts/sparklines/BarSparkline.tsx +43 -5
- package/src/cli/check-coverage.test.ts +297 -0
- package/src/cli/check-theme.test.ts +38 -14
- package/src/cli/index.ts +392 -16
- package/src/cli/init-sync.test.ts +97 -0
- package/src/connectivity/connectivity-indicator.tsx +16 -9
- package/src/content/index.ts +16 -3
- package/src/content/prose.module.css +7 -28
- package/src/content/prose.module.css.test.ts +8 -24
- package/src/controls/actions.module.css +62 -0
- package/src/controls/actions.module.css.d.ts +13 -0
- package/src/controls/actions.test.tsx +252 -0
- package/src/controls/actions.tsx +655 -0
- package/src/controls/actions.type-guard.test.ts +63 -0
- package/src/controls/compare-filter.tsx +64 -0
- package/src/controls/control-group.test.tsx +229 -0
- package/src/controls/control-group.tsx +63 -0
- package/src/controls/controls.module.css +542 -0
- package/src/controls/controls.module.css.d.ts +35 -0
- package/src/controls/controls.router.test.tsx +637 -0
- package/src/controls/enum-filter.tsx +159 -0
- package/src/controls/filter-context.tsx +153 -0
- package/src/controls/filter-pill.tsx +120 -0
- package/src/controls/filter-set.test.tsx +472 -0
- package/src/controls/filter-set.tsx +225 -0
- package/src/controls/filter-sheet.tsx +285 -0
- package/src/controls/glyphs.tsx +81 -0
- package/src/controls/index.ts +48 -0
- package/src/controls/multi-select-filter.tsx +140 -0
- package/src/controls/peer-boundary.test.ts +85 -0
- package/src/controls/props-contract.test.tsx +106 -0
- package/src/controls/range-filter.tsx +184 -0
- package/src/controls/search-filter.tsx +123 -0
- package/src/controls/select-filter.tsx +89 -0
- package/src/controls/sync-button.module.css +63 -0
- package/src/controls/sync-button.module.css.d.ts +11 -0
- package/src/controls/sync-button.test.tsx +183 -0
- package/src/controls/sync-button.tsx +197 -0
- package/src/controls/toggle-filter.tsx +60 -0
- package/src/controls/view-tabs.tsx +110 -0
- package/src/controls-dates/date-range-picker.tsx +42 -0
- package/src/controls-dates/index.ts +8 -0
- package/src/dashboard/delta-badge.tsx +4 -74
- package/src/dashboard/query-state.tsx +1 -1
- package/src/dashboard/settings-section.module.css +10 -0
- package/src/dashboard/settings-section.module.css.d.ts +1 -0
- package/src/dashboard/settings-section.test.tsx +56 -0
- package/src/dashboard/settings-section.tsx +33 -30
- package/src/dashboard/stat-card.module.css +82 -0
- package/src/dashboard/stat-card.module.css.d.ts +11 -0
- package/src/dashboard/stat-card.test.tsx +67 -1
- package/src/dashboard/stat-card.tsx +209 -68
- package/src/data/data-table.test.tsx +282 -6
- package/src/data/data-table.tsx +429 -94
- package/src/data/table.ts +2 -0
- package/src/guard/check-source.test.ts +126 -13
- package/src/guard/grace.test.ts +149 -0
- package/src/guard/index.ts +172 -29
- package/src/guard/types.ts +4 -2
- package/src/index.ts +46 -5
- package/src/nav/types.ts +84 -2
- package/src/notifications/bell.tsx +6 -3
- package/src/notifications/index.ts +1 -1
- package/src/router-tanstack/field.ts +31 -0
- package/src/router-tanstack/index.ts +24 -2
- package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
- package/src/router-tanstack/multi-search-param-store.ts +46 -77
- package/src/router-tanstack/search-param-store.ts +42 -159
- package/src/router-tanstack/search-store.router.test.tsx +661 -0
- package/src/router-tanstack/search-store.test.ts +418 -0
- package/src/router-tanstack/search-store.ts +380 -0
- package/src/router-tanstack/use-nav.test.tsx +187 -0
- package/src/router-tanstack/use-nav.ts +72 -14
- package/src/section/index.ts +1 -0
- package/src/section/section.module.css +104 -0
- package/src/section/section.module.css.d.ts +11 -0
- package/src/section/section.test.tsx +204 -0
- package/src/section/section.tsx +220 -0
- package/src/shell/app-breadcrumbs.tsx +44 -31
- package/src/shell/app-header.module.css +77 -37
- package/src/shell/app-header.module.css.d.ts +2 -1
- package/src/shell/app-mobile-nav.module.css +8 -0
- package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/src/shell/app-mobile-nav.tsx +147 -8
- package/src/shell/app-sidebar-account.tsx +2 -2
- package/src/shell/app-sidebar.module.css +303 -20
- package/src/shell/app-sidebar.module.css.d.ts +19 -1
- package/src/shell/app-sidebar.test.tsx +456 -41
- package/src/shell/app-sidebar.tsx +276 -140
- package/src/shell/index.test.tsx +291 -5
- package/src/shell/index.tsx +137 -38
- package/src/shell/mobile-nav-model.test.ts +228 -7
- package/src/shell/mobile-nav-model.ts +53 -3
- package/src/shell/nav-count-badge.tsx +1 -1
- package/src/shell/page-bar.module.css +178 -0
- package/src/shell/page-bar.module.css.d.ts +17 -0
- package/src/shell/page-bar.test.tsx +316 -0
- package/src/shell/page-bar.tsx +315 -0
- package/src/shell/sidebar-block-model.ts +83 -0
- package/src/shell/sidebar-blocks.tsx +301 -0
- package/src/shell/sidebar-search.module.css +26 -0
- package/src/shell/sidebar-search.module.css.d.ts +2 -0
- package/src/shell/sidebar-search.tsx +71 -2
- package/src/state/fields.test.tsx +258 -0
- package/src/state/fields.ts +892 -0
- package/src/state/persisted.ts +211 -0
- package/src/state.ts +46 -240
- package/src/styles.css +13 -0
- package/src/surfaces.ts +203 -36
- package/src/theme/controls.module.css +16 -1
- package/src/theme/ctl-theme.test.tsx +275 -0
- package/src/theme/ctl-theme.tsx +138 -0
- package/src/theme/ctl-tier-coverage.test.ts +170 -0
- package/src/theme/density-relations.test.ts +106 -51
- package/src/theme/divider-law.test.ts +259 -0
- package/src/theme/icon-slot.module.css +47 -0
- package/src/theme/icon-slot.module.css.d.ts +8 -0
- package/src/theme/icon-slot.test.tsx +275 -0
- package/src/theme/icon-slot.tsx +59 -0
- package/src/theme/index.ts +165 -3
- package/src/theme/layout-rhythm-css.test.ts +72 -0
- package/src/theme/radius.test.ts +11 -1
- package/src/theme/segmented-control.module.css +20 -0
- package/src/theme/shadow-surfaces.test.ts +20 -7
- package/src/theme/spacing.test.ts +75 -27
- package/src/theme/use-basalt-spacing.ts +2 -2
- package/src/tokens/density.test.ts +7 -25
- package/src/tokens/index.ts +48 -8
- package/src/tokens/palette.ts +107 -73
- package/src/widget-header/delta-badge.test.tsx +60 -0
- package/src/widget-header/delta-badge.tsx +57 -0
- package/src/widget-header/index.ts +2 -0
- package/src/widget-header/no-mantine.test.ts +33 -0
- package/src/widget-header/widget-header.module.css +335 -0
- package/src/widget-header/widget-header.module.css.d.ts +24 -0
- package/src/widget-header/widget-header.test.tsx +187 -0
- package/src/widget-header/widget-header.tsx +168 -0
- package/agent/rules/basalt-agent.md +0 -452
- package/agent/rules/basalt-app.md +0 -246
- package/agent/rules/basalt-commands.md +0 -320
- package/agent/rules/basalt-content.md +0 -409
- package/agent/rules/basalt-data.md +0 -269
- package/agent/rules/basalt-forms.md +0 -186
- package/agent/rules/basalt-notifications.md +0 -264
- package/agent/rules/basalt-query.md +0 -187
- package/agent/rules/basalt-router.md +0 -336
- package/dist/content/article-filter-bar.d.ts +0 -40
- package/dist/content/article-filter-bar.d.ts.map +0 -1
- package/dist/content/article-filter-bar.js +0 -56
- package/dist/shell/page-header.d.ts +0 -13
- package/dist/shell/page-header.d.ts.map +0 -1
- package/dist/shell/page-header.js +0 -23
- package/src/content/article-filter-bar.tsx +0 -88
- package/src/shell/page-header.tsx +0 -35
|
@@ -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.
|
|
27
43
|
* <StatCard
|
|
28
|
-
*
|
|
44
|
+
* title="Active Users"
|
|
29
45
|
* value="12,483"
|
|
30
46
|
* delta={4.2}
|
|
31
|
-
* sparkline={<LineSparkline data={history} width={
|
|
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.
|
|
53
|
+
* <StatCard
|
|
54
|
+
* title="Active Users"
|
|
55
|
+
* value="12,483"
|
|
56
|
+
* delta={4.2}
|
|
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,86 @@
|
|
|
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
|
+
* The `'right'` placement's slot width, in px — a flex BASIS, not a fixed size. 72 is the reference
|
|
105
|
+
* KPI card's trend width and the slot asks for exactly that; when the hero row needs the space (a
|
|
106
|
+
* long formatted value plus a delta badge in a 4-up grid) the slot is the side that yields, because
|
|
107
|
+
* the number is the card's content and the trend qualifies it. The render prop is handed the
|
|
108
|
+
* MEASURED width either way, so the bars are drawn at the width they actually got — the previous
|
|
109
|
+
* fixed 72 painted over the value instead.
|
|
110
|
+
*/
|
|
111
|
+
const SPARKLINE_RIGHT_WIDTH = 72
|
|
112
|
+
/** The `'right'` placement's slot height — sized to the hero row it sits beside, not to the card. */
|
|
113
|
+
const SPARKLINE_RIGHT_HEIGHT = 26
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The measured box a `sparkline` render prop receives.
|
|
117
|
+
*
|
|
118
|
+
* A `LineSparkline`/`BarSparkline` takes `width`/`height` as NUMBERS (they are SVG attributes — an
|
|
119
|
+
* svg cannot size itself from CSS and then scale its scales), so "full bleed" was never expressible
|
|
120
|
+
* as a `ReactNode`: the caller had to guess a pixel width, or write its own `useChartSize` wrapper
|
|
121
|
+
* component. Four consumers wrote that wrapper; two of them hardcoded a width and drew a sparkline
|
|
122
|
+
* that stopped short of the card edge on every viewport but the one they built on.
|
|
123
|
+
*/
|
|
124
|
+
export type StatCardSparklineSize = {
|
|
125
|
+
width: number
|
|
126
|
+
height: number
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Measures a slot and re-measures on resize. Plain `ResizeObserver`, not `useChartSize`:
|
|
131
|
+
* `src/dashboard/**` is Mantine-coupled and @visx-FREE (see the module doc), and `useChartSize`
|
|
132
|
+
* wraps `@visx/responsive`.
|
|
133
|
+
*
|
|
134
|
+
* The `undefined` guard is for the test DOM — happy-dom ships no `ResizeObserver`, and the one-shot
|
|
135
|
+
* measure above it is what keeps a mounted card rendering its sparkline there.
|
|
136
|
+
*/
|
|
137
|
+
function useSlotWidth(active: boolean): {
|
|
138
|
+
ref: (node: HTMLDivElement | null) => void
|
|
139
|
+
width: number
|
|
140
|
+
} {
|
|
141
|
+
const [width, setWidth] = useState(0)
|
|
142
|
+
const nodeRef = useRef<HTMLDivElement | null>(null)
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const node = nodeRef.current
|
|
146
|
+
if (!active || node === null) return
|
|
147
|
+
const measure = () => {
|
|
148
|
+
setWidth(node.getBoundingClientRect().width)
|
|
149
|
+
}
|
|
150
|
+
measure()
|
|
151
|
+
if (typeof ResizeObserver === 'undefined') return
|
|
152
|
+
const observer = new ResizeObserver(measure)
|
|
153
|
+
observer.observe(node)
|
|
154
|
+
return () => {
|
|
155
|
+
observer.disconnect()
|
|
156
|
+
}
|
|
157
|
+
}, [active])
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
ref: (node) => {
|
|
161
|
+
nodeRef.current = node
|
|
162
|
+
},
|
|
163
|
+
width,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
62
167
|
/** The rail's width, in px. Wide enough to read as a deliberate mark at a glance, narrow enough
|
|
63
168
|
* that it cannot be mistaken for the card's own edge. */
|
|
64
169
|
const TONE_RAIL_WIDTH = 3
|
|
@@ -70,19 +175,41 @@ const TONE_LABEL: Record<StatCardTone, string> = {
|
|
|
70
175
|
}
|
|
71
176
|
|
|
72
177
|
export type StatCardProps = {
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
/**
|
|
178
|
+
/** Head-font title, rendered via `WidgetHeader tier="widget"`. */
|
|
179
|
+
title: string
|
|
180
|
+
/** Optional leading icon, forwarded to `WidgetHeader`. */
|
|
181
|
+
icon?: ReactNode
|
|
182
|
+
/** Muted line under the hero row, forwarded to `WidgetHeader` — the unit or basis a
|
|
183
|
+
* pre-formatted `value` cannot carry (`per day`, `of 40 planned`). Not a second metric. */
|
|
184
|
+
subtitle?: string
|
|
185
|
+
/** Info tooltip beside the title, forwarded to `WidgetHeader` — how the number is computed. Never
|
|
186
|
+
* part of the heading's accessible name; see `WidgetHeaderProps.info`. */
|
|
187
|
+
info?: string
|
|
188
|
+
/** Pre-formatted KPI value string (mono ~24px, weight 600, ink) — the hero-row value. */
|
|
76
189
|
value: string
|
|
77
190
|
/** Signed delta rendered via `DeltaBadge`; omit to hide the trend chip entirely. */
|
|
78
191
|
delta?: number
|
|
79
192
|
/** Optional comparison timeframe shown after the delta (e.g. `MoM`) — forwarded to `DeltaBadge`. */
|
|
80
193
|
deltaPeriod?: string
|
|
81
|
-
/**
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Optional trend visual. Either a node, or a RENDER PROP receiving the slot's measured box.
|
|
196
|
+
*
|
|
197
|
+
* Prefer the render prop for anything from `basalt-ui/charts`: those take numeric `width`/`height`
|
|
198
|
+
* (SVG attributes), so a plain node has to hardcode a width, and a hardcoded width is not
|
|
199
|
+
* full-bleed on any viewport but the one it was typed on. With `sparklinePlacement="bleed"` the
|
|
200
|
+
* measured width is the card's own inner width including the bled inset; with `'right'` it is the
|
|
201
|
+
* fixed 72×26 slot (see {@link SPARKLINE_RIGHT_WIDTH} for why that one is not measured).
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* sparkline={({ width, height }) => <BarSparkline data={history} width={width} height={height} />}
|
|
205
|
+
*/
|
|
206
|
+
sparkline?: ReactNode | ((size: StatCardSparklineSize) => ReactNode)
|
|
207
|
+
/** Where `sparkline` sits. `'bleed'` (default) is today's full-width row bled to the card edges;
|
|
208
|
+
* `'right'` sits it beside the hero value row. Collapses to `'bleed'` below `sm`. */
|
|
209
|
+
sparklinePlacement?: 'bleed' | 'right'
|
|
210
|
+
/** Header-right slot (e.g. a ghost "..." menu trigger) — wrapped in `CtlSlot tier="widget"`, so a
|
|
211
|
+
* raw `ActionIcon` with no `size` lands on the 24px step the 28px header row holds (C1/C5). */
|
|
212
|
+
actions?: ReactNode
|
|
86
213
|
/** Threshold verdict — draws an accent rail down the card's leading edge and announces itself to
|
|
87
214
|
* assistive tech. Omitting it is NOT `'good'`: omitted covers a reading that is fine AND one that
|
|
88
215
|
* is absent, and stays untinted so a card with nothing measured can never render green. Pass
|
|
@@ -91,14 +218,24 @@ export type StatCardProps = {
|
|
|
91
218
|
}
|
|
92
219
|
|
|
93
220
|
export function StatCard({
|
|
94
|
-
|
|
221
|
+
title,
|
|
222
|
+
icon,
|
|
223
|
+
subtitle,
|
|
224
|
+
info,
|
|
95
225
|
value,
|
|
96
226
|
delta,
|
|
97
227
|
deltaPeriod,
|
|
98
228
|
sparkline,
|
|
99
|
-
|
|
229
|
+
sparklinePlacement = 'bleed',
|
|
230
|
+
actions,
|
|
100
231
|
tone,
|
|
101
232
|
}: StatCardProps) {
|
|
233
|
+
const isRender = typeof sparkline === 'function'
|
|
234
|
+
const bleeds = sparklinePlacement === 'bleed'
|
|
235
|
+
// Only a render prop needs a measurement; a plain node sizes itself.
|
|
236
|
+
const slot = useSlotWidth(isRender)
|
|
237
|
+
const measured: StatCardSparklineSize = { width: slot.width, height: SPARKLINE_RIGHT_HEIGHT }
|
|
238
|
+
|
|
102
239
|
return (
|
|
103
240
|
<Card
|
|
104
241
|
style={{
|
|
@@ -108,11 +245,13 @@ export function StatCard({
|
|
|
108
245
|
// shadow-card ring is unaffected (verified — only an ANCESTOR's overflow clips a
|
|
109
246
|
// descendant's shadow). Mantine's Card root already sets `overflow: hidden`; this is
|
|
110
247
|
// explicit for intent.
|
|
248
|
+
//
|
|
249
|
+
// NO `minHeight`. It was 118px, which is ~25px of dead space under a card holding a title
|
|
250
|
+
// row, a hero row and a 26px trend — and it was dead space the card could not use, since the
|
|
251
|
+
// body is `height: 100%` and the sparkline is `margin-top: auto`. The content is what states
|
|
252
|
+
// the height now (~88-96px), and four cards in a `SimpleGrid` still match because a grid row
|
|
253
|
+
// stretches its items.
|
|
111
254
|
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
255
|
overflow: 'hidden',
|
|
117
256
|
// Anchors the tone rail. Set unconditionally so a card's stacking context does not change
|
|
118
257
|
// depending on whether it happens to have crossed a threshold this render.
|
|
@@ -143,48 +282,50 @@ export function StatCard({
|
|
|
143
282
|
</>
|
|
144
283
|
)}
|
|
145
284
|
|
|
146
|
-
<
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
fontFamily: 'var(--basalt-font-mono)',
|
|
166
|
-
fontSize: VX.text.kpi,
|
|
167
|
-
fontWeight: 600,
|
|
168
|
-
letterSpacing: '-0.02em',
|
|
169
|
-
color: VX.ink,
|
|
170
|
-
}}
|
|
171
|
-
>
|
|
172
|
-
{value}
|
|
173
|
-
</span>
|
|
174
|
-
{delta !== undefined && <DeltaBadge value={delta} period={deltaPeriod} />}
|
|
175
|
-
</Group>
|
|
176
|
-
|
|
177
|
-
{sparkline && (
|
|
178
|
-
<div
|
|
179
|
-
style={{
|
|
180
|
-
marginTop: 'auto',
|
|
181
|
-
marginInline: 'calc(-1 * var(--mantine-spacing-sm))',
|
|
182
|
-
marginBottom: 'calc(-1 * var(--mantine-spacing-xs))',
|
|
183
|
-
}}
|
|
184
|
-
>
|
|
185
|
-
{sparkline}
|
|
285
|
+
<div className={classes.body} data-placement={sparklinePlacement}>
|
|
286
|
+
<div className={classes.header}>
|
|
287
|
+
<WidgetHeader
|
|
288
|
+
tier="widget"
|
|
289
|
+
title={title}
|
|
290
|
+
{...(icon !== undefined && { icon })}
|
|
291
|
+
{...(subtitle !== undefined && { subtitle })}
|
|
292
|
+
{...(info !== undefined && { info })}
|
|
293
|
+
value={value}
|
|
294
|
+
{...(delta !== undefined && { delta })}
|
|
295
|
+
{...(deltaPeriod !== undefined && { deltaPeriod })}
|
|
296
|
+
// `tier="widget"`, not the default `ctl`: the header row this slot sits in is 28px
|
|
297
|
+
// (`--vx-space-widget-header-height`) and a 30px `ctl` control grew it to 30, so a card
|
|
298
|
+
// with a kebab sat 2px below a card without one in the same grid row. See
|
|
299
|
+
// `theme/ctl-theme.tsx`'s `WIDGET_THEME`.
|
|
300
|
+
{...(actions !== undefined && {
|
|
301
|
+
actions: <CtlSlot tier="widget">{actions}</CtlSlot>,
|
|
302
|
+
})}
|
|
303
|
+
/>
|
|
186
304
|
</div>
|
|
187
|
-
|
|
305
|
+
|
|
306
|
+
{sparkline !== undefined && (
|
|
307
|
+
<div
|
|
308
|
+
ref={slot.ref}
|
|
309
|
+
className={bleeds ? classes.sparklineBleed : classes.sparklineRight}
|
|
310
|
+
{...(!bleeds && {
|
|
311
|
+
// CUSTOM PROPERTIES, not `flexBasis`/`height` directly. A React inline style beats
|
|
312
|
+
// every stylesheet rule, so an inline `flexBasis: 72` also applied below `sm` — where
|
|
313
|
+
// `'right'` collapses to a COLUMN and flex-basis is therefore the main-axis HEIGHT.
|
|
314
|
+
// The mobile sparkline rendered in a 72px-tall box holding 26px of bars, which is the
|
|
315
|
+
// dead band under every KPI value on a phone. Handed to CSS as values instead, the
|
|
316
|
+
// media query can reset the box and the numbers still live in one place.
|
|
317
|
+
style: {
|
|
318
|
+
'--basalt-stat-sparkline-w': `${SPARKLINE_RIGHT_WIDTH}px`,
|
|
319
|
+
'--basalt-stat-sparkline-h': `${SPARKLINE_RIGHT_HEIGHT}px`,
|
|
320
|
+
} as CSSProperties,
|
|
321
|
+
})}
|
|
322
|
+
>
|
|
323
|
+
{/* A measured slot renders nothing on the first commit (width 0) — an SVG at width 0 is
|
|
324
|
+
a visible 0-width box that then jumps, which is worse than one frame of nothing. */}
|
|
325
|
+
{isRender ? measured.width > 0 && sparkline(measured) : sparkline}
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
188
329
|
</Card>
|
|
189
330
|
)
|
|
190
331
|
}
|
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
* (`apps/playground/src/data-table-align.type-guard.ts`); the value can only be caught here,
|
|
14
14
|
* and a silent fallback to left-aligned would look correct in review.
|
|
15
15
|
*/
|
|
16
|
-
import { MantineProvider } from '@mantine/core'
|
|
17
|
-
import { render, screen } from '@testing-library/react'
|
|
16
|
+
import { MantineProvider, Text } from '@mantine/core'
|
|
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'
|
|
21
21
|
import type { BasaltDataTableProps } from './data-table'
|
|
22
|
+
import type { ColumnFiltersState } from './table'
|
|
22
23
|
|
|
23
24
|
type Row = { project: string; cost: number }
|
|
24
25
|
const col = createColumnHelper<Row>()
|
|
@@ -73,15 +74,19 @@ describe('stickyHeader and table chrome', () => {
|
|
|
73
74
|
expect(renderTable().container.querySelector('thead')?.getAttribute('data-sticky')).toBeNull()
|
|
74
75
|
})
|
|
75
76
|
|
|
76
|
-
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.
|
|
77
82
|
expect(
|
|
78
83
|
renderTable().container.querySelector('table')?.getAttribute('data-with-table-border'),
|
|
79
|
-
).
|
|
84
|
+
).toBeNull()
|
|
80
85
|
expect(
|
|
81
|
-
renderTable({ withTableBorder:
|
|
86
|
+
renderTable({ withTableBorder: true })
|
|
82
87
|
.container.querySelector('table')
|
|
83
88
|
?.getAttribute('data-with-table-border'),
|
|
84
|
-
).
|
|
89
|
+
).toBe('true')
|
|
85
90
|
})
|
|
86
91
|
})
|
|
87
92
|
|
|
@@ -156,3 +161,274 @@ describe('regression — the bare table is unchanged', () => {
|
|
|
156
161
|
expect(screen.getByText('linewatch')).toBeTruthy()
|
|
157
162
|
})
|
|
158
163
|
})
|
|
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
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The manual-pagination contract (argo round 10, P1).
|
|
198
|
+
*
|
|
199
|
+
* `manualPagination` makes `data` one server page while the bar reads "Showing 1–25 of 412", so
|
|
200
|
+
* every remaining client-side control becomes a claim about 412 rows it can only make about 25.
|
|
201
|
+
* These pin the replacement for the old silence: loud where the props contradict each other, and
|
|
202
|
+
* honest — never plausible-but-wrong — where a production bundle has dropped the throw.
|
|
203
|
+
*/
|
|
204
|
+
|
|
205
|
+
/** Run `fn` with the production dev-gate, restoring whatever the runner had set. */
|
|
206
|
+
function inProd(fn: () => void): void {
|
|
207
|
+
const previous = process.env['NODE_ENV']
|
|
208
|
+
process.env['NODE_ENV'] = 'production'
|
|
209
|
+
try {
|
|
210
|
+
fn()
|
|
211
|
+
} finally {
|
|
212
|
+
if (previous === undefined) delete process.env['NODE_ENV']
|
|
213
|
+
else process.env['NODE_ENV'] = previous
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const SERVER_PAGE: Partial<BasaltDataTableProps<Row>> = {
|
|
218
|
+
enablePagination: true,
|
|
219
|
+
manualPagination: true,
|
|
220
|
+
rowCount: 412,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
describe('manualPagination — the props that would misreport must be resolved', () => {
|
|
224
|
+
test('the bare adoption throws, naming the page-local sort and both ways out', () => {
|
|
225
|
+
expect(() => renderTable(SERVER_PAGE)).toThrow(
|
|
226
|
+
/client-side sorting is still armed[\s\S]*`manualSorting`[\s\S]*`enableSorting=\{false\}`/,
|
|
227
|
+
)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('no rowCount and no pageCount throws — "of N" would count the page', () => {
|
|
231
|
+
expect(() =>
|
|
232
|
+
renderTable({ enablePagination: true, manualPagination: true, manualSorting: true }),
|
|
233
|
+
).toThrow(/neither `rowCount` nor `pageCount`/)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test('manualPagination without enablePagination throws — the prop is inert, not harmless', () => {
|
|
237
|
+
expect(() => renderTable({ manualPagination: true })).toThrow(
|
|
238
|
+
/`enablePagination` is not set, so `manualPagination` never reaches the table/,
|
|
239
|
+
)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('client-side search over one server page throws too — the same defect, one control over', () => {
|
|
243
|
+
expect(() =>
|
|
244
|
+
renderTable({ ...SERVER_PAGE, manualSorting: true, enableGlobalFilter: true }),
|
|
245
|
+
).toThrow(/the search input \/ facets still filter client-side/)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('facets trip the filtering breach even with no search input', () => {
|
|
249
|
+
expect(() =>
|
|
250
|
+
renderTable({
|
|
251
|
+
...SERVER_PAGE,
|
|
252
|
+
manualSorting: true,
|
|
253
|
+
facets: [{ columnId: 'project', label: 'Project', options: [] }],
|
|
254
|
+
}),
|
|
255
|
+
).toThrow(/still filter client-side/)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('every unresolved breach is reported at once, not one per fix-and-rerun', () => {
|
|
259
|
+
expect(() => renderTable({ manualPagination: true, enablePagination: true })).toThrow(
|
|
260
|
+
/neither `rowCount`[\s\S]*client-side sorting is still armed/,
|
|
261
|
+
)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
describe('manualPagination — the two sanctioned resolutions', () => {
|
|
266
|
+
test("enableSorting={false} renders, and no header claims to sort — argo's workaround", () => {
|
|
267
|
+
const { container } = renderTable({ ...SERVER_PAGE, enableSorting: false })
|
|
268
|
+
for (const th of container.querySelectorAll('th')) {
|
|
269
|
+
expect(th.getAttribute('aria-sort')).toBeNull()
|
|
270
|
+
expect(th.getAttribute('tabindex')).toBeNull()
|
|
271
|
+
}
|
|
272
|
+
expect(container.textContent).toContain('of 412')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('manualSorting keeps the headers live but stops reordering the page locally', () => {
|
|
276
|
+
// asc by cost would put linewatch (3) above argo (12) — under manualSorting the server's
|
|
277
|
+
// order is what renders, because the server is the one that sorted.
|
|
278
|
+
const sorted = renderTable({
|
|
279
|
+
...SERVER_PAGE,
|
|
280
|
+
manualSorting: true,
|
|
281
|
+
initialSorting: [{ id: 'cost', desc: false }],
|
|
282
|
+
})
|
|
283
|
+
expect(sorted.container.querySelector('tbody tr')?.textContent).toContain('argo')
|
|
284
|
+
expect(sorted.container.querySelector('th')?.getAttribute('aria-sort')).toBe('none')
|
|
285
|
+
|
|
286
|
+
// The control: the same initialSorting on a client-side table DOES reorder.
|
|
287
|
+
const local = renderTable({ initialSorting: [{ id: 'cost', desc: false }] })
|
|
288
|
+
expect(local.container.querySelector('tbody tr')?.textContent).toContain('linewatch')
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('manualFiltering hands the search to the server — the input filters nothing locally', () => {
|
|
292
|
+
const { container } = renderTable({
|
|
293
|
+
...SERVER_PAGE,
|
|
294
|
+
manualSorting: true,
|
|
295
|
+
manualFiltering: true,
|
|
296
|
+
enableGlobalFilter: true,
|
|
297
|
+
initialGlobalFilter: 'zzzz',
|
|
298
|
+
})
|
|
299
|
+
// Client-side that term matches no row; the server page renders whole.
|
|
300
|
+
expect(container.querySelectorAll('tbody tr')).toHaveLength(2)
|
|
301
|
+
expect(container.querySelector('input')).toBeTruthy()
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
describe('manualPagination — production degrades to the honest table, never the plausible one', () => {
|
|
306
|
+
test('no sort headers, no search input, and no "of N" it cannot stand behind', () => {
|
|
307
|
+
const errors: unknown[][] = []
|
|
308
|
+
const previous = console.error
|
|
309
|
+
console.error = (...args: unknown[]) => errors.push(args)
|
|
310
|
+
try {
|
|
311
|
+
inProd(() => {
|
|
312
|
+
const { container } = renderTable({
|
|
313
|
+
manualPagination: true,
|
|
314
|
+
enablePagination: true,
|
|
315
|
+
enableGlobalFilter: true,
|
|
316
|
+
})
|
|
317
|
+
// Sorting: the control is gone, so the table asserts nothing about the unseen rows.
|
|
318
|
+
for (const th of container.querySelectorAll('th')) {
|
|
319
|
+
expect(th.getAttribute('aria-sort')).toBeNull()
|
|
320
|
+
}
|
|
321
|
+
// Filtering: same — a control that can only narrow one page is not rendered.
|
|
322
|
+
expect(container.querySelector('input[placeholder="Search…"]')).toBeNull()
|
|
323
|
+
// The count: no rowCount was given, so the bar states the range and claims no total.
|
|
324
|
+
expect(container.textContent).toContain('Showing 1–2')
|
|
325
|
+
expect(container.textContent).not.toContain('of 2')
|
|
326
|
+
})
|
|
327
|
+
} finally {
|
|
328
|
+
console.error = previous
|
|
329
|
+
}
|
|
330
|
+
// The degradation is not its own silence.
|
|
331
|
+
expect(errors).toHaveLength(1)
|
|
332
|
+
expect(String(errors[0]?.[0])).toContain('BasaltDataTable: `manualPagination` is set')
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Sibling defect found by reading, not by a consumer: the empty branch keyed off `data.length`,
|
|
338
|
+
* so any narrowing that left `data` non-empty but the row model empty rendered a `<tbody>` with
|
|
339
|
+
* nothing in it — no rows, no message. A blank body reads as a broken table, not as no matches.
|
|
340
|
+
*/
|
|
341
|
+
describe('the empty state tracks the rendered rows, not the raw array', () => {
|
|
342
|
+
test('a search matching nothing shows the empty state instead of a blank tbody', () => {
|
|
343
|
+
const { container } = renderTable({
|
|
344
|
+
enableGlobalFilter: true,
|
|
345
|
+
initialGlobalFilter: 'no-such-project',
|
|
346
|
+
})
|
|
347
|
+
expect(container.querySelectorAll('tbody tr')).toHaveLength(1)
|
|
348
|
+
expect(container.textContent).toContain('No data to display.')
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test('a custom emptyState is honoured on a filtered-to-nothing table too', () => {
|
|
352
|
+
const { container } = renderTable({
|
|
353
|
+
enableGlobalFilter: true,
|
|
354
|
+
initialGlobalFilter: 'no-such-project',
|
|
355
|
+
emptyState: <Text>Nothing matches that search.</Text>,
|
|
356
|
+
})
|
|
357
|
+
expect(container.textContent).toContain('Nothing matches that search.')
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('a page index past the end shows the empty state rather than an empty body', () => {
|
|
361
|
+
const { container } = renderTable({
|
|
362
|
+
enablePagination: true,
|
|
363
|
+
initialPagination: { pageIndex: 9, pageSize: 10 },
|
|
364
|
+
})
|
|
365
|
+
expect(container.textContent).toContain('No data to display.')
|
|
366
|
+
})
|
|
367
|
+
})
|
|
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
|
+
|
|
380
|
+
describe('onColumnFiltersChange — the seam server-side faceting needs', () => {
|
|
381
|
+
test('a single-select facet reports the new columnFilters via its pill popover', async () => {
|
|
382
|
+
const seen: ColumnFiltersState[] = []
|
|
383
|
+
renderTable({
|
|
384
|
+
facets: [
|
|
385
|
+
{ columnId: 'project', label: 'Project', options: [{ value: 'argo', label: 'argo' }] },
|
|
386
|
+
],
|
|
387
|
+
onColumnFiltersChange: (filters) => seen.push(filters),
|
|
388
|
+
})
|
|
389
|
+
await openPill('Any project')
|
|
390
|
+
fireEvent.click(screen.getByRole('radio', { name: 'argo', hidden: true }))
|
|
391
|
+
await waitFor(() => expect(seen).toEqual([[{ id: 'project', value: 'argo' }]]))
|
|
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
|
+
})
|
|
434
|
+
})
|