@eifi1/ui-kit 0.34.0 → 0.36.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/README.md +115 -41
- package/dist/admin/role-select.d.ts +3 -2
- package/dist/admin/role-select.js +2 -0
- package/dist/admin/role-select.js.map +1 -1
- package/dist/billing/billing-banner.d.ts +5 -2
- package/dist/billing/billing-banner.js.map +1 -1
- package/dist/billing/plan-limit-notice.d.ts +5 -2
- package/dist/billing/plan-limit-notice.js.map +1 -1
- package/dist/chart.js.map +1 -1
- package/dist/components/amount-input.d.ts +2 -5
- package/dist/components/amount-input.js +2 -2
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +2 -2
- package/dist/components/autocomplete.js +2 -1
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-look.d.ts +1 -1
- package/dist/components/button-look.js +9 -4
- package/dist/components/button-look.js.map +1 -1
- package/dist/components/calculator.d.ts +1 -4
- package/dist/components/calculator.js +1 -3
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/chart-zoom.d.ts +0 -37
- package/dist/components/chart-zoom.js +1 -28
- package/dist/components/chart-zoom.js.map +1 -1
- package/dist/components/checkbox-group.d.ts +5 -2
- package/dist/components/checkbox-group.js +15 -4
- package/dist/components/checkbox-group.js.map +1 -1
- package/dist/components/chip-input.d.ts +0 -4
- package/dist/components/chip-input.js +1 -5
- package/dist/components/chip-input.js.map +1 -1
- package/dist/components/column-mapper.d.ts +5 -3
- package/dist/components/column-mapper.js +2 -0
- package/dist/components/column-mapper.js.map +1 -1
- package/dist/components/combobox.d.ts +3 -3
- package/dist/components/combobox.js +2 -1
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/copy-button.d.ts +5 -1
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/country-select.d.ts +3 -3
- package/dist/components/country-select.js +3 -0
- package/dist/components/country-select.js.map +1 -1
- package/dist/components/currency-option-list.d.ts +3 -8
- package/dist/components/currency-option-list.js +1 -29
- package/dist/components/currency-option-list.js.map +1 -1
- package/dist/components/currency-select.d.ts +3 -2
- package/dist/components/currency-select.js +10 -1
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +3 -3
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filter-popover.js +1 -1
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +1 -42
- package/dist/components/data-table-labels.js +2 -31
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-shared.d.ts +17 -1
- package/dist/components/data-table-shared.js +22 -0
- package/dist/components/data-table-shared.js.map +1 -1
- package/dist/components/data-table.d.ts +12 -2
- package/dist/components/data-table.js +18 -14
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-field.d.ts +2 -1
- package/dist/components/date-field.js.map +1 -1
- package/dist/components/date-picker.js +3 -0
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/date-range-picker.js +3 -0
- package/dist/components/date-range-picker.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +3 -1
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/dropdown.d.ts +18 -15
- package/dist/components/dropdown.js +11 -3
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +3 -3
- package/dist/components/entity-combobox.js +2 -1
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/field-base.d.ts +0 -5
- package/dist/components/field-base.js +0 -5
- package/dist/components/field-base.js.map +1 -1
- package/dist/components/field-parts.d.ts +69 -22
- package/dist/components/field-parts.js +33 -19
- package/dist/components/field-parts.js.map +1 -1
- package/dist/components/field-strip.d.ts +9 -23
- package/dist/components/field-strip.js +26 -23
- package/dist/components/field-strip.js.map +1 -1
- package/dist/components/field-sync.d.ts +0 -4
- package/dist/components/field-sync.js +0 -4
- package/dist/components/field-sync.js.map +1 -1
- package/dist/components/field.d.ts +7 -6
- package/dist/components/field.js +24 -15
- package/dist/components/field.js.map +1 -1
- package/dist/components/full-bleed-dialog.js +2 -2
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/help-popover.d.ts +9 -7
- package/dist/components/help-popover.js.map +1 -1
- package/dist/components/hover-menu.d.ts +6 -13
- package/dist/components/hover-menu.js +3 -3
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/icon-picker.d.ts +3 -2
- package/dist/components/icon-picker.js +3 -0
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/inline-entity-combobox.d.ts +3 -3
- package/dist/components/inline-entity-combobox.js +2 -1
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/components/input.d.ts +2 -2
- package/dist/components/input.js +2 -1
- package/dist/components/input.js.map +1 -1
- package/dist/components/modal.d.ts +9 -1
- package/dist/components/modal.js +7 -4
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +2 -1
- package/dist/components/month-picker.js +8 -1
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +2 -2
- package/dist/components/multi-entity-combobox.js +2 -1
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +7 -18
- package/dist/components/multi-select.js +8 -10
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-input.d.ts +2 -5
- package/dist/components/number-input.js +2 -4
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numeric-field.d.ts +5 -2
- package/dist/components/numeric-field.js +4 -1
- package/dist/components/numeric-field.js.map +1 -1
- package/dist/components/one-time-code-input.d.ts +2 -1
- package/dist/components/one-time-code-input.js +2 -1
- package/dist/components/one-time-code-input.js.map +1 -1
- package/dist/components/phone-input.d.ts +1 -1
- package/dist/components/phone-input.js +4 -0
- package/dist/components/phone-input.js.map +1 -1
- package/dist/components/popover.js +3 -3
- package/dist/components/popover.js.map +1 -1
- package/dist/components/select.d.ts +4 -3
- package/dist/components/select.js +2 -1
- package/dist/components/select.js.map +1 -1
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/server-wake.d.ts +0 -6
- package/dist/components/server-wake.js.map +1 -1
- package/dist/components/settings-fields.d.ts +4 -2
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/slider.d.ts +3 -2
- package/dist/components/slider.js +7 -1
- package/dist/components/slider.js.map +1 -1
- package/dist/components/stat-tile.d.ts +8 -2
- package/dist/components/stat-tile.js +2 -11
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/swatch-picker.d.ts +3 -2
- package/dist/components/swatch-picker.js +3 -0
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/swipeable-row.d.ts +2 -17
- package/dist/components/swipeable-row.js +11 -16
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/text-link.js +3 -1
- package/dist/components/text-link.js.map +1 -1
- package/dist/components/textarea.d.ts +2 -1
- package/dist/components/textarea.js +9 -3
- package/dist/components/textarea.js.map +1 -1
- package/dist/components/toggle-group.d.ts +2 -2
- package/dist/components/toggle-group.js +7 -3
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/tooltip.d.ts +9 -1
- package/dist/components/tooltip.js +20 -5
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/translation-review.d.ts +10 -2
- package/dist/components/translation-review.js +11 -3
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/use-table-state.d.ts +31 -3
- package/dist/components/use-table-state.js +53 -8
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/components/user-avatar.d.ts +2 -2
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/components/wizard-stepper.d.ts +1 -4
- package/dist/components/wizard-stepper.js +2 -3
- package/dist/components/wizard-stepper.js.map +1 -1
- package/dist/currencies.d.ts +23 -0
- package/dist/currencies.js +38 -0
- package/dist/currencies.js.map +1 -0
- package/dist/demo/demo-banner.d.ts +7 -2
- package/dist/demo/demo-banner.js.map +1 -1
- package/dist/eslint/index.d.ts +83 -0
- package/dist/eslint/index.js +69 -0
- package/dist/eslint/index.js.map +1 -0
- package/dist/eslint/role-utilities.d.ts +48 -0
- package/dist/eslint/role-utilities.generated.d.ts +2 -0
- package/dist/eslint/role-utilities.generated.js +143 -0
- package/dist/eslint/role-utilities.generated.js.map +1 -0
- package/dist/eslint/role-utilities.js +43 -0
- package/dist/eslint/role-utilities.js.map +1 -0
- package/dist/feedback/feedback-menu.d.ts +2 -2
- package/dist/feedback/feedback-menu.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +3 -8
- package/dist/feedback/feedback-thread.js +0 -2
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/hooks/use-dismiss.d.ts +16 -3
- package/dist/hooks/use-dismiss.js +14 -2
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/i18n/defaults.js +5 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +97 -1
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +29 -4
- package/dist/i18n/kit-labels.js +21 -1
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +104 -3
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +95 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +99 -3
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +95 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +99 -3
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +95 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +13 -0
- package/dist/i18n/review.js.map +1 -1
- package/dist/i18n/shared-labels.js +3 -1
- package/dist/i18n/shared-labels.js.map +1 -1
- package/dist/index.d.ts +23 -1
- package/dist/index.js +40 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/device-settings.d.ts +72 -0
- package/dist/lib/device-settings.js +64 -0
- package/dist/lib/device-settings.js.map +1 -0
- package/dist/lib/plain-text.d.ts +11 -0
- package/dist/lib/plain-text.js +16 -0
- package/dist/lib/plain-text.js.map +1 -0
- package/dist/lib/refs.d.ts +13 -0
- package/dist/lib/refs.js +15 -0
- package/dist/lib/refs.js.map +1 -0
- package/dist/lib/safe-storage.d.ts +6 -0
- package/dist/lib/safe-storage.js +11 -0
- package/dist/lib/safe-storage.js.map +1 -1
- package/dist/notifications/notification-parts.d.ts +39 -0
- package/dist/notifications/notification-parts.js +47 -0
- package/dist/notifications/notification-parts.js.map +1 -0
- package/dist/notifications/notification-types-setting.d.ts +62 -0
- package/dist/notifications/notification-types-setting.js +192 -0
- package/dist/notifications/notification-types-setting.js.map +1 -0
- package/dist/notifications/notification-types.d.ts +75 -0
- package/dist/notifications/notification-types.js +2 -0
- package/dist/notifications/notification-types.js.map +1 -0
- package/dist/notifications/notifications-labels.d.ts +131 -0
- package/dist/notifications/notifications-labels.js +83 -0
- package/dist/notifications/notifications-labels.js.map +1 -0
- package/dist/notifications/push-device-setting.d.ts +37 -0
- package/dist/notifications/push-device-setting.js +164 -0
- package/dist/notifications/push-device-setting.js.map +1 -0
- package/dist/notifications/push-devices-setting.d.ts +44 -0
- package/dist/notifications/push-devices-setting.js +203 -0
- package/dist/notifications/push-devices-setting.js.map +1 -0
- package/dist/notifications/quiet-hours-setting.d.ts +47 -0
- package/dist/notifications/quiet-hours-setting.js +131 -0
- package/dist/notifications/quiet-hours-setting.js.map +1 -0
- package/dist/notifications/time-zone-select.d.ts +50 -0
- package/dist/notifications/time-zone-select.js +76 -0
- package/dist/notifications/time-zone-select.js.map +1 -0
- package/dist/notifications/unsubscribe-panel.d.ts +53 -0
- package/dist/notifications/unsubscribe-panel.js +139 -0
- package/dist/notifications/unsubscribe-panel.js.map +1 -0
- package/dist/notifications/use-push-device.d.ts +122 -0
- package/dist/notifications/use-push-device.js +230 -0
- package/dist/notifications/use-push-device.js.map +1 -0
- package/dist/rhf/fields.d.ts +36 -29
- package/dist/rhf/fields.js +51 -22
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form-core.d.ts +5 -4
- package/dist/rhf/form-core.js.map +1 -1
- package/dist/rhf/form.d.ts +7 -36
- package/dist/rhf/form.js +1 -16
- package/dist/rhf/form.js.map +1 -1
- package/dist/rhf/line-items.d.ts +1 -1
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf/use-rhf-wizard-step.js.map +1 -1
- package/dist/rhf.js.map +1 -1
- package/dist/settings/device-reset-setting-labels.d.ts +3 -0
- package/dist/settings/device-reset-setting-labels.js +15 -0
- package/dist/settings/device-reset-setting-labels.js.map +1 -0
- package/dist/settings/device-reset-setting.d.ts +58 -0
- package/dist/settings/device-reset-setting.js +51 -0
- package/dist/settings/device-reset-setting.js.map +1 -0
- package/dist/shell/app-shell.js +5 -0
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/option-switcher-menu.d.ts +5 -17
- package/dist/shell/option-switcher-menu.js +1 -3
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/role-switcher.d.ts +1 -4
- package/dist/shell/role-switcher.js +2 -3
- package/dist/shell/role-switcher.js.map +1 -1
- package/dist/shell/topbar-action-menu.d.ts +7 -19
- package/dist/shell/topbar-action-menu.js +1 -3
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +3 -12
- package/dist/shell/topbar-controls.js +6 -9
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/viewport.d.ts +25 -2
- package/dist/testing/viewport.js +61 -10
- package/dist/testing/viewport.js.map +1 -1
- package/dist/theme/contrast-tokens.d.ts +42 -4
- package/dist/theme/contrast-tokens.js +56 -9
- package/dist/theme/contrast-tokens.js.map +1 -1
- package/dist/theme/contrast.d.ts +4 -2
- package/dist/theme/contrast.js +13 -0
- package/dist/theme/contrast.js.map +1 -1
- package/dist/theme/palette-derive.d.ts +15 -1
- package/dist/theme/palette-derive.js +37 -8
- package/dist/theme/palette-derive.js.map +1 -1
- package/dist/theme/palette-presets.d.ts +2 -8
- package/dist/theme/palette-presets.js +5 -284
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/theme/palette-store.js +6 -0
- package/dist/theme/palette-store.js.map +1 -1
- package/dist/theme/text-size.js +6 -0
- package/dist/theme/text-size.js.map +1 -1
- package/dist/theme/theme-store.js +6 -0
- package/dist/theme/theme-store.js.map +1 -1
- package/dist/wizard/types.d.ts +0 -5
- package/dist/wizard/types.js +1 -5
- package/dist/wizard/types.js.map +1 -1
- package/package.json +9 -1
- package/src/admin/role-select.tsx +4 -1
- package/src/billing/billing-banner.tsx +5 -2
- package/src/billing/plan-limit-notice.tsx +5 -2
- package/src/chart.ts +5 -3
- package/src/components/amount-input.tsx +4 -7
- package/src/components/autocomplete.tsx +5 -2
- package/src/components/button-look.ts +15 -5
- package/src/components/calculator.tsx +2 -7
- package/src/components/chart-zoom.tsx +4 -47
- package/src/components/checkbox-group.tsx +22 -4
- package/src/components/chip-input.tsx +0 -7
- package/src/components/column-mapper.tsx +6 -2
- package/src/components/combobox.tsx +6 -3
- package/src/components/copy-button.tsx +5 -1
- package/src/components/country-select.tsx +6 -1
- package/src/components/currency-option-list.tsx +8 -42
- package/src/components/currency-select.tsx +12 -3
- package/src/components/danger-confirm.tsx +3 -3
- package/src/components/data-table-filter-popover.tsx +2 -1
- package/src/components/data-table-labels.ts +2 -84
- package/src/components/data-table-shared.tsx +55 -2
- package/src/components/data-table.tsx +29 -17
- package/src/components/date-field.tsx +2 -2
- package/src/components/date-picker.tsx +3 -0
- package/src/components/date-range-picker.tsx +3 -0
- package/src/components/dialog-frame.tsx +3 -1
- package/src/components/dropdown.tsx +42 -18
- package/src/components/entity-combobox.tsx +6 -3
- package/src/components/field-base.tsx +0 -11
- package/src/components/field-parts.tsx +112 -38
- package/src/components/field-strip.tsx +12 -27
- package/src/components/field-sync.tsx +0 -7
- package/src/components/field.tsx +32 -19
- package/src/components/full-bleed-dialog.tsx +4 -3
- package/src/components/help-popover.tsx +9 -7
- package/src/components/hover-menu.tsx +12 -16
- package/src/components/icon-picker.tsx +8 -3
- package/src/components/inline-entity-combobox.tsx +6 -3
- package/src/components/input.tsx +4 -2
- package/src/components/modal.tsx +20 -4
- package/src/components/month-picker.tsx +10 -3
- package/src/components/multi-entity-combobox.tsx +5 -2
- package/src/components/multi-select.tsx +19 -31
- package/src/components/number-input.tsx +5 -10
- package/src/components/numeric-field.ts +10 -2
- package/src/components/one-time-code-input.tsx +5 -3
- package/src/components/phone-input.tsx +4 -0
- package/src/components/popover.tsx +8 -4
- package/src/components/select.tsx +6 -3
- package/src/components/series-chart.tsx +1 -1
- package/src/components/server-wake.tsx +0 -6
- package/src/components/settings-fields.tsx +4 -2
- package/src/components/slider.tsx +10 -3
- package/src/components/stat-tile.tsx +11 -20
- package/src/components/swatch-picker.tsx +9 -4
- package/src/components/swipeable-row.tsx +15 -37
- package/src/components/text-link.tsx +3 -1
- package/src/components/textarea.tsx +11 -4
- package/src/components/toggle-group.tsx +21 -5
- package/src/components/tooltip.tsx +37 -6
- package/src/components/translation-review.tsx +25 -7
- package/src/components/use-table-state.ts +91 -10
- package/src/components/user-avatar.tsx +2 -2
- package/src/components/wizard-stepper.tsx +2 -6
- package/src/currencies.ts +70 -0
- package/src/demo/demo-banner.tsx +7 -2
- package/src/eslint/index.ts +161 -0
- package/src/eslint/role-utilities.generated.ts +146 -0
- package/src/eslint/role-utilities.ts +99 -0
- package/src/feedback/feedback-menu.tsx +2 -2
- package/src/feedback/feedback-thread.tsx +3 -8
- package/src/hooks/use-dismiss.ts +39 -4
- package/src/i18n/defaults.ts +4 -0
- package/src/i18n/german.ts +113 -0
- package/src/i18n/kit-labels.tsx +66 -6
- package/src/i18n/locales/en.ts +6 -0
- package/src/i18n/locales/es.ts +106 -0
- package/src/i18n/locales/fr.ts +109 -2
- package/src/i18n/locales/hu.ts +104 -0
- package/src/i18n/locales/it.ts +108 -2
- package/src/i18n/locales/zh.ts +96 -0
- package/src/i18n/review.ts +13 -0
- package/src/i18n/shared-labels.ts +2 -0
- package/src/index.ts +53 -1
- package/src/lib/device-settings.ts +146 -0
- package/src/lib/plain-text.ts +22 -0
- package/src/lib/refs.ts +22 -0
- package/src/lib/safe-storage.ts +16 -0
- package/src/notifications/notification-parts.tsx +83 -0
- package/src/notifications/notification-types-setting.tsx +309 -0
- package/src/notifications/notification-types.ts +80 -0
- package/src/notifications/notifications-labels.ts +238 -0
- package/src/notifications/push-device-setting.tsx +224 -0
- package/src/notifications/push-devices-setting.tsx +306 -0
- package/src/notifications/quiet-hours-setting.tsx +185 -0
- package/src/notifications/time-zone-select.tsx +126 -0
- package/src/notifications/unsubscribe-panel.tsx +203 -0
- package/src/notifications/use-push-device.ts +392 -0
- package/src/rhf/fields.tsx +87 -39
- package/src/rhf/form-core.tsx +5 -4
- package/src/rhf/form.tsx +7 -43
- package/src/rhf/line-items.tsx +1 -1
- package/src/rhf/use-rhf-wizard-step.ts +4 -4
- package/src/rhf.ts +3 -2
- package/src/settings/device-reset-setting-labels.ts +20 -0
- package/src/settings/device-reset-setting.tsx +119 -0
- package/src/shell/app-shell.tsx +8 -0
- package/src/shell/option-switcher-menu.tsx +5 -22
- package/src/shell/role-switcher.tsx +2 -6
- package/src/shell/topbar-action-menu.tsx +7 -24
- package/src/shell/topbar-controls.tsx +6 -18
- package/src/testing/index.ts +3 -1
- package/src/testing/viewport.ts +125 -19
- package/src/theme/contrast-tokens.ts +106 -12
- package/src/theme/contrast.ts +30 -4
- package/src/theme/palette-derive.ts +68 -7
- package/src/theme/palette-presets.ts +16 -299
- package/src/theme/palette-store.ts +12 -0
- package/src/theme/text-size.ts +8 -0
- package/src/theme/theme-store.ts +10 -0
- package/src/wizard/types.ts +0 -8
- package/tokens.css +38 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/input.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport { Eye, EyeOff } from \"lucide-react\";\nimport type { ChangeEvent, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { usePhoneLayout } from \"../hooks/use-breakpoint\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport type { CommitScope } from \"./write-lock\";\nimport {\n EndHintRow,\n FieldCaption,\n LockedReason,\n mergeDescribedBy,\n type DisabledReasonDisplay,\n useFieldError,\n useFieldHint,\n useLockReason,\n} from \"./field-parts\";\nimport { DEFAULT_PASSWORD_REVEAL_LABELS } from \"./ui-labels\";\nimport {\n CharacterCount,\n FIELD_BASE,\n FIELD_DISPLAY,\n FIELD_INVALID,\n FieldGroup,\n FloatingField,\n FLOATING_INPUT_CLASS,\n FLOATING_PLACEHOLDER_ON_FOCUS,\n fieldBelow,\n hasPlaceholderText,\n useCharacterCountLabels,\n useTypedLength,\n type CharacterCountLabels,\n type PasswordRevealLabels,\n} from \"./field-base\";\n\n// Native date/time inputs only reveal the calendar via the tiny trailing icon;\n// open the picker on a click anywhere in the field instead (feedback #224).\nconst PICKER_TYPES = new Set([\"date\", \"datetime-local\", \"month\", \"time\", \"week\"]);\n\nexport interface InputProps extends InputHTMLAttributes<HTMLInputElement> {\n label?: ReactNode;\n /** Classes for the `<input>` itself, as distinct from `className`, which\n * styles the field WRAPPER once a `label` turns this into a FloatingField.\n * Without it a labelled Input had no way to reach its own element — so\n * `tabular-nums` on a numeric text field, which NumberInput has supported\n * all along through the identically named prop, was simply unavailable. */\n inputClassName?: string;\n /** {@link FIELD_DISPLAY} — on a phone, drop the chrome and set the value as a\n * heading. For the one field a form is about (a feedback subject, an account\n * name), never for a stack of them. Labelled fields only: the label is what\n * the placeholder falls back to once it goes `sr-only`. */\n variant?: \"field\" | \"display\";\n /** The field is required and unanswered, or holds something that cannot be\n * saved — {@link FIELD_INVALID}, the same rose border/ring `Select` has worn\n * since feedback #235.\n *\n * It exists here because writing `aria-invalid` by hand did NOT do this. The\n * attribute spreads onto the element and nothing styles it — there is no\n * `[aria-invalid]` rule in this package or in either consumer's stylesheet —\n * so three Keksdose dialogs flagged a mismatched passphrase to a screen reader\n * and painted the field exactly as if it were fine. Setting the prop sets the\n * attribute too, so the two can no longer be spelled separately. */\n invalid?: boolean;\n /** What is wrong with the value, in the caller's own words (\"That IBAN has 21\n * digits\"). Rendered under the field, pointed at by the control's\n * `aria-describedby` — MERGED with any the caller already passed — and implies\n * `invalid`, so the field paints as well as announces. `invalid` alone still\n * covers the case where the message lives elsewhere. See {@link useFieldError}. */\n error?: ReactNode;\n /**\n * Standing advice for the field (keksdose K4: Input had `error` but no `hint`, so the\n * app built the caption by hand under each field, and half of those never reached a\n * screen reader). The {@link Select} rule, so every field reads alike:\n * - plain TEXT (a string or a number) is a caption UNDER the field, attached with\n * `aria-describedby` — after the caller's own ids, before the error's;\n * - anything else (a {@link FieldHint} \"?\") rides the label line beside the\n * animated label, as NumberInput's does — or, on a field with no label, sits at the\n * field's end edge outside the box, and `className` then styles that row.\n */\n hint?: ReactNode;\n /**\n * A live \"12/80\" under the field, with `maxLength` (keksdose K11 — the broadcast,\n * support and handoff-note fields counted by hand). Read with the field as \"12 of 80\n * characters\", and announced politely only on entering the last stretch and on\n * reaching the limit — never on every keystroke. See {@link CharacterCount}. Ignored\n * without a `maxLength`: a count with no limit is not what this answers. Counts\n * UTF-16 code units, as `maxLength` itself does. Controlled, it counts `value`;\n * uncontrolled, `defaultValue` and then every change event — a value written from\n * outside without one (a form library's `reset`) shows from the next keystroke.\n */\n showCount?: boolean;\n /** The counter's words. See {@link CharacterCountLabels}. */\n countLabels?: Partial<CharacterCountLabels>;\n /** Names for the password reveal toggle, English by default — it is the one\n * string this component renders on its own behalf, and a German form was\n * reading it out in English. See {@link PasswordRevealLabels}. */\n passwordLabels?: Partial<PasswordRevealLabels>;\n /**\n * Why the value cannot be changed — {@link Button}'s `disabledReason`, for a text field\n * that SAVES itself: a name committed on blur (0.34, KS U10 — Kurvenschmiede's segment\n * and profile names, behind a local `CommitInput`). `disabled` takes the field out of\n * the tab order, so the reason never reached a keyboard.\n *\n * With a reason the field stays in the tab order and shows its value, `readOnly` (the\n * settled grey FIELD_BASE gives a `[readonly]` field) and `aria-disabled` — NumberInput's\n * lock (0.33.1), Autocomplete's. Nothing a save listens to is reported: not `onChange`,\n * not `onBlur` (on a field that saves itself, blur IS the save), and not Enter, which is\n * swallowed so the field does not submit the form around it; the caller's `onKeyDown`\n * still hears every other key. The reason is a line under the field at Large and on a\n * touch screen, else in the kit {@link Tooltip} (`disabledReasonDisplay`), and on the\n * input's `aria-describedby`, between the caption and the error. Wins over `disabled`.\n */\n disabledReason?: ReactNode;\n /**\n * Where the reason shows — {@link Button}'s `disabledReasonDisplay`: by default a line\n * under the field at Large and on a touch screen, the tooltip otherwise (§4 \"No fact\n * only in a tooltip\"; DUP-3, refactor audit 2026-10).\n */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /**\n * This field COMMITS — {@link Button}'s `commit`: under a locked\n * {@link WriteLockProvider} it is locked the `disabledReason` way with the lock's reason\n * (which wins over its own). A field whose value only reaches the server through its\n * form's Save leaves this off and stays editable under the lock — the Save takes\n * `commit`. No provider, or an unlocked one: no effect.\n */\n commit?: CommitScope;\n /** The `<input>`. A React 19 ref prop since 0.34 (MOD-4, refactor audit 2026-10): as\n * `forwardRef`, with a `displayName` line beside it, this module could not drop an\n * unused component, and `{ Spinner }` brought Input, Select and Textarea along. */\n ref?: Ref<HTMLInputElement>;\n}\n\n/** What a locked {@link Input} swallows of a key: Enter, which would submit the form. */\nfunction swallowEnter(onKeyDown: ((e: KeyboardEvent<HTMLInputElement>) => void) | undefined) {\n return (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n return;\n }\n onKeyDown?.(e);\n };\n}\n\nexport function Input(props: InputProps) {\n const {\n ref,\n className,\n inputClassName,\n label,\n id,\n placeholder,\n type,\n variant = \"field\",\n invalid,\n error,\n hint,\n showCount,\n countLabels,\n passwordLabels,\n disabledReason,\n disabledReasonDisplay,\n commit,\n ...rest\n } = props;\n // Passed at all — even as `undefined` — the field keeps its box; see FieldGroup. The\n // same for `hint` and `showCount` (keksdose, 0.22): a caption coming and going under\n // the control must not rebuild it, or focus and the caret are lost mid-edit.\n const reserve = \"error\" in props || \"hint\" in props || \"showCount\" in props;\n const generated = useId();\n const fieldId = id ?? generated;\n const countId = useId();\n const hintParts = useFieldHint(hint, rest[\"aria-describedby\"]);\n const maxLength = rest.maxLength;\n const counting = Boolean(showCount) && typeof maxLength === \"number\" && maxLength > 0;\n const length = useTypedLength(rest.value, rest.defaultValue);\n const countText = useCharacterCountLabels(countLabels);\n // The lock (0.34, KS U10): focusable, `readOnly`, nothing a save listens to is\n // reported — see `disabledReason`.\n const lock = useLockReason(commit, disabledReason);\n const locked = lock.locked;\n const { isInvalid, describedBy, errorEl } = useFieldError(\n error,\n invalid,\n // Caller's, caption, count, a lock's reason — then the error, appended by the hook.\n mergeDescribedBy(hintParts.describedBy, counting && countId, locked && lock.reasonId),\n rest[\"aria-invalid\"],\n );\n const counter = counting ? (\n <CharacterCount\n id={countId}\n used={length.used}\n max={maxLength}\n labels={countText}\n caption={<FieldCaption parts={hintParts} className=\"mt-0\" />}\n />\n ) : null;\n const below = fieldBelow(hintParts, counter, errorEl);\n const onChange = locked\n ? undefined\n : counting\n ? (e: ChangeEvent<HTMLInputElement>) => {\n length.track(e.target.value);\n rest.onChange?.(e);\n }\n : rest.onChange;\n // Locked, these win over what the caller's spread carried: focusable rather than\n // `disabled`, `readOnly`, no blur reported (blur is a self-saving field's save), and\n // Enter swallowed. Spread AFTER `rest` on every branch's input.\n const lockProps = locked\n ? {\n disabled: undefined,\n readOnly: true,\n \"aria-disabled\": true as const,\n onBlur: undefined,\n onKeyDown: swallowEnter(rest.onKeyDown),\n }\n : undefined;\n // While locked the field sits in the reason's frame (the line, or the Tooltip), which\n // becomes its outermost box — so `className` (a width, a flex or grid cell) moves onto\n // that, as on Select, and the form keeps its layout. `block` so a full-width field\n // stays full width in it.\n const boxClass = locked ? undefined : className;\n const withReason = (box: ReactNode) =>\n locked ? (\n <LockedReason lock={lock} display={disabledReasonDisplay} className={cn(\"block\", className)}>\n {box}\n </LockedReason>\n ) : (\n box\n );\n const asDisplay = usePhoneLayout() && variant === \"display\";\n // Password fields get a reveal toggle so users can check what they typed.\n const isPassword = type === \"password\";\n const [revealed, setRevealed] = useState(false);\n const effectiveType = isPassword && revealed ? \"text\" : type;\n const handleClick = PICKER_TYPES.has(type ?? \"\")\n ? (e: MouseEvent<HTMLInputElement>) => {\n rest.onClick?.(e);\n try {\n // showPicker throws if unsupported or not user-activated — a click is\n // a valid activation, so this is safe; guard for older browsers.\n (e.currentTarget as HTMLInputElement & { showPicker?: () => void }).showPicker?.();\n } catch {\n /* ignore */\n }\n }\n : rest.onClick;\n const passwordText = useKitLabels(\"passwordReveal\", DEFAULT_PASSWORD_REVEAL_LABELS, passwordLabels);\n const revealToggle = isPassword ? (\n <button\n type=\"button\"\n // `tabIndex={-1}` sat here, which made this a painted, clickable control that\n // Tab stepped straight over — on the one field whose value cannot be checked by\n // looking at it. There is no mouse-only case for a reveal toggle; the people who\n // cannot see what they typed are exactly who it is for. It is a tab stop now,\n // and it needs a focus ring of its own, because FIELD_BASE's ring belongs to the\n // input underneath and stays put while focus moves onto the button on top of it.\n onClick={() => setRevealed((v) => !v)}\n // …and since it is a tab stop, a field the user may not edit must not hand the\n // keyboard a control that shows what is in it — a locked one included.\n disabled={rest.disabled || locked}\n aria-label={revealed ? passwordText.hide : passwordText.show}\n aria-pressed={revealed}\n className={cn(\n \"absolute inset-y-0 end-0 flex items-center rounded-e-md px-2.5 text-placeholder transition-colors hover:text-secondary focus:outline-none disabled:cursor-default disabled:opacity-50\",\n FOCUS_RING,\n )}\n >\n {revealed ? <EyeOff className=\"size-4\" /> : <Eye className=\"size-4\" />}\n </button>\n ) : null;\n if (label === undefined) {\n // A \"?\" with no label line to ride goes at the end edge, outside the box, and\n // `className` moves to that row (see `hint`); without one nothing moves.\n const endHint = hintParts.labelHint;\n const ownClass = endHint === undefined ? boxClass : undefined;\n if (!isPassword) {\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n {withReason(\n <EndHintRow hint={endHint} className={boxClass}>\n <input\n ref={ref}\n id={id}\n type={type}\n placeholder={placeholder}\n {...rest}\n {...lockProps}\n // AFTER the spread, so the prop wins — but OR-ed with whatever the spread\n // carried, or setting `invalid` would have quietly deleted a caller's own\n // `aria-invalid`. The prop is the one that also paints; a bare attribute\n // still announces, which is all it ever did.\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n // Likewise merged rather than replaced — see {@link useFieldError}.\n aria-describedby={describedBy}\n onClick={handleClick}\n onChange={onChange}\n className={cn(FIELD_BASE, ownClass, inputClassName, isInvalid && FIELD_INVALID, locked && \"cursor-not-allowed\")}\n />\n </EndHintRow>,\n )}\n </FieldGroup>\n );\n }\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n {withReason(\n <EndHintRow hint={endHint} className={boxClass}>\n <div className={cn(\"relative\", ownClass)}>\n <input\n ref={ref}\n id={id}\n type={effectiveType}\n placeholder={placeholder}\n {...rest}\n {...lockProps}\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n aria-describedby={describedBy}\n onChange={onChange}\n className={cn(FIELD_BASE, \"pe-9\", inputClassName, isInvalid && FIELD_INVALID, locked && \"cursor-not-allowed\")}\n />\n {revealToggle}\n </div>\n </EndHintRow>,\n )}\n </FieldGroup>\n );\n }\n const ownPlaceholder = hasPlaceholderText(placeholder);\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n {withReason(\n <FloatingField\n className={boxClass}\n htmlFor={fieldId}\n label={label}\n srOnlyLabel={asDisplay}\n hint={hintParts.labelHint}\n >\n <input\n ref={ref}\n id={fieldId}\n type={effectiveType}\n // A labelled field with no placeholder of its own gets a single space, which\n // feeds the floating label's peer-placeholder-shown trick. A caller's own is\n // kept and shown on focus, once the label has floated out of its way (kastlan\n // 8) — see FLOATING_PLACEHOLDER_ON_FOCUS. With the label sr-only there is no\n // float left to drive, and an empty borderless line would say nothing at all\n // — so there the label text is the placeholder when the caller gave none.\n placeholder={\n asDisplay\n ? (placeholder ?? (typeof label === \"string\" ? label : \" \"))\n : ownPlaceholder\n ? placeholder\n : \" \"\n }\n {...rest}\n {...lockProps}\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n aria-describedby={describedBy}\n onClick={handleClick}\n onChange={onChange}\n className={cn(\n asDisplay\n ? cn(FIELD_DISPLAY, \"text-xl font-semibold leading-snug\")\n : cn(FLOATING_INPUT_CLASS, ownPlaceholder && FLOATING_PLACEHOLDER_ON_FOCUS),\n isPassword && \"pe-9\",\n inputClassName,\n isInvalid && FIELD_INVALID,\n locked && \"cursor-not-allowed\",\n )}\n />\n {revealToggle}\n </FloatingField>,\n )}\n </FieldGroup>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/input.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport { Eye, EyeOff } from \"lucide-react\";\nimport type { ChangeEvent, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { usePhoneLayout } from \"../hooks/use-breakpoint\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport type { CommitScope } from \"./write-lock\";\nimport {\n EndHintRow,\n FieldCaption,\n LockedReason,\n mergeDescribedBy,\n type DisabledReasonDisplay,\n type FieldHelpProps,\n useFieldError,\n useFieldHint,\n useLockReason,\n} from \"./field-parts\";\nimport { DEFAULT_PASSWORD_REVEAL_LABELS } from \"./ui-labels\";\nimport {\n CharacterCount,\n FIELD_BASE,\n FIELD_DISPLAY,\n FIELD_INVALID,\n FieldGroup,\n FloatingField,\n FLOATING_INPUT_CLASS,\n FLOATING_PLACEHOLDER_ON_FOCUS,\n fieldBelow,\n hasPlaceholderText,\n useCharacterCountLabels,\n useTypedLength,\n type CharacterCountLabels,\n type PasswordRevealLabels,\n} from \"./field-base\";\n\n// Native date/time inputs only reveal the calendar via the tiny trailing icon;\n// open the picker on a click anywhere in the field instead (feedback #224).\nconst PICKER_TYPES = new Set([\"date\", \"datetime-local\", \"month\", \"time\", \"week\"]);\n\nexport interface InputProps extends InputHTMLAttributes<HTMLInputElement>, FieldHelpProps {\n label?: ReactNode;\n /** Classes for the `<input>` itself, as distinct from `className`, which\n * styles the field WRAPPER once a `label` turns this into a FloatingField.\n * Without it a labelled Input had no way to reach its own element — so\n * `tabular-nums` on a numeric text field, which NumberInput has supported\n * all along through the identically named prop, was simply unavailable. */\n inputClassName?: string;\n /** {@link FIELD_DISPLAY} — on a phone, drop the chrome and set the value as a\n * heading. For the one field a form is about (a feedback subject, an account\n * name), never for a stack of them. Labelled fields only: the label is what\n * the placeholder falls back to once it goes `sr-only`. */\n variant?: \"field\" | \"display\";\n /** The field is required and unanswered, or holds something that cannot be\n * saved — {@link FIELD_INVALID}, the same rose border/ring `Select` has worn\n * since feedback #235.\n *\n * It exists here because writing `aria-invalid` by hand did NOT do this. The\n * attribute spreads onto the element and nothing styles it — there is no\n * `[aria-invalid]` rule in this package or in either consumer's stylesheet —\n * so three Keksdose dialogs flagged a mismatched passphrase to a screen reader\n * and painted the field exactly as if it were fine. Setting the prop sets the\n * attribute too, so the two can no longer be spelled separately. */\n invalid?: boolean;\n /** What is wrong with the value, in the caller's own words (\"That IBAN has 21\n * digits\"). Rendered under the field, pointed at by the control's\n * `aria-describedby` — MERGED with any the caller already passed — and implies\n * `invalid`, so the field paints as well as announces. `invalid` alone still\n * covers the case where the message lives elsewhere. See {@link useFieldError}. */\n error?: ReactNode;\n /**\n * Standing advice for the field (keksdose K4: Input had `error` but no `hint`, so the\n * app built the caption by hand under each field, and half of those never reached a\n * screen reader). The {@link Select} rule, so every field reads alike:\n * - plain TEXT (a string or a number) is a caption UNDER the field, attached with\n * `aria-describedby` — after the caller's own ids, before the error's;\n * - anything else (a {@link FieldHint} \"?\") rides the label line beside the\n * animated label, as NumberInput's does — or, on a field with no label, sits at the\n * field's end edge outside the box, and `className` then styles that row.\n */\n hint?: ReactNode;\n /**\n * A live \"12/80\" under the field, with `maxLength` (keksdose K11 — the broadcast,\n * support and handoff-note fields counted by hand). Read with the field as \"12 of 80\n * characters\", and announced politely only on entering the last stretch and on\n * reaching the limit — never on every keystroke. See {@link CharacterCount}. Ignored\n * without a `maxLength`: a count with no limit is not what this answers. Counts\n * UTF-16 code units, as `maxLength` itself does. Controlled, it counts `value`;\n * uncontrolled, `defaultValue` and then every change event — a value written from\n * outside without one (a form library's `reset`) shows from the next keystroke.\n */\n showCount?: boolean;\n /** The counter's words. See {@link CharacterCountLabels}. */\n countLabels?: Partial<CharacterCountLabels>;\n /** Names for the password reveal toggle, English by default — it is the one\n * string this component renders on its own behalf, and a German form was\n * reading it out in English. See {@link PasswordRevealLabels}. */\n passwordLabels?: Partial<PasswordRevealLabels>;\n /**\n * Why the value cannot be changed — {@link Button}'s `disabledReason`, for a text field\n * that SAVES itself: a name committed on blur (0.34, KS U10 — Kurvenschmiede's segment\n * and profile names, behind a local `CommitInput`). `disabled` takes the field out of\n * the tab order, so the reason never reached a keyboard.\n *\n * With a reason the field stays in the tab order and shows its value, `readOnly` (the\n * settled grey FIELD_BASE gives a `[readonly]` field) and `aria-disabled` — NumberInput's\n * lock (0.33.1), Autocomplete's. Nothing a save listens to is reported: not `onChange`,\n * not `onBlur` (on a field that saves itself, blur IS the save), and not Enter, which is\n * swallowed so the field does not submit the form around it; the caller's `onKeyDown`\n * still hears every other key. The reason is a line under the field at Large and on a\n * touch screen, else in the kit {@link Tooltip} (`disabledReasonDisplay`), and on the\n * input's `aria-describedby`, between the caption and the error. Wins over `disabled`.\n */\n disabledReason?: ReactNode;\n /**\n * Where the reason shows — {@link Button}'s `disabledReasonDisplay`: by default a line\n * under the field at Large and on a touch screen, the tooltip otherwise (§4 \"No fact\n * only in a tooltip\"; DUP-3, refactor audit 2026-10).\n */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /**\n * This field COMMITS — {@link Button}'s `commit`: under a locked\n * {@link WriteLockProvider} it is locked the `disabledReason` way with the lock's reason\n * (which wins over its own). A field whose value only reaches the server through its\n * form's Save leaves this off and stays editable under the lock — the Save takes\n * `commit`. No provider, or an unlocked one: no effect.\n */\n commit?: CommitScope;\n /** The `<input>`. A React 19 ref prop since 0.34 (MOD-4, refactor audit 2026-10): as\n * `forwardRef`, with a `displayName` line beside it, this module could not drop an\n * unused component, and `{ Spinner }` brought Input, Select and Textarea along. */\n ref?: Ref<HTMLInputElement>;\n}\n\n/** What a locked {@link Input} swallows of a key: Enter, which would submit the form. */\nfunction swallowEnter(onKeyDown: ((e: KeyboardEvent<HTMLInputElement>) => void) | undefined) {\n return (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n return;\n }\n onKeyDown?.(e);\n };\n}\n\nexport function Input(props: InputProps) {\n const {\n ref,\n className,\n inputClassName,\n label,\n id,\n placeholder,\n type,\n variant = \"field\",\n invalid,\n error,\n hint,\n help,\n showCount,\n countLabels,\n passwordLabels,\n disabledReason,\n disabledReasonDisplay,\n commit,\n ...rest\n } = props;\n // Passed at all — even as `undefined` — the field keeps its box; see FieldGroup. The\n // same for `hint` and `showCount` (keksdose, 0.22): a caption coming and going under\n // the control must not rebuild it, or focus and the caret are lost mid-edit.\n const reserve = \"error\" in props || \"hint\" in props || \"showCount\" in props;\n const generated = useId();\n const fieldId = id ?? generated;\n const countId = useId();\n const hintParts = useFieldHint(hint, rest[\"aria-describedby\"], { help, name: label ?? rest[\"aria-label\"] });\n const maxLength = rest.maxLength;\n const counting = Boolean(showCount) && typeof maxLength === \"number\" && maxLength > 0;\n const length = useTypedLength(rest.value, rest.defaultValue);\n const countText = useCharacterCountLabels(countLabels);\n // The lock (0.34, KS U10): focusable, `readOnly`, nothing a save listens to is\n // reported — see `disabledReason`.\n const lock = useLockReason(commit, disabledReason);\n const locked = lock.locked;\n const { isInvalid, describedBy, errorEl } = useFieldError(\n error,\n invalid,\n // Caller's, caption, count, a lock's reason — then the error, appended by the hook.\n mergeDescribedBy(hintParts.describedBy, counting && countId, locked && lock.reasonId),\n rest[\"aria-invalid\"],\n );\n const counter = counting ? (\n <CharacterCount\n id={countId}\n used={length.used}\n max={maxLength}\n labels={countText}\n caption={<FieldCaption parts={hintParts} className=\"mt-0\" />}\n />\n ) : null;\n const below = fieldBelow(hintParts, counter, errorEl);\n const onChange = locked\n ? undefined\n : counting\n ? (e: ChangeEvent<HTMLInputElement>) => {\n length.track(e.target.value);\n rest.onChange?.(e);\n }\n : rest.onChange;\n // Locked, these win over what the caller's spread carried: focusable rather than\n // `disabled`, `readOnly`, no blur reported (blur is a self-saving field's save), and\n // Enter swallowed. Spread AFTER `rest` on every branch's input.\n const lockProps = locked\n ? {\n disabled: undefined,\n readOnly: true,\n \"aria-disabled\": true as const,\n onBlur: undefined,\n onKeyDown: swallowEnter(rest.onKeyDown),\n }\n : undefined;\n // While locked the field sits in the reason's frame (the line, or the Tooltip), which\n // becomes its outermost box — so `className` (a width, a flex or grid cell) moves onto\n // that, as on Select, and the form keeps its layout. `block` so a full-width field\n // stays full width in it.\n const boxClass = locked ? undefined : className;\n const withReason = (box: ReactNode) =>\n locked ? (\n <LockedReason lock={lock} display={disabledReasonDisplay} className={cn(\"block\", className)}>\n {box}\n </LockedReason>\n ) : (\n box\n );\n const asDisplay = usePhoneLayout() && variant === \"display\";\n // Password fields get a reveal toggle so users can check what they typed.\n const isPassword = type === \"password\";\n const [revealed, setRevealed] = useState(false);\n const effectiveType = isPassword && revealed ? \"text\" : type;\n const handleClick = PICKER_TYPES.has(type ?? \"\")\n ? (e: MouseEvent<HTMLInputElement>) => {\n rest.onClick?.(e);\n try {\n // showPicker throws if unsupported or not user-activated — a click is\n // a valid activation, so this is safe; guard for older browsers.\n (e.currentTarget as HTMLInputElement & { showPicker?: () => void }).showPicker?.();\n } catch {\n /* ignore */\n }\n }\n : rest.onClick;\n const passwordText = useKitLabels(\"passwordReveal\", DEFAULT_PASSWORD_REVEAL_LABELS, passwordLabels);\n const revealToggle = isPassword ? (\n <button\n type=\"button\"\n // `tabIndex={-1}` sat here, which made this a painted, clickable control that\n // Tab stepped straight over — on the one field whose value cannot be checked by\n // looking at it. There is no mouse-only case for a reveal toggle; the people who\n // cannot see what they typed are exactly who it is for. It is a tab stop now,\n // and it needs a focus ring of its own, because FIELD_BASE's ring belongs to the\n // input underneath and stays put while focus moves onto the button on top of it.\n onClick={() => setRevealed((v) => !v)}\n // …and since it is a tab stop, a field the user may not edit must not hand the\n // keyboard a control that shows what is in it — a locked one included.\n disabled={rest.disabled || locked}\n aria-label={revealed ? passwordText.hide : passwordText.show}\n aria-pressed={revealed}\n className={cn(\n \"absolute inset-y-0 end-0 flex items-center rounded-e-md px-2.5 text-placeholder transition-colors hover:text-secondary focus:outline-none disabled:cursor-default disabled:opacity-50\",\n FOCUS_RING,\n )}\n >\n {revealed ? <EyeOff className=\"size-4\" /> : <Eye className=\"size-4\" />}\n </button>\n ) : null;\n if (label === undefined) {\n // A \"?\" with no label line to ride goes at the end edge, outside the box, and\n // `className` moves to that row (see `hint`); without one nothing moves.\n const endHint = hintParts.labelHint;\n const ownClass = endHint === undefined ? boxClass : undefined;\n if (!isPassword) {\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n {withReason(\n <EndHintRow hint={endHint} className={boxClass}>\n <input\n ref={ref}\n id={id}\n type={type}\n placeholder={placeholder}\n {...rest}\n {...lockProps}\n // AFTER the spread, so the prop wins — but OR-ed with whatever the spread\n // carried, or setting `invalid` would have quietly deleted a caller's own\n // `aria-invalid`. The prop is the one that also paints; a bare attribute\n // still announces, which is all it ever did.\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n // Likewise merged rather than replaced — see {@link useFieldError}.\n aria-describedby={describedBy}\n onClick={handleClick}\n onChange={onChange}\n className={cn(FIELD_BASE, ownClass, inputClassName, isInvalid && FIELD_INVALID, locked && \"cursor-not-allowed\")}\n />\n </EndHintRow>,\n )}\n </FieldGroup>\n );\n }\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n {withReason(\n <EndHintRow hint={endHint} className={boxClass}>\n <div className={cn(\"relative\", ownClass)}>\n <input\n ref={ref}\n id={id}\n type={effectiveType}\n placeholder={placeholder}\n {...rest}\n {...lockProps}\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n aria-describedby={describedBy}\n onChange={onChange}\n className={cn(FIELD_BASE, \"pe-9\", inputClassName, isInvalid && FIELD_INVALID, locked && \"cursor-not-allowed\")}\n />\n {revealToggle}\n </div>\n </EndHintRow>,\n )}\n </FieldGroup>\n );\n }\n const ownPlaceholder = hasPlaceholderText(placeholder);\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n {withReason(\n <FloatingField\n className={boxClass}\n htmlFor={fieldId}\n label={label}\n srOnlyLabel={asDisplay}\n hint={hintParts.labelHint}\n >\n <input\n ref={ref}\n id={fieldId}\n type={effectiveType}\n // A labelled field with no placeholder of its own gets a single space, which\n // feeds the floating label's peer-placeholder-shown trick. A caller's own is\n // kept and shown on focus, once the label has floated out of its way (kastlan\n // 8) — see FLOATING_PLACEHOLDER_ON_FOCUS. With the label sr-only there is no\n // float left to drive, and an empty borderless line would say nothing at all\n // — so there the label text is the placeholder when the caller gave none.\n placeholder={\n asDisplay\n ? (placeholder ?? (typeof label === \"string\" ? label : \" \"))\n : ownPlaceholder\n ? placeholder\n : \" \"\n }\n {...rest}\n {...lockProps}\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n aria-describedby={describedBy}\n onClick={handleClick}\n onChange={onChange}\n className={cn(\n asDisplay\n ? cn(FIELD_DISPLAY, \"text-xl font-semibold leading-snug\")\n : cn(FLOATING_INPUT_CLASS, ownPlaceholder && FLOATING_PLACEHOLDER_ON_FOCUS),\n isPassword && \"pe-9\",\n inputClassName,\n isInvalid && FIELD_INVALID,\n locked && \"cursor-not-allowed\",\n )}\n />\n {revealToggle}\n </FloatingField>,\n )}\n </FieldGroup>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
|
1
|
+
import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode, Ref } from "react";
|
|
2
2
|
import { type DialogParam } from "../hooks/use-search-param-state.js";
|
|
3
3
|
/**
|
|
4
4
|
* The panel's own ANIMATED dismissal, for chrome rendered inside it. `DialogFrame`'s X
|
|
@@ -130,6 +130,14 @@ export interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"
|
|
|
130
130
|
* `onClose` still runs, after the param is cleared. Requires a react-router Router.
|
|
131
131
|
*/
|
|
132
132
|
urlParam?: DialogParam;
|
|
133
|
+
/**
|
|
134
|
+
* The PANEL — the `role="dialog"` element the rest of the props go on, not the
|
|
135
|
+
* backdrop around it (0.35, round 0.35 R4, the follow-up to API-2; as on
|
|
136
|
+
* `FullBleedDialog`). Merged with the panel's own ref, which the focus
|
|
137
|
+
* trap holds. Set while the panel is in the document: `null` while a dialog with
|
|
138
|
+
* `open={false}` is closed, and after a `urlParam` dialog's param is cleared.
|
|
139
|
+
*/
|
|
140
|
+
ref?: Ref<HTMLDivElement>;
|
|
133
141
|
}
|
|
134
142
|
/**
|
|
135
143
|
* Centered (bottom-sheet on mobile) modal dialog: dimmed backdrop, single
|
package/dist/components/modal.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx } from "react/jsx-runtime";
|
|
3
|
-
import { createContext, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { createContext, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { mergeRefs } from "./field-parts.js";
|
|
6
7
|
import { useFocusTrap } from "../hooks/use-focus-trap.js";
|
|
7
8
|
import { useBodyScrollLock } from "../hooks/use-body-scroll-lock.js";
|
|
8
9
|
import { useOverlayHistory } from "../hooks/use-overlay-history.js";
|
|
@@ -94,9 +95,11 @@ function ModalPanel({
|
|
|
94
95
|
role = "dialog",
|
|
95
96
|
urlParam: _urlParam,
|
|
96
97
|
ownHistoryEntry = true,
|
|
98
|
+
ref,
|
|
97
99
|
...rest
|
|
98
100
|
}) {
|
|
99
101
|
const panelRef = useRef(null);
|
|
102
|
+
const setPanel = useMemo(() => mergeRefs(panelRef, ref), [ref]);
|
|
100
103
|
const drag = useDragOffset(Boolean(draggable));
|
|
101
104
|
const active = open ?? true;
|
|
102
105
|
const [dismissed, setDismissed] = useState(false);
|
|
@@ -123,9 +126,9 @@ function ModalPanel({
|
|
|
123
126
|
useBodyScrollLock(active);
|
|
124
127
|
useFocusTrap(panelRef, { active, initialFocus: "container" });
|
|
125
128
|
const leaving = closing || exiting;
|
|
126
|
-
useDismissLayer(active, requestClose);
|
|
129
|
+
const layer = useDismissLayer(active, requestClose);
|
|
127
130
|
const handleKeyDown = (e) => {
|
|
128
|
-
runOwnKeyDown(e, onKeyDown);
|
|
131
|
+
runOwnKeyDown(e, onKeyDown, layer);
|
|
129
132
|
takeEscape(e);
|
|
130
133
|
};
|
|
131
134
|
if (!active && !exiting) return null;
|
|
@@ -147,7 +150,7 @@ function ModalPanel({
|
|
|
147
150
|
"div",
|
|
148
151
|
{
|
|
149
152
|
...rest,
|
|
150
|
-
ref:
|
|
153
|
+
ref: setPanel,
|
|
151
154
|
role: role === "alertdialog" ? "alertdialog" : "dialog",
|
|
152
155
|
"aria-modal": "true",
|
|
153
156
|
"aria-labelledby": labelledBy,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/modal.tsx"],"sourcesContent":["import { createContext, useEffect, 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 { useDialogParam, type DialogParam } from \"../hooks/use-search-param-state\";\nimport { OVERLAY_EXIT_MS, skipsExitAnimation, useCloseTransition } from \"../hooks/use-close-transition\";\nimport { runOwnKeyDown, takeEscape, useDismissLayer } from \"../hooks/use-dismiss\";\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 /** Back to where the panel opens. A `Modal` with an `open` prop outlives its own\n * closes, so \"the position resets when the dialog closes\" needs saying. */\n reset: () => setOffset(null),\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 * The panel's own ANIMATED dismissal, for chrome rendered inside it. `DialogFrame`'s X\n * and its `actions(close)` read this, so a Cancel or an X lowers the panel exactly the\n * way Escape does — calling `onClose` from inside unmounts with no exit, which is the\n * documented limit of {@link useCloseTransition}. `null` outside a `Modal`.\n */\nexport const ModalCloseContext = createContext<(() => void) | null>(null);\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 Omit<ComponentPropsWithoutRef<\"div\">, \"role\"> {\n /** Invoked on backdrop click and on Escape. */\n onClose: () => void;\n /**\n * Who decides whether the dialog is up, when the caller wants to keep it MOUNTED.\n *\n * Left out, the dialog is open for as long as it is mounted — `{open && <Modal/>}`,\n * which is every caller before this prop and stays exactly as it was. That idiom has\n * one blind spot: a close the CALLER decides on (a save that succeeded, a\n * `setOpen(false)` from outside) unmounts the panel on the spot, with no exit,\n * because there is nothing left to animate. kastlan's `FormModal` was nothing but an\n * `if (!open) return null` gate in front of this.\n *\n * Passed, the component stays mounted while closed and renders nothing, and a flip\n * to `false` plays the same exit a dismissal does before the panel goes. The focus\n * trap, the scroll lock and the Back-gesture history entry are held only while\n * `open` is true — they are released the moment it turns false, so focus is back on\n * the trigger while the panel is still lowering. A dismissal (Escape, the backdrop,\n * Back, the frame's X) animates first and then calls `onClose`; the `false` that\n * comes back from it is not animated a second time.\n *\n * The body is unmounted once the exit has finished, as it is today, so a form inside\n * still starts fresh on the next open.\n */\n open?: boolean;\n /** `\"alertdialog\"` for a dialog that interrupts to ask a question the user must\n * answer (a confirmation) — screen readers announce it with more urgency. Only the\n * two dialog roles are allowed: the panel stays modal either way. */\n role?: \"dialog\" | \"alertdialog\";\n children: ReactNode;\n /**\n * Panel max-width: `md` (28rem), `lg` (32rem), `xl` (48rem), `2xl` (the display less\n * the backdrop's margin, capped at 110rem).\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 * `2xl` is the same signal one size up (round 0.34 C3, KS U1): a dialog whose\n * content is a TABLE to be read. Kurvenschmiede's five part dialogs hold a measured\n * table pasted from a spreadsheet and had patched themselves to\n * `w-[min(110rem,94vw)] max-w-none`; Keksdose's report drilldown passed `size=\"lg\"`\n * and then `max-w-5xl`. The cap is the generous one of the two, because a table\n * squeezed into a box in the middle of a wide screen is a table scrolled sideways\n * (KS feedback #16); below the cap the panel is as wide as the backdrop lets it be.\n * In rem, so it grows with the text size until the screen stops it.\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 four 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\" | \"2xl\";\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; a\n * `preventDefault()` on Escape HERE keeps the dialog open — the owner's claim (see\n * `use-dismiss.ts`). One a field inside makes does not, since 0.34: the rule\n * `FullBleedDialog` already had, so a search box keeping Chromium from clearing itself\n * no longer holds the dialog open (Wave 2, DUP-1).\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 * Bind the open state to a URL search param (0.12.0): `\"edit\"` is open while\n * `?edit` is in the query, `{ key: \"dialog\", value: \"create-lease\" }` while\n * `?dialog=create-lease` is. The URL is then the only state — `open` is ignored —\n * and the dialog opens from anywhere that sets the param: {@link useDialogParam}'s\n * setter, a `<Link>`, a shared link, a reload.\n *\n * kastlan's `useDialogParam` did this beside the dialog, so its open state could be\n * deep-linked and captured in a feedback report. Here it also owns the history:\n * opening through `useDialogParam` pushes an entry, so the Back gesture closes the\n * dialog by leaving it, and a close (Escape, the backdrop, the X) goes back over\n * that entry rather than stacking a second one. The dialog's own Back handling\n * ({@link useOverlayHistory}'s throwaway entry) stays off in this mode — the param's\n * entry already is one, and two would take two Back presses to close one dialog.\n *\n * `onClose` still runs, after the param is cleared. Requires a react-router Router.\n */\n urlParam?: DialogParam;\n}\n\n/**\n * The URL-bound dialog: the param is the open state, and closing clears it. A\n * component of its own so the router hook is only called by a `Modal` that asked for\n * it — a plain `Modal` must keep working with no Router above it.\n */\nfunction UrlParamModal({ urlParam, onClose, ...rest }: ModalProps & { urlParam: DialogParam }) {\n const [open, setOpen] = useDialogParam(urlParam);\n // Closed from here the moment the dismissal is decided. Going back is a traversal\n // the browser lands a task later, and between the two the param still says \"open\":\n // without this the panel, already lowered, would pop back up for that task.\n const [leaving, setLeaving] = useState(false);\n if (leaving && !open) setLeaving(false);\n return (\n <ModalPanel\n {...rest}\n open={open && !leaving}\n ownHistoryEntry={false}\n onClose={() => {\n setLeaving(true);\n setOpen(false);\n onClose();\n }}\n />\n );\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(props: ModalProps) {\n return props.urlParam !== undefined ? (\n <UrlParamModal {...props} urlParam={props.urlParam} />\n ) : (\n <ModalPanel {...props} />\n );\n}\n\nfunction ModalPanel({\n onClose,\n open,\n children,\n size = \"md\",\n className,\n labelledBy,\n onKeyDown,\n fullBleed,\n draggable,\n style,\n role = \"dialog\",\n urlParam: _urlParam,\n ownHistoryEntry = true,\n ...rest\n}: ModalProps & {\n /** False when the URL already holds an entry for this dialog — see `urlParam`. */\n ownHistoryEntry?: boolean;\n}) {\n const panelRef = useRef<HTMLDivElement>(null);\n const drag = useDragOffset(Boolean(draggable));\n // Without `open`, mounted means open — the constant `true` every hook below used to\n // be given. With it, `open` itself.\n const active = open ?? true;\n\n // Whether the last close came through `requestClose`, i.e. has ALREADY been\n // animated. Set in the same batch as the caller's `onClose`, so the render that sees\n // `open` turn false also sees this and does not lower the panel a second time.\n const [dismissed, setDismissed] = useState(false);\n // The exit of a close the caller decided on (only reachable with `open`). Adjusted\n // during render when `open` flips — `Collapse`'s pattern — so the very render that\n // closes it already paints the `-out` classes rather than a frame of nothing.\n const [exiting, setExiting] = useState(false);\n const [prevOpen, setPrevOpen] = useState(open);\n if (open !== prevOpen) {\n setPrevOpen(open);\n setDismissed(false);\n setExiting(open === false && prevOpen === true && !dismissed && !skipsExitAnimation());\n if (open) drag.reset();\n }\n useEffect(() => {\n if (!exiting) return;\n const timer = setTimeout(() => setExiting(false), OVERLAY_EXIT_MS);\n return () => clearTimeout(timer);\n }, [exiting]);\n\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 unless it passes `open`, which is what that prop is for.\n const { closing, requestClose } = useCloseTransition(() => {\n setDismissed(true);\n onClose();\n });\n // A panel on its way out after `open` turned false is already closed: a press on\n // its fading backdrop must not report a second close.\n const backdropClose = useBackdropClose(active ? 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 // Held only while open: a closed `open={false}` dialog must not own a history entry.\n useOverlayHistory(active && ownHistoryEntry, requestClose);\n\n // Through the shared hook and not by hand: this component's own save/restore copy\n // was one half of the pair that left the page permanently unscrollable when a dialog\n // containing an open sheet was closed — see the note in use-body-scroll-lock.ts.\n useBodyScrollLock(active);\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. Released when `open` turns false, which\n // hands focus back to the trigger while the panel is still on its way out.\n //\n // \"The panel itself\" only when nothing inside asked for focus: a field the app\n // autofocuses on purpose keeps it (0.33.1, keksdose live #402 — the same trap took\n // an autofocused amount straight back in `FullBleedDialog`). Its keyboard is then the\n // one the app chose to show; the trigger still gets focus back on close.\n useFocusTrap(panelRef, { active, initialFocus: \"container\" });\n const leaving = closing || exiting;\n\n // One layer on the Escape stack while open (Wave 2, DUP-1): a list, a popover or a\n // tooltip opened inside takes the key before the dialog does, wherever focus is —\n // and the dialog gets it even when focus is not in the panel.\n useDismissLayer(active, requestClose);\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n runOwnKeyDown(e, onKeyDown);\n // To whatever is on top — this dialog, unless something opened over it — and\n // stopped here. Tab containment lives in useFocusTrap, which listens on the panel.\n takeEscape(e);\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 if (!active && !exiting) return null;\n return createPortal(\n <div\n // Out of the accessibility tree for the length of a caller-driven exit: focus is\n // already back on the page, and an `aria-modal` panel that is leaving would hide\n // that page from a screen reader for 220ms. Not `inert` — that would let a tap\n // fall through the fading backdrop to the row underneath.\n aria-hidden={exiting || undefined}\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 leaving ? \"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 {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- always\n role=\"dialog\" or \"alertdialog\" (the ternary hides that from the rule): `onKeyDown`\n is Escape-to-close per the APG dialog pattern, and the drag handlers are\n swipe-to-dismiss, whose keyboard path is that same Escape. */}\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 // Normalised at runtime too: an untyped caller's `role=\"presentation\"` must not\n // take the dialog role away (props-passthrough pins that).\n role={role === \"alertdialog\" ? \"alertdialog\" : \"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 leaving ? \"animate-sheet-out md:animate-none\" : \"animate-sheet md:animate-none\",\n \"w-full rounded-lg border border-subtle bg-surface shadow-sm outline-none\",\n { md: \"max-w-md\", lg: \"max-w-lg\", xl: \"max-w-3xl\", \"2xl\": \"max-w-[110rem]\" }[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 <ModalCloseContext.Provider value={requestClose}>{children}</ModalCloseContext.Provider>\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 { createContext, useEffect, useMemo, 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 Ref,\n} from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { mergeRefs } from \"./field-parts\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\nimport { useBodyScrollLock } from \"../hooks/use-body-scroll-lock\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { useDialogParam, type DialogParam } from \"../hooks/use-search-param-state\";\nimport { OVERLAY_EXIT_MS, skipsExitAnimation, useCloseTransition } from \"../hooks/use-close-transition\";\nimport { runOwnKeyDown, takeEscape, useDismissLayer } from \"../hooks/use-dismiss\";\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 /** Back to where the panel opens. A `Modal` with an `open` prop outlives its own\n * closes, so \"the position resets when the dialog closes\" needs saying. */\n reset: () => setOffset(null),\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 * The panel's own ANIMATED dismissal, for chrome rendered inside it. `DialogFrame`'s X\n * and its `actions(close)` read this, so a Cancel or an X lowers the panel exactly the\n * way Escape does — calling `onClose` from inside unmounts with no exit, which is the\n * documented limit of {@link useCloseTransition}. `null` outside a `Modal`.\n */\nexport const ModalCloseContext = createContext<(() => void) | null>(null);\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 Omit<ComponentPropsWithoutRef<\"div\">, \"role\"> {\n /** Invoked on backdrop click and on Escape. */\n onClose: () => void;\n /**\n * Who decides whether the dialog is up, when the caller wants to keep it MOUNTED.\n *\n * Left out, the dialog is open for as long as it is mounted — `{open && <Modal/>}`,\n * which is every caller before this prop and stays exactly as it was. That idiom has\n * one blind spot: a close the CALLER decides on (a save that succeeded, a\n * `setOpen(false)` from outside) unmounts the panel on the spot, with no exit,\n * because there is nothing left to animate. kastlan's `FormModal` was nothing but an\n * `if (!open) return null` gate in front of this.\n *\n * Passed, the component stays mounted while closed and renders nothing, and a flip\n * to `false` plays the same exit a dismissal does before the panel goes. The focus\n * trap, the scroll lock and the Back-gesture history entry are held only while\n * `open` is true — they are released the moment it turns false, so focus is back on\n * the trigger while the panel is still lowering. A dismissal (Escape, the backdrop,\n * Back, the frame's X) animates first and then calls `onClose`; the `false` that\n * comes back from it is not animated a second time.\n *\n * The body is unmounted once the exit has finished, as it is today, so a form inside\n * still starts fresh on the next open.\n */\n open?: boolean;\n /** `\"alertdialog\"` for a dialog that interrupts to ask a question the user must\n * answer (a confirmation) — screen readers announce it with more urgency. Only the\n * two dialog roles are allowed: the panel stays modal either way. */\n role?: \"dialog\" | \"alertdialog\";\n children: ReactNode;\n /**\n * Panel max-width: `md` (28rem), `lg` (32rem), `xl` (48rem), `2xl` (the display less\n * the backdrop's margin, capped at 110rem).\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 * `2xl` is the same signal one size up (round 0.34 C3, KS U1): a dialog whose\n * content is a TABLE to be read. Kurvenschmiede's five part dialogs hold a measured\n * table pasted from a spreadsheet and had patched themselves to\n * `w-[min(110rem,94vw)] max-w-none`; Keksdose's report drilldown passed `size=\"lg\"`\n * and then `max-w-5xl`. The cap is the generous one of the two, because a table\n * squeezed into a box in the middle of a wide screen is a table scrolled sideways\n * (KS feedback #16); below the cap the panel is as wide as the backdrop lets it be.\n * In rem, so it grows with the text size until the screen stops it.\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 four 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\" | \"2xl\";\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; a\n * `preventDefault()` on Escape HERE keeps the dialog open — the owner's claim (see\n * `use-dismiss.ts`). One a field inside makes does not, since 0.34: the rule\n * `FullBleedDialog` already had, so a search box keeping Chromium from clearing itself\n * no longer holds the dialog open (Wave 2, DUP-1).\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 * Bind the open state to a URL search param (0.12.0): `\"edit\"` is open while\n * `?edit` is in the query, `{ key: \"dialog\", value: \"create-lease\" }` while\n * `?dialog=create-lease` is. The URL is then the only state — `open` is ignored —\n * and the dialog opens from anywhere that sets the param: {@link useDialogParam}'s\n * setter, a `<Link>`, a shared link, a reload.\n *\n * kastlan's `useDialogParam` did this beside the dialog, so its open state could be\n * deep-linked and captured in a feedback report. Here it also owns the history:\n * opening through `useDialogParam` pushes an entry, so the Back gesture closes the\n * dialog by leaving it, and a close (Escape, the backdrop, the X) goes back over\n * that entry rather than stacking a second one. The dialog's own Back handling\n * ({@link useOverlayHistory}'s throwaway entry) stays off in this mode — the param's\n * entry already is one, and two would take two Back presses to close one dialog.\n *\n * `onClose` still runs, after the param is cleared. Requires a react-router Router.\n */\n urlParam?: DialogParam;\n /**\n * The PANEL — the `role=\"dialog\"` element the rest of the props go on, not the\n * backdrop around it (0.35, round 0.35 R4, the follow-up to API-2; as on\n * `FullBleedDialog`). Merged with the panel's own ref, which the focus\n * trap holds. Set while the panel is in the document: `null` while a dialog with\n * `open={false}` is closed, and after a `urlParam` dialog's param is cleared.\n */\n ref?: Ref<HTMLDivElement>;\n}\n\n/**\n * The URL-bound dialog: the param is the open state, and closing clears it. A\n * component of its own so the router hook is only called by a `Modal` that asked for\n * it — a plain `Modal` must keep working with no Router above it.\n */\nfunction UrlParamModal({ urlParam, onClose, ...rest }: ModalProps & { urlParam: DialogParam }) {\n const [open, setOpen] = useDialogParam(urlParam);\n // Closed from here the moment the dismissal is decided. Going back is a traversal\n // the browser lands a task later, and between the two the param still says \"open\":\n // without this the panel, already lowered, would pop back up for that task.\n const [leaving, setLeaving] = useState(false);\n if (leaving && !open) setLeaving(false);\n return (\n <ModalPanel\n {...rest}\n open={open && !leaving}\n ownHistoryEntry={false}\n onClose={() => {\n setLeaving(true);\n setOpen(false);\n onClose();\n }}\n />\n );\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(props: ModalProps) {\n return props.urlParam !== undefined ? (\n <UrlParamModal {...props} urlParam={props.urlParam} />\n ) : (\n <ModalPanel {...props} />\n );\n}\n\nfunction ModalPanel({\n onClose,\n open,\n children,\n size = \"md\",\n className,\n labelledBy,\n onKeyDown,\n fullBleed,\n draggable,\n style,\n role = \"dialog\",\n urlParam: _urlParam,\n ownHistoryEntry = true,\n ref,\n ...rest\n}: ModalProps & {\n /** False when the URL already holds an entry for this dialog — see `urlParam`. */\n ownHistoryEntry?: boolean;\n}) {\n const panelRef = useRef<HTMLDivElement>(null);\n // The caller's `ref` beside the trap's (round 0.35 R4): memoised, so the panel is not\n // detached and re-attached on every render.\n const setPanel = useMemo(() => mergeRefs(panelRef, ref), [ref]);\n const drag = useDragOffset(Boolean(draggable));\n // Without `open`, mounted means open — the constant `true` every hook below used to\n // be given. With it, `open` itself.\n const active = open ?? true;\n\n // Whether the last close came through `requestClose`, i.e. has ALREADY been\n // animated. Set in the same batch as the caller's `onClose`, so the render that sees\n // `open` turn false also sees this and does not lower the panel a second time.\n const [dismissed, setDismissed] = useState(false);\n // The exit of a close the caller decided on (only reachable with `open`). Adjusted\n // during render when `open` flips — `Collapse`'s pattern — so the very render that\n // closes it already paints the `-out` classes rather than a frame of nothing.\n const [exiting, setExiting] = useState(false);\n const [prevOpen, setPrevOpen] = useState(open);\n if (open !== prevOpen) {\n setPrevOpen(open);\n setDismissed(false);\n setExiting(open === false && prevOpen === true && !dismissed && !skipsExitAnimation());\n if (open) drag.reset();\n }\n useEffect(() => {\n if (!exiting) return;\n const timer = setTimeout(() => setExiting(false), OVERLAY_EXIT_MS);\n return () => clearTimeout(timer);\n }, [exiting]);\n\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 unless it passes `open`, which is what that prop is for.\n const { closing, requestClose } = useCloseTransition(() => {\n setDismissed(true);\n onClose();\n });\n // A panel on its way out after `open` turned false is already closed: a press on\n // its fading backdrop must not report a second close.\n const backdropClose = useBackdropClose(active ? 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 // Held only while open: a closed `open={false}` dialog must not own a history entry.\n useOverlayHistory(active && ownHistoryEntry, requestClose);\n\n // Through the shared hook and not by hand: this component's own save/restore copy\n // was one half of the pair that left the page permanently unscrollable when a dialog\n // containing an open sheet was closed — see the note in use-body-scroll-lock.ts.\n useBodyScrollLock(active);\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. Released when `open` turns false, which\n // hands focus back to the trigger while the panel is still on its way out.\n //\n // \"The panel itself\" only when nothing inside asked for focus: a field the app\n // autofocuses on purpose keeps it (0.33.1, keksdose live #402 — the same trap took\n // an autofocused amount straight back in `FullBleedDialog`). Its keyboard is then the\n // one the app chose to show; the trigger still gets focus back on close.\n useFocusTrap(panelRef, { active, initialFocus: \"container\" });\n const leaving = closing || exiting;\n\n // One layer on the Escape stack while open (Wave 2, DUP-1): a list, a popover or a\n // tooltip opened inside takes the key before the dialog does, wherever focus is —\n // and the dialog gets it even when focus is not in the panel.\n const layer = useDismissLayer(active, requestClose);\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n // The caller's handler sees Escape only while this dialog is the layer on top: a\n // tooltip or a list open over it takes that press first (0.35, KS's gear wizard).\n runOwnKeyDown(e, onKeyDown, layer);\n // To whatever is on top — this dialog, unless something opened over it — and\n // stopped here. Tab containment lives in useFocusTrap, which listens on the panel.\n takeEscape(e);\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 if (!active && !exiting) return null;\n return createPortal(\n <div\n // Out of the accessibility tree for the length of a caller-driven exit: focus is\n // already back on the page, and an `aria-modal` panel that is leaving would hide\n // that page from a screen reader for 220ms. Not `inert` — that would let a tap\n // fall through the fading backdrop to the row underneath.\n aria-hidden={exiting || undefined}\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 leaving ? \"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 {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- always\n role=\"dialog\" or \"alertdialog\" (the ternary hides that from the rule): `onKeyDown`\n is Escape-to-close per the APG dialog pattern, and the drag handlers are\n swipe-to-dismiss, whose keyboard path is that same Escape. */}\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={setPanel}\n // Normalised at runtime too: an untyped caller's `role=\"presentation\"` must not\n // take the dialog role away (props-passthrough pins that).\n role={role === \"alertdialog\" ? \"alertdialog\" : \"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 leaving ? \"animate-sheet-out md:animate-none\" : \"animate-sheet md:animate-none\",\n \"w-full rounded-lg border border-subtle bg-surface shadow-sm outline-none\",\n { md: \"max-w-md\", lg: \"max-w-lg\", xl: \"max-w-3xl\", \"2xl\": \"max-w-[110rem]\" }[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 <ModalCloseContext.Provider value={requestClose}>{children}</ModalCloseContext.Provider>\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":";AA+PI;AA/PJ,SAAS,eAAe,WAAW,SAAS,QAAQ,gBAAgB;AACpE,SAAS,oBAAoB;AAS7B,SAAS,UAAU;AACnB,SAAS,iBAAiB;AAC1B,SAAS,oBAAoB;AAC7B,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAClC,SAAS,sBAAwC;AACjD,SAAS,iBAAiB,oBAAoB,0BAA0B;AACxE,SAAS,eAAe,YAAY,uBAAuB;AAoB3D,SAAS,cAAc,SAAkB;AACvC,QAAM,CAAC,QAAQ,SAAS,IAAI,SAA0C,IAAI;AAC1E,QAAM,OAAO,OAAgE,IAAI;AAEjF,QAAM,UAAU,MAAM;AACpB,SAAK,UAAU;AAAA,EACjB;AAEA,SAAO;AAAA;AAAA;AAAA,IAGL,OAAO,MAAM,UAAU,IAAI;AAAA,IAC3B,OAAO,SAAS,EAAE,WAAW,aAAa,OAAO,CAAC,OAAO,OAAO,CAAC,MAAM,IAAI;AAAA,IAC3E,UAAU,CAAC,UACP,SACA;AAAA,MACE,eAAe,CAAC,MAAyC;AACvD,YAAI,EAAE,WAAW,KAAK,OAAO,aAAa,IAAK;AAC/C,cAAM,SAAS,EAAE;AAGjB,YAAI,OAAO,QAAQ,gDAAgD,EAAG;AACtE,aAAK,UAAU,EAAE,GAAG,EAAE,SAAS,GAAG,EAAE,SAAS,IAAI,QAAQ,KAAK,GAAG,IAAI,QAAQ,KAAK,EAAE;AACpF,YAAI;AAKF,YAAE,cAAc,kBAAkB,EAAE,SAAS;AAAA,QAC/C,QAAQ;AAAA,QAKR;AAAA,MACF;AAAA,MACA,eAAe,CAAC,MAAyC;AACvD,cAAM,QAAQ,KAAK;AACnB,YAAI,CAAC,MAAO;AACZ,kBAAU,EAAE,GAAG,MAAM,KAAK,EAAE,UAAU,MAAM,GAAG,GAAG,MAAM,KAAK,EAAE,UAAU,MAAM,EAAE,CAAC;AAAA,MACpF;AAAA,MACA,aAAa;AAAA;AAAA;AAAA;AAAA,MAIb,iBAAiB;AAAA,MACjB,sBAAsB;AAAA,IACxB;AAAA,EACN;AACF;AAQO,MAAM,oBAAoB,cAAmC,IAAI;AASjE,SAAS,iBAAiB,SAAqB;AACpD,QAAM,yBAAyB,OAAO,KAAK;AAC3C,SAAO;AAAA,IACL,aAAa,CAAC,MAAuC;AACnD,6BAAuB,UAAU,EAAE,WAAW,EAAE;AAAA,IAClD;AAAA,IACA,WAAW,CAAC,MAAuC;AACjD,YAAM,UAAU,uBAAuB;AACvC,6BAAuB,UAAU;AACjC,UAAI,WAAW,EAAE,WAAW,EAAE,cAAe,SAAQ;AAAA,IACvD;AAAA,EACF;AACF;AAoIA,SAAS,cAAc,EAAE,UAAU,SAAS,GAAG,KAAK,GAA2C;AAC7F,QAAM,CAAC,MAAM,OAAO,IAAI,eAAe,QAAQ;AAI/C,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAC5C,MAAI,WAAW,CAAC,KAAM,YAAW,KAAK;AACtC,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ,MAAM,QAAQ,CAAC;AAAA,MACf,iBAAiB;AAAA,MACjB,SAAS,MAAM;AACb,mBAAW,IAAI;AACf,gBAAQ,KAAK;AACb,gBAAQ;AAAA,MACV;AAAA;AAAA,EACF;AAEJ;AAUO,SAAS,MAAM,OAAmB;AACvC,SAAO,MAAM,aAAa,SACxB,oBAAC,iBAAe,GAAG,OAAO,UAAU,MAAM,UAAU,IAEpD,oBAAC,cAAY,GAAG,OAAO;AAE3B;AAEA,SAAS,WAAW;AAAA,EAClB;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP,UAAU;AAAA,EACV,kBAAkB;AAAA,EAClB;AAAA,EACA,GAAG;AACL,GAGG;AACD,QAAM,WAAW,OAAuB,IAAI;AAG5C,QAAM,WAAW,QAAQ,MAAM,UAAU,UAAU,GAAG,GAAG,CAAC,GAAG,CAAC;AAC9D,QAAM,OAAO,cAAc,QAAQ,SAAS,CAAC;AAG7C,QAAM,SAAS,QAAQ;AAKvB,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,KAAK;AAIhD,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAC5C,QAAM,CAAC,UAAU,WAAW,IAAI,SAAS,IAAI;AAC7C,MAAI,SAAS,UAAU;AACrB,gBAAY,IAAI;AAChB,iBAAa,KAAK;AAClB,eAAW,SAAS,SAAS,aAAa,QAAQ,CAAC,aAAa,CAAC,mBAAmB,CAAC;AACrF,QAAI,KAAM,MAAK,MAAM;AAAA,EACvB;AACA,YAAU,MAAM;AACd,QAAI,CAAC,QAAS;AACd,UAAM,QAAQ,WAAW,MAAM,WAAW,KAAK,GAAG,eAAe;AACjE,WAAO,MAAM,aAAa,KAAK;AAAA,EACjC,GAAG,CAAC,OAAO,CAAC;AAMZ,QAAM,EAAE,SAAS,aAAa,IAAI,mBAAmB,MAAM;AACzD,iBAAa,IAAI;AACjB,YAAQ;AAAA,EACV,CAAC;AAGD,QAAM,gBAAgB,iBAAiB,SAAS,eAAe,MAAM;AAAA,EAAC,CAAC;AAKvE,oBAAkB,UAAU,iBAAiB,YAAY;AAKzD,oBAAkB,MAAM;AAaxB,eAAa,UAAU,EAAE,QAAQ,cAAc,YAAY,CAAC;AAC5D,QAAM,UAAU,WAAW;AAK3B,QAAM,QAAQ,gBAAgB,QAAQ,YAAY;AAClD,QAAM,gBAAgB,CAAC,MAAqC;AAG1D,kBAAc,GAAG,WAAW,KAAK;AAGjC,eAAW,CAAC;AAAA,EACd;AAOA,MAAI,CAAC,UAAU,CAAC,QAAS,QAAO;AAChC,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QAKC,eAAa,WAAW;AAAA,QACxB,WAAW;AAAA;AAAA;AAAA;AAAA,UAIT,UAAU,wBAAwB;AAAA,UAClC;AAAA,UACA,YAAY,eAAe;AAAA,QAC7B;AAAA,QACC,GAAG;AAAA,QAMJ;AAAA,UAAC;AAAA;AAAA,YAME,GAAG;AAAA,YACJ,KAAK;AAAA,YAGL,MAAM,SAAS,gBAAgB,gBAAgB;AAAA,YAC/C,cAAW;AAAA,YACX,mBAAiB;AAAA,YACjB,UAAU;AAAA,YACV,WAAW;AAAA,YACV,GAAG,KAAK;AAAA,YAIT,OAAO,EAAE,GAAG,OAAO,GAAG,KAAK,MAAM;AAAA,YACjC,WAAW;AAAA,cACT,UAAU,sCAAsC;AAAA,cAChD;AAAA,cACA,EAAE,IAAI,YAAY,IAAI,YAAY,IAAI,aAAa,OAAO,iBAAiB,EAAE,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAcjF;AAAA,cACA;AAAA;AAAA;AAAA,cAGA,aAAa;AAAA,cACb;AAAA,YACF;AAAA,YAEA,8BAAC,kBAAkB,UAAlB,EAA2B,OAAO,cAAe,UAAS;AAAA;AAAA,QAC7D;AAAA;AAAA,IACF;AAAA,IACA,SAAS;AAAA,EACX;AACF;","names":[]}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentPropsWithoutRef, ReactNode, Ref } from "react";
|
|
2
2
|
import { type LabelOverride } from "../i18n/kit-labels.js";
|
|
3
|
+
import { type FieldHelpProps } from "./field-parts.js";
|
|
3
4
|
import { DEFAULT_MONTH_PICKER_LABELS } from "./month-picker-labels.js";
|
|
4
5
|
/**
|
|
5
6
|
* Every string the month picker can speak. The month and year names are NOT here —
|
|
@@ -39,7 +40,7 @@ export { DEFAULT_MONTH_PICKER_LABELS };
|
|
|
39
40
|
* a `FormEvent`, and this one answers with a month key. Everything else reaches the
|
|
40
41
|
* field's root, so a test id or a `data-tour` anchor can find it.
|
|
41
42
|
*/
|
|
42
|
-
export interface MonthPickerProps extends Omit<ComponentPropsWithoutRef<"div">, "onChange"
|
|
43
|
+
export interface MonthPickerProps extends Omit<ComponentPropsWithoutRef<"div">, "onChange">, FieldHelpProps {
|
|
43
44
|
/**
|
|
44
45
|
* The selected month as a `"YYYY-MM"` key, or `""` for none.
|
|
45
46
|
*
|
|
@@ -458,6 +458,7 @@ function MonthPicker(props) {
|
|
|
458
458
|
invalid,
|
|
459
459
|
error,
|
|
460
460
|
hint,
|
|
461
|
+
help,
|
|
461
462
|
size = "md",
|
|
462
463
|
mode = "month",
|
|
463
464
|
formatOptions,
|
|
@@ -492,7 +493,13 @@ function MonthPicker(props) {
|
|
|
492
493
|
locale
|
|
493
494
|
) : placeholder ?? "";
|
|
494
495
|
const id = useId();
|
|
495
|
-
const messages = useFieldMessages({
|
|
496
|
+
const messages = useFieldMessages({
|
|
497
|
+
hint,
|
|
498
|
+
help,
|
|
499
|
+
name: label ?? rest["aria-label"],
|
|
500
|
+
error,
|
|
501
|
+
describedBy: rest["aria-describedby"]
|
|
502
|
+
});
|
|
496
503
|
const [ownAria, wrapperRest] = splitTriggerAria(rest);
|
|
497
504
|
const aria = { ...ownAria, "aria-describedby": messages.describedBy };
|
|
498
505
|
const labelId = `${id}-label`;
|