@eifi1/ui-kit 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +32 -0
- package/README.md +498 -57
- package/dist/chart.d.ts +10 -0
- package/dist/chart.js +10 -0
- package/dist/chart.js.map +1 -0
- package/dist/components/account-settings.d.ts +11 -0
- package/dist/components/account-settings.js +19 -12
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/alert-banner.d.ts +10 -6
- package/dist/components/alert-banner.js +20 -16
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +30 -4
- package/dist/components/amount-input.js +36 -14
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/calculator.d.ts +24 -7
- package/dist/components/calculator.js +37 -20
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/chart-zoom.d.ts +156 -0
- package/dist/components/chart-zoom.js +262 -0
- package/dist/components/chart-zoom.js.map +1 -0
- package/dist/components/chart.d.ts +28 -3
- package/dist/components/chart.js +71 -32
- package/dist/components/chart.js.map +1 -1
- package/dist/components/checkbox.d.ts +51 -0
- package/dist/components/checkbox.js +123 -0
- package/dist/components/checkbox.js.map +1 -0
- package/dist/components/chip.d.ts +128 -0
- package/dist/components/chip.js +357 -0
- package/dist/components/chip.js.map +1 -0
- package/dist/components/choice-parts.d.ts +24 -0
- package/dist/components/choice-parts.js +18 -0
- package/dist/components/choice-parts.js.map +1 -0
- package/dist/components/combobox-core.d.ts +52 -9
- package/dist/components/combobox-core.js +91 -20
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +43 -33
- package/dist/components/combobox.js +409 -340
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/currency-select.d.ts +48 -8
- package/dist/components/currency-select.js +107 -65
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +15 -3
- package/dist/components/data-table-filter-popover.js +23 -17
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-filters.js +3 -2
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +53 -2
- package/dist/components/data-table-labels.js +31 -2
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-pagination.d.ts +3 -1
- package/dist/components/data-table-pagination.js +16 -10
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-sort.js +1 -0
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +201 -85
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +25 -9
- package/dist/components/date-picker.js +232 -94
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/dropdown.d.ts +96 -20
- package/dist/components/dropdown.js +77 -33
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +5 -4
- package/dist/components/entity-combobox.js +107 -76
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +36 -0
- package/dist/components/facing-pair.js +40 -0
- package/dist/components/facing-pair.js.map +1 -0
- package/dist/components/field-sync.d.ts +164 -0
- package/dist/components/field-sync.js +264 -0
- package/dist/components/field-sync.js.map +1 -0
- package/dist/components/file-dropzone.d.ts +38 -6
- package/dist/components/file-dropzone.js +34 -9
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/full-bleed-dialog.d.ts +37 -26
- package/dist/components/full-bleed-dialog.js +20 -5
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/grouped-picker.d.ts +21 -10
- package/dist/components/grouped-picker.js +66 -52
- package/dist/components/grouped-picker.js.map +1 -1
- package/dist/components/hover-menu.d.ts +20 -4
- package/dist/components/hover-menu.js +15 -4
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +102 -14
- package/dist/components/mini-calendar.js +256 -70
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +9 -3
- package/dist/components/modal.js +34 -45
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +79 -0
- package/dist/components/month-picker.js +342 -0
- package/dist/components/month-picker.js.map +1 -0
- package/dist/components/multi-entity-combobox.d.ts +7 -6
- package/dist/components/multi-entity-combobox.js +112 -72
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +13 -11
- package/dist/components/multi-select.js +161 -84
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +92 -0
- package/dist/components/number-field.js +111 -0
- package/dist/components/number-field.js.map +1 -0
- package/dist/components/number-input.d.ts +28 -2
- package/dist/components/number-input.js +12 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +32 -4
- package/dist/components/numpad-sheet.js +30 -13
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/page-contents.d.ts +90 -0
- package/dist/components/page-contents.js +170 -0
- package/dist/components/page-contents.js.map +1 -0
- package/dist/components/password-strength.d.ts +106 -0
- package/dist/components/password-strength.js +132 -0
- package/dist/components/password-strength.js.map +1 -0
- package/dist/components/picker-sheet.d.ts +23 -15
- package/dist/components/picker-sheet.js +34 -14
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/popover.d.ts +62 -4
- package/dist/components/popover.js +52 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/search-field.d.ts +25 -7
- package/dist/components/search-field.js +7 -5
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/series-chart-labels.d.ts +14 -0
- package/dist/components/series-chart-labels.js +11 -0
- package/dist/components/series-chart-labels.js.map +1 -0
- package/dist/components/series-chart.d.ts +207 -0
- package/dist/components/series-chart.js +223 -0
- package/dist/components/series-chart.js.map +1 -0
- package/dist/components/settings-fields.d.ts +14 -4
- package/dist/components/settings-fields.js +19 -8
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/signature-pad.d.ts +119 -0
- package/dist/components/signature-pad.js +405 -0
- package/dist/components/signature-pad.js.map +1 -0
- package/dist/components/slider.d.ts +80 -0
- package/dist/components/slider.js +155 -0
- package/dist/components/slider.js.map +1 -0
- package/dist/components/sparkline.d.ts +93 -0
- package/dist/components/sparkline.js +205 -0
- package/dist/components/sparkline.js.map +1 -0
- package/dist/components/stat-tile.d.ts +151 -0
- package/dist/components/stat-tile.js +236 -0
- package/dist/components/stat-tile.js.map +1 -0
- package/dist/components/swipeable-row.d.ts +25 -2
- package/dist/components/swipeable-row.js +31 -0
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/switch.d.ts +60 -0
- package/dist/components/switch.js +81 -0
- package/dist/components/switch.js.map +1 -0
- package/dist/components/time-input.d.ts +58 -0
- package/dist/components/time-input.js +50 -0
- package/dist/components/time-input.js.map +1 -0
- package/dist/components/toggle-group.d.ts +18 -3
- package/dist/components/toggle-group.js +10 -6
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +115 -0
- package/dist/components/toggle-legend.js +114 -0
- package/dist/components/toggle-legend.js.map +1 -0
- package/dist/components/tooltip.d.ts +40 -11
- package/dist/components/tooltip.js +77 -20
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/treemap.d.ts +132 -0
- package/dist/components/treemap.js +179 -0
- package/dist/components/treemap.js.map +1 -0
- package/dist/components/ui.d.ts +159 -46
- package/dist/components/ui.js +220 -115
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-mobile-reveal.js +1 -0
- package/dist/components/use-mobile-reveal.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/use-table-state.js +38 -17
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/components/user-avatar.d.ts +15 -4
- package/dist/components/user-avatar.js +5 -4
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/components/wizard-stepper.js +3 -2
- package/dist/components/wizard-stepper.js.map +1 -1
- package/dist/{data-table-filters-aXyf0Xub.d.ts → data-table-filters-DM1auUfH.d.ts} +27 -5
- package/dist/data-table.d.ts +7 -0
- package/dist/data-table.js +12 -0
- package/dist/data-table.js.map +1 -0
- package/dist/feedback/feedback-attachment.js +12 -11
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.js +2 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +33 -7
- package/dist/feedback/feedback-inbox.js +49 -37
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback.d.ts +5 -0
- package/dist/feedback.js +5 -0
- package/dist/feedback.js.map +1 -0
- package/dist/hooks/use-anchored-panel.js +1 -0
- package/dist/hooks/use-anchored-panel.js.map +1 -1
- package/dist/hooks/use-anchored-rect.js +1 -0
- package/dist/hooks/use-anchored-rect.js.map +1 -1
- package/dist/hooks/use-announce.d.ts +46 -0
- package/dist/hooks/use-announce.js +37 -0
- package/dist/hooks/use-announce.js.map +1 -0
- package/dist/hooks/use-body-scroll-lock.js +1 -0
- package/dist/hooks/use-body-scroll-lock.js.map +1 -1
- package/dist/hooks/use-close-transition.js +1 -0
- package/dist/hooks/use-close-transition.js.map +1 -1
- package/dist/hooks/use-dismiss.js +1 -0
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/hooks/use-focus-trap.d.ts +16 -0
- package/dist/hooks/use-focus-trap.js +67 -0
- package/dist/hooks/use-focus-trap.js.map +1 -0
- package/dist/hooks/use-media-query.js +1 -0
- package/dist/hooks/use-media-query.js.map +1 -1
- package/dist/hooks/use-overlay-history.js +27 -1
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/hooks/use-row-swipe.js +1 -0
- package/dist/hooks/use-row-swipe.js.map +1 -1
- package/dist/i18n/defaults.d.ts +31 -0
- package/dist/i18n/defaults.js +63 -0
- package/dist/i18n/defaults.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +250 -0
- package/dist/i18n/kit-labels.js +184 -0
- package/dist/i18n/kit-labels.js.map +1 -0
- package/dist/index.d.ts +47 -22
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/calc.js +1 -0
- package/dist/lib/calc.js.map +1 -1
- package/dist/lib/cn.js +1 -0
- package/dist/lib/cn.js.map +1 -1
- package/dist/lib/dates.js +1 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/logger.js +1 -0
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/safe-storage.js +1 -0
- package/dist/lib/safe-storage.js.map +1 -1
- package/dist/search/command-palette.d.ts +11 -1
- package/dist/search/command-palette.js +21 -15
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search.d.ts +2 -0
- package/dist/search.js +3 -0
- package/dist/search.js.map +1 -0
- package/dist/shell/app-shell.d.ts +33 -4
- package/dist/shell/app-shell.js +275 -78
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/option-switcher-menu.js +3 -2
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/role-switcher.d.ts +6 -0
- package/dist/shell/role-switcher.js +7 -4
- package/dist/shell/role-switcher.js.map +1 -1
- package/dist/shell/top-bar.d.ts +15 -7
- package/dist/shell/top-bar.js +3 -5
- package/dist/shell/top-bar.js.map +1 -1
- package/dist/shell/topbar-action-menu.js +3 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +5 -2
- package/dist/shell/topbar-controls.js +20 -15
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/shell.d.ts +13 -0
- package/dist/shell.js +8 -0
- package/dist/shell.js.map +1 -0
- package/dist/theme/chart-palette.d.ts +19 -12
- package/dist/theme/chart-palette.js +24 -30
- package/dist/theme/chart-palette.js.map +1 -1
- package/dist/theme/color.d.ts +78 -0
- package/dist/theme/color.js +148 -0
- package/dist/theme/color.js.map +1 -0
- package/dist/theme/palette-derive.d.ts +140 -0
- package/dist/theme/palette-derive.js +376 -0
- package/dist/theme/palette-derive.js.map +1 -0
- package/dist/theme/palette-presets.d.ts +27 -5
- package/dist/theme/palette-presets.js +231 -33
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/theme/palette-store.js +1 -0
- package/dist/theme/palette-store.js.map +1 -1
- package/dist/theme/theme-store.js +1 -0
- package/dist/theme/theme-store.js.map +1 -1
- package/dist/tour/tour.d.ts +2 -1
- package/dist/tour/tour.js +15 -9
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour.d.ts +2 -0
- package/dist/tour.js +3 -0
- package/dist/tour.js.map +1 -0
- package/dist/wizard/stepper-nav.js +10 -8
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +18 -2
- package/dist/wizard/types.js +4 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +12 -4
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/validation.js +1 -0
- package/dist/wizard/validation.js.map +1 -1
- package/dist/wizard/wizard-context.js +1 -0
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.js +1 -0
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard/wizard-summary.js +5 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +8 -0
- package/dist/wizard.js +9 -0
- package/dist/wizard.js.map +1 -0
- package/package.json +67 -14
- package/src/chart.ts +22 -0
- package/src/components/account-settings.tsx +275 -0
- package/src/components/alert-banner.tsx +75 -0
- package/src/components/amount-input.tsx +471 -0
- package/src/components/calculator.tsx +252 -0
- package/src/components/chart-zoom.tsx +499 -0
- package/src/components/chart.tsx +363 -0
- package/src/components/checkbox.tsx +222 -0
- package/src/components/chip.tsx +570 -0
- package/src/components/choice-parts.ts +33 -0
- package/src/components/combobox-core.tsx +540 -0
- package/src/components/combobox.tsx +1026 -0
- package/src/components/currency-select.tsx +269 -0
- package/src/components/data-table-filter-popover.tsx +252 -0
- package/src/components/data-table-filters.ts +176 -0
- package/src/components/data-table-labels.ts +190 -0
- package/src/components/data-table-pagination.tsx +126 -0
- package/src/components/data-table-sort.ts +70 -0
- package/src/components/data-table.tsx +1740 -0
- package/src/components/date-picker.tsx +631 -0
- package/src/components/dropdown.tsx +341 -0
- package/src/components/entity-combobox.tsx +215 -0
- package/src/components/facing-pair.ts +86 -0
- package/src/components/field-sync.tsx +472 -0
- package/src/components/file-dropzone.tsx +181 -0
- package/src/components/full-bleed-dialog.tsx +179 -0
- package/src/components/grouped-picker.tsx +157 -0
- package/src/components/hover-menu.tsx +183 -0
- package/src/components/mini-calendar.tsx +554 -0
- package/src/components/modal.tsx +287 -0
- package/src/components/month-picker.tsx +549 -0
- package/src/components/multi-entity-combobox.tsx +226 -0
- package/src/components/multi-select.tsx +303 -0
- package/src/components/number-field.tsx +225 -0
- package/src/components/number-input.tsx +211 -0
- package/src/components/numpad-sheet.tsx +264 -0
- package/src/components/page-contents.tsx +289 -0
- package/src/components/password-strength.tsx +258 -0
- package/src/components/picker-sheet.tsx +251 -0
- package/src/components/popover.tsx +195 -0
- package/src/components/search-field.tsx +146 -0
- package/src/components/series-chart-labels.ts +26 -0
- package/src/components/series-chart.tsx +481 -0
- package/src/components/settings-fields.tsx +68 -0
- package/src/components/signature-pad.tsx +630 -0
- package/src/components/slider.tsx +313 -0
- package/src/components/sparkline.tsx +329 -0
- package/src/components/stat-tile.tsx +448 -0
- package/src/components/swipeable-row.tsx +271 -0
- package/src/components/switch.tsx +170 -0
- package/src/components/time-input.tsx +107 -0
- package/src/components/toggle-group.tsx +153 -0
- package/src/components/toggle-legend.tsx +229 -0
- package/src/components/tooltip.tsx +477 -0
- package/src/components/treemap.tsx +390 -0
- package/src/components/ui.tsx +1284 -0
- package/src/components/use-mobile-reveal.ts +54 -0
- package/src/components/use-table-state.ts +295 -0
- package/src/components/user-avatar.tsx +61 -0
- package/src/components/wizard-stepper.tsx +37 -0
- package/src/data-table.ts +19 -0
- package/src/feedback/feedback-attachment.tsx +246 -0
- package/src/feedback/feedback-dialog.tsx +209 -0
- package/src/feedback/feedback-inbox.tsx +615 -0
- package/src/feedback.ts +8 -0
- package/src/hooks/use-anchored-panel.ts +169 -0
- package/src/hooks/use-anchored-rect.ts +78 -0
- package/src/hooks/use-announce.ts +85 -0
- package/src/hooks/use-body-scroll-lock.ts +94 -0
- package/src/hooks/use-close-transition.ts +99 -0
- package/src/hooks/use-dismiss.ts +77 -0
- package/src/hooks/use-focus-trap.ts +160 -0
- package/src/hooks/use-media-query.ts +23 -0
- package/src/hooks/use-overlay-history.ts +345 -0
- package/src/hooks/use-row-swipe.ts +212 -0
- package/src/i18n/defaults.ts +68 -0
- package/src/i18n/kit-labels.tsx +447 -0
- package/src/index.ts +169 -0
- package/src/lib/calc.ts +309 -0
- package/src/lib/cn.ts +5 -0
- package/src/lib/dates.ts +187 -0
- package/src/lib/logger.ts +73 -0
- package/src/lib/safe-storage.ts +37 -0
- package/src/search/command-palette.tsx +342 -0
- package/src/search.ts +3 -0
- package/src/shell/app-shell.tsx +772 -0
- package/src/shell/option-switcher-menu.tsx +72 -0
- package/src/shell/role-switcher.tsx +46 -0
- package/src/shell/top-bar.tsx +39 -0
- package/src/shell/topbar-action-menu.tsx +91 -0
- package/src/shell/topbar-controls.tsx +229 -0
- package/src/shell.ts +10 -0
- package/src/theme/chart-palette.ts +145 -0
- package/src/theme/color.ts +266 -0
- package/src/theme/palette-derive.ts +681 -0
- package/src/theme/palette-presets.ts +760 -0
- package/src/theme/palette-store.ts +94 -0
- package/src/theme/theme-store.ts +145 -0
- package/src/tour/tour.tsx +586 -0
- package/src/tour.ts +3 -0
- package/src/vite-env.d.ts +26 -0
- package/src/wizard/stepper-nav.tsx +221 -0
- package/src/wizard/types.ts +178 -0
- package/src/wizard/use-wizard.ts +345 -0
- package/src/wizard/validation.ts +37 -0
- package/src/wizard/wizard-context.tsx +29 -0
- package/src/wizard/wizard-step.tsx +14 -0
- package/src/wizard/wizard-summary.tsx +69 -0
- package/src/wizard.ts +14 -0
- package/tokens.css +222 -20
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { ReactNode, KeyboardEvent, MouseEvent } from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef, ReactNode, KeyboardEvent, MouseEvent } from 'react';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Backdrop handlers that close only when a press starts AND ends on the
|
|
@@ -12,7 +12,13 @@ declare function useBackdropClose(onClose: () => void): {
|
|
|
12
12
|
onMouseDown: (e: MouseEvent<HTMLDivElement>) => void;
|
|
13
13
|
onMouseUp: (e: MouseEvent<HTMLDivElement>) => void;
|
|
14
14
|
};
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* `extends ComponentPropsWithoutRef<"div">` so anything a `<div>` takes reaches the
|
|
17
|
+
* PANEL — the element with `role="dialog"`, which is the one a consumer means. That is
|
|
18
|
+
* where a `data-tour` anchor, a test id, an `aria-describedby` or an `aria-label` has
|
|
19
|
+
* to land; before this, a closed prop list dropped all four silently (audit §api-design).
|
|
20
|
+
*/
|
|
21
|
+
interface ModalProps extends ComponentPropsWithoutRef<"div"> {
|
|
16
22
|
/** Invoked on backdrop click and on Escape. */
|
|
17
23
|
onClose: () => void;
|
|
18
24
|
children: ReactNode;
|
|
@@ -71,6 +77,6 @@ interface ModalProps {
|
|
|
71
77
|
* into the panel on open and back to the trigger on close, a Tab focus trap, and
|
|
72
78
|
* `role="dialog"`/`aria-modal`.
|
|
73
79
|
*/
|
|
74
|
-
declare function Modal({ onClose, children, size, className, labelledBy, onKeyDown, fullBleed, draggable, }: ModalProps): react.ReactPortal;
|
|
80
|
+
declare function Modal({ onClose, children, size, className, labelledBy, onKeyDown, fullBleed, draggable, style, ...rest }: ModalProps): react.ReactPortal;
|
|
75
81
|
|
|
76
82
|
export { Modal, type ModalProps, useBackdropClose };
|
package/dist/components/modal.js
CHANGED
|
@@ -1,32 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import { jsx } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
+
import { useRef, useState } from "react";
|
|
3
4
|
import { createPortal } from "react-dom";
|
|
4
5
|
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { useFocusTrap } from "../hooks/use-focus-trap.js";
|
|
5
7
|
import { useBodyScrollLock } from "../hooks/use-body-scroll-lock.js";
|
|
6
8
|
import { useOverlayHistory } from "../hooks/use-overlay-history.js";
|
|
7
9
|
import { useCloseTransition } from "../hooks/use-close-transition.js";
|
|
8
10
|
function useDragOffset(enabled) {
|
|
9
11
|
const [offset, setOffset] = useState(null);
|
|
10
12
|
const from = useRef(null);
|
|
11
|
-
const
|
|
12
|
-
const start = from.current;
|
|
13
|
-
if (!start) return;
|
|
14
|
-
setOffset({ x: start.ox + e.clientX - start.x, y: start.oy + e.clientY - start.y });
|
|
15
|
-
};
|
|
16
|
-
const onPointerUp = () => {
|
|
13
|
+
const endDrag = () => {
|
|
17
14
|
from.current = null;
|
|
18
|
-
window.removeEventListener("pointermove", onPointerMove);
|
|
19
|
-
window.removeEventListener("pointerup", onPointerUp);
|
|
20
15
|
};
|
|
21
16
|
return {
|
|
22
17
|
style: offset ? { transform: `translate(${offset.x}px, ${offset.y}px)` } : void 0,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
18
|
+
handlers: !enabled ? void 0 : {
|
|
19
|
+
onPointerDown: (e) => {
|
|
20
|
+
if (e.button !== 0 || window.innerWidth < 768) return;
|
|
21
|
+
const target = e.target;
|
|
22
|
+
if (target.closest("input,textarea,select,button,a,[role='button']")) return;
|
|
23
|
+
from.current = { x: e.clientX, y: e.clientY, ox: offset?.x ?? 0, oy: offset?.y ?? 0 };
|
|
24
|
+
try {
|
|
25
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
26
|
+
} catch {
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
onPointerMove: (e) => {
|
|
30
|
+
const start = from.current;
|
|
31
|
+
if (!start) return;
|
|
32
|
+
setOffset({ x: start.ox + e.clientX - start.x, y: start.oy + e.clientY - start.y });
|
|
33
|
+
},
|
|
34
|
+
onPointerUp: endDrag,
|
|
35
|
+
// A cancel (the browser taking the pointer for a scroll or a gesture) and a
|
|
36
|
+
// lost capture both mean no `pointerup` is coming. Without them the drag would
|
|
37
|
+
// stay "live" and the next move over the panel would jump it.
|
|
38
|
+
onPointerCancel: endDrag,
|
|
39
|
+
onLostPointerCapture: endDrag
|
|
30
40
|
}
|
|
31
41
|
};
|
|
32
42
|
}
|
|
@@ -43,7 +53,6 @@ function useBackdropClose(onClose) {
|
|
|
43
53
|
}
|
|
44
54
|
};
|
|
45
55
|
}
|
|
46
|
-
const FOCUSABLE = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
47
56
|
function Modal({
|
|
48
57
|
onClose,
|
|
49
58
|
children,
|
|
@@ -52,7 +61,9 @@ function Modal({
|
|
|
52
61
|
labelledBy,
|
|
53
62
|
onKeyDown,
|
|
54
63
|
fullBleed,
|
|
55
|
-
draggable
|
|
64
|
+
draggable,
|
|
65
|
+
style,
|
|
66
|
+
...rest
|
|
56
67
|
}) {
|
|
57
68
|
const panelRef = useRef(null);
|
|
58
69
|
const drag = useDragOffset(Boolean(draggable));
|
|
@@ -60,13 +71,7 @@ function Modal({
|
|
|
60
71
|
const backdropClose = useBackdropClose(requestClose);
|
|
61
72
|
useOverlayHistory(true, requestClose);
|
|
62
73
|
useBodyScrollLock(true);
|
|
63
|
-
|
|
64
|
-
const previouslyFocused = document.activeElement;
|
|
65
|
-
panelRef.current?.focus();
|
|
66
|
-
return () => {
|
|
67
|
-
previouslyFocused?.focus?.();
|
|
68
|
-
};
|
|
69
|
-
}, []);
|
|
74
|
+
useFocusTrap(panelRef, { active: true, initialFocus: "container" });
|
|
70
75
|
const handleKeyDown = (e) => {
|
|
71
76
|
onKeyDown?.(e);
|
|
72
77
|
if (e.defaultPrevented) return;
|
|
@@ -75,23 +80,6 @@ function Modal({
|
|
|
75
80
|
requestClose();
|
|
76
81
|
return;
|
|
77
82
|
}
|
|
78
|
-
if (e.key !== "Tab") return;
|
|
79
|
-
const focusables = panelRef.current?.querySelectorAll(FOCUSABLE);
|
|
80
|
-
if (!focusables || focusables.length === 0) {
|
|
81
|
-
e.preventDefault();
|
|
82
|
-
panelRef.current?.focus();
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
const first = focusables[0];
|
|
86
|
-
const last = focusables[focusables.length - 1];
|
|
87
|
-
const active = document.activeElement;
|
|
88
|
-
if (e.shiftKey && (active === first || active === panelRef.current)) {
|
|
89
|
-
e.preventDefault();
|
|
90
|
-
last.focus();
|
|
91
|
-
} else if (!e.shiftKey && active === last) {
|
|
92
|
-
e.preventDefault();
|
|
93
|
-
first.focus();
|
|
94
|
-
}
|
|
95
83
|
};
|
|
96
84
|
return createPortal(
|
|
97
85
|
/* @__PURE__ */ jsx(
|
|
@@ -109,17 +97,18 @@ function Modal({
|
|
|
109
97
|
children: /* @__PURE__ */ jsx(
|
|
110
98
|
"div",
|
|
111
99
|
{
|
|
100
|
+
...rest,
|
|
112
101
|
ref: panelRef,
|
|
113
102
|
role: "dialog",
|
|
114
103
|
"aria-modal": "true",
|
|
115
104
|
"aria-labelledby": labelledBy,
|
|
116
105
|
tabIndex: -1,
|
|
117
106
|
onKeyDown: handleKeyDown,
|
|
118
|
-
|
|
119
|
-
style: drag.style,
|
|
107
|
+
...drag.handlers,
|
|
108
|
+
style: { ...style, ...drag.style },
|
|
120
109
|
className: cn(
|
|
121
110
|
closing ? "animate-sheet-out md:animate-none" : "animate-sheet md:animate-none",
|
|
122
|
-
"w-full rounded-lg border border-
|
|
111
|
+
"w-full rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm outline-none",
|
|
123
112
|
{ md: "max-w-md", lg: "max-w-lg", xl: "max-w-3xl" }[size],
|
|
124
113
|
// A panel taller than the screen has to scroll ITSELF. The backdrop is
|
|
125
114
|
// `fixed inset-0` and the body is scroll-locked while a dialog is open, so
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/modal.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type {\n KeyboardEvent,\n MouseEvent as ReactMouseEvent,\n PointerEvent as ReactPointerEvent,\n ReactNode,\n} from \"react\";\nimport { cn } from \"../lib/cn\";\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 * Drag the panel by its top strip, in pointer space (dev#460).\n *\n * Deliberately not a library and not persisted: the offset is component state that\n * dies with the dialog. It starts only on a press that lands on the panel's own\n * chrome — never on a field, a button or a link, which is what keeps \"drag to select\n * text in the textarea\" working. Above `md` only: below it the panel is a\n * full-width bottom sheet with nothing beside it to uncover.\n */\nfunction useDragOffset(enabled: boolean) {\n const [offset, setOffset] = useState<{ x: number; y: number } | null>(null);\n const from = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);\n\n const onPointerMove = (e: PointerEvent) => {\n const start = from.current;\n if (!start) return;\n setOffset({ x: start.ox + e.clientX - start.x, y: start.oy + e.clientY - start.y });\n };\n const onPointerUp = () => {\n from.current = null;\n window.removeEventListener(\"pointermove\", onPointerMove);\n window.removeEventListener(\"pointerup\", onPointerUp);\n };\n\n return {\n style: offset ? { transform: `translate(${offset.x}px, ${offset.y}px)` } : undefined,\n onPointerDown: !enabled\n ? undefined\n : (e: ReactPointerEvent<HTMLDivElement>) => {\n if (e.button !== 0 || window.innerWidth < 768) return;\n const target = e.target as HTMLElement;\n // Only the panel itself and its non-interactive chrome start a drag; a\n // press inside any control belongs to that control.\n if (target.closest(\"input,textarea,select,button,a,[role='button']\")) return;\n from.current = { x: e.clientX, y: e.clientY, ox: offset?.x ?? 0, oy: offset?.y ?? 0 };\n window.addEventListener(\"pointermove\", onPointerMove);\n window.addEventListener(\"pointerup\", onPointerUp);\n },\n };\n}\n\n/**\n * Backdrop handlers that close only when a press starts AND ends on the\n * backdrop itself. A plain onClick fires on mouseup, so dragging a text\n * selection that starts inside the panel and releases over the backdrop would\n * dismiss the dialog (feedback #254) — and the reverse drag would too, since\n * click targets the common ancestor of the down/up elements.\n */\nexport function useBackdropClose(onClose: () => void) {\n const pressStartedOnBackdrop = useRef(false);\n return {\n onMouseDown: (e: ReactMouseEvent<HTMLDivElement>) => {\n pressStartedOnBackdrop.current = e.target === e.currentTarget;\n },\n onMouseUp: (e: ReactMouseEvent<HTMLDivElement>) => {\n const started = pressStartedOnBackdrop.current;\n pressStartedOnBackdrop.current = false;\n if (started && e.target === e.currentTarget) onClose();\n },\n };\n}\n\n// Tab-cycle target set: the interactive descendants the focus trap rotates\n// through. Querying live (per keystroke) means dynamically rendered fields are\n// included automatically.\nconst FOCUSABLE =\n 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\nexport interface ModalProps {\n /** Invoked on backdrop click and on Escape. */\n onClose: () => void;\n children: ReactNode;\n /**\n * Panel max-width: `md` (28rem), `lg` (32rem), `xl` (48rem).\n *\n * `xl` exists because two callers had already written `className=\"max-w-3xl\"` to\n * reach it — the payee backfill's preview table and the price-merge dialog, both\n * of which hold a list and several fields rather than one question. A caller\n * patching a missing size IS the signal that the size is missing, and a\n * hand-written width in a `className` is a width nothing else can line up with.\n *\n * The panel is `w-full` under every one of them, so the cap only bites once the\n * viewport is wider than it: on a phone all three are the same full-width bottom\n * sheet. Nothing here may introduce a `min-w-*`, which is what would break that.\n */\n size?: \"md\" | \"lg\" | \"xl\";\n /** Extra classes for the panel (e.g. `space-y-3` for form content spacing). */\n className?: string;\n /** id of the heading element, wired to `aria-labelledby`. */\n labelledBy?: string;\n /**\n * Extra key handling on the panel (e.g. Ctrl/Cmd+Enter to submit). Runs first;\n * if it calls `preventDefault()` the built-in Escape/Tab handling is skipped\n * for that event.\n */\n onKeyDown?: (e: KeyboardEvent<HTMLDivElement>) => void;\n /**\n * Drop the backdrop's mobile margin so the panel can reach the screen edges\n * (a full-screen sheet on phones). The panel still needs its own\n * `h-[100dvh] max-w-full rounded-none` classes via `className` to fill it; the\n * desktop `md:p-4` margin is kept. Defaults to false (padded on all sizes).\n */\n fullBleed?: boolean;\n /**\n * Let the user drag the panel out of the way by its top edge (Keksdose feedback\n * dev#460: *\"Make the feedback dialog draggable so I can see behind it if it\n * blocks something.\"*).\n *\n * Opt-in, because it only makes sense for a dialog whose content is ABOUT the page\n * behind it — the feedback composer is the case, an ordinary form is not, and a\n * draggable panel that has nothing to reveal is a control that can only get lost.\n *\n * Pointer-only by design: it is a \"move this out of the way\" gesture, not a layout\n * choice worth persisting, and the position resets when the dialog closes. On a\n * phone the panel is a bottom sheet the width of the screen, so dragging is\n * suppressed there — there is nothing beside it to see.\n */\n draggable?: boolean;\n}\n\n/**\n * Centered (bottom-sheet on mobile) modal dialog: dimmed backdrop, single\n * rounded panel. Owns the overlay mechanics that the hand-rolled dialogs each\n * re-implemented or lacked — backdrop-click + Escape to close, the platform Back\n * gesture (Keksdose feedback #172), background scroll lock (feedback #204), focus\n * into the panel on open and back to the trigger on close, a Tab focus trap, and\n * `role=\"dialog\"`/`aria-modal`.\n */\nexport function Modal({\n onClose,\n children,\n size = \"md\",\n className,\n labelledBy,\n onKeyDown,\n fullBleed,\n draggable,\n}: ModalProps) {\n const panelRef = useRef<HTMLDivElement>(null);\n const drag = useDragOffset(Boolean(draggable));\n // The panel lowers itself before the caller unmounts it (live #320 rework). Every\n // dismissal below goes through `requestClose`; `onClose` still does the closing, one\n // animation later. A caller that closes the dialog ITSELF — after a save, say —\n // unmounts with no exit, which is the documented limit of this.\n const { closing, requestClose } = useCloseTransition(onClose);\n const backdropClose = useBackdropClose(requestClose);\n // Back means the same as Escape here. On a phone Escape doesn't exist, so without\n // this the only way out of a dialog is finding its close button — and Back, the\n // gesture everyone reaches for, navigated the page underneath instead (#172).\n // Mounted only while open, hence the constant `true`.\n useOverlayHistory(true, requestClose);\n\n // Mounted only while open, hence the constant `true`. Through the shared hook and\n // not by hand: this component's own save/restore copy was one half of the pair that\n // left the page permanently unscrollable when a dialog containing an open sheet was\n // closed — see the note in use-body-scroll-lock.ts.\n useBodyScrollLock(true);\n\n useEffect(() => {\n const previouslyFocused = document.activeElement as HTMLElement | null;\n // Focus the panel itself (not the first field) so opening doesn't pop the\n // mobile keyboard, while still moving focus into the dialog for keyboard and\n // screen-reader users.\n panelRef.current?.focus();\n return () => {\n previouslyFocused?.focus?.();\n };\n }, []);\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n onKeyDown?.(e);\n if (e.defaultPrevented) return;\n if (e.key === \"Escape\") {\n e.stopPropagation();\n requestClose();\n return;\n }\n if (e.key !== \"Tab\") return;\n // Keep Tab within the dialog. Elements inside a portal'd panel (e.g. an open\n // dropdown) live outside this subtree, so this handler never fires for them\n // — they manage their own focus.\n const focusables = panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE);\n if (!focusables || focusables.length === 0) {\n e.preventDefault();\n panelRef.current?.focus();\n return;\n }\n const first = focusables[0];\n const last = focusables[focusables.length - 1];\n const active = document.activeElement;\n if (e.shiftKey && (active === first || active === panelRef.current)) {\n e.preventDefault();\n last.focus();\n } else if (!e.shiftKey && active === last) {\n e.preventDefault();\n first.focus();\n }\n };\n\n // Portal to <body> so the `fixed inset-0 z-50` overlay escapes whatever stacking\n // context its caller sits in. Rendered inline, a modal opened from e.g. the top\n // bar (a z-30 context) could paint BELOW a sibling z-30 sticky element like the\n // expanded transaction editor (feedback #320). The tour/command-palette overlays\n // already portal for the same reason.\n return createPortal(\n <div\n className={cn(\n // Live #320. The panel below rises only where it is bottom-anchored: from md up\n // it is centred, and a centred box sliding up from off-screen reads as a\n // different component arriving rather than as the same one settling.\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n \"fixed inset-0 z-50 flex items-end justify-center bg-black/40 md:items-center\",\n fullBleed ? \"p-0 md:p-4\" : \"p-4\",\n )}\n {...backdropClose}\n >\n <div\n ref={panelRef}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={labelledBy}\n tabIndex={-1}\n onKeyDown={handleKeyDown}\n onPointerDown={drag.onPointerDown}\n style={drag.style}\n className={cn(\n closing ? \"animate-sheet-out md:animate-none\" : \"animate-sheet md:animate-none\",\n \"w-full rounded-lg border border-slate-200 bg-white shadow-sm outline-none dark:border-slate-800 dark:bg-slate-900\",\n { md: \"max-w-md\", lg: \"max-w-lg\", xl: \"max-w-3xl\" }[size],\n // A panel taller than the screen has to scroll ITSELF. The backdrop is\n // `fixed inset-0` and the body is scroll-locked while a dialog is open, so\n // without this the overflow simply had nowhere to go: on a phone the panel\n // is bottom-anchored, so it grew UPWARD past the top edge and the part that\n // went past it could not be reached by any gesture (Keksdose live #254, on\n // the price-merge dialog — but it was every dialog with more than a screen\n // of content). `max-h-full` resolves against the backdrop's content box,\n // which is the viewport minus its own padding, so the panel stops exactly\n // where the screen does; `overscroll-contain` keeps the fling inside it.\n //\n // Three callers had already patched themselves with `max-h-[90vh]` +\n // an inner scroller. Those still win — `cn` is tailwind-merge — and this is\n // the default they should not have needed.\n \"max-h-full overflow-y-auto overscroll-contain\",\n \"p-4\",\n // The grab affordance sits on the panel's own top strip: a drag handle of\n // its own would be one more control in a dialog that is mostly one field.\n draggable && \"md:[&>*:first-child]:cursor-grab\",\n className,\n )}\n >\n {children}\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/modal.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type {\n ComponentPropsWithoutRef,\n KeyboardEvent,\n MouseEvent as ReactMouseEvent,\n PointerEvent as ReactPointerEvent,\n ReactNode,\n} from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\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 * Drag the panel by its top strip, in pointer space (dev#460).\n *\n * Deliberately not a library and not persisted: the offset is component state that\n * dies with the dialog. It starts only on a press that lands on the panel's own\n * chrome — never on a field, a button or a link, which is what keeps \"drag to select\n * text in the textarea\" working. Above `md` only: below it the panel is a\n * full-width bottom sheet with nothing beside it to uncover.\n *\n * The whole gesture stays on the PANEL — captured pointer, React handlers — rather than\n * on `window`. The first shape of this added `pointermove`/`pointerup` to `window` from\n * inside the `pointerdown` handler and took them off again from the `pointerup` handler,\n * so the teardown ran only if the gesture ended: React never sees a listener added from\n * an event handler, and an unmount mid-drag left a live `pointermove` on `window` for\n * the life of the page, pinning this hook's state with it. The one caller that enables\n * dragging is the feedback composer, which closes ITSELF on submit — a Ctrl+Enter sent\n * with the pointer still down is exactly that order of events, once per drag.\n */\nfunction useDragOffset(enabled: boolean) {\n const [offset, setOffset] = useState<{ x: number; y: number } | null>(null);\n const from = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);\n\n const endDrag = () => {\n from.current = null;\n };\n\n return {\n style: offset ? { transform: `translate(${offset.x}px, ${offset.y}px)` } : undefined,\n handlers: !enabled\n ? undefined\n : {\n onPointerDown: (e: ReactPointerEvent<HTMLDivElement>) => {\n if (e.button !== 0 || window.innerWidth < 768) return;\n const target = e.target as HTMLElement;\n // Only the panel itself and its non-interactive chrome start a drag; a\n // press inside any control belongs to that control.\n if (target.closest(\"input,textarea,select,button,a,[role='button']\")) return;\n from.current = { x: e.clientX, y: e.clientY, ox: offset?.x ?? 0, oy: offset?.y ?? 0 };\n try {\n // Capture is what lets the handlers live on the panel: every later\n // pointer event for this pointer retargets here, so the drag keeps\n // working once the pointer outruns the panel's own box — which it always\n // does, since the panel moves out from under it.\n e.currentTarget.setPointerCapture(e.pointerId);\n } catch {\n // jsdom implements no pointer capture, and a browser throws\n // NotFoundError for a pointerId that is already gone. Neither is worth\n // refusing the drag: uncaptured, the panel still gets the moves that\n // land on it.\n }\n },\n onPointerMove: (e: ReactPointerEvent<HTMLDivElement>) => {\n const start = from.current;\n if (!start) return;\n setOffset({ x: start.ox + e.clientX - start.x, y: start.oy + e.clientY - start.y });\n },\n onPointerUp: endDrag,\n // A cancel (the browser taking the pointer for a scroll or a gesture) and a\n // lost capture both mean no `pointerup` is coming. Without them the drag would\n // stay \"live\" and the next move over the panel would jump it.\n onPointerCancel: endDrag,\n onLostPointerCapture: endDrag,\n },\n };\n}\n\n/**\n * Backdrop handlers that close only when a press starts AND ends on the\n * backdrop itself. A plain onClick fires on mouseup, so dragging a text\n * selection that starts inside the panel and releases over the backdrop would\n * dismiss the dialog (feedback #254) — and the reverse drag would too, since\n * click targets the common ancestor of the down/up elements.\n */\nexport function useBackdropClose(onClose: () => void) {\n const pressStartedOnBackdrop = useRef(false);\n return {\n onMouseDown: (e: ReactMouseEvent<HTMLDivElement>) => {\n pressStartedOnBackdrop.current = e.target === e.currentTarget;\n },\n onMouseUp: (e: ReactMouseEvent<HTMLDivElement>) => {\n const started = pressStartedOnBackdrop.current;\n pressStartedOnBackdrop.current = false;\n if (started && e.target === e.currentTarget) onClose();\n },\n };\n}\n\n// Tab-cycle target set: the interactive descendants the focus trap rotates\n// through. Querying live (per keystroke) means dynamically rendered fields are\n// included automatically.\n/**\n * `extends ComponentPropsWithoutRef<\"div\">` so anything a `<div>` takes reaches the\n * PANEL — the element with `role=\"dialog\"`, which is the one a consumer means. That is\n * where a `data-tour` anchor, a test id, an `aria-describedby` or an `aria-label` has\n * to land; before this, a closed prop list dropped all four silently (audit §api-design).\n */\nexport interface ModalProps extends ComponentPropsWithoutRef<\"div\"> {\n /** Invoked on backdrop click and on Escape. */\n onClose: () => void;\n children: ReactNode;\n /**\n * Panel max-width: `md` (28rem), `lg` (32rem), `xl` (48rem).\n *\n * `xl` exists because two callers had already written `className=\"max-w-3xl\"` to\n * reach it — the payee backfill's preview table and the price-merge dialog, both\n * of which hold a list and several fields rather than one question. A caller\n * patching a missing size IS the signal that the size is missing, and a\n * hand-written width in a `className` is a width nothing else can line up with.\n *\n * The panel is `w-full` under every one of them, so the cap only bites once the\n * viewport is wider than it: on a phone all three are the same full-width bottom\n * sheet. Nothing here may introduce a `min-w-*`, which is what would break that.\n */\n size?: \"md\" | \"lg\" | \"xl\";\n /** Extra classes for the panel (e.g. `space-y-3` for form content spacing). */\n className?: string;\n /** id of the heading element, wired to `aria-labelledby`. */\n labelledBy?: string;\n /**\n * Extra key handling on the panel (e.g. Ctrl/Cmd+Enter to submit). Runs first;\n * if it calls `preventDefault()` the built-in Escape/Tab handling is skipped\n * for that event.\n */\n onKeyDown?: (e: KeyboardEvent<HTMLDivElement>) => void;\n /**\n * Drop the backdrop's mobile margin so the panel can reach the screen edges\n * (a full-screen sheet on phones). The panel still needs its own\n * `h-[100dvh] max-w-full rounded-none` classes via `className` to fill it; the\n * desktop `md:p-4` margin is kept. Defaults to false (padded on all sizes).\n */\n fullBleed?: boolean;\n /**\n * Let the user drag the panel out of the way by its top edge (Keksdose feedback\n * dev#460: *\"Make the feedback dialog draggable so I can see behind it if it\n * blocks something.\"*).\n *\n * Opt-in, because it only makes sense for a dialog whose content is ABOUT the page\n * behind it — the feedback composer is the case, an ordinary form is not, and a\n * draggable panel that has nothing to reveal is a control that can only get lost.\n *\n * Pointer-only by design: it is a \"move this out of the way\" gesture, not a layout\n * choice worth persisting, and the position resets when the dialog closes. On a\n * phone the panel is a bottom sheet the width of the screen, so dragging is\n * suppressed there — there is nothing beside it to see.\n */\n draggable?: boolean;\n}\n\n/**\n * Centered (bottom-sheet on mobile) modal dialog: dimmed backdrop, single\n * rounded panel. Owns the overlay mechanics that the hand-rolled dialogs each\n * re-implemented or lacked — backdrop-click + Escape to close, the platform Back\n * gesture (Keksdose feedback #172), background scroll lock (feedback #204), focus\n * into the panel on open and back to the trigger on close, a Tab focus trap, and\n * `role=\"dialog\"`/`aria-modal`.\n */\nexport function Modal({\n onClose,\n children,\n size = \"md\",\n className,\n labelledBy,\n onKeyDown,\n fullBleed,\n draggable,\n style,\n ...rest\n}: ModalProps) {\n const panelRef = useRef<HTMLDivElement>(null);\n const drag = useDragOffset(Boolean(draggable));\n // The panel lowers itself before the caller unmounts it (live #320 rework). Every\n // dismissal below goes through `requestClose`; `onClose` still does the closing, one\n // animation later. A caller that closes the dialog ITSELF — after a save, say —\n // unmounts with no exit, which is the documented limit of this.\n const { closing, requestClose } = useCloseTransition(onClose);\n const backdropClose = useBackdropClose(requestClose);\n // Back means the same as Escape here. On a phone Escape doesn't exist, so without\n // this the only way out of a dialog is finding its close button — and Back, the\n // gesture everyone reaches for, navigated the page underneath instead (#172).\n // Mounted only while open, hence the constant `true`.\n useOverlayHistory(true, requestClose);\n\n // Mounted only while open, hence the constant `true`. Through the shared hook and\n // not by hand: this component's own save/restore copy was one half of the pair that\n // left the page permanently unscrollable when a dialog containing an open sheet was\n // closed — see the note in use-body-scroll-lock.ts.\n useBodyScrollLock(true);\n\n // Focus the panel itself (not the first field) so opening doesn't pop the mobile\n // keyboard, while still moving focus into the dialog for keyboard and screen-reader\n // users. Through the shared hook since this component is where it came from — the\n // hook adds per-keystroke recomputation, a guard on restoring to a detached node,\n // and nesting, none of which this copy had.\n useFocusTrap(panelRef, { active: true, initialFocus: \"container\" });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n onKeyDown?.(e);\n if (e.defaultPrevented) return;\n if (e.key === \"Escape\") {\n e.stopPropagation();\n requestClose();\n return;\n }\n // Tab containment lives in useFocusTrap, which listens on the panel itself.\n };\n\n // Portal to <body> so the `fixed inset-0 z-50` overlay escapes whatever stacking\n // context its caller sits in. Rendered inline, a modal opened from e.g. the top\n // bar (a z-30 context) could paint BELOW a sibling z-30 sticky element like the\n // expanded transaction editor (feedback #320). The tour/command-palette overlays\n // already portal for the same reason.\n return createPortal(\n <div\n className={cn(\n // Live #320. The panel below rises only where it is bottom-anchored: from md up\n // it is centred, and a centred box sliding up from off-screen reads as a\n // different component arriving rather than as the same one settling.\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n \"fixed inset-0 z-50 flex items-end justify-center bg-black/40 md:items-center\",\n fullBleed ? \"p-0 md:p-4\" : \"p-4\",\n )}\n {...backdropClose}\n >\n <div\n // `...rest` FIRST, everything the dialog needs to be a dialog after it. A\n // wrapper that spreads its own props through to this one cannot then take away\n // the role, the modality or the `tabIndex={-1}` that `useFocusTrap` needs —\n // losing any of the three is silent, and the symptom (a dialog a screen reader\n // walks straight out of) shows up nowhere near the call site that caused it.\n {...rest}\n ref={panelRef}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={labelledBy}\n tabIndex={-1}\n onKeyDown={handleKeyDown}\n {...drag.handlers}\n // MERGED, not replaced: `drag.style` is undefined until a drag starts, so\n // assigning it outright would drop a caller's own `style` on every render but\n // the ones where the panel is being dragged.\n style={{ ...style, ...drag.style }}\n className={cn(\n closing ? \"animate-sheet-out md:animate-none\" : \"animate-sheet md:animate-none\",\n \"w-full rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm outline-none\",\n { md: \"max-w-md\", lg: \"max-w-lg\", xl: \"max-w-3xl\" }[size],\n // A panel taller than the screen has to scroll ITSELF. The backdrop is\n // `fixed inset-0` and the body is scroll-locked while a dialog is open, so\n // without this the overflow simply had nowhere to go: on a phone the panel\n // is bottom-anchored, so it grew UPWARD past the top edge and the part that\n // went past it could not be reached by any gesture (Keksdose live #254, on\n // the price-merge dialog — but it was every dialog with more than a screen\n // of content). `max-h-full` resolves against the backdrop's content box,\n // which is the viewport minus its own padding, so the panel stops exactly\n // where the screen does; `overscroll-contain` keeps the fling inside it.\n //\n // Three callers had already patched themselves with `max-h-[90vh]` +\n // an inner scroller. Those still win — `cn` is tailwind-merge — and this is\n // the default they should not have needed.\n \"max-h-full overflow-y-auto overscroll-contain\",\n \"p-4\",\n // The grab affordance sits on the panel's own top strip: a drag handle of\n // its own would be one more control in a dialog that is mostly one field.\n draggable && \"md:[&>*:first-child]:cursor-grab\",\n className,\n )}\n >\n {children}\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every string the month picker can speak. The month and year names are NOT here —
|
|
6
|
+
* they come from `Intl` in the `locale` prop, so they are already in the user's
|
|
7
|
+
* language. What is left is the two icon-only year arrows, whose `aria-label` is the
|
|
8
|
+
* only name they have, the panel's name, and a month cell's name.
|
|
9
|
+
*/
|
|
10
|
+
interface MonthPickerLabels {
|
|
11
|
+
previousYear: string;
|
|
12
|
+
nextYear: string;
|
|
13
|
+
/** The popover's accessible name (`role="dialog"`). */
|
|
14
|
+
panel: string;
|
|
15
|
+
/**
|
|
16
|
+
* Accessible name of one month cell, given that month and its year formatted in
|
|
17
|
+
* `locale` ("August 2026"). The cell's visible text is the short month alone, and
|
|
18
|
+
* "Aug" read out on its own does not say which year the grid is on.
|
|
19
|
+
*/
|
|
20
|
+
month: (monthYear: string) => string;
|
|
21
|
+
}
|
|
22
|
+
/** The English starting point every override is merged onto. Exported (unlike the
|
|
23
|
+
* calendar's) because it is asked for by name: a host building its own translation
|
|
24
|
+
* table can spread it rather than re-typing four keys to change one. */
|
|
25
|
+
declare const DEFAULT_MONTH_PICKER_LABELS: MonthPickerLabels;
|
|
26
|
+
/**
|
|
27
|
+
* `onChange` is omitted from the `<div>` attributes: the DOM event of that name carries
|
|
28
|
+
* a `FormEvent`, and this one answers with a month key. Everything else reaches the
|
|
29
|
+
* field's root, so a test id or a `data-tour` anchor can find it.
|
|
30
|
+
*/
|
|
31
|
+
interface MonthPickerProps extends Omit<ComponentPropsWithoutRef<"div">, "onChange"> {
|
|
32
|
+
/**
|
|
33
|
+
* The selected month as a `"YYYY-MM"` key, or `""` for none.
|
|
34
|
+
*
|
|
35
|
+
* A string rather than `{ year, month }` or a `Date`, for the reason every date in
|
|
36
|
+
* this kit is one: it is what `monthKey()` in `lib/dates` produces, it sorts
|
|
37
|
+
* chronologically so the bounds are plain `<` / `>`, and it crosses a URL or an API
|
|
38
|
+
* without a serialiser. A `{ year, month }` caller converts with
|
|
39
|
+
* `` `${year}-${pad(month)}` `` (month 1–12) and back with `key.split("-").map(Number)`.
|
|
40
|
+
*/
|
|
41
|
+
value: string;
|
|
42
|
+
/** Called with the picked month as `"YYYY-MM"`. */
|
|
43
|
+
onChange: (month: string) => void;
|
|
44
|
+
/** Drives the month and year names (`Intl.DateTimeFormat`). Defaults to the
|
|
45
|
+
* `<UiKitProvider>` locale, then the runtime's. */
|
|
46
|
+
locale?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Inclusive bounds, as `"YYYY-MM"`. A full ISO date (`"YYYY-MM-DD"`) is accepted and
|
|
49
|
+
* read as its month, so a host holding "the first day we have data for" can pass it
|
|
50
|
+
* straight in. Months outside are shown but refuse selection; a year arrow is
|
|
51
|
+
* disabled once the whole year beyond it is out of bounds.
|
|
52
|
+
*/
|
|
53
|
+
min?: string;
|
|
54
|
+
max?: string;
|
|
55
|
+
/** Embedded top-boundary label, matching the other field-shaped controls. */
|
|
56
|
+
label?: ReactNode;
|
|
57
|
+
/** Trigger text when `value` is `""`. */
|
|
58
|
+
placeholder?: string;
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
/** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
|
|
61
|
+
invalid?: boolean;
|
|
62
|
+
/** Intl options for the trigger's text (default: long month + numeric year). */
|
|
63
|
+
formatOptions?: Intl.DateTimeFormatOptions;
|
|
64
|
+
/**
|
|
65
|
+
* The month to ring as "now", as `"YYYY-MM"`. Defaults to the local calendar's
|
|
66
|
+
* current month; pass one to pin it (a test, a host with its own notion of today).
|
|
67
|
+
*/
|
|
68
|
+
currentMonth?: string;
|
|
69
|
+
/** User-facing strings; see {@link MonthPickerLabels}. */
|
|
70
|
+
labels?: Partial<MonthPickerLabels>;
|
|
71
|
+
/** Extra classes for the field's root. */
|
|
72
|
+
className?: string;
|
|
73
|
+
/** Extra classes for the trigger button — e.g. a compact `h-9 w-auto` trigger
|
|
74
|
+
* sitting between two icon buttons in a toolbar. */
|
|
75
|
+
triggerClassName?: string;
|
|
76
|
+
}
|
|
77
|
+
declare function MonthPicker({ value, onChange, locale: localeProp, min, max, label, placeholder, disabled, invalid, formatOptions, currentMonth, labels: labelsProp, className, triggerClassName, ...rest }: MonthPickerProps): react.JSX.Element;
|
|
78
|
+
|
|
79
|
+
export { DEFAULT_MONTH_PICKER_LABELS, MonthPicker, type MonthPickerLabels, type MonthPickerProps };
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { Calendar, ChevronLeft, ChevronRight } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { monthKey, pad } from "../lib/dates.js";
|
|
7
|
+
import { FieldLabel, FIELD_FLOATING_PAD, FIELD_INVALID, FIELD_TRIGGER } from "./ui.js";
|
|
8
|
+
import { Popover } from "./popover.js";
|
|
9
|
+
import { useKitLabels, useKitLocale } from "../i18n/kit-labels.js";
|
|
10
|
+
const DEFAULT_MONTH_PICKER_LABELS = {
|
|
11
|
+
previousYear: "Previous year",
|
|
12
|
+
nextYear: "Next year",
|
|
13
|
+
panel: "Choose a month",
|
|
14
|
+
month: (monthYear) => monthYear
|
|
15
|
+
};
|
|
16
|
+
function useMonthPickerLabels(partial) {
|
|
17
|
+
return useKitLabels("monthPicker", DEFAULT_MONTH_PICKER_LABELS, partial);
|
|
18
|
+
}
|
|
19
|
+
const KEY = /^(\d{4})-(\d{2})/;
|
|
20
|
+
function parseMonth(key) {
|
|
21
|
+
const m = key ? KEY.exec(key) : null;
|
|
22
|
+
if (!m) return null;
|
|
23
|
+
const month = Number(m[2]);
|
|
24
|
+
return month >= 1 && month <= 12 ? { year: Number(m[1]), month } : null;
|
|
25
|
+
}
|
|
26
|
+
function toKey({ year, month }) {
|
|
27
|
+
return `${year}-${pad(month)}`;
|
|
28
|
+
}
|
|
29
|
+
function shiftMonth(ym, delta) {
|
|
30
|
+
const index = ym.year * 12 + (ym.month - 1) + delta;
|
|
31
|
+
return { year: Math.floor(index / 12), month: index % 12 + 1 };
|
|
32
|
+
}
|
|
33
|
+
function boundKey(bound) {
|
|
34
|
+
const ym = parseMonth(bound);
|
|
35
|
+
return ym ? toKey(ym) : null;
|
|
36
|
+
}
|
|
37
|
+
function firstOf({ year, month }) {
|
|
38
|
+
return new Date(year, month - 1, 1);
|
|
39
|
+
}
|
|
40
|
+
const DEFAULT_TRIGGER_FORMAT = { month: "long", year: "numeric" };
|
|
41
|
+
const MONTH_NAME_FORMAT = { month: "long", year: "numeric" };
|
|
42
|
+
function MonthFieldTrigger({
|
|
43
|
+
open,
|
|
44
|
+
toggle,
|
|
45
|
+
triggerRef,
|
|
46
|
+
triggerText,
|
|
47
|
+
hasValue,
|
|
48
|
+
labelledBy,
|
|
49
|
+
valueId,
|
|
50
|
+
panelId,
|
|
51
|
+
padded,
|
|
52
|
+
disabled,
|
|
53
|
+
invalid,
|
|
54
|
+
className
|
|
55
|
+
}) {
|
|
56
|
+
const wasOpen = useRef(false);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const justClosed = wasOpen.current && !open;
|
|
59
|
+
wasOpen.current = open;
|
|
60
|
+
if (!justClosed) return;
|
|
61
|
+
const active = document.activeElement;
|
|
62
|
+
if (!active || active === document.body) triggerRef.current?.focus();
|
|
63
|
+
}, [open, triggerRef]);
|
|
64
|
+
return /* @__PURE__ */ jsx(
|
|
65
|
+
"button",
|
|
66
|
+
{
|
|
67
|
+
ref: triggerRef,
|
|
68
|
+
type: "button",
|
|
69
|
+
disabled,
|
|
70
|
+
onClick: toggle,
|
|
71
|
+
role: "combobox",
|
|
72
|
+
"aria-haspopup": "dialog",
|
|
73
|
+
"aria-controls": panelId,
|
|
74
|
+
"aria-expanded": open,
|
|
75
|
+
"aria-invalid": invalid || void 0,
|
|
76
|
+
"aria-labelledby": labelledBy,
|
|
77
|
+
className: cn(
|
|
78
|
+
FIELD_TRIGGER,
|
|
79
|
+
"pe-9",
|
|
80
|
+
padded && FIELD_FLOATING_PAD,
|
|
81
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
82
|
+
invalid && FIELD_INVALID,
|
|
83
|
+
className
|
|
84
|
+
),
|
|
85
|
+
children: /* @__PURE__ */ jsx(
|
|
86
|
+
"span",
|
|
87
|
+
{
|
|
88
|
+
id: valueId,
|
|
89
|
+
className: cn("truncate", !hasValue && "text-[var(--text-placeholder)]"),
|
|
90
|
+
children: triggerText || " "
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const COLUMNS = 3;
|
|
97
|
+
function MonthGrid({
|
|
98
|
+
value,
|
|
99
|
+
onPick,
|
|
100
|
+
locale,
|
|
101
|
+
minKey,
|
|
102
|
+
maxKey,
|
|
103
|
+
currentKey,
|
|
104
|
+
labels
|
|
105
|
+
}) {
|
|
106
|
+
const outOfBounds = (key) => Boolean(minKey && key < minKey || maxKey && key > maxKey);
|
|
107
|
+
const [active, setActive] = useState(() => {
|
|
108
|
+
const start = value ? toKey(value) : currentKey;
|
|
109
|
+
if (minKey && start < minKey) return parseMonth(minKey);
|
|
110
|
+
if (maxKey && start > maxKey) return parseMonth(maxKey);
|
|
111
|
+
return parseMonth(start);
|
|
112
|
+
});
|
|
113
|
+
const year = active.year;
|
|
114
|
+
const gridRef = useRef(null);
|
|
115
|
+
const captionId = `${useId()}-year`;
|
|
116
|
+
const shortNames = useMemo(() => {
|
|
117
|
+
const fmt = new Intl.DateTimeFormat(locale, { month: "short" });
|
|
118
|
+
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2e3, i, 1)));
|
|
119
|
+
}, [locale]);
|
|
120
|
+
const longName = useMemo(() => new Intl.DateTimeFormat(locale, MONTH_NAME_FORMAT), [locale]);
|
|
121
|
+
const yearCaption = new Intl.DateTimeFormat(locale, { year: "numeric" }).format(
|
|
122
|
+
firstOf({ year, month: 1 })
|
|
123
|
+
);
|
|
124
|
+
const pendingFocus = useRef(null);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
const key = pendingFocus.current;
|
|
127
|
+
if (!key) return;
|
|
128
|
+
pendingFocus.current = null;
|
|
129
|
+
gridRef.current?.querySelector(`[data-month="${key}"]`)?.focus();
|
|
130
|
+
});
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
gridRef.current?.querySelector('[tabindex="0"]')?.focus();
|
|
133
|
+
}, []);
|
|
134
|
+
const moveTo = (next, focus) => {
|
|
135
|
+
if (focus) pendingFocus.current = toKey(next);
|
|
136
|
+
setActive(next);
|
|
137
|
+
};
|
|
138
|
+
const onCellKeyDown = (e) => {
|
|
139
|
+
const rtl = e.currentTarget.closest("[dir]")?.getAttribute("dir") === "rtl";
|
|
140
|
+
const col = (active.month - 1) % COLUMNS;
|
|
141
|
+
let delta;
|
|
142
|
+
switch (e.key) {
|
|
143
|
+
case "ArrowLeft":
|
|
144
|
+
delta = rtl ? 1 : -1;
|
|
145
|
+
break;
|
|
146
|
+
case "ArrowRight":
|
|
147
|
+
delta = rtl ? -1 : 1;
|
|
148
|
+
break;
|
|
149
|
+
case "ArrowUp":
|
|
150
|
+
delta = -COLUMNS;
|
|
151
|
+
break;
|
|
152
|
+
case "ArrowDown":
|
|
153
|
+
delta = COLUMNS;
|
|
154
|
+
break;
|
|
155
|
+
case "Home":
|
|
156
|
+
delta = -col;
|
|
157
|
+
break;
|
|
158
|
+
case "End":
|
|
159
|
+
delta = COLUMNS - 1 - col;
|
|
160
|
+
break;
|
|
161
|
+
case "PageUp":
|
|
162
|
+
delta = -12;
|
|
163
|
+
break;
|
|
164
|
+
case "PageDown":
|
|
165
|
+
delta = 12;
|
|
166
|
+
break;
|
|
167
|
+
default:
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
if (delta !== 0) moveTo(shiftMonth(active, delta), true);
|
|
172
|
+
};
|
|
173
|
+
const prevBlocked = Boolean(minKey && toKey({ year: year - 1, month: 12 }) < minKey);
|
|
174
|
+
const nextBlocked = Boolean(maxKey && toKey({ year: year + 1, month: 1 }) > maxKey);
|
|
175
|
+
const selectedKey = value ? toKey(value) : null;
|
|
176
|
+
const arrow = "flex size-7 items-center justify-center rounded text-[var(--text-muted)] hover:bg-[var(--bg-hover)] disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent";
|
|
177
|
+
return /* @__PURE__ */ jsxs("div", { className: "select-none", children: [
|
|
178
|
+
/* @__PURE__ */ jsxs("div", { className: "mb-1 flex items-center justify-between", children: [
|
|
179
|
+
/* @__PURE__ */ jsx(
|
|
180
|
+
"button",
|
|
181
|
+
{
|
|
182
|
+
type: "button",
|
|
183
|
+
"aria-label": labels.previousYear,
|
|
184
|
+
disabled: prevBlocked,
|
|
185
|
+
onClick: () => moveTo(shiftMonth(active, -12), false),
|
|
186
|
+
className: arrow,
|
|
187
|
+
children: /* @__PURE__ */ jsx(ChevronLeft, { className: "size-4 rtl:-scale-x-100" })
|
|
188
|
+
}
|
|
189
|
+
),
|
|
190
|
+
/* @__PURE__ */ jsx(
|
|
191
|
+
"div",
|
|
192
|
+
{
|
|
193
|
+
id: captionId,
|
|
194
|
+
"aria-live": "polite",
|
|
195
|
+
className: "text-sm font-semibold tabular-nums text-[var(--text-primary)]",
|
|
196
|
+
children: yearCaption
|
|
197
|
+
}
|
|
198
|
+
),
|
|
199
|
+
/* @__PURE__ */ jsx(
|
|
200
|
+
"button",
|
|
201
|
+
{
|
|
202
|
+
type: "button",
|
|
203
|
+
"aria-label": labels.nextYear,
|
|
204
|
+
disabled: nextBlocked,
|
|
205
|
+
onClick: () => moveTo(shiftMonth(active, 12), false),
|
|
206
|
+
className: arrow,
|
|
207
|
+
children: /* @__PURE__ */ jsx(ChevronRight, { className: "size-4 rtl:-scale-x-100" })
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
] }),
|
|
211
|
+
/* @__PURE__ */ jsx("div", { ref: gridRef, role: "grid", "aria-labelledby": captionId, className: "grid gap-1", children: Array.from({ length: 12 / COLUMNS }, (_, row) => /* @__PURE__ */ jsx("div", { role: "row", className: "grid grid-cols-3 gap-1", children: Array.from({ length: COLUMNS }, (_2, c) => {
|
|
212
|
+
const ym = { year, month: row * COLUMNS + c + 1 };
|
|
213
|
+
const key = toKey(ym);
|
|
214
|
+
const disabled = outOfBounds(key);
|
|
215
|
+
const selected = key === selectedKey;
|
|
216
|
+
const current = key === currentKey;
|
|
217
|
+
return /* @__PURE__ */ jsx(
|
|
218
|
+
"button",
|
|
219
|
+
{
|
|
220
|
+
type: "button",
|
|
221
|
+
role: "gridcell",
|
|
222
|
+
"data-month": key,
|
|
223
|
+
tabIndex: ym.month === active.month ? 0 : -1,
|
|
224
|
+
"aria-label": labels.month(longName.format(firstOf(ym))),
|
|
225
|
+
"aria-selected": selected,
|
|
226
|
+
"aria-disabled": disabled || void 0,
|
|
227
|
+
"aria-current": current ? "date" : void 0,
|
|
228
|
+
onClick: () => {
|
|
229
|
+
if (disabled) return;
|
|
230
|
+
setActive(ym);
|
|
231
|
+
onPick(key);
|
|
232
|
+
},
|
|
233
|
+
onKeyDown: onCellKeyDown,
|
|
234
|
+
className: cn(
|
|
235
|
+
"rounded px-2 py-1.5 text-sm capitalize transition-colors",
|
|
236
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset",
|
|
237
|
+
// A --brand ring on a --brand fill exists only in the DOM.
|
|
238
|
+
selected && !disabled ? "focus-visible:ring-[var(--brand-contrast)]" : "focus-visible:ring-[var(--brand)]",
|
|
239
|
+
disabled ? "cursor-not-allowed text-[var(--text-placeholder)]" : selected ? "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]" : "text-[var(--text-primary)] hover:bg-[var(--bg-hover)]",
|
|
240
|
+
current && !selected && !disabled && "ring-1 ring-inset ring-[var(--border-strong)]"
|
|
241
|
+
),
|
|
242
|
+
children: shortNames[ym.month - 1]
|
|
243
|
+
},
|
|
244
|
+
c
|
|
245
|
+
);
|
|
246
|
+
}) }, row)) })
|
|
247
|
+
] });
|
|
248
|
+
}
|
|
249
|
+
function upperFirst(text, locale) {
|
|
250
|
+
return text.charAt(0).toLocaleUpperCase(locale) + text.slice(1);
|
|
251
|
+
}
|
|
252
|
+
function MonthPicker({
|
|
253
|
+
value,
|
|
254
|
+
onChange,
|
|
255
|
+
locale: localeProp,
|
|
256
|
+
min,
|
|
257
|
+
max,
|
|
258
|
+
label,
|
|
259
|
+
placeholder,
|
|
260
|
+
disabled,
|
|
261
|
+
invalid,
|
|
262
|
+
formatOptions,
|
|
263
|
+
currentMonth,
|
|
264
|
+
labels: labelsProp,
|
|
265
|
+
className,
|
|
266
|
+
triggerClassName,
|
|
267
|
+
...rest
|
|
268
|
+
}) {
|
|
269
|
+
const labels = useMonthPickerLabels(labelsProp);
|
|
270
|
+
const locale = useKitLocale(localeProp);
|
|
271
|
+
const selected = parseMonth(value);
|
|
272
|
+
const minKey = boundKey(min);
|
|
273
|
+
const maxKey = boundKey(max);
|
|
274
|
+
const currentKey = boundKey(currentMonth) ?? monthKey(/* @__PURE__ */ new Date());
|
|
275
|
+
const triggerText = selected ? upperFirst(
|
|
276
|
+
new Intl.DateTimeFormat(locale, formatOptions ?? DEFAULT_TRIGGER_FORMAT).format(
|
|
277
|
+
firstOf(selected)
|
|
278
|
+
),
|
|
279
|
+
locale
|
|
280
|
+
) : placeholder ?? "";
|
|
281
|
+
const id = useId();
|
|
282
|
+
const labelId = `${id}-label`;
|
|
283
|
+
const valueId = `${id}-value`;
|
|
284
|
+
const panelId = `${id}-panel`;
|
|
285
|
+
const named = typeof label === "string";
|
|
286
|
+
return /* @__PURE__ */ jsxs("div", { ...rest, className: cn("relative", className), children: [
|
|
287
|
+
label !== void 0 && /* @__PURE__ */ jsx(FieldLabel, { children: label }),
|
|
288
|
+
named && /* @__PURE__ */ jsx("span", { id: labelId, "aria-hidden": true, className: "sr-only-fixed", children: label }),
|
|
289
|
+
/* @__PURE__ */ jsx(
|
|
290
|
+
Popover,
|
|
291
|
+
{
|
|
292
|
+
width: 256,
|
|
293
|
+
panelId,
|
|
294
|
+
labels: { panel: labels.panel },
|
|
295
|
+
trigger: ({ open, toggle, ref }) => /* @__PURE__ */ jsx(
|
|
296
|
+
MonthFieldTrigger,
|
|
297
|
+
{
|
|
298
|
+
open,
|
|
299
|
+
toggle,
|
|
300
|
+
triggerRef: ref,
|
|
301
|
+
triggerText,
|
|
302
|
+
hasValue: selected != null,
|
|
303
|
+
labelledBy: named ? `${labelId} ${valueId}` : valueId,
|
|
304
|
+
valueId,
|
|
305
|
+
panelId,
|
|
306
|
+
padded: label !== void 0,
|
|
307
|
+
disabled,
|
|
308
|
+
invalid,
|
|
309
|
+
className: triggerClassName
|
|
310
|
+
}
|
|
311
|
+
),
|
|
312
|
+
children: (close) => /* @__PURE__ */ jsx(
|
|
313
|
+
MonthGrid,
|
|
314
|
+
{
|
|
315
|
+
value: selected,
|
|
316
|
+
locale,
|
|
317
|
+
minKey,
|
|
318
|
+
maxKey,
|
|
319
|
+
currentKey,
|
|
320
|
+
labels,
|
|
321
|
+
onPick: (key) => {
|
|
322
|
+
onChange(key);
|
|
323
|
+
close();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
),
|
|
329
|
+
/* @__PURE__ */ jsx(
|
|
330
|
+
Calendar,
|
|
331
|
+
{
|
|
332
|
+
"aria-hidden": true,
|
|
333
|
+
className: "pointer-events-none absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]"
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
] });
|
|
337
|
+
}
|
|
338
|
+
export {
|
|
339
|
+
DEFAULT_MONTH_PICKER_LABELS,
|
|
340
|
+
MonthPicker
|
|
341
|
+
};
|
|
342
|
+
//# sourceMappingURL=month-picker.js.map
|