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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C2 and C5 as COMPILER assertions. The claim "a basalt filter has no `value`/`onChange`/`size`" is
|
|
3
|
+
* only worth making if writing one fails the build, so each `@ts-expect-error` below is the actual
|
|
4
|
+
* enforcement: `bunx tsc --noEmit` fails if any of them stops being an error, which is what happens
|
|
5
|
+
* the moment someone widens a prop type back open.
|
|
6
|
+
*
|
|
7
|
+
* `tsc` covers `src/**` including test files, so this file is checked by the ordinary typecheck —
|
|
8
|
+
* there is nothing extra to run and nothing to remember.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from 'bun:test'
|
|
11
|
+
import type { ReactNode } from 'react'
|
|
12
|
+
import { createLocalStore, field } from '../state'
|
|
13
|
+
import { CompareFilter } from './compare-filter'
|
|
14
|
+
import { MultiSelectFilter } from './multi-select-filter'
|
|
15
|
+
import { RangeFilter } from './range-filter'
|
|
16
|
+
import { SearchFilter } from './search-filter'
|
|
17
|
+
import { SelectFilter } from './select-filter'
|
|
18
|
+
import { ToggleFilter } from './toggle-filter'
|
|
19
|
+
import { ViewTabs } from './view-tabs'
|
|
20
|
+
|
|
21
|
+
const local = createLocalStore({
|
|
22
|
+
key: 'props-contract',
|
|
23
|
+
fields: {
|
|
24
|
+
currency: field.enum(['USD', 'EUR'], 'USD'),
|
|
25
|
+
compare: field.enum(['none', 'previous', 'year'] as const, 'none'),
|
|
26
|
+
channels: field.multi(['web', 'email'], []),
|
|
27
|
+
range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }),
|
|
28
|
+
errorsOnly: field.boolean(false),
|
|
29
|
+
q: field.string(),
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/** A runtime catalogue — the shape both `options` props take (`FilterOption[]`). */
|
|
34
|
+
const PROJECTS = [
|
|
35
|
+
{ value: 'argo', label: 'Argo' },
|
|
36
|
+
{ value: 'linewatch', label: 'Linewatch', disabled: true },
|
|
37
|
+
]
|
|
38
|
+
const CHANNELS = [{ value: 'web', label: 'web · 1.2k' }]
|
|
39
|
+
|
|
40
|
+
/** Never rendered — the file's product is the type errors below, not a DOM assertion. */
|
|
41
|
+
function Rejected(): ReactNode {
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
{/* @ts-expect-error C2 — a filter has no `value`; the field owns it. */}
|
|
45
|
+
<SelectFilter field={local.field.currency} label="Currency" value="EUR" />
|
|
46
|
+
{/* @ts-expect-error C2 — nor an `onChange`; the write goes through the field. */}
|
|
47
|
+
<SelectFilter field={local.field.currency} label="Currency" onChange={() => {}} />
|
|
48
|
+
{/* @ts-expect-error C5 — the home sets the tier; a control carries no `size`. */}
|
|
49
|
+
<SelectFilter field={local.field.currency} label="Currency" size="xs" />
|
|
50
|
+
{/* @ts-expect-error C5 — nor a responsive override; the swap is the control's own. */}
|
|
51
|
+
<ViewTabs field={local.field.currency} visibleFrom="sm" />
|
|
52
|
+
{/* @ts-expect-error C2 — a tab is store state too. */}
|
|
53
|
+
<ViewTabs field={local.field.currency} value="USD" />
|
|
54
|
+
{/* @ts-expect-error a boolean field is not an enum field. */}
|
|
55
|
+
<ToggleFilter field={local.field.currency} label="Errors only" />
|
|
56
|
+
{/* @ts-expect-error a multi field is not an enum field. */}
|
|
57
|
+
<SelectFilter field={local.field.channels} label="Channels" />
|
|
58
|
+
{/* @ts-expect-error an enum field is not a range field. */}
|
|
59
|
+
<RangeFilter field={local.field.currency} />
|
|
60
|
+
{/* @ts-expect-error a string field is not a boolean field. */}
|
|
61
|
+
<ToggleFilter field={local.field.q} label="Errors only" />
|
|
62
|
+
{/* @ts-expect-error CompareFilter's enum is the three basalt names, not any enum. */}
|
|
63
|
+
<CompareFilter field={local.field.currency} />
|
|
64
|
+
{/* @ts-expect-error `label` is required on SelectFilter — the pill needs a name at rest. */}
|
|
65
|
+
<SelectFilter field={local.field.currency} />
|
|
66
|
+
{/* A string field declares no values, so `options` is the only possible row source — without
|
|
67
|
+
it the popover would render empty. Two overloads rather than one union prop type precisely
|
|
68
|
+
so the message names `options`; see `SelectFilter`'s own doc. */}
|
|
69
|
+
{/* @ts-expect-error a string field needs `options` — nothing else can supply the rows. */}
|
|
70
|
+
<SelectFilter field={local.field.q} label="Project" />
|
|
71
|
+
{/* @ts-expect-error `options` does not widen a MULTI field into a single-select either. */}
|
|
72
|
+
<SelectFilter field={local.field.channels} label="Channels" options={PROJECTS} />
|
|
73
|
+
</>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The same call sites, spelled correctly — so a type error above cannot be a false positive. */
|
|
78
|
+
function Accepted(): ReactNode {
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
<SelectFilter field={local.field.currency} label="Currency" clearable />
|
|
82
|
+
{/* A runtime catalogue over a closed enum — `options` is optional there. */}
|
|
83
|
+
<SelectFilter
|
|
84
|
+
field={local.field.currency}
|
|
85
|
+
label="Currency"
|
|
86
|
+
options={[{ value: 'USD', label: 'USD · 1.00' }]}
|
|
87
|
+
/>
|
|
88
|
+
{/* A string field WITH options — the id set an enum cannot close over. */}
|
|
89
|
+
<SelectFilter field={local.field.q} label="Project" options={PROJECTS} />
|
|
90
|
+
<MultiSelectFilter field={local.field.channels} label="All channels" options={CHANNELS} />
|
|
91
|
+
<CompareFilter field={local.field.compare} />
|
|
92
|
+
<MultiSelectFilter field={local.field.channels} label="All channels" noun="channels" />
|
|
93
|
+
<RangeFilter field={local.field.range} />
|
|
94
|
+
<ToggleFilter field={local.field.errorsOnly} label="Errors only" />
|
|
95
|
+
<SearchFilter field={local.field.q} placeholder="Search" />
|
|
96
|
+
<ViewTabs field={local.field.currency} />
|
|
97
|
+
</>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('props contract', () => {
|
|
102
|
+
test('both fixtures exist — tsc, not bun, is the assertion (see this file header)', () => {
|
|
103
|
+
expect(typeof Rejected).toBe('function')
|
|
104
|
+
expect(typeof Accepted).toBe('function')
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RangeFilter` — the time-window filter (`docs/CONTROLS-SPEC.md` §3). One pill over a
|
|
3
|
+
* `field.range`, which owns THREE URL params (preset + `from` + `to`), so a consumer's existing deep
|
|
4
|
+
* links and loaders keep their shape and `field.toWindow()` replaces every hand-rolled
|
|
5
|
+
* `presetToParams`.
|
|
6
|
+
*
|
|
7
|
+
* In the POPOVER the presets render as a `SegmentedControl` — vertical past four options, where a
|
|
8
|
+
* horizontal track would either overflow the popover or shrink each label below reading size.
|
|
9
|
+
* Numeric preset labels (`7d` / `30d`) get `data-numeric`, which is the mono treatment
|
|
10
|
+
* `theme/segmented-control.module.css` owns; that attribute is what retired the per-consumer
|
|
11
|
+
* `theme-allow` + inline `fontFamily` hack (C7).
|
|
12
|
+
*
|
|
13
|
+
* In the SHEET they render as a `SheetOptionList`, and the custom picker sits behind a
|
|
14
|
+
* `Custom range…` disclosure row. A vertical `SegmentedControl` stretched across a bottom drawer
|
|
15
|
+
* read as a broken control rather than a choice (see `SheetOptionList`'s doc), and an unconditional
|
|
16
|
+
* calendar pushed every OTHER filter in the sheet below the fold — the sheet holds all of them, and
|
|
17
|
+
* `RangeFilter` is one.
|
|
18
|
+
*
|
|
19
|
+
* The custom picker is INJECTED, never imported: `basalt-ui/controls-dates` holds the
|
|
20
|
+
* `@mantine/dates` implementation, and this module must resolve for a consumer who has no
|
|
21
|
+
* `@mantine/dates` at all (`docs/CONTROLS-SPEC.md` §3 — `basaltViteConfig`'s `optimizeDeps.include`
|
|
22
|
+
* for `@mantine/*` would break on an absent peer).
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* import { DateRangePicker } from 'basalt-ui/controls-dates'
|
|
26
|
+
* // range: field.range({ presets: ['7d', '30d', '90d', 'ytd'], fallback: '30d', custom: true })
|
|
27
|
+
* <RangeFilter field={analytics.field.range} customPicker={DateRangePicker} />
|
|
28
|
+
*/
|
|
29
|
+
import { SegmentedControl, Stack } from '@mantine/core'
|
|
30
|
+
import { useState } from 'react'
|
|
31
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
32
|
+
import type { FieldHandle, RangeField, RangeValue } from '../state'
|
|
33
|
+
import classes from './controls.module.css'
|
|
34
|
+
import { useFilterRegistration, useFilterSurface } from './filter-context'
|
|
35
|
+
import { FilterPill } from './filter-pill'
|
|
36
|
+
import { CalendarGlyph } from './glyphs'
|
|
37
|
+
import { SheetDisclosure, SheetField, SheetOptionList, useControlName } from './filter-sheet'
|
|
38
|
+
|
|
39
|
+
/** Past four presets the track goes vertical. */
|
|
40
|
+
const VERTICAL_FROM = 5
|
|
41
|
+
/** `7d`, `30d`, `24h`, `90` — a label the mono numeric treatment is for. */
|
|
42
|
+
const NUMERIC_LABEL = /^\d/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The contract a custom-range picker satisfies — `basalt-ui/controls-dates`' `DateRangePicker`, or a
|
|
46
|
+
* consumer's own. Dates are ISO (`YYYY-MM-DD`), the same shape `field.range` stores and
|
|
47
|
+
* `toWindow()` emits, so the picker never learns the store's encoding.
|
|
48
|
+
*/
|
|
49
|
+
export type RangeCustomPickerProps = {
|
|
50
|
+
readonly value: { readonly from?: string | undefined; readonly to?: string | undefined }
|
|
51
|
+
readonly onChange: (next: { from: string; to: string }) => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type RangeFilterProps<P extends string> = {
|
|
55
|
+
readonly field: FieldHandle<RangeField<P>>
|
|
56
|
+
/** Leading pill icon. Defaults to a calendar glyph — a range filter reads as a date control, so
|
|
57
|
+
* the glyph is part of the control's identity, not a per-call-site decision. */
|
|
58
|
+
readonly icon?: ReactNode
|
|
59
|
+
/** Rendered when the field declares `custom: true`. Omitted → presets only. */
|
|
60
|
+
readonly customPicker?: ComponentType<RangeCustomPickerProps>
|
|
61
|
+
/** Sheet-form heading. The pill's own label is the VALUE, which is what a bar reads. @default 'Range' */
|
|
62
|
+
readonly label?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function RangeFilter<P extends string>({
|
|
66
|
+
field,
|
|
67
|
+
icon,
|
|
68
|
+
customPicker,
|
|
69
|
+
label = 'Range',
|
|
70
|
+
}: RangeFilterProps<P>): ReactNode {
|
|
71
|
+
const [value, setValue] = field.use()
|
|
72
|
+
const surface = useFilterSurface()
|
|
73
|
+
const isDefault = field.isDefault(value)
|
|
74
|
+
useFilterRegistration(!isDefault, () => {
|
|
75
|
+
setValue(field.fallback)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const options = field.options
|
|
79
|
+
const presets = options.filter((option) => option.value !== 'custom')
|
|
80
|
+
const allowsCustom = options.length !== presets.length
|
|
81
|
+
const numeric = presets.length > 0 && presets.every((option) => NUMERIC_LABEL.test(option.label))
|
|
82
|
+
const inSheet = surface === 'sheet'
|
|
83
|
+
const Picker = customPicker
|
|
84
|
+
// The track is a `radiogroup`; without this it announces unnamed, and the pill it hangs off reads
|
|
85
|
+
// the VALUE (`30d`), not the filter.
|
|
86
|
+
const { labelId, nameProps } = useControlName(label, inSheet)
|
|
87
|
+
// Expanded when the field ALREADY holds a custom window — a reader who deep-linked one must see
|
|
88
|
+
// it without hunting for the row that holds it. Local, because it is an overlay's open flag and
|
|
89
|
+
// not a filter value (the same line `FilterPill`'s `opened` state draws).
|
|
90
|
+
const [customOpen, setCustomOpen] = useState(value.preset === 'custom')
|
|
91
|
+
const showPicker = allowsCustom && Picker !== undefined
|
|
92
|
+
|
|
93
|
+
if (inSheet) {
|
|
94
|
+
return (
|
|
95
|
+
<SheetField label={label} labelId={labelId}>
|
|
96
|
+
<SheetOptionList
|
|
97
|
+
mode="single"
|
|
98
|
+
labelId={labelId}
|
|
99
|
+
selected={[value.preset]}
|
|
100
|
+
options={presets.map((option) => ({ value: option.value, label: option.label }))}
|
|
101
|
+
onToggle={(next) => {
|
|
102
|
+
setCustomOpen(false)
|
|
103
|
+
// Rendered from `field.options`, so `next` is always a declared preset — the cast
|
|
104
|
+
// restores what the codec already guarantees.
|
|
105
|
+
setValue({ preset: next } as RangeValue<P | 'custom'>)
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
{showPicker && (
|
|
109
|
+
<SheetDisclosure
|
|
110
|
+
label="Custom range…"
|
|
111
|
+
expanded={customOpen}
|
|
112
|
+
onToggle={() => {
|
|
113
|
+
setCustomOpen((open) => !open)
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<Picker
|
|
117
|
+
value={{ from: value.from, to: value.to }}
|
|
118
|
+
onChange={({ from, to }) => {
|
|
119
|
+
setValue({ preset: 'custom', from, to } as RangeValue<P | 'custom'>)
|
|
120
|
+
}}
|
|
121
|
+
/>
|
|
122
|
+
</SheetDisclosure>
|
|
123
|
+
)}
|
|
124
|
+
</SheetField>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const body = (
|
|
129
|
+
<Stack gap="xs">
|
|
130
|
+
<SegmentedControl
|
|
131
|
+
{...nameProps}
|
|
132
|
+
size="ctl"
|
|
133
|
+
orientation={presets.length >= VERTICAL_FROM ? 'vertical' : 'horizontal'}
|
|
134
|
+
value={value.preset}
|
|
135
|
+
data={presets.map((option) => ({ value: option.value, label: option.label }))}
|
|
136
|
+
{...(numeric && { 'data-numeric': true })}
|
|
137
|
+
onChange={(next) => {
|
|
138
|
+
// The segments are rendered from `field.options`, so `next` is always a declared preset —
|
|
139
|
+
// the cast restores what the codec already guarantees.
|
|
140
|
+
setValue({ preset: next } as RangeValue<P | 'custom'>)
|
|
141
|
+
}}
|
|
142
|
+
/>
|
|
143
|
+
{showPicker && (
|
|
144
|
+
<Picker
|
|
145
|
+
value={{ from: value.from, to: value.to }}
|
|
146
|
+
onChange={({ from, to }) => {
|
|
147
|
+
setValue({ preset: 'custom', from, to } as RangeValue<P | 'custom'>)
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
</Stack>
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<FilterPill
|
|
156
|
+
label={rangeLabel(value, options)}
|
|
157
|
+
active={!isDefault}
|
|
158
|
+
numeric={numeric && value.preset !== 'custom'}
|
|
159
|
+
icon={icon ?? <CalendarGlyph />}
|
|
160
|
+
>
|
|
161
|
+
<div className={classes.optionList}>{body}</div>
|
|
162
|
+
</FilterPill>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const DAY_FORMAT = new Intl.DateTimeFormat('en-US', {
|
|
167
|
+
month: 'short',
|
|
168
|
+
day: 'numeric',
|
|
169
|
+
// ISO date strings parse as UTC midnight; formatting them in the runtime's zone would render the
|
|
170
|
+
// previous day west of Greenwich — and differently on server than on client (a hydration
|
|
171
|
+
// mismatch), the same hazard `formatArticleDate` avoids.
|
|
172
|
+
timeZone: 'UTC',
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
/** `Last 30 days`, or `Mar 1 – Mar 14` for a custom window. */
|
|
176
|
+
function rangeLabel(
|
|
177
|
+
value: RangeValue<string>,
|
|
178
|
+
options: readonly { value: string; label: string }[],
|
|
179
|
+
): string {
|
|
180
|
+
if (value.preset === 'custom' && value.from !== undefined && value.to !== undefined) {
|
|
181
|
+
return `${DAY_FORMAT.format(new Date(value.from))} – ${DAY_FORMAT.format(new Date(value.to))}`
|
|
182
|
+
}
|
|
183
|
+
return options.find((option) => option.value === value.preset)?.label ?? value.preset
|
|
184
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SearchFilter` — free text over a `field.string` (`docs/CONTROLS-SPEC.md` §3). The one filter that
|
|
3
|
+
* is not a pill: a text box states its own value, so wrapping it in a chip + popover would cost a
|
|
4
|
+
* press per keystroke session for nothing.
|
|
5
|
+
*
|
|
6
|
+
* Keystrokes are DEBOUNCED (200ms) before they reach the field. `field.string()` already writes with
|
|
7
|
+
* `history: 'replace'`, so an un-debounced input would still not stack history entries — what the
|
|
8
|
+
* debounce buys is one navigation per phrase instead of one per character, which is the difference
|
|
9
|
+
* between a typed query and a re-rendered route tree eight times over.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // q: field.string({ max: 120 })
|
|
13
|
+
* <SearchFilter field={table.field.q} placeholder="Search pages" />
|
|
14
|
+
*/
|
|
15
|
+
import { TextInput } from '@mantine/core'
|
|
16
|
+
import { useEffect, useRef, useState } from 'react'
|
|
17
|
+
import type { ReactNode } from 'react'
|
|
18
|
+
import type { FieldHandle, StringField } from '../state'
|
|
19
|
+
import { useFilterRegistration, useFilterSurface } from './filter-context'
|
|
20
|
+
import { SheetField, useControlName } from './filter-sheet'
|
|
21
|
+
|
|
22
|
+
/** One navigation per typed phrase, not per keystroke. */
|
|
23
|
+
const DEBOUNCE_MS = 200
|
|
24
|
+
|
|
25
|
+
export type SearchFilterProps = {
|
|
26
|
+
readonly field: FieldHandle<StringField>
|
|
27
|
+
readonly placeholder?: string
|
|
28
|
+
/** Sheet-form heading. @default 'Search' */
|
|
29
|
+
readonly label?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function SearchFilter({
|
|
33
|
+
field,
|
|
34
|
+
placeholder,
|
|
35
|
+
label = 'Search',
|
|
36
|
+
}: SearchFilterProps): ReactNode {
|
|
37
|
+
const [value, setValue] = field.use()
|
|
38
|
+
const surface = useFilterSurface()
|
|
39
|
+
const draft = useDebouncedField(value, setValue)
|
|
40
|
+
useFilterRegistration(!field.isDefault(value), () => {
|
|
41
|
+
setValue(field.fallback)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// `placeholder` is optional and, even when set, stops being the name the moment the user types —
|
|
45
|
+
// so the name comes from `label`, or from the sheet heading when there is one.
|
|
46
|
+
const { labelId, nameProps } = useControlName(label, surface === 'sheet')
|
|
47
|
+
|
|
48
|
+
const input = (
|
|
49
|
+
<TextInput
|
|
50
|
+
{...nameProps}
|
|
51
|
+
size="ctl"
|
|
52
|
+
value={draft.value}
|
|
53
|
+
leftSection={<SearchIcon />}
|
|
54
|
+
{...(placeholder !== undefined && { placeholder })}
|
|
55
|
+
onChange={(event) => {
|
|
56
|
+
draft.set(event.currentTarget.value)
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if (surface === 'sheet') {
|
|
62
|
+
return (
|
|
63
|
+
<SheetField label={label} labelId={labelId}>
|
|
64
|
+
{input}
|
|
65
|
+
</SheetField>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
return input
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A local draft that lands on the field `DEBOUNCE_MS` after the last keystroke, and that follows the
|
|
73
|
+
* field when it changes from OUTSIDE the input (`Reset all`, a back navigation, a deep link).
|
|
74
|
+
* `seen` is what separates the two directions: it holds the last value this hook itself committed,
|
|
75
|
+
* so an incoming change it did not cause is the only thing that overwrites the draft.
|
|
76
|
+
*/
|
|
77
|
+
function useDebouncedField(
|
|
78
|
+
value: string,
|
|
79
|
+
setValue: (next: string) => void,
|
|
80
|
+
): { value: string; set: (next: string) => void } {
|
|
81
|
+
const [draft, setDraft] = useState(value)
|
|
82
|
+
const seen = useRef(value)
|
|
83
|
+
const commit = useRef(setValue)
|
|
84
|
+
commit.current = setValue
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (seen.current === value) return
|
|
88
|
+
seen.current = value
|
|
89
|
+
setDraft(value)
|
|
90
|
+
}, [value])
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (draft === value) return
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
seen.current = draft
|
|
96
|
+
commit.current(draft)
|
|
97
|
+
}, DEBOUNCE_MS)
|
|
98
|
+
return () => {
|
|
99
|
+
clearTimeout(timer)
|
|
100
|
+
}
|
|
101
|
+
}, [draft, value])
|
|
102
|
+
|
|
103
|
+
return { value: draft, set: setDraft }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function SearchIcon(): ReactNode {
|
|
107
|
+
return (
|
|
108
|
+
<svg
|
|
109
|
+
width={16}
|
|
110
|
+
height={16}
|
|
111
|
+
viewBox="0 0 24 24"
|
|
112
|
+
fill="none"
|
|
113
|
+
stroke="currentColor"
|
|
114
|
+
strokeWidth={2}
|
|
115
|
+
strokeLinecap="round"
|
|
116
|
+
strokeLinejoin="round"
|
|
117
|
+
aria-hidden
|
|
118
|
+
>
|
|
119
|
+
<circle cx="11" cy="11" r="7" />
|
|
120
|
+
<path d="M21 21l-4.35 -4.35" />
|
|
121
|
+
</svg>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SelectFilter` — one choice, one pill (`docs/CONTROLS-SPEC.md` §3). Takes `field`, never
|
|
3
|
+
* `value`/`onChange`/`size` (C2/C5): the handle owns the URL write, the localStorage mirror and the
|
|
4
|
+
* option labels (`store.labels()`), so the call site is the field plus its human name.
|
|
5
|
+
*
|
|
6
|
+
* `options` is the escape hatch for a catalogue that only exists at runtime — the label carries live
|
|
7
|
+
* data (`EUR · 1.08`), or the id set is fetched rather than declared. It OVERRIDES `field.options`
|
|
8
|
+
* at render, and it is what makes the second `field` shape legal: a `StringField` handle plus
|
|
9
|
+
* `options` covers an id set no enum could close over (a project picker, a device list), while a
|
|
10
|
+
* `StringField` handle WITHOUT `options` stays a type error — nothing would supply the rows, so the
|
|
11
|
+
* filter would render an empty popover.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* <SelectFilter field={analytics.field.currency} label="Currency" clearable />
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* // A runtime catalogue over a closed enum — same field, live labels.
|
|
18
|
+
* <SelectFilter
|
|
19
|
+
* field={analytics.field.currency}
|
|
20
|
+
* label="Currency"
|
|
21
|
+
* options={rates.map((r) => ({ value: r.code, label: `${r.code} · ${r.rate}` }))}
|
|
22
|
+
* />
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* // An id set the enum cannot close over — a string field, options mandatory.
|
|
26
|
+
* <SelectFilter
|
|
27
|
+
* field={dashboard.field.projectId}
|
|
28
|
+
* label="Project"
|
|
29
|
+
* options={projects.map((p) => ({ value: p.id, label: p.name, disabled: p.archived }))}
|
|
30
|
+
* />
|
|
31
|
+
*/
|
|
32
|
+
import type { ReactNode } from 'react'
|
|
33
|
+
import type { EnumField, FieldHandle, StringField } from '../state'
|
|
34
|
+
import { EnumFilter } from './enum-filter'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One row of a runtime catalogue. Wider than the store's own `FieldOption` by exactly one member:
|
|
38
|
+
* `disabled` renders the row and refuses the selection, which a declared enum expresses by leaving
|
|
39
|
+
* the value out and a live catalogue cannot (an archived project still labels the rows already
|
|
40
|
+
* pointing at it).
|
|
41
|
+
*/
|
|
42
|
+
export type FilterOption = {
|
|
43
|
+
readonly value: string
|
|
44
|
+
readonly label: string
|
|
45
|
+
readonly disabled?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type SelectFilterCommon = {
|
|
49
|
+
readonly label: string
|
|
50
|
+
readonly icon?: ReactNode
|
|
51
|
+
/** Adds a `Clear` action that writes the field back to its fallback. */
|
|
52
|
+
readonly clearable?: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A closed enum field: `options` relabels rows the field already declares, so it is optional. */
|
|
56
|
+
export type SelectFilterEnumProps<T extends string> = SelectFilterCommon & {
|
|
57
|
+
readonly field: FieldHandle<EnumField<T>>
|
|
58
|
+
/** Overrides `field.options` at render — a runtime catalogue, labels included. */
|
|
59
|
+
readonly options?: readonly FilterOption[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A string field: it declares no values, so `options` is the only row source and is required. */
|
|
63
|
+
export type SelectFilterStringProps = SelectFilterCommon & {
|
|
64
|
+
readonly field: FieldHandle<StringField>
|
|
65
|
+
/** Required here — see this type's doc. */
|
|
66
|
+
readonly options: readonly FilterOption[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type SelectFilterProps<T extends string> = SelectFilterEnumProps<T> | SelectFilterStringProps
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* TWO OVERLOADS, not one conditional prop type, and the reason is the error text. A union prop type
|
|
73
|
+
* reports only the constituent it likes best — for a `StringField` handle passed without `options`
|
|
74
|
+
* that is the enum branch, so the message reads `Type '"string"' is not assignable to type '"enum"'`
|
|
75
|
+
* and never mentions the prop that would fix it. The overload form reports both halves, ending in
|
|
76
|
+
* `Property 'options' is missing`, which is the actual instruction.
|
|
77
|
+
*/
|
|
78
|
+
export function SelectFilter<T extends string>(props: SelectFilterEnumProps<T>): ReactNode
|
|
79
|
+
export function SelectFilter(props: SelectFilterStringProps): ReactNode
|
|
80
|
+
export function SelectFilter<T extends string>(props: SelectFilterProps<T>): ReactNode {
|
|
81
|
+
const { field, ...rest } = props
|
|
82
|
+
|
|
83
|
+
// Two arms, one body — and they are not the same code to the compiler. `field.kind` is a NESTED
|
|
84
|
+
// discriminant, which narrows `field` but never `props`, so each arm is what fixes the shared
|
|
85
|
+
// body's value type: `string` for the string field, `T` for the enum. Written out rather than
|
|
86
|
+
// cast, so widening either branch fails here instead of at a consumer's call site.
|
|
87
|
+
if (field.kind === 'string') return <EnumFilter {...rest} field={field} />
|
|
88
|
+
return <EnumFilter {...rest} field={field} />
|
|
89
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `SyncButton` chrome — the rotating glyph and the desktop-only age suffix.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
.spin {
|
|
6
|
+
animation: basalt-sync-spin 900ms linear infinite;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
@keyframes basalt-sync-spin {
|
|
10
|
+
to {
|
|
11
|
+
transform: rotate(360deg);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
* Reduced motion drops the rotation, never the icon: the `aria-busy`/`aria-disabled` pair is what
|
|
17
|
+
* actually announces "syncing", so removing the spin costs no information.
|
|
18
|
+
*/
|
|
19
|
+
@media (prefers-reduced-motion: reduce) {
|
|
20
|
+
.spin {
|
|
21
|
+
animation: none;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* The age rides beside the label on desktop and disappears below `sm`, where the tooltip carries
|
|
27
|
+
* it instead — one mount, a CSS-only swap (law C9).
|
|
28
|
+
*/
|
|
29
|
+
.age {
|
|
30
|
+
margin-inline-start: var(--vx-space-control-gap);
|
|
31
|
+
color: var(--vx-muted);
|
|
32
|
+
font-family: var(--basalt-font-mono);
|
|
33
|
+
font-size: var(--vx-text-xs);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/*
|
|
37
|
+
* `scope: 'page'` below `sm`: the label and the age go, the glyph stays, and the button collapses
|
|
38
|
+
* to the square icon form (spec §3, "icon-only, age in tooltip"). Hidden, not unmounted — a second
|
|
39
|
+
* mount under `hiddenFrom` is exactly the responsive twin law C9 forbids, which is also why the
|
|
40
|
+
* accessible name lives on `aria-label` rather than in this text.
|
|
41
|
+
*
|
|
42
|
+
* Three declarations, because Mantine's Button spends its width in three places: `--button-padding-x`
|
|
43
|
+
* (the inline padding, which `[data-with-left-section]` divides by 1.5), the left section's own
|
|
44
|
+
* `margin-inline-end` (a fixed `--mantine-spacing-xs`, not a variable, so it takes a rule), and the
|
|
45
|
+
* width itself, pinned to the `ctl` height so the box is square. This module is unlayered, so it
|
|
46
|
+
* outranks `@layer mantine` regardless of specificity.
|
|
47
|
+
*/
|
|
48
|
+
@media (max-width: 47.99375em) {
|
|
49
|
+
.label,
|
|
50
|
+
.age {
|
|
51
|
+
display: none;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.pageButton {
|
|
55
|
+
--button-padding-x: 0;
|
|
56
|
+
|
|
57
|
+
width: calc(var(--vx-space-control-height-ctl, 1.875rem) * var(--mantine-scale, 1));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.pageButton :global(.mantine-Button-section[data-position='left']) {
|
|
61
|
+
margin-inline-end: 0;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed CSS-module declaration (the S4 CSS-modules path). Named string props — not an index
|
|
3
|
+
* signature — so dot access stays verbatim under the package's strict flags.
|
|
4
|
+
*/
|
|
5
|
+
declare const classes: {
|
|
6
|
+
readonly spin: string
|
|
7
|
+
readonly age: string
|
|
8
|
+
readonly label: string
|
|
9
|
+
readonly pageButton: string
|
|
10
|
+
}
|
|
11
|
+
export default classes
|