@eifi1/ui-kit 0.4.0 → 0.5.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/LICENSE +32 -0
- package/README.md +498 -57
- package/dist/chart.d.ts +10 -0
- package/dist/chart.js +10 -0
- package/dist/chart.js.map +1 -0
- package/dist/components/account-settings.d.ts +11 -0
- package/dist/components/account-settings.js +19 -12
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/alert-banner.d.ts +10 -6
- package/dist/components/alert-banner.js +20 -16
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +30 -4
- package/dist/components/amount-input.js +36 -14
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/calculator.d.ts +24 -7
- package/dist/components/calculator.js +37 -20
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/chart-zoom.d.ts +156 -0
- package/dist/components/chart-zoom.js +262 -0
- package/dist/components/chart-zoom.js.map +1 -0
- package/dist/components/chart.d.ts +28 -3
- package/dist/components/chart.js +71 -32
- package/dist/components/chart.js.map +1 -1
- package/dist/components/checkbox.d.ts +51 -0
- package/dist/components/checkbox.js +123 -0
- package/dist/components/checkbox.js.map +1 -0
- package/dist/components/chip.d.ts +128 -0
- package/dist/components/chip.js +357 -0
- package/dist/components/chip.js.map +1 -0
- package/dist/components/choice-parts.d.ts +24 -0
- package/dist/components/choice-parts.js +18 -0
- package/dist/components/choice-parts.js.map +1 -0
- package/dist/components/combobox-core.d.ts +52 -9
- package/dist/components/combobox-core.js +91 -20
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +43 -33
- package/dist/components/combobox.js +409 -340
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/currency-select.d.ts +48 -8
- package/dist/components/currency-select.js +107 -65
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +15 -3
- package/dist/components/data-table-filter-popover.js +23 -17
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-filters.js +3 -2
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +53 -2
- package/dist/components/data-table-labels.js +31 -2
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-pagination.d.ts +3 -1
- package/dist/components/data-table-pagination.js +16 -10
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-sort.js +1 -0
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +201 -85
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +25 -9
- package/dist/components/date-picker.js +232 -94
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/dropdown.d.ts +96 -20
- package/dist/components/dropdown.js +77 -33
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +5 -4
- package/dist/components/entity-combobox.js +107 -76
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +36 -0
- package/dist/components/facing-pair.js +40 -0
- package/dist/components/facing-pair.js.map +1 -0
- package/dist/components/field-sync.d.ts +164 -0
- package/dist/components/field-sync.js +264 -0
- package/dist/components/field-sync.js.map +1 -0
- package/dist/components/file-dropzone.d.ts +38 -6
- package/dist/components/file-dropzone.js +34 -9
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/full-bleed-dialog.d.ts +37 -26
- package/dist/components/full-bleed-dialog.js +20 -5
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/grouped-picker.d.ts +21 -10
- package/dist/components/grouped-picker.js +66 -52
- package/dist/components/grouped-picker.js.map +1 -1
- package/dist/components/hover-menu.d.ts +20 -4
- package/dist/components/hover-menu.js +15 -4
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +102 -14
- package/dist/components/mini-calendar.js +256 -70
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +9 -3
- package/dist/components/modal.js +34 -45
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +79 -0
- package/dist/components/month-picker.js +342 -0
- package/dist/components/month-picker.js.map +1 -0
- package/dist/components/multi-entity-combobox.d.ts +7 -6
- package/dist/components/multi-entity-combobox.js +112 -72
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +13 -11
- package/dist/components/multi-select.js +161 -84
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +92 -0
- package/dist/components/number-field.js +111 -0
- package/dist/components/number-field.js.map +1 -0
- package/dist/components/number-input.d.ts +28 -2
- package/dist/components/number-input.js +12 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +32 -4
- package/dist/components/numpad-sheet.js +30 -13
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/page-contents.d.ts +90 -0
- package/dist/components/page-contents.js +170 -0
- package/dist/components/page-contents.js.map +1 -0
- package/dist/components/password-strength.d.ts +106 -0
- package/dist/components/password-strength.js +132 -0
- package/dist/components/password-strength.js.map +1 -0
- package/dist/components/picker-sheet.d.ts +23 -15
- package/dist/components/picker-sheet.js +34 -14
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/popover.d.ts +62 -4
- package/dist/components/popover.js +52 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/search-field.d.ts +25 -7
- package/dist/components/search-field.js +7 -5
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/series-chart-labels.d.ts +14 -0
- package/dist/components/series-chart-labels.js +11 -0
- package/dist/components/series-chart-labels.js.map +1 -0
- package/dist/components/series-chart.d.ts +207 -0
- package/dist/components/series-chart.js +223 -0
- package/dist/components/series-chart.js.map +1 -0
- package/dist/components/settings-fields.d.ts +14 -4
- package/dist/components/settings-fields.js +19 -8
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/signature-pad.d.ts +119 -0
- package/dist/components/signature-pad.js +405 -0
- package/dist/components/signature-pad.js.map +1 -0
- package/dist/components/slider.d.ts +80 -0
- package/dist/components/slider.js +155 -0
- package/dist/components/slider.js.map +1 -0
- package/dist/components/sparkline.d.ts +93 -0
- package/dist/components/sparkline.js +205 -0
- package/dist/components/sparkline.js.map +1 -0
- package/dist/components/stat-tile.d.ts +151 -0
- package/dist/components/stat-tile.js +236 -0
- package/dist/components/stat-tile.js.map +1 -0
- package/dist/components/swipeable-row.d.ts +25 -2
- package/dist/components/swipeable-row.js +31 -0
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/switch.d.ts +60 -0
- package/dist/components/switch.js +81 -0
- package/dist/components/switch.js.map +1 -0
- package/dist/components/time-input.d.ts +58 -0
- package/dist/components/time-input.js +50 -0
- package/dist/components/time-input.js.map +1 -0
- package/dist/components/toggle-group.d.ts +18 -3
- package/dist/components/toggle-group.js +10 -6
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +115 -0
- package/dist/components/toggle-legend.js +114 -0
- package/dist/components/toggle-legend.js.map +1 -0
- package/dist/components/tooltip.d.ts +40 -11
- package/dist/components/tooltip.js +77 -20
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/treemap.d.ts +132 -0
- package/dist/components/treemap.js +179 -0
- package/dist/components/treemap.js.map +1 -0
- package/dist/components/ui.d.ts +159 -46
- package/dist/components/ui.js +220 -115
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-mobile-reveal.js +1 -0
- package/dist/components/use-mobile-reveal.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/use-table-state.js +38 -17
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/components/user-avatar.d.ts +15 -4
- package/dist/components/user-avatar.js +5 -4
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/components/wizard-stepper.js +3 -2
- package/dist/components/wizard-stepper.js.map +1 -1
- package/dist/{data-table-filters-aXyf0Xub.d.ts → data-table-filters-DM1auUfH.d.ts} +27 -5
- package/dist/data-table.d.ts +7 -0
- package/dist/data-table.js +12 -0
- package/dist/data-table.js.map +1 -0
- package/dist/feedback/feedback-attachment.js +12 -11
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.js +2 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +33 -7
- package/dist/feedback/feedback-inbox.js +49 -37
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback.d.ts +5 -0
- package/dist/feedback.js +5 -0
- package/dist/feedback.js.map +1 -0
- package/dist/hooks/use-anchored-panel.js +1 -0
- package/dist/hooks/use-anchored-panel.js.map +1 -1
- package/dist/hooks/use-anchored-rect.js +1 -0
- package/dist/hooks/use-anchored-rect.js.map +1 -1
- package/dist/hooks/use-announce.d.ts +46 -0
- package/dist/hooks/use-announce.js +37 -0
- package/dist/hooks/use-announce.js.map +1 -0
- package/dist/hooks/use-body-scroll-lock.js +1 -0
- package/dist/hooks/use-body-scroll-lock.js.map +1 -1
- package/dist/hooks/use-close-transition.js +1 -0
- package/dist/hooks/use-close-transition.js.map +1 -1
- package/dist/hooks/use-dismiss.js +1 -0
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/hooks/use-focus-trap.d.ts +16 -0
- package/dist/hooks/use-focus-trap.js +67 -0
- package/dist/hooks/use-focus-trap.js.map +1 -0
- package/dist/hooks/use-media-query.js +1 -0
- package/dist/hooks/use-media-query.js.map +1 -1
- package/dist/hooks/use-overlay-history.js +27 -1
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/hooks/use-row-swipe.js +1 -0
- package/dist/hooks/use-row-swipe.js.map +1 -1
- package/dist/i18n/defaults.d.ts +31 -0
- package/dist/i18n/defaults.js +63 -0
- package/dist/i18n/defaults.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +250 -0
- package/dist/i18n/kit-labels.js +184 -0
- package/dist/i18n/kit-labels.js.map +1 -0
- package/dist/index.d.ts +47 -22
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/calc.js +1 -0
- package/dist/lib/calc.js.map +1 -1
- package/dist/lib/cn.js +1 -0
- package/dist/lib/cn.js.map +1 -1
- package/dist/lib/dates.js +1 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/logger.js +1 -0
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/safe-storage.js +1 -0
- package/dist/lib/safe-storage.js.map +1 -1
- package/dist/search/command-palette.d.ts +11 -1
- package/dist/search/command-palette.js +21 -15
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search.d.ts +2 -0
- package/dist/search.js +3 -0
- package/dist/search.js.map +1 -0
- package/dist/shell/app-shell.d.ts +33 -4
- package/dist/shell/app-shell.js +275 -78
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/option-switcher-menu.js +3 -2
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/role-switcher.d.ts +6 -0
- package/dist/shell/role-switcher.js +7 -4
- package/dist/shell/role-switcher.js.map +1 -1
- package/dist/shell/top-bar.d.ts +15 -7
- package/dist/shell/top-bar.js +3 -5
- package/dist/shell/top-bar.js.map +1 -1
- package/dist/shell/topbar-action-menu.js +3 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +5 -2
- package/dist/shell/topbar-controls.js +20 -15
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/shell.d.ts +13 -0
- package/dist/shell.js +8 -0
- package/dist/shell.js.map +1 -0
- package/dist/theme/chart-palette.d.ts +19 -12
- package/dist/theme/chart-palette.js +24 -30
- package/dist/theme/chart-palette.js.map +1 -1
- package/dist/theme/color.d.ts +78 -0
- package/dist/theme/color.js +148 -0
- package/dist/theme/color.js.map +1 -0
- package/dist/theme/palette-derive.d.ts +140 -0
- package/dist/theme/palette-derive.js +376 -0
- package/dist/theme/palette-derive.js.map +1 -0
- package/dist/theme/palette-presets.d.ts +27 -5
- package/dist/theme/palette-presets.js +231 -33
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/theme/palette-store.js +1 -0
- package/dist/theme/palette-store.js.map +1 -1
- package/dist/theme/theme-store.js +1 -0
- package/dist/theme/theme-store.js.map +1 -1
- package/dist/tour/tour.d.ts +2 -1
- package/dist/tour/tour.js +15 -9
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour.d.ts +2 -0
- package/dist/tour.js +3 -0
- package/dist/tour.js.map +1 -0
- package/dist/wizard/stepper-nav.js +10 -8
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +18 -2
- package/dist/wizard/types.js +4 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +12 -4
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/validation.js +1 -0
- package/dist/wizard/validation.js.map +1 -1
- package/dist/wizard/wizard-context.js +1 -0
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.js +1 -0
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard/wizard-summary.js +5 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +8 -0
- package/dist/wizard.js +9 -0
- package/dist/wizard.js.map +1 -0
- package/package.json +67 -14
- package/src/chart.ts +22 -0
- package/src/components/account-settings.tsx +275 -0
- package/src/components/alert-banner.tsx +75 -0
- package/src/components/amount-input.tsx +471 -0
- package/src/components/calculator.tsx +252 -0
- package/src/components/chart-zoom.tsx +499 -0
- package/src/components/chart.tsx +363 -0
- package/src/components/checkbox.tsx +222 -0
- package/src/components/chip.tsx +570 -0
- package/src/components/choice-parts.ts +33 -0
- package/src/components/combobox-core.tsx +540 -0
- package/src/components/combobox.tsx +1026 -0
- package/src/components/currency-select.tsx +269 -0
- package/src/components/data-table-filter-popover.tsx +252 -0
- package/src/components/data-table-filters.ts +176 -0
- package/src/components/data-table-labels.ts +190 -0
- package/src/components/data-table-pagination.tsx +126 -0
- package/src/components/data-table-sort.ts +70 -0
- package/src/components/data-table.tsx +1740 -0
- package/src/components/date-picker.tsx +631 -0
- package/src/components/dropdown.tsx +341 -0
- package/src/components/entity-combobox.tsx +215 -0
- package/src/components/facing-pair.ts +86 -0
- package/src/components/field-sync.tsx +472 -0
- package/src/components/file-dropzone.tsx +181 -0
- package/src/components/full-bleed-dialog.tsx +179 -0
- package/src/components/grouped-picker.tsx +157 -0
- package/src/components/hover-menu.tsx +183 -0
- package/src/components/mini-calendar.tsx +554 -0
- package/src/components/modal.tsx +287 -0
- package/src/components/month-picker.tsx +549 -0
- package/src/components/multi-entity-combobox.tsx +226 -0
- package/src/components/multi-select.tsx +303 -0
- package/src/components/number-field.tsx +225 -0
- package/src/components/number-input.tsx +211 -0
- package/src/components/numpad-sheet.tsx +264 -0
- package/src/components/page-contents.tsx +289 -0
- package/src/components/password-strength.tsx +258 -0
- package/src/components/picker-sheet.tsx +251 -0
- package/src/components/popover.tsx +195 -0
- package/src/components/search-field.tsx +146 -0
- package/src/components/series-chart-labels.ts +26 -0
- package/src/components/series-chart.tsx +481 -0
- package/src/components/settings-fields.tsx +68 -0
- package/src/components/signature-pad.tsx +630 -0
- package/src/components/slider.tsx +313 -0
- package/src/components/sparkline.tsx +329 -0
- package/src/components/stat-tile.tsx +448 -0
- package/src/components/swipeable-row.tsx +271 -0
- package/src/components/switch.tsx +170 -0
- package/src/components/time-input.tsx +107 -0
- package/src/components/toggle-group.tsx +153 -0
- package/src/components/toggle-legend.tsx +229 -0
- package/src/components/tooltip.tsx +477 -0
- package/src/components/treemap.tsx +390 -0
- package/src/components/ui.tsx +1284 -0
- package/src/components/use-mobile-reveal.ts +54 -0
- package/src/components/use-table-state.ts +295 -0
- package/src/components/user-avatar.tsx +61 -0
- package/src/components/wizard-stepper.tsx +37 -0
- package/src/data-table.ts +19 -0
- package/src/feedback/feedback-attachment.tsx +246 -0
- package/src/feedback/feedback-dialog.tsx +209 -0
- package/src/feedback/feedback-inbox.tsx +615 -0
- package/src/feedback.ts +8 -0
- package/src/hooks/use-anchored-panel.ts +169 -0
- package/src/hooks/use-anchored-rect.ts +78 -0
- package/src/hooks/use-announce.ts +85 -0
- package/src/hooks/use-body-scroll-lock.ts +94 -0
- package/src/hooks/use-close-transition.ts +99 -0
- package/src/hooks/use-dismiss.ts +77 -0
- package/src/hooks/use-focus-trap.ts +160 -0
- package/src/hooks/use-media-query.ts +23 -0
- package/src/hooks/use-overlay-history.ts +345 -0
- package/src/hooks/use-row-swipe.ts +212 -0
- package/src/i18n/defaults.ts +68 -0
- package/src/i18n/kit-labels.tsx +447 -0
- package/src/index.ts +169 -0
- package/src/lib/calc.ts +309 -0
- package/src/lib/cn.ts +5 -0
- package/src/lib/dates.ts +187 -0
- package/src/lib/logger.ts +73 -0
- package/src/lib/safe-storage.ts +37 -0
- package/src/search/command-palette.tsx +342 -0
- package/src/search.ts +3 -0
- package/src/shell/app-shell.tsx +772 -0
- package/src/shell/option-switcher-menu.tsx +72 -0
- package/src/shell/role-switcher.tsx +46 -0
- package/src/shell/top-bar.tsx +39 -0
- package/src/shell/topbar-action-menu.tsx +91 -0
- package/src/shell/topbar-controls.tsx +229 -0
- package/src/shell.ts +10 -0
- package/src/theme/chart-palette.ts +145 -0
- package/src/theme/color.ts +266 -0
- package/src/theme/palette-derive.ts +681 -0
- package/src/theme/palette-presets.ts +760 -0
- package/src/theme/palette-store.ts +94 -0
- package/src/theme/theme-store.ts +145 -0
- package/src/tour/tour.tsx +586 -0
- package/src/tour.ts +3 -0
- package/src/vite-env.d.ts +26 -0
- package/src/wizard/stepper-nav.tsx +221 -0
- package/src/wizard/types.ts +178 -0
- package/src/wizard/use-wizard.ts +345 -0
- package/src/wizard/validation.ts +37 -0
- package/src/wizard/wizard-context.tsx +29 -0
- package/src/wizard/wizard-step.tsx +14 -0
- package/src/wizard/wizard-summary.tsx +69 -0
- package/src/wizard.ts +14 -0
- package/tokens.css +222 -20
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef, ReactNode, ReactElement } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The words a stat tile speaks. The delta's arrow is an icon, and an icon is not a
|
|
6
|
+
* sentence: a screen reader gets the direction (and, under `goodDirection`, the
|
|
7
|
+
* verdict) as words, and a sighted reader who cannot tell the two delta colours apart
|
|
8
|
+
* still has the arrow — neither judgement rides on colour alone.
|
|
9
|
+
*/
|
|
10
|
+
interface StatTileLabels {
|
|
11
|
+
/** The delta, rising. `amount` is already formatted and unsigned. */
|
|
12
|
+
increase: (amount: string) => string;
|
|
13
|
+
decrease: (amount: string) => string;
|
|
14
|
+
unchanged: string;
|
|
15
|
+
/** Wraps the direction sentence when `goodDirection` says the change is good. */
|
|
16
|
+
better: (change: string) => string;
|
|
17
|
+
worse: (change: string) => string;
|
|
18
|
+
/** Spoken in place of a missing value (the tile shows a dash). */
|
|
19
|
+
noValue: string;
|
|
20
|
+
/** Spoken while `loading`. */
|
|
21
|
+
loading: string;
|
|
22
|
+
}
|
|
23
|
+
declare const DEFAULT_STAT_TILE_LABELS: StatTileLabels;
|
|
24
|
+
/**
|
|
25
|
+
* What colours a figure. Token-backed like everything else in the kit.
|
|
26
|
+
*
|
|
27
|
+
* - `income` / `expense` / `net` — the money trio (teal / amber / violet, CVD-safe).
|
|
28
|
+
* - `signed` — a money figure read by its own sign: above zero `income`, below zero
|
|
29
|
+
* `expense`, exactly zero neutral (a zero balance is neither news). Needs a NUMBER
|
|
30
|
+
* value; a pre-formatted string cannot be read and stays neutral.
|
|
31
|
+
* - `success` / `warning` / `danger` / `info` — a threshold verdict ("phase margin
|
|
32
|
+
* below 30°"). Say it in the hint or description as well; colour alone is not one.
|
|
33
|
+
*/
|
|
34
|
+
type StatTileTone = "neutral" | "brand" | "income" | "expense" | "net" | "signed" | "success" | "warning" | "danger" | "info";
|
|
35
|
+
/** A change since the previous period. A bare number is shorthand for `{ value }`. */
|
|
36
|
+
interface StatTileDelta {
|
|
37
|
+
/** Signed: above zero is "up". */
|
|
38
|
+
value: number;
|
|
39
|
+
/** `"value"` (default) formats it like the headline — same currency, same
|
|
40
|
+
* notation. `"percent"` reads it as a RATIO: `0.12` is shown as 12 %. */
|
|
41
|
+
unit?: "value" | "percent";
|
|
42
|
+
/** What it is measured against, shown after it: "vs last month". */
|
|
43
|
+
label?: ReactNode;
|
|
44
|
+
}
|
|
45
|
+
/** A smaller figure under the headline — a prior period, a projection, a split. */
|
|
46
|
+
interface StatTileSubValue {
|
|
47
|
+
label: ReactNode;
|
|
48
|
+
/** A number is formatted like the headline; anything else is rendered as given. */
|
|
49
|
+
value: ReactNode;
|
|
50
|
+
/** Defaults to the tile's own `tone`, read per figure (so a `signed` tile shows a
|
|
51
|
+
* teal September above an amber August). Sub-values are always the muted weight
|
|
52
|
+
* of their tone: they are history, and must not outshout the figure above them. */
|
|
53
|
+
tone?: StatTileTone;
|
|
54
|
+
}
|
|
55
|
+
/** What {@link StatTileProps.renderLink} is handed. Spread it onto your router's
|
|
56
|
+
* link: the class is what stretches the link over the whole tile. */
|
|
57
|
+
interface StatTileLinkProps {
|
|
58
|
+
href: string;
|
|
59
|
+
className: string;
|
|
60
|
+
"aria-describedby": string;
|
|
61
|
+
children: ReactNode;
|
|
62
|
+
}
|
|
63
|
+
interface StatTileProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "onClick"> {
|
|
64
|
+
/** What is measured. Also the tile's accessible name when it is a link/button. */
|
|
65
|
+
label: ReactNode;
|
|
66
|
+
/**
|
|
67
|
+
* The headline. A number is formatted with `Intl.NumberFormat` in the kit's locale
|
|
68
|
+
* (see `currency`, `compact`, `numberFormat`, `format`); a string or element is
|
|
69
|
+
* rendered as given. `null`/`undefined` shows a dash and says "No data".
|
|
70
|
+
*/
|
|
71
|
+
value?: ReactNode;
|
|
72
|
+
/** ISO 4217 code — formats a number value (and a `"value"` delta, and numeric
|
|
73
|
+
* sub-values) as money. */
|
|
74
|
+
currency?: string;
|
|
75
|
+
/** Compact notation: 12 400 → "12K". */
|
|
76
|
+
compact?: boolean;
|
|
77
|
+
/** Any further `Intl.NumberFormat` options, merged over the two above. */
|
|
78
|
+
numberFormat?: Intl.NumberFormatOptions;
|
|
79
|
+
/** Formats every NUMBER the tile shows, replacing the `Intl` default outright. */
|
|
80
|
+
format?: (value: number) => string;
|
|
81
|
+
locale?: string;
|
|
82
|
+
/** A short unit after the value that `Intl` has no name for: "ms", "dB", "Hz". */
|
|
83
|
+
unit?: ReactNode;
|
|
84
|
+
/** The period the headline belongs to, shown to its left ("SEP"). */
|
|
85
|
+
valueLabel?: ReactNode;
|
|
86
|
+
tone?: StatTileTone;
|
|
87
|
+
delta?: number | StatTileDelta;
|
|
88
|
+
/** Which way is good news. Unset, the delta is stated and not judged (users up and
|
|
89
|
+
* cost up are the same arrow); set, it is coloured and its sentence says so. */
|
|
90
|
+
goodDirection?: "up" | "down";
|
|
91
|
+
/** The explanation behind a "?" beside the label (a {@link FieldHint}). */
|
|
92
|
+
hint?: string;
|
|
93
|
+
/** A visible line under the value: "Last 12 months", "3 estates / 9 buildings". */
|
|
94
|
+
description?: ReactNode;
|
|
95
|
+
subValues?: StatTileSubValue[];
|
|
96
|
+
/** Shorthand for a fluid {@link Sparkline} named after the tile and speaking in
|
|
97
|
+
* the tile's number format. */
|
|
98
|
+
trend?: ReadonlyArray<number | null | undefined>;
|
|
99
|
+
/** A custom sparkline (or any small graphic) in the same place. Wins over `trend`. */
|
|
100
|
+
sparkline?: ReactNode;
|
|
101
|
+
/** A decorative icon at the top end of the tile. */
|
|
102
|
+
icon?: ReactNode;
|
|
103
|
+
/** Anything below the rest — a progress bar, a link row. */
|
|
104
|
+
footer?: ReactNode;
|
|
105
|
+
/** Tag every figure `data-private`, so the host's demo-mode rule blurs it — the
|
|
106
|
+
* same attribute `Tooltip redact` and the chart tooltip's value cell carry. */
|
|
107
|
+
sensitive?: boolean;
|
|
108
|
+
/** Skeleton in place of the value; delta, sub-values and trend are withheld. */
|
|
109
|
+
loading?: boolean;
|
|
110
|
+
/** `"sm"` for dense grids (a metrics wall); `"md"` the default KPI. */
|
|
111
|
+
size?: "sm" | "md";
|
|
112
|
+
/** Makes the tile a link. See {@link renderLink} for a router. */
|
|
113
|
+
href?: string;
|
|
114
|
+
/** Renders the link for `href` — pass your router's `<Link>` here, since a plain
|
|
115
|
+
* `<a>` reloads a single-page app. Default: `<a>`. */
|
|
116
|
+
renderLink?: (props: StatTileLinkProps) => ReactElement;
|
|
117
|
+
/** Makes the tile a button (ignored when `href` is given). */
|
|
118
|
+
onClick?: () => void;
|
|
119
|
+
labels?: Partial<StatTileLabels>;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The KPI card: a label, one number, and optionally how it moved, what it is made of
|
|
123
|
+
* and which way it has been going.
|
|
124
|
+
*
|
|
125
|
+
* Replaces keksdose's `KpiCard` and admin `Tile`, lenkbank's metrics tiles and
|
|
126
|
+
* kastlan's `KpiCard` — the same card four times, with four answers to the same
|
|
127
|
+
* questions (how a currency is formatted, whether a delta is judged, what happens to
|
|
128
|
+
* the figures in demo mode).
|
|
129
|
+
*
|
|
130
|
+
* Interactive tiles use a STRETCHED link: the label is the `<a>`/`<button>`, and its
|
|
131
|
+
* `::after` covers the card. Making the card itself the link would nest the hint's
|
|
132
|
+
* `<button>` inside it, which is invalid HTML and an unreachable control; this way
|
|
133
|
+
* the hint sits above the overlay and stays its own tab stop. The link's name is the
|
|
134
|
+
* label, and it is described by the value and the delta.
|
|
135
|
+
*/
|
|
136
|
+
declare function StatTile({ label, value, currency, compact, numberFormat, format, locale, unit, valueLabel, tone, delta, goodDirection, hint, description, subValues, trend, sparkline, icon, footer, sensitive, loading, size, href, renderLink, onClick, labels, className, ...rest }: StatTileProps): react.JSX.Element;
|
|
137
|
+
interface StatTileGridProps extends ComponentPropsWithoutRef<"div"> {
|
|
138
|
+
/** The narrowest a tile may get before the grid drops a column. The default fits
|
|
139
|
+
* two tiles on a 320px phone. */
|
|
140
|
+
minTileWidth?: string;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The row of tiles every dashboard in the three apps lays out by hand, each with its
|
|
144
|
+
* own breakpoint ladder (`grid-cols-2 md:grid-cols-4 min-[2400px]:grid-cols-8` …).
|
|
145
|
+
* Those ladders read the VIEWPORT and so ignored the sidebar: 6-up tiles at 155px in
|
|
146
|
+
* the band where the sidebar was open. This fits as many columns as `minTileWidth`
|
|
147
|
+
* allows in the space the grid actually has, and needs no breakpoint at all.
|
|
148
|
+
*/
|
|
149
|
+
declare function StatTileGrid({ minTileWidth, className, style, ...rest }: StatTileGridProps): react.JSX.Element;
|
|
150
|
+
|
|
151
|
+
export { DEFAULT_STAT_TILE_LABELS, StatTile, type StatTileDelta, StatTileGrid, type StatTileGridProps, type StatTileLabels, type StatTileLinkProps, type StatTileProps, type StatTileSubValue, type StatTileTone };
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId } from "react";
|
|
4
|
+
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { useKitLabels, useKitLocale } from "../i18n/kit-labels.js";
|
|
7
|
+
import { Card, FieldHint } from "./ui.js";
|
|
8
|
+
import { Sparkline } from "./sparkline.js";
|
|
9
|
+
const DEFAULT_STAT_TILE_LABELS = {
|
|
10
|
+
increase: (amount) => `Up ${amount}`,
|
|
11
|
+
decrease: (amount) => `Down ${amount}`,
|
|
12
|
+
unchanged: "No change",
|
|
13
|
+
better: (change) => `${change} (better)`,
|
|
14
|
+
worse: (change) => `${change} (worse)`,
|
|
15
|
+
noValue: "No data",
|
|
16
|
+
loading: "Loading\u2026"
|
|
17
|
+
};
|
|
18
|
+
const TONE_CLASS = {
|
|
19
|
+
neutral: "text-[var(--text-primary)]",
|
|
20
|
+
brand: "text-[var(--brand)]",
|
|
21
|
+
income: "text-[var(--money-income)]",
|
|
22
|
+
expense: "text-[var(--money-expense)]",
|
|
23
|
+
net: "text-[var(--money-net)]",
|
|
24
|
+
success: "text-[var(--success)]",
|
|
25
|
+
warning: "text-[var(--warning)]",
|
|
26
|
+
danger: "text-[var(--danger)]",
|
|
27
|
+
info: "text-[var(--info)]"
|
|
28
|
+
};
|
|
29
|
+
function toneClass(tone, value) {
|
|
30
|
+
if (tone !== "signed") return TONE_CLASS[tone];
|
|
31
|
+
if (typeof value !== "number" || value === 0) return TONE_CLASS.neutral;
|
|
32
|
+
return value < 0 ? TONE_CLASS.expense : TONE_CLASS.income;
|
|
33
|
+
}
|
|
34
|
+
const formatters = /* @__PURE__ */ new Map();
|
|
35
|
+
function numberFormatter(locale, options) {
|
|
36
|
+
const key = `${locale ?? ""}|${JSON.stringify(options)}`;
|
|
37
|
+
let f = formatters.get(key);
|
|
38
|
+
if (!f) {
|
|
39
|
+
f = new Intl.NumberFormat(locale, options);
|
|
40
|
+
formatters.set(key, f);
|
|
41
|
+
}
|
|
42
|
+
return f;
|
|
43
|
+
}
|
|
44
|
+
const VALUE_TEXT = {
|
|
45
|
+
md: "text-xl @[13rem]:text-2xl",
|
|
46
|
+
sm: "text-xs @[8.5rem]:text-sm @[13rem]:text-base"
|
|
47
|
+
};
|
|
48
|
+
const PADDING = { md: "p-3 sm:p-4", sm: "px-2.5 py-2" };
|
|
49
|
+
const SKELETON = "animate-pulse rounded bg-[var(--bg-active)]";
|
|
50
|
+
function StatTile({
|
|
51
|
+
label,
|
|
52
|
+
value,
|
|
53
|
+
currency,
|
|
54
|
+
compact = false,
|
|
55
|
+
numberFormat,
|
|
56
|
+
format,
|
|
57
|
+
locale,
|
|
58
|
+
unit,
|
|
59
|
+
valueLabel,
|
|
60
|
+
tone = "neutral",
|
|
61
|
+
delta,
|
|
62
|
+
goodDirection,
|
|
63
|
+
hint,
|
|
64
|
+
description,
|
|
65
|
+
subValues,
|
|
66
|
+
trend,
|
|
67
|
+
sparkline,
|
|
68
|
+
icon,
|
|
69
|
+
footer,
|
|
70
|
+
sensitive = false,
|
|
71
|
+
loading = false,
|
|
72
|
+
size = "md",
|
|
73
|
+
href,
|
|
74
|
+
renderLink,
|
|
75
|
+
onClick,
|
|
76
|
+
labels,
|
|
77
|
+
className,
|
|
78
|
+
...rest
|
|
79
|
+
}) {
|
|
80
|
+
const text = useKitLabels("statTile", DEFAULT_STAT_TILE_LABELS, labels);
|
|
81
|
+
const kitLocale = useKitLocale(locale);
|
|
82
|
+
const id = useId();
|
|
83
|
+
const valueId = `${id}-value`;
|
|
84
|
+
const deltaId = `${id}-delta`;
|
|
85
|
+
const priv = sensitive ? "" : void 0;
|
|
86
|
+
const fmt = format ?? ((n) => numberFormatter(kitLocale, {
|
|
87
|
+
...currency ? { style: "currency", currency } : null,
|
|
88
|
+
...compact ? { notation: "compact" } : null,
|
|
89
|
+
...numberFormat
|
|
90
|
+
}).format(n));
|
|
91
|
+
const show = (v) => typeof v === "number" ? fmt(v) : v;
|
|
92
|
+
const d = typeof delta === "number" ? { value: delta } : delta;
|
|
93
|
+
const hasValue = value !== null && value !== void 0 && value !== "";
|
|
94
|
+
const labelText = typeof label === "string" ? label : void 0;
|
|
95
|
+
const stretched = cn(
|
|
96
|
+
"text-start uppercase tracking-wide",
|
|
97
|
+
"after:absolute after:inset-0 after:rounded-lg after:content-['']",
|
|
98
|
+
"focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-[var(--brand)]"
|
|
99
|
+
);
|
|
100
|
+
const describedBy = d ? `${valueId} ${deltaId}` : valueId;
|
|
101
|
+
let labelNode = label;
|
|
102
|
+
if (href !== void 0) {
|
|
103
|
+
const linkProps = {
|
|
104
|
+
href,
|
|
105
|
+
className: stretched,
|
|
106
|
+
"aria-describedby": describedBy,
|
|
107
|
+
children: label
|
|
108
|
+
};
|
|
109
|
+
labelNode = renderLink ? renderLink(linkProps) : /* @__PURE__ */ jsx("a", { href, className: stretched, "aria-describedby": describedBy, children: label });
|
|
110
|
+
} else if (onClick) {
|
|
111
|
+
labelNode = /* @__PURE__ */ jsx("button", { type: "button", onClick, "aria-describedby": describedBy, className: stretched, children: label });
|
|
112
|
+
}
|
|
113
|
+
const interactive = href !== void 0 || onClick !== void 0;
|
|
114
|
+
let deltaNode = null;
|
|
115
|
+
if (d && !loading && Number.isFinite(d.value)) {
|
|
116
|
+
const magnitude = Math.abs(d.value);
|
|
117
|
+
const amount = d.unit === "percent" ? numberFormatter(kitLocale, { style: "percent", maximumFractionDigits: 1 }).format(magnitude) : fmt(magnitude);
|
|
118
|
+
const dir = d.value > 0 ? "up" : d.value < 0 ? "down" : "flat";
|
|
119
|
+
const Icon = dir === "up" ? ArrowUp : dir === "down" ? ArrowDown : Minus;
|
|
120
|
+
let spoken = dir === "up" ? text.increase(amount) : dir === "down" ? text.decrease(amount) : text.unchanged;
|
|
121
|
+
let deltaTone = "text-[var(--text-muted)]";
|
|
122
|
+
if (goodDirection && dir !== "flat") {
|
|
123
|
+
const good = dir === goodDirection;
|
|
124
|
+
spoken = good ? text.better(spoken) : text.worse(spoken);
|
|
125
|
+
deltaTone = good ? "text-[var(--success)]" : "text-[var(--danger)]";
|
|
126
|
+
}
|
|
127
|
+
deltaNode = // `relative`: the containing block for the sr-only sentence (see the
|
|
128
|
+
// sr-only-containment test for what an escaped one does to the page).
|
|
129
|
+
/* @__PURE__ */ jsxs("div", { className: "relative mt-1 flex flex-wrap items-center gap-x-1.5 text-xs tabular-nums", children: [
|
|
130
|
+
/* @__PURE__ */ jsxs("span", { id: deltaId, "data-private": priv, className: cn("inline-flex items-center gap-0.5 font-medium", deltaTone), children: [
|
|
131
|
+
/* @__PURE__ */ jsx(Icon, { className: "size-3 shrink-0", "aria-hidden": true }),
|
|
132
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": true, children: dir === "flat" ? null : amount }),
|
|
133
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: spoken })
|
|
134
|
+
] }),
|
|
135
|
+
d.label != null && /* @__PURE__ */ jsx("span", { className: "text-[var(--text-muted)]", children: d.label })
|
|
136
|
+
] });
|
|
137
|
+
}
|
|
138
|
+
const valueClass = cn(
|
|
139
|
+
"whitespace-nowrap font-semibold tabular-nums",
|
|
140
|
+
VALUE_TEXT[size],
|
|
141
|
+
toneClass(tone, value)
|
|
142
|
+
);
|
|
143
|
+
const headline = loading ? /* @__PURE__ */ jsxs("span", { id: valueId, className: "relative block py-0.5", children: [
|
|
144
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": true, className: cn(SKELETON, "block h-6 w-24 max-w-full") }),
|
|
145
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: text.loading })
|
|
146
|
+
] }) : hasValue ? /* @__PURE__ */ jsxs("span", { id: valueId, "data-private": priv, className: cn(valueClass, valueLabel != null && "ms-auto"), children: [
|
|
147
|
+
show(value),
|
|
148
|
+
unit != null && /* @__PURE__ */ jsx("span", { className: "ms-1 text-[0.7em] font-medium text-[var(--text-muted)]", children: unit })
|
|
149
|
+
] }) : /* @__PURE__ */ jsxs("span", { id: valueId, className: cn("relative", valueClass, "text-[var(--text-muted)]"), children: [
|
|
150
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": true, children: "\u2014" }),
|
|
151
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: text.noValue })
|
|
152
|
+
] });
|
|
153
|
+
const graphic = loading ? null : sparkline ?? (trend && /* @__PURE__ */ jsx(
|
|
154
|
+
Sparkline,
|
|
155
|
+
{
|
|
156
|
+
data: trend,
|
|
157
|
+
fluid: true,
|
|
158
|
+
height: size === "sm" ? 20 : 28,
|
|
159
|
+
label: labelText,
|
|
160
|
+
formatValue: fmt,
|
|
161
|
+
locale: kitLocale,
|
|
162
|
+
tone: tone === "signed" ? "signed" : tone === "neutral" ? "chart" : tone,
|
|
163
|
+
"data-private": priv
|
|
164
|
+
}
|
|
165
|
+
));
|
|
166
|
+
return /* @__PURE__ */ jsxs(
|
|
167
|
+
Card,
|
|
168
|
+
{
|
|
169
|
+
...rest,
|
|
170
|
+
"aria-busy": loading || void 0,
|
|
171
|
+
className: cn(
|
|
172
|
+
// @container: the headline sizes read the tile's width. relative: the stretched
|
|
173
|
+
// link's overlay. overflow-hidden: at pathological widths a figure clips inside
|
|
174
|
+
// the border instead of painting over the neighbouring tile.
|
|
175
|
+
"@container relative flex min-w-0 flex-col overflow-hidden",
|
|
176
|
+
PADDING[size],
|
|
177
|
+
interactive && "transition-colors hover:border-[var(--border-strong)] hover:bg-[var(--bg-hover)]",
|
|
178
|
+
className
|
|
179
|
+
),
|
|
180
|
+
children: [
|
|
181
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-start gap-2", children: [
|
|
182
|
+
/* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-1 items-center gap-1 break-words text-xs uppercase tracking-wide text-[var(--text-muted)]", children: [
|
|
183
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0", children: labelNode }),
|
|
184
|
+
hint && /* @__PURE__ */ jsx(FieldHint, { label: hint, side: "top", className: "relative z-10 shrink-0" })
|
|
185
|
+
] }),
|
|
186
|
+
icon != null && /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "shrink-0 text-[var(--text-muted)] [&_svg]:size-4", children: icon })
|
|
187
|
+
] }),
|
|
188
|
+
valueLabel != null ? (
|
|
189
|
+
// Period-left / value-right, matching the sub-rows below it. flex-wrap lets a
|
|
190
|
+
// too-long amount drop to its own end-aligned line instead of overflowing.
|
|
191
|
+
/* @__PURE__ */ jsxs("div", { className: "mt-1 flex flex-wrap items-baseline gap-x-1.5", children: [
|
|
192
|
+
/* @__PURE__ */ jsx("span", { className: "shrink-0 text-xs uppercase tracking-wide text-[var(--text-muted)]", children: valueLabel }),
|
|
193
|
+
headline
|
|
194
|
+
] })
|
|
195
|
+
) : /* @__PURE__ */ jsx("div", { className: "mt-1", children: headline }),
|
|
196
|
+
deltaNode,
|
|
197
|
+
description != null && /* @__PURE__ */ jsx("div", { className: "mt-1 text-xs text-[var(--text-muted)]", children: description }),
|
|
198
|
+
graphic && /* @__PURE__ */ jsx("div", { className: "mt-2", children: graphic }),
|
|
199
|
+
!loading && subValues && subValues.length > 0 && /* @__PURE__ */ jsx("dl", { className: "mt-1.5 space-y-0.5", children: subValues.map((sv, i) => {
|
|
200
|
+
const svTone = sv.tone ?? (tone === "neutral" ? void 0 : tone);
|
|
201
|
+
return /* @__PURE__ */ jsxs(
|
|
202
|
+
"div",
|
|
203
|
+
{
|
|
204
|
+
className: "flex flex-wrap items-baseline gap-x-2 text-xs tabular-nums text-[var(--text-muted)]",
|
|
205
|
+
children: [
|
|
206
|
+
/* @__PURE__ */ jsx("dt", { className: "uppercase tracking-wide", children: sv.label }),
|
|
207
|
+
/* @__PURE__ */ jsx(
|
|
208
|
+
"dd",
|
|
209
|
+
{
|
|
210
|
+
"data-private": priv,
|
|
211
|
+
className: cn("ms-auto whitespace-nowrap", svTone && [toneClass(svTone, sv.value), "opacity-75"]),
|
|
212
|
+
children: show(sv.value)
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
]
|
|
216
|
+
},
|
|
217
|
+
i
|
|
218
|
+
);
|
|
219
|
+
}) }),
|
|
220
|
+
footer != null && /* @__PURE__ */ jsx("div", { className: "mt-2", children: footer })
|
|
221
|
+
]
|
|
222
|
+
}
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
function StatTileGrid({ minTileWidth = "9rem", className, style, ...rest }) {
|
|
226
|
+
const columns = {
|
|
227
|
+
gridTemplateColumns: `repeat(auto-fit, minmax(min(100%, ${minTileWidth}), 1fr))`
|
|
228
|
+
};
|
|
229
|
+
return /* @__PURE__ */ jsx("div", { ...rest, className: cn("grid gap-3", className), style: { ...columns, ...style } });
|
|
230
|
+
}
|
|
231
|
+
export {
|
|
232
|
+
DEFAULT_STAT_TILE_LABELS,
|
|
233
|
+
StatTile,
|
|
234
|
+
StatTileGrid
|
|
235
|
+
};
|
|
236
|
+
//# sourceMappingURL=stat-tile.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/stat-tile.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { ComponentPropsWithoutRef, CSSProperties, ReactElement, ReactNode } from \"react\";\nimport { ArrowDown, ArrowUp, Minus } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport { Card, FieldHint } from \"./ui\";\nimport { Sparkline } from \"./sparkline\";\n\n/**\n * The words a stat tile speaks. The delta's arrow is an icon, and an icon is not a\n * sentence: a screen reader gets the direction (and, under `goodDirection`, the\n * verdict) as words, and a sighted reader who cannot tell the two delta colours apart\n * still has the arrow — neither judgement rides on colour alone.\n */\nexport interface StatTileLabels {\n /** The delta, rising. `amount` is already formatted and unsigned. */\n increase: (amount: string) => string;\n decrease: (amount: string) => string;\n unchanged: string;\n /** Wraps the direction sentence when `goodDirection` says the change is good. */\n better: (change: string) => string;\n worse: (change: string) => string;\n /** Spoken in place of a missing value (the tile shows a dash). */\n noValue: string;\n /** Spoken while `loading`. */\n loading: string;\n}\n\nexport const DEFAULT_STAT_TILE_LABELS: StatTileLabels = {\n increase: (amount) => `Up ${amount}`,\n decrease: (amount) => `Down ${amount}`,\n unchanged: \"No change\",\n better: (change) => `${change} (better)`,\n worse: (change) => `${change} (worse)`,\n noValue: \"No data\",\n loading: \"Loading…\",\n};\n\n/**\n * What colours a figure. Token-backed like everything else in the kit.\n *\n * - `income` / `expense` / `net` — the money trio (teal / amber / violet, CVD-safe).\n * - `signed` — a money figure read by its own sign: above zero `income`, below zero\n * `expense`, exactly zero neutral (a zero balance is neither news). Needs a NUMBER\n * value; a pre-formatted string cannot be read and stays neutral.\n * - `success` / `warning` / `danger` / `info` — a threshold verdict (\"phase margin\n * below 30°\"). Say it in the hint or description as well; colour alone is not one.\n */\nexport type StatTileTone =\n | \"neutral\"\n | \"brand\"\n | \"income\"\n | \"expense\"\n | \"net\"\n | \"signed\"\n | \"success\"\n | \"warning\"\n | \"danger\"\n | \"info\";\n\nconst TONE_CLASS: Record<Exclude<StatTileTone, \"signed\">, string> = {\n neutral: \"text-[var(--text-primary)]\",\n brand: \"text-[var(--brand)]\",\n income: \"text-[var(--money-income)]\",\n expense: \"text-[var(--money-expense)]\",\n net: \"text-[var(--money-net)]\",\n success: \"text-[var(--success)]\",\n warning: \"text-[var(--warning)]\",\n danger: \"text-[var(--danger)]\",\n info: \"text-[var(--info)]\",\n};\n\nfunction toneClass(tone: StatTileTone, value: unknown): string {\n if (tone !== \"signed\") return TONE_CLASS[tone];\n if (typeof value !== \"number\" || value === 0) return TONE_CLASS.neutral;\n return value < 0 ? TONE_CLASS.expense : TONE_CLASS.income;\n}\n\n/** A change since the previous period. A bare number is shorthand for `{ value }`. */\nexport interface StatTileDelta {\n /** Signed: above zero is \"up\". */\n value: number;\n /** `\"value\"` (default) formats it like the headline — same currency, same\n * notation. `\"percent\"` reads it as a RATIO: `0.12` is shown as 12 %. */\n unit?: \"value\" | \"percent\";\n /** What it is measured against, shown after it: \"vs last month\". */\n label?: ReactNode;\n}\n\n/** A smaller figure under the headline — a prior period, a projection, a split. */\nexport interface StatTileSubValue {\n label: ReactNode;\n /** A number is formatted like the headline; anything else is rendered as given. */\n value: ReactNode;\n /** Defaults to the tile's own `tone`, read per figure (so a `signed` tile shows a\n * teal September above an amber August). Sub-values are always the muted weight\n * of their tone: they are history, and must not outshout the figure above them. */\n tone?: StatTileTone;\n}\n\n/** What {@link StatTileProps.renderLink} is handed. Spread it onto your router's\n * link: the class is what stretches the link over the whole tile. */\nexport interface StatTileLinkProps {\n href: string;\n className: string;\n \"aria-describedby\": string;\n children: ReactNode;\n}\n\nexport interface StatTileProps\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\" | \"onClick\"> {\n /** What is measured. Also the tile's accessible name when it is a link/button. */\n label: ReactNode;\n /**\n * The headline. A number is formatted with `Intl.NumberFormat` in the kit's locale\n * (see `currency`, `compact`, `numberFormat`, `format`); a string or element is\n * rendered as given. `null`/`undefined` shows a dash and says \"No data\".\n */\n value?: ReactNode;\n /** ISO 4217 code — formats a number value (and a `\"value\"` delta, and numeric\n * sub-values) as money. */\n currency?: string;\n /** Compact notation: 12 400 → \"12K\". */\n compact?: boolean;\n /** Any further `Intl.NumberFormat` options, merged over the two above. */\n numberFormat?: Intl.NumberFormatOptions;\n /** Formats every NUMBER the tile shows, replacing the `Intl` default outright. */\n format?: (value: number) => string;\n locale?: string;\n /** A short unit after the value that `Intl` has no name for: \"ms\", \"dB\", \"Hz\". */\n unit?: ReactNode;\n /** The period the headline belongs to, shown to its left (\"SEP\"). */\n valueLabel?: ReactNode;\n tone?: StatTileTone;\n delta?: number | StatTileDelta;\n /** Which way is good news. Unset, the delta is stated and not judged (users up and\n * cost up are the same arrow); set, it is coloured and its sentence says so. */\n goodDirection?: \"up\" | \"down\";\n /** The explanation behind a \"?\" beside the label (a {@link FieldHint}). */\n hint?: string;\n /** A visible line under the value: \"Last 12 months\", \"3 estates / 9 buildings\". */\n description?: ReactNode;\n subValues?: StatTileSubValue[];\n /** Shorthand for a fluid {@link Sparkline} named after the tile and speaking in\n * the tile's number format. */\n trend?: ReadonlyArray<number | null | undefined>;\n /** A custom sparkline (or any small graphic) in the same place. Wins over `trend`. */\n sparkline?: ReactNode;\n /** A decorative icon at the top end of the tile. */\n icon?: ReactNode;\n /** Anything below the rest — a progress bar, a link row. */\n footer?: ReactNode;\n /** Tag every figure `data-private`, so the host's demo-mode rule blurs it — the\n * same attribute `Tooltip redact` and the chart tooltip's value cell carry. */\n sensitive?: boolean;\n /** Skeleton in place of the value; delta, sub-values and trend are withheld. */\n loading?: boolean;\n /** `\"sm\"` for dense grids (a metrics wall); `\"md\"` the default KPI. */\n size?: \"sm\" | \"md\";\n /** Makes the tile a link. See {@link renderLink} for a router. */\n href?: string;\n /** Renders the link for `href` — pass your router's `<Link>` here, since a plain\n * `<a>` reloads a single-page app. Default: `<a>`. */\n renderLink?: (props: StatTileLinkProps) => ReactElement;\n /** Makes the tile a button (ignored when `href` is given). */\n onClick?: () => void;\n labels?: Partial<StatTileLabels>;\n}\n\n// One formatter per option set: a metrics wall is twenty of these.\nconst formatters = new Map<string, Intl.NumberFormat>();\nfunction numberFormatter(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat {\n const key = `${locale ?? \"\"}|${JSON.stringify(options)}`;\n let f = formatters.get(key);\n if (!f) {\n f = new Intl.NumberFormat(locale, options);\n formatters.set(key, f);\n }\n return f;\n}\n\n// The headline sizes are keyed on the TILE's width (it is a @container), not the\n// viewport: a grid beside a sidebar makes tiles narrowest exactly where a viewport\n// breakpoint would hand them the larger font. `sm` is keksdose's compact headline,\n// sized so a worst-case \"CHF 123,456.78\" still fits a 320px phone's 2-up tiles.\nconst VALUE_TEXT = {\n md: \"text-xl @[13rem]:text-2xl\",\n sm: \"text-xs @[8.5rem]:text-sm @[13rem]:text-base\",\n} as const;\nconst PADDING = { md: \"p-3 sm:p-4\", sm: \"px-2.5 py-2\" } as const;\nconst SKELETON = \"animate-pulse rounded bg-[var(--bg-active)]\";\n\n/**\n * The KPI card: a label, one number, and optionally how it moved, what it is made of\n * and which way it has been going.\n *\n * Replaces keksdose's `KpiCard` and admin `Tile`, lenkbank's metrics tiles and\n * kastlan's `KpiCard` — the same card four times, with four answers to the same\n * questions (how a currency is formatted, whether a delta is judged, what happens to\n * the figures in demo mode).\n *\n * Interactive tiles use a STRETCHED link: the label is the `<a>`/`<button>`, and its\n * `::after` covers the card. Making the card itself the link would nest the hint's\n * `<button>` inside it, which is invalid HTML and an unreachable control; this way\n * the hint sits above the overlay and stays its own tab stop. The link's name is the\n * label, and it is described by the value and the delta.\n */\nexport function StatTile({\n label,\n value,\n currency,\n compact = false,\n numberFormat,\n format,\n locale,\n unit,\n valueLabel,\n tone = \"neutral\",\n delta,\n goodDirection,\n hint,\n description,\n subValues,\n trend,\n sparkline,\n icon,\n footer,\n sensitive = false,\n loading = false,\n size = \"md\",\n href,\n renderLink,\n onClick,\n labels,\n className,\n ...rest\n}: StatTileProps) {\n const text = useKitLabels(\"statTile\", DEFAULT_STAT_TILE_LABELS, labels);\n const kitLocale = useKitLocale(locale);\n const id = useId();\n const valueId = `${id}-value`;\n const deltaId = `${id}-delta`;\n const priv = sensitive ? \"\" : undefined;\n\n const fmt =\n format ??\n ((n: number) =>\n numberFormatter(kitLocale, {\n ...(currency ? { style: \"currency\", currency } : null),\n ...(compact ? { notation: \"compact\" } : null),\n ...numberFormat,\n }).format(n));\n const show = (v: ReactNode) => (typeof v === \"number\" ? fmt(v) : v);\n\n const d = typeof delta === \"number\" ? { value: delta } : delta;\n const hasValue = value !== null && value !== undefined && value !== \"\";\n const labelText = typeof label === \"string\" ? label : undefined;\n\n // ── The label, which is also the link/button when the tile is interactive ──\n const stretched = cn(\n \"text-start uppercase tracking-wide\",\n \"after:absolute after:inset-0 after:rounded-lg after:content-['']\",\n \"focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-[var(--brand)]\",\n );\n const describedBy = d ? `${valueId} ${deltaId}` : valueId;\n let labelNode: ReactNode = label;\n if (href !== undefined) {\n const linkProps: StatTileLinkProps = {\n href,\n className: stretched,\n \"aria-describedby\": describedBy,\n children: label,\n };\n labelNode = renderLink ? (\n renderLink(linkProps)\n ) : (\n <a href={href} className={stretched} aria-describedby={describedBy}>\n {label}\n </a>\n );\n } else if (onClick) {\n labelNode = (\n <button type=\"button\" onClick={onClick} aria-describedby={describedBy} className={stretched}>\n {label}\n </button>\n );\n }\n const interactive = href !== undefined || onClick !== undefined;\n\n // ── Delta ──\n let deltaNode: ReactNode = null;\n if (d && !loading && Number.isFinite(d.value)) {\n const magnitude = Math.abs(d.value);\n const amount =\n d.unit === \"percent\"\n ? numberFormatter(kitLocale, { style: \"percent\", maximumFractionDigits: 1 }).format(magnitude)\n : fmt(magnitude);\n const dir = d.value > 0 ? \"up\" : d.value < 0 ? \"down\" : \"flat\";\n const Icon = dir === \"up\" ? ArrowUp : dir === \"down\" ? ArrowDown : Minus;\n let spoken = dir === \"up\" ? text.increase(amount) : dir === \"down\" ? text.decrease(amount) : text.unchanged;\n let deltaTone = \"text-[var(--text-muted)]\";\n if (goodDirection && dir !== \"flat\") {\n const good = dir === goodDirection;\n spoken = good ? text.better(spoken) : text.worse(spoken);\n deltaTone = good ? \"text-[var(--success)]\" : \"text-[var(--danger)]\";\n }\n deltaNode = (\n // `relative`: the containing block for the sr-only sentence (see the\n // sr-only-containment test for what an escaped one does to the page).\n <div className=\"relative mt-1 flex flex-wrap items-center gap-x-1.5 text-xs tabular-nums\">\n <span id={deltaId} data-private={priv} className={cn(\"inline-flex items-center gap-0.5 font-medium\", deltaTone)}>\n <Icon className=\"size-3 shrink-0\" aria-hidden />\n <span aria-hidden>{dir === \"flat\" ? null : amount}</span>\n <span className=\"sr-only\">{spoken}</span>\n </span>\n {d.label != null && <span className=\"text-[var(--text-muted)]\">{d.label}</span>}\n </div>\n );\n }\n\n // ── Headline ──\n const valueClass = cn(\n \"whitespace-nowrap font-semibold tabular-nums\",\n VALUE_TEXT[size],\n toneClass(tone, value),\n );\n const headline = loading ? (\n <span id={valueId} className=\"relative block py-0.5\">\n <span aria-hidden className={cn(SKELETON, \"block h-6 w-24 max-w-full\")} />\n <span className=\"sr-only\">{text.loading}</span>\n </span>\n ) : hasValue ? (\n <span id={valueId} data-private={priv} className={cn(valueClass, valueLabel != null && \"ms-auto\")}>\n {show(value)}\n {unit != null && (\n <span className=\"ms-1 text-[0.7em] font-medium text-[var(--text-muted)]\">{unit}</span>\n )}\n </span>\n ) : (\n <span id={valueId} className={cn(\"relative\", valueClass, \"text-[var(--text-muted)]\")}>\n <span aria-hidden>—</span>\n <span className=\"sr-only\">{text.noValue}</span>\n </span>\n );\n\n const graphic =\n loading ? null : sparkline ??\n (trend && (\n <Sparkline\n data={trend}\n fluid\n height={size === \"sm\" ? 20 : 28}\n label={labelText}\n formatValue={fmt}\n locale={kitLocale}\n tone={tone === \"signed\" ? \"signed\" : tone === \"neutral\" ? \"chart\" : tone}\n data-private={priv}\n />\n ));\n\n return (\n <Card\n {...rest}\n aria-busy={loading || undefined}\n className={cn(\n // @container: the headline sizes read the tile's width. relative: the stretched\n // link's overlay. overflow-hidden: at pathological widths a figure clips inside\n // the border instead of painting over the neighbouring tile.\n \"@container relative flex min-w-0 flex-col overflow-hidden\",\n PADDING[size],\n interactive && \"transition-colors hover:border-[var(--border-strong)] hover:bg-[var(--bg-hover)]\",\n className,\n )}\n >\n <div className=\"flex items-start gap-2\">\n <div className=\"flex min-w-0 flex-1 items-center gap-1 break-words text-xs uppercase tracking-wide text-[var(--text-muted)]\">\n <span className=\"min-w-0\">{labelNode}</span>\n {/* Above the stretched link's overlay, so it stays hoverable and focusable. */}\n {hint && <FieldHint label={hint} side=\"top\" className=\"relative z-10 shrink-0\" />}\n </div>\n {icon != null && (\n <span aria-hidden className=\"shrink-0 text-[var(--text-muted)] [&_svg]:size-4\">\n {icon}\n </span>\n )}\n </div>\n\n {valueLabel != null ? (\n // Period-left / value-right, matching the sub-rows below it. flex-wrap lets a\n // too-long amount drop to its own end-aligned line instead of overflowing.\n <div className=\"mt-1 flex flex-wrap items-baseline gap-x-1.5\">\n <span className=\"shrink-0 text-xs uppercase tracking-wide text-[var(--text-muted)]\">{valueLabel}</span>\n {headline}\n </div>\n ) : (\n <div className=\"mt-1\">{headline}</div>\n )}\n\n {deltaNode}\n {description != null && <div className=\"mt-1 text-xs text-[var(--text-muted)]\">{description}</div>}\n {graphic && <div className=\"mt-2\">{graphic}</div>}\n\n {!loading && subValues && subValues.length > 0 && (\n <dl className=\"mt-1.5 space-y-0.5\">\n {subValues.map((sv, i) => {\n const svTone = sv.tone ?? (tone === \"neutral\" ? undefined : tone);\n return (\n <div\n key={i}\n className=\"flex flex-wrap items-baseline gap-x-2 text-xs tabular-nums text-[var(--text-muted)]\"\n >\n <dt className=\"uppercase tracking-wide\">{sv.label}</dt>\n <dd\n data-private={priv}\n className={cn(\"ms-auto whitespace-nowrap\", svTone && [toneClass(svTone, sv.value), \"opacity-75\"])}\n >\n {show(sv.value)}\n </dd>\n </div>\n );\n })}\n </dl>\n )}\n\n {footer != null && <div className=\"mt-2\">{footer}</div>}\n </Card>\n );\n}\n\nexport interface StatTileGridProps extends ComponentPropsWithoutRef<\"div\"> {\n /** The narrowest a tile may get before the grid drops a column. The default fits\n * two tiles on a 320px phone. */\n minTileWidth?: string;\n}\n\n/**\n * The row of tiles every dashboard in the three apps lays out by hand, each with its\n * own breakpoint ladder (`grid-cols-2 md:grid-cols-4 min-[2400px]:grid-cols-8` …).\n * Those ladders read the VIEWPORT and so ignored the sidebar: 6-up tiles at 155px in\n * the band where the sidebar was open. This fits as many columns as `minTileWidth`\n * allows in the space the grid actually has, and needs no breakpoint at all.\n */\nexport function StatTileGrid({ minTileWidth = \"9rem\", className, style, ...rest }: StatTileGridProps) {\n const columns: CSSProperties = {\n gridTemplateColumns: `repeat(auto-fit, minmax(min(100%, ${minTileWidth}), 1fr))`,\n };\n return <div {...rest} className={cn(\"grid gap-3\", className)} style={{ ...columns, ...style }} />;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -25,8 +25,14 @@ interface SwipeableRowProps {
|
|
|
25
25
|
right?: SwipeAction[];
|
|
26
26
|
/** Actions committed by dragging LEFT, nearest threshold first. */
|
|
27
27
|
left?: SwipeAction[];
|
|
28
|
-
/** Turn the gesture off — an open editor, a pending mutation, a locked row.
|
|
28
|
+
/** Turn the gesture off — an open editor, a pending mutation, a locked row. This
|
|
29
|
+
* also withdraws the keyboard buttons below: a row that must not be acted on must
|
|
30
|
+
* not be actionable by ANY input. */
|
|
29
31
|
enabled?: boolean;
|
|
32
|
+
/** Names the revealed action buttons as a set ("Row actions"), for a reader that
|
|
33
|
+
* reaches the group before it reaches any one button. Default:
|
|
34
|
+
* `swipeableRow.actions` from the {@link UiKitProvider}, else English. */
|
|
35
|
+
actionsLabel?: string;
|
|
30
36
|
className?: string;
|
|
31
37
|
children: ReactNode;
|
|
32
38
|
}
|
|
@@ -54,7 +60,24 @@ interface SwipeableRowProps {
|
|
|
54
60
|
* Clicks that follow a drag are swallowed in the capture phase: without that, a swipe
|
|
55
61
|
* would also fire whatever click handler the row content carries (expanding it, opening
|
|
56
62
|
* a dialog) on top of the action it just committed.
|
|
63
|
+
*
|
|
64
|
+
* ## The keyboard path is buttons, and deliberately not a typed gesture
|
|
65
|
+
*
|
|
66
|
+
* Every action here used to live exclusively inside a pointer drag — `onCommit` had no
|
|
67
|
+
* other caller — so with a keyboard, with a screen reader, or on any input that cannot
|
|
68
|
+
* express 120px of horizontal travel, the row's actions did not exist at all. That is
|
|
69
|
+
* not a rough edge on a phone control: {@link DataTable} mounts this for every row of
|
|
70
|
+
* its mobile layout, and the actions an app puts here are its destructive ones.
|
|
71
|
+
*
|
|
72
|
+
* The rejected fix was to map the gesture onto keys — arrows nudging `dx`, a chord that
|
|
73
|
+
* "swipes". That invents a private idiom with nothing to discover it by, and it makes
|
|
74
|
+
* the keyboard imitate a touchscreen instead of doing the job the touchscreen is doing.
|
|
75
|
+
* So each action is ALSO a real button, `sr-only` until it takes focus and then shown at
|
|
76
|
+
* the row's trailing edge, firing the identical `onCommit`: one definition of what an
|
|
77
|
+
* action is, two ways to reach it, and nothing to drift apart. `sr-only` hides from
|
|
78
|
+
* the eye and not from the accessibility tree, so the row also ANNOUNCES what can be
|
|
79
|
+
* done to it rather than keeping it behind a gesture nobody can hear.
|
|
57
80
|
*/
|
|
58
|
-
declare function SwipeableRow({ right, left, enabled, className, children, }: SwipeableRowProps): react.JSX.Element;
|
|
81
|
+
declare function SwipeableRow({ right, left, enabled, actionsLabel, className, children, }: SwipeableRowProps): react.JSX.Element;
|
|
59
82
|
|
|
60
83
|
export { type SwipeAction, SwipeableRow, type SwipeableRowProps };
|
|
@@ -1,15 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
3
|
import { useEffect, useRef, useState } from "react";
|
|
3
4
|
import { cn } from "../lib/cn.js";
|
|
4
5
|
import { useRowSwipe } from "../hooks/use-row-swipe.js";
|
|
6
|
+
import { DEFAULT_SWIPEABLE_ROW_LABELS, useKitLabels } from "../i18n/kit-labels.js";
|
|
5
7
|
const PEEK_PX = 40;
|
|
6
8
|
function SwipeableRow({
|
|
7
9
|
right,
|
|
8
10
|
left,
|
|
9
11
|
enabled = true,
|
|
12
|
+
actionsLabel,
|
|
10
13
|
className,
|
|
11
14
|
children
|
|
12
15
|
}) {
|
|
16
|
+
const labels = useKitLabels("swipeableRow", DEFAULT_SWIPEABLE_ROW_LABELS, {
|
|
17
|
+
actions: actionsLabel
|
|
18
|
+
});
|
|
13
19
|
const rowRef = useRef(null);
|
|
14
20
|
const [rowWidth, setRowWidth] = useState(0);
|
|
15
21
|
useEffect(() => {
|
|
@@ -106,6 +112,31 @@ function SwipeableRow({
|
|
|
106
112
|
...active ? swipe.handlers : {},
|
|
107
113
|
children
|
|
108
114
|
}
|
|
115
|
+
),
|
|
116
|
+
active && /* @__PURE__ */ jsx(
|
|
117
|
+
"div",
|
|
118
|
+
{
|
|
119
|
+
role: "group",
|
|
120
|
+
"aria-label": labels.actions,
|
|
121
|
+
className: "pointer-events-none absolute inset-y-0 right-0 z-10 flex items-center gap-1 pr-2",
|
|
122
|
+
children: [...rightActions, ...leftActions].map((action, i) => /* @__PURE__ */ jsxs(
|
|
123
|
+
"button",
|
|
124
|
+
{
|
|
125
|
+
type: "button",
|
|
126
|
+
onClick: action.onCommit,
|
|
127
|
+
className: cn(
|
|
128
|
+
"sr-only whitespace-nowrap rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 text-xs font-medium text-[var(--text-primary)] shadow-sm",
|
|
129
|
+
"focus:not-sr-only focus:pointer-events-auto focus:inline-flex focus:items-center focus:gap-1.5",
|
|
130
|
+
"focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
|
131
|
+
),
|
|
132
|
+
children: [
|
|
133
|
+
action.icon && /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: "[&_svg]:size-4", children: action.icon }),
|
|
134
|
+
action.label
|
|
135
|
+
]
|
|
136
|
+
},
|
|
137
|
+
`${action.label}-${i}`
|
|
138
|
+
))
|
|
139
|
+
}
|
|
109
140
|
)
|
|
110
141
|
] });
|
|
111
142
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/swipeable-row.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useRowSwipe, type SwipeStage } from \"../hooks/use-row-swipe\";\n\n/**\n * One armed swipe action: what it does, and how the reveal panel presents it.\n *\n * The two background classes are the idle and armed states. Before the first\n * threshold is crossed the panel previews the nearest action in its IDLE colour, so\n * the user can see what a little more drag will do; crossing the threshold flips it to\n * `armedClassName`. Passing the same value for both simply removes that feedback.\n */\nexport interface SwipeAction {\n onCommit: () => void;\n label: string;\n /** Rendered inside the panel. Icons stay the consumer's choice — this package\n * ships no opinion about which icon set an app uses. */\n icon?: ReactNode;\n /** Background utility class while the action is previewed but not yet armed. */\n className: string;\n /** Background utility class once the drag has passed this action's threshold. */\n armedClassName: string;\n}\n\nexport interface SwipeableRowProps {\n /** Actions committed by dragging RIGHT, nearest threshold first. */\n right?: SwipeAction[];\n /** Actions committed by dragging LEFT, nearest threshold first. */\n left?: SwipeAction[];\n /** Turn the gesture off — an open editor, a pending mutation, a locked row. */\n enabled?: boolean;\n className?: string;\n children: ReactNode;\n}\n\n/** How much of the row stays on screen at full drag. A row that can be pulled\n * entirely off its own track reads as already deleted, and there is nothing left to\n * drag back; a sliver keeps the gesture reversible and the row identifiable. */\nconst PEEK_PX = 40;\n\n/**\n * A row whose horizontal drag reveals actions underneath it.\n *\n * The reveal panel spans the FULL row width (Keksdose feedback #174). It used to be\n * two half-width blocks side by side, which capped the drag at half the row so the\n * opposite panel could never be exposed — and on a row bound to three actions that\n * left ~55px between stages on a phone, close enough that the wrong one committed.\n * Only the side being dragged toward is painted now, so full width costs nothing:\n * at rest the content covers both anyway, and a drag only ever reveals one side.\n *\n * Thresholds are still not fixed pixel values — they are spread evenly across the\n * available drag, which is now the row width less a {@link PEEK_PX} sliver. A fixed\n * 72/150/228px ladder overshoots a narrow phone; a proportional one keeps every\n * action reachable and roughly doubles the room between them.\n *\n * **Whether the action will fire is stated three ways, not one** (feedback #174): the\n * panel goes from dimmed to solid, the icon grows and gains a filled disc, and the\n * label turns bold — plus the existing haptic tick per newly armed stage. A colour\n * shift alone is easy to miss mid-gesture, and it is invisible to anyone who cannot\n * distinguish the two tones.\n *\n * Clicks that follow a drag are swallowed in the capture phase: without that, a swipe\n * would also fire whatever click handler the row content carries (expanding it, opening\n * a dialog) on top of the action it just committed.\n */\nexport function SwipeableRow({\n right,\n left,\n enabled = true,\n className,\n children,\n}: SwipeableRowProps) {\n const rowRef = useRef<HTMLDivElement>(null);\n const [rowWidth, setRowWidth] = useState(0);\n useEffect(() => {\n const el = rowRef.current;\n if (!el) return;\n setRowWidth(el.clientWidth);\n if (typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver((entries) => {\n for (const e of entries) setRowWidth(e.contentRect.width);\n });\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n const rightActions = right ?? [];\n const leftActions = left ?? [];\n const active = enabled && (rightActions.length > 0 || leftActions.length > 0);\n // The whole row less a sliver, not half of it (feedback #174). `Math.max` keeps a\n // very narrow row (or one measured before layout) from producing a zero or\n // negative drag, which would arm every stage at once.\n const maxDrag = rowWidth > 0 ? Math.max(96, Math.round(rowWidth - PEEK_PX)) : 200;\n const spread = (actions: SwipeAction[]): SwipeStage[] =>\n actions.map((a, i) => ({\n threshold: Math.round((maxDrag * (i + 1)) / (actions.length + 1)),\n onCommit: a.onCommit,\n }));\n\n const swipe = useRowSwipe({\n enabled: active,\n rightStages: spread(rightActions),\n leftStages: spread(leftActions),\n maxDrag,\n });\n\n // A haptic tick per newly armed threshold, so each stage is felt and not only seen.\n const armedNow = swipe.dx >= 0 ? swipe.armedRightIndex : swipe.armedLeftIndex;\n const prevArmed = useRef(-1);\n useEffect(() => {\n if (armedNow > prevArmed.current && armedNow >= 0) navigator.vibrate?.(12);\n prevArmed.current = armedNow;\n }, [armedNow]);\n\n // Index -1 (nothing armed yet) still previews the nearest action, idle-coloured.\n const dx = swipe.dx;\n // Only the side being dragged toward is painted, which is what makes a full-width\n // panel safe.\n //\n // The side has to SURVIVE the release, though, and that is what this ref is for\n // (Keksdose feedback dev#467): *\"Swiping a category to the left with no option — if\n // letting it go it gets, for the way back, the background color of the opposite\n // swipe direction where there should be no color change at all.\"* On release dx\n // snaps to 0 while the row itself slides back over 150ms, so a plain `dx < 0` read\n // \"right\" for exactly the frames where the left side was still uncovered — and\n // painted the RIGHT action's colour into the gap. With no left action at all that\n // colour was the only thing the gesture ever showed.\n //\n // Written during render rather than in an effect: an effect would repaint a frame\n // later (visibly, at 150ms) and `setState` in an effect body is an eslint error in\n // the consuming app. The value is derived from this render's own dx, so a\n // concurrent re-render recomputes it identically.\n const lastSide = useRef<\"left\" | \"right\">(\"right\");\n if (dx !== 0) lastSide.current = dx < 0 ? \"left\" : \"right\";\n const draggingLeft = dx !== 0 ? dx < 0 : lastSide.current === \"left\";\n const shown = draggingLeft\n ? leftActions[Math.max(0, swipe.armedLeftIndex)]\n : rightActions[Math.max(0, swipe.armedRightIndex)];\n const armed = draggingLeft ? swipe.armedLeftIndex >= 0 : swipe.armedRightIndex >= 0;\n\n return (\n <div ref={rowRef} className={cn(\"relative overflow-hidden\", className)}>\n {active && shown && (\n <div\n className={cn(\n \"pointer-events-none absolute inset-0 flex items-center gap-2 px-4\",\n \"text-xs font-medium text-white transition-all\",\n // Dimmed until the drag has actually passed a threshold: \"nothing will\n // happen yet\" has to look different from \"let go and this fires\", and on\n // a moving row a one-step colour change was too quiet to notice.\n armed ? \"opacity-100\" : \"opacity-60\",\n armed ? shown.armedClassName : shown.className,\n // The panel is anchored to the edge the row is uncovering, so the label\n // sits where the eye already is rather than across the screen.\n draggingLeft && \"flex-row-reverse\",\n )}\n >\n {shown.icon && (\n <span\n className={cn(\n \"flex shrink-0 items-center justify-center rounded-full transition-all\",\n // Armed, the icon gets a disc of its own and grows — a second,\n // non-colour signal, and the one that stays readable at a glance.\n armed ? \"size-8 scale-110 bg-white/25\" : \"size-7 bg-white/10\",\n )}\n >\n {shown.icon}\n </span>\n )}\n <span className={cn(\"truncate transition-all\", armed && \"text-sm font-semibold\")}>\n {shown.label}\n </span>\n </div>\n )}\n <div\n // Capture phase: the row's own click handler lives on a DESCENDANT, so a\n // bubble-phase listener here would run after it — too late to suppress.\n onClickCapture={(e) => {\n if (!swipe.consumeClick()) return;\n e.preventDefault();\n e.stopPropagation();\n }}\n className={cn(\n \"relative bg-[var(--bg-surface)]\",\n // A mouse drag is a supported way to swipe (use-row-swipe gates only on\n // the button, not the pointer type), and a horizontal mouse drag over\n // text also SELECTS that text — so committing an action left the row\n // highlighted blue. Suppressed only while dragging, so text in a row at\n // rest stays selectable.\n swipe.dragging ? \"select-none\" : \"transition-transform duration-150\",\n )}\n style={{\n transform: dx !== 0 ? `translate3d(${dx}px,0,0)` : undefined,\n touchAction: active ? \"pan-y\" : undefined,\n }}\n {...(active ? swipe.handlers : {})}\n >\n {children}\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/swipeable-row.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useRowSwipe, type SwipeStage } from \"../hooks/use-row-swipe\";\nimport { DEFAULT_SWIPEABLE_ROW_LABELS, useKitLabels } from \"../i18n/kit-labels\";\n\n/**\n * One armed swipe action: what it does, and how the reveal panel presents it.\n *\n * The two background classes are the idle and armed states. Before the first\n * threshold is crossed the panel previews the nearest action in its IDLE colour, so\n * the user can see what a little more drag will do; crossing the threshold flips it to\n * `armedClassName`. Passing the same value for both simply removes that feedback.\n */\nexport interface SwipeAction {\n onCommit: () => void;\n label: string;\n /** Rendered inside the panel. Icons stay the consumer's choice — this package\n * ships no opinion about which icon set an app uses. */\n icon?: ReactNode;\n /** Background utility class while the action is previewed but not yet armed. */\n className: string;\n /** Background utility class once the drag has passed this action's threshold. */\n armedClassName: string;\n}\n\nexport interface SwipeableRowProps {\n /** Actions committed by dragging RIGHT, nearest threshold first. */\n right?: SwipeAction[];\n /** Actions committed by dragging LEFT, nearest threshold first. */\n left?: SwipeAction[];\n /** Turn the gesture off — an open editor, a pending mutation, a locked row. This\n * also withdraws the keyboard buttons below: a row that must not be acted on must\n * not be actionable by ANY input. */\n enabled?: boolean;\n /** Names the revealed action buttons as a set (\"Row actions\"), for a reader that\n * reaches the group before it reaches any one button. Default:\n * `swipeableRow.actions` from the {@link UiKitProvider}, else English. */\n actionsLabel?: string;\n className?: string;\n children: ReactNode;\n}\n\n/** How much of the row stays on screen at full drag. A row that can be pulled\n * entirely off its own track reads as already deleted, and there is nothing left to\n * drag back; a sliver keeps the gesture reversible and the row identifiable. */\nconst PEEK_PX = 40;\n\n/**\n * A row whose horizontal drag reveals actions underneath it.\n *\n * The reveal panel spans the FULL row width (Keksdose feedback #174). It used to be\n * two half-width blocks side by side, which capped the drag at half the row so the\n * opposite panel could never be exposed — and on a row bound to three actions that\n * left ~55px between stages on a phone, close enough that the wrong one committed.\n * Only the side being dragged toward is painted now, so full width costs nothing:\n * at rest the content covers both anyway, and a drag only ever reveals one side.\n *\n * Thresholds are still not fixed pixel values — they are spread evenly across the\n * available drag, which is now the row width less a {@link PEEK_PX} sliver. A fixed\n * 72/150/228px ladder overshoots a narrow phone; a proportional one keeps every\n * action reachable and roughly doubles the room between them.\n *\n * **Whether the action will fire is stated three ways, not one** (feedback #174): the\n * panel goes from dimmed to solid, the icon grows and gains a filled disc, and the\n * label turns bold — plus the existing haptic tick per newly armed stage. A colour\n * shift alone is easy to miss mid-gesture, and it is invisible to anyone who cannot\n * distinguish the two tones.\n *\n * Clicks that follow a drag are swallowed in the capture phase: without that, a swipe\n * would also fire whatever click handler the row content carries (expanding it, opening\n * a dialog) on top of the action it just committed.\n *\n * ## The keyboard path is buttons, and deliberately not a typed gesture\n *\n * Every action here used to live exclusively inside a pointer drag — `onCommit` had no\n * other caller — so with a keyboard, with a screen reader, or on any input that cannot\n * express 120px of horizontal travel, the row's actions did not exist at all. That is\n * not a rough edge on a phone control: {@link DataTable} mounts this for every row of\n * its mobile layout, and the actions an app puts here are its destructive ones.\n *\n * The rejected fix was to map the gesture onto keys — arrows nudging `dx`, a chord that\n * \"swipes\". That invents a private idiom with nothing to discover it by, and it makes\n * the keyboard imitate a touchscreen instead of doing the job the touchscreen is doing.\n * So each action is ALSO a real button, `sr-only` until it takes focus and then shown at\n * the row's trailing edge, firing the identical `onCommit`: one definition of what an\n * action is, two ways to reach it, and nothing to drift apart. `sr-only` hides from\n * the eye and not from the accessibility tree, so the row also ANNOUNCES what can be\n * done to it rather than keeping it behind a gesture nobody can hear.\n */\nexport function SwipeableRow({\n right,\n left,\n enabled = true,\n actionsLabel,\n className,\n children,\n}: SwipeableRowProps) {\n const labels = useKitLabels(\"swipeableRow\", DEFAULT_SWIPEABLE_ROW_LABELS, {\n actions: actionsLabel,\n });\n const rowRef = useRef<HTMLDivElement>(null);\n const [rowWidth, setRowWidth] = useState(0);\n useEffect(() => {\n const el = rowRef.current;\n if (!el) return;\n setRowWidth(el.clientWidth);\n if (typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver((entries) => {\n for (const e of entries) setRowWidth(e.contentRect.width);\n });\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n const rightActions = right ?? [];\n const leftActions = left ?? [];\n const active = enabled && (rightActions.length > 0 || leftActions.length > 0);\n // The whole row less a sliver, not half of it (feedback #174). `Math.max` keeps a\n // very narrow row (or one measured before layout) from producing a zero or\n // negative drag, which would arm every stage at once.\n const maxDrag = rowWidth > 0 ? Math.max(96, Math.round(rowWidth - PEEK_PX)) : 200;\n const spread = (actions: SwipeAction[]): SwipeStage[] =>\n actions.map((a, i) => ({\n threshold: Math.round((maxDrag * (i + 1)) / (actions.length + 1)),\n onCommit: a.onCommit,\n }));\n\n const swipe = useRowSwipe({\n enabled: active,\n rightStages: spread(rightActions),\n leftStages: spread(leftActions),\n maxDrag,\n });\n\n // A haptic tick per newly armed threshold, so each stage is felt and not only seen.\n const armedNow = swipe.dx >= 0 ? swipe.armedRightIndex : swipe.armedLeftIndex;\n const prevArmed = useRef(-1);\n useEffect(() => {\n if (armedNow > prevArmed.current && armedNow >= 0) navigator.vibrate?.(12);\n prevArmed.current = armedNow;\n }, [armedNow]);\n\n // Index -1 (nothing armed yet) still previews the nearest action, idle-coloured.\n const dx = swipe.dx;\n // Only the side being dragged toward is painted, which is what makes a full-width\n // panel safe.\n //\n // The side has to SURVIVE the release, though, and that is what this ref is for\n // (Keksdose feedback dev#467): *\"Swiping a category to the left with no option — if\n // letting it go it gets, for the way back, the background color of the opposite\n // swipe direction where there should be no color change at all.\"* On release dx\n // snaps to 0 while the row itself slides back over 150ms, so a plain `dx < 0` read\n // \"right\" for exactly the frames where the left side was still uncovered — and\n // painted the RIGHT action's colour into the gap. With no left action at all that\n // colour was the only thing the gesture ever showed.\n //\n // Written during render rather than in an effect: an effect would repaint a frame\n // later (visibly, at 150ms) and `setState` in an effect body is an eslint error in\n // the consuming app. The value is derived from this render's own dx, so a\n // concurrent re-render recomputes it identically.\n const lastSide = useRef<\"left\" | \"right\">(\"right\");\n if (dx !== 0) lastSide.current = dx < 0 ? \"left\" : \"right\";\n const draggingLeft = dx !== 0 ? dx < 0 : lastSide.current === \"left\";\n const shown = draggingLeft\n ? leftActions[Math.max(0, swipe.armedLeftIndex)]\n : rightActions[Math.max(0, swipe.armedRightIndex)];\n const armed = draggingLeft ? swipe.armedLeftIndex >= 0 : swipe.armedRightIndex >= 0;\n\n return (\n <div ref={rowRef} className={cn(\"relative overflow-hidden\", className)}>\n {active && shown && (\n <div\n className={cn(\n \"pointer-events-none absolute inset-0 flex items-center gap-2 px-4\",\n \"text-xs font-medium text-white transition-all\",\n // Dimmed until the drag has actually passed a threshold: \"nothing will\n // happen yet\" has to look different from \"let go and this fires\", and on\n // a moving row a one-step colour change was too quiet to notice.\n armed ? \"opacity-100\" : \"opacity-60\",\n armed ? shown.armedClassName : shown.className,\n // The panel is anchored to the edge the row is uncovering, so the label\n // sits where the eye already is rather than across the screen.\n draggingLeft && \"flex-row-reverse\",\n )}\n >\n {shown.icon && (\n <span\n className={cn(\n \"flex shrink-0 items-center justify-center rounded-full transition-all\",\n // Armed, the icon gets a disc of its own and grows — a second,\n // non-colour signal, and the one that stays readable at a glance.\n armed ? \"size-8 scale-110 bg-white/25\" : \"size-7 bg-white/10\",\n )}\n >\n {shown.icon}\n </span>\n )}\n <span className={cn(\"truncate transition-all\", armed && \"text-sm font-semibold\")}>\n {shown.label}\n </span>\n </div>\n )}\n <div\n // Capture phase: the row's own click handler lives on a DESCENDANT, so a\n // bubble-phase listener here would run after it — too late to suppress.\n onClickCapture={(e) => {\n if (!swipe.consumeClick()) return;\n e.preventDefault();\n e.stopPropagation();\n }}\n className={cn(\n \"relative bg-[var(--bg-surface)]\",\n // A mouse drag is a supported way to swipe (use-row-swipe gates only on\n // the button, not the pointer type), and a horizontal mouse drag over\n // text also SELECTS that text — so committing an action left the row\n // highlighted blue. Suppressed only while dragging, so text in a row at\n // rest stays selectable.\n swipe.dragging ? \"select-none\" : \"transition-transform duration-150\",\n )}\n style={{\n transform: dx !== 0 ? `translate3d(${dx}px,0,0)` : undefined,\n touchAction: active ? \"pan-y\" : undefined,\n }}\n {...(active ? swipe.handlers : {})}\n >\n {children}\n </div>\n {active && (\n <div\n role=\"group\"\n aria-label={labels.actions}\n // OUTSIDE the sliding div on purpose: an action that travels with the row it\n // acts on is a moving target, and the drag transform would carry it off the\n // screen mid-gesture.\n //\n // `pointer-events-none` here, restored on a button only once it HAS focus: a\n // sr-only button is clipped to nothing but is still a hit target, and this\n // strip sits at the row's right edge — exactly where a leftward drag begins.\n // A gesture that silently failed to start because it landed on an invisible\n // button is not a bug anyone would find by looking at the screen.\n className=\"pointer-events-none absolute inset-y-0 right-0 z-10 flex items-center gap-1 pr-2\"\n >\n {/* Both sides, each still nearest-threshold first, so the tab order reads in\n the order the gesture arms them. */}\n {[...rightActions, ...leftActions].map((action, i) => (\n <button\n key={`${action.label}-${i}`}\n type=\"button\"\n onClick={action.onCommit}\n className={cn(\n \"sr-only whitespace-nowrap rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 text-xs font-medium text-[var(--text-primary)] shadow-sm\",\n \"focus:not-sr-only focus:pointer-events-auto focus:inline-flex focus:items-center focus:gap-1.5\",\n \"focus-visible:ring-2 focus-visible:ring-[var(--brand)]\",\n )}\n >\n {/* The label is the button's accessible name, so the caller's icon is\n decoration on top of it rather than a second reading of it. */}\n {action.icon && (\n <span aria-hidden=\"true\" className=\"[&_svg]:size-4\">\n {action.icon}\n </span>\n )}\n {action.label}\n </button>\n ))}\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|