basalt-ui 1.25.0 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -6
- package/CHANGELOG.md +26 -0
- package/MIGRATING.md +815 -23
- package/README.md +45 -32
- package/agent/rules/basalt-batteries.md +205 -0
- package/agent/rules/basalt-charts.md +125 -555
- package/agent/rules/basalt-controls.md +160 -0
- package/agent/rules/basalt-mantine.md +146 -439
- package/agent/rules/basalt-state.md +118 -96
- package/agent/rules/basalt-tokens.md +126 -288
- package/agent/skills/basalt-app/SKILL.md +70 -195
- package/agent/skills/basalt-charts/SKILL.md +54 -234
- package/agent/skills/basalt-design/SKILL.md +56 -248
- package/agent/templates/CLAUDE-block.md.tpl +31 -40
- package/agent/templates/DESIGN.md.tpl +22 -51
- package/configs/oxlint-plugin.js +1465 -36
- package/configs/oxlint-plugin.test.ts +1190 -11
- package/configs/oxlint.json +11 -3
- package/dist/charts/cursor/resolve.d.ts +18 -0
- package/dist/charts/cursor/resolve.d.ts.map +1 -1
- package/dist/charts/cursor/resolve.js +15 -6
- package/dist/charts/cursor/scope.d.ts +6 -3
- package/dist/charts/cursor/scope.d.ts.map +1 -1
- package/dist/charts/cursor/store.d.ts +5 -1
- package/dist/charts/cursor/store.d.ts.map +1 -1
- package/dist/charts/cursor/store.js +4 -4
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
- package/dist/charts/hooks/useChartCursor.js +9 -8
- package/dist/charts/index.d.ts +2 -2
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.d.ts +34 -9
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +29 -114
- package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
- package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
- package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
- package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
- package/dist/charts/sparklines/BarSparkline.js +18 -4
- package/dist/cli/check-coverage.test.d.ts +2 -0
- package/dist/cli/check-coverage.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts +67 -4
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +225 -7
- package/dist/connectivity/connectivity-indicator.d.ts +4 -1
- package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
- package/dist/connectivity/connectivity-indicator.js +1 -1
- package/dist/content/index.d.ts +5 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +4 -2
- package/dist/content/prose.module.css +7 -28
- package/dist/controls/actions.d.ts +169 -0
- package/dist/controls/actions.d.ts.map +1 -0
- package/dist/controls/actions.js +287 -0
- package/dist/controls/actions.module.css +62 -0
- package/dist/controls/actions.module.css.d.ts +13 -0
- package/dist/controls/actions.test.d.ts +2 -0
- package/dist/controls/actions.test.d.ts.map +1 -0
- package/dist/controls/actions.type-guard.test.d.ts +2 -0
- package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
- package/dist/controls/compare-filter.d.ts +41 -0
- package/dist/controls/compare-filter.d.ts.map +1 -0
- package/dist/controls/compare-filter.js +23 -0
- package/dist/controls/control-group.d.ts +19 -0
- package/dist/controls/control-group.d.ts.map +1 -0
- package/dist/controls/control-group.js +20 -0
- package/dist/controls/control-group.test.d.ts +2 -0
- package/dist/controls/control-group.test.d.ts.map +1 -0
- package/dist/controls/controls.module.css +542 -0
- package/dist/controls/controls.module.css.d.ts +35 -0
- package/dist/controls/controls.router.test.d.ts +2 -0
- package/dist/controls/controls.router.test.d.ts.map +1 -0
- package/dist/controls/enum-filter.d.ts +39 -0
- package/dist/controls/enum-filter.d.ts.map +1 -0
- package/dist/controls/enum-filter.js +93 -0
- package/dist/controls/filter-context.d.ts +60 -0
- package/dist/controls/filter-context.d.ts.map +1 -0
- package/dist/controls/filter-context.js +69 -0
- package/dist/controls/filter-pill.d.ts +28 -0
- package/dist/controls/filter-pill.d.ts.map +1 -0
- package/dist/controls/filter-pill.js +70 -0
- package/dist/controls/filter-set.d.ts +13 -0
- package/dist/controls/filter-set.d.ts.map +1 -0
- package/dist/controls/filter-set.js +125 -0
- package/dist/controls/filter-set.test.d.ts +2 -0
- package/dist/controls/filter-set.test.d.ts.map +1 -0
- package/dist/controls/filter-sheet.d.ts +117 -0
- package/dist/controls/filter-sheet.d.ts.map +1 -0
- package/dist/controls/filter-sheet.js +146 -0
- package/dist/controls/glyphs.d.ts +27 -0
- package/dist/controls/glyphs.d.ts.map +1 -0
- package/dist/controls/glyphs.js +49 -0
- package/dist/controls/index.d.ts +20 -0
- package/dist/controls/index.d.ts.map +1 -0
- package/dist/controls/index.js +46 -0
- package/dist/controls/multi-select-filter.d.ts +23 -0
- package/dist/controls/multi-select-filter.d.ts.map +1 -0
- package/dist/controls/multi-select-filter.js +89 -0
- package/dist/controls/number-filter.d.ts +26 -0
- package/dist/controls/number-filter.d.ts.map +1 -0
- package/dist/controls/number-filter.js +143 -0
- package/dist/controls/peer-boundary.test.d.ts +2 -0
- package/dist/controls/peer-boundary.test.d.ts.map +1 -0
- package/dist/controls/props-contract.test.d.ts +2 -0
- package/dist/controls/props-contract.test.d.ts.map +1 -0
- package/dist/controls/range-filter.d.ts +43 -0
- package/dist/controls/range-filter.d.ts.map +1 -0
- package/dist/controls/range-filter.js +135 -0
- package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
- package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
- package/dist/controls/search-filter.d.ts +10 -0
- package/dist/controls/search-filter.d.ts.map +1 -0
- package/dist/controls/search-filter.js +81 -0
- package/dist/controls/select-filter.d.ts +74 -0
- package/dist/controls/select-filter.d.ts.map +1 -0
- package/dist/controls/select-filter.js +10 -0
- package/dist/controls/sync-button.d.ts +38 -0
- package/dist/controls/sync-button.d.ts.map +1 -0
- package/dist/controls/sync-button.js +119 -0
- package/dist/controls/sync-button.module.css +63 -0
- package/dist/controls/sync-button.module.css.d.ts +11 -0
- package/dist/controls/sync-button.test.d.ts +2 -0
- package/dist/controls/sync-button.test.d.ts.map +1 -0
- package/dist/controls/toggle-filter.d.ts +9 -0
- package/dist/controls/toggle-filter.d.ts.map +1 -0
- package/dist/controls/toggle-filter.js +42 -0
- package/dist/controls/view-tabs.d.ts +23 -0
- package/dist/controls/view-tabs.d.ts.map +1 -0
- package/dist/controls/view-tabs.js +59 -0
- package/dist/controls-dates/date-range-picker.d.ts +4 -0
- package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
- package/dist/controls-dates/date-range-picker.js +18 -0
- package/dist/controls-dates/index.d.ts +9 -0
- package/dist/controls-dates/index.d.ts.map +1 -0
- package/dist/controls-dates/index.js +4 -0
- package/dist/dashboard/delta-badge.d.ts +6 -12
- package/dist/dashboard/delta-badge.d.ts.map +1 -1
- package/dist/dashboard/delta-badge.js +1 -50
- package/dist/dashboard/index.d.ts +1 -1
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +3 -1
- package/dist/dashboard/settings-section.d.ts +6 -4
- package/dist/dashboard/settings-section.d.ts.map +1 -1
- package/dist/dashboard/settings-section.js +23 -16
- package/dist/dashboard/settings-section.module.css +10 -0
- package/dist/dashboard/settings-section.module.css.d.ts +1 -0
- package/dist/dashboard/settings-section.test.d.ts +2 -0
- package/dist/dashboard/settings-section.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.d.ts +95 -9
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +108 -49
- package/dist/dashboard/stat-card.module.css +153 -0
- package/dist/dashboard/stat-card.module.css.d.ts +15 -0
- package/dist/data/data-table.d.ts +37 -15
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +96 -58
- package/dist/guard/grace.test.d.ts +20 -0
- package/dist/guard/grace.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +66 -3
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +78 -7
- package/dist/guard/types.d.ts +3 -3
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -10
- package/dist/nav/types.d.ts +78 -2
- package/dist/nav/types.d.ts.map +1 -1
- package/dist/notifications/bell.d.ts +4 -1
- package/dist/notifications/bell.d.ts.map +1 -1
- package/dist/notifications/index.d.ts +1 -1
- package/dist/router-tanstack/field.d.ts +14 -0
- package/dist/router-tanstack/field.d.ts.map +1 -0
- package/dist/router-tanstack/field.js +4 -0
- package/dist/router-tanstack/index.d.ts +6 -2
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/index.js +4 -0
- package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.js +19 -27
- package/dist/router-tanstack/search-param-store.d.ts +3 -87
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-param-store.js +16 -45
- package/dist/router-tanstack/search-store.d.ts +84 -0
- package/dist/router-tanstack/search-store.d.ts.map +1 -0
- package/dist/router-tanstack/search-store.js +271 -0
- package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
- package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
- package/dist/router-tanstack/search-store.test.d.ts +2 -0
- package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
- package/dist/router-tanstack/use-nav.d.ts +0 -2
- package/dist/router-tanstack/use-nav.d.ts.map +1 -1
- package/dist/router-tanstack/use-nav.js +34 -12
- package/dist/router-tanstack/use-nav.test.d.ts +2 -0
- package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
- package/dist/section/index.d.ts +2 -0
- package/dist/section/index.d.ts.map +1 -0
- package/dist/section/index.js +4 -0
- package/dist/section/section.d.ts +24 -0
- package/dist/section/section.d.ts.map +1 -0
- package/dist/section/section.js +126 -0
- package/dist/section/section.module.css +104 -0
- package/dist/section/section.module.css.d.ts +11 -0
- package/dist/section/section.test.d.ts +2 -0
- package/dist/section/section.test.d.ts.map +1 -0
- package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
- package/dist/shell/app-breadcrumbs.js +18 -15
- package/dist/shell/app-header.module.css +77 -37
- package/dist/shell/app-header.module.css.d.ts +2 -1
- package/dist/shell/app-mobile-nav.d.ts +9 -2
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +98 -11
- package/dist/shell/app-mobile-nav.module.css +8 -0
- package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/dist/shell/app-sidebar.d.ts +55 -28
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +116 -67
- package/dist/shell/app-sidebar.module.css +303 -20
- package/dist/shell/app-sidebar.module.css.d.ts +19 -1
- package/dist/shell/index.d.ts +55 -21
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +40 -18
- package/dist/shell/mobile-nav-model.d.ts +13 -2
- package/dist/shell/mobile-nav-model.d.ts.map +1 -1
- package/dist/shell/mobile-nav-model.js +15 -1
- package/dist/shell/nav-count-badge.d.ts +1 -1
- package/dist/shell/page-bar.d.ts +51 -0
- package/dist/shell/page-bar.d.ts.map +1 -0
- package/dist/shell/page-bar.js +154 -0
- package/dist/shell/page-bar.module.css +178 -0
- package/dist/shell/page-bar.module.css.d.ts +17 -0
- package/dist/shell/page-bar.test.d.ts +2 -0
- package/dist/shell/page-bar.test.d.ts.map +1 -0
- package/dist/shell/sidebar-block-model.d.ts +48 -0
- package/dist/shell/sidebar-block-model.d.ts.map +1 -0
- package/dist/shell/sidebar-block-model.js +39 -0
- package/dist/shell/sidebar-blocks.d.ts +32 -0
- package/dist/shell/sidebar-blocks.d.ts.map +1 -0
- package/dist/shell/sidebar-blocks.js +169 -0
- package/dist/shell/sidebar-search.d.ts +13 -1
- package/dist/shell/sidebar-search.d.ts.map +1 -1
- package/dist/shell/sidebar-search.js +34 -1
- package/dist/shell/sidebar-search.module.css +26 -0
- package/dist/shell/sidebar-search.module.css.d.ts +2 -0
- package/dist/state/fields.d.ts +565 -0
- package/dist/state/fields.d.ts.map +1 -0
- package/dist/state/fields.js +405 -0
- package/dist/state/fields.test.d.ts +2 -0
- package/dist/state/fields.test.d.ts.map +1 -0
- package/dist/state/persisted.d.ts +60 -0
- package/dist/state/persisted.d.ts.map +1 -0
- package/dist/state/persisted.js +123 -0
- package/dist/state/persisted.test.d.ts +2 -0
- package/dist/state/persisted.test.d.ts.map +1 -0
- package/dist/state.d.ts +11 -56
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +14 -120
- package/dist/styles.css +13 -0
- package/dist/surfaces.d.ts +106 -24
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +156 -19
- package/dist/theme/controls.module.css +16 -1
- package/dist/theme/ctl-theme.d.ts +32 -0
- package/dist/theme/ctl-theme.d.ts.map +1 -0
- package/dist/theme/ctl-theme.js +54 -0
- package/dist/theme/ctl-theme.test.d.ts +2 -0
- package/dist/theme/ctl-theme.test.d.ts.map +1 -0
- package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
- package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
- package/dist/theme/divider-law.test.d.ts +2 -0
- package/dist/theme/divider-law.test.d.ts.map +1 -0
- package/dist/theme/icon-slot.d.ts +44 -0
- package/dist/theme/icon-slot.d.ts.map +1 -0
- package/dist/theme/icon-slot.js +21 -0
- package/dist/theme/icon-slot.module.css +47 -0
- package/dist/theme/icon-slot.module.css.d.ts +8 -0
- package/dist/theme/icon-slot.test.d.ts +2 -0
- package/dist/theme/icon-slot.test.d.ts.map +1 -0
- package/dist/theme/index.d.ts +1 -0
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +82 -5
- package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
- package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
- package/dist/theme/segmented-control.module.css +20 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
- package/dist/tokens/index.d.ts +12 -0
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +38 -8
- package/dist/tokens/palette.d.ts +97 -61
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +76 -38
- package/dist/tokens.css +10 -2
- package/dist/widget-header/delta-badge.d.ts +13 -0
- package/dist/widget-header/delta-badge.d.ts.map +1 -0
- package/dist/widget-header/delta-badge.js +29 -0
- package/dist/widget-header/delta-badge.test.d.ts +2 -0
- package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
- package/dist/widget-header/index.d.ts +3 -0
- package/dist/widget-header/index.d.ts.map +1 -0
- package/dist/widget-header/index.js +6 -0
- package/dist/widget-header/no-mantine.test.d.ts +2 -0
- package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
- package/dist/widget-header/widget-header.d.ts +67 -0
- package/dist/widget-header/widget-header.d.ts.map +1 -0
- package/dist/widget-header/widget-header.js +89 -0
- package/dist/widget-header/widget-header.module.css +350 -0
- package/dist/widget-header/widget-header.module.css.d.ts +25 -0
- package/dist/widget-header/widget-header.test.d.ts +2 -0
- package/dist/widget-header/widget-header.test.d.ts.map +1 -0
- package/llms.txt +30 -17
- package/package.json +14 -1
- package/src/charts/cursor/resolve.test.ts +29 -1
- package/src/charts/cursor/resolve.ts +38 -6
- package/src/charts/cursor/scope.tsx +6 -3
- package/src/charts/cursor/store.test.ts +19 -1
- package/src/charts/cursor/store.ts +10 -5
- package/src/charts/hooks/useChartCursor.ts +16 -8
- package/src/charts/index.ts +2 -2
- package/src/charts/primitives/CartesianChart.test.tsx +124 -0
- package/src/charts/primitives/ChartCard.test.tsx +66 -0
- package/src/charts/primitives/ChartCard.tsx +86 -146
- package/src/charts/sparklines/BarSparkline.tsx +43 -5
- package/src/cli/check-coverage.test.ts +297 -0
- package/src/cli/check-theme.test.ts +38 -14
- package/src/cli/index.ts +392 -16
- package/src/cli/init-sync.test.ts +97 -0
- package/src/connectivity/connectivity-indicator.tsx +16 -9
- package/src/content/index.ts +16 -3
- package/src/content/prose.module.css +7 -28
- package/src/content/prose.module.css.test.ts +8 -24
- package/src/controls/actions.module.css +62 -0
- package/src/controls/actions.module.css.d.ts +13 -0
- package/src/controls/actions.test.tsx +252 -0
- package/src/controls/actions.tsx +655 -0
- package/src/controls/actions.type-guard.test.ts +63 -0
- package/src/controls/compare-filter.tsx +64 -0
- package/src/controls/control-group.test.tsx +229 -0
- package/src/controls/control-group.tsx +63 -0
- package/src/controls/controls.module.css +542 -0
- package/src/controls/controls.module.css.d.ts +35 -0
- package/src/controls/controls.router.test.tsx +1053 -0
- package/src/controls/enum-filter.tsx +170 -0
- package/src/controls/filter-context.tsx +158 -0
- package/src/controls/filter-pill.tsx +120 -0
- package/src/controls/filter-set.test.tsx +472 -0
- package/src/controls/filter-set.tsx +225 -0
- package/src/controls/filter-sheet.tsx +285 -0
- package/src/controls/glyphs.tsx +81 -0
- package/src/controls/index.ts +49 -0
- package/src/controls/multi-select-filter.tsx +140 -0
- package/src/controls/number-filter.tsx +291 -0
- package/src/controls/peer-boundary.test.ts +85 -0
- package/src/controls/props-contract.test.tsx +137 -0
- package/src/controls/range-filter.tsx +237 -0
- package/src/controls/range-filter.type-guard.test.tsx +122 -0
- package/src/controls/search-filter.tsx +123 -0
- package/src/controls/select-filter.tsx +89 -0
- package/src/controls/sync-button.module.css +63 -0
- package/src/controls/sync-button.module.css.d.ts +11 -0
- package/src/controls/sync-button.test.tsx +183 -0
- package/src/controls/sync-button.tsx +197 -0
- package/src/controls/toggle-filter.tsx +60 -0
- package/src/controls/view-tabs.tsx +110 -0
- package/src/controls-dates/date-range-picker.tsx +42 -0
- package/src/controls-dates/index.ts +8 -0
- package/src/dashboard/delta-badge.tsx +4 -74
- package/src/dashboard/index.ts +6 -1
- package/src/dashboard/settings-section.module.css +10 -0
- package/src/dashboard/settings-section.module.css.d.ts +1 -0
- package/src/dashboard/settings-section.test.tsx +56 -0
- package/src/dashboard/settings-section.tsx +33 -30
- package/src/dashboard/stat-card.module.css +153 -0
- package/src/dashboard/stat-card.module.css.d.ts +15 -0
- package/src/dashboard/stat-card.test.tsx +298 -1
- package/src/dashboard/stat-card.tsx +289 -67
- package/src/data/data-table.test.tsx +96 -11
- package/src/data/data-table.tsx +228 -84
- package/src/guard/check-source.test.ts +159 -13
- package/src/guard/grace.test.ts +158 -0
- package/src/guard/index.ts +185 -29
- package/src/guard/types.ts +4 -2
- package/src/index.ts +51 -5
- package/src/nav/types.ts +84 -2
- package/src/notifications/bell.tsx +6 -3
- package/src/notifications/index.ts +1 -1
- package/src/router-tanstack/field.ts +35 -0
- package/src/router-tanstack/index.ts +28 -2
- package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
- package/src/router-tanstack/multi-search-param-store.ts +46 -77
- package/src/router-tanstack/search-param-store.ts +42 -159
- package/src/router-tanstack/search-store.router.test.tsx +882 -0
- package/src/router-tanstack/search-store.test.ts +578 -0
- package/src/router-tanstack/search-store.ts +546 -0
- package/src/router-tanstack/use-nav.test.tsx +187 -0
- package/src/router-tanstack/use-nav.ts +72 -14
- package/src/section/index.ts +1 -0
- package/src/section/section.module.css +104 -0
- package/src/section/section.module.css.d.ts +11 -0
- package/src/section/section.test.tsx +204 -0
- package/src/section/section.tsx +220 -0
- package/src/shell/app-breadcrumbs.tsx +44 -31
- package/src/shell/app-header.module.css +77 -37
- package/src/shell/app-header.module.css.d.ts +2 -1
- package/src/shell/app-mobile-nav.module.css +8 -0
- package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/src/shell/app-mobile-nav.tsx +147 -8
- package/src/shell/app-sidebar-account.tsx +2 -2
- package/src/shell/app-sidebar.module.css +303 -20
- package/src/shell/app-sidebar.module.css.d.ts +19 -1
- package/src/shell/app-sidebar.test.tsx +456 -41
- package/src/shell/app-sidebar.tsx +276 -140
- package/src/shell/index.test.tsx +291 -5
- package/src/shell/index.tsx +137 -38
- package/src/shell/mobile-nav-model.test.ts +228 -7
- package/src/shell/mobile-nav-model.ts +53 -3
- package/src/shell/nav-count-badge.tsx +1 -1
- package/src/shell/page-bar.module.css +178 -0
- package/src/shell/page-bar.module.css.d.ts +17 -0
- package/src/shell/page-bar.test.tsx +316 -0
- package/src/shell/page-bar.tsx +315 -0
- package/src/shell/sidebar-block-model.ts +83 -0
- package/src/shell/sidebar-blocks.tsx +301 -0
- package/src/shell/sidebar-search.module.css +26 -0
- package/src/shell/sidebar-search.module.css.d.ts +2 -0
- package/src/shell/sidebar-search.tsx +71 -2
- package/src/state/fields.test.tsx +512 -0
- package/src/state/fields.ts +1150 -0
- package/src/state/persisted.test.ts +91 -0
- package/src/state/persisted.ts +261 -0
- package/src/state.ts +52 -240
- package/src/styles.css +13 -0
- package/src/surfaces.ts +204 -36
- package/src/theme/controls.module.css +16 -1
- package/src/theme/ctl-theme.test.tsx +275 -0
- package/src/theme/ctl-theme.tsx +138 -0
- package/src/theme/ctl-tier-coverage.test.ts +170 -0
- package/src/theme/density-relations.test.ts +106 -51
- package/src/theme/divider-law.test.ts +259 -0
- package/src/theme/icon-slot.module.css +47 -0
- package/src/theme/icon-slot.module.css.d.ts +8 -0
- package/src/theme/icon-slot.test.tsx +275 -0
- package/src/theme/icon-slot.tsx +59 -0
- package/src/theme/index.ts +165 -3
- package/src/theme/layout-rhythm-css.test.ts +72 -0
- package/src/theme/radius.test.ts +11 -1
- package/src/theme/segmented-control.module.css +20 -0
- package/src/theme/shadow-surfaces.test.ts +20 -7
- package/src/theme/spacing.test.ts +75 -27
- package/src/theme/use-basalt-spacing.ts +2 -2
- package/src/tokens/density.test.ts +7 -25
- package/src/tokens/index.ts +48 -8
- package/src/tokens/palette.ts +107 -73
- package/src/widget-header/delta-badge.test.tsx +60 -0
- package/src/widget-header/delta-badge.tsx +57 -0
- package/src/widget-header/index.ts +2 -0
- package/src/widget-header/no-mantine.test.ts +33 -0
- package/src/widget-header/widget-header.module.css +350 -0
- package/src/widget-header/widget-header.module.css.d.ts +25 -0
- package/src/widget-header/widget-header.test.tsx +187 -0
- package/src/widget-header/widget-header.tsx +213 -0
- package/agent/rules/basalt-agent.md +0 -452
- package/agent/rules/basalt-app.md +0 -246
- package/agent/rules/basalt-commands.md +0 -320
- package/agent/rules/basalt-content.md +0 -409
- package/agent/rules/basalt-data.md +0 -329
- package/agent/rules/basalt-forms.md +0 -186
- package/agent/rules/basalt-notifications.md +0 -264
- package/agent/rules/basalt-query.md +0 -187
- package/agent/rules/basalt-router.md +0 -336
- package/dist/content/article-filter-bar.d.ts +0 -40
- package/dist/content/article-filter-bar.d.ts.map +0 -1
- package/dist/content/article-filter-bar.js +0 -56
- package/dist/shell/page-header.d.ts +0 -13
- package/dist/shell/page-header.d.ts.map +0 -1
- package/dist/shell/page-header.js +0 -23
- package/src/content/article-filter-bar.tsx +0 -88
- package/src/shell/page-header.tsx +0 -35
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SyncButton` — the age formatting and the syncing state (law C12, `docs/CONTROLS-SPEC.md` §3).
|
|
3
|
+
*
|
|
4
|
+
* The age is the part that had four hand-rolled copies across consumers, each with its own
|
|
5
|
+
* rounding; `formatAge` is asserted directly so the boundaries are pinned, not inferred from one
|
|
6
|
+
* rendered string.
|
|
7
|
+
*/
|
|
8
|
+
import { MantineProvider } from '@mantine/core'
|
|
9
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
10
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
11
|
+
import { describe, expect, test } from 'bun:test'
|
|
12
|
+
import { AGE_REFRESH_MS, SyncButton, formatAge } from './sync-button'
|
|
13
|
+
|
|
14
|
+
function renderSync(props: Partial<Parameters<typeof SyncButton>[0]> = {}) {
|
|
15
|
+
return render(
|
|
16
|
+
<MantineProvider>
|
|
17
|
+
<SyncButton syncing={false} scope="page" onSync={() => {}} {...props} />
|
|
18
|
+
</MantineProvider>,
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('formatAge', () => {
|
|
23
|
+
test('anything under a minute is `just now`, so a fresh sync stops flickering', () => {
|
|
24
|
+
expect(formatAge(0)).toBe('just now')
|
|
25
|
+
expect(formatAge(59_000)).toBe('just now')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('minutes, then hours, then days', () => {
|
|
29
|
+
expect(formatAge(60_000)).toBe('1m ago')
|
|
30
|
+
expect(formatAge(2 * 60_000)).toBe('2m ago')
|
|
31
|
+
expect(formatAge(59 * 60_000)).toBe('59m ago')
|
|
32
|
+
expect(formatAge(60 * 60_000)).toBe('1h ago')
|
|
33
|
+
expect(formatAge(23 * 60 * 60_000)).toBe('23h ago')
|
|
34
|
+
expect(formatAge(24 * 60 * 60_000)).toBe('1d ago')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('the refresh interval is coarser than the smallest unit it renders', () => {
|
|
38
|
+
expect(AGE_REFRESH_MS).toBeLessThanOrEqual(60_000)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('SyncButton', () => {
|
|
43
|
+
test('renders the age beside the label on desktop', () => {
|
|
44
|
+
renderSync({ lastCompletedAt: Date.now() - 2 * 60_000 })
|
|
45
|
+
expect(screen.getByRole('button').textContent).toContain('Sync')
|
|
46
|
+
expect(screen.getByRole('button').textContent).toContain('2m ago')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('a Date and an epoch number are the same input', () => {
|
|
50
|
+
renderSync({ lastCompletedAt: new Date(Date.now() - 3 * 60_000) })
|
|
51
|
+
expect(screen.getByRole('button').textContent).toContain('3m ago')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('no age at all when nothing has completed yet', () => {
|
|
55
|
+
renderSync({ lastCompletedAt: null })
|
|
56
|
+
expect(screen.getByRole('button').textContent).toBe('Sync')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('syncing announces itself and refuses a second click — WITHOUT losing focus', () => {
|
|
60
|
+
let presses = 0
|
|
61
|
+
renderSync({
|
|
62
|
+
syncing: true,
|
|
63
|
+
onSync: () => {
|
|
64
|
+
presses += 1
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
const button = screen.getByRole('button')
|
|
68
|
+
|
|
69
|
+
expect(button.getAttribute('aria-busy')).toBe('true')
|
|
70
|
+
expect(button.getAttribute('aria-disabled')).toBe('true')
|
|
71
|
+
// The NATIVE attribute is what drops focus to <body> mid-press, so it must stay off: a keyboard
|
|
72
|
+
// user who presses Sync has to still be standing on the button when the sync lands.
|
|
73
|
+
expect(button.hasAttribute('disabled')).toBe(false)
|
|
74
|
+
expect(button.hasAttribute('data-disabled')).toBe(true)
|
|
75
|
+
|
|
76
|
+
button.focus()
|
|
77
|
+
fireEvent.click(button)
|
|
78
|
+
expect(presses).toBe(0)
|
|
79
|
+
expect(document.activeElement).toBe(button)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('an idle press does call onSync — the refusal above is not just a dead handler', () => {
|
|
83
|
+
let presses = 0
|
|
84
|
+
renderSync({
|
|
85
|
+
onSync: () => {
|
|
86
|
+
presses += 1
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
fireEvent.click(screen.getByRole('button'))
|
|
90
|
+
expect(presses).toBe(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('the age is absent on the server, so hydration cannot mismatch it', () => {
|
|
94
|
+
// `Date.now()` during render is the whole bug: the server renders one age and a client hydrating
|
|
95
|
+
// a minute later renders another. Both must render NO age; the effect fills it in after mount.
|
|
96
|
+
const html = renderToStaticMarkup(
|
|
97
|
+
<MantineProvider>
|
|
98
|
+
<SyncButton
|
|
99
|
+
syncing={false}
|
|
100
|
+
scope="page"
|
|
101
|
+
onSync={() => {}}
|
|
102
|
+
lastCompletedAt={Date.now() - 5 * 60_000}
|
|
103
|
+
/>
|
|
104
|
+
</MantineProvider>,
|
|
105
|
+
)
|
|
106
|
+
expect(html).not.toContain('5m ago')
|
|
107
|
+
expect(html).toContain('Sync')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('an error puts the control in the danger tone', () => {
|
|
111
|
+
renderSync({ error: 'Upstream 503' })
|
|
112
|
+
expect(screen.getByRole('button').getAttribute('data-variant')).toBe('default')
|
|
113
|
+
expect(document.querySelector('[data-basalt-sync-scope="page"]')).not.toBeNull()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('the error message is in the accessible name, not only in a hover tooltip', () => {
|
|
117
|
+
// A tooltip announces only while open, and Mantine's opens on hover ALONE by default — so
|
|
118
|
+
// without this the red colour is the entire signal for a keyboard or screen-reader user.
|
|
119
|
+
renderSync({ error: 'Rate limited' })
|
|
120
|
+
expect(screen.getByRole('button', { name: 'Sync — Rate limited' })).toBeDefined()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('scope reaches the DOM so a call site is readable without reading the props', () => {
|
|
124
|
+
renderSync({ scope: 'global' })
|
|
125
|
+
expect(document.querySelector('[data-basalt-sync-scope="global"]')).not.toBeNull()
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The shape per scope. `scope` used to be documentation only; it now picks the form, because the
|
|
131
|
+
* shell header has no room for a labelled button beside the breadcrumb, `PageBar` row 1 and every
|
|
132
|
+
* other global action — and a page bar does (spec §3, law C12).
|
|
133
|
+
*/
|
|
134
|
+
describe('the shape follows the scope', () => {
|
|
135
|
+
test('global renders an ActionIcon with no label text at any width', () => {
|
|
136
|
+
renderSync({ scope: 'global', label: 'Sync all' })
|
|
137
|
+
const button = screen.getByRole('button')
|
|
138
|
+
// Mantine's own class names are static strings (unlike a CSS-module hash), so which PRIMITIVE
|
|
139
|
+
// rendered is assertable — and it is the whole difference between the two forms.
|
|
140
|
+
expect(button.classList.contains('mantine-ActionIcon-root')).toBe(true)
|
|
141
|
+
// Icon-only on EVERY viewport, so there is no text to hide with a media query in the first
|
|
142
|
+
// place: the label reaches the user as the accessible name only.
|
|
143
|
+
expect(button.textContent).toBe('')
|
|
144
|
+
expect(screen.getByRole('button', { name: 'Sync all' })).toBeDefined()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('a global button carries no rendered age either — the tooltip is its only home', () => {
|
|
148
|
+
renderSync({ scope: 'global', lastCompletedAt: Date.now() - 4 * 60_000 })
|
|
149
|
+
expect(screen.getByRole('button').textContent).toBe('')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('page renders the labelled Button, and is named even once the label is hidden below sm', () => {
|
|
153
|
+
renderSync({ scope: 'page' })
|
|
154
|
+
const button = screen.getByRole('button')
|
|
155
|
+
expect(button.classList.contains('mantine-Button-root')).toBe(true)
|
|
156
|
+
expect(button.textContent).toBe('Sync')
|
|
157
|
+
// jsdom evaluates no media query, so the `display: none` below `sm` is not observable here —
|
|
158
|
+
// what IS observable is the half that makes it safe: `display: none` text leaves the
|
|
159
|
+
// accessibility tree, so the name has to come from `aria-label`, not from the children.
|
|
160
|
+
expect(button.getAttribute('aria-label')).toBe('Sync')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('both scopes fold an error into the accessible name, not only into the tone', () => {
|
|
164
|
+
renderSync({ scope: 'global', error: 'Upstream 503' })
|
|
165
|
+
expect(screen.getByRole('button', { name: 'Sync — Upstream 503' })).toBeDefined()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('a global button refuses a second click while syncing, exactly as the page form does', () => {
|
|
169
|
+
let presses = 0
|
|
170
|
+
renderSync({
|
|
171
|
+
scope: 'global',
|
|
172
|
+
syncing: true,
|
|
173
|
+
onSync: () => {
|
|
174
|
+
presses += 1
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
const button = screen.getByRole('button')
|
|
178
|
+
expect(button.getAttribute('aria-disabled')).toBe('true')
|
|
179
|
+
expect(button.hasAttribute('disabled')).toBe(false)
|
|
180
|
+
fireEvent.click(button)
|
|
181
|
+
expect(presses).toBe(0)
|
|
182
|
+
})
|
|
183
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SyncButton` — the ONE shape refresh/sync takes anywhere in a basalt app (law C12). It replaced
|
|
3
|
+
* a per-consumer zoo: a `RefreshButton`, a `sync-control`, a reading `SyncButton` and a bare
|
|
4
|
+
* `ActionIcon` in one app alone, each with its own age formatting and its own spinner.
|
|
5
|
+
*
|
|
6
|
+
* `scope` names the home, and the home decides the SHAPE — one component, two forms, so the age
|
|
7
|
+
* formatting, the spinner, the error tone and the accessible name can never drift apart between
|
|
8
|
+
* them (and the home stays queryable in the DOM via `data-basalt-sync-scope`):
|
|
9
|
+
*
|
|
10
|
+
* - `scope: 'global'` → the shell's `globalActions`. Icon-only on EVERY viewport, an `ActionIcon`
|
|
11
|
+
* carrying the spinning glyph. The shell header is 48px of width shared with the breadcrumb,
|
|
12
|
+
* `PageBar` row 1 and every other global action, so a labelled button there is the one that
|
|
13
|
+
* pushes a page's own actions into the kebab. The age and the error live in the tooltip; the
|
|
14
|
+
* accessible name is `label`.
|
|
15
|
+
* - `scope: 'page'` → `PageBar.sync` (the page's own data). The labelled `Button` with the age
|
|
16
|
+
* inline beside it on desktop, icon-only below `sm` where that width is gone — CSS only, one
|
|
17
|
+
* mount, never a `visibleFrom` twin (law C9).
|
|
18
|
+
*
|
|
19
|
+
* Because the label is hidden rather than unmounted below `sm`, the accessible name comes from
|
|
20
|
+
* `aria-label` in BOTH forms: `display: none` text is out of the accessibility tree, so a page
|
|
21
|
+
* button relying on its visible children would be an unnamed icon on a phone.
|
|
22
|
+
*/
|
|
23
|
+
import { ActionIcon, Button, Tooltip } from '@mantine/core'
|
|
24
|
+
import { useEffect, useState } from 'react'
|
|
25
|
+
import type { ReactNode } from 'react'
|
|
26
|
+
import { IconSlot } from '../theme/icon-slot'
|
|
27
|
+
import classes from './sync-button.module.css'
|
|
28
|
+
|
|
29
|
+
export type SyncButtonProps = {
|
|
30
|
+
syncing: boolean
|
|
31
|
+
/** Last successful completion. `undefined`/`null` renders no age at all. */
|
|
32
|
+
lastCompletedAt?: number | Date | null
|
|
33
|
+
onSync: () => void
|
|
34
|
+
/** The home, and with it the shape: `'global'` is icon-only at every width, `'page'` is labelled
|
|
35
|
+
* on desktop and icon-only below `sm` — see this module's doc. */
|
|
36
|
+
scope: 'page' | 'global'
|
|
37
|
+
/** Visible label at `scope: 'page'` on desktop, and the accessible name in every other form.
|
|
38
|
+
* @default 'Sync' */
|
|
39
|
+
label?: string
|
|
40
|
+
/**
|
|
41
|
+
* Last failure. Puts the control in the danger tone, carries the message in the tooltip (which
|
|
42
|
+
* opens on hover, focus AND touch) and folds it into the button's accessible name, so the red is
|
|
43
|
+
* never the only signal.
|
|
44
|
+
*/
|
|
45
|
+
error?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How often the relative age re-renders. One interval, only while mounted. */
|
|
49
|
+
export const AGE_REFRESH_MS = 30_000
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Relative age, coarse on purpose: a sync indicator answers "is this stale?", not "how stale to
|
|
53
|
+
* the second". Anything under a minute is `just now`, so the label stops flickering while a sync
|
|
54
|
+
* has just landed.
|
|
55
|
+
*/
|
|
56
|
+
export function formatAge(ms: number): string {
|
|
57
|
+
if (ms < 60_000) return 'just now'
|
|
58
|
+
const minutes = Math.floor(ms / 60_000)
|
|
59
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
60
|
+
const hours = Math.floor(minutes / 60)
|
|
61
|
+
if (hours < 24) return `${hours}h ago`
|
|
62
|
+
return `${Math.floor(hours / 24)}d ago`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toMillis(at: number | Date | null | undefined): number | undefined {
|
|
66
|
+
if (at === null || at === undefined) return undefined
|
|
67
|
+
return at instanceof Date ? at.getTime() : at
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The age string, refreshed on an interval that exists ONLY while the component is mounted and
|
|
72
|
+
* only while there is an age to show — a sync control on a background route costs no timer.
|
|
73
|
+
*
|
|
74
|
+
* The clock is read in the EFFECT, never during render. A global `SyncButton` in `globalActions`
|
|
75
|
+
* renders inline in the shell header (no portal), so under SSR the server would emit `just now` and
|
|
76
|
+
* a client hydrating a minute later would emit `1m ago` — a React text mismatch that discards the
|
|
77
|
+
* subtree. Server and first client paint both omit the age; the effect fills it in after mount.
|
|
78
|
+
* That also makes the interval the only thing that re-renders, so the old `setTick` counter is gone.
|
|
79
|
+
*/
|
|
80
|
+
function useRelativeAge(at: number | Date | null | undefined): string | undefined {
|
|
81
|
+
const [age, setAge] = useState<string | undefined>(undefined)
|
|
82
|
+
const timestamp = toMillis(at)
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (timestamp === undefined) {
|
|
86
|
+
setAge(undefined)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
const publish = (): void => {
|
|
90
|
+
setAge(formatAge(Date.now() - timestamp))
|
|
91
|
+
}
|
|
92
|
+
publish()
|
|
93
|
+
const id = setInterval(publish, AGE_REFRESH_MS)
|
|
94
|
+
return () => clearInterval(id)
|
|
95
|
+
}, [timestamp])
|
|
96
|
+
|
|
97
|
+
return age
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function RefreshGlyph({ spinning }: { spinning: boolean }) {
|
|
101
|
+
return (
|
|
102
|
+
<svg
|
|
103
|
+
width={16}
|
|
104
|
+
height={16}
|
|
105
|
+
viewBox="0 0 24 24"
|
|
106
|
+
fill="none"
|
|
107
|
+
aria-hidden
|
|
108
|
+
className={spinning ? classes.spin : undefined}
|
|
109
|
+
>
|
|
110
|
+
<path
|
|
111
|
+
d="M20 11a8 8 0 1 0-2.34 5.66M20 5v6h-6"
|
|
112
|
+
stroke="currentColor"
|
|
113
|
+
strokeWidth={2}
|
|
114
|
+
strokeLinecap="round"
|
|
115
|
+
strokeLinejoin="round"
|
|
116
|
+
/>
|
|
117
|
+
</svg>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @example
|
|
123
|
+
* // page scope — the page's own data
|
|
124
|
+
* <PageBar sync={{ syncing, lastCompletedAt, onSync: refetch }} />
|
|
125
|
+
*
|
|
126
|
+
* // global scope — the shell header, icon-only at every width
|
|
127
|
+
* <BasaltShell globalActions={[{ key: 'sync', mobile: 'bar',
|
|
128
|
+
* node: <SyncButton scope="global" syncing={s} lastCompletedAt={t} onSync={run} /> }]} />
|
|
129
|
+
*/
|
|
130
|
+
export function SyncButton({
|
|
131
|
+
syncing,
|
|
132
|
+
lastCompletedAt,
|
|
133
|
+
onSync,
|
|
134
|
+
scope,
|
|
135
|
+
label = 'Sync',
|
|
136
|
+
error,
|
|
137
|
+
}: SyncButtonProps): ReactNode {
|
|
138
|
+
const age = useRelativeAge(lastCompletedAt)
|
|
139
|
+
// Wherever the label is not painted — every `global` mount, and a `page` mount below `sm` — the
|
|
140
|
+
// tooltip is where the age lives; an `error` outranks it everywhere. ONE tooltip, one mount.
|
|
141
|
+
const tooltip = error ?? age
|
|
142
|
+
// The accessible name in both forms. The visible label is `display: none` below `sm`, which takes
|
|
143
|
+
// it out of the accessibility tree, and an error must be readable without opening a tooltip.
|
|
144
|
+
const name = error !== undefined ? `${label} — ${error}` : label
|
|
145
|
+
|
|
146
|
+
const control = {
|
|
147
|
+
'data-basalt-sync-scope': scope,
|
|
148
|
+
'aria-busy': syncing,
|
|
149
|
+
// NOT the native `disabled`. That attribute drops focus to <body> the moment a keyboard user
|
|
150
|
+
// presses the button, so when the sync lands focus is gone and the next Tab restarts from
|
|
151
|
+
// the top of the document — and an `aria-busy` element you cannot focus announces nothing.
|
|
152
|
+
// `aria-disabled` + Mantine's `data-disabled` styling say the same thing and keep the button
|
|
153
|
+
// where the user left it; the handler does the actual refusing.
|
|
154
|
+
'aria-disabled': syncing,
|
|
155
|
+
'aria-label': name,
|
|
156
|
+
...(syncing && { 'data-disabled': true }),
|
|
157
|
+
...(error !== undefined && { color: 'red' }),
|
|
158
|
+
onClick: () => {
|
|
159
|
+
if (!syncing) onSync()
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
// `events` is not a default: Mantine's Tooltip opens on HOVER only, so on the stock settings a
|
|
165
|
+
// keyboard user and a phone user never reach the error — and the age's "the tooltip carries it
|
|
166
|
+
// below `sm`" claim in the CSS was false on touch. `aria-label` is the belt to that braces: a
|
|
167
|
+
// tooltip announces only while open, and an error has to be readable without one.
|
|
168
|
+
<Tooltip
|
|
169
|
+
label={tooltip ?? ''}
|
|
170
|
+
disabled={tooltip === undefined}
|
|
171
|
+
events={{ hover: true, focus: true, touch: true }}
|
|
172
|
+
withArrow
|
|
173
|
+
>
|
|
174
|
+
{scope === 'global' ? (
|
|
175
|
+
<ActionIcon variant="default" {...control}>
|
|
176
|
+
<IconSlot>
|
|
177
|
+
<RefreshGlyph spinning={syncing} />
|
|
178
|
+
</IconSlot>
|
|
179
|
+
</ActionIcon>
|
|
180
|
+
) : (
|
|
181
|
+
<Button
|
|
182
|
+
variant="default"
|
|
183
|
+
className={classes.pageButton}
|
|
184
|
+
leftSection={
|
|
185
|
+
<IconSlot>
|
|
186
|
+
<RefreshGlyph spinning={syncing} />
|
|
187
|
+
</IconSlot>
|
|
188
|
+
}
|
|
189
|
+
{...control}
|
|
190
|
+
>
|
|
191
|
+
<span className={classes.label}>{label}</span>
|
|
192
|
+
{age !== undefined && <span className={classes.age}>{age}</span>}
|
|
193
|
+
</Button>
|
|
194
|
+
)}
|
|
195
|
+
</Tooltip>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ToggleFilter` — a boolean field as one pill (`docs/CONTROLS-SPEC.md` §3). The only filter whose
|
|
3
|
+
* pill has no popover: there is nothing to choose, so the pill IS the control and a press flips it.
|
|
4
|
+
* In the sheet it becomes a `Switch` row, where a 44px row with an explicit on/off affordance reads
|
|
5
|
+
* better than a chip whose state is a border colour.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* // errorsOnly: field.boolean(false)
|
|
9
|
+
* <ToggleFilter field={filters.field.errorsOnly} label="Errors only" />
|
|
10
|
+
*/
|
|
11
|
+
import { Switch } from '@mantine/core'
|
|
12
|
+
import type { ReactNode } from 'react'
|
|
13
|
+
import type { BooleanField, FieldHandle } from '../state'
|
|
14
|
+
import { useFilterRegistration, useFilterSurface } from './filter-context'
|
|
15
|
+
import { FilterPill } from './filter-pill'
|
|
16
|
+
import { sheetRowClassNames } from './filter-sheet'
|
|
17
|
+
|
|
18
|
+
export type ToggleFilterProps = {
|
|
19
|
+
readonly field: FieldHandle<BooleanField>
|
|
20
|
+
readonly label: string
|
|
21
|
+
readonly icon?: ReactNode
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ToggleFilter({ field, label, icon }: ToggleFilterProps): ReactNode {
|
|
25
|
+
const [value, setValue] = field.use()
|
|
26
|
+
const surface = useFilterSurface()
|
|
27
|
+
useFilterRegistration(!field.isDefault(value), () => {
|
|
28
|
+
field.clear()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
if (surface === 'sheet') {
|
|
32
|
+
// The `Switch` IS the row — no wrapping div. `sheetRowClassNames` stretches its own `<label>`
|
|
33
|
+
// across the full 44px, so the whole row is the touch target rather than the middle 20px of it
|
|
34
|
+
// (C15). A wrapper div would put the height on a non-interactive box instead.
|
|
35
|
+
return (
|
|
36
|
+
<Switch
|
|
37
|
+
classNames={sheetRowClassNames}
|
|
38
|
+
checked={value}
|
|
39
|
+
label={label}
|
|
40
|
+
labelPosition="left"
|
|
41
|
+
onChange={(event) => {
|
|
42
|
+
setValue(event.currentTarget.checked)
|
|
43
|
+
}}
|
|
44
|
+
/>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<FilterPill
|
|
50
|
+
label={label}
|
|
51
|
+
active={value}
|
|
52
|
+
pressed={value}
|
|
53
|
+
hideGlyph
|
|
54
|
+
{...(icon !== undefined && { icon })}
|
|
55
|
+
onClick={() => {
|
|
56
|
+
setValue(!value)
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ViewTabs` — the page's or section's view switch (`docs/CONTROLS-SPEC.md` §3). A tab is store
|
|
3
|
+
* state like any filter (C3): it takes `field`, never `value`/`onChange`, so a deep link and a back
|
|
4
|
+
* navigation land on the same tab the user left.
|
|
5
|
+
*
|
|
6
|
+
* The responsive swap lives HERE, not at the call site (C9), and it is CSS — `visibleFrom` /
|
|
7
|
+
* `hiddenFrom`, one mount each, never a JS media query (which renders differently on the server than
|
|
8
|
+
* on the first client paint). Below `sm` a set of three or fewer stays a full-width
|
|
9
|
+
* `SegmentedControl`; past that a track cannot hold readable labels on a phone, so it becomes a
|
|
10
|
+
* `Select`.
|
|
11
|
+
*
|
|
12
|
+
* `only` is how a tab that exists on ONE viewport is declared instead of hand-rolling a second
|
|
13
|
+
* control: `'sm-up'` keeps it off the phone form, `'sm-down'` keeps it off the desktop form.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* <ViewTabs
|
|
17
|
+
* field={strength.field.tab}
|
|
18
|
+
* options={[
|
|
19
|
+
* { value: 'overview', label: 'Overview' },
|
|
20
|
+
* { value: 'history', label: 'History' },
|
|
21
|
+
* { value: 'train', label: 'Train', only: 'sm-down' },
|
|
22
|
+
* ]}
|
|
23
|
+
* />
|
|
24
|
+
*/
|
|
25
|
+
import { SegmentedControl, Select } from '@mantine/core'
|
|
26
|
+
import type { ReactNode } from 'react'
|
|
27
|
+
import type { EnumField, FieldHandle } from '../state'
|
|
28
|
+
import classes from './controls.module.css'
|
|
29
|
+
|
|
30
|
+
/** Past three options the phone form is a `Select`, not a track. */
|
|
31
|
+
const PHONE_TRACK_MAX = 3
|
|
32
|
+
|
|
33
|
+
export type ViewTabsOption<T extends string> = {
|
|
34
|
+
readonly value: T
|
|
35
|
+
readonly label: string
|
|
36
|
+
/** Render this option on ONE viewport only. Omitted → both. */
|
|
37
|
+
readonly only?: 'sm-up' | 'sm-down'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ViewTabsProps<T extends string> = {
|
|
41
|
+
readonly field: FieldHandle<EnumField<T>>
|
|
42
|
+
/** Defaults to `field.options` (every declared value, labelled by `store.labels()`). */
|
|
43
|
+
readonly options?: readonly ViewTabsOption<T>[]
|
|
44
|
+
/**
|
|
45
|
+
* The switch's accessible name. Both forms render without a visible Mantine `label` (a home has
|
|
46
|
+
* no room for one), so without this the desktop track is an unnamed `radiogroup` and the phone
|
|
47
|
+
* form an unnamed `combobox`.
|
|
48
|
+
*
|
|
49
|
+
* @default 'View'
|
|
50
|
+
*/
|
|
51
|
+
readonly label?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function ViewTabs<T extends string>({
|
|
55
|
+
field,
|
|
56
|
+
options,
|
|
57
|
+
label = 'View',
|
|
58
|
+
}: ViewTabsProps<T>): ReactNode {
|
|
59
|
+
const [value, setValue] = field.use()
|
|
60
|
+
const all: readonly ViewTabsOption<T>[] =
|
|
61
|
+
options ?? field.options.map((option) => ({ value: option.value as T, label: option.label }))
|
|
62
|
+
|
|
63
|
+
const desktop = all.filter((option) => option.only !== 'sm-down')
|
|
64
|
+
const phone = all.filter((option) => option.only !== 'sm-up')
|
|
65
|
+
// The segments/items are rendered from the option list, so `next` is always a declared value —
|
|
66
|
+
// the cast restores what the codec already guarantees.
|
|
67
|
+
const commit = (next: string): void => {
|
|
68
|
+
setValue(next as T)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<>
|
|
73
|
+
<SegmentedControl
|
|
74
|
+
aria-label={label}
|
|
75
|
+
visibleFrom="sm"
|
|
76
|
+
size="ctl"
|
|
77
|
+
value={value}
|
|
78
|
+
data={desktop.map((option) => ({ value: option.value, label: option.label }))}
|
|
79
|
+
onChange={commit}
|
|
80
|
+
/>
|
|
81
|
+
{phone.length <= PHONE_TRACK_MAX ? (
|
|
82
|
+
<SegmentedControl
|
|
83
|
+
aria-label={label}
|
|
84
|
+
hiddenFrom="sm"
|
|
85
|
+
size="ctl"
|
|
86
|
+
fullWidth
|
|
87
|
+
value={value}
|
|
88
|
+
data={phone.map((option) => ({ value: option.value, label: option.label }))}
|
|
89
|
+
onChange={commit}
|
|
90
|
+
/>
|
|
91
|
+
) : (
|
|
92
|
+
<Select
|
|
93
|
+
aria-label={label}
|
|
94
|
+
hiddenFrom="sm"
|
|
95
|
+
size="ctl"
|
|
96
|
+
// BOUNDED, not greedy — see `.phoneSelect`'s comment in `controls.module.css`. This is the
|
|
97
|
+
// control owning its own responsive form (law C9), which is also why it is not a `w` prop
|
|
98
|
+
// at the call site (C5, `basalt/control-size-literal`).
|
|
99
|
+
className={classes.phoneSelect}
|
|
100
|
+
value={value}
|
|
101
|
+
allowDeselect={false}
|
|
102
|
+
data={phone.map((option) => ({ value: option.value, label: option.label }))}
|
|
103
|
+
onChange={(next) => {
|
|
104
|
+
if (next !== null) commit(next)
|
|
105
|
+
}}
|
|
106
|
+
/>
|
|
107
|
+
)}
|
|
108
|
+
</>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DateRangePicker` — the `@mantine/dates` implementation of `RangeFilter`'s custom-window picker
|
|
3
|
+
* (`docs/CONTROLS-SPEC.md` §3). Its OWN subpath, and the reason is packaging, not taste:
|
|
4
|
+
* `@mantine/dates` is an optional peer, `basaltViteConfig` pre-bundles `@mantine/*`, and a consumer
|
|
5
|
+
* without the peer (linewatch) would break on an absent import — so nothing under `src/controls/**`
|
|
6
|
+
* may reference it, statically or lazily. `RangeFilter` takes the picker as a `customPicker` prop
|
|
7
|
+
* instead, which is the whole seam.
|
|
8
|
+
*
|
|
9
|
+
* `DatePicker type="range"` deals in ISO `YYYY-MM-DD` strings in Mantine 9 (`DateStringValue`), which
|
|
10
|
+
* is exactly what `field.range` stores — so there is no date parsing, formatting or timezone
|
|
11
|
+
* handling in this adapter, and that is deliberate.
|
|
12
|
+
*
|
|
13
|
+
* No `size` prop: the calendar is a POPOVER BODY, not a control in a home, so it carries none of the
|
|
14
|
+
* `ctl` tier's var set (`docs/CONTROLS-SPEC.md` §5 declares `-ctl` vars for Button/ActionIcon/Input/
|
|
15
|
+
* SegmentedControl/Combobox — not for the calendar), and pinning one would be a size with no
|
|
16
|
+
* declaration behind it.
|
|
17
|
+
*
|
|
18
|
+
* The consumer imports `@mantine/dates/styles.layer.css` alongside the other Mantine layer bundles
|
|
19
|
+
* (before `basalt-ui/styles.css`); this module ships no CSS of its own.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* import { DateRangePicker } from 'basalt-ui/controls-dates'
|
|
23
|
+
* <RangeFilter field={analytics.field.range} customPicker={DateRangePicker} />
|
|
24
|
+
*/
|
|
25
|
+
import { DatePicker } from '@mantine/dates'
|
|
26
|
+
import type { ReactNode } from 'react'
|
|
27
|
+
import type { RangeCustomPickerProps } from '../controls/range-filter'
|
|
28
|
+
|
|
29
|
+
export function DateRangePicker({ value, onChange }: RangeCustomPickerProps): ReactNode {
|
|
30
|
+
return (
|
|
31
|
+
<DatePicker
|
|
32
|
+
type="range"
|
|
33
|
+
allowSingleDateInRange
|
|
34
|
+
value={[value.from ?? null, value.to ?? null]}
|
|
35
|
+
onChange={([from, to]) => {
|
|
36
|
+
// A range is only a value once BOTH ends are picked — the first click of a new range hands
|
|
37
|
+
// back `[from, null]`, and committing that would write a half range onto the URL.
|
|
38
|
+
if (from !== null && to !== null) onChange({ from, to })
|
|
39
|
+
}}
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `basalt-ui/controls-dates` — the one export that needs `@mantine/dates`
|
|
3
|
+
* (`docs/CONTROLS-SPEC.md` §3). Split off `./controls` on purpose: `./controls` resolves and renders
|
|
4
|
+
* with no `@mantine/dates` installed, and this subpath is the only place that stops being true.
|
|
5
|
+
* Proven from outside the package by `scripts/pack-test.sh`.
|
|
6
|
+
*/
|
|
7
|
+
export { DateRangePicker } from './date-range-picker'
|
|
8
|
+
export type { RangeCustomPickerProps } from '../controls/range-filter'
|