@eifi1/ui-kit 0.4.1 → 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 +89 -19
- 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 +74 -19
- package/dist/components/dropdown.js +72 -22
- 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 +21 -7
- package/dist/components/file-dropzone.js +28 -7
- 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 +21 -2
- package/dist/components/number-input.js +2 -1
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +25 -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 +5 -3
- package/dist/components/user-avatar.js +4 -2
- 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 +14 -1
- package/dist/theme/palette-presets.js +231 -3
- 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 +66 -13
- 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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/entity-combobox.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from \"./ui\";\nimport { ComboboxPanel, useComboboxCore, type ComboOption } from \"./combobox-core\";\n\nexport type { ComboOption } from \"./combobox-core\";\n\nexport interface EntityComboboxProps<V extends string | number> {\n /** Selected id, or null/undefined when nothing is selected. */\n value: V | null | undefined;\n /** Selecting an option emits its value; the clear button emits `null`. */\n onChange: (value: V | null) => void;\n /** Already-loaded options (client-side filtered). Also used to resolve the\n * trigger label for the current `value`. */\n options?: ComboOption<V>[];\n /** Async option source, debounced and called on open + as the query changes.\n * Stale responses are ignored. When set, `options` is used only for label\n * resolution, not as the result list. */\n loadOptions?: (query: string) => Promise<ComboOption<V>[]>;\n /** External loading flag (OR-ed with the internal async state). */\n loading?: boolean;\n label?: ReactNode;\n placeholder?: string;\n searchPlaceholder?: string;\n emptyLabel?: string;\n clearable?: boolean;\n clearLabel?: string;\n /** The phone sheet's close button. Its own prop rather than a reuse of\n * `clearLabel`: \"clear the selection\" and \"close this screen\" are different\n * actions, and on a full-screen sheet the close button is the only way out —\n * so it is the one control here that MUST be in the reader's language. */\n closeLabel?: string;\n disabled?: boolean;\n /** When set, a \"create\" row appears for a non-empty query with no exact match. */\n onCreate?: (query: string) => void;\n createLabel?: (query: string) => string;\n className?: string;\n /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */\n invalid?: boolean;\n}\n\n/**\n * An id-keyed, searchable entity picker: a field-styled trigger showing the\n * selected item's label, and a portalled dropdown of `{label, sublabel, icon}`\n * options — loaded up front via `options` or lazily via `loadOptions`. Built on\n * the shared field/anchor/dismiss/search primitives (no cmdk/Radix). For picking\n * several entities use {@link MultiEntityCombobox}.\n */\nexport function EntityCombobox<V extends string | number>({\n value,\n onChange,\n options,\n loadOptions,\n loading,\n label,\n placeholder,\n searchPlaceholder = \"Search\",\n emptyLabel = \"No results\",\n clearable,\n clearLabel,\n closeLabel,\n disabled,\n onCreate,\n createLabel,\n className,\n invalid,\n}: EntityComboboxProps<V>) {\n const core = useComboboxCore<V>({ options, loadOptions, loading });\n const { results, resolve, setOpen } = core;\n\n const selectedOption = value == null ? null : resolve(value);\n const q = core.query.trim();\n const showCreate =\n Boolean(onCreate) && q.length > 0 && !results.some((o) => o.label.toLowerCase() === q.toLowerCase());\n const showClear = Boolean(clearable && value != null && !disabled);\n /** What the closed control is showing — the second half of its accessible name. */\n const triggerText = selectedOption?.label ?? placeholder ?? \"\";\n\n const choose = (o: ComboOption<V>) => {\n core.cacheRef.current.set(o.value, o);\n onChange(o.value);\n setOpen(false);\n };\n\n return (\n <div className={cn(\"relative\", className)}>\n {label !== undefined && <FieldLabel>{label}</FieldLabel>}\n <button\n ref={core.triggerRef}\n type=\"button\"\n // The label is a floating <span>, not a <label for>, so without this the\n // trigger's accessible name is whatever value happens to be selected —\n // \"Checking\" with nothing saying it is the account. The label AND the\n // value, because `aria-label` replaces the content rather than adding to\n // it, and a control that announces only its name has lost the answer.\n aria-label={\n typeof label === \"string\" ? `${label}: ${triggerText}` : undefined\n }\n disabled={disabled}\n aria-invalid={invalid || undefined}\n onClick={() => !disabled && setOpen((o) => !o)}\n className={cn(\n FIELD_TRIGGER,\n \"pr-9\",\n label !== undefined && FIELD_FLOATING_PAD,\n disabled && \"cursor-not-allowed opacity-50\",\n invalid && FIELD_INVALID,\n )}\n >\n <span className=\"flex min-w-0 items-center gap-2\">\n {selectedOption?.icon && <span className=\"shrink-0\">{selectedOption.icon}</span>}\n <span\n className={cn(\n \"truncate\",\n // A chosen value is the field's VALUE, so it is set in the same ink an\n // <input>'s value is — FIELD_BASE's own text colour, inherited rather\n // than restated (Keksdose dev#477). It used to be one notch lighter\n // than the typeahead fields beside it, which is visible when a picker\n // and a text field share a form row. Nothing selected keeps the\n // placeholder grey, which every field here agrees on.\n !selectedOption && \"text-slate-400 dark:text-slate-500\",\n )}\n >\n {selectedOption?.label ?? placeholder ?? \"\"}\n </span>\n </span>\n {showClear ? (\n <span\n role=\"button\"\n tabIndex={-1}\n aria-label={clearLabel ?? \"Clear\"}\n onClick={(e) => {\n e.stopPropagation();\n onChange(null);\n }}\n className=\"absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300\"\n >\n <X className=\"size-4\" />\n </span>\n ) : (\n <FieldChevron />\n )}\n </button>\n <ComboboxPanel\n core={core}\n // On a phone the panel becomes a full-screen sheet, which needs the field's\n // own label to say what it is asking for (live #200).\n sheetTitle={label ?? placeholder}\n searchPlaceholder={searchPlaceholder}\n emptyLabel={emptyLabel}\n closeLabel={closeLabel}\n isSelected={(v) => v === value}\n onChoose={choose}\n showCreate={showCreate}\n onCreate={() => {\n onCreate?.(q);\n setOpen(false);\n }}\n createContent={createLabel ? createLabel(q) : `Create “${q}”`}\n />\n </div>\n );\n}\n"],"mappings":"AAuF8B,cAuBtB,YAvBsB;AAtF9B,SAAS,SAAS;AAClB,SAAS,UAAU;AACnB,SAAS,cAAc,YAAY,eAAe,oBAAoB,qBAAqB;AAC3F,SAAS,eAAe,uBAAyC;AA6C1D,SAAS,eAA0C;AAAA,EACxD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,oBAAoB;AAAA,EACpB,aAAa;AAAA,EACb;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA2B;AACzB,QAAM,OAAO,gBAAmB,EAAE,SAAS,aAAa,QAAQ,CAAC;AACjE,QAAM,EAAE,SAAS,SAAS,QAAQ,IAAI;AAEtC,QAAM,iBAAiB,SAAS,OAAO,OAAO,QAAQ,KAAK;AAC3D,QAAM,IAAI,KAAK,MAAM,KAAK;AAC1B,QAAM,aACJ,QAAQ,QAAQ,KAAK,EAAE,SAAS,KAAK,CAAC,QAAQ,KAAK,CAAC,MAAM,EAAE,MAAM,YAAY,MAAM,EAAE,YAAY,CAAC;AACrG,QAAM,YAAY,QAAQ,aAAa,SAAS,QAAQ,CAAC,QAAQ;AAEjE,QAAM,cAAc,gBAAgB,SAAS,eAAe;AAE5D,QAAM,SAAS,CAAC,MAAsB;AACpC,SAAK,SAAS,QAAQ,IAAI,EAAE,OAAO,CAAC;AACpC,aAAS,EAAE,KAAK;AAChB,YAAQ,KAAK;AAAA,EACf;AAEA,SACE,qBAAC,SAAI,WAAW,GAAG,YAAY,SAAS,GACrC;AAAA,cAAU,UAAa,oBAAC,cAAY,iBAAM;AAAA,IAC3C;AAAA,MAAC;AAAA;AAAA,QACC,KAAK,KAAK;AAAA,QACV,MAAK;AAAA,QAML,cACE,OAAO,UAAU,WAAW,GAAG,KAAK,KAAK,WAAW,KAAK;AAAA,QAE3D;AAAA,QACA,gBAAc,WAAW;AAAA,QACzB,SAAS,MAAM,CAAC,YAAY,QAAQ,CAAC,MAAM,CAAC,CAAC;AAAA,QAC7C,WAAW;AAAA,UACT;AAAA,UACA;AAAA,UACA,UAAU,UAAa;AAAA,UACvB,YAAY;AAAA,UACZ,WAAW;AAAA,QACb;AAAA,QAEA;AAAA,+BAAC,UAAK,WAAU,mCACb;AAAA,4BAAgB,QAAQ,oBAAC,UAAK,WAAU,YAAY,yBAAe,MAAK;AAAA,YACzE;AAAA,cAAC;AAAA;AAAA,gBACC,WAAW;AAAA,kBACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAOA,CAAC,kBAAkB;AAAA,gBACrB;AAAA,gBAEC,0BAAgB,SAAS,eAAe;AAAA;AAAA,YAC3C;AAAA,aACF;AAAA,UACC,YACC;AAAA,YAAC;AAAA;AAAA,cACC,MAAK;AAAA,cACL,UAAU;AAAA,cACV,cAAY,cAAc;AAAA,cAC1B,SAAS,CAAC,MAAM;AACd,kBAAE,gBAAgB;AAClB,yBAAS,IAAI;AAAA,cACf;AAAA,cACA,WAAU;AAAA,cAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,UACxB,IAEA,oBAAC,gBAAa;AAAA;AAAA;AAAA,IAElB;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QAGA,YAAY,SAAS;AAAA,QACrB;AAAA,QACA;AAAA,QACA;AAAA,QACA,YAAY,CAAC,MAAM,MAAM;AAAA,QACzB,UAAU;AAAA,QACV;AAAA,QACA,UAAU,MAAM;AACd,qBAAW,CAAC;AACZ,kBAAQ,KAAK;AAAA,QACf;AAAA,QACA,eAAe,cAAc,YAAY,CAAC,IAAI,gBAAW,CAAC;AAAA;AAAA,IAC5D;AAAA,KACF;AAEJ;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/entity-combobox.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from \"./ui\";\nimport { ComboboxPanel, useComboboxCore, type ComboOption } from \"./combobox-core\";\nimport { DEFAULT_COMBOBOX_LABELS, DEFAULT_COMMON_LABELS, useKitLabels } from \"../i18n/kit-labels\";\n\nexport type { ComboOption } from \"./combobox-core\";\n\n/** `onChange` is the kit's — \"a selection was made\", carrying values — rather\n * than the div's form event, so the DOM's spelling of it is omitted. */\nexport interface EntityComboboxProps<V extends string | number>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\"> {\n /** Selected id, or null/undefined when nothing is selected. */\n value: V | null | undefined;\n /** Selecting an option emits its value; the clear button emits `null`. */\n onChange: (value: V | null) => void;\n /** Already-loaded options (client-side filtered). Also used to resolve the\n * trigger label for the current `value`. */\n options?: ComboOption<V>[];\n /** Async option source, debounced and called on open + as the query changes.\n * Stale responses are ignored. When set, `options` is used only for label\n * resolution, not as the result list. */\n loadOptions?: (query: string) => Promise<ComboOption<V>[]>;\n /** External loading flag (OR-ed with the internal async state). */\n loading?: boolean;\n label?: ReactNode;\n placeholder?: string;\n searchPlaceholder?: string;\n emptyLabel?: string;\n clearable?: boolean;\n clearLabel?: string;\n /** The phone sheet's close button. Its own prop rather than a reuse of\n * `clearLabel`: \"clear the selection\" and \"close this screen\" are different\n * actions, and on a full-screen sheet the close button is the only way out —\n * so it is the one control here that MUST be in the reader's language. */\n closeLabel?: string;\n disabled?: boolean;\n /** When set, a \"create\" row appears for a non-empty query with no exact match. */\n onCreate?: (query: string) => void;\n createLabel?: (query: string) => string;\n /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */\n invalid?: boolean;\n}\n\n/**\n * An id-keyed, searchable entity picker: a field-styled trigger showing the\n * selected item's label, and a portalled dropdown of `{label, sublabel, icon}`\n * options — loaded up front via `options` or lazily via `loadOptions`. Built on\n * the shared field/anchor/dismiss/search primitives (no cmdk/Radix). For picking\n * several entities use {@link MultiEntityCombobox}.\n */\nexport function EntityCombobox<V extends string | number>({\n value,\n onChange,\n options,\n loadOptions,\n loading,\n label,\n placeholder,\n searchPlaceholder,\n emptyLabel,\n clearable,\n clearLabel,\n closeLabel,\n disabled,\n onCreate,\n createLabel,\n className,\n invalid,\n \"aria-label\": ariaLabel,\n ...rest\n}: EntityComboboxProps<V>) {\n const core = useComboboxCore<V>({ options, loadOptions, loading });\n // The props are the per-instance overrides, the provider the app-wide ones; a\n // prop left `undefined` falls through to the provider rather than masking it.\n const labels = useKitLabels(\"combobox\", DEFAULT_COMBOBOX_LABELS, {\n search: searchPlaceholder,\n noResults: emptyLabel,\n clear: clearLabel,\n create: createLabel,\n });\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const { open, results, resolve, setOpen } = core;\n // One id per instance, generated here rather than in the core: `aria-controls` on\n // the trigger has to name the list while the list is still closed, so the id\n // belongs to whoever renders both ends of it.\n const listboxId = useId();\n\n const selectedOption = value == null ? null : resolve(value);\n const q = core.query.trim();\n const showCreate =\n Boolean(onCreate) && q.length > 0 && !results.some((o) => o.label.toLowerCase() === q.toLowerCase());\n const showClear = Boolean(clearable && value != null && !disabled);\n /** What the closed control is showing — the second half of its accessible name. */\n const triggerText = selectedOption?.label ?? placeholder ?? \"\";\n\n const choose = (o: ComboOption<V>) => {\n core.cacheRef.current.set(o.value, o);\n onChange(o.value);\n // Back to the trigger, not to <body>: the panel that held focus is about to\n // unmount, and a keyboard user who just answered this field should be standing\n // on it, ready to Tab to the next one.\n core.closeToTrigger();\n };\n\n return (\n // `rest` dresses the wrapper, which has no role; the accessible NAME goes on\n // the trigger, which has one. Spread FIRST so the trigger's ARIA and the\n // handlers that open the panel cannot be clobbered from outside.\n <div {...rest} className={cn(\"relative\", className)}>\n {label !== undefined && <FieldLabel>{label}</FieldLabel>}\n <button\n ref={core.triggerRef}\n type=\"button\"\n // A combobox, not a button. The distinction is not pedantry: this control\n // carried `aria-invalid`, which `button` does not support, so a required\n // field left empty painted a rose border and told a reader nothing at all —\n // and ESLint flagged it as exactly that (`role-supports-aria-props`). The\n // fix the audit asked for is the role that describes what this IS: a closed\n // choice that expands into the list named below. `combobox` supports\n // `aria-invalid`, so the border and the announcement finally agree.\n role=\"combobox\"\n aria-expanded={open}\n aria-controls={listboxId}\n aria-haspopup=\"listbox\"\n // The label is a floating <span>, not a <label for>, so without this the\n // trigger's accessible name is whatever value happens to be selected —\n // \"Checking\" with nothing saying it is the account. The label AND the\n // value, because `aria-label` replaces the content rather than adding to\n // it, and a control that announces only its name has lost the answer.\n // A caller's own name wins over the composition: two fields labelled\n // \"Account\" on a transfer form are the from and the to, and only the\n // caller knows which is which.\n aria-label={\n ariaLabel ??\n (typeof label === \"string\" ? common.fieldValue(label, triggerText) : undefined)\n }\n disabled={disabled}\n aria-invalid={invalid || undefined}\n onClick={() => !disabled && setOpen((o) => !o)}\n // Down/Up opens the list from the closed trigger, per the APG. Enter and\n // Space already do it through the button's own click.\n onKeyDown={(e) => {\n if (disabled) return;\n if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n setOpen(true);\n }\n }}\n className={cn(\n FIELD_TRIGGER,\n \"pr-9\",\n label !== undefined && FIELD_FLOATING_PAD,\n disabled && \"cursor-not-allowed opacity-50\",\n invalid && FIELD_INVALID,\n )}\n >\n <span className=\"flex min-w-0 items-center gap-2\">\n {selectedOption?.icon && <span className=\"shrink-0\">{selectedOption.icon}</span>}\n <span\n className={cn(\n \"truncate\",\n // A chosen value is the field's VALUE, so it is set in the same ink an\n // <input>'s value is — FIELD_BASE's own text colour, inherited rather\n // than restated (Keksdose dev#477). It used to be one notch lighter\n // than the typeahead fields beside it, which is visible when a picker\n // and a text field share a form row. Nothing selected keeps the\n // placeholder tone (`--text-placeholder`), which every field here\n // agrees on.\n !selectedOption && \"text-[var(--text-placeholder)]\",\n )}\n >\n {selectedOption?.label ?? placeholder ?? \"\"}\n </span>\n </span>\n {showClear ? (\n <span\n role=\"button\"\n tabIndex={-1}\n aria-label={labels.clear}\n onClick={(e) => {\n e.stopPropagation();\n onChange(null);\n }}\n className=\"absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]\"\n >\n <X className=\"size-4\" />\n </span>\n ) : (\n <FieldChevron />\n )}\n </button>\n <ComboboxPanel\n core={core}\n listboxId={listboxId}\n // On a phone the panel becomes a full-screen sheet, which needs the field's\n // own label to say what it is asking for (live #200).\n sheetTitle={label ?? placeholder}\n searchPlaceholder={labels.search}\n emptyLabel={labels.noResults}\n closeLabel={closeLabel}\n isSelected={(v) => v === value}\n onChoose={choose}\n showCreate={showCreate}\n onCreate={() => {\n onCreate?.(q);\n core.closeToTrigger();\n }}\n createContent={labels.create(q)}\n />\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { CSSProperties } from 'react';
|
|
2
|
+
import { SeriesChartAxis } from './series-chart.js';
|
|
3
|
+
import './chart-zoom.js';
|
|
4
|
+
import 'recharts';
|
|
5
|
+
import './series-chart-labels.js';
|
|
6
|
+
|
|
7
|
+
/** Which chart of a facing pair. Physical, like the axes: charts do not mirror in RTL. */
|
|
8
|
+
type FacingSide = "left" | "right";
|
|
9
|
+
/** The two sides, in the order the columns stand. */
|
|
10
|
+
declare const FACING_SIDES: readonly FacingSide[];
|
|
11
|
+
/** The whole band a facing y axis occupies: its ticks, and the strip the rotated title
|
|
12
|
+
* is drawn in — whether or not this chart is the one that spends it. */
|
|
13
|
+
declare function facingBand(tickWidth?: number): number;
|
|
14
|
+
/**
|
|
15
|
+
* The y axis of one chart of a facing pair.
|
|
16
|
+
*
|
|
17
|
+
* The left one carries the row's quantity and the ticks' own width; the right one has
|
|
18
|
+
* no title, stands on the right and reserves the whole band, so the two plots come out
|
|
19
|
+
* the same width. Both may be pinned to one `domain` — whether they are is a statement
|
|
20
|
+
* about the measurement, so it is the caller's.
|
|
21
|
+
*/
|
|
22
|
+
declare function facingAxes({ side, title, format, tickWidth, domain, color, }: {
|
|
23
|
+
side: FacingSide;
|
|
24
|
+
/** The row's quantity, with its unit. Drawn on the left chart only. */
|
|
25
|
+
title: string;
|
|
26
|
+
format?: (value: number) => string;
|
|
27
|
+
tickWidth?: number;
|
|
28
|
+
domain?: [number, number];
|
|
29
|
+
color?: string;
|
|
30
|
+
}): SeriesChartAxis[];
|
|
31
|
+
/** What a column heading is padded by to sit over the PLOT rather than the column: the
|
|
32
|
+
* axis band, on the side that column's axis stands on. Physical padding on purpose —
|
|
33
|
+
* the chart under it does not flip in RTL, so neither may the heading's offset. */
|
|
34
|
+
declare function facingHeadingPad(side: FacingSide, tickWidth?: number): Pick<CSSProperties, "paddingLeft" | "paddingRight">;
|
|
35
|
+
|
|
36
|
+
export { FACING_SIDES, type FacingSide, facingAxes, facingBand, facingHeadingPad };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { DEFAULT_Y_AXIS } from "./chart-zoom.js";
|
|
3
|
+
import { AXIS_TICK_WIDTH, axisBandWidth } from "./series-chart.js";
|
|
4
|
+
const FACING_SIDES = ["left", "right"];
|
|
5
|
+
function facingBand(tickWidth = AXIS_TICK_WIDTH) {
|
|
6
|
+
return axisBandWidth(tickWidth, true);
|
|
7
|
+
}
|
|
8
|
+
function facingAxes({
|
|
9
|
+
side,
|
|
10
|
+
title,
|
|
11
|
+
format,
|
|
12
|
+
tickWidth = AXIS_TICK_WIDTH,
|
|
13
|
+
domain,
|
|
14
|
+
color
|
|
15
|
+
}) {
|
|
16
|
+
if (side === "right") {
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
id: DEFAULT_Y_AXIS,
|
|
20
|
+
orientation: "right",
|
|
21
|
+
width: facingBand(tickWidth),
|
|
22
|
+
title: "",
|
|
23
|
+
format,
|
|
24
|
+
domain,
|
|
25
|
+
color
|
|
26
|
+
}
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
return [{ id: DEFAULT_Y_AXIS, width: tickWidth, title, format, domain, color }];
|
|
30
|
+
}
|
|
31
|
+
function facingHeadingPad(side, tickWidth = AXIS_TICK_WIDTH) {
|
|
32
|
+
return side === "left" ? { paddingLeft: facingBand(tickWidth) } : { paddingRight: facingBand(tickWidth) };
|
|
33
|
+
}
|
|
34
|
+
export {
|
|
35
|
+
FACING_SIDES,
|
|
36
|
+
facingAxes,
|
|
37
|
+
facingBand,
|
|
38
|
+
facingHeadingPad
|
|
39
|
+
};
|
|
40
|
+
//# sourceMappingURL=facing-pair.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/facing-pair.ts"],"sourcesContent":["// Two series charts side by side that are MIRROR IMAGES of each other — the two sides\n// of a symmetric system, read against each other — and the arithmetic that keeps their\n// plots the same width. Lifted out of lenkbank, where four hand-assembled copies of\n// these rules had already drifted apart.\n//\n// The rules:\n// * The ticks stand on the OUTSIDE: the left chart's on its left, the right chart's on\n// its right. The two plots meet in the middle with nothing between them, and neither\n// column of numbers stands between a picture and the one it is compared with.\n// * The quantity is named ONCE, on the left chart; the right title would be the same\n// words twice.\n// * The untitled right axis still RESERVES the title's strip. Reserve only what its\n// ticks need and its plot comes out `AXIS_TITLE_STRIP` px wider than its neighbour's\n// — inside one shared x window, the same abscissa then lands on different pixels.\n// * A heading sits over the PLOT, not over the column, padded by the axis band on the\n// side that column's axis stands on.\n//\n// Only the geometry is here. The grid the pair stands in — column widths, where the\n// legend goes, the chart height — is a screen's layout, and stays with the screen.\nimport type { CSSProperties } from \"react\";\nimport { DEFAULT_Y_AXIS } from \"./chart-zoom\";\nimport { AXIS_TICK_WIDTH, axisBandWidth, type SeriesChartAxis } from \"./series-chart\";\n\n/** Which chart of a facing pair. Physical, like the axes: charts do not mirror in RTL. */\nexport type FacingSide = \"left\" | \"right\";\n\n/** The two sides, in the order the columns stand. */\nexport const FACING_SIDES: readonly FacingSide[] = [\"left\", \"right\"];\n\n/** The whole band a facing y axis occupies: its ticks, and the strip the rotated title\n * is drawn in — whether or not this chart is the one that spends it. */\nexport function facingBand(tickWidth: number = AXIS_TICK_WIDTH): number {\n return axisBandWidth(tickWidth, true);\n}\n\n/**\n * The y axis of one chart of a facing pair.\n *\n * The left one carries the row's quantity and the ticks' own width; the right one has\n * no title, stands on the right and reserves the whole band, so the two plots come out\n * the same width. Both may be pinned to one `domain` — whether they are is a statement\n * about the measurement, so it is the caller's.\n */\nexport function facingAxes({\n side,\n title,\n format,\n tickWidth = AXIS_TICK_WIDTH,\n domain,\n color,\n}: {\n side: FacingSide;\n /** The row's quantity, with its unit. Drawn on the left chart only. */\n title: string;\n format?: (value: number) => string;\n tickWidth?: number;\n domain?: [number, number];\n color?: string;\n}): SeriesChartAxis[] {\n if (side === \"right\") {\n return [\n {\n id: DEFAULT_Y_AXIS,\n orientation: \"right\",\n width: facingBand(tickWidth),\n title: \"\",\n format,\n domain,\n color,\n },\n ];\n }\n return [{ id: DEFAULT_Y_AXIS, width: tickWidth, title, format, domain, color }];\n}\n\n/** What a column heading is padded by to sit over the PLOT rather than the column: the\n * axis band, on the side that column's axis stands on. Physical padding on purpose —\n * the chart under it does not flip in RTL, so neither may the heading's offset. */\nexport function facingHeadingPad(\n side: FacingSide,\n tickWidth: number = AXIS_TICK_WIDTH,\n): Pick<CSSProperties, \"paddingLeft\" | \"paddingRight\"> {\n return side === \"left\"\n ? { paddingLeft: facingBand(tickWidth) }\n : { paddingRight: facingBand(tickWidth) };\n}\n"],"mappings":";AAoBA,SAAS,sBAAsB;AAC/B,SAAS,iBAAiB,qBAA2C;AAM9D,MAAM,eAAsC,CAAC,QAAQ,OAAO;AAI5D,SAAS,WAAW,YAAoB,iBAAyB;AACtE,SAAO,cAAc,WAAW,IAAI;AACtC;AAUO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ;AAAA,EACA;AACF,GAQsB;AACpB,MAAI,SAAS,SAAS;AACpB,WAAO;AAAA,MACL;AAAA,QACE,IAAI;AAAA,QACJ,aAAa;AAAA,QACb,OAAO,WAAW,SAAS;AAAA,QAC3B,OAAO;AAAA,QACP;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,SAAO,CAAC,EAAE,IAAI,gBAAgB,OAAO,WAAW,OAAO,QAAQ,QAAQ,MAAM,CAAC;AAChF;AAKO,SAAS,iBACd,MACA,YAAoB,iBACiC;AACrD,SAAO,SAAS,SACZ,EAAE,aAAa,WAAW,SAAS,EAAE,IACrC,EAAE,cAAc,WAAW,SAAS,EAAE;AAC5C;","names":[]}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sync state for a field backed by a database row.
|
|
6
|
+
*
|
|
7
|
+
* The four states a value can be in between the keyboard and the server:
|
|
8
|
+
*
|
|
9
|
+
* synced the draft equals what was last persisted
|
|
10
|
+
* edited the draft differs and nothing has been sent yet
|
|
11
|
+
* pending a save is in flight
|
|
12
|
+
* error the last save was rejected; `error` carries why
|
|
13
|
+
*
|
|
14
|
+
* `useFieldSync` is the engine and holds no opinion about markup;
|
|
15
|
+
* `FieldSyncIndicator` is the affordance and holds no state. They are separate
|
|
16
|
+
* because the state is useful without the dot — a form can disable its submit
|
|
17
|
+
* button while any field is `pending`, and a router can block navigation while
|
|
18
|
+
* any field is `edited`.
|
|
19
|
+
*/
|
|
20
|
+
type FieldSyncState = "synced" | "edited" | "pending" | "error";
|
|
21
|
+
interface FieldSyncLabels {
|
|
22
|
+
synced: string;
|
|
23
|
+
edited: string;
|
|
24
|
+
pending: string;
|
|
25
|
+
/** Shown when the failure carries no message of its own. */
|
|
26
|
+
error: string;
|
|
27
|
+
retry: string;
|
|
28
|
+
}
|
|
29
|
+
declare const DEFAULT_FIELD_SYNC_LABELS: FieldSyncLabels;
|
|
30
|
+
declare function resolveFieldSyncLabels(labels?: Partial<FieldSyncLabels>): FieldSyncLabels;
|
|
31
|
+
interface UseFieldSyncOptions<T> {
|
|
32
|
+
/** What the database currently holds. Changing it re-seeds a CLEAN field. */
|
|
33
|
+
value: T;
|
|
34
|
+
/** Persist `next`. Reject — or throw — to put the field in `error`. */
|
|
35
|
+
onSave: (next: T) => Promise<void> | void;
|
|
36
|
+
/**
|
|
37
|
+
* Quiet period before an edit saves itself, in ms. Default `0`: no auto-save at
|
|
38
|
+
* all, so `save()` is the only way to persist — called on blur by `FieldSyncRow`,
|
|
39
|
+
* and on Enter by the caller. Saving after a pause in typing wrote half-finished
|
|
40
|
+
* values to the database whenever somebody stopped to think; pass a positive
|
|
41
|
+
* number only for a field where that is genuinely wanted.
|
|
42
|
+
*/
|
|
43
|
+
debounceMs?: number;
|
|
44
|
+
/** How to tell two values apart. Defaults to `Object.is`. */
|
|
45
|
+
equals?: (a: T, b: T) => boolean;
|
|
46
|
+
/** Called once per rejected save, with whatever `onSave` threw. */
|
|
47
|
+
onError?: (error: Error) => void;
|
|
48
|
+
}
|
|
49
|
+
interface UseFieldSyncReturn<T> {
|
|
50
|
+
/** The draft — bind this to the input, not the `value` option. */
|
|
51
|
+
value: T;
|
|
52
|
+
setValue: (next: T) => void;
|
|
53
|
+
state: FieldSyncState;
|
|
54
|
+
/** Non-null exactly when `state === "error"`. */
|
|
55
|
+
error: Error | null;
|
|
56
|
+
/** True whenever the draft differs from the last persisted value. */
|
|
57
|
+
dirty: boolean;
|
|
58
|
+
/** Persist now, cancelling any pending debounce. A no-op on a clean field. */
|
|
59
|
+
save: () => void;
|
|
60
|
+
/** Send the current draft again after a failure. */
|
|
61
|
+
retry: () => void;
|
|
62
|
+
/** Throw the draft away and go back to the last persisted value. */
|
|
63
|
+
reset: () => void;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Track one database-backed field: a local draft, a debounced save, and the state
|
|
67
|
+
* of the round trip.
|
|
68
|
+
*
|
|
69
|
+
* Four things this handles that a `useState` + `useEffect` in a page does not:
|
|
70
|
+
*
|
|
71
|
+
* 1. **An edit during a save is never lost.** A save in flight is not cancelled
|
|
72
|
+
* and not raced — when it settles, the draft is compared again and sent once
|
|
73
|
+
* more if it moved. Cancelling instead would drop a keystroke that arrived
|
|
74
|
+
* mid-flight; firing immediately would let two writes land out of order.
|
|
75
|
+
* 2. **The server value is only adopted when the field is clean.** An external
|
|
76
|
+
* refresh that overwrote what somebody was typing would be a data-loss bug, so
|
|
77
|
+
* a dirty draft wins and keeps winning until it is saved or reset.
|
|
78
|
+
* 3. **Nothing is set after unmount.** The save resolves into a component that
|
|
79
|
+
* may be gone — a row scrolled out of a virtualised table, a closed dialog.
|
|
80
|
+
* 4. **`onSave` is read through a ref**, so an inline arrow — which every call
|
|
81
|
+
* site will pass — does not restart the debounce on every render.
|
|
82
|
+
*/
|
|
83
|
+
declare function useFieldSync<T>({ value, onSave, debounceMs, equals, onError, }: UseFieldSyncOptions<T>): UseFieldSyncReturn<T>;
|
|
84
|
+
interface FieldSyncIndicatorProps {
|
|
85
|
+
state: FieldSyncState;
|
|
86
|
+
/** The failure. Its message becomes the tooltip when `state === "error"`. */
|
|
87
|
+
error?: Error | null;
|
|
88
|
+
/** Override any of the five English defaults. */
|
|
89
|
+
labels?: Partial<FieldSyncLabels>;
|
|
90
|
+
/** Render the label beside the icon instead of only in the tooltip. */
|
|
91
|
+
showLabel?: boolean;
|
|
92
|
+
/** When given, the error state renders a retry button after the icon. */
|
|
93
|
+
onRetry?: () => void;
|
|
94
|
+
className?: string;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The affordance for {@link useFieldSync}: a coloured icon, and the reason on hover.
|
|
98
|
+
*
|
|
99
|
+
* **Every state has its own icon, and that is not decoration.** The four colours
|
|
100
|
+
* include the green/red pair, which is the one combination this package's palette
|
|
101
|
+
* otherwise refuses (see the note in `tokens.css`) because red-green colour blindness
|
|
102
|
+
* affects roughly one man in twelve. The icon is what carries the meaning for them,
|
|
103
|
+
* and what carries it into a greyscale print or a screenshot. WCAG 1.4.1 is the same
|
|
104
|
+
* requirement stated formally.
|
|
105
|
+
*
|
|
106
|
+
* The state is also announced: the wrapper is a polite live region, so a screen
|
|
107
|
+
* reader hears "Saving…" then "Saved" without the user going looking. `error` is
|
|
108
|
+
* assertive instead — a failed write is worth interrupting for.
|
|
109
|
+
*/
|
|
110
|
+
declare function FieldSyncIndicator({ state, error, labels, showLabel, onRetry, className, }: FieldSyncIndicatorProps): react.JSX.Element;
|
|
111
|
+
/**
|
|
112
|
+
* The field's own frame, coloured by state — applied from the WRAPPER to whatever
|
|
113
|
+
* native control sits inside it, so {@link FieldSyncRow} works with any of the kit's
|
|
114
|
+
* inputs (and a caller's own) without each one growing a `syncState` prop.
|
|
115
|
+
*
|
|
116
|
+
* Border AND a 1px ring, for the reason {@link FIELD_INVALID} gives: a lone 1px
|
|
117
|
+
* border loses a vertical edge to sub-pixel spread at 125% display scaling. The
|
|
118
|
+
* `:focus` twins are what keep the state visible while the reader is typing —
|
|
119
|
+
* the base field class's `focus:` brand colour is `(0,2,0)` and would otherwise win
|
|
120
|
+
* over a descendant selector exactly when the field is in use.
|
|
121
|
+
*
|
|
122
|
+
* `synced` is green, but {@link FieldSyncRow} paints it only for a moment after a
|
|
123
|
+
* save lands — a form of eight permanently green boxes says nothing the absence of
|
|
124
|
+
* colour does not.
|
|
125
|
+
*
|
|
126
|
+
* Every class is a literal so Tailwind's scanner can see it.
|
|
127
|
+
*/
|
|
128
|
+
declare const FIELD_SYNC_FRAME: Record<FieldSyncState, string>;
|
|
129
|
+
/** How long the green "saved" frame and check stay after a save lands, in ms. */
|
|
130
|
+
declare const FIELD_SYNC_SAVED_MS = 1500;
|
|
131
|
+
interface FieldSyncRowProps<T> extends Omit<FieldSyncIndicatorProps, "state" | "error" | "showLabel"> {
|
|
132
|
+
sync: UseFieldSyncReturn<T>;
|
|
133
|
+
children: ReactNode;
|
|
134
|
+
/** Call `sync.save()` when focus leaves the field. Default true — together with the
|
|
135
|
+
* hook's default `debounceMs: 0` this is "save on blur": nothing is written while
|
|
136
|
+
* the reader is still typing, however long they pause. */
|
|
137
|
+
saveOnBlur?: boolean;
|
|
138
|
+
/** How long the saved confirmation shows. Default {@link FIELD_SYNC_SAVED_MS}. */
|
|
139
|
+
savedMs?: number;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* A field wearing its sync state — as its FRAME COLOUR and an ICON AT THE END OF THE
|
|
143
|
+
* FIELD, so the field never changes size (a helper line appearing under it moved
|
|
144
|
+
* everything below it on every save):
|
|
145
|
+
*
|
|
146
|
+
* edited amber frame, pencil — changed, not yet saved
|
|
147
|
+
* pending blue frame, spinning circle — for as long as the save is in flight
|
|
148
|
+
* synced green frame, check — for {@link FIELD_SYNC_SAVED_MS} after a
|
|
149
|
+
* save lands, then an ordinary field again
|
|
150
|
+
* error red frame, alert mark — hover or focus the mark for the reason;
|
|
151
|
+
* click it to retry
|
|
152
|
+
*
|
|
153
|
+
* It saves when focus LEAVES the field (`saveOnBlur`), not after a pause in typing: a
|
|
154
|
+
* database write in the middle of a word is a write nobody asked for. Enter in a
|
|
155
|
+
* single-line field is the caller's to wire to `sync.save`.
|
|
156
|
+
*
|
|
157
|
+
* Colour is never the only signal: every state has its own icon, and the state text is
|
|
158
|
+
* always in the DOM as a live region — polite for the ordinary states, assertive for
|
|
159
|
+
* a failure. The input gets end padding for the icon whether or not one is showing,
|
|
160
|
+
* so the text never reflows when the state changes.
|
|
161
|
+
*/
|
|
162
|
+
declare function FieldSyncRow<T>({ sync, children, className, labels, onRetry, saveOnBlur, savedMs, }: FieldSyncRowProps<T>): react.JSX.Element;
|
|
163
|
+
|
|
164
|
+
export { DEFAULT_FIELD_SYNC_LABELS, FIELD_SYNC_FRAME, FIELD_SYNC_SAVED_MS, FieldSyncIndicator, type FieldSyncIndicatorProps, type FieldSyncLabels, FieldSyncRow, type FieldSyncRowProps, type FieldSyncState, type UseFieldSyncOptions, type UseFieldSyncReturn, resolveFieldSyncLabels, useFieldSync };
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { AlertCircle, Check, Loader2, Pencil, PencilLine } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { Tooltip } from "./tooltip.js";
|
|
7
|
+
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
8
|
+
const DEFAULT_FIELD_SYNC_LABELS = {
|
|
9
|
+
synced: "Saved",
|
|
10
|
+
edited: "Unsaved changes",
|
|
11
|
+
pending: "Saving\u2026",
|
|
12
|
+
error: "Could not save",
|
|
13
|
+
retry: "Retry"
|
|
14
|
+
};
|
|
15
|
+
function resolveFieldSyncLabels(labels) {
|
|
16
|
+
return labels ? { ...DEFAULT_FIELD_SYNC_LABELS, ...labels } : DEFAULT_FIELD_SYNC_LABELS;
|
|
17
|
+
}
|
|
18
|
+
function toError(cause) {
|
|
19
|
+
if (cause instanceof Error) return cause;
|
|
20
|
+
return new Error(typeof cause === "string" ? cause : "", { cause });
|
|
21
|
+
}
|
|
22
|
+
function useFieldSync({
|
|
23
|
+
value,
|
|
24
|
+
onSave,
|
|
25
|
+
debounceMs = 0,
|
|
26
|
+
equals = Object.is,
|
|
27
|
+
onError
|
|
28
|
+
}) {
|
|
29
|
+
const [draft, setDraft] = useState(value);
|
|
30
|
+
const [state, setState] = useState("synced");
|
|
31
|
+
const [error, setError] = useState(null);
|
|
32
|
+
const savedRef = useRef(value);
|
|
33
|
+
const draftRef = useRef(value);
|
|
34
|
+
const inFlightRef = useRef(false);
|
|
35
|
+
const timerRef = useRef(null);
|
|
36
|
+
const mountedRef = useRef(true);
|
|
37
|
+
const onSaveRef = useRef(onSave);
|
|
38
|
+
const onErrorRef = useRef(onError);
|
|
39
|
+
const equalsRef = useRef(equals);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
onSaveRef.current = onSave;
|
|
42
|
+
onErrorRef.current = onError;
|
|
43
|
+
equalsRef.current = equals;
|
|
44
|
+
});
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
mountedRef.current = true;
|
|
47
|
+
return () => {
|
|
48
|
+
mountedRef.current = false;
|
|
49
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
const flush = useCallback(async () => {
|
|
53
|
+
if (inFlightRef.current) return;
|
|
54
|
+
const next = draftRef.current;
|
|
55
|
+
if (equalsRef.current(next, savedRef.current)) return;
|
|
56
|
+
inFlightRef.current = true;
|
|
57
|
+
setState("pending");
|
|
58
|
+
setError(null);
|
|
59
|
+
try {
|
|
60
|
+
await onSaveRef.current(next);
|
|
61
|
+
savedRef.current = next;
|
|
62
|
+
inFlightRef.current = false;
|
|
63
|
+
if (!mountedRef.current) return;
|
|
64
|
+
if (equalsRef.current(draftRef.current, next)) {
|
|
65
|
+
setState("synced");
|
|
66
|
+
} else {
|
|
67
|
+
setState("edited");
|
|
68
|
+
void flush();
|
|
69
|
+
}
|
|
70
|
+
} catch (cause) {
|
|
71
|
+
inFlightRef.current = false;
|
|
72
|
+
const err = toError(cause);
|
|
73
|
+
onErrorRef.current?.(err);
|
|
74
|
+
if (!mountedRef.current) return;
|
|
75
|
+
setState("error");
|
|
76
|
+
setError(err);
|
|
77
|
+
}
|
|
78
|
+
}, []);
|
|
79
|
+
const setValue = useCallback(
|
|
80
|
+
(next) => {
|
|
81
|
+
draftRef.current = next;
|
|
82
|
+
setDraft(next);
|
|
83
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
84
|
+
if (equalsRef.current(next, savedRef.current)) {
|
|
85
|
+
setState(inFlightRef.current ? "pending" : "synced");
|
|
86
|
+
setError(null);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
setState("edited");
|
|
90
|
+
setError(null);
|
|
91
|
+
if (debounceMs > 0) timerRef.current = setTimeout(() => void flush(), debounceMs);
|
|
92
|
+
},
|
|
93
|
+
[debounceMs, flush]
|
|
94
|
+
);
|
|
95
|
+
const save = useCallback(() => {
|
|
96
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
97
|
+
void flush();
|
|
98
|
+
}, [flush]);
|
|
99
|
+
const retry = save;
|
|
100
|
+
const reset = useCallback(() => {
|
|
101
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
102
|
+
draftRef.current = savedRef.current;
|
|
103
|
+
setDraft(savedRef.current);
|
|
104
|
+
setState(inFlightRef.current ? "pending" : "synced");
|
|
105
|
+
setError(null);
|
|
106
|
+
}, []);
|
|
107
|
+
const dirty = state !== "synced";
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (inFlightRef.current) return;
|
|
110
|
+
if (!equalsRef.current(draftRef.current, savedRef.current)) return;
|
|
111
|
+
if (equalsRef.current(value, savedRef.current)) return;
|
|
112
|
+
savedRef.current = value;
|
|
113
|
+
draftRef.current = value;
|
|
114
|
+
setDraft(value);
|
|
115
|
+
setState("synced");
|
|
116
|
+
setError(null);
|
|
117
|
+
}, [value]);
|
|
118
|
+
return { value: draft, setValue, state, error, dirty, save, retry, reset };
|
|
119
|
+
}
|
|
120
|
+
const STATE_STYLE = {
|
|
121
|
+
synced: { color: "var(--status-synced)", Icon: Check },
|
|
122
|
+
edited: { color: "var(--status-edited)", Icon: Pencil },
|
|
123
|
+
pending: { color: "var(--status-pending)", Icon: Loader2 },
|
|
124
|
+
error: { color: "var(--status-error)", Icon: AlertCircle }
|
|
125
|
+
};
|
|
126
|
+
function FieldSyncIndicator({
|
|
127
|
+
state,
|
|
128
|
+
error,
|
|
129
|
+
labels,
|
|
130
|
+
showLabel = false,
|
|
131
|
+
onRetry,
|
|
132
|
+
className
|
|
133
|
+
}) {
|
|
134
|
+
const l = useKitLabels("fieldSync", DEFAULT_FIELD_SYNC_LABELS, labels);
|
|
135
|
+
const { color, Icon } = STATE_STYLE[state];
|
|
136
|
+
const text = state === "error" ? error?.message?.trim() ? error.message : l.error : l[state];
|
|
137
|
+
return /* @__PURE__ */ jsxs(
|
|
138
|
+
"span",
|
|
139
|
+
{
|
|
140
|
+
className: cn("inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]", className),
|
|
141
|
+
role: state === "error" ? "alert" : "status",
|
|
142
|
+
"aria-live": state === "error" ? "assertive" : "polite",
|
|
143
|
+
children: [
|
|
144
|
+
/* @__PURE__ */ jsx(Tooltip, { label: showLabel ? void 0 : text, redact: state === "error", children: /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1.5", children: [
|
|
145
|
+
/* @__PURE__ */ jsx(
|
|
146
|
+
Icon,
|
|
147
|
+
{
|
|
148
|
+
"aria-hidden": true,
|
|
149
|
+
style: { color },
|
|
150
|
+
className: cn("size-3.5 shrink-0", state === "pending" && "animate-spin")
|
|
151
|
+
}
|
|
152
|
+
),
|
|
153
|
+
/* @__PURE__ */ jsx("span", { className: showLabel ? void 0 : "sr-only", children: text })
|
|
154
|
+
] }) }),
|
|
155
|
+
state === "error" && onRetry && /* @__PURE__ */ jsx(
|
|
156
|
+
"button",
|
|
157
|
+
{
|
|
158
|
+
type: "button",
|
|
159
|
+
onClick: onRetry,
|
|
160
|
+
className: "rounded underline underline-offset-2 hover:no-underline focus-visible:outline-2 focus-visible:outline-offset-2",
|
|
161
|
+
style: { color },
|
|
162
|
+
children: l.retry
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const FIELD_SYNC_FRAME = {
|
|
170
|
+
synced: "[&_:is(input,select,textarea)]:border-[var(--status-synced)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-synced)] [&_:is(input,select,textarea):focus]:border-[var(--status-synced)] [&_:is(input,select,textarea):focus]:ring-[var(--status-synced)]",
|
|
171
|
+
edited: "[&_:is(input,select,textarea)]:border-[var(--status-edited)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-edited)] [&_:is(input,select,textarea):focus]:border-[var(--status-edited)] [&_:is(input,select,textarea):focus]:ring-[var(--status-edited)]",
|
|
172
|
+
pending: "[&_:is(input,select,textarea)]:border-[var(--status-pending)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-pending)] [&_:is(input,select,textarea):focus]:border-[var(--status-pending)] [&_:is(input,select,textarea):focus]:ring-[var(--status-pending)]",
|
|
173
|
+
error: "[&_:is(input,select,textarea)]:border-[var(--status-error)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-error)] [&_:is(input,select,textarea):focus]:border-[var(--status-error)] [&_:is(input,select,textarea):focus]:ring-[var(--status-error)]"
|
|
174
|
+
};
|
|
175
|
+
const FIELD_SYNC_SAVED_MS = 1500;
|
|
176
|
+
function FieldSyncRow({
|
|
177
|
+
sync,
|
|
178
|
+
children,
|
|
179
|
+
className,
|
|
180
|
+
labels,
|
|
181
|
+
onRetry,
|
|
182
|
+
saveOnBlur = true,
|
|
183
|
+
savedMs = FIELD_SYNC_SAVED_MS
|
|
184
|
+
}) {
|
|
185
|
+
const l = useKitLabels("fieldSync", DEFAULT_FIELD_SYNC_LABELS, labels);
|
|
186
|
+
const { state, error } = sync;
|
|
187
|
+
const isError = state === "error";
|
|
188
|
+
const text = isError ? error?.message?.trim() ? error.message : l.error : l[state];
|
|
189
|
+
const retry = onRetry ?? sync.retry;
|
|
190
|
+
const previous = useRef(state);
|
|
191
|
+
const [justSaved, setJustSaved] = useState(false);
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
const was = previous.current;
|
|
194
|
+
previous.current = state;
|
|
195
|
+
if (state === "synced" && was === "pending") {
|
|
196
|
+
setJustSaved(true);
|
|
197
|
+
const t = setTimeout(() => setJustSaved(false), savedMs);
|
|
198
|
+
return () => clearTimeout(t);
|
|
199
|
+
}
|
|
200
|
+
if (state !== "synced") setJustSaved(false);
|
|
201
|
+
}, [state, savedMs]);
|
|
202
|
+
const shown = state === "synced" ? justSaved ? "synced" : null : state;
|
|
203
|
+
return /* @__PURE__ */ jsxs(
|
|
204
|
+
"div",
|
|
205
|
+
{
|
|
206
|
+
"data-field-sync": state,
|
|
207
|
+
className: cn(
|
|
208
|
+
// `relative` for the icon and for the sr-only live region's containing block.
|
|
209
|
+
"relative [&_:is(input,select,textarea)]:pe-9",
|
|
210
|
+
shown && FIELD_SYNC_FRAME[shown],
|
|
211
|
+
className
|
|
212
|
+
),
|
|
213
|
+
onBlur: saveOnBlur ? (e) => {
|
|
214
|
+
if (!e.currentTarget.contains(e.relatedTarget)) sync.save();
|
|
215
|
+
} : void 0,
|
|
216
|
+
children: [
|
|
217
|
+
children,
|
|
218
|
+
/* @__PURE__ */ jsxs("span", { className: "pointer-events-none absolute inset-y-0 end-0 flex items-center pe-3", children: [
|
|
219
|
+
state === "edited" && /* @__PURE__ */ jsx(PencilLine, { "aria-hidden": true, className: "size-4 text-[var(--status-edited)]" }),
|
|
220
|
+
state === "pending" && /* @__PURE__ */ jsx(Loader2, { "aria-hidden": true, className: "size-4 animate-spin text-[var(--status-pending)] motion-reduce:animate-none" }),
|
|
221
|
+
state === "synced" && /* @__PURE__ */ jsx(
|
|
222
|
+
Check,
|
|
223
|
+
{
|
|
224
|
+
"aria-hidden": true,
|
|
225
|
+
className: cn(
|
|
226
|
+
"size-4 text-[var(--status-synced)] transition-opacity duration-500 motion-reduce:transition-none",
|
|
227
|
+
justSaved ? "opacity-100" : "opacity-0"
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
),
|
|
231
|
+
isError && /* @__PURE__ */ jsx(Tooltip, { label: text, portal: true, redact: true, children: /* @__PURE__ */ jsx(
|
|
232
|
+
"button",
|
|
233
|
+
{
|
|
234
|
+
type: "button",
|
|
235
|
+
onClick: retry,
|
|
236
|
+
"aria-label": l.retry,
|
|
237
|
+
className: "pointer-events-auto flex rounded-full text-[var(--status-error)] focus-visible:outline-2 focus-visible:outline-offset-2",
|
|
238
|
+
children: /* @__PURE__ */ jsx(AlertCircle, { "aria-hidden": true, className: "size-4" })
|
|
239
|
+
}
|
|
240
|
+
) })
|
|
241
|
+
] }),
|
|
242
|
+
/* @__PURE__ */ jsx(
|
|
243
|
+
"span",
|
|
244
|
+
{
|
|
245
|
+
role: isError ? "alert" : "status",
|
|
246
|
+
"aria-live": isError ? "assertive" : "polite",
|
|
247
|
+
className: "sr-only",
|
|
248
|
+
children: shown === null ? "" : text
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
]
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
export {
|
|
256
|
+
DEFAULT_FIELD_SYNC_LABELS,
|
|
257
|
+
FIELD_SYNC_FRAME,
|
|
258
|
+
FIELD_SYNC_SAVED_MS,
|
|
259
|
+
FieldSyncIndicator,
|
|
260
|
+
FieldSyncRow,
|
|
261
|
+
resolveFieldSyncLabels,
|
|
262
|
+
useFieldSync
|
|
263
|
+
};
|
|
264
|
+
//# sourceMappingURL=field-sync.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/field-sync.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { AlertCircle, Check, Loader2, Pencil, PencilLine } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Tooltip } from \"./tooltip\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\n\n/**\n * Sync state for a field backed by a database row.\n *\n * The four states a value can be in between the keyboard and the server:\n *\n * synced the draft equals what was last persisted\n * edited the draft differs and nothing has been sent yet\n * pending a save is in flight\n * error the last save was rejected; `error` carries why\n *\n * `useFieldSync` is the engine and holds no opinion about markup;\n * `FieldSyncIndicator` is the affordance and holds no state. They are separate\n * because the state is useful without the dot — a form can disable its submit\n * button while any field is `pending`, and a router can block navigation while\n * any field is `edited`.\n */\nexport type FieldSyncState = \"synced\" | \"edited\" | \"pending\" | \"error\";\n\nexport interface FieldSyncLabels {\n synced: string;\n edited: string;\n pending: string;\n /** Shown when the failure carries no message of its own. */\n error: string;\n retry: string;\n}\n\nexport const DEFAULT_FIELD_SYNC_LABELS: FieldSyncLabels = {\n synced: \"Saved\",\n edited: \"Unsaved changes\",\n pending: \"Saving…\",\n error: \"Could not save\",\n retry: \"Retry\",\n};\n\nexport function resolveFieldSyncLabels(labels?: Partial<FieldSyncLabels>): FieldSyncLabels {\n return labels ? { ...DEFAULT_FIELD_SYNC_LABELS, ...labels } : DEFAULT_FIELD_SYNC_LABELS;\n}\n\nexport interface UseFieldSyncOptions<T> {\n /** What the database currently holds. Changing it re-seeds a CLEAN field. */\n value: T;\n /** Persist `next`. Reject — or throw — to put the field in `error`. */\n onSave: (next: T) => Promise<void> | void;\n /**\n * Quiet period before an edit saves itself, in ms. Default `0`: no auto-save at\n * all, so `save()` is the only way to persist — called on blur by `FieldSyncRow`,\n * and on Enter by the caller. Saving after a pause in typing wrote half-finished\n * values to the database whenever somebody stopped to think; pass a positive\n * number only for a field where that is genuinely wanted.\n */\n debounceMs?: number;\n /** How to tell two values apart. Defaults to `Object.is`. */\n equals?: (a: T, b: T) => boolean;\n /** Called once per rejected save, with whatever `onSave` threw. */\n onError?: (error: Error) => void;\n}\n\nexport interface UseFieldSyncReturn<T> {\n /** The draft — bind this to the input, not the `value` option. */\n value: T;\n setValue: (next: T) => void;\n state: FieldSyncState;\n /** Non-null exactly when `state === \"error\"`. */\n error: Error | null;\n /** True whenever the draft differs from the last persisted value. */\n dirty: boolean;\n /** Persist now, cancelling any pending debounce. A no-op on a clean field. */\n save: () => void;\n /** Send the current draft again after a failure. */\n retry: () => void;\n /** Throw the draft away and go back to the last persisted value. */\n reset: () => void;\n}\n\nfunction toError(cause: unknown): Error {\n if (cause instanceof Error) return cause;\n // An EMPTY message for a rejection that carried none, not an English one: the\n // indicator shows `error.message` when there is one and the translated\n // `fieldSync.error` otherwise, so a fallback message here was an English sentence\n // that outranked the app's own translation. The original value stays on `cause`\n // for whoever logs it from `onError`.\n return new Error(typeof cause === \"string\" ? cause : \"\", { cause });\n}\n\n/**\n * Track one database-backed field: a local draft, a debounced save, and the state\n * of the round trip.\n *\n * Four things this handles that a `useState` + `useEffect` in a page does not:\n *\n * 1. **An edit during a save is never lost.** A save in flight is not cancelled\n * and not raced — when it settles, the draft is compared again and sent once\n * more if it moved. Cancelling instead would drop a keystroke that arrived\n * mid-flight; firing immediately would let two writes land out of order.\n * 2. **The server value is only adopted when the field is clean.** An external\n * refresh that overwrote what somebody was typing would be a data-loss bug, so\n * a dirty draft wins and keeps winning until it is saved or reset.\n * 3. **Nothing is set after unmount.** The save resolves into a component that\n * may be gone — a row scrolled out of a virtualised table, a closed dialog.\n * 4. **`onSave` is read through a ref**, so an inline arrow — which every call\n * site will pass — does not restart the debounce on every render.\n */\nexport function useFieldSync<T>({\n value,\n onSave,\n debounceMs = 0,\n equals = Object.is,\n onError,\n}: UseFieldSyncOptions<T>): UseFieldSyncReturn<T> {\n const [draft, setDraft] = useState<T>(value);\n const [state, setState] = useState<FieldSyncState>(\"synced\");\n const [error, setError] = useState<Error | null>(null);\n\n // The last value known to be on the server. Not state: it changes together with\n // `state` inside the save, and a second render for it would be a wasted one.\n const savedRef = useRef<T>(value);\n const draftRef = useRef<T>(value);\n const inFlightRef = useRef(false);\n const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const mountedRef = useRef(true);\n\n // Latest-ref for the callbacks and comparators, so a caller passing inline\n // functions (all of them) does not invalidate the timer on every render.\n const onSaveRef = useRef(onSave);\n const onErrorRef = useRef(onError);\n const equalsRef = useRef(equals);\n useEffect(() => {\n onSaveRef.current = onSave;\n onErrorRef.current = onError;\n equalsRef.current = equals;\n });\n\n useEffect(() => {\n mountedRef.current = true;\n return () => {\n mountedRef.current = false;\n if (timerRef.current) clearTimeout(timerRef.current);\n };\n }, []);\n\n const flush = useCallback(async () => {\n if (inFlightRef.current) return; // the settle path below picks the new draft up\n const next = draftRef.current;\n if (equalsRef.current(next, savedRef.current)) return;\n\n inFlightRef.current = true;\n setState(\"pending\");\n setError(null);\n try {\n await onSaveRef.current(next);\n savedRef.current = next;\n inFlightRef.current = false;\n if (!mountedRef.current) return;\n // The draft may have moved while this was in the air. Compare against what is\n // on screen NOW, not against what was sent.\n if (equalsRef.current(draftRef.current, next)) {\n setState(\"synced\");\n } else {\n setState(\"edited\");\n void flush();\n }\n } catch (cause) {\n inFlightRef.current = false;\n const err = toError(cause);\n onErrorRef.current?.(err);\n if (!mountedRef.current) return;\n setState(\"error\");\n setError(err);\n }\n }, []);\n\n const setValue = useCallback(\n (next: T) => {\n draftRef.current = next;\n setDraft(next);\n if (timerRef.current) clearTimeout(timerRef.current);\n\n if (equalsRef.current(next, savedRef.current)) {\n // Typed back to what the server already has. Nothing to send, and an error\n // from the previous attempt no longer describes anything.\n setState(inFlightRef.current ? \"pending\" : \"synced\");\n setError(null);\n return;\n }\n\n setState(\"edited\");\n setError(null);\n if (debounceMs > 0) timerRef.current = setTimeout(() => void flush(), debounceMs);\n },\n [debounceMs, flush],\n );\n\n const save = useCallback(() => {\n if (timerRef.current) clearTimeout(timerRef.current);\n void flush();\n }, [flush]);\n\n const retry = save;\n\n const reset = useCallback(() => {\n if (timerRef.current) clearTimeout(timerRef.current);\n draftRef.current = savedRef.current;\n setDraft(savedRef.current);\n setState(inFlightRef.current ? \"pending\" : \"synced\");\n setError(null);\n }, []);\n\n // Anything that is not `synced` has something not yet on the server: `edited` and\n // `error` obviously, and `pending` too — the write is in the air, not landed.\n // Derived from state rather than by comparing against `savedRef` during render,\n // because a ref read here would not re-render when the comparison's answer changes.\n const dirty = state !== \"synced\";\n\n // Adopt a new server value ONLY when there is nothing local to lose.\n useEffect(() => {\n if (inFlightRef.current) return;\n if (!equalsRef.current(draftRef.current, savedRef.current)) return; // dirty: keep it\n if (equalsRef.current(value, savedRef.current)) return; // unchanged\n savedRef.current = value;\n draftRef.current = value;\n setDraft(value);\n setState(\"synced\");\n setError(null);\n }, [value]);\n\n return { value: draft, setValue, state, error, dirty, save, retry, reset };\n}\n\nconst STATE_STYLE: Record<FieldSyncState, { color: string; Icon: typeof Check }> = {\n synced: { color: \"var(--status-synced)\", Icon: Check },\n edited: { color: \"var(--status-edited)\", Icon: Pencil },\n pending: { color: \"var(--status-pending)\", Icon: Loader2 },\n error: { color: \"var(--status-error)\", Icon: AlertCircle },\n};\n\nexport interface FieldSyncIndicatorProps {\n state: FieldSyncState;\n /** The failure. Its message becomes the tooltip when `state === \"error\"`. */\n error?: Error | null;\n /** Override any of the five English defaults. */\n labels?: Partial<FieldSyncLabels>;\n /** Render the label beside the icon instead of only in the tooltip. */\n showLabel?: boolean;\n /** When given, the error state renders a retry button after the icon. */\n onRetry?: () => void;\n className?: string;\n}\n\n/**\n * The affordance for {@link useFieldSync}: a coloured icon, and the reason on hover.\n *\n * **Every state has its own icon, and that is not decoration.** The four colours\n * include the green/red pair, which is the one combination this package's palette\n * otherwise refuses (see the note in `tokens.css`) because red-green colour blindness\n * affects roughly one man in twelve. The icon is what carries the meaning for them,\n * and what carries it into a greyscale print or a screenshot. WCAG 1.4.1 is the same\n * requirement stated formally.\n *\n * The state is also announced: the wrapper is a polite live region, so a screen\n * reader hears \"Saving…\" then \"Saved\" without the user going looking. `error` is\n * assertive instead — a failed write is worth interrupting for.\n */\nexport function FieldSyncIndicator({\n state,\n error,\n labels,\n showLabel = false,\n onRetry,\n className,\n}: FieldSyncIndicatorProps) {\n const l = useKitLabels(\"fieldSync\", DEFAULT_FIELD_SYNC_LABELS, labels);\n const { color, Icon } = STATE_STYLE[state];\n const text =\n state === \"error\" ? (error?.message?.trim() ? error.message : l.error) : l[state];\n\n return (\n <span\n // The ICON carries the status colour; the words are `--text-secondary`, because\n // the amber of `edited` is a graphic colour (3:1), not a text colour (4.5:1).\n className={cn(\"inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]\", className)}\n // Polite for the three ordinary states so save chatter does not interrupt\n // typing; assertive for a failure, which the user has to know about now.\n role={state === \"error\" ? \"alert\" : \"status\"}\n aria-live={state === \"error\" ? \"assertive\" : \"polite\"}\n >\n <Tooltip label={showLabel ? undefined : text} redact={state === \"error\"}>\n <span className=\"inline-flex items-center gap-1.5\">\n <Icon\n aria-hidden\n style={{ color }}\n className={cn(\"size-3.5 shrink-0\", state === \"pending\" && \"animate-spin\")}\n />\n {/* Always in the DOM, so the live region has something to announce even\n when the label is visually a tooltip. */}\n <span className={showLabel ? undefined : \"sr-only\"}>{text}</span>\n </span>\n </Tooltip>\n {state === \"error\" && onRetry && (\n <button\n type=\"button\"\n onClick={onRetry}\n className=\"rounded underline underline-offset-2 hover:no-underline focus-visible:outline-2 focus-visible:outline-offset-2\"\n style={{ color }}\n >\n {l.retry}\n </button>\n )}\n </span>\n );\n}\n\n/**\n * The field's own frame, coloured by state — applied from the WRAPPER to whatever\n * native control sits inside it, so {@link FieldSyncRow} works with any of the kit's\n * inputs (and a caller's own) without each one growing a `syncState` prop.\n *\n * Border AND a 1px ring, for the reason {@link FIELD_INVALID} gives: a lone 1px\n * border loses a vertical edge to sub-pixel spread at 125% display scaling. The\n * `:focus` twins are what keep the state visible while the reader is typing —\n * the base field class's `focus:` brand colour is `(0,2,0)` and would otherwise win\n * over a descendant selector exactly when the field is in use.\n *\n * `synced` is green, but {@link FieldSyncRow} paints it only for a moment after a\n * save lands — a form of eight permanently green boxes says nothing the absence of\n * colour does not.\n *\n * Every class is a literal so Tailwind's scanner can see it.\n */\nexport const FIELD_SYNC_FRAME: Record<FieldSyncState, string> = {\n synced:\n \"[&_:is(input,select,textarea)]:border-[var(--status-synced)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-synced)] [&_:is(input,select,textarea):focus]:border-[var(--status-synced)] [&_:is(input,select,textarea):focus]:ring-[var(--status-synced)]\",\n edited:\n \"[&_:is(input,select,textarea)]:border-[var(--status-edited)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-edited)] [&_:is(input,select,textarea):focus]:border-[var(--status-edited)] [&_:is(input,select,textarea):focus]:ring-[var(--status-edited)]\",\n pending:\n \"[&_:is(input,select,textarea)]:border-[var(--status-pending)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-pending)] [&_:is(input,select,textarea):focus]:border-[var(--status-pending)] [&_:is(input,select,textarea):focus]:ring-[var(--status-pending)]\",\n error:\n \"[&_:is(input,select,textarea)]:border-[var(--status-error)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-error)] [&_:is(input,select,textarea):focus]:border-[var(--status-error)] [&_:is(input,select,textarea):focus]:ring-[var(--status-error)]\",\n};\n\n/** How long the green \"saved\" frame and check stay after a save lands, in ms. */\nexport const FIELD_SYNC_SAVED_MS = 1500;\n\nexport interface FieldSyncRowProps<T> extends Omit<FieldSyncIndicatorProps, \"state\" | \"error\" | \"showLabel\"> {\n sync: UseFieldSyncReturn<T>;\n children: ReactNode;\n /** Call `sync.save()` when focus leaves the field. Default true — together with the\n * hook's default `debounceMs: 0` this is \"save on blur\": nothing is written while\n * the reader is still typing, however long they pause. */\n saveOnBlur?: boolean;\n /** How long the saved confirmation shows. Default {@link FIELD_SYNC_SAVED_MS}. */\n savedMs?: number;\n}\n\n/**\n * A field wearing its sync state — as its FRAME COLOUR and an ICON AT THE END OF THE\n * FIELD, so the field never changes size (a helper line appearing under it moved\n * everything below it on every save):\n *\n * edited amber frame, pencil — changed, not yet saved\n * pending blue frame, spinning circle — for as long as the save is in flight\n * synced green frame, check — for {@link FIELD_SYNC_SAVED_MS} after a\n * save lands, then an ordinary field again\n * error red frame, alert mark — hover or focus the mark for the reason;\n * click it to retry\n *\n * It saves when focus LEAVES the field (`saveOnBlur`), not after a pause in typing: a\n * database write in the middle of a word is a write nobody asked for. Enter in a\n * single-line field is the caller's to wire to `sync.save`.\n *\n * Colour is never the only signal: every state has its own icon, and the state text is\n * always in the DOM as a live region — polite for the ordinary states, assertive for\n * a failure. The input gets end padding for the icon whether or not one is showing,\n * so the text never reflows when the state changes.\n */\nexport function FieldSyncRow<T>({\n sync,\n children,\n className,\n labels,\n onRetry,\n saveOnBlur = true,\n savedMs = FIELD_SYNC_SAVED_MS,\n}: FieldSyncRowProps<T>) {\n const l = useKitLabels(\"fieldSync\", DEFAULT_FIELD_SYNC_LABELS, labels);\n const { state, error } = sync;\n const isError = state === \"error\";\n const text = isError ? (error?.message?.trim() ? error.message : l.error) : l[state];\n const retry = onRetry ?? sync.retry;\n\n // The saved confirmation: on only for the moment after a save lands. Keyed on the\n // TRANSITION pending → synced, so a field that starts synced shows nothing.\n const previous = useRef(state);\n const [justSaved, setJustSaved] = useState(false);\n useEffect(() => {\n const was = previous.current;\n previous.current = state;\n if (state === \"synced\" && was === \"pending\") {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- reacts to the save landing\n setJustSaved(true);\n const t = setTimeout(() => setJustSaved(false), savedMs);\n return () => clearTimeout(t);\n }\n if (state !== \"synced\") setJustSaved(false);\n }, [state, savedMs]);\n const shown: FieldSyncState | null = state === \"synced\" ? (justSaved ? \"synced\" : null) : state;\n\n return (\n <div\n data-field-sync={state}\n className={cn(\n // `relative` for the icon and for the sr-only live region's containing block.\n \"relative [&_:is(input,select,textarea)]:pe-9\",\n shown && FIELD_SYNC_FRAME[shown],\n className,\n )}\n onBlur={\n saveOnBlur\n ? (e) => {\n // Focus moving between two controls INSIDE the row is not leaving it.\n if (!e.currentTarget.contains(e.relatedTarget as Node | null)) sync.save();\n }\n : undefined\n }\n >\n {children}\n <span className=\"pointer-events-none absolute inset-y-0 end-0 flex items-center pe-3\">\n {state === \"edited\" && (\n <PencilLine aria-hidden className=\"size-4 text-[var(--status-edited)]\" />\n )}\n {state === \"pending\" && (\n <Loader2 aria-hidden className=\"size-4 animate-spin text-[var(--status-pending)] motion-reduce:animate-none\" />\n )}\n {state === \"synced\" && (\n <Check\n aria-hidden\n className={cn(\n \"size-4 text-[var(--status-synced)] transition-opacity duration-500 motion-reduce:transition-none\",\n justSaved ? \"opacity-100\" : \"opacity-0\",\n )}\n />\n )}\n {isError && (\n <Tooltip label={text} portal redact>\n <button\n type=\"button\"\n onClick={retry}\n aria-label={l.retry}\n className=\"pointer-events-auto flex rounded-full text-[var(--status-error)] focus-visible:outline-2 focus-visible:outline-offset-2\"\n >\n <AlertCircle aria-hidden className=\"size-4\" />\n </button>\n </Tooltip>\n )}\n </span>\n <span\n role={isError ? \"alert\" : \"status\"}\n aria-live={isError ? \"assertive\" : \"polite\"}\n className=\"sr-only\"\n >\n {shown === null ? \"\" : text}\n </span>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|