@exxatdesignux/ui 0.2.19 → 0.4.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/CHANGELOG.md +662 -7
- package/bin/sync-extras.mjs +116 -29
- package/consumer-extras/README.md +42 -7
- package/consumer-extras/cursor-rules/exxat-accessibility.mdc +39 -0
- package/consumer-extras/cursor-rules/exxat-board-cards.mdc +26 -0
- package/consumer-extras/cursor-rules/exxat-breadcrumbs-no-back.mdc +21 -0
- package/consumer-extras/cursor-rules/exxat-card-vs-list-rows.mdc +21 -0
- package/consumer-extras/cursor-rules/exxat-centralized-list-dataset.mdc +44 -0
- package/consumer-extras/cursor-rules/exxat-collaboration-access.mdc +32 -0
- package/consumer-extras/cursor-rules/exxat-command-menu.mdc +22 -0
- package/consumer-extras/cursor-rules/exxat-dashboard-view-charts.mdc +53 -0
- package/consumer-extras/cursor-rules/exxat-data-tables.mdc +43 -0
- package/consumer-extras/cursor-rules/exxat-dedicated-search-surfaces.mdc +25 -0
- package/consumer-extras/cursor-rules/exxat-drawer-vs-dialog.mdc +22 -0
- package/consumer-extras/cursor-rules/exxat-ds-agents.mdc +56 -0
- package/consumer-extras/cursor-rules/exxat-fontawesome-icons.mdc +31 -0
- package/consumer-extras/cursor-rules/exxat-kbd-shortcuts.mdc +100 -0
- package/consumer-extras/cursor-rules/exxat-kpi-flat-band.mdc +28 -0
- package/consumer-extras/cursor-rules/exxat-kpi-max-four.mdc +21 -0
- package/consumer-extras/cursor-rules/exxat-kpi-trends.mdc +31 -0
- package/consumer-extras/cursor-rules/exxat-library-hub-header.mdc +28 -0
- package/consumer-extras/cursor-rules/exxat-list-page-connected-views.mdc +24 -0
- package/consumer-extras/cursor-rules/exxat-list-page-view-shells.mdc +31 -0
- package/consumer-extras/cursor-rules/exxat-mono-ids.mdc +30 -0
- package/consumer-extras/cursor-rules/exxat-no-slds-leakage.mdc +78 -0
- package/consumer-extras/cursor-rules/exxat-no-toast.mdc +25 -0
- package/consumer-extras/cursor-rules/exxat-page-vs-drawer.mdc +23 -0
- package/consumer-extras/cursor-rules/exxat-person-identity-display.mdc +47 -0
- package/consumer-extras/cursor-rules/exxat-primary-nav-secondary-panel.mdc +52 -0
- package/consumer-extras/cursor-rules/exxat-reuse-before-custom.mdc +34 -0
- package/consumer-extras/cursor-rules/exxat-table-properties-drawer.mdc +77 -0
- package/consumer-extras/cursor-rules/exxat-token-discipline.mdc +103 -0
- package/consumer-extras/cursor-skills/exxat-accessibility/SKILL.md +1 -1
- package/consumer-extras/cursor-skills/exxat-board-cards/SKILL.md +3 -3
- package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +5 -16
- package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -3
- package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +2 -2
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +19 -34
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +1 -1
- package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +1 -1
- package/consumer-extras/cursor-skills/exxat-list-page-view-shells/SKILL.md +1 -1
- package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +4 -4
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +10 -12
- package/consumer-extras/cursor-skills/exxat-token-economy/SKILL.md +277 -0
- package/consumer-extras/handbook/HANDBOOK.md +187 -0
- package/consumer-extras/handbook/glossary.md +58 -0
- package/consumer-extras/handbook/reference-implementations.md +153 -0
- package/consumer-extras/handbook/voice-and-tone.md +262 -0
- package/consumer-extras/patterns/collaboration-access-pattern.md +7 -7
- package/consumer-extras/patterns/command-menu-pattern.md +1 -1
- package/consumer-extras/patterns/consumer-upgrade-checklist.md +0 -20
- package/consumer-extras/patterns/data-views-pattern.md +31 -66
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +2 -2
- package/consumer-extras/patterns/shell-surface-elevation-pattern.md +3 -5
- package/dist/components/data-table/filter-date-calendar.d.ts +10 -0
- package/dist/components/data-table/filter-date-calendar.js +280 -0
- package/dist/components/data-table/filter-date-calendar.js.map +1 -0
- package/dist/components/data-table/filter-text-value-input.d.ts +15 -0
- package/dist/components/data-table/filter-text-value-input.js +561 -0
- package/dist/components/data-table/filter-text-value-input.js.map +1 -0
- package/dist/components/data-table/index.d.ts +45 -0
- package/dist/components/data-table/index.js +3085 -0
- package/dist/components/data-table/index.js.map +1 -0
- package/dist/components/data-table/pagination.d.ts +28 -0
- package/dist/components/data-table/pagination.js +3264 -0
- package/dist/components/data-table/pagination.js.map +1 -0
- package/dist/components/data-table/types.d.ts +84 -0
- package/dist/components/data-table/types.js +3 -0
- package/dist/components/data-table/types.js.map +1 -0
- package/dist/components/data-table/use-table-state.d.ts +116 -0
- package/dist/components/data-table/use-table-state.js +670 -0
- package/dist/components/data-table/use-table-state.js.map +1 -0
- package/dist/components/data-views/board-card-primitives.d.ts +22 -0
- package/dist/components/data-views/board-card-primitives.js +84 -0
- package/dist/components/data-views/board-card-primitives.js.map +1 -0
- package/dist/components/data-views/data-row-list.d.ts +33 -0
- package/dist/components/data-views/data-row-list.js +106 -0
- package/dist/components/data-views/data-row-list.js.map +1 -0
- package/dist/components/data-views/finder-panel-view.d.ts +54 -0
- package/dist/components/data-views/finder-panel-view.js +388 -0
- package/dist/components/data-views/finder-panel-view.js.map +1 -0
- package/dist/components/data-views/folder-grid-view.d.ts +22 -0
- package/dist/components/data-views/folder-grid-view.js +58 -0
- package/dist/components/data-views/folder-grid-view.js.map +1 -0
- package/dist/components/data-views/hub-table.d.ts +173 -0
- package/dist/components/data-views/hub-table.js +5783 -0
- package/dist/components/data-views/hub-table.js.map +1 -0
- package/dist/components/data-views/index.d.ts +27 -0
- package/dist/components/data-views/index.js +6797 -0
- package/dist/components/data-views/index.js.map +1 -0
- package/dist/components/data-views/list-page-board-card.d.ts +72 -0
- package/dist/components/data-views/list-page-board-card.js +264 -0
- package/dist/components/data-views/list-page-board-card.js.map +1 -0
- package/dist/components/data-views/list-page-board-template.d.ts +24 -0
- package/dist/components/data-views/list-page-board-template.js +137 -0
- package/dist/components/data-views/list-page-board-template.js.map +1 -0
- package/dist/components/data-views/list-page-connected-view-body.d.ts +19 -0
- package/dist/components/data-views/list-page-connected-view-body.js +116 -0
- package/dist/components/data-views/list-page-connected-view-body.js.map +1 -0
- package/dist/components/data-views/list-page-split-details-placeholder.d.ts +14 -0
- package/dist/components/data-views/list-page-split-details-placeholder.js +38 -0
- package/dist/components/data-views/list-page-split-details-placeholder.js.map +1 -0
- package/dist/components/data-views/list-page-split-hub-chrome.d.ts +17 -0
- package/dist/components/data-views/list-page-split-hub-chrome.js +54 -0
- package/dist/components/data-views/list-page-split-hub-chrome.js.map +1 -0
- package/dist/components/data-views/list-page-split-hub-tokens.d.ts +12 -0
- package/dist/components/data-views/list-page-split-hub-tokens.js +8 -0
- package/dist/components/data-views/list-page-split-hub-tokens.js.map +1 -0
- package/dist/components/data-views/list-page-tree-column-header.d.ts +15 -0
- package/dist/components/data-views/list-page-tree-column-header.js +22 -0
- package/dist/components/data-views/list-page-tree-column-header.js.map +1 -0
- package/dist/components/data-views/list-page-tree-panel-shell.d.ts +25 -0
- package/dist/components/data-views/list-page-tree-panel-shell.js +146 -0
- package/dist/components/data-views/list-page-tree-panel-shell.js.map +1 -0
- package/dist/components/data-views/os-folder-glyph.d.ts +35 -0
- package/dist/components/data-views/os-folder-glyph.js +104 -0
- package/dist/components/data-views/os-folder-glyph.js.map +1 -0
- package/dist/components/data-views/outline-tree-menu.d.ts +36 -0
- package/dist/components/data-views/outline-tree-menu.js +131 -0
- package/dist/components/data-views/outline-tree-menu.js.map +1 -0
- package/dist/components/table-properties/column-row.d.ts +22 -0
- package/dist/components/table-properties/column-row.js +153 -0
- package/dist/components/table-properties/column-row.js.map +1 -0
- package/dist/components/table-properties/draggable-list.d.ts +24 -0
- package/dist/components/table-properties/draggable-list.js +53 -0
- package/dist/components/table-properties/draggable-list.js.map +1 -0
- package/dist/components/table-properties/drawer-button.d.ts +110 -0
- package/dist/components/table-properties/drawer-button.js +2748 -0
- package/dist/components/table-properties/drawer-button.js.map +1 -0
- package/dist/components/table-properties/drawer.d.ts +100 -0
- package/dist/components/table-properties/drawer.js +2595 -0
- package/dist/components/table-properties/drawer.js.map +1 -0
- package/dist/components/table-properties/filter-card.d.ts +24 -0
- package/dist/components/table-properties/filter-card.js +854 -0
- package/dist/components/table-properties/filter-card.js.map +1 -0
- package/dist/components/table-properties/index.d.ts +14 -0
- package/dist/components/table-properties/index.js +2768 -0
- package/dist/components/table-properties/index.js.map +1 -0
- package/dist/components/table-properties/sort-card.d.ts +20 -0
- package/dist/components/table-properties/sort-card.js +102 -0
- package/dist/components/table-properties/sort-card.js.map +1 -0
- package/dist/components/templates/dedicated-search-landing-template.d.ts +21 -0
- package/dist/components/templates/dedicated-search-landing-template.js +254 -0
- package/dist/components/templates/dedicated-search-landing-template.js.map +1 -0
- package/dist/components/templates/dedicated-search-results-template.d.ts +15 -0
- package/dist/components/templates/dedicated-search-results-template.js +16 -0
- package/dist/components/templates/dedicated-search-results-template.js.map +1 -0
- package/dist/components/templates/index.d.ts +9 -0
- package/dist/components/templates/index.js +2720 -0
- package/dist/components/templates/index.js.map +1 -0
- package/dist/components/templates/list-page.d.ts +83 -0
- package/dist/components/templates/list-page.js +2433 -0
- package/dist/components/templates/list-page.js.map +1 -0
- package/dist/components/templates/nested-secondary-panel-shell.d.ts +20 -0
- package/dist/components/templates/nested-secondary-panel-shell.js +54 -0
- package/dist/components/templates/nested-secondary-panel-shell.js.map +1 -0
- package/dist/components/ui/accordion.d.ts +10 -0
- package/dist/components/ui/accordion.js +74 -0
- package/dist/components/ui/accordion.js.map +1 -0
- package/dist/components/ui/alert-dialog.d.ts +37 -0
- package/dist/components/ui/alert-dialog.js +201 -0
- package/dist/components/ui/alert-dialog.js.map +1 -0
- package/dist/components/ui/avatar.d.ts +84 -0
- package/dist/components/ui/avatar.js +328 -0
- package/dist/components/ui/avatar.js.map +1 -0
- package/dist/components/ui/badge.d.ts +13 -0
- package/dist/components/ui/badge.js +49 -0
- package/dist/components/ui/badge.js.map +1 -0
- package/dist/components/ui/banner.d.ts +62 -0
- package/dist/components/ui/banner.js +364 -0
- package/dist/components/ui/banner.js.map +1 -0
- package/dist/components/ui/breadcrumb.d.ts +14 -0
- package/dist/components/ui/breadcrumb.js +114 -0
- package/dist/components/ui/breadcrumb.js.map +1 -0
- package/dist/components/ui/button.d.ts +16 -0
- package/dist/components/ui/button.js +59 -0
- package/dist/components/ui/button.js.map +1 -0
- package/dist/components/ui/calendar.d.ts +13 -0
- package/dist/components/ui/calendar.js +238 -0
- package/dist/components/ui/calendar.js.map +1 -0
- package/dist/components/ui/card.d.ts +14 -0
- package/dist/components/ui/card.js +102 -0
- package/dist/components/ui/card.js.map +1 -0
- package/dist/components/ui/chart.d.ts +58 -0
- package/dist/components/ui/chart.js +292 -0
- package/dist/components/ui/chart.js.map +1 -0
- package/dist/components/ui/checkbox.d.ts +23 -0
- package/dist/components/ui/checkbox.js +155 -0
- package/dist/components/ui/checkbox.js.map +1 -0
- package/dist/components/ui/coach-mark.d.ts +27 -0
- package/dist/components/ui/coach-mark.js +306 -0
- package/dist/components/ui/coach-mark.js.map +1 -0
- package/dist/components/ui/collapsible.d.ts +8 -0
- package/dist/components/ui/collapsible.js +35 -0
- package/dist/components/ui/collapsible.js.map +1 -0
- package/dist/components/ui/command.d.ts +36 -0
- package/dist/components/ui/command.js +274 -0
- package/dist/components/ui/command.js.map +1 -0
- package/dist/components/ui/context-menu.d.ts +32 -0
- package/dist/components/ui/context-menu.js +245 -0
- package/dist/components/ui/context-menu.js.map +1 -0
- package/dist/components/ui/date-picker-field.d.ts +38 -0
- package/dist/components/ui/date-picker-field.js +550 -0
- package/dist/components/ui/date-picker-field.js.map +1 -0
- package/dist/components/ui/dialog.d.ts +22 -0
- package/dist/components/ui/dialog.js +200 -0
- package/dist/components/ui/dialog.js.map +1 -0
- package/dist/components/ui/dot-pattern.d.ts +21 -0
- package/dist/components/ui/dot-pattern.js +139 -0
- package/dist/components/ui/dot-pattern.js.map +1 -0
- package/dist/components/ui/drag-handle-grip.d.ts +10 -0
- package/dist/components/ui/drag-handle-grip.js +15 -0
- package/dist/components/ui/drag-handle-grip.js.map +1 -0
- package/dist/components/ui/drawer.d.ts +16 -0
- package/dist/components/ui/drawer.js +125 -0
- package/dist/components/ui/drawer.js.map +1 -0
- package/dist/components/ui/dropdown-menu.d.ts +45 -0
- package/dist/components/ui/dropdown-menu.js +353 -0
- package/dist/components/ui/dropdown-menu.js.map +1 -0
- package/dist/components/ui/export-drawer.d.ts +11 -0
- package/dist/components/ui/export-drawer.js +1658 -0
- package/dist/components/ui/export-drawer.js.map +1 -0
- package/dist/components/ui/field.d.ts +30 -0
- package/dist/components/ui/field.js +249 -0
- package/dist/components/ui/field.js.map +1 -0
- package/dist/components/ui/form.d.ts +28 -0
- package/dist/components/ui/form.js +110 -0
- package/dist/components/ui/form.js.map +1 -0
- package/dist/components/ui/hover-card.d.ts +9 -0
- package/dist/components/ui/hover-card.js +43 -0
- package/dist/components/ui/hover-card.js.map +1 -0
- package/dist/components/ui/input-group.d.ts +20 -0
- package/dist/components/ui/input-group.js +219 -0
- package/dist/components/ui/input-group.js.map +1 -0
- package/dist/components/ui/input-mask.d.ts +39 -0
- package/dist/components/ui/input-mask.js +118 -0
- package/dist/components/ui/input-mask.js.map +1 -0
- package/dist/components/ui/input.d.ts +5 -0
- package/dist/components/ui/input.js +30 -0
- package/dist/components/ui/input.js.map +1 -0
- package/dist/components/ui/kbd.d.ts +20 -0
- package/dist/components/ui/kbd.js +45 -0
- package/dist/components/ui/kbd.js.map +1 -0
- package/dist/components/ui/key-metrics-context.d.ts +19 -0
- package/dist/components/ui/key-metrics-context.js +26 -0
- package/dist/components/ui/key-metrics-context.js.map +1 -0
- package/dist/components/ui/key-metrics.d.ts +131 -0
- package/dist/components/ui/key-metrics.js +1015 -0
- package/dist/components/ui/key-metrics.js.map +1 -0
- package/dist/components/ui/label.d.ts +6 -0
- package/dist/components/ui/label.js +28 -0
- package/dist/components/ui/label.js.map +1 -0
- package/dist/components/ui/list-page-view-frame.d.ts +22 -0
- package/dist/components/ui/list-page-view-frame.js +24 -0
- package/dist/components/ui/list-page-view-frame.js.map +1 -0
- package/dist/components/ui/page-header.d.ts +51 -0
- package/dist/components/ui/page-header.js +372 -0
- package/dist/components/ui/page-header.js.map +1 -0
- package/dist/components/ui/payment-card-fields.d.ts +10 -0
- package/dist/components/ui/payment-card-fields.js +80 -0
- package/dist/components/ui/payment-card-fields.js.map +1 -0
- package/dist/components/ui/popover.d.ts +10 -0
- package/dist/components/ui/popover.js +47 -0
- package/dist/components/ui/popover.js.map +1 -0
- package/dist/components/ui/radio-group.d.ts +29 -0
- package/dist/components/ui/radio-group.js +190 -0
- package/dist/components/ui/radio-group.js.map +1 -0
- package/dist/components/ui/resizable.d.ts +16 -0
- package/dist/components/ui/resizable.js +51 -0
- package/dist/components/ui/resizable.js.map +1 -0
- package/dist/components/ui/scroll-area.d.ts +8 -0
- package/dist/components/ui/scroll-area.js +66 -0
- package/dist/components/ui/scroll-area.js.map +1 -0
- package/dist/components/ui/select.d.ts +18 -0
- package/dist/components/ui/select.js +186 -0
- package/dist/components/ui/select.js.map +1 -0
- package/dist/components/ui/selection-tile-grid.d.ts +52 -0
- package/dist/components/ui/selection-tile-grid.js +347 -0
- package/dist/components/ui/selection-tile-grid.js.map +1 -0
- package/dist/components/ui/separator.d.ts +7 -0
- package/dist/components/ui/separator.js +33 -0
- package/dist/components/ui/separator.js.map +1 -0
- package/dist/components/ui/sheet.d.ts +18 -0
- package/dist/components/ui/sheet.js +181 -0
- package/dist/components/ui/sheet.js.map +1 -0
- package/dist/components/ui/sidebar.d.ts +94 -0
- package/dist/components/ui/sidebar.js +805 -0
- package/dist/components/ui/sidebar.js.map +1 -0
- package/dist/components/ui/skeleton.d.ts +5 -0
- package/dist/components/ui/skeleton.js +22 -0
- package/dist/components/ui/skeleton.js.map +1 -0
- package/dist/components/ui/slider.d.ts +7 -0
- package/dist/components/ui/slider.js +66 -0
- package/dist/components/ui/slider.js.map +1 -0
- package/dist/components/ui/sonner.d.ts +6 -0
- package/dist/components/ui/sonner.js +38 -0
- package/dist/components/ui/sonner.js.map +1 -0
- package/dist/components/ui/status-badge.d.ts +38 -0
- package/dist/components/ui/status-badge.js +77 -0
- package/dist/components/ui/status-badge.js.map +1 -0
- package/dist/components/ui/table.d.ts +13 -0
- package/dist/components/ui/table.js +115 -0
- package/dist/components/ui/table.js.map +1 -0
- package/dist/components/ui/tabs.d.ts +15 -0
- package/dist/components/ui/tabs.js +93 -0
- package/dist/components/ui/tabs.js.map +1 -0
- package/dist/components/ui/textarea.d.ts +6 -0
- package/dist/components/ui/textarea.js +25 -0
- package/dist/components/ui/textarea.js.map +1 -0
- package/dist/components/ui/tip.d.ts +12 -0
- package/dist/components/ui/tip.js +61 -0
- package/dist/components/ui/tip.js.map +1 -0
- package/dist/components/ui/toggle-group.d.ts +14 -0
- package/dist/components/ui/toggle-group.js +104 -0
- package/dist/components/ui/toggle-group.js.map +1 -0
- package/dist/components/ui/toggle-switch.d.ts +10 -0
- package/dist/components/ui/toggle-switch.js +33 -0
- package/dist/components/ui/toggle-switch.js.map +1 -0
- package/dist/components/ui/toggle.d.ts +13 -0
- package/dist/components/ui/toggle.js +51 -0
- package/dist/components/ui/toggle.js.map +1 -0
- package/dist/components/ui/tooltip.d.ts +10 -0
- package/dist/components/ui/tooltip.js +68 -0
- package/dist/components/ui/tooltip.js.map +1 -0
- package/dist/components/ui/view-segmented-control.d.ts +31 -0
- package/dist/components/ui/view-segmented-control.js +167 -0
- package/dist/components/ui/view-segmented-control.js.map +1 -0
- package/dist/data-list-view-registry-CyBoBML4.d.ts +73 -0
- package/dist/hooks/use-app-theme.d.ts +24 -0
- package/dist/hooks/use-app-theme.js +286 -0
- package/dist/hooks/use-app-theme.js.map +1 -0
- package/dist/hooks/use-coach-mark.d.ts +86 -0
- package/dist/hooks/use-coach-mark.js +218 -0
- package/dist/hooks/use-coach-mark.js.map +1 -0
- package/dist/hooks/use-mobile.d.ts +3 -0
- package/dist/hooks/use-mobile.js +29 -0
- package/dist/hooks/use-mobile.js.map +1 -0
- package/dist/hooks/use-mod-key-label.d.ts +6 -0
- package/dist/hooks/use-mod-key-label.js +25 -0
- package/dist/hooks/use-mod-key-label.js.map +1 -0
- package/dist/index.d.ts +120 -0
- package/dist/index.js +13421 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/compose-refs.d.ts +6 -0
- package/dist/lib/compose-refs.js +17 -0
- package/dist/lib/compose-refs.js.map +1 -0
- package/dist/lib/conditional-rule-match.d.ts +30 -0
- package/dist/lib/conditional-rule-match.js +66 -0
- package/dist/lib/conditional-rule-match.js.map +1 -0
- package/dist/lib/data-list-display-options.d.ts +26 -0
- package/dist/lib/data-list-display-options.js +14 -0
- package/dist/lib/data-list-display-options.js.map +1 -0
- package/dist/lib/data-list-view-registry.d.ts +2 -0
- package/dist/lib/data-list-view-registry.js +102 -0
- package/dist/lib/data-list-view-registry.js.map +1 -0
- package/dist/lib/data-list-view-surface.d.ts +2 -0
- package/dist/lib/data-list-view-surface.js +80 -0
- package/dist/lib/data-list-view-surface.js.map +1 -0
- package/dist/lib/data-list-view.d.ts +21 -0
- package/dist/lib/data-list-view.js +25 -0
- package/dist/lib/data-list-view.js.map +1 -0
- package/dist/lib/date-filter.d.ts +22 -0
- package/dist/lib/date-filter.js +61 -0
- package/dist/lib/date-filter.js.map +1 -0
- package/dist/lib/dev-log.d.ts +8 -0
- package/dist/lib/dev-log.js +10 -0
- package/dist/lib/dev-log.js.map +1 -0
- package/dist/lib/dropdown-menu-surface.d.ts +14 -0
- package/dist/lib/dropdown-menu-surface.js +6 -0
- package/dist/lib/dropdown-menu-surface.js.map +1 -0
- package/dist/lib/editable-target.d.ts +12 -0
- package/dist/lib/editable-target.js +12 -0
- package/dist/lib/editable-target.js.map +1 -0
- package/dist/lib/list-page-table-properties.d.ts +35 -0
- package/dist/lib/list-page-table-properties.js +81 -0
- package/dist/lib/list-page-table-properties.js.map +1 -0
- package/dist/lib/raf-throttle.d.ts +23 -0
- package/dist/lib/raf-throttle.js +27 -0
- package/dist/lib/raf-throttle.js.map +1 -0
- package/dist/lib/row-height.d.ts +16 -0
- package/dist/lib/row-height.js +10 -0
- package/dist/lib/row-height.js.map +1 -0
- package/dist/lib/table-properties-types.d.ts +83 -0
- package/dist/lib/table-properties-types.js +19 -0
- package/dist/lib/table-properties-types.js.map +1 -0
- package/dist/lib/utils.d.ts +5 -0
- package/dist/lib/utils.js +11 -0
- package/dist/lib/utils.js.map +1 -0
- package/package.json +83 -19
- package/src/components/data-table/filter-date-calendar.tsx +38 -0
- package/src/components/data-table/filter-text-value-input.tsx +77 -0
- package/src/components/data-table/index.tsx +1678 -0
- package/src/components/data-table/pagination.tsx +259 -0
- package/src/components/data-table/types.ts +96 -0
- package/src/components/data-table/use-table-state.ts +767 -0
- package/src/components/data-views/board-card-primitives.tsx +93 -0
- package/src/components/data-views/data-row-list.tsx +183 -0
- package/src/components/data-views/finder-panel-view.tsx +405 -0
- package/src/components/data-views/folder-grid-view.tsx +86 -0
- package/src/components/data-views/hub-table.tsx +606 -0
- package/src/components/data-views/index.ts +28 -0
- package/src/components/data-views/list-page-board-card.tsx +192 -0
- package/src/components/data-views/list-page-board-template.tsx +122 -0
- package/src/components/data-views/list-page-connected-view-body.tsx +66 -0
- package/src/components/data-views/list-page-split-details-placeholder.tsx +39 -0
- package/src/components/data-views/list-page-split-hub-chrome.tsx +60 -0
- package/src/components/data-views/list-page-split-hub-tokens.ts +16 -0
- package/src/components/data-views/list-page-tree-column-header.tsx +31 -0
- package/src/components/data-views/list-page-tree-panel-shell.tsx +91 -0
- package/src/components/data-views/os-folder-glyph.tsx +141 -0
- package/src/components/data-views/outline-tree-menu.tsx +157 -0
- package/src/components/table-properties/column-row.tsx +90 -0
- package/src/components/table-properties/draggable-list.ts +54 -0
- package/src/components/table-properties/drawer-button.tsx +300 -0
- package/src/components/table-properties/drawer.tsx +1148 -0
- package/src/components/table-properties/filter-card.tsx +251 -0
- package/src/components/table-properties/index.ts +36 -0
- package/src/components/table-properties/sort-card.tsx +63 -0
- package/src/components/templates/dedicated-search-landing-template.tsx +124 -0
- package/src/components/templates/dedicated-search-results-template.tsx +19 -0
- package/src/components/templates/index.ts +33 -0
- package/src/components/templates/list-page.tsx +602 -0
- package/src/components/templates/nested-secondary-panel-shell.tsx +70 -0
- package/src/components/ui/accordion.tsx +92 -0
- package/src/components/ui/alert-dialog.tsx +221 -0
- package/src/components/ui/avatar.tsx +13 -2
- package/src/components/ui/banner.tsx +2 -2
- package/src/components/ui/button.tsx +4 -4
- package/src/components/ui/calendar.tsx +1 -1
- package/src/components/ui/coach-mark.tsx +1 -1
- package/src/components/ui/context-menu.tsx +291 -0
- package/src/components/ui/date-picker-field.tsx +2 -2
- package/src/components/ui/dot-pattern.tsx +183 -0
- package/src/components/ui/export-drawer.tsx +375 -0
- package/src/components/ui/hover-card.tsx +66 -0
- package/src/components/ui/key-metrics-context.tsx +78 -0
- package/src/components/ui/key-metrics.tsx +1133 -0
- package/src/components/ui/list-page-view-frame.tsx +64 -0
- package/src/components/ui/page-header.tsx +244 -0
- package/src/components/ui/payment-card-fields.tsx +2 -2
- package/src/components/ui/resizable.tsx +68 -0
- package/src/components/ui/scroll-area.tsx +72 -0
- package/src/components/ui/selection-tile-grid.tsx +9 -2
- package/src/components/ui/sidebar.tsx +84 -12
- package/src/components/ui/slider.tsx +83 -0
- package/src/globals.css +2201 -7
- package/src/globals.d.ts +20 -0
- package/src/index.ts +68 -1
- package/src/lib/conditional-rule-match.ts +119 -0
- package/src/lib/data-list-display-options.ts +35 -0
- package/src/lib/data-list-view-registry.ts +104 -0
- package/src/lib/data-list-view-surface.ts +83 -0
- package/src/lib/data-list-view.ts +47 -0
- package/src/lib/dev-log.ts +10 -0
- package/src/lib/editable-target.ts +20 -0
- package/src/lib/list-page-table-properties.ts +48 -0
- package/src/lib/raf-throttle.ts +45 -0
- package/src/lib/row-height.ts +19 -0
- package/src/lib/table-properties-types.ts +98 -0
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +8 -7
- package/template/.cursor/rules/exxat-accessibility.mdc +1 -1
- package/template/.cursor/rules/exxat-command-menu.mdc +2 -2
- package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +7 -7
- package/template/.cursor/rules/exxat-data-tables.mdc +3 -3
- package/template/.cursor/rules/exxat-ds-agents.mdc +2 -2
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +7 -7
- package/template/.cursor/rules/exxat-mono-ids.mdc +1 -1
- package/template/.cursor/rules/exxat-page-vs-drawer.mdc +1 -1
- package/template/.cursor/rules/exxat-table-properties-drawer.mdc +1 -1
- package/template/AGENTS.md +135 -103
- package/template/app/(app)/columns/page.tsx +11 -0
- package/template/app/(app)/dashboard/loading.tsx +15 -3
- package/template/app/(app)/dashboard/page.tsx +14 -2
- package/template/app/(app)/layout.tsx +17 -4
- package/template/app/(app)/library/all/page.tsx +11 -0
- package/template/app/(app)/library/find/page.tsx +12 -0
- package/template/app/(app)/{question-bank → library}/layout.tsx +17 -17
- package/template/app/(app)/library/list/page.tsx +12 -0
- package/template/app/(app)/library/new/page.tsx +45 -0
- package/template/app/(app)/library/page.tsx +11 -0
- package/template/app/(app)/loading.tsx +18 -1
- package/template/app/(app)/settings/page.tsx +5 -4
- package/template/app/(app)/tokens-themes/page.tsx +11 -0
- package/template/app/globals.css +14 -16
- package/template/components/ask-leo-composer.tsx +2 -2
- package/template/components/ask-leo-sidebar.tsx +5 -1
- package/template/components/brand-color-picker.tsx +2 -2
- package/template/components/charts-overview.tsx +1 -1
- package/template/components/columns-client.tsx +158 -0
- package/template/components/columns-showcase.tsx +541 -0
- package/template/components/dashboard-report-charts.tsx +1 -1
- package/template/components/dashboard-tabs.tsx +1 -1
- package/template/components/data-table/filter-date-calendar.tsx +1 -38
- package/template/components/data-table/filter-text-value-input.tsx +1 -77
- package/template/components/data-table/index.tsx +1 -1634
- package/template/components/data-table/pagination.tsx +1 -255
- package/template/components/data-table/types.ts +1 -94
- package/template/components/data-table/use-table-state.test.ts +420 -0
- package/template/components/data-table/use-table-state.ts +1 -758
- package/template/components/data-views/board-card-primitives.tsx +1 -93
- package/template/components/data-views/data-row-list.tsx +1 -183
- package/template/components/data-views/finder-panel-view.tsx +1 -405
- package/template/components/data-views/folder-grid-view.tsx +1 -86
- package/template/components/data-views/hub-table.tsx +1 -0
- package/template/components/data-views/index.ts +77 -38
- package/template/components/data-views/{question-bank-folder-tree-branch.tsx → library-folder-tree-branch.tsx} +19 -19
- package/template/components/data-views/list-page-board-card.tsx +1 -192
- package/template/components/data-views/list-page-board-template.tsx +1 -122
- package/template/components/data-views/list-page-connected-view-body.tsx +1 -66
- package/template/components/data-views/list-page-split-details-placeholder.tsx +1 -39
- package/template/components/data-views/list-page-split-hub-chrome.tsx +1 -68
- package/template/components/data-views/list-page-split-hub-tokens.ts +1 -16
- package/template/components/data-views/list-page-tree-column-header.tsx +1 -31
- package/template/components/data-views/list-page-tree-panel-shell.tsx +1 -91
- package/template/components/data-views/list-page-view-frame.tsx +5 -53
- package/template/components/data-views/os-folder-glyph.tsx +1 -129
- package/template/components/data-views/outline-tree-menu.tsx +1 -157
- package/template/components/data-views/table-cells.tsx +673 -0
- package/template/components/export-drawer.test.tsx +71 -0
- package/template/components/export-drawer.tsx +1 -375
- package/template/components/exxat-product-logo.tsx +5 -5
- package/template/components/folder-details-shell.tsx +11 -11
- package/template/components/hub-tree-panel-view.tsx +26 -26
- package/template/components/invite-collaborators-drawer.tsx +3 -3
- package/template/components/key-metrics-ask-leo-bridge.tsx +40 -0
- package/template/components/key-metrics.tsx +1 -1063
- package/template/components/leo-insight-indicator.tsx +2 -2
- package/template/components/{question-bank-board-view.tsx → library-board-view.tsx} +44 -44
- package/template/components/{question-bank-client.tsx → library-client.tsx} +83 -83
- package/template/components/{question-bank-dashboard-charts.tsx → library-dashboard-charts.tsx} +14 -14
- package/template/components/{question-bank-favorite-button.tsx → library-favorite-button.tsx} +7 -7
- package/template/components/{question-bank-hub-client.tsx → library-hub-client.tsx} +44 -44
- package/template/components/{question-bank-new-folder-sheet.tsx → library-new-folder-sheet.tsx} +16 -16
- package/template/components/{question-bank-os-folder-view.tsx → library-os-folder-view.tsx} +31 -31
- package/template/components/{question-bank-page-header.tsx → library-page-header.tsx} +6 -6
- package/template/components/library-panel-activator.tsx +8 -0
- package/template/components/{question-bank-secondary-nav.tsx → library-secondary-nav.tsx} +63 -63
- package/template/components/library-table.tsx +839 -0
- package/template/components/list-hub-status-badge.tsx +2 -2
- package/template/components/{new-question-composer.tsx → new-library-item-form.tsx} +489 -441
- package/template/components/onboarding/index.ts +9 -0
- package/template/components/onboarding/onboarding-01.tsx +1 -1
- package/template/components/onboarding/onboarding-02.tsx +1 -1
- package/template/components/onboarding/onboarding-03.tsx +1 -1
- package/template/components/onboarding/onboarding-04.tsx +1 -1
- package/template/components/page-header.tsx +8 -226
- package/template/components/product-switcher.tsx +3 -4
- package/template/components/product-wordmark.tsx +2 -1
- package/template/components/settings-appearance-card.tsx +3 -4
- package/template/components/settings-client.tsx +15 -59
- package/template/components/settings-form-row.tsx +4 -9
- package/template/components/{app-sidebar-dynamic.tsx → sidebar/app-sidebar-dynamic.tsx} +1 -1
- package/template/components/{app-sidebar.tsx → sidebar/app-sidebar.tsx} +114 -73
- package/template/components/sidebar/index.ts +16 -0
- package/template/components/{secondary-nav.tsx → sidebar/secondary-nav.tsx} +2 -2
- package/template/components/sidebar/secondary-panel.tsx +316 -0
- package/template/components/sidebar/sidebar-auto-collapse.tsx +27 -0
- package/template/components/{sidebar-auto-open.tsx → sidebar/sidebar-auto-open.tsx} +2 -1
- package/template/components/{sidebar-shell.tsx → sidebar/sidebar-shell.tsx} +1 -1
- package/template/components/site-header.tsx +1 -1
- package/template/components/table-properties/column-row.tsx +1 -90
- package/template/components/table-properties/draggable-list.ts +1 -49
- package/template/components/table-properties/drawer-button.tsx +1 -262
- package/template/components/table-properties/drawer.tsx +1 -1166
- package/template/components/table-properties/filter-card.tsx +1 -251
- package/template/components/table-properties/sort-card.tsx +1 -59
- package/template/components/table-properties/types.ts +28 -71
- package/template/components/templates/dedicated-search-landing-template.tsx +1 -124
- package/template/components/templates/dedicated-search-results-template.tsx +1 -19
- package/template/components/templates/discovery-hub-template.tsx +1 -1
- package/template/components/templates/list-page.tsx +1 -608
- package/template/components/templates/nested-secondary-panel-shell.tsx +1 -63
- package/template/components/templates/new-focus-template.tsx +659 -0
- package/template/components/templates/secondary-panel-hub-template.tsx +2 -2
- package/template/components/tokens-secondary-nav.tsx +192 -0
- package/template/components/tokens-themes-client.tsx +476 -0
- package/template/components/tokens-themes-section.tsx +386 -0
- package/template/components/ui/accordion.tsx +1 -0
- package/template/components/ui/alert-dialog.tsx +1 -0
- package/template/components/ui/context-menu.tsx +1 -0
- package/template/components/ui/dot-pattern.tsx +1 -183
- package/template/components/ui/hover-card.tsx +1 -0
- package/template/components/ui/resizable.tsx +1 -68
- package/template/components/ui/scroll-area.tsx +1 -0
- package/template/components/ui/slider.tsx +1 -0
- package/template/docs/HANDBOOK.md +187 -0
- package/template/docs/blueprints/README.md +86 -0
- package/template/docs/blueprints/_template.md +91 -0
- package/template/docs/blueprints/board-card.md +123 -0
- package/template/docs/blueprints/data-table.md +139 -0
- package/template/docs/blueprints/key-metrics.md +128 -0
- package/template/docs/blueprints/list-page-template.md +123 -0
- package/template/docs/blueprints/page-header.md +130 -0
- package/template/docs/collaboration-access-pattern.md +7 -7
- package/template/docs/command-menu-pattern.md +1 -1
- package/template/docs/component-selection-guide.md +224 -0
- package/template/docs/components-audit-2026-05.md +158 -0
- package/template/docs/data-views-pattern.md +31 -66
- package/template/docs/drawer-vs-dialog-pattern.md +1 -3
- package/template/docs/glossary.md +58 -0
- package/template/docs/kpi-flat-band-pattern.md +3 -3
- package/template/docs/kpi-trend-pattern.md +18 -3
- package/template/docs/large-dataset-strategy.md +155 -0
- package/template/docs/library-hub-header-pattern.md +25 -0
- package/template/docs/migrations/0001-brand-deep-alias-stabilization.md +95 -0
- package/template/docs/migrations/0002-exxat-token-namespace.md +154 -0
- package/template/docs/migrations/0003-globals-css-canonical.md +110 -0
- package/template/docs/migrations/README.md +100 -0
- package/template/docs/migrations/_template.md +64 -0
- package/template/docs/reference-implementations.md +151 -0
- package/template/docs/shell-surface-elevation-pattern.md +3 -5
- package/template/docs/token-taxonomy.md +416 -0
- package/template/docs/voice-and-tone.md +262 -0
- package/template/eslint.config.mjs +27 -0
- package/template/hooks/use-secondary-panel-hub-nav.ts +11 -11
- package/template/lib/ask-leo-route-context.ts +6 -18
- package/template/lib/coach-mark-registry.ts +0 -16
- package/template/lib/command-menu-config.ts +5 -13
- package/template/lib/command-menu-search-data.ts +8 -23
- package/template/lib/conditional-rule-match.ts +6 -97
- package/template/lib/data-list-display-options.ts +1 -49
- package/template/lib/data-list-view-registry.ts +1 -104
- package/template/lib/data-list-view-surface.ts +1 -83
- package/template/lib/data-list-view.ts +1 -47
- package/template/lib/data-view-dashboard-storage.ts +35 -38
- package/template/lib/dev-log.ts +1 -8
- package/template/lib/editable-target.ts +1 -10
- package/template/lib/{question-bank-authoring.ts → library-authoring.ts} +89 -88
- package/template/lib/library-dedicated-search.ts +19 -0
- package/template/lib/library-hub-search.ts +90 -0
- package/template/lib/library-nav.ts +477 -0
- package/template/lib/library-recent-searches.ts +22 -0
- package/template/lib/{question-bank-supported-views.ts → library-supported-views.ts} +2 -3
- package/template/lib/list-page-table-properties.ts +1 -48
- package/template/lib/list-status-badges.ts +16 -11
- package/template/lib/mock/dashboard.ts +1 -1
- package/template/lib/mock/{question-bank-folders.ts → library-folders.ts} +30 -30
- package/template/lib/mock/library-header-collaborators.ts +54 -0
- package/template/lib/mock/{question-bank-inspector.ts → library-inspector.ts} +29 -29
- package/template/lib/mock/{question-bank-kpi.ts → library-kpi.ts} +20 -20
- package/template/lib/mock/library.ts +249 -0
- package/template/lib/mock/navigation.tsx +32 -35
- package/template/lib/raf-throttle.ts +1 -45
- package/template/lib/row-height.ts +4 -10
- package/template/lib/sidebar-state-cookie.ts +11 -2
- package/template/lib/table-state-lifecycle.ts +3 -3
- package/template/next.config.mjs +7 -4
- package/template/package.json +1 -0
- package/template/tests/setup.ts +25 -0
- package/consumer-extras/AGENTS.md +0 -76
- package/consumer-extras/cursor-skills/exxat-consumer-app/SKILL.md +0 -37
- package/consumer-extras/cursor-skills/exxat-focused-workflow-page/SKILL.md +0 -57
- package/consumer-extras/patterns/consumer-app-pattern.md +0 -39
- package/consumer-extras/patterns/focused-workflow-page-pattern.md +0 -84
- package/src/components/ui/button-group.tsx +0 -81
- package/src/theme.css +0 -16
- package/src/tokens/README.md +0 -15
- package/src/tokens/base.css +0 -337
- package/src/tokens/high-contrast.css +0 -1195
- package/src/tokens/layers.css +0 -224
- package/src/tokens/tailwind-bridge.css +0 -118
- package/src/tokens/themes.css +0 -201
- package/template/app/(app)/data-list/layout.tsx +0 -43
- package/template/app/(app)/data-list/page.tsx +0 -10
- package/template/app/(app)/examples/focused-workflow/page.tsx +0 -5
- package/template/app/(app)/examples/page.tsx +0 -43
- package/template/app/(app)/question-bank/find/page.tsx +0 -13
- package/template/app/(app)/question-bank/library/page.tsx +0 -12
- package/template/app/(app)/question-bank/list/page.tsx +0 -13
- package/template/app/(app)/question-bank/new/page.tsx +0 -50
- package/template/app/(app)/question-bank/page.tsx +0 -12
- package/template/components/app-route-loading.tsx +0 -14
- package/template/components/dashboard-onboarding-gallery.tsx +0 -13
- package/template/components/dashboard-onboarding.tsx +0 -21
- package/template/components/data-views/list-page-calendar-view.tsx +0 -593
- package/template/components/data-views/list-page-folder-columns-panel.tsx +0 -345
- package/template/components/examples/focused-workflow-showcase.tsx +0 -183
- package/template/components/list-hub-board-view.tsx +0 -68
- package/template/components/list-hub-client.tsx +0 -186
- package/template/components/list-hub-list-view.tsx +0 -36
- package/template/components/list-hub-panel-activator.tsx +0 -8
- package/template/components/list-hub-secondary-nav.tsx +0 -121
- package/template/components/list-hub-table.tsx +0 -336
- package/template/components/question-bank-folder-columns-panel.tsx +0 -104
- package/template/components/question-bank-list-view.tsx +0 -53
- package/template/components/question-bank-panel-activator.tsx +0 -8
- package/template/components/question-bank-table.tsx +0 -729
- package/template/components/secondary-panel/nav-link-rows.tsx +0 -83
- package/template/components/secondary-panel.tsx +0 -220
- package/template/components/secondary-panels/list-hub-panel.tsx +0 -39
- package/template/components/secondary-panels/question-bank-panel.tsx +0 -39
- package/template/components/secondary-panels/registry.tsx +0 -15
- package/template/components/section-cards.tsx +0 -106
- package/template/components/sidebar-auto-collapse.tsx +0 -23
- package/template/components/templates/focused-workflow-layouts.tsx +0 -448
- package/template/components/templates/focused-workflow-page-template.tsx +0 -69
- package/template/components/templates/page-loading-shell.tsx +0 -262
- package/template/components/ui/button-group.tsx +0 -1
- package/template/docs/consumer-app-pattern.md +0 -39
- package/template/docs/focused-workflow-page-pattern.md +0 -84
- package/template/docs/question-bank-hub-header-pattern.md +0 -25
- package/template/lib/list-hub-nav.ts +0 -121
- package/template/lib/mock/list-hub-directory.ts +0 -27
- package/template/lib/mock/list-hub-kpi.ts +0 -27
- package/template/lib/mock/question-bank-header-collaborators.ts +0 -54
- package/template/lib/mock/question-bank.ts +0 -249
- package/template/lib/page-loading-variant.ts +0 -40
- package/template/lib/question-bank-dedicated-search.ts +0 -19
- package/template/lib/question-bank-hub-search.ts +0 -90
- package/template/lib/question-bank-nav.ts +0 -477
- package/template/lib/question-bank-recent-searches.ts +0 -22
- /package/template/components/{getting-started.tsx → onboarding/getting-started.tsx} +0 -0
- /package/template/components/{nav-documents.tsx → sidebar/nav-documents.tsx} +0 -0
- /package/template/components/{nav-main.tsx → sidebar/nav-main.tsx} +0 -0
- /package/template/components/{nav-secondary.tsx → sidebar/nav-secondary.tsx} +0 -0
- /package/template/components/{nav-user.tsx → sidebar/nav-user.tsx} +0 -0
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* KeyMetrics — WCAG 2.1 AA reusable KPI panel
|
|
5
|
+
*
|
|
6
|
+
* Variants:
|
|
7
|
+
* "card" (default) — shadcn Card wrapper with brand gradient fill
|
|
8
|
+
* "flat" — full-width soft tint band (brand-tint → background) + bottom glow, no card chrome
|
|
9
|
+
*
|
|
10
|
+
* AA checklist:
|
|
11
|
+
* ✓ Trend text never relies on colour alone — icon + label (WCAG 1.4.1)
|
|
12
|
+
* ✓ `trend` matches signed change; `trendPolarity` flips sentiment when “up” is bad (see `docs/kpi-trend-pattern.md`)
|
|
13
|
+
* ✓ Trend icons have aria-hidden; chip `aria-label` uses `metricTrendAriaQualifier` (1.1.1)
|
|
14
|
+
* ✓ Select has accessible label via aria-label (4.1.2)
|
|
15
|
+
* ✓ Insight action button has descriptive text (4.1.2)
|
|
16
|
+
* ✓ Decorative dividers are aria-hidden (1.1.1)
|
|
17
|
+
* ✓ Contrast: value text foreground ≥ 17:1, trend colours ≥ 4.5:1 (1.4.3)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as React from "react"
|
|
21
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./card"
|
|
22
|
+
import {
|
|
23
|
+
Select,
|
|
24
|
+
SelectContent,
|
|
25
|
+
SelectItem,
|
|
26
|
+
SelectTrigger,
|
|
27
|
+
SelectValue,
|
|
28
|
+
} from "./select"
|
|
29
|
+
import { Separator } from "./separator"
|
|
30
|
+
import { Button } from "./button"
|
|
31
|
+
import {
|
|
32
|
+
Tooltip,
|
|
33
|
+
TooltipContent,
|
|
34
|
+
TooltipTrigger,
|
|
35
|
+
} from "./tooltip"
|
|
36
|
+
import { cn } from "../../lib/utils"
|
|
37
|
+
import { useKeyMetricsContext } from "./key-metrics-context"
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
KeyMetricsProvider,
|
|
41
|
+
useKeyMetricsContext,
|
|
42
|
+
type KeyMetricsContextValue,
|
|
43
|
+
} from "./key-metrics-context"
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Tooltip around the default insight CTA. Renders the shortcut hint
|
|
47
|
+
* injected by `KeyMetricsProvider` (typically `⌘⌥K` for Ask Leo) when
|
|
48
|
+
* the CTA still uses the default action label — i.e. the consumer did
|
|
49
|
+
* NOT override `actionLabel` on the individual `MetricInsight`. Any
|
|
50
|
+
* custom `actionLabel` ("Open ticket", "Acknowledge", …) suppresses
|
|
51
|
+
* the shortcut hint since it no longer maps to the default chord.
|
|
52
|
+
*/
|
|
53
|
+
function InsightDefaultTooltip({
|
|
54
|
+
actionLabel,
|
|
55
|
+
children,
|
|
56
|
+
}: {
|
|
57
|
+
actionLabel?: string
|
|
58
|
+
children: React.ReactNode
|
|
59
|
+
}) {
|
|
60
|
+
const { shortcutHint, defaultActionLabel = "Ask Leo" } = useKeyMetricsContext()
|
|
61
|
+
const label = actionLabel ?? defaultActionLabel
|
|
62
|
+
const showShortcut =
|
|
63
|
+
!!shortcutHint && (!actionLabel || actionLabel === defaultActionLabel)
|
|
64
|
+
if (!showShortcut) {
|
|
65
|
+
return (
|
|
66
|
+
<Tooltip>
|
|
67
|
+
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
|
68
|
+
<TooltipContent side="top">{label}</TooltipContent>
|
|
69
|
+
</Tooltip>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
return (
|
|
73
|
+
<Tooltip>
|
|
74
|
+
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
|
75
|
+
<TooltipContent side="top" className="flex flex-wrap items-center gap-1.5">
|
|
76
|
+
<span>{label}</span>
|
|
77
|
+
{shortcutHint}
|
|
78
|
+
</TooltipContent>
|
|
79
|
+
</Tooltip>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* ── Types ────────────────────────────────────────────────────────────────── */
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether an **up** arrow should read as “good news” for tinting and assistive text.
|
|
87
|
+
* - **`higher_is_better`** (default) — revenue, pass rate, approved count: up = favorable.
|
|
88
|
+
* - **`lower_is_better`** — defects, overdue, **low PBI / quality flags**: more flags + up arrow = unfavorable.
|
|
89
|
+
* - **`informational`** — volume or mix only; keep arrows **muted** (direction without value judgment).
|
|
90
|
+
*/
|
|
91
|
+
export type MetricTrendPolarity = "higher_is_better" | "lower_is_better" | "informational"
|
|
92
|
+
|
|
93
|
+
export type MetricTrendTone = "positive" | "negative" | "muted"
|
|
94
|
+
|
|
95
|
+
/** Maps `trend` + polarity to semantic tone for colours (arrow direction still follows `trend`). */
|
|
96
|
+
export function metricTrendTone(
|
|
97
|
+
trend: "up" | "down" | "neutral",
|
|
98
|
+
polarity: MetricTrendPolarity = "higher_is_better",
|
|
99
|
+
): MetricTrendTone {
|
|
100
|
+
if (trend === "neutral") return "muted"
|
|
101
|
+
if (polarity === "informational") return "muted"
|
|
102
|
+
if (polarity === "higher_is_better") {
|
|
103
|
+
return trend === "up" ? "positive" : "negative"
|
|
104
|
+
}
|
|
105
|
+
return trend === "up" ? "negative" : "positive"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Short clause for `aria-label` on the trend chip (paired with the delta string). */
|
|
109
|
+
export function metricTrendAriaQualifier(
|
|
110
|
+
trend: "up" | "down" | "neutral",
|
|
111
|
+
polarity: MetricTrendPolarity = "higher_is_better",
|
|
112
|
+
): string {
|
|
113
|
+
if (trend === "neutral") return "no net change"
|
|
114
|
+
if (polarity === "informational") {
|
|
115
|
+
return trend === "up" ? "increased" : "decreased"
|
|
116
|
+
}
|
|
117
|
+
if (polarity === "higher_is_better") {
|
|
118
|
+
return trend === "up" ? "increased, favorable" : "decreased, unfavorable"
|
|
119
|
+
}
|
|
120
|
+
return trend === "up" ? "increased, unfavorable" : "decreased, favorable"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface MetricItem {
|
|
124
|
+
/** Unique identifier for React keying */
|
|
125
|
+
id: string
|
|
126
|
+
/** Short label shown above the value */
|
|
127
|
+
label: string
|
|
128
|
+
/** Displayed value — e.g. "23", "98%", "1,250" */
|
|
129
|
+
value: string | number
|
|
130
|
+
/**
|
|
131
|
+
* Change **count** for the trend chip — e.g. `"+5"`, `"-3"`, `"+12%"`.
|
|
132
|
+
*
|
|
133
|
+
* Pass an **empty string** (or `0`) when there is no comparison delta to show.
|
|
134
|
+
* In that case the **trend chip is hidden entirely** (the previous `—` placeholder
|
|
135
|
+
* is dropped) — see `MetricCell`. Put contextual prose like
|
|
136
|
+
* `"left + right"` / `"vs last week"` in `description`, **never** here.
|
|
137
|
+
*/
|
|
138
|
+
delta: string | number
|
|
139
|
+
/**
|
|
140
|
+
* Visual trend direction (arrow follows the signed change in the underlying metric).
|
|
141
|
+
* `"neutral"` paired with an empty `delta` suppresses the chip — use `description`
|
|
142
|
+
* for any caption you want to show below the value instead.
|
|
143
|
+
*/
|
|
144
|
+
trend: "up" | "down" | "neutral"
|
|
145
|
+
/**
|
|
146
|
+
* Optional short caption rendered **below** the value + trend row (muted, small).
|
|
147
|
+
* Use for **what** the number means or **how** it breaks down
|
|
148
|
+
* (e.g. `"left + right"`, `"vs last week"`, `"across 4 sites"`) — NOT for delta counts.
|
|
149
|
+
*/
|
|
150
|
+
description?: string
|
|
151
|
+
/**
|
|
152
|
+
* How to **tint** the trend chip. Omit = **`higher_is_better`** (legacy behaviour).
|
|
153
|
+
* Arrows always match `trend`; sentiment colours flip for **`lower_is_better`**.
|
|
154
|
+
*/
|
|
155
|
+
trendPolarity?: MetricTrendPolarity
|
|
156
|
+
/** Makes the cell a link */
|
|
157
|
+
href?: string
|
|
158
|
+
/** Makes the cell a button */
|
|
159
|
+
onClick?: () => void
|
|
160
|
+
/**
|
|
161
|
+
* "hero" — primary KPI (e.g. total count): larger value, same structure as siblings.
|
|
162
|
+
* "default" — standard KPI strip cell.
|
|
163
|
+
*/
|
|
164
|
+
metricVariant?: "default" | "hero"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface MetricInsight {
|
|
168
|
+
/** Optional single line for custom copy; rail prefers `title` + `description` when both are set */
|
|
169
|
+
statement?: string
|
|
170
|
+
/** Card headline */
|
|
171
|
+
title: string
|
|
172
|
+
/** Supporting body copy */
|
|
173
|
+
description?: string
|
|
174
|
+
/** Optional deep-link for the ↗ button */
|
|
175
|
+
href?: string
|
|
176
|
+
/** CTA label — defaults to "Ask Leo" */
|
|
177
|
+
actionLabel?: string
|
|
178
|
+
/** Font Awesome class for the CTA icon — defaults to fa-wand-magic-sparkles */
|
|
179
|
+
actionIcon?: string
|
|
180
|
+
/** Callback for the CTA button */
|
|
181
|
+
onAction?: () => void
|
|
182
|
+
/** Severity determines the badge colour (default: warning) */
|
|
183
|
+
severity?: "warning" | "info" | "error"
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface PeriodOption {
|
|
187
|
+
value: string
|
|
188
|
+
label: string
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface KeyMetricsProps {
|
|
192
|
+
/**
|
|
193
|
+
* "card" — shadcn Card with brand gradient (default)
|
|
194
|
+
* "flat" — full-width gradient band, no card chrome
|
|
195
|
+
*/
|
|
196
|
+
variant?: "card" | "flat" | "compact"
|
|
197
|
+
/** Panel title */
|
|
198
|
+
title?: string
|
|
199
|
+
/** Subtitle / description below title */
|
|
200
|
+
description?: string
|
|
201
|
+
/** Array of KPI items — by default split into rows of 3 */
|
|
202
|
+
metrics: MetricItem[]
|
|
203
|
+
/** When true, all metrics share one horizontal row (md+ and compact mobile grid) */
|
|
204
|
+
metricsSingleRow?: boolean
|
|
205
|
+
/**
|
|
206
|
+
* When true with `metricsSingleRow`, use a 2-column KPI grid so half-width dashboard cards
|
|
207
|
+
* fit 1–4 KPIs without horizontal overflow (pair rows on md+; 2-col grid on small screens).
|
|
208
|
+
* The insight rail (if any) stacks below the KPI grid instead of sitting beside it on md+.
|
|
209
|
+
*/
|
|
210
|
+
metricsHalfWidthLayout?: boolean
|
|
211
|
+
/** Optional insight card — see `insightFullWidth` */
|
|
212
|
+
insight?: MetricInsight
|
|
213
|
+
/**
|
|
214
|
+
* When true, the insight sits on its own full-width row under the metrics (not a narrow side rail).
|
|
215
|
+
*/
|
|
216
|
+
insightFullWidth?: boolean
|
|
217
|
+
/** Comparison-period options for the Select */
|
|
218
|
+
periods?: PeriodOption[]
|
|
219
|
+
/** Initially-selected period value */
|
|
220
|
+
defaultPeriod?: string
|
|
221
|
+
/** Called with the new period value when the Select changes */
|
|
222
|
+
onPeriodChange?: (period: string) => void
|
|
223
|
+
/** When false, hides the title/description/period-selector header row (default: true) */
|
|
224
|
+
showHeader?: boolean
|
|
225
|
+
/**
|
|
226
|
+
* Tighter insight card: one short title + line of body, no vertical filler;
|
|
227
|
+
* aligns visually with a single-row KPI band.
|
|
228
|
+
*/
|
|
229
|
+
insightCompact?: boolean
|
|
230
|
+
className?: string
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* KPI grid column step patterns — Tailwind v4 container-query classes.
|
|
235
|
+
*
|
|
236
|
+
* We deliberately AVOID `repeat(auto-fit, minmax(...))` here because it
|
|
237
|
+
* produces awkward "N + leftover" layouts at intermediate widths (e.g. 3
|
|
238
|
+
* tiles in row 1 + 1 lonely tile in row 2 for a 4-KPI strip). Instead we
|
|
239
|
+
* step the column count through values that evenly divide the row size:
|
|
240
|
+
* 1 → 2 → 4 for a 4-KPI strip (3 is skipped on purpose).
|
|
241
|
+
*
|
|
242
|
+
* The breakpoints are container-query based (`@[Xrem]:…`) so they react to
|
|
243
|
+
* the metrics strip's OWN width, not the viewport — that's what makes the
|
|
244
|
+
* 2×2 fallback kick in when the primary sidebar + secondary panel are
|
|
245
|
+
* both open and the strip column is ~360 px wide, even on a 1280 px display.
|
|
246
|
+
*
|
|
247
|
+
* `metricsHalfWidthLayout` = strip shares its row with the insight rail
|
|
248
|
+
* (3fr / 2fr split). Tighter breakpoints because available width is ~60%
|
|
249
|
+
* of the section.
|
|
250
|
+
*/
|
|
251
|
+
/**
|
|
252
|
+
* Flat KPI hairlines — cell borders only (no grid gap fill / no surface).
|
|
253
|
+
* Four tiles: default 4-across verticals; 2×2 hairlines only when @container is narrow.
|
|
254
|
+
*/
|
|
255
|
+
function flatMetricsHairlineClass(
|
|
256
|
+
itemCount: number,
|
|
257
|
+
metricsHalfWidthLayout: boolean,
|
|
258
|
+
): string {
|
|
259
|
+
if (itemCount <= 1) return "gap-0"
|
|
260
|
+
|
|
261
|
+
const childBorder = "[&>*]:border-[color:var(--key-metrics-flat-divider)]"
|
|
262
|
+
|
|
263
|
+
if (itemCount === 2) {
|
|
264
|
+
return cn("gap-0", childBorder, "[&>*:first-child]:border-r")
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (itemCount === 4) {
|
|
268
|
+
const narrow2x2 = metricsHalfWidthLayout
|
|
269
|
+
? "@[max-width:25.99rem]"
|
|
270
|
+
: "@[max-width:29.99rem]"
|
|
271
|
+
return cn(
|
|
272
|
+
"gap-0",
|
|
273
|
+
childBorder,
|
|
274
|
+
/* Wide strip (matches `@[30rem]:grid-cols-4`) — verticals between all tiles, no horizontal */
|
|
275
|
+
"[&>*:not(:last-child)]:border-r",
|
|
276
|
+
/* Narrow strip (`@[18rem]`–`@[30rem]` 2×2) */
|
|
277
|
+
`${narrow2x2}:[&>*:not(:last-child)]:border-e-0`,
|
|
278
|
+
`${narrow2x2}:[&>*:nth-child(odd)]:border-r`,
|
|
279
|
+
`${narrow2x2}:[&>*:not(:nth-last-child(-n+2))]:border-b`,
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return cn("gap-0", childBorder, "[&>*:not(:last-child)]:border-r")
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function metricsRowColumnsClass(rowLength: number, metricsHalfWidthLayout: boolean): string {
|
|
287
|
+
const half = metricsHalfWidthLayout
|
|
288
|
+
switch (rowLength) {
|
|
289
|
+
case 1:
|
|
290
|
+
return "grid-cols-1"
|
|
291
|
+
case 2:
|
|
292
|
+
return half
|
|
293
|
+
? "grid-cols-1 @[14rem]:grid-cols-2"
|
|
294
|
+
: "grid-cols-1 @[18rem]:grid-cols-2"
|
|
295
|
+
case 3:
|
|
296
|
+
// 3 tiles divide evenly already — step 1 → 3.
|
|
297
|
+
return half
|
|
298
|
+
? "grid-cols-1 @[18rem]:grid-cols-3"
|
|
299
|
+
: "grid-cols-1 @[24rem]:grid-cols-3"
|
|
300
|
+
case 4:
|
|
301
|
+
// Step 1 → 2 (2×2 grid) → 4. Skip 3 — that's the awkward 3+1 layout.
|
|
302
|
+
// Aggressive 4-col thresholds so the strip fits all four tiles even
|
|
303
|
+
// when the primary sidebar + secondary panel + insight rail are all
|
|
304
|
+
// expanded (typical library layout puts the KPI grid at ~27rem).
|
|
305
|
+
return half
|
|
306
|
+
? "grid-cols-1 @[14rem]:grid-cols-2 @[26rem]:grid-cols-4"
|
|
307
|
+
: "grid-cols-1 @[18rem]:grid-cols-2 @[30rem]:grid-cols-4"
|
|
308
|
+
default:
|
|
309
|
+
// 5+ KPIs (`exxat-kpi-max-four` caps the strip at 4, but key-metrics
|
|
310
|
+
// is a generic primitive — fall back to a sensible step). 1 → 2 → 3 → 6.
|
|
311
|
+
return half
|
|
312
|
+
? "grid-cols-1 @[14rem]:grid-cols-2 @[26rem]:grid-cols-3 @[40rem]:grid-cols-6"
|
|
313
|
+
: "grid-cols-1 @[18rem]:grid-cols-2 @[30rem]:grid-cols-3 @[56rem]:grid-cols-6"
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* ── Default data ─────────────────────────────────────────────────────────── */
|
|
318
|
+
|
|
319
|
+
const DEFAULT_PERIODS: PeriodOption[] = [
|
|
320
|
+
{ value: "week", label: "vs last week" },
|
|
321
|
+
{ value: "month", label: "vs last month" },
|
|
322
|
+
{ value: "quarter", label: "vs last quarter" },
|
|
323
|
+
{ value: "year", label: "vs last year" },
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
/* ── Sub-components ───────────────────────────────────────────────────────── */
|
|
327
|
+
|
|
328
|
+
/** Single KPI cell inside the metrics grid */
|
|
329
|
+
const MetricCell = React.memo(function MetricCell({
|
|
330
|
+
label,
|
|
331
|
+
value,
|
|
332
|
+
delta,
|
|
333
|
+
trend,
|
|
334
|
+
trendPolarity = "higher_is_better",
|
|
335
|
+
description,
|
|
336
|
+
href,
|
|
337
|
+
onClick,
|
|
338
|
+
metricVariant = "default",
|
|
339
|
+
dense = false,
|
|
340
|
+
edgeGutter = true,
|
|
341
|
+
}: Omit<MetricItem, "id"> & { dense?: boolean; edgeGutter?: boolean }) {
|
|
342
|
+
const isUp = trend === "up"
|
|
343
|
+
const isDown = trend === "down"
|
|
344
|
+
const tone = metricTrendTone(trend, trendPolarity)
|
|
345
|
+
const isInteractive = !!(href || onClick)
|
|
346
|
+
const isHero = metricVariant === "hero"
|
|
347
|
+
|
|
348
|
+
// Hide the trend chip entirely when there's no direction *and* no count to
|
|
349
|
+
// surface. This avoids the noisy `—` placeholder for purely informational
|
|
350
|
+
// metrics — see `docs/kpi-trend-pattern.md`.
|
|
351
|
+
const deltaText = typeof delta === "number"
|
|
352
|
+
? (delta === 0 ? "" : String(delta))
|
|
353
|
+
: String(delta ?? "").trim()
|
|
354
|
+
const showTrendChip = isUp || isDown || deltaText.length > 0
|
|
355
|
+
|
|
356
|
+
const inner = (
|
|
357
|
+
<>
|
|
358
|
+
{/* Label row — min-height = 2 lines so values align when some titles wrap */}
|
|
359
|
+
<div
|
|
360
|
+
className={cn(
|
|
361
|
+
"grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 gap-y-0.5",
|
|
362
|
+
dense ? "min-h-[2.125rem]" : "min-h-[2.625rem]",
|
|
363
|
+
)}
|
|
364
|
+
>
|
|
365
|
+
<p
|
|
366
|
+
className={cn(
|
|
367
|
+
"min-w-0 text-muted-foreground leading-snug wrap-break-word",
|
|
368
|
+
dense ? "text-xs" : "text-sm",
|
|
369
|
+
isHero && "font-medium",
|
|
370
|
+
)}
|
|
371
|
+
>
|
|
372
|
+
{label}
|
|
373
|
+
</p>
|
|
374
|
+
{isInteractive ? (
|
|
375
|
+
<span className="mt-0.5 inline-flex shrink-0" aria-hidden="true">
|
|
376
|
+
<i className="fa-light fa-arrow-right text-xs text-foreground/70 transition-colors duration-150 group-hover:text-interactive-hover-foreground sm:group-hover:translate-x-0.5" />
|
|
377
|
+
</span>
|
|
378
|
+
) : null}
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
{/* Value + trend badge */}
|
|
382
|
+
<div className="flex items-baseline gap-2 flex-wrap">
|
|
383
|
+
<span
|
|
384
|
+
className={cn(
|
|
385
|
+
"font-bold tabular-nums leading-none text-foreground",
|
|
386
|
+
dense
|
|
387
|
+
? isHero
|
|
388
|
+
? "text-lg sm:text-xl"
|
|
389
|
+
: "text-base sm:text-lg"
|
|
390
|
+
: isHero
|
|
391
|
+
? "text-2xl sm:text-[1.625rem]"
|
|
392
|
+
: "text-xl sm:text-2xl",
|
|
393
|
+
)}
|
|
394
|
+
>
|
|
395
|
+
{value}
|
|
396
|
+
</span>
|
|
397
|
+
|
|
398
|
+
{/* Trend chip — icon + count, never colour-only (WCAG 1.4.1).
|
|
399
|
+
Suppressed when both `trend === "neutral"` and `delta` is empty
|
|
400
|
+
(see `showTrendChip` above) so informational KPIs render cleanly. */}
|
|
401
|
+
{showTrendChip && (
|
|
402
|
+
<span
|
|
403
|
+
className={cn(
|
|
404
|
+
"inline-flex items-center gap-1 font-medium leading-none",
|
|
405
|
+
dense ? "text-xs sm:text-xs" : "text-xs sm:text-sm",
|
|
406
|
+
tone === "positive" && "text-chart-2",
|
|
407
|
+
tone === "negative" && "text-destructive",
|
|
408
|
+
tone === "muted" && "text-muted-foreground",
|
|
409
|
+
)}
|
|
410
|
+
aria-label={`${metricTrendAriaQualifier(trend, trendPolarity)} ${deltaText}`.trim()}
|
|
411
|
+
>
|
|
412
|
+
{isUp && <i className="fa-light fa-arrow-trend-up text-[0.8rem]" aria-hidden="true" />}
|
|
413
|
+
{isDown && <i className="fa-light fa-arrow-trend-down text-[0.8rem]" aria-hidden="true" />}
|
|
414
|
+
{!isUp && !isDown && deltaText.length > 0 && (
|
|
415
|
+
<i className="fa-light fa-minus text-[0.8rem]" aria-hidden="true" />
|
|
416
|
+
)}
|
|
417
|
+
{deltaText.length > 0 && <span>{deltaText}</span>}
|
|
418
|
+
</span>
|
|
419
|
+
)}
|
|
420
|
+
</div>
|
|
421
|
+
|
|
422
|
+
{/* Caption — below the value row. Use for what the number means or how
|
|
423
|
+
it breaks down (e.g. "left + right", "vs last week"). Never the delta. */}
|
|
424
|
+
{description ? (
|
|
425
|
+
<p
|
|
426
|
+
className={cn(
|
|
427
|
+
"min-w-0 text-muted-foreground leading-snug wrap-break-word",
|
|
428
|
+
dense ? "text-[11px]" : "text-xs",
|
|
429
|
+
)}
|
|
430
|
+
>
|
|
431
|
+
{description}
|
|
432
|
+
</p>
|
|
433
|
+
) : null}
|
|
434
|
+
</>
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
const sharedClass = cn(
|
|
438
|
+
"group flex min-w-0 flex-col gap-2 text-start outline-none",
|
|
439
|
+
edgeGutter && "first:ps-0 last:pe-0",
|
|
440
|
+
dense ? "gap-1.5 px-2 py-2 sm:px-3 sm:py-3" : "gap-2 px-3 py-3 sm:px-5 sm:py-4",
|
|
441
|
+
isHero && "gap-2.5",
|
|
442
|
+
isInteractive && [
|
|
443
|
+
"cursor-pointer transition-colors duration-150",
|
|
444
|
+
"hover:bg-foreground/5",
|
|
445
|
+
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
|
446
|
+
]
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
if (href) {
|
|
450
|
+
return (
|
|
451
|
+
<a href={href} className={sharedClass} aria-label={`${label}: ${value}`}>
|
|
452
|
+
{inner}
|
|
453
|
+
</a>
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (onClick) {
|
|
458
|
+
return (
|
|
459
|
+
<button type="button" onClick={onClick} className={sharedClass} aria-label={`${label}: ${value}`}>
|
|
460
|
+
{inner}
|
|
461
|
+
</button>
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return <div className={sharedClass}>{inner}</div>
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
/** Body line for rail: `description`, else optional `statement` */
|
|
469
|
+
function insightRailBody(insight: MetricInsight): string {
|
|
470
|
+
const d = insight.description?.trim()
|
|
471
|
+
if (d) return d
|
|
472
|
+
return insight.statement?.trim() ?? ""
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Rail insight: severity badge + title + description + optional ↗, Ask Leo (no rule between copy and action).
|
|
477
|
+
*/
|
|
478
|
+
function InsightRailStatementAction({
|
|
479
|
+
insight,
|
|
480
|
+
compact,
|
|
481
|
+
}: {
|
|
482
|
+
insight: MetricInsight
|
|
483
|
+
compact: boolean
|
|
484
|
+
}) {
|
|
485
|
+
const badgeSize = compact ? "sm" : "default"
|
|
486
|
+
const surface = compact
|
|
487
|
+
? "border border-border/50 bg-gradient-to-b from-muted/35 to-card"
|
|
488
|
+
: "bg-card"
|
|
489
|
+
const body = insightRailBody(insight)
|
|
490
|
+
|
|
491
|
+
return (
|
|
492
|
+
<Card
|
|
493
|
+
role="region"
|
|
494
|
+
aria-label="Insight"
|
|
495
|
+
className={cn(
|
|
496
|
+
"flex h-full min-h-0 flex-col overflow-hidden rounded-lg border-0 p-0 shadow-none ring-1 ring-foreground/8",
|
|
497
|
+
surface
|
|
498
|
+
)}
|
|
499
|
+
>
|
|
500
|
+
{/* flex-1 + mt-auto on the CTA: copy stays top-aligned when the rail stretches to KPI height */}
|
|
501
|
+
<div className="flex min-h-0 flex-1 flex-col px-3 py-3 sm:px-4 sm:py-4">
|
|
502
|
+
<div className="flex items-start gap-2.5">
|
|
503
|
+
<InsightBadge severity={insight.severity} size={badgeSize} />
|
|
504
|
+
<div className="min-w-0 flex-1">
|
|
505
|
+
<p className="text-sm font-semibold leading-snug text-foreground">{insight.title}</p>
|
|
506
|
+
{body ? (
|
|
507
|
+
<p className="mt-1 text-sm leading-snug text-muted-foreground">{body}</p>
|
|
508
|
+
) : null}
|
|
509
|
+
</div>
|
|
510
|
+
{insight.href && (
|
|
511
|
+
<a
|
|
512
|
+
href={insight.href}
|
|
513
|
+
className="mt-0.5 shrink-0 text-muted-foreground transition-colors hover:text-interactive-hover-foreground focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-ring"
|
|
514
|
+
aria-label={`Open ${insight.title} — details`}
|
|
515
|
+
>
|
|
516
|
+
<i className="fa-light fa-arrow-up-right text-xs" aria-hidden="true" />
|
|
517
|
+
</a>
|
|
518
|
+
)}
|
|
519
|
+
</div>
|
|
520
|
+
|
|
521
|
+
<div className="mt-auto flex shrink-0 justify-end pt-3">
|
|
522
|
+
<InsightDefaultTooltip actionLabel={insight.actionLabel}>
|
|
523
|
+
<Button
|
|
524
|
+
variant={compact ? "outline" : "ghost"}
|
|
525
|
+
size="sm"
|
|
526
|
+
className={cn(
|
|
527
|
+
"h-8 w-full gap-1.5 text-xs sm:w-auto",
|
|
528
|
+
compact
|
|
529
|
+
? "border-border/60 bg-background px-3 text-foreground hover:bg-background"
|
|
530
|
+
: "px-3 text-muted-foreground hover:text-interactive-hover-foreground"
|
|
531
|
+
)}
|
|
532
|
+
onClick={insight.onAction}
|
|
533
|
+
aria-label={insight.actionLabel ?? "Ask Leo"}
|
|
534
|
+
>
|
|
535
|
+
<i
|
|
536
|
+
className={
|
|
537
|
+
insight.actionIcon
|
|
538
|
+
? `fa-light ${insight.actionIcon} text-xs`
|
|
539
|
+
: "fa-duotone fa-solid fa-star-christmas text-xs text-brand"
|
|
540
|
+
}
|
|
541
|
+
aria-hidden="true"
|
|
542
|
+
/>
|
|
543
|
+
{insight.actionLabel ?? "Ask Leo"}
|
|
544
|
+
</Button>
|
|
545
|
+
</InsightDefaultTooltip>
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
</Card>
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Severity icon badge for the insight card */
|
|
553
|
+
|
|
554
|
+
function InsightBadge({
|
|
555
|
+
severity = "warning",
|
|
556
|
+
size = "default",
|
|
557
|
+
}: {
|
|
558
|
+
severity?: MetricInsight["severity"]
|
|
559
|
+
size?: "default" | "sm"
|
|
560
|
+
}) {
|
|
561
|
+
const styles = {
|
|
562
|
+
warning: {
|
|
563
|
+
bg: "bg-[var(--insight-severity-warning-bg)]",
|
|
564
|
+
icon: "fa-circle-exclamation",
|
|
565
|
+
color: "text-[var(--insight-severity-warning-fg)]",
|
|
566
|
+
},
|
|
567
|
+
info: {
|
|
568
|
+
bg: "bg-[var(--insight-severity-info-bg)]",
|
|
569
|
+
icon: "fa-circle-info",
|
|
570
|
+
color: "text-[var(--insight-severity-info-fg)]",
|
|
571
|
+
},
|
|
572
|
+
error: { bg: "bg-destructive/15", icon: "fa-circle-xmark", color: "text-destructive" },
|
|
573
|
+
}[severity]
|
|
574
|
+
|
|
575
|
+
return (
|
|
576
|
+
<span
|
|
577
|
+
className={cn(
|
|
578
|
+
"inline-flex shrink-0 items-center justify-center rounded-full",
|
|
579
|
+
size === "sm" ? "h-6 w-6 text-xs" : "h-7 w-7 text-sm",
|
|
580
|
+
styles.bg,
|
|
581
|
+
styles.color
|
|
582
|
+
)}
|
|
583
|
+
aria-hidden="true"
|
|
584
|
+
>
|
|
585
|
+
<i className={`fa-light ${styles.icon}`} />
|
|
586
|
+
</span>
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/* ── Shared inner content ─────────────────────────────────────────────────── */
|
|
591
|
+
|
|
592
|
+
interface InnerProps {
|
|
593
|
+
title: string
|
|
594
|
+
description: string
|
|
595
|
+
period: string
|
|
596
|
+
periods: PeriodOption[]
|
|
597
|
+
metrics: MetricItem[]
|
|
598
|
+
rows: MetricItem[][]
|
|
599
|
+
insight?: MetricInsight
|
|
600
|
+
onPeriodChange: (v: string) => void
|
|
601
|
+
/** Extra padding class injected by flat variant */
|
|
602
|
+
innerPadding?: string
|
|
603
|
+
/** When false, the header (title/description/period select) is hidden */
|
|
604
|
+
showHeader?: boolean
|
|
605
|
+
insightCompact?: boolean
|
|
606
|
+
insightFullWidth?: boolean
|
|
607
|
+
metricsSingleRow?: boolean
|
|
608
|
+
/** Tighter KPI cells + 2-col mobile grid (half-width dashboard card). */
|
|
609
|
+
metricsHalfWidthLayout?: boolean
|
|
610
|
+
/** Opaque fill behind each KPI cell when using hairline grid gaps (below `lg`). */
|
|
611
|
+
metricsCellSurfaceClassName?: string
|
|
612
|
+
/** Flat list-page band: softer dividers + tinted cells on a lavender-tinted surface */
|
|
613
|
+
surfaceVariant?: "default" | "flat"
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function KeyMetricsInner({
|
|
617
|
+
title,
|
|
618
|
+
description,
|
|
619
|
+
period,
|
|
620
|
+
periods,
|
|
621
|
+
metrics,
|
|
622
|
+
rows,
|
|
623
|
+
insight,
|
|
624
|
+
onPeriodChange,
|
|
625
|
+
innerPadding = "",
|
|
626
|
+
showHeader = true,
|
|
627
|
+
insightCompact = false,
|
|
628
|
+
insightFullWidth = false,
|
|
629
|
+
metricsSingleRow = false,
|
|
630
|
+
metricsHalfWidthLayout = false,
|
|
631
|
+
metricsCellSurfaceClassName = "bg-background",
|
|
632
|
+
surfaceVariant = "default",
|
|
633
|
+
}: InnerProps) {
|
|
634
|
+
const isFlatBand = surfaceVariant === "flat"
|
|
635
|
+
const metricsGridClassName = isFlatBand
|
|
636
|
+
? flatMetricsHairlineClass(metrics.length, metricsHalfWidthLayout)
|
|
637
|
+
: "gap-px bg-border"
|
|
638
|
+
/** Side-by-side KPI + insight rail (md+). Disabled for half-width dashboard cards — insight stacks below. */
|
|
639
|
+
const insightSideBySide = insight && !insightFullWidth && !metricsHalfWidthLayout
|
|
640
|
+
const stackedRailInsight = insight && !insightFullWidth && metricsHalfWidthLayout
|
|
641
|
+
|
|
642
|
+
return (
|
|
643
|
+
<div data-slot="key-metrics" className="contents">
|
|
644
|
+
{/* ── Header ──────────────────────────────────────────────────── */}
|
|
645
|
+
{showHeader && (
|
|
646
|
+
<div className={cn(
|
|
647
|
+
"flex flex-col gap-2 pb-3",
|
|
648
|
+
"sm:flex-row sm:items-center sm:justify-between sm:gap-4",
|
|
649
|
+
innerPadding
|
|
650
|
+
)}>
|
|
651
|
+
<div>
|
|
652
|
+
<p className="text-base font-semibold text-foreground leading-tight">{title}</p>
|
|
653
|
+
<p className="mt-0.5 text-sm text-muted-foreground">{description}</p>
|
|
654
|
+
</div>
|
|
655
|
+
|
|
656
|
+
{/* Period selector — align="end" keeps dropdown flush-right */}
|
|
657
|
+
<Select value={period} onValueChange={onPeriodChange}>
|
|
658
|
+
<SelectTrigger
|
|
659
|
+
className="h-8 w-full sm:w-auto sm:min-w-[9rem] shrink-0 text-sm"
|
|
660
|
+
aria-label="Select comparison period"
|
|
661
|
+
>
|
|
662
|
+
<SelectValue />
|
|
663
|
+
</SelectTrigger>
|
|
664
|
+
<SelectContent align="end" sideOffset={4}>
|
|
665
|
+
{periods.map((p) => (
|
|
666
|
+
<SelectItem key={p.value} value={p.value}>
|
|
667
|
+
{p.label}
|
|
668
|
+
</SelectItem>
|
|
669
|
+
))}
|
|
670
|
+
</SelectContent>
|
|
671
|
+
</Select>
|
|
672
|
+
</div>
|
|
673
|
+
)}
|
|
674
|
+
|
|
675
|
+
{/* ── Body: metrics grid + optional insight ───────────────────── */}
|
|
676
|
+
<div
|
|
677
|
+
className={cn(
|
|
678
|
+
"flex flex-col gap-0",
|
|
679
|
+
/* 60% KPIs / 40% insight (3fr:2fr); lg+ only so phones/tablets stack KPIs + insight */
|
|
680
|
+
insightSideBySide &&
|
|
681
|
+
"lg:grid lg:grid-cols-[minmax(0,3fr)_minmax(13rem,2fr)] lg:items-stretch lg:gap-x-6 lg:gap-y-0",
|
|
682
|
+
innerPadding
|
|
683
|
+
)}
|
|
684
|
+
>
|
|
685
|
+
|
|
686
|
+
{/* Metrics section — self-start so KPI cells don’t stretch when the insight column is taller */}
|
|
687
|
+
<div
|
|
688
|
+
className={cn(
|
|
689
|
+
"min-w-0 lg:flex lg:min-h-0 lg:flex-col",
|
|
690
|
+
!insightSideBySide && "w-full",
|
|
691
|
+
insightSideBySide && "lg:self-start"
|
|
692
|
+
)}
|
|
693
|
+
>
|
|
694
|
+
{/*
|
|
695
|
+
Phone (<md): one column. Tablet (md–lg): 2-column grid (e.g. 2×2 for four KPIs).
|
|
696
|
+
Hairline separators use gap-px + opaque cell surfaces (divide-* breaks for 2-col order).
|
|
697
|
+
Half-width dashboard cards keep divide-x + optional template columns.
|
|
698
|
+
*/}
|
|
699
|
+
{metricsHalfWidthLayout ? (
|
|
700
|
+
<div
|
|
701
|
+
className={cn(
|
|
702
|
+
"@container/metrics-strip grid lg:hidden",
|
|
703
|
+
metricsSingleRow
|
|
704
|
+
? metricsRowColumnsClass(metrics.length, /* half */ true)
|
|
705
|
+
: "grid-cols-2",
|
|
706
|
+
metricsGridClassName,
|
|
707
|
+
)}
|
|
708
|
+
>
|
|
709
|
+
{metrics.map((m) => (
|
|
710
|
+
<div key={m.id} className={cn("min-w-0", metricsCellSurfaceClassName)}>
|
|
711
|
+
<MetricCell {...m} dense edgeGutter={false} />
|
|
712
|
+
</div>
|
|
713
|
+
))}
|
|
714
|
+
</div>
|
|
715
|
+
) : (
|
|
716
|
+
<div
|
|
717
|
+
className={cn(
|
|
718
|
+
"@container/metrics-strip grid lg:hidden",
|
|
719
|
+
metricsRowColumnsClass(metrics.length, /* half */ false),
|
|
720
|
+
metricsGridClassName,
|
|
721
|
+
)}
|
|
722
|
+
>
|
|
723
|
+
{metrics.map((m) => (
|
|
724
|
+
<div key={m.id} className={cn("min-w-0", metricsCellSurfaceClassName)}>
|
|
725
|
+
<MetricCell {...m} dense={false} edgeGutter={false} />
|
|
726
|
+
</div>
|
|
727
|
+
))}
|
|
728
|
+
</div>
|
|
729
|
+
)}
|
|
730
|
+
|
|
731
|
+
{/*
|
|
732
|
+
lg+: row-by-row container-queried grid. Uses a `gap-px + bg` hairline
|
|
733
|
+
instead of `divide-x` so dividers render correctly when the row wraps
|
|
734
|
+
from 4-across to a 2×2 grid (the awkward 3+1 layout is skipped — see
|
|
735
|
+
`metricsRowColumnsClass`).
|
|
736
|
+
*/}
|
|
737
|
+
<div className="@container/metrics-strip hidden lg:block">
|
|
738
|
+
{rows.map((row, rowIdx) => (
|
|
739
|
+
<React.Fragment key={rowIdx}>
|
|
740
|
+
{rowIdx > 0 && !isFlatBand && (
|
|
741
|
+
<Separator aria-hidden="true" className="my-1" />
|
|
742
|
+
)}
|
|
743
|
+
<div
|
|
744
|
+
className={cn(
|
|
745
|
+
"grid",
|
|
746
|
+
metricsRowColumnsClass(row.length, metricsHalfWidthLayout),
|
|
747
|
+
isFlatBand
|
|
748
|
+
? flatMetricsHairlineClass(row.length, metricsHalfWidthLayout)
|
|
749
|
+
: metricsGridClassName,
|
|
750
|
+
)}
|
|
751
|
+
>
|
|
752
|
+
{row.map((m) => (
|
|
753
|
+
<div key={m.id} className={cn("min-w-0", metricsCellSurfaceClassName)}>
|
|
754
|
+
<MetricCell {...m} dense={metricsHalfWidthLayout} edgeGutter={false} />
|
|
755
|
+
</div>
|
|
756
|
+
))}
|
|
757
|
+
</div>
|
|
758
|
+
</React.Fragment>
|
|
759
|
+
))}
|
|
760
|
+
</div>
|
|
761
|
+
</div>
|
|
762
|
+
|
|
763
|
+
{/* Insight card — only rendered when data provided */}
|
|
764
|
+
{insight && (
|
|
765
|
+
<>
|
|
766
|
+
{insightFullWidth ? (
|
|
767
|
+
<Separator
|
|
768
|
+
aria-hidden="true"
|
|
769
|
+
className={cn("my-4 w-full", isFlatBand && "bg-foreground/[0.06]")}
|
|
770
|
+
/>
|
|
771
|
+
) : stackedRailInsight ? (
|
|
772
|
+
<Separator
|
|
773
|
+
aria-hidden="true"
|
|
774
|
+
className={cn("my-4 w-full", isFlatBand && "bg-foreground/[0.06]")}
|
|
775
|
+
/>
|
|
776
|
+
) : (
|
|
777
|
+
<Separator
|
|
778
|
+
aria-hidden="true"
|
|
779
|
+
className={cn("my-3 lg:hidden", isFlatBand && "bg-foreground/[0.055]")}
|
|
780
|
+
/>
|
|
781
|
+
)}
|
|
782
|
+
|
|
783
|
+
<div
|
|
784
|
+
className={cn(
|
|
785
|
+
"flex min-h-0 min-w-0 w-full flex-col",
|
|
786
|
+
/* Divider + padding replace vertical Separator so grid stays 2 columns */
|
|
787
|
+
insightSideBySide &&
|
|
788
|
+
!insightFullWidth &&
|
|
789
|
+
cn(
|
|
790
|
+
"lg:h-full lg:ps-6",
|
|
791
|
+
/* Flat band: insight card ring is the divider — skip `border-l` (double line). */
|
|
792
|
+
!isFlatBand && "lg:border-s lg:border-border",
|
|
793
|
+
)
|
|
794
|
+
)}
|
|
795
|
+
>
|
|
796
|
+
{insight && !insightFullWidth ? (
|
|
797
|
+
<InsightRailStatementAction insight={insight} compact={insightCompact} />
|
|
798
|
+
) : (
|
|
799
|
+
<Card
|
|
800
|
+
role="region"
|
|
801
|
+
aria-label="Insight"
|
|
802
|
+
className={cn(
|
|
803
|
+
"overflow-hidden rounded-lg p-0 ring-1 ring-foreground/8 shadow-none",
|
|
804
|
+
"flex min-h-0 flex-col bg-muted/25"
|
|
805
|
+
)}
|
|
806
|
+
>
|
|
807
|
+
{insightCompact ? (
|
|
808
|
+
<div className="flex min-h-0 flex-1 flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between md:gap-8 md:p-5">
|
|
809
|
+
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
810
|
+
<div className="flex items-start gap-2.5">
|
|
811
|
+
<InsightBadge severity={insight.severity} size="sm" />
|
|
812
|
+
<div className="flex min-w-0 flex-1 items-start justify-between gap-2">
|
|
813
|
+
<p className="text-base font-semibold leading-tight text-foreground">
|
|
814
|
+
{insight.title}
|
|
815
|
+
</p>
|
|
816
|
+
{insight.href && (
|
|
817
|
+
<a
|
|
818
|
+
href={insight.href}
|
|
819
|
+
className="shrink-0 text-muted-foreground transition-colors hover:text-interactive-hover-foreground focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-ring"
|
|
820
|
+
aria-label={`Open ${insight.title} — details`}
|
|
821
|
+
>
|
|
822
|
+
<i className="fa-light fa-arrow-up-right text-xs" aria-hidden="true" />
|
|
823
|
+
</a>
|
|
824
|
+
)}
|
|
825
|
+
</div>
|
|
826
|
+
</div>
|
|
827
|
+
{insight.description ? (
|
|
828
|
+
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
829
|
+
{insight.description}
|
|
830
|
+
</p>
|
|
831
|
+
) : null}
|
|
832
|
+
</div>
|
|
833
|
+
<div className="flex w-full shrink-0 md:w-auto">
|
|
834
|
+
<InsightDefaultTooltip actionLabel={insight.actionLabel}>
|
|
835
|
+
<Button
|
|
836
|
+
variant="ghost"
|
|
837
|
+
size="sm"
|
|
838
|
+
className="h-9 w-full gap-1.5 px-4 text-xs text-muted-foreground hover:text-interactive-hover-foreground md:min-w-[8.5rem]"
|
|
839
|
+
onClick={insight.onAction}
|
|
840
|
+
aria-label={insight.actionLabel ?? "Ask Leo"}
|
|
841
|
+
>
|
|
842
|
+
<i
|
|
843
|
+
className={insight.actionIcon ? `fa-light ${insight.actionIcon} text-xs` : "fa-duotone fa-solid fa-star-christmas text-xs text-brand"}
|
|
844
|
+
aria-hidden="true"
|
|
845
|
+
/>
|
|
846
|
+
{insight.actionLabel ?? "Ask Leo"}
|
|
847
|
+
</Button>
|
|
848
|
+
</InsightDefaultTooltip>
|
|
849
|
+
</div>
|
|
850
|
+
</div>
|
|
851
|
+
) : (
|
|
852
|
+
<div className="flex min-h-0 flex-1 flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between md:gap-8 md:p-5">
|
|
853
|
+
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
|
854
|
+
<div className="flex items-start gap-3">
|
|
855
|
+
<InsightBadge severity={insight.severity} />
|
|
856
|
+
<div className="flex min-w-0 flex-1 items-start justify-between gap-2">
|
|
857
|
+
<p className="text-base font-semibold leading-snug text-foreground">
|
|
858
|
+
{insight.title}
|
|
859
|
+
</p>
|
|
860
|
+
{insight.href && (
|
|
861
|
+
<a
|
|
862
|
+
href={insight.href}
|
|
863
|
+
className="shrink-0 text-muted-foreground transition-colors hover:text-interactive-hover-foreground focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-ring"
|
|
864
|
+
aria-label={`Open ${insight.title} — details`}
|
|
865
|
+
>
|
|
866
|
+
<i className="fa-light fa-arrow-up-right text-xs" aria-hidden="true" />
|
|
867
|
+
</a>
|
|
868
|
+
)}
|
|
869
|
+
</div>
|
|
870
|
+
</div>
|
|
871
|
+
{insight.description ? (
|
|
872
|
+
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
873
|
+
{insight.description}
|
|
874
|
+
</p>
|
|
875
|
+
) : null}
|
|
876
|
+
</div>
|
|
877
|
+
<div className="flex w-full shrink-0 md:w-auto">
|
|
878
|
+
<InsightDefaultTooltip actionLabel={insight.actionLabel}>
|
|
879
|
+
<Button
|
|
880
|
+
variant="ghost"
|
|
881
|
+
size="sm"
|
|
882
|
+
className="h-9 w-full gap-1.5 px-4 text-xs text-muted-foreground hover:text-interactive-hover-foreground md:min-w-[8.5rem]"
|
|
883
|
+
onClick={insight.onAction}
|
|
884
|
+
aria-label={insight.actionLabel ?? "Ask Leo"}
|
|
885
|
+
>
|
|
886
|
+
<i
|
|
887
|
+
className={insight.actionIcon ? `fa-light ${insight.actionIcon} text-xs` : "fa-duotone fa-solid fa-star-christmas text-xs text-brand"}
|
|
888
|
+
aria-hidden="true"
|
|
889
|
+
/>
|
|
890
|
+
{insight.actionLabel ?? "Ask Leo"}
|
|
891
|
+
</Button>
|
|
892
|
+
</InsightDefaultTooltip>
|
|
893
|
+
</div>
|
|
894
|
+
</div>
|
|
895
|
+
)}
|
|
896
|
+
</Card>
|
|
897
|
+
)}
|
|
898
|
+
</div>
|
|
899
|
+
</>
|
|
900
|
+
)}
|
|
901
|
+
</div>
|
|
902
|
+
</div>
|
|
903
|
+
)
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function chunkMetricPairs(metrics: MetricItem[]): MetricItem[][] {
|
|
907
|
+
const out: MetricItem[][] = []
|
|
908
|
+
for (let i = 0; i < metrics.length; i += 2) out.push(metrics.slice(i, i + 2))
|
|
909
|
+
return out
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/* ── Main component ───────────────────────────────────────────────────────── */
|
|
913
|
+
|
|
914
|
+
export function KeyMetrics({
|
|
915
|
+
variant = "card",
|
|
916
|
+
title = "Key Metrics",
|
|
917
|
+
description = "Overview of performance indicators",
|
|
918
|
+
metrics = [],
|
|
919
|
+
insight,
|
|
920
|
+
periods = DEFAULT_PERIODS,
|
|
921
|
+
defaultPeriod = "week",
|
|
922
|
+
onPeriodChange,
|
|
923
|
+
showHeader = true,
|
|
924
|
+
insightCompact = false,
|
|
925
|
+
insightFullWidth = false,
|
|
926
|
+
metricsSingleRow = false,
|
|
927
|
+
metricsHalfWidthLayout = false,
|
|
928
|
+
className,
|
|
929
|
+
}: KeyMetricsProps) {
|
|
930
|
+
const [period, setPeriod] = React.useState(defaultPeriod)
|
|
931
|
+
// Pull the host app's "default insight action" (e.g. toggle Ask Leo
|
|
932
|
+
// sidebar) and label out of `KeyMetricsProvider` instead of
|
|
933
|
+
// hardcoding `useAskLeo()`. When no provider is mounted, the default
|
|
934
|
+
// action button hides entirely so the strip stays useful in apps
|
|
935
|
+
// without an AI assistant.
|
|
936
|
+
const {
|
|
937
|
+
defaultInsightAction,
|
|
938
|
+
defaultActionLabel = "Ask Leo",
|
|
939
|
+
} = useKeyMetricsContext()
|
|
940
|
+
|
|
941
|
+
function handlePeriodChange(v: string) {
|
|
942
|
+
setPeriod(v)
|
|
943
|
+
onPeriodChange?.(v)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/* Split metrics into rows of 3, or paired rows when half-width + single row, else one row */
|
|
947
|
+
const rows: MetricItem[][] = metricsSingleRow
|
|
948
|
+
? metrics.length
|
|
949
|
+
? metricsHalfWidthLayout
|
|
950
|
+
? chunkMetricPairs(metrics)
|
|
951
|
+
: [metrics]
|
|
952
|
+
: []
|
|
953
|
+
: (() => {
|
|
954
|
+
const out: MetricItem[][] = []
|
|
955
|
+
for (let i = 0; i < metrics.length; i += 3) {
|
|
956
|
+
out.push(metrics.slice(i, i + 3))
|
|
957
|
+
}
|
|
958
|
+
return out
|
|
959
|
+
})()
|
|
960
|
+
|
|
961
|
+
const metricsCellSurfaceClassName =
|
|
962
|
+
variant === "flat"
|
|
963
|
+
? "bg-transparent"
|
|
964
|
+
: "bg-card dark:bg-transparent"
|
|
965
|
+
|
|
966
|
+
const innerProps: InnerProps = {
|
|
967
|
+
title,
|
|
968
|
+
description,
|
|
969
|
+
period,
|
|
970
|
+
periods,
|
|
971
|
+
metrics,
|
|
972
|
+
rows,
|
|
973
|
+
insight,
|
|
974
|
+
onPeriodChange: handlePeriodChange,
|
|
975
|
+
insightCompact,
|
|
976
|
+
insightFullWidth,
|
|
977
|
+
metricsSingleRow,
|
|
978
|
+
metricsHalfWidthLayout,
|
|
979
|
+
metricsCellSurfaceClassName,
|
|
980
|
+
surfaceVariant: variant === "flat" ? "flat" : "default",
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/*
|
|
984
|
+
* ── GLOW GUIDELINE ────────────────────────────────────────────────────────
|
|
985
|
+
* The bottom-glow treatment is a deliberate design signal. Use it only for:
|
|
986
|
+
*
|
|
987
|
+
* 1. AI / intelligence surfaces — e.g. AI Insights, Ask Leo responses,
|
|
988
|
+
* any card that surfaces machine-generated content.
|
|
989
|
+
* Opacity: 0.12–0.16 (subtle; the glow should not dominate)
|
|
990
|
+
*
|
|
991
|
+
* 2. Designer-designated hero sections — e.g. Key Metrics (the primary
|
|
992
|
+
* KPI band), onboarding completion, or any section the product team
|
|
993
|
+
* explicitly wants to "elevate" visually.
|
|
994
|
+
* Opacity: 0.18–0.24 (more pronounced; intentional focal point)
|
|
995
|
+
*
|
|
996
|
+
* Do NOT add glow to:
|
|
997
|
+
* • Standard data/content cards (Tasks, Activity, Learn, Charts…)
|
|
998
|
+
* • Navigation or shell elements
|
|
999
|
+
* • Cards that already use a coloured border or badge for status
|
|
1000
|
+
*
|
|
1001
|
+
* Implementation:
|
|
1002
|
+
* style={{ background: "radial-gradient(ellipse 110% 90% at 50% 100%,
|
|
1003
|
+
* oklch(from var(--brand-color) l c h / <opacity>) 0%, transparent 68%)" }}
|
|
1004
|
+
* + className="overflow-hidden" ← required to clip the gradient
|
|
1005
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
1006
|
+
*/
|
|
1007
|
+
const glowStyle: React.CSSProperties = {
|
|
1008
|
+
background: "var(--key-metrics-card-glow-radial)",
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** List-page KPI band — transparent; only `--key-metrics-flat-band-radial` glow. */
|
|
1012
|
+
const flatBandStyle: React.CSSProperties = {
|
|
1013
|
+
background: "var(--key-metrics-flat-band-radial)",
|
|
1014
|
+
boxShadow: "var(--key-metrics-flat-band-shadow)",
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/* ── Card variant — ChartCard-style chrome ───────────────────────────── */
|
|
1018
|
+
if (variant === "card") {
|
|
1019
|
+
return (
|
|
1020
|
+
<Card className={cn("shadow-xs overflow-hidden flex flex-col", className)} style={glowStyle}>
|
|
1021
|
+
<CardHeader className={cn("shrink-0 pb-2", metricsHalfWidthLayout && "space-y-2")}>
|
|
1022
|
+
<div
|
|
1023
|
+
className={cn(
|
|
1024
|
+
"flex gap-2",
|
|
1025
|
+
metricsHalfWidthLayout
|
|
1026
|
+
? "flex-col min-[400px]:flex-row min-[400px]:items-start min-[400px]:justify-between"
|
|
1027
|
+
: "items-start",
|
|
1028
|
+
)}
|
|
1029
|
+
>
|
|
1030
|
+
<div className="flex-1 min-w-0">
|
|
1031
|
+
<CardTitle className="text-sm font-semibold leading-tight">{title}</CardTitle>
|
|
1032
|
+
<CardDescription className="text-xs mt-0.5">{description}</CardDescription>
|
|
1033
|
+
</div>
|
|
1034
|
+
<div className="flex flex-wrap items-center gap-1.5 shrink-0">
|
|
1035
|
+
{defaultInsightAction ? (
|
|
1036
|
+
<InsightDefaultTooltip actionLabel={defaultActionLabel}>
|
|
1037
|
+
<Button
|
|
1038
|
+
size="sm"
|
|
1039
|
+
variant="outline"
|
|
1040
|
+
className="h-7 shrink-0 text-xs gap-1.5 px-2"
|
|
1041
|
+
aria-label={`${defaultActionLabel} about these metrics`}
|
|
1042
|
+
onClick={defaultInsightAction}
|
|
1043
|
+
type="button"
|
|
1044
|
+
>
|
|
1045
|
+
<i className="fa-duotone fa-solid fa-star-christmas text-xs text-brand" aria-hidden="true" />
|
|
1046
|
+
<span>{defaultActionLabel}</span>
|
|
1047
|
+
</Button>
|
|
1048
|
+
</InsightDefaultTooltip>
|
|
1049
|
+
) : null}
|
|
1050
|
+
<Select value={period} onValueChange={handlePeriodChange}>
|
|
1051
|
+
<SelectTrigger
|
|
1052
|
+
size="sm"
|
|
1053
|
+
className="w-auto min-w-[9rem] shrink-0 text-sm"
|
|
1054
|
+
aria-label="Select comparison period"
|
|
1055
|
+
>
|
|
1056
|
+
<SelectValue />
|
|
1057
|
+
</SelectTrigger>
|
|
1058
|
+
<SelectContent align="end" sideOffset={4}>
|
|
1059
|
+
{periods.map((p) => (
|
|
1060
|
+
<SelectItem key={p.value} value={p.value}>
|
|
1061
|
+
{p.label}
|
|
1062
|
+
</SelectItem>
|
|
1063
|
+
))}
|
|
1064
|
+
</SelectContent>
|
|
1065
|
+
</Select>
|
|
1066
|
+
</div>
|
|
1067
|
+
</div>
|
|
1068
|
+
</CardHeader>
|
|
1069
|
+
<CardContent className="flex-1 pb-4">
|
|
1070
|
+
<KeyMetricsInner {...innerProps} showHeader={false} />
|
|
1071
|
+
</CardContent>
|
|
1072
|
+
</Card>
|
|
1073
|
+
)
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/* ── Compact variant — card chrome, no header, metrics only ──────────── */
|
|
1077
|
+
if (variant === "compact") {
|
|
1078
|
+
return (
|
|
1079
|
+
<Card className={cn("shadow-xs overflow-hidden", className)} style={glowStyle}>
|
|
1080
|
+
<CardContent className="py-3 px-4">
|
|
1081
|
+
<KeyMetricsInner {...innerProps} showHeader={false} />
|
|
1082
|
+
</CardContent>
|
|
1083
|
+
</Card>
|
|
1084
|
+
)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/* ── Flat variant — no surface; bottom brand glow only ── */
|
|
1088
|
+
return (
|
|
1089
|
+
<section
|
|
1090
|
+
aria-label={title}
|
|
1091
|
+
className={cn("relative w-full overflow-hidden pt-5 pb-8", className)}
|
|
1092
|
+
style={flatBandStyle}
|
|
1093
|
+
>
|
|
1094
|
+
<KeyMetricsInner
|
|
1095
|
+
{...innerProps}
|
|
1096
|
+
innerPadding="px-4 lg:px-6"
|
|
1097
|
+
showHeader={showHeader}
|
|
1098
|
+
/>
|
|
1099
|
+
</section>
|
|
1100
|
+
)
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* KeyMetricsContent — renders just the metrics grid + optional insight panel.
|
|
1105
|
+
* No card wrapper, no header, no period selector.
|
|
1106
|
+
* Designed for embedding inside a ChartCard with tabOptions period tabs.
|
|
1107
|
+
*/
|
|
1108
|
+
export function KeyMetricsContent({
|
|
1109
|
+
metrics = [],
|
|
1110
|
+
insight,
|
|
1111
|
+
insightCompact = false,
|
|
1112
|
+
insightFullWidth = false,
|
|
1113
|
+
}: Pick<KeyMetricsProps, "metrics" | "insight" | "insightCompact" | "insightFullWidth">) {
|
|
1114
|
+
const rows: MetricItem[][] = []
|
|
1115
|
+
for (let i = 0; i < metrics.length; i += 3) rows.push(metrics.slice(i, i + 3))
|
|
1116
|
+
|
|
1117
|
+
return (
|
|
1118
|
+
<KeyMetricsInner
|
|
1119
|
+
title=""
|
|
1120
|
+
description=""
|
|
1121
|
+
period=""
|
|
1122
|
+
periods={[]}
|
|
1123
|
+
metrics={metrics}
|
|
1124
|
+
rows={rows}
|
|
1125
|
+
insight={insight}
|
|
1126
|
+
onPeriodChange={() => {}}
|
|
1127
|
+
showHeader={false}
|
|
1128
|
+
insightCompact={insightCompact}
|
|
1129
|
+
insightFullWidth={insightFullWidth}
|
|
1130
|
+
metricsCellSurfaceClassName="bg-card dark:bg-transparent"
|
|
1131
|
+
/>
|
|
1132
|
+
)
|
|
1133
|
+
}
|