@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,10 +1,15 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef } from 'react';
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
|
|
4
|
+
/**
|
|
5
|
+
* `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.
|
|
6
|
+
* The DOM event of that name belongs to form validation and takes a `FormEvent`; this
|
|
7
|
+
* one takes the rejected `File`, and the kit's meaning is the one every caller already
|
|
8
|
+
* writes. Everything else a `<div>` takes reaches the root — which carries
|
|
9
|
+
* `role="button"`, so that is also where an `aria-label` or an `aria-describedby`
|
|
10
|
+
* belongs.
|
|
11
|
+
*/
|
|
12
|
+
interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">, "onInvalid"> {
|
|
8
13
|
file: File | null;
|
|
9
14
|
onFileSelected: (file: File) => void;
|
|
10
15
|
accept: string;
|
|
@@ -27,10 +32,19 @@ declare function FileDropzone({ file, onFileSelected, accept, isValid, invalidMe
|
|
|
27
32
|
* writing, two modules over.
|
|
28
33
|
*/
|
|
29
34
|
onInvalid?: (file: File) => void;
|
|
35
|
+
/** The dropzone's accessible name, and the instruction shown on it. A caller's own
|
|
36
|
+
* `aria-label` wins over it — see the root element below. */
|
|
30
37
|
dropLabel: string;
|
|
31
38
|
browseLabel: string;
|
|
32
39
|
emptyLabel: string;
|
|
33
40
|
hint: string;
|
|
34
|
-
|
|
41
|
+
/** Extra classes for the dropzone's root. */
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml).
|
|
45
|
+
* Validation failures surface `invalidMessage` (a `sonner` toast by default, see
|
|
46
|
+
* `onInvalid`); the chosen file is echoed with its size, otherwise `emptyLabel` +
|
|
47
|
+
* `hint` describe what to drop. */
|
|
48
|
+
declare function FileDropzone({ file, onFileSelected, accept, isValid, invalidMessage, onInvalid, dropLabel, browseLabel, emptyLabel, hint, className, "aria-label": ariaLabel, ...rest }: FileDropzoneProps): react.JSX.Element;
|
|
35
49
|
|
|
36
|
-
export { FileDropzone };
|
|
50
|
+
export { FileDropzone, type FileDropzoneProps };
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
3
|
import { useRef, useState } from "react";
|
|
3
4
|
import { Upload } from "lucide-react";
|
|
4
5
|
import { Button } from "./ui.js";
|
|
5
6
|
import { cn } from "../lib/cn.js";
|
|
7
|
+
import { useKitFileLabels } from "../i18n/kit-labels.js";
|
|
6
8
|
function FileDropzone({
|
|
7
9
|
file,
|
|
8
10
|
onFileSelected,
|
|
@@ -13,10 +15,14 @@ function FileDropzone({
|
|
|
13
15
|
dropLabel,
|
|
14
16
|
browseLabel,
|
|
15
17
|
emptyLabel,
|
|
16
|
-
hint
|
|
18
|
+
hint,
|
|
19
|
+
className,
|
|
20
|
+
"aria-label": ariaLabel,
|
|
21
|
+
...rest
|
|
17
22
|
}) {
|
|
18
23
|
const [dragOver, setDragOver] = useState(false);
|
|
19
24
|
const fileInputRef = useRef(null);
|
|
25
|
+
const fileText = useKitFileLabels();
|
|
20
26
|
const acceptFile = (f) => {
|
|
21
27
|
if (!f) return;
|
|
22
28
|
if (!isValid(f)) {
|
|
@@ -32,6 +38,7 @@ function FileDropzone({
|
|
|
32
38
|
return /* @__PURE__ */ jsxs(
|
|
33
39
|
"div",
|
|
34
40
|
{
|
|
41
|
+
...rest,
|
|
35
42
|
onDragEnter: (e) => {
|
|
36
43
|
e.preventDefault();
|
|
37
44
|
e.stopPropagation();
|
|
@@ -64,15 +71,29 @@ function FileDropzone({
|
|
|
64
71
|
},
|
|
65
72
|
role: "button",
|
|
66
73
|
tabIndex: 0,
|
|
67
|
-
"aria-label": dropLabel,
|
|
74
|
+
"aria-label": ariaLabel ?? dropLabel,
|
|
68
75
|
className: cn(
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
// `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,
|
|
77
|
+
// which Tailwind implements as `position: absolute` — so without a positioned
|
|
78
|
+
// ancestor its containing block is the INITIAL one, and it is laid out at its
|
|
79
|
+
// own offset from the top of the document. Nothing looks wrong, because the
|
|
80
|
+
// input is 1x1 and clipped; what breaks is the page height. On a long page
|
|
81
|
+
// every escaped sr-only element extends `documentElement.scrollHeight` to its
|
|
82
|
+
// own offset, which produces a second, whole-document scrollbar alongside the
|
|
83
|
+
// app shell's own — one that scrolls past the end of the content into nothing.
|
|
84
|
+
// Measured on the showcase: body 900px, document 47,919px.
|
|
85
|
+
"relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors",
|
|
86
|
+
// Three states out of two border tokens: the target rests on the plain
|
|
87
|
+
// hairline, hover pulls it to `--border-strong`, and a live drag keeps that
|
|
88
|
+
// border and adds the `--bg-active` wash on top — so "let go here" still reads
|
|
89
|
+
// one step louder than "you are over it".
|
|
90
|
+
dragOver ? "border-[var(--border-strong)] bg-[var(--bg-active)]" : "border-[var(--border)] hover:border-[var(--border-strong)]",
|
|
91
|
+
className
|
|
71
92
|
),
|
|
72
93
|
children: [
|
|
73
|
-
/* @__PURE__ */ jsx(Upload, { className: "size-5 text-
|
|
74
|
-
/* @__PURE__ */ jsx("div", { className: "text-sm text-
|
|
75
|
-
/* @__PURE__ */ jsx("div", { className: "text-xs text-
|
|
94
|
+
/* @__PURE__ */ jsx(Upload, { className: "size-5 text-[var(--text-muted)]" }),
|
|
95
|
+
/* @__PURE__ */ jsx("div", { className: "text-sm text-[var(--text-secondary)]", children: file ? /* @__PURE__ */ jsx("span", { className: "font-medium", children: file.name }) : emptyLabel }),
|
|
96
|
+
/* @__PURE__ */ jsx("div", { className: "text-xs text-[var(--text-muted)]", children: file ? fileText.size(file.size) : hint }),
|
|
76
97
|
/* @__PURE__ */ jsx(
|
|
77
98
|
Button,
|
|
78
99
|
{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/file-dropzone.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport { Upload } from \"lucide-react\";\nimport { Button } from \"./ui\";\nimport { cn } from \"../lib/cn\";\n\n
|
|
1
|
+
{"version":3,"sources":["../../src/components/file-dropzone.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef } from \"react\";\nimport { Upload } from \"lucide-react\";\nimport { Button } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { useKitFileLabels } from \"../i18n/kit-labels\";\n\n/**\n * `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.\n * The DOM event of that name belongs to form validation and takes a `FormEvent`; this\n * one takes the rejected `File`, and the kit's meaning is the one every caller already\n * writes. Everything else a `<div>` takes reaches the root — which carries\n * `role=\"button\"`, so that is also where an `aria-label` or an `aria-describedby`\n * belongs.\n */\nexport interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onInvalid\"> {\n file: File | null;\n onFileSelected: (file: File) => void;\n accept: string;\n isValid: (file: File) => boolean;\n invalidMessage: string;\n /**\n * How to surface {@link invalidMessage}. Defaults to a `sonner` toast — an\n * optional peer, imported only when a file is actually rejected. Pass your own to\n * route it somewhere else, or a no-op to silence it. The same escape hatch, for\n * the same reason, as `useWizard`'s `onValidationFailed`.\n *\n * It exists so this module does not import `sonner` STATICALLY. `package.json`\n * declares that peer `optional`, and the barrel re-exports this file — so a\n * top-level `import { toast } from \"sonner\"` made the claim false for everybody:\n * in an app that installed `@hb/ui` without sonner, `import { Button } from\n * \"@hb/ui\"` failed to resolve and `tsc --noEmit` failed on the missing types,\n * without a FileDropzone anywhere in it. Both current consumers happen to depend\n * on sonner, which is why nothing broke and why the contradiction survived:\n * `use-wizard.ts` had already answered the identical question the other way, in\n * writing, two modules over.\n */\n onInvalid?: (file: File) => void;\n /** The dropzone's accessible name, and the instruction shown on it. A caller's own\n * `aria-label` wins over it — see the root element below. */\n dropLabel: string;\n browseLabel: string;\n emptyLabel: string;\n hint: string;\n /** Extra classes for the dropzone's root. */\n className?: string;\n}\n\n/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml).\n * Validation failures surface `invalidMessage` (a `sonner` toast by default, see\n * `onInvalid`); the chosen file is echoed with its size, otherwise `emptyLabel` +\n * `hint` describe what to drop. */\nexport function FileDropzone({\n file,\n onFileSelected,\n accept,\n isValid,\n invalidMessage,\n onInvalid,\n dropLabel,\n browseLabel,\n emptyLabel,\n hint,\n className,\n \"aria-label\": ariaLabel,\n ...rest\n}: FileDropzoneProps) {\n const [dragOver, setDragOver] = useState(false);\n const fileInputRef = useRef<HTMLInputElement>(null);\n // The size through the kit's `file.size` label (the provider's locale by default).\n // This was `${n / 1024} KB`: ASCII digits, an English unit, and a binary kilobyte\n // mislabelled as a decimal one, all in one template literal.\n const fileText = useKitFileLabels();\n\n const acceptFile = (f: File | undefined | null) => {\n if (!f) return;\n if (!isValid(f)) {\n if (onInvalid) {\n onInvalid(f);\n } else {\n // sonner is an optional peer: imported here, on the failure path only, so an\n // app that never trips this never has to install it. Not awaited — nothing\n // downstream depends on the toast having appeared, and the handlers that\n // reach this are DOM events.\n void import(\"sonner\").then(({ toast }) => toast.error(invalidMessage));\n }\n return;\n }\n onFileSelected(f);\n };\n\n return (\n <div\n // `...rest` first: every handler below is the drop gesture itself, and the\n // `role`/`tabIndex` pair is what makes this div operable by keyboard at all.\n {...rest}\n onDragEnter={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragOver={(e) => {\n e.preventDefault();\n e.stopPropagation();\n e.dataTransfer.dropEffect = \"copy\";\n if (!dragOver && e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragLeave={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.currentTarget.contains(e.relatedTarget as Node)) return;\n setDragOver(false);\n }}\n onDrop={(e) => {\n e.preventDefault();\n e.stopPropagation();\n setDragOver(false);\n acceptFile(e.dataTransfer.files?.[0]);\n }}\n onClick={() => fileInputRef.current?.click()}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n fileInputRef.current?.click();\n }\n }}\n role=\"button\"\n tabIndex={0}\n // The DOM spelling wins over `dropLabel`, which stays the name for every caller\n // that passes no `aria-label` — i.e. all of them today. Standardising on\n // `aria-label` (audit §api-design) does not get to silently rename an existing\n // required prop.\n aria-label={ariaLabel ?? dropLabel}\n className={cn(\n // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,\n // which Tailwind implements as `position: absolute` — so without a positioned\n // ancestor its containing block is the INITIAL one, and it is laid out at its\n // own offset from the top of the document. Nothing looks wrong, because the\n // input is 1x1 and clipped; what breaks is the page height. On a long page\n // every escaped sr-only element extends `documentElement.scrollHeight` to its\n // own offset, which produces a second, whole-document scrollbar alongside the\n // app shell's own — one that scrolls past the end of the content into nothing.\n // Measured on the showcase: body 900px, document 47,919px.\n \"relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors\",\n // Three states out of two border tokens: the target rests on the plain\n // hairline, hover pulls it to `--border-strong`, and a live drag keeps that\n // border and adds the `--bg-active` wash on top — so \"let go here\" still reads\n // one step louder than \"you are over it\".\n dragOver\n ? \"border-[var(--border-strong)] bg-[var(--bg-active)]\"\n : \"border-[var(--border)] hover:border-[var(--border-strong)]\",\n className,\n )}\n >\n <Upload className=\"size-5 text-[var(--text-muted)]\" />\n <div className=\"text-sm text-[var(--text-secondary)]\">\n {file ? <span className=\"font-medium\">{file.name}</span> : emptyLabel}\n </div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {file ? fileText.size(file.size) : hint}\n </div>\n <Button\n type=\"button\"\n variant=\"secondary\"\n onClick={(e) => {\n e.stopPropagation();\n fileInputRef.current?.click();\n }}\n >\n {browseLabel}\n </Button>\n <input\n ref={fileInputRef}\n type=\"file\"\n accept={accept}\n onChange={(e) => acceptFile(e.target.files?.[0])}\n className=\"sr-only\"\n />\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { ReactNode } from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Named and exported, because `ComponentProps<typeof FullBleedDialog>` was the only way
|
|
6
|
+
* to say "the props of this" and five call sites across keksdose and kastlan had already
|
|
7
|
+
* written one (audit §api-design).
|
|
8
|
+
*
|
|
9
|
+
* It extends the `<div>` attributes so a caller can reach the OUTER element — the one
|
|
10
|
+
* carrying `role="dialog"`, and therefore the one an `aria-label`, an
|
|
11
|
+
* `aria-describedby` or a `data-tour` anchor belongs on. {@link className} keeps going
|
|
12
|
+
* to the panel inside it, which is what it has always meant here.
|
|
13
|
+
*/
|
|
14
|
+
interface FullBleedDialogProps extends ComponentPropsWithoutRef<"div"> {
|
|
15
|
+
open: boolean;
|
|
16
|
+
/** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —
|
|
17
|
+
* the platform Back gesture all call this. */
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
/** What the header strip shows beside the close button. */
|
|
20
|
+
header?: ReactNode;
|
|
21
|
+
closeLabel: string;
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
/**
|
|
24
|
+
* Push a history entry so Back dismisses this dialog instead of navigating
|
|
25
|
+
* (Keksdose feedback #172). On by default, because a full-screen form that ignores
|
|
26
|
+
* the phone's universal "go back" gesture does not merely fail to close — the
|
|
27
|
+
* navigation lands somewhere else with the form still notionally open.
|
|
28
|
+
*
|
|
29
|
+
* **Off for a caller whose open state is already in the URL.** The transactions
|
|
30
|
+
* create card is `?action=new`, so Back already pops it through the router; a
|
|
31
|
+
* sentinel on top of that would cost two presses to close one dialog. One owner of
|
|
32
|
+
* the Back press, always.
|
|
33
|
+
*/
|
|
34
|
+
backCloses?: boolean;
|
|
35
|
+
/** Extra classes for the PANEL (not the backdrop). */
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
4
38
|
/**
|
|
5
39
|
* The phone's full-screen dialog: a panel that covers the viewport edge to edge.
|
|
6
40
|
*
|
|
@@ -29,29 +63,6 @@ import { ReactNode } from 'react';
|
|
|
29
63
|
* editor shows the row's own first column, the create card a plain title. And `open`,
|
|
30
64
|
* because who decides is the caller's business — see {@link backCloses}.
|
|
31
65
|
*/
|
|
32
|
-
declare function FullBleedDialog({ open, onClose, header, closeLabel, children, backCloses, className, }:
|
|
33
|
-
open: boolean;
|
|
34
|
-
/** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —
|
|
35
|
-
* the platform Back gesture all call this. */
|
|
36
|
-
onClose: () => void;
|
|
37
|
-
/** What the header strip shows beside the close button. */
|
|
38
|
-
header?: ReactNode;
|
|
39
|
-
closeLabel: string;
|
|
40
|
-
children: ReactNode;
|
|
41
|
-
/**
|
|
42
|
-
* Push a history entry so Back dismisses this dialog instead of navigating
|
|
43
|
-
* (Keksdose feedback #172). On by default, because a full-screen form that ignores
|
|
44
|
-
* the phone's universal "go back" gesture does not merely fail to close — the
|
|
45
|
-
* navigation lands somewhere else with the form still notionally open.
|
|
46
|
-
*
|
|
47
|
-
* **Off for a caller whose open state is already in the URL.** The transactions
|
|
48
|
-
* create card is `?action=new`, so Back already pops it through the router; a
|
|
49
|
-
* sentinel on top of that would cost two presses to close one dialog. One owner of
|
|
50
|
-
* the Back press, always.
|
|
51
|
-
*/
|
|
52
|
-
backCloses?: boolean;
|
|
53
|
-
/** Extra classes for the PANEL (not the backdrop). */
|
|
54
|
-
className?: string;
|
|
55
|
-
}): react.ReactPortal | null;
|
|
66
|
+
declare function FullBleedDialog({ open, onClose, header, closeLabel, children, backCloses, className, ...rest }: FullBleedDialogProps): react.ReactPortal | null;
|
|
56
67
|
|
|
57
|
-
export { FullBleedDialog };
|
|
68
|
+
export { FullBleedDialog, type FullBleedDialogProps };
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useRef } from "react";
|
|
2
4
|
import { createPortal } from "react-dom";
|
|
3
5
|
import { X } from "lucide-react";
|
|
4
6
|
import { cn } from "../lib/cn.js";
|
|
@@ -6,6 +8,7 @@ import { useBackdropClose } from "./modal.js";
|
|
|
6
8
|
import { useBodyScrollLock } from "../hooks/use-body-scroll-lock.js";
|
|
7
9
|
import { useOverlayHistory } from "../hooks/use-overlay-history.js";
|
|
8
10
|
import { useCloseTransition } from "../hooks/use-close-transition.js";
|
|
11
|
+
import { useFocusTrap } from "../hooks/use-focus-trap.js";
|
|
9
12
|
function FullBleedDialog({
|
|
10
13
|
open,
|
|
11
14
|
onClose,
|
|
@@ -13,34 +16,46 @@ function FullBleedDialog({
|
|
|
13
16
|
closeLabel,
|
|
14
17
|
children,
|
|
15
18
|
backCloses = true,
|
|
16
|
-
className
|
|
19
|
+
className,
|
|
20
|
+
...rest
|
|
17
21
|
}) {
|
|
18
22
|
const { closing, requestClose } = useCloseTransition(onClose);
|
|
19
23
|
const backdropClose = useBackdropClose(requestClose);
|
|
20
24
|
useBodyScrollLock(open);
|
|
21
25
|
useOverlayHistory(open && backCloses, requestClose);
|
|
26
|
+
const dialogRef = useRef(null);
|
|
27
|
+
useFocusTrap(dialogRef, { active: open });
|
|
28
|
+
const handleKeyDown = (e) => {
|
|
29
|
+
if (e.key !== "Escape") return;
|
|
30
|
+
e.stopPropagation();
|
|
31
|
+
requestClose();
|
|
32
|
+
};
|
|
22
33
|
if (!open || typeof document === "undefined") return null;
|
|
23
34
|
return createPortal(
|
|
24
35
|
/* @__PURE__ */ jsx(
|
|
25
36
|
"div",
|
|
26
37
|
{
|
|
38
|
+
...rest,
|
|
27
39
|
className: cn(
|
|
28
|
-
"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40",
|
|
40
|
+
"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40 outline-none",
|
|
29
41
|
closing ? "animate-overlay-out" : "animate-overlay"
|
|
30
42
|
),
|
|
31
43
|
role: "dialog",
|
|
32
44
|
"aria-modal": "true",
|
|
45
|
+
tabIndex: -1,
|
|
46
|
+
ref: dialogRef,
|
|
47
|
+
onKeyDown: handleKeyDown,
|
|
33
48
|
...backdropClose,
|
|
34
49
|
children: /* @__PURE__ */ jsxs(
|
|
35
50
|
"div",
|
|
36
51
|
{
|
|
37
52
|
className: cn(
|
|
38
|
-
"flex h-full w-full flex-col overflow-hidden bg-
|
|
53
|
+
"flex h-full w-full flex-col overflow-hidden bg-[var(--bg-surface)] shadow-xl",
|
|
39
54
|
closing ? "animate-sheet-out" : "animate-sheet",
|
|
40
55
|
className
|
|
41
56
|
),
|
|
42
57
|
children: [
|
|
43
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-
|
|
58
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-3", children: [
|
|
44
59
|
/* @__PURE__ */ jsx("div", { className: "min-w-0 font-medium", children: header }),
|
|
45
60
|
/* @__PURE__ */ jsx(
|
|
46
61
|
"button",
|
|
@@ -48,7 +63,7 @@ function FullBleedDialog({
|
|
|
48
63
|
type: "button",
|
|
49
64
|
onClick: requestClose,
|
|
50
65
|
"aria-label": closeLabel,
|
|
51
|
-
className: "-mr-1 shrink-0 rounded p-1.5 text-
|
|
66
|
+
className: "-mr-1 shrink-0 rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]",
|
|
52
67
|
children: /* @__PURE__ */ jsx(X, { className: "size-5" })
|
|
53
68
|
}
|
|
54
69
|
)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/full-bleed-dialog.tsx"],"sourcesContent":["import { createPortal } from \"react-dom\";\nimport type { ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { useBackdropClose } from \"./modal\";\nimport { useBodyScrollLock } from \"../hooks/use-body-scroll-lock\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { useCloseTransition } from \"../hooks/use-close-transition\";\n\n/**\n * The phone's full-screen dialog: a panel that covers the viewport edge to edge.\n *\n * Not {@link Modal}, and that is a decision rather than an omission. Keksdose feedback\n * #32: the inset panel #204 shipped left a strip of page showing beside it on a phone,\n * and tapping that strip dismissed a form mid-edit. With no backdrop exposed there is\n * nothing to mis-tap, so the X is the way out — `backdropClose` stays wired for any\n * layout that does leave a backdrop visible, and costs nothing where none is.\n *\n * ## Why it is a component\n *\n * It was markup inside `data-table.tsx`, where it is the phone's row EDITOR. Keksdose\n * live #307's follow-up needed the same shell for the transactions CREATE card —\n * *\"Is the creation window of the tx still different than the edit window? Since the tx\n * list still appears below when scrolling down?\"* — and the two answers to that are\n * either one component or two copies that drift. Every previous convergence in that app\n * drifted the moment a second surface was written beside the first, which is what\n * `field-layer-parity` exists to catch one layer down.\n *\n * So the panel, the header strip, the scrolling body, the body-scroll lock and the Back\n * handling are all here, once.\n *\n * ## What the caller still owns\n *\n * The HEADER's content, because the two callers name themselves differently: the row\n * editor shows the row's own first column, the create card a plain title. And `open`,\n * because who decides is the caller's business — see {@link backCloses}.\n */\nexport function FullBleedDialog({\n open,\n onClose,\n header,\n closeLabel,\n children,\n backCloses = true,\n className,\n}: {\n open: boolean;\n /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —\n * the platform Back gesture all call this. */\n onClose: () => void;\n /** What the header strip shows beside the close button. */\n header?: ReactNode;\n closeLabel: string;\n children: ReactNode;\n /**\n * Push a history entry so Back dismisses this dialog instead of navigating\n * (Keksdose feedback #172). On by default, because a full-screen form that ignores\n * the phone's universal \"go back\" gesture does not merely fail to close — the\n * navigation lands somewhere else with the form still notionally open.\n *\n * **Off for a caller whose open state is already in the URL.** The transactions\n * create card is `?action=new`, so Back already pops it through the router; a\n * sentinel on top of that would cost two presses to close one dialog. One owner of\n * the Back press, always.\n */\n backCloses?: boolean;\n /** Extra classes for the PANEL (not the backdrop). */\n className?: string;\n}) {\n // Every way OUT goes through `requestClose`, so the panel lowers itself before the\n // caller unmounts it (live #320 rework). `onClose` is still what finally runs — this\n // only delays it by the length of the animation.\n const { closing, requestClose } = useCloseTransition(onClose);\n const backdropClose = useBackdropClose(requestClose);\n // The page behind must not scroll or jump under the overlay (feedback #204).\n useBodyScrollLock(open);\n useOverlayHistory(open && backCloses, requestClose);\n\n if (!open || typeof document === \"undefined\") return null;\n\n return createPortal(\n <div\n // `animate-overlay` on the backdrop and `animate-sheet` on the panel (live #320):\n // the backdrop fades, the panel rises from the bottom edge it is anchored to.\n // Both are no-ops under prefers-reduced-motion — see tokens.css.\n //\n // …and the same two backwards while `closing`, which is the #320 rework. The\n // backdrop keeps taking pointer events on the way out: a tap during those 220ms\n // is a tap on a dialog that is leaving, and letting it through to the row\n // underneath would open a second one.\n className={cn(\n \"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40\",\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n )}\n role=\"dialog\"\n aria-modal=\"true\"\n {...backdropClose}\n >\n <div\n className={cn(\n \"flex h-full w-full flex-col overflow-hidden bg-white shadow-xl dark:bg-slate-900\",\n closing ? \"animate-sheet-out\" : \"animate-sheet\",\n className,\n )}\n >\n <div className=\"flex items-center justify-between gap-2 border-b border-slate-100 px-3 py-3 dark:border-slate-800\">\n <div className=\"min-w-0 font-medium\">{header}</div>\n <button\n type=\"button\"\n onClick={requestClose}\n aria-label={closeLabel}\n className=\"-mr-1 shrink-0 rounded p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200\"\n >\n <X className=\"size-5\" />\n </button>\n </div>\n {/* px-3, not px-4: every pixel of chrome here is width the form fields lose\n on a phone (feedback #32). The body is the only thing that scrolls, so the\n header stays put and the page underneath cannot move at all. */}\n <div className=\"flex-1 overflow-y-auto overscroll-contain px-3 py-3\">{children}</div>\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":"AA0GQ,SACE,KADF;AA1GR,SAAS,oBAAoB;AAE7B,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,wBAAwB;AACjC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AA8B5B,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb;AACF,GAuBG;AAID,QAAM,EAAE,SAAS,aAAa,IAAI,mBAAmB,OAAO;AAC5D,QAAM,gBAAgB,iBAAiB,YAAY;AAEnD,oBAAkB,IAAI;AACtB,oBAAkB,QAAQ,YAAY,YAAY;AAElD,MAAI,CAAC,QAAQ,OAAO,aAAa,YAAa,QAAO;AAErD,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QASC,WAAW;AAAA,UACT;AAAA,UACA,UAAU,wBAAwB;AAAA,QACpC;AAAA,QACA,MAAK;AAAA,QACL,cAAW;AAAA,QACV,GAAG;AAAA,QAEJ;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,UAAU,sBAAsB;AAAA,cAChC;AAAA,YACF;AAAA,YAEA;AAAA,mCAAC,SAAI,WAAU,qGACb;AAAA,oCAAC,SAAI,WAAU,uBAAuB,kBAAO;AAAA,gBAC7C;AAAA,kBAAC;AAAA;AAAA,oBACC,MAAK;AAAA,oBACL,SAAS;AAAA,oBACT,cAAY;AAAA,oBACZ,WAAU;AAAA,oBAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,gBACxB;AAAA,iBACF;AAAA,cAIA,oBAAC,SAAI,WAAU,uDAAuD,UAAS;AAAA;AAAA;AAAA,QACjF;AAAA;AAAA,IACF;AAAA,IACA,SAAS;AAAA,EACX;AACF;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/full-bleed-dialog.tsx"],"sourcesContent":["import { useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { useBackdropClose } from \"./modal\";\nimport { useBodyScrollLock } from \"../hooks/use-body-scroll-lock\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { useCloseTransition } from \"../hooks/use-close-transition\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\n\n/**\n * Named and exported, because `ComponentProps<typeof FullBleedDialog>` was the only way\n * to say \"the props of this\" and five call sites across keksdose and kastlan had already\n * written one (audit §api-design).\n *\n * It extends the `<div>` attributes so a caller can reach the OUTER element — the one\n * carrying `role=\"dialog\"`, and therefore the one an `aria-label`, an\n * `aria-describedby` or a `data-tour` anchor belongs on. {@link className} keeps going\n * to the panel inside it, which is what it has always meant here.\n */\nexport interface FullBleedDialogProps extends ComponentPropsWithoutRef<\"div\"> {\n open: boolean;\n /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —\n * the platform Back gesture all call this. */\n onClose: () => void;\n /** What the header strip shows beside the close button. */\n header?: ReactNode;\n closeLabel: string;\n children: ReactNode;\n /**\n * Push a history entry so Back dismisses this dialog instead of navigating\n * (Keksdose feedback #172). On by default, because a full-screen form that ignores\n * the phone's universal \"go back\" gesture does not merely fail to close — the\n * navigation lands somewhere else with the form still notionally open.\n *\n * **Off for a caller whose open state is already in the URL.** The transactions\n * create card is `?action=new`, so Back already pops it through the router; a\n * sentinel on top of that would cost two presses to close one dialog. One owner of\n * the Back press, always.\n */\n backCloses?: boolean;\n /** Extra classes for the PANEL (not the backdrop). */\n className?: string;\n}\n\n/**\n * The phone's full-screen dialog: a panel that covers the viewport edge to edge.\n *\n * Not {@link Modal}, and that is a decision rather than an omission. Keksdose feedback\n * #32: the inset panel #204 shipped left a strip of page showing beside it on a phone,\n * and tapping that strip dismissed a form mid-edit. With no backdrop exposed there is\n * nothing to mis-tap, so the X is the way out — `backdropClose` stays wired for any\n * layout that does leave a backdrop visible, and costs nothing where none is.\n *\n * ## Why it is a component\n *\n * It was markup inside `data-table.tsx`, where it is the phone's row EDITOR. Keksdose\n * live #307's follow-up needed the same shell for the transactions CREATE card —\n * *\"Is the creation window of the tx still different than the edit window? Since the tx\n * list still appears below when scrolling down?\"* — and the two answers to that are\n * either one component or two copies that drift. Every previous convergence in that app\n * drifted the moment a second surface was written beside the first, which is what\n * `field-layer-parity` exists to catch one layer down.\n *\n * So the panel, the header strip, the scrolling body, the body-scroll lock and the Back\n * handling are all here, once.\n *\n * ## What the caller still owns\n *\n * The HEADER's content, because the two callers name themselves differently: the row\n * editor shows the row's own first column, the create card a plain title. And `open`,\n * because who decides is the caller's business — see {@link backCloses}.\n */\nexport function FullBleedDialog({\n open,\n onClose,\n header,\n closeLabel,\n children,\n backCloses = true,\n className,\n ...rest\n}: FullBleedDialogProps) {\n // Every way OUT goes through `requestClose`, so the panel lowers itself before the\n // caller unmounts it (live #320 rework). `onClose` is still what finally runs — this\n // only delays it by the length of the animation.\n const { closing, requestClose } = useCloseTransition(onClose);\n const backdropClose = useBackdropClose(requestClose);\n // The page behind must not scroll or jump under the overlay (feedback #204).\n useBodyScrollLock(open);\n useOverlayHistory(open && backCloses, requestClose);\n\n const dialogRef = useRef<HTMLDivElement>(null);\n // The half of \"modal\" this dialog was only claiming. It has said\n // `aria-modal=\"true\"` since it was extracted from `data-table.tsx`, which tells\n // assistive technology to hide everything outside it — while the user's focus stayed\n // on the row they tapped, in the part that is now hidden. Tab then walked them\n // through a table the screen reader would not describe, and on a phone this dialog\n // covers the whole screen, so there was nothing on screen to say where they were.\n //\n // `initialFocus` is left at the default — the dialog itself, not the first field.\n // This is the phone's row EDITOR and its first child is usually an input; focusing it\n // would pull the software keyboard up over half the form before the user has said\n // which field they came for. `Modal` made the same call for the same reason.\n useFocusTrap(dialogRef, { active: open });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key !== \"Escape\") return;\n // On the panel, the way `Modal` does it, and NOT through `useEscapeKey`: this\n // dialog is the thing a `PickerSheet` opens on top of, and a document-level\n // listener cannot tell which of the two the user meant. One press would dismiss\n // the sheet AND throw away the edit behind it — Keksdose live #309 (*\"Mouse Back\n // does not only close the select but also the whole edit or create dialog\"*) on a\n // different key. The sheet's own handler stops the event here, so the innermost\n // overlay is the one that closes, which is only true because focus is now in it.\n e.stopPropagation();\n requestClose();\n };\n\n if (!open || typeof document === \"undefined\") return null;\n\n return createPortal(\n <div\n // `animate-overlay` on the backdrop and `animate-sheet` on the panel (live #320):\n // the backdrop fades, the panel rises from the bottom edge it is anchored to.\n // Both are no-ops under prefers-reduced-motion — see tokens.css.\n //\n // …and the same two backwards while `closing`, which is the #320 rework. The\n // backdrop keeps taking pointer events on the way out: a tap during those 220ms\n // is a tap on a dialog that is leaving, and letting it through to the row\n // underneath would open a second one.\n //\n // `...rest` first, the dialog's own contract after it. A caller adding a\n // `data-tour` anchor must not be able to take away the role, the modality or the\n // `tabIndex` below by passing one of them too — this is the element the focus\n // trap holds, and each of those failures is invisible on screen.\n {...rest}\n className={cn(\n \"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40 outline-none\",\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n )}\n role=\"dialog\"\n aria-modal=\"true\"\n // `tabIndex={-1}` is what makes the line above more than a claim: without it the\n // container cannot take focus and `useFocusTrap` silently does nothing.\n tabIndex={-1}\n ref={dialogRef}\n onKeyDown={handleKeyDown}\n {...backdropClose}\n >\n <div\n className={cn(\n \"flex h-full w-full flex-col overflow-hidden bg-[var(--bg-surface)] shadow-xl\",\n closing ? \"animate-sheet-out\" : \"animate-sheet\",\n className,\n )}\n >\n <div className=\"flex items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-3\">\n <div className=\"min-w-0 font-medium\">{header}</div>\n <button\n type=\"button\"\n onClick={requestClose}\n aria-label={closeLabel}\n className=\"-mr-1 shrink-0 rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <X className=\"size-5\" />\n </button>\n </div>\n {/* px-3, not px-4: every pixel of chrome here is width the form fields lose\n on a phone (feedback #32). The body is the only thing that scrolls, so the\n header stays put and the page underneath cannot move at all. */}\n <div className=\"flex-1 overflow-y-auto overscroll-contain px-3 py-3\">{children}</div>\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":";AA+JQ,SACE,KADF;AA/JR,SAAS,cAAc;AACvB,SAAS,oBAAoB;AAE7B,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,wBAAwB;AACjC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;AAiEtB,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb;AAAA,EACA,GAAG;AACL,GAAyB;AAIvB,QAAM,EAAE,SAAS,aAAa,IAAI,mBAAmB,OAAO;AAC5D,QAAM,gBAAgB,iBAAiB,YAAY;AAEnD,oBAAkB,IAAI;AACtB,oBAAkB,QAAQ,YAAY,YAAY;AAElD,QAAM,YAAY,OAAuB,IAAI;AAY7C,eAAa,WAAW,EAAE,QAAQ,KAAK,CAAC;AAExC,QAAM,gBAAgB,CAAC,MAAqC;AAC1D,QAAI,EAAE,QAAQ,SAAU;AAQxB,MAAE,gBAAgB;AAClB,iBAAa;AAAA,EACf;AAEA,MAAI,CAAC,QAAQ,OAAO,aAAa,YAAa,QAAO;AAErD,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QAcE,GAAG;AAAA,QACJ,WAAW;AAAA,UACT;AAAA,UACA,UAAU,wBAAwB;AAAA,QACpC;AAAA,QACA,MAAK;AAAA,QACL,cAAW;AAAA,QAGX,UAAU;AAAA,QACV,KAAK;AAAA,QACL,WAAW;AAAA,QACV,GAAG;AAAA,QAEJ;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,UAAU,sBAAsB;AAAA,cAChC;AAAA,YACF;AAAA,YAEA;AAAA,mCAAC,SAAI,WAAU,qFACb;AAAA,oCAAC,SAAI,WAAU,uBAAuB,kBAAO;AAAA,gBAC7C;AAAA,kBAAC;AAAA;AAAA,oBACC,MAAK;AAAA,oBACL,SAAS;AAAA,oBACT,cAAY;AAAA,oBACZ,WAAU;AAAA,oBAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,gBACxB;AAAA,iBACF;AAAA,cAIA,oBAAC,SAAI,WAAU,uDAAuD,UAAS;AAAA;AAAA;AAAA,QACjF;AAAA;AAAA,IACF;AAAA,IACA,SAAS;AAAA,EACX;AACF;","names":[]}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { ReactNode } from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
3
3
|
|
|
4
4
|
interface PickerGroup {
|
|
5
5
|
key: string;
|
|
@@ -14,12 +14,13 @@ interface PickerGroup {
|
|
|
14
14
|
icon?: ReactNode;
|
|
15
15
|
}[];
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
|
|
17
|
+
/**
|
|
18
|
+
* `onSelect` is omitted from the div's own attributes because this component's
|
|
19
|
+
* means "an item was chosen", not the DOM's "text was selected inside me" — two
|
|
20
|
+
* different signatures under one name, and the kit's meaning is the one three apps
|
|
21
|
+
* are written against.
|
|
22
|
+
*/
|
|
23
|
+
interface GroupedPickerProps extends Omit<ComponentPropsWithoutRef<"div">, "onSelect"> {
|
|
23
24
|
groups: PickerGroup[];
|
|
24
25
|
/** Face of the closed picker button. */
|
|
25
26
|
buttonLabel: ReactNode;
|
|
@@ -29,9 +30,19 @@ declare function GroupedPicker({ groups, buttonLabel, selected, onSelect, ariaLa
|
|
|
29
30
|
item: string;
|
|
30
31
|
} | null;
|
|
31
32
|
onSelect: (groupKey: string, itemKey: string) => void;
|
|
33
|
+
/**
|
|
34
|
+
* @deprecated Use `aria-label`, the DOM spelling every control in the kit now
|
|
35
|
+
* takes. Kept working — three apps pass it — and it applies only when
|
|
36
|
+
* `aria-label` is absent; it goes in a later minor.
|
|
37
|
+
*/
|
|
32
38
|
ariaLabel?: string;
|
|
33
39
|
filterPlaceholder?: string;
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
}
|
|
41
|
+
/** Panel picker for large grouped option sets: instead of one long flat
|
|
42
|
+
* dropdown, the groups sit side by side as columns with their items listed
|
|
43
|
+
* beneath (feedback #267/#276), narrowed by a filter box at the top (feedback
|
|
44
|
+
* #267 rework). A query that matches a group's name keeps the whole group;
|
|
45
|
+
* otherwise groups are reduced to their matching items. */
|
|
46
|
+
declare function GroupedPicker({ groups, buttonLabel, selected, onSelect, ariaLabel, "aria-label": ariaLabelAttr, filterPlaceholder, className, ...rest }: GroupedPickerProps): react.JSX.Element;
|
|
36
47
|
|
|
37
|
-
export { GroupedPicker, type PickerGroup };
|
|
48
|
+
export { GroupedPicker, type GroupedPickerProps, type PickerGroup };
|
|
@@ -1,16 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
3
|
import { useEffect, useMemo } from "react";
|
|
3
4
|
import { ChevronDown } from "lucide-react";
|
|
4
5
|
import { cn } from "../lib/cn.js";
|
|
5
6
|
import { DropdownSearchHeader, useDropdownSearch } from "./dropdown.js";
|
|
7
|
+
import { useKitLocale } from "../i18n/kit-labels.js";
|
|
6
8
|
function GroupedPicker({
|
|
7
9
|
groups,
|
|
8
10
|
buttonLabel,
|
|
9
11
|
selected,
|
|
10
12
|
onSelect,
|
|
11
13
|
ariaLabel,
|
|
14
|
+
"aria-label": ariaLabelAttr,
|
|
12
15
|
filterPlaceholder,
|
|
13
|
-
className
|
|
16
|
+
className,
|
|
17
|
+
...rest
|
|
14
18
|
}) {
|
|
15
19
|
const { open, setOpen, wrapperRef, query, setQuery, inputRef } = useDropdownSearch();
|
|
16
20
|
useEffect(() => {
|
|
@@ -21,65 +25,75 @@ function GroupedPicker({
|
|
|
21
25
|
document.addEventListener("keydown", onKey);
|
|
22
26
|
return () => document.removeEventListener("keydown", onKey);
|
|
23
27
|
}, [open, setOpen]);
|
|
28
|
+
const locale = useKitLocale();
|
|
24
29
|
const filtered = useMemo(() => {
|
|
25
|
-
const
|
|
30
|
+
const fold = (s) => s.toLocaleLowerCase(locale);
|
|
31
|
+
const q = fold(query.trim());
|
|
26
32
|
if (!q) return groups;
|
|
27
33
|
return groups.map(
|
|
28
|
-
(g) => g.label
|
|
34
|
+
(g) => fold(g.label).includes(q) ? g : { ...g, items: g.items.filter((i) => fold(i.label).includes(q)) }
|
|
29
35
|
).filter((g) => g.items.length > 0);
|
|
30
|
-
}, [groups, query]);
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
children: [
|
|
42
|
-
/* @__PURE__ */ jsx("span", { className: "truncate", children: buttonLabel }),
|
|
43
|
-
/* @__PURE__ */ jsx(ChevronDown, { className: "size-4 shrink-0 text-slate-400" })
|
|
44
|
-
]
|
|
45
|
-
}
|
|
46
|
-
),
|
|
47
|
-
open && /* @__PURE__ */ jsxs("div", { className: "absolute left-0 z-30 mt-1 flex max-h-96 w-[min(56rem,85vw)] flex-col overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-900", children: [
|
|
48
|
-
/* @__PURE__ */ jsx(
|
|
49
|
-
DropdownSearchHeader,
|
|
36
|
+
}, [groups, query, locale]);
|
|
37
|
+
return (
|
|
38
|
+
// `rest` goes on the wrapper, but the accessible NAME does not: this div is a
|
|
39
|
+
// positioning box with no role, so a label parked on it would be announced by
|
|
40
|
+
// nothing. It belongs to the control — the same split every picker in this wave
|
|
41
|
+
// makes, and the reason `aria-label` is destructured out rather than left in
|
|
42
|
+
// `rest`. Spread FIRST so the attributes below, each of them a fix with an
|
|
43
|
+
// incident behind it, cannot be clobbered from outside.
|
|
44
|
+
/* @__PURE__ */ jsxs("div", { ...rest, ref: wrapperRef, className: cn("relative", className), children: [
|
|
45
|
+
/* @__PURE__ */ jsxs(
|
|
46
|
+
"button",
|
|
50
47
|
{
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
type: "button",
|
|
49
|
+
"aria-haspopup": "dialog",
|
|
50
|
+
"aria-expanded": open,
|
|
51
|
+
"aria-label": ariaLabelAttr ?? ariaLabel,
|
|
52
|
+
onClick: () => setOpen((o) => !o),
|
|
53
|
+
className: "flex w-full items-center justify-between gap-2 rounded-md border border-[var(--border-strong)] bg-[var(--bg-surface)] px-2.5 py-1.5 text-sm text-[var(--text-primary)] hover:bg-[var(--bg-hover)]",
|
|
54
|
+
children: [
|
|
55
|
+
/* @__PURE__ */ jsx("span", { className: "truncate", children: buttonLabel }),
|
|
56
|
+
/* @__PURE__ */ jsx(ChevronDown, { className: "size-4 shrink-0 text-[var(--text-placeholder)]" })
|
|
57
|
+
]
|
|
55
58
|
}
|
|
56
59
|
),
|
|
57
|
-
/* @__PURE__ */
|
|
58
|
-
/* @__PURE__ */ jsx(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
60
|
+
open && /* @__PURE__ */ jsxs("div", { className: "absolute left-0 z-30 mt-1 flex max-h-96 w-[min(56rem,85vw)] flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg", children: [
|
|
61
|
+
/* @__PURE__ */ jsx(
|
|
62
|
+
DropdownSearchHeader,
|
|
63
|
+
{
|
|
64
|
+
query,
|
|
65
|
+
onQueryChange: setQuery,
|
|
66
|
+
inputRef,
|
|
67
|
+
placeholder: filterPlaceholder
|
|
68
|
+
}
|
|
69
|
+
),
|
|
70
|
+
/* @__PURE__ */ jsx("div", { className: "overflow-y-auto p-3", children: filtered.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-[var(--text-muted)]", children: "\u2014" }) : /* @__PURE__ */ jsx("div", { className: "columns-2 gap-4 md:columns-3 xl:columns-4", children: filtered.map((g) => /* @__PURE__ */ jsxs("div", { className: "mb-3 break-inside-avoid", children: [
|
|
71
|
+
/* @__PURE__ */ jsx("div", { className: "mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]", children: g.label }),
|
|
72
|
+
/* @__PURE__ */ jsx("ul", { className: "space-y-0.5", children: g.items.map((item) => {
|
|
73
|
+
const isSelected = selected !== null && g.key === selected.group && item.key === selected.item;
|
|
74
|
+
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
|
|
75
|
+
"button",
|
|
76
|
+
{
|
|
77
|
+
type: "button",
|
|
78
|
+
onClick: () => {
|
|
79
|
+
onSelect(g.key, item.key);
|
|
80
|
+
setOpen(false);
|
|
81
|
+
},
|
|
82
|
+
className: cn(
|
|
83
|
+
"flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-sm",
|
|
84
|
+
isSelected ? "bg-[var(--brand-bg)] font-medium text-[var(--brand-muted)]" : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
|
|
85
|
+
),
|
|
86
|
+
children: [
|
|
87
|
+
item.icon && /* @__PURE__ */ jsx("span", { className: "shrink-0", children: item.icon }),
|
|
88
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 truncate", children: item.label })
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
) }, item.key);
|
|
92
|
+
}) })
|
|
93
|
+
] }, g.key)) }) })
|
|
94
|
+
] })
|
|
81
95
|
] })
|
|
82
|
-
|
|
96
|
+
);
|
|
83
97
|
}
|
|
84
98
|
export {
|
|
85
99
|
GroupedPicker
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/grouped-picker.tsx"],"sourcesContent":["import { useEffect, useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { DropdownSearchHeader, useDropdownSearch } from \"./dropdown\";\n\nexport interface PickerGroup {\n key: string;\n label: string;\n /** `icon` is an optional leading glyph, drawn before the label — the same slot\n * `ComboOption` has always had, so the two pickers can present one option set\n * the same way. Omitted, a row is exactly what it was: label only, with no\n * reserved space, so a list where nothing has an icon does not indent. */\n items: { key: string; label: string; icon?: ReactNode }[];\n}\n\n/** Panel picker for large grouped option sets: instead of one long flat\n * dropdown, the groups sit side by side as columns with their items listed\n * beneath (feedback #267/#276), narrowed by a filter box at the top (feedback\n * #267 rework). A query that matches a group's name keeps the whole group;\n * otherwise groups are reduced to their matching items. */\nexport function GroupedPicker({\n groups,\n buttonLabel,\n selected,\n onSelect,\n ariaLabel,\n
|
|
1
|
+
{"version":3,"sources":["../../src/components/grouped-picker.tsx"],"sourcesContent":["import { useEffect, useMemo } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { DropdownSearchHeader, useDropdownSearch } from \"./dropdown\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\n\nexport interface PickerGroup {\n key: string;\n label: string;\n /** `icon` is an optional leading glyph, drawn before the label — the same slot\n * `ComboOption` has always had, so the two pickers can present one option set\n * the same way. Omitted, a row is exactly what it was: label only, with no\n * reserved space, so a list where nothing has an icon does not indent. */\n items: { key: string; label: string; icon?: ReactNode }[];\n}\n\n/**\n * `onSelect` is omitted from the div's own attributes because this component's\n * means \"an item was chosen\", not the DOM's \"text was selected inside me\" — two\n * different signatures under one name, and the kit's meaning is the one three apps\n * are written against.\n */\nexport interface GroupedPickerProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onSelect\"> {\n groups: PickerGroup[];\n /** Face of the closed picker button. */\n buttonLabel: ReactNode;\n /** Keys of the current selection, if any. */\n selected: { group: string; item: string } | null;\n onSelect: (groupKey: string, itemKey: string) => void;\n /**\n * @deprecated Use `aria-label`, the DOM spelling every control in the kit now\n * takes. Kept working — three apps pass it — and it applies only when\n * `aria-label` is absent; it goes in a later minor.\n */\n ariaLabel?: string;\n filterPlaceholder?: string;\n}\n\n/** Panel picker for large grouped option sets: instead of one long flat\n * dropdown, the groups sit side by side as columns with their items listed\n * beneath (feedback #267/#276), narrowed by a filter box at the top (feedback\n * #267 rework). A query that matches a group's name keeps the whole group;\n * otherwise groups are reduced to their matching items. */\nexport function GroupedPicker({\n groups,\n buttonLabel,\n selected,\n onSelect,\n ariaLabel,\n \"aria-label\": ariaLabelAttr,\n filterPlaceholder,\n className,\n ...rest\n}: GroupedPickerProps) {\n const { open, setOpen, wrapperRef, query, setQuery, inputRef } = useDropdownSearch();\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"keydown\", onKey);\n return () => document.removeEventListener(\"keydown\", onKey);\n }, [open, setOpen]);\n\n // Case folding in the provider's locale — see `rowMatches` for the Turkish \"İ\"\n // that plain `toLowerCase` gets wrong.\n const locale = useKitLocale();\n const filtered = useMemo(() => {\n const fold = (s: string) => s.toLocaleLowerCase(locale);\n const q = fold(query.trim());\n if (!q) return groups;\n return groups\n .map((g) =>\n fold(g.label).includes(q)\n ? g\n : { ...g, items: g.items.filter((i) => fold(i.label).includes(q)) },\n )\n .filter((g) => g.items.length > 0);\n }, [groups, query, locale]);\n\n return (\n // `rest` goes on the wrapper, but the accessible NAME does not: this div is a\n // positioning box with no role, so a label parked on it would be announced by\n // nothing. It belongs to the control — the same split every picker in this wave\n // makes, and the reason `aria-label` is destructured out rather than left in\n // `rest`. Spread FIRST so the attributes below, each of them a fix with an\n // incident behind it, cannot be clobbered from outside.\n <div {...rest} ref={wrapperRef} className={cn(\"relative\", className)}>\n <button\n type=\"button\"\n aria-haspopup=\"dialog\"\n aria-expanded={open}\n aria-label={ariaLabelAttr ?? ariaLabel}\n onClick={() => setOpen((o) => !o)}\n className=\"flex w-full items-center justify-between gap-2 rounded-md border border-[var(--border-strong)] bg-[var(--bg-surface)] px-2.5 py-1.5 text-sm text-[var(--text-primary)] hover:bg-[var(--bg-hover)]\"\n >\n <span className=\"truncate\">{buttonLabel}</span>\n <ChevronDown className=\"size-4 shrink-0 text-[var(--text-placeholder)]\" />\n </button>\n {open && (\n <div className=\"absolute left-0 z-30 mt-1 flex max-h-96 w-[min(56rem,85vw)] flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg\">\n <DropdownSearchHeader\n query={query}\n onQueryChange={setQuery}\n inputRef={inputRef}\n placeholder={filterPlaceholder}\n />\n <div className=\"overflow-y-auto p-3\">\n {filtered.length === 0 ? (\n <p className=\"text-sm text-[var(--text-muted)]\">—</p>\n ) : (\n <div className=\"columns-2 gap-4 md:columns-3 xl:columns-4\">\n {filtered.map((g) => (\n <div key={g.key} className=\"mb-3 break-inside-avoid\">\n <div className=\"mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]\">\n {g.label}\n </div>\n <ul className=\"space-y-0.5\">\n {g.items.map((item) => {\n const isSelected =\n selected !== null &&\n g.key === selected.group &&\n item.key === selected.item;\n return (\n <li key={item.key}>\n <button\n type=\"button\"\n onClick={() => {\n onSelect(g.key, item.key);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-sm\",\n isSelected\n ? \"bg-[var(--brand-bg)] font-medium text-[var(--brand-muted)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )}\n >\n {item.icon && <span className=\"shrink-0\">{item.icon}</span>}\n <span className=\"min-w-0 truncate\">{item.label}</span>\n </button>\n </li>\n );\n })}\n </ul>\n </div>\n ))}\n </div>\n )}\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|