@eifi1/ui-kit 0.53.0 → 0.55.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 +96 -82
- package/dist/account/delete-account-setting.d.ts +3 -2
- package/dist/account/delete-account-setting.js.map +1 -1
- package/dist/admin/plan-change.js +2 -2
- package/dist/admin/plan-change.js.map +1 -1
- package/dist/admin/user-roster.d.ts +6 -0
- package/dist/admin/user-roster.js +4 -0
- package/dist/admin/user-roster.js.map +1 -1
- package/dist/auth/auth-errors.d.ts +36 -0
- package/dist/auth/auth-errors.js +20 -0
- package/dist/auth/auth-errors.js.map +1 -1
- package/dist/billing/billing-labels.d.ts +26 -0
- package/dist/billing/billing-labels.js +9 -0
- package/dist/billing/billing-labels.js.map +1 -1
- package/dist/billing/subscription-standing.d.ts +61 -0
- package/dist/billing/subscription-standing.js +51 -0
- package/dist/billing/subscription-standing.js.map +1 -0
- package/dist/billing/usage-meter.d.ts +52 -0
- package/dist/billing/usage-meter.js +52 -0
- package/dist/billing/usage-meter.js.map +1 -0
- package/dist/chart.d.ts +1 -0
- package/dist/chart.js +4 -0
- package/dist/chart.js.map +1 -1
- package/dist/components/alert-banner.js +2 -3
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +2 -1
- package/dist/components/amount-input.js +3 -15
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.js +2 -2
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/bulk-action-bar.js +2 -1
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button.d.ts +35 -1
- package/dist/components/button.js +5 -9
- package/dist/components/button.js.map +1 -1
- package/dist/components/calendar-heatmap.js +6 -4
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/camera-button.d.ts +89 -0
- package/dist/components/camera-button.js +122 -0
- package/dist/components/camera-button.js.map +1 -0
- package/dist/components/camera-capture.d.ts +66 -0
- package/dist/components/camera-capture.js +190 -0
- package/dist/components/camera-capture.js.map +1 -0
- package/dist/components/camera-labels.d.ts +20 -0
- package/dist/components/camera-labels.js +15 -0
- package/dist/components/camera-labels.js.map +1 -0
- package/dist/components/chart-edge.d.ts +9 -0
- package/dist/components/chart-edge.js +14 -3
- package/dist/components/chart-edge.js.map +1 -1
- package/dist/components/chart-zoom-core.js +34 -29
- package/dist/components/chart-zoom-core.js.map +1 -1
- package/dist/components/checkbox-group.js +1 -1
- package/dist/components/checkbox-group.js.map +1 -1
- package/dist/components/combobox-core.js +3 -2
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.js +2 -2
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/consent-again-notice-labels.d.ts +25 -0
- package/dist/components/consent-again-notice-labels.js +15 -0
- package/dist/components/consent-again-notice-labels.js.map +1 -0
- package/dist/components/consent-again-notice.d.ts +85 -0
- package/dist/components/consent-again-notice.js +94 -0
- package/dist/components/consent-again-notice.js.map +1 -0
- package/dist/components/country-select.js +2 -7
- package/dist/components/country-select.js.map +1 -1
- package/dist/components/currency-option-list.d.ts +2 -1
- package/dist/components/currency-option-list.js +10 -3
- package/dist/components/currency-option-list.js.map +1 -1
- package/dist/components/currency-select.d.ts +3 -1
- package/dist/components/currency-select.js +2 -2
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +22 -1
- package/dist/components/danger-confirm.js +52 -30
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-column-settings.js +2 -2
- package/dist/components/data-table-column-settings.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +2 -1
- package/dist/components/data-table-filter-popover.js +3 -2
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +33 -3
- package/dist/components/data-table-filters.js +6 -4
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-fit.js.map +1 -1
- package/dist/components/data-table-virtual.js +13 -3
- package/dist/components/data-table-virtual.js.map +1 -1
- package/dist/components/date-field.js +2 -2
- package/dist/components/date-field.js.map +1 -1
- package/dist/components/date-picker.js +2 -2
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/date-range-picker.js +2 -6
- package/dist/components/date-range-picker.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +5 -1
- package/dist/components/dialog-frame.js +6 -2
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/dropdown.js +7 -3
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/entity-combobox.js +2 -2
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/field-parts.d.ts +108 -6
- package/dist/components/field-parts.js +59 -6
- package/dist/components/field-parts.js.map +1 -1
- package/dist/components/field-suggestion-labels.d.ts +2 -0
- package/dist/components/field-suggestion-labels.js +10 -0
- package/dist/components/field-suggestion-labels.js.map +1 -0
- package/dist/components/field.js +1 -1
- package/dist/components/field.js.map +1 -1
- package/dist/components/file-button.d.ts +1 -1
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/floating-panel.d.ts +7 -6
- package/dist/components/floating-panel.js +3 -17
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/form-actions.d.ts +3 -2
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/hover-menu.js +3 -2
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/icon-button.d.ts +23 -5
- package/dist/components/icon-button.js +6 -8
- package/dist/components/icon-button.js.map +1 -1
- package/dist/components/inline-confirm.js +2 -1
- package/dist/components/inline-confirm.js.map +1 -1
- package/dist/components/inline-entity-combobox.js +2 -2
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/components/input.js +2 -2
- package/dist/components/input.js.map +1 -1
- package/dist/components/jump-calendar.js +3 -3
- package/dist/components/jump-calendar.js.map +1 -1
- package/dist/components/legal.d.ts +13 -1
- package/dist/components/legal.js +12 -0
- package/dist/components/legal.js.map +1 -1
- package/dist/components/legended-series-chart.d.ts +80 -0
- package/dist/components/legended-series-chart.js +70 -0
- package/dist/components/legended-series-chart.js.map +1 -0
- package/dist/components/lightbox.js +2 -1
- package/dist/components/lightbox.js.map +1 -1
- package/dist/components/mini-calendar.js +16 -7
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +48 -0
- package/dist/components/modal.js +26 -6
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +1 -1
- package/dist/components/month-picker.js +6 -6
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.js +2 -2
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.js +2 -6
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-input.js +2 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/page-header-layout.d.ts +18 -0
- package/dist/components/page-header-layout.js +23 -0
- package/dist/components/page-header-layout.js.map +1 -0
- package/dist/components/page-header-skeleton.d.ts +41 -0
- package/dist/components/page-header-skeleton.js +52 -0
- package/dist/components/page-header-skeleton.js.map +1 -0
- package/dist/components/page-header.js +4 -17
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/popover.js +7 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/release-notes-labels.d.ts +16 -0
- package/dist/components/release-notes-labels.js +18 -0
- package/dist/components/release-notes-labels.js.map +1 -0
- package/dist/components/release-notes.d.ts +65 -0
- package/dist/components/release-notes.js +65 -0
- package/dist/components/release-notes.js.map +1 -0
- package/dist/components/select.js +2 -2
- package/dist/components/select.js.map +1 -1
- package/dist/components/series-chart-labels.d.ts +9 -0
- package/dist/components/series-chart-labels.js +3 -1
- package/dist/components/series-chart-labels.js.map +1 -1
- package/dist/components/series-chart.d.ts +5 -0
- package/dist/components/series-chart.js +89 -36
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/stat-tile.d.ts +21 -2
- package/dist/components/stat-tile.js +4 -2
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/toggle-group.js +3 -2
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/treemap-layout.js.map +1 -1
- package/dist/components/treemap.d.ts +17 -15
- package/dist/components/treemap.js +167 -85
- package/dist/components/treemap.js.map +1 -1
- package/dist/currencies.d.ts +16 -2
- package/dist/currencies.js +2 -1
- package/dist/currencies.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/eslint/role-utilities.d.ts +2 -2
- package/dist/eslint/role-utilities.js.map +1 -1
- package/dist/feedback/feedback-context-labels.js +2 -1
- package/dist/feedback/feedback-context-labels.js.map +1 -1
- package/dist/feedback/feedback-context.d.ts +19 -3
- package/dist/feedback/feedback-context.js +12 -1
- package/dist/feedback/feedback-context.js.map +1 -1
- package/dist/feedback/feedback-diagnostics.d.ts +17 -0
- package/dist/feedback/feedback-diagnostics.js +27 -0
- package/dist/feedback/feedback-diagnostics.js.map +1 -0
- package/dist/feedback/feedback-dialog.d.ts +10 -0
- package/dist/feedback/feedback-dialog.js +6 -4
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox-page.d.ts +260 -0
- package/dist/feedback/feedback-inbox-page.js +207 -0
- package/dist/feedback/feedback-inbox-page.js.map +1 -0
- package/dist/feedback/feedback-record.d.ts +5 -0
- package/dist/feedback/feedback-record.js.map +1 -1
- package/dist/feedback/feedback-row-detail-labels.js +1 -0
- package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +3 -0
- package/dist/feedback/feedback-row-detail.js +3 -0
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-submit-dialog.d.ts +5 -1
- package/dist/feedback/feedback-submit-dialog.js +15 -3
- package/dist/feedback/feedback-submit-dialog.js.map +1 -1
- package/dist/feedback/feedback-submit.d.ts +30 -2
- package/dist/feedback/feedback-submit.js +5 -2
- package/dist/feedback/feedback-submit.js.map +1 -1
- package/dist/feedback/feedback-vocabulary.js +2 -2
- package/dist/feedback/feedback-vocabulary.js.map +1 -1
- package/dist/feedback.d.ts +1 -0
- package/dist/feedback.js +6 -0
- package/dist/feedback.js.map +1 -1
- package/dist/hooks/use-debounce.d.ts +32 -5
- package/dist/hooks/use-debounce.js +6 -5
- package/dist/hooks/use-debounce.js.map +1 -1
- package/dist/hooks/use-file-picker.d.ts +3 -1
- package/dist/hooks/use-file-picker.js.map +1 -1
- package/dist/hooks/use-focus-trap.d.ts +19 -3
- package/dist/hooks/use-focus-trap.js +15 -5
- package/dist/hooks/use-focus-trap.js.map +1 -1
- package/dist/hooks/use-has-camera.d.ts +36 -0
- package/dist/hooks/use-has-camera.js +53 -0
- package/dist/hooks/use-has-camera.js.map +1 -0
- package/dist/hooks/use-hotkey.js +2 -1
- package/dist/hooks/use-hotkey.js.map +1 -1
- package/dist/hooks/use-overlay-history.d.ts +6 -1
- package/dist/hooks/use-overlay-history.js +10 -2
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/defaults.js +11 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +78 -4
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +20 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +95 -5
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +74 -4
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +75 -3
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +77 -4
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +77 -4
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +74 -4
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +22 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +27 -8
- package/dist/index.js +41 -2
- package/dist/index.js.map +1 -1
- package/dist/landing/landing-labels.d.ts +8 -0
- package/dist/landing/landing-labels.js +2 -1
- package/dist/landing/landing-labels.js.map +1 -1
- package/dist/lib/changelog.d.ts +130 -0
- package/dist/lib/changelog.js +137 -0
- package/dist/lib/changelog.js.map +1 -0
- package/dist/lib/clipboard-minus.js +2 -2
- package/dist/lib/clipboard-minus.js.map +1 -1
- package/dist/lib/dates.d.ts +36 -4
- package/dist/lib/dates.js +49 -13
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/editable-content.d.ts +33 -0
- package/dist/lib/editable-content.js +15 -0
- package/dist/lib/editable-content.js.map +1 -0
- package/dist/lib/focus-left.d.ts +47 -1
- package/dist/lib/focus-left.js +11 -2
- package/dist/lib/focus-left.js.map +1 -1
- package/dist/lib/format.d.ts +37 -2
- package/dist/lib/format.js +25 -1
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/intl-cache.d.ts +4 -2
- package/dist/lib/intl-cache.js.map +1 -1
- package/dist/lib/server-wake.d.ts +49 -9
- package/dist/lib/server-wake.js +25 -14
- package/dist/lib/server-wake.js.map +1 -1
- package/dist/lib/session-refresh.d.ts +53 -0
- package/dist/lib/session-refresh.js +27 -1
- package/dist/lib/session-refresh.js.map +1 -1
- package/dist/lib/translation-review.d.ts +26 -0
- package/dist/lib/translation-review.js +14 -0
- package/dist/lib/translation-review.js.map +1 -1
- package/dist/rhf/fields.js +3 -3
- package/dist/rhf/fields.js.map +1 -1
- package/dist/shell/app-shell.d.ts +25 -1
- package/dist/shell/app-shell.js +3 -1
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/dev-menu.d.ts +3 -0
- package/dist/shell/dev-menu.js +4 -2
- package/dist/shell/dev-menu.js.map +1 -1
- package/dist/shell/held-changes-banner-labels.d.ts +23 -0
- package/dist/shell/held-changes-banner-labels.js +14 -0
- package/dist/shell/held-changes-banner-labels.js.map +1 -0
- package/dist/shell/held-changes-banner.d.ts +75 -0
- package/dist/shell/held-changes-banner.js +106 -0
- package/dist/shell/held-changes-banner.js.map +1 -0
- package/dist/shell/sync-status-indicator.d.ts +27 -12
- package/dist/shell/sync-status-indicator.js +13 -4
- package/dist/shell/sync-status-indicator.js.map +1 -1
- package/dist/shell/top-bar-parts-labels.d.ts +5 -0
- package/dist/shell/top-bar-parts-labels.js +3 -1
- package/dist/shell/top-bar-parts-labels.js.map +1 -1
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +3 -0
- package/dist/shell.js.map +1 -1
- package/dist/sync/engine.d.ts +11 -2
- package/dist/sync/engine.js +13 -5
- package/dist/sync/engine.js.map +1 -1
- package/dist/sync/hooks.d.ts +3 -2
- package/dist/sync/hooks.js +4 -2
- package/dist/sync/hooks.js.map +1 -1
- package/dist/testing/css-variables.generated.d.ts +2 -2
- package/dist/testing/css-variables.generated.js +1 -1
- package/dist/testing/css-variables.generated.js.map +1 -1
- package/dist/theme/chart-palette.d.ts +18 -0
- package/dist/theme/chart-palette.js +17 -1
- package/dist/theme/chart-palette.js.map +1 -1
- package/dist/theme/contrast-tokens.d.ts +3 -3
- package/dist/theme/contrast-tokens.js.map +1 -1
- package/dist/theme/contrast.d.ts +4 -4
- package/dist/theme/contrast.js.map +1 -1
- package/dist/tour/tour-overlay.js +2 -1
- package/dist/tour/tour-overlay.js.map +1 -1
- package/package.json +5 -9
- package/tokens.css +19 -54
- package/src/account/account-parts.ts +0 -47
- package/src/account/account-settings-labels.ts +0 -240
- package/src/account/account-settings.tsx +0 -698
- package/src/account/data-export-setting-labels.ts +0 -20
- package/src/account/data-export-setting.tsx +0 -200
- package/src/account/delete-account-setting-labels.ts +0 -33
- package/src/account/delete-account-setting.tsx +0 -341
- package/src/account/email-change-setting-labels.ts +0 -31
- package/src/account/email-change-setting.tsx +0 -443
- package/src/account/passkeys-setting.tsx +0 -445
- package/src/account/sessions-setting-labels.ts +0 -24
- package/src/account/sessions-setting.tsx +0 -283
- package/src/admin/admin-action-confirm-labels.ts +0 -19
- package/src/admin/admin-action-confirm.tsx +0 -509
- package/src/admin/admin-action-log-labels.ts +0 -83
- package/src/admin/admin-action-log.tsx +0 -464
- package/src/admin/admin-area-labels.ts +0 -114
- package/src/admin/admin-forbidden.tsx +0 -75
- package/src/admin/admin-overview.tsx +0 -258
- package/src/admin/admin-parts.ts +0 -68
- package/src/admin/admin-sections.tsx +0 -208
- package/src/admin/admin-tool-links.tsx +0 -90
- package/src/admin/invitations-panel-labels.ts +0 -31
- package/src/admin/invitations-panel.tsx +0 -516
- package/src/admin/mail-test-card-labels.ts +0 -19
- package/src/admin/mail-test-card.tsx +0 -189
- package/src/admin/plan-change-labels.ts +0 -5
- package/src/admin/plan-change.tsx +0 -474
- package/src/admin/reviewer-scope-editor-labels.ts +0 -16
- package/src/admin/reviewer-scope-editor.tsx +0 -224
- package/src/admin/role-select-labels.ts +0 -16
- package/src/admin/role-select.tsx +0 -282
- package/src/admin/transfer-ownership-dialog-labels.ts +0 -20
- package/src/admin/transfer-ownership-dialog.tsx +0 -162
- package/src/admin/user-roster-labels.ts +0 -17
- package/src/admin/user-roster.tsx +0 -559
- package/src/auth/accept-invitation-labels.ts +0 -21
- package/src/auth/accept-invitation.tsx +0 -210
- package/src/auth/auth-errors-labels.ts +0 -23
- package/src/auth/auth-errors.ts +0 -370
- package/src/auth/complete-name-dialog-labels.ts +0 -16
- package/src/auth/complete-name-dialog.tsx +0 -253
- package/src/auth/email-tag.ts +0 -75
- package/src/auth/forgot-password-form-labels.ts +0 -20
- package/src/auth/forgot-password-form.tsx +0 -221
- package/src/auth/form-rules.ts +0 -40
- package/src/auth/new-password-fields.tsx +0 -85
- package/src/auth/not-found-page-labels.ts +0 -12
- package/src/auth/not-found-page.tsx +0 -95
- package/src/auth/register-form-labels.ts +0 -31
- package/src/auth/register-form.tsx +0 -456
- package/src/auth/reset-password-form-labels.ts +0 -27
- package/src/auth/reset-password-form.tsx +0 -341
- package/src/auth/sign-in-form-labels.ts +0 -44
- package/src/auth/sign-in-form.tsx +0 -858
- package/src/auth/status-parts.tsx +0 -132
- package/src/auth/verify-email-labels.ts +0 -26
- package/src/auth/verify-email.tsx +0 -445
- package/src/billing/billing-banner.tsx +0 -258
- package/src/billing/billing-labels.ts +0 -381
- package/src/billing/billing-lock.ts +0 -108
- package/src/billing/billing-overview.ts +0 -157
- package/src/billing/billing-standing.ts +0 -80
- package/src/billing/cancel-request.ts +0 -97
- package/src/billing/cancel-subscription.tsx +0 -98
- package/src/billing/checkout-processing.ts +0 -394
- package/src/billing/checkout-return.ts +0 -90
- package/src/billing/pay-page.ts +0 -330
- package/src/billing/plan-card.tsx +0 -562
- package/src/billing/plan-change-labels.ts +0 -66
- package/src/billing/plan-limit-notice.tsx +0 -148
- package/src/billing/plan-limit-toast.tsx +0 -90
- package/src/billing/plan-limit.ts +0 -52
- package/src/billing/plan-price.ts +0 -98
- package/src/billing/plan-switch.tsx +0 -216
- package/src/billing/provider-page.ts +0 -84
- package/src/billing/subscription-actions.tsx +0 -247
- package/src/billing/subscription-status-chip.tsx +0 -95
- package/src/chart.ts +0 -103
- package/src/components/account-chips-labels.ts +0 -16
- package/src/components/account-chips.tsx +0 -474
- package/src/components/alert-banner.tsx +0 -670
- package/src/components/amount-input.tsx +0 -815
- package/src/components/appearance-labels.ts +0 -63
- package/src/components/authed-image-labels.ts +0 -11
- package/src/components/authed-image.tsx +0 -209
- package/src/components/autocomplete.tsx +0 -645
- package/src/components/breadcrumbs-labels.ts +0 -10
- package/src/components/breadcrumbs.tsx +0 -205
- package/src/components/bulk-action-bar-labels.ts +0 -11
- package/src/components/bulk-action-bar.tsx +0 -443
- package/src/components/button-group.tsx +0 -223
- package/src/components/button-look.ts +0 -256
- package/src/components/button.tsx +0 -339
- package/src/components/calculator.tsx +0 -341
- package/src/components/calendar-heatmap-labels.ts +0 -15
- package/src/components/calendar-heatmap.tsx +0 -594
- package/src/components/card.tsx +0 -377
- package/src/components/chart-edge.ts +0 -106
- package/src/components/chart-figure.tsx +0 -35
- package/src/components/chart-height.ts +0 -30
- package/src/components/chart-zoom-core.tsx +0 -627
- package/src/components/chart-zoom.tsx +0 -22
- package/src/components/chart.tsx +0 -554
- package/src/components/checkbox-group.tsx +0 -247
- package/src/components/checkbox.tsx +0 -346
- package/src/components/chip-input.tsx +0 -370
- package/src/components/chip-labels.ts +0 -13
- package/src/components/chip.tsx +0 -843
- package/src/components/choice-card.tsx +0 -544
- package/src/components/column-mapper-labels.ts +0 -38
- package/src/components/column-mapper.tsx +0 -655
- package/src/components/combobox-core.tsx +0 -1321
- package/src/components/combobox-suggestions.tsx +0 -131
- package/src/components/combobox.tsx +0 -716
- package/src/components/confirm-context.ts +0 -7
- package/src/components/confirm-dialog-labels.ts +0 -13
- package/src/components/confirm-dialog.tsx +0 -341
- package/src/components/copy-button-labels.ts +0 -13
- package/src/components/copy-button.tsx +0 -192
- package/src/components/country-select-labels.ts +0 -11
- package/src/components/country-select.tsx +0 -539
- package/src/components/currency-option-list.tsx +0 -180
- package/src/components/currency-select.tsx +0 -341
- package/src/components/danger-confirm-labels.ts +0 -20
- package/src/components/danger-confirm.tsx +0 -934
- package/src/components/data-table-cells.tsx +0 -118
- package/src/components/data-table-column-settings.tsx +0 -198
- package/src/components/data-table-filter-popover.tsx +0 -281
- package/src/components/data-table-filters.ts +0 -287
- package/src/components/data-table-fit.ts +0 -301
- package/src/components/data-table-groups.tsx +0 -288
- package/src/components/data-table-head.tsx +0 -280
- package/src/components/data-table-labels.ts +0 -198
- package/src/components/data-table-leading-row.tsx +0 -58
- package/src/components/data-table-mobile.tsx +0 -1117
- package/src/components/data-table-pagination.tsx +0 -202
- package/src/components/data-table-resize.ts +0 -188
- package/src/components/data-table-row-actions.tsx +0 -302
- package/src/components/data-table-row.tsx +0 -496
- package/src/components/data-table-selection.ts +0 -71
- package/src/components/data-table-shared.tsx +0 -287
- package/src/components/data-table-sort.ts +0 -163
- package/src/components/data-table-virtual.ts +0 -576
- package/src/components/data-table.tsx +0 -2598
- package/src/components/date-field.tsx +0 -684
- package/src/components/date-format-setting.tsx +0 -64
- package/src/components/date-picker.tsx +0 -404
- package/src/components/date-range-picker.tsx +0 -518
- package/src/components/description-list-labels.ts +0 -9
- package/src/components/description-list.tsx +0 -386
- package/src/components/dialog-frame-labels.ts +0 -7
- package/src/components/dialog-frame.tsx +0 -276
- package/src/components/disclosure.tsx +0 -476
- package/src/components/dropdown.tsx +0 -461
- package/src/components/empty-state.tsx +0 -136
- package/src/components/entity-combobox.tsx +0 -422
- package/src/components/error-boundary-labels.ts +0 -20
- package/src/components/error-boundary.tsx +0 -675
- package/src/components/facing-pair.ts +0 -93
- package/src/components/field-base-labels.ts +0 -20
- package/src/components/field-base.tsx +0 -656
- package/src/components/field-parts.tsx +0 -825
- package/src/components/field-strip.tsx +0 -129
- package/src/components/field.tsx +0 -206
- package/src/components/file-button.tsx +0 -295
- package/src/components/file-dropzone.tsx +0 -559
- package/src/components/floating-panel-labels.ts +0 -10
- package/src/components/floating-panel.tsx +0 -828
- package/src/components/focus-ring.ts +0 -16
- package/src/components/form-actions-labels.ts +0 -11
- package/src/components/form-actions.tsx +0 -578
- package/src/components/full-bleed-dialog.tsx +0 -294
- package/src/components/grouped-picker.tsx +0 -184
- package/src/components/help-popover.tsx +0 -117
- package/src/components/hover-menu.tsx +0 -343
- package/src/components/iban-input-labels.ts +0 -14
- package/src/components/iban-input.tsx +0 -236
- package/src/components/icon-button.tsx +0 -959
- package/src/components/icon-picker-labels.ts +0 -13
- package/src/components/icon-picker.tsx +0 -232
- package/src/components/image-grid-labels.ts +0 -11
- package/src/components/image-grid.tsx +0 -202
- package/src/components/inline-confirm.tsx +0 -116
- package/src/components/inline-edit-field-labels.ts +0 -11
- package/src/components/inline-edit-field.tsx +0 -552
- package/src/components/inline-entity-combobox.tsx +0 -849
- package/src/components/input.tsx +0 -394
- package/src/components/jump-calendar.tsx +0 -265
- package/src/components/language-select.tsx +0 -93
- package/src/components/legal-labels.ts +0 -105
- package/src/components/legal-page.tsx +0 -711
- package/src/components/legal.tsx +0 -668
- package/src/components/lightbox-labels.ts +0 -18
- package/src/components/lightbox.tsx +0 -399
- package/src/components/line-items-labels.ts +0 -14
- package/src/components/line-items.tsx +0 -602
- package/src/components/list-labels.ts +0 -12
- package/src/components/list.tsx +0 -870
- package/src/components/loading-state.tsx +0 -78
- package/src/components/measured-grid-labels.ts +0 -25
- package/src/components/measured-grid.tsx +0 -837
- package/src/components/menu-item.tsx +0 -241
- package/src/components/mini-calendar-labels.ts +0 -17
- package/src/components/mini-calendar.tsx +0 -789
- package/src/components/modal.tsx +0 -505
- package/src/components/money-draft.ts +0 -83
- package/src/components/money-field.tsx +0 -76
- package/src/components/month-grid.tsx +0 -229
- package/src/components/month-picker-labels.ts +0 -22
- package/src/components/month-picker.tsx +0 -1087
- package/src/components/multi-entity-combobox.tsx +0 -369
- package/src/components/multi-select.tsx +0 -376
- package/src/components/nav-pills.tsx +0 -198
- package/src/components/number-field.tsx +0 -327
- package/src/components/number-input.tsx +0 -558
- package/src/components/numeric-field.ts +0 -169
- package/src/components/numpad-sheet.tsx +0 -285
- package/src/components/one-time-code-input.tsx +0 -270
- package/src/components/page-contents-labels.ts +0 -9
- package/src/components/page-contents.tsx +0 -282
- package/src/components/page-header-context.ts +0 -8
- package/src/components/page-header.tsx +0 -298
- package/src/components/password-strength-labels.ts +0 -22
- package/src/components/password-strength.tsx +0 -241
- package/src/components/phone-input-labels.ts +0 -10
- package/src/components/phone-input.tsx +0 -282
- package/src/components/picker-sheet.tsx +0 -330
- package/src/components/pie-chart-labels.ts +0 -40
- package/src/components/pie-chart.tsx +0 -480
- package/src/components/popover-labels.ts +0 -14
- package/src/components/popover.tsx +0 -268
- package/src/components/progress-bar-labels.ts +0 -10
- package/src/components/progress-bar.tsx +0 -479
- package/src/components/qr-code.tsx +0 -76
- package/src/components/reauth-dialog-labels.ts +0 -13
- package/src/components/reauth-dialog.tsx +0 -265
- package/src/components/row-actions-labels.ts +0 -12
- package/src/components/row-actions.tsx +0 -795
- package/src/components/scroll-area.tsx +0 -123
- package/src/components/search-field.tsx +0 -192
- package/src/components/select.tsx +0 -294
- package/src/components/separator.tsx +0 -33
- package/src/components/series-chart-axes.ts +0 -342
- package/src/components/series-chart-budget.ts +0 -150
- package/src/components/series-chart-labels.ts +0 -33
- package/src/components/series-chart-thin.ts +0 -180
- package/src/components/series-chart-ticks.ts +0 -194
- package/src/components/series-chart.tsx +0 -1906
- package/src/components/server-wake-labels.ts +0 -11
- package/src/components/server-wake.tsx +0 -129
- package/src/components/settings-fields.tsx +0 -237
- package/src/components/share-card-labels.ts +0 -30
- package/src/components/share-card.tsx +0 -675
- package/src/components/signature-pad-labels.ts +0 -24
- package/src/components/signature-pad.tsx +0 -747
- package/src/components/signed-amount-labels.ts +0 -10
- package/src/components/signed-amount.tsx +0 -336
- package/src/components/skeleton.tsx +0 -80
- package/src/components/slider.tsx +0 -338
- package/src/components/sparkline-labels.ts +0 -14
- package/src/components/sparkline.tsx +0 -322
- package/src/components/spinner.tsx +0 -81
- package/src/components/stat-tile-labels.ts +0 -15
- package/src/components/stat-tile.tsx +0 -725
- package/src/components/status-dot.tsx +0 -170
- package/src/components/swatch-picker-labels.ts +0 -10
- package/src/components/swatch-picker.tsx +0 -283
- package/src/components/swipeable-row.tsx +0 -386
- package/src/components/switch.tsx +0 -273
- package/src/components/table-skeleton.tsx +0 -136
- package/src/components/table.tsx +0 -647
- package/src/components/tabs-labels.ts +0 -13
- package/src/components/tabs.tsx +0 -693
- package/src/components/text-link.tsx +0 -359
- package/src/components/text.tsx +0 -150
- package/src/components/textarea.tsx +0 -330
- package/src/components/tile-radio.tsx +0 -332
- package/src/components/time-input.tsx +0 -121
- package/src/components/toast-labels.ts +0 -12
- package/src/components/toast.tsx +0 -519
- package/src/components/toggle-group.tsx +0 -752
- package/src/components/toggle-legend.tsx +0 -394
- package/src/components/tooltip.tsx +0 -1149
- package/src/components/translation-review-editor.tsx +0 -290
- package/src/components/translation-review-labels.ts +0 -181
- package/src/components/translation-review-parts.tsx +0 -272
- package/src/components/translation-review.tsx +0 -1240
- package/src/components/tree-view.tsx +0 -589
- package/src/components/treemap-labels.ts +0 -47
- package/src/components/treemap-layout.ts +0 -210
- package/src/components/treemap.tsx +0 -1184
- package/src/components/trigger-aria.ts +0 -44
- package/src/components/use-anchor-dir.ts +0 -20
- package/src/components/use-mobile-reveal.ts +0 -62
- package/src/components/use-table-state.ts +0 -753
- package/src/components/user-avatar.tsx +0 -116
- package/src/components/wizard-stepper.tsx +0 -42
- package/src/components/write-lock-labels.ts +0 -10
- package/src/components/write-lock.tsx +0 -253
- package/src/currencies.ts +0 -70
- package/src/data-table.ts +0 -42
- package/src/demo/demo-banner.tsx +0 -153
- package/src/demo/demo-countdown.ts +0 -136
- package/src/demo/demo-ended.tsx +0 -90
- package/src/demo/demo-labels.ts +0 -78
- package/src/demo/demo-session.ts +0 -32
- package/src/demo/demo-start.tsx +0 -156
- package/src/eslint/index.ts +0 -161
- package/src/eslint/role-utilities.generated.ts +0 -146
- package/src/eslint/role-utilities.ts +0 -233
- package/src/feedback/feedback-attachment-download.tsx +0 -77
- package/src/feedback/feedback-attachment-labels.ts +0 -37
- package/src/feedback/feedback-attachment.tsx +0 -1239
- package/src/feedback/feedback-capture.ts +0 -287
- package/src/feedback/feedback-comment-thread.tsx +0 -353
- package/src/feedback/feedback-context-labels.ts +0 -10
- package/src/feedback/feedback-context.tsx +0 -213
- package/src/feedback/feedback-crash.ts +0 -547
- package/src/feedback/feedback-dialog-labels.ts +0 -29
- package/src/feedback/feedback-dialog.tsx +0 -440
- package/src/feedback/feedback-entry-file.tsx +0 -150
- package/src/feedback/feedback-inbox.tsx +0 -552
- package/src/feedback/feedback-labels.ts +0 -144
- package/src/feedback/feedback-menu-labels.ts +0 -12
- package/src/feedback/feedback-menu.tsx +0 -177
- package/src/feedback/feedback-mutation.ts +0 -20
- package/src/feedback/feedback-page-link.ts +0 -90
- package/src/feedback/feedback-public-labels.ts +0 -81
- package/src/feedback/feedback-public-list.tsx +0 -524
- package/src/feedback/feedback-public-switch.tsx +0 -102
- package/src/feedback/feedback-public-types.ts +0 -101
- package/src/feedback/feedback-record.ts +0 -423
- package/src/feedback/feedback-replies-context.ts +0 -9
- package/src/feedback/feedback-replies.tsx +0 -343
- package/src/feedback/feedback-rework-entry-chip.tsx +0 -18
- package/src/feedback/feedback-row-detail-labels.ts +0 -30
- package/src/feedback/feedback-row-detail.tsx +0 -858
- package/src/feedback/feedback-status-undo.ts +0 -109
- package/src/feedback/feedback-submit-dialog.tsx +0 -118
- package/src/feedback/feedback-submit.tsx +0 -237
- package/src/feedback/feedback-swipe.tsx +0 -192
- package/src/feedback/feedback-table-labels.ts +0 -27
- package/src/feedback/feedback-table.tsx +0 -912
- package/src/feedback/feedback-thread-labels.ts +0 -22
- package/src/feedback/feedback-thread-parts-labels.ts +0 -41
- package/src/feedback/feedback-thread.tsx +0 -905
- package/src/feedback/feedback-vocabulary.ts +0 -263
- package/src/feedback/feedback-vote-button.tsx +0 -124
- package/src/feedback.ts +0 -116
- package/src/hooks/use-anchored-panel.ts +0 -396
- package/src/hooks/use-anchored-rect.ts +0 -77
- package/src/hooks/use-announce.ts +0 -85
- package/src/hooks/use-authed-src.ts +0 -167
- package/src/hooks/use-body-scroll-lock.ts +0 -119
- package/src/hooks/use-breakpoint.ts +0 -133
- package/src/hooks/use-close-transition.ts +0 -107
- package/src/hooks/use-copy-to-clipboard.ts +0 -122
- package/src/hooks/use-debounce.ts +0 -114
- package/src/hooks/use-dismiss.ts +0 -371
- package/src/hooks/use-document-title.ts +0 -33
- package/src/hooks/use-file-drop.ts +0 -121
- package/src/hooks/use-file-picker-labels.ts +0 -28
- package/src/hooks/use-file-picker.tsx +0 -359
- package/src/hooks/use-focus-trap.ts +0 -199
- package/src/hooks/use-hotkey.ts +0 -189
- package/src/hooks/use-large-text.ts +0 -41
- package/src/hooks/use-long-press.ts +0 -140
- package/src/hooks/use-media-query.ts +0 -39
- package/src/hooks/use-mounted.ts +0 -39
- package/src/hooks/use-noindex.ts +0 -37
- package/src/hooks/use-overlay-history.ts +0 -714
- package/src/hooks/use-row-swipe.ts +0 -207
- package/src/hooks/use-search-param-state.ts +0 -434
- package/src/hooks/use-windowed-rows.ts +0 -225
- package/src/i18n/defaults.ts +0 -267
- package/src/i18n/german.ts +0 -2057
- package/src/i18n/kit-labels.tsx +0 -1003
- package/src/i18n/languages.ts +0 -349
- package/src/i18n/locales/de-CH.ts +0 -24
- package/src/i18n/locales/en.ts +0 -267
- package/src/i18n/locales/es.ts +0 -1997
- package/src/i18n/locales/fr.ts +0 -2035
- package/src/i18n/locales/hu.ts +0 -2002
- package/src/i18n/locales/it.ts +0 -2012
- package/src/i18n/locales/zh.ts +0 -1910
- package/src/i18n/plurals.ts +0 -86
- package/src/i18n/review.ts +0 -475
- package/src/i18n/shared-labels.ts +0 -138
- package/src/i18n/swiss.ts +0 -29
- package/src/index.ts +0 -1179
- package/src/landing/access.ts +0 -93
- package/src/landing/landing-actions.tsx +0 -97
- package/src/landing/landing-labels.ts +0 -59
- package/src/landing/landing-sections.tsx +0 -341
- package/src/landing/page-seo.ts +0 -261
- package/src/landing/public-header.tsx +0 -121
- package/src/landing/public-page.tsx +0 -106
- package/src/landing/routing.tsx +0 -225
- package/src/lib/broadcast.ts +0 -52
- package/src/lib/calc.ts +0 -323
- package/src/lib/chip-edge-snap.ts +0 -212
- package/src/lib/clipboard-minus.ts +0 -107
- package/src/lib/clipping.ts +0 -34
- package/src/lib/cn.ts +0 -28
- package/src/lib/column-mapping.ts +0 -234
- package/src/lib/countries.ts +0 -91
- package/src/lib/data-attributes.ts +0 -14
- package/src/lib/date-format.ts +0 -227
- package/src/lib/dates.ts +0 -266
- package/src/lib/day-format.ts +0 -96
- package/src/lib/decimal-marks.ts +0 -108
- package/src/lib/describe-failure.ts +0 -18
- package/src/lib/device-settings.ts +0 -147
- package/src/lib/direction.ts +0 -74
- package/src/lib/document-navigation.ts +0 -17
- package/src/lib/document-title.ts +0 -41
- package/src/lib/download.ts +0 -172
- package/src/lib/float-clearance.ts +0 -70
- package/src/lib/floating-stack.ts +0 -70
- package/src/lib/focus-left.ts +0 -156
- package/src/lib/format.ts +0 -522
- package/src/lib/http-status.ts +0 -38
- package/src/lib/iban.ts +0 -251
- package/src/lib/ime.ts +0 -24
- package/src/lib/intl-cache.ts +0 -25
- package/src/lib/iso-day.ts +0 -13
- package/src/lib/lazy-with-fallback.ts +0 -82
- package/src/lib/logger.ts +0 -69
- package/src/lib/minus.ts +0 -50
- package/src/lib/path-pattern.ts +0 -55
- package/src/lib/person-name.ts +0 -155
- package/src/lib/phone.ts +0 -208
- package/src/lib/plain-text.ts +0 -22
- package/src/lib/presence.ts +0 -18
- package/src/lib/qr-encode.ts +0 -444
- package/src/lib/quiet-focus.ts +0 -32
- package/src/lib/reduced-motion.ts +0 -22
- package/src/lib/refs.ts +0 -22
- package/src/lib/round.ts +0 -43
- package/src/lib/safe-storage.ts +0 -119
- package/src/lib/server-wake.ts +0 -315
- package/src/lib/session-refresh.ts +0 -472
- package/src/lib/share-format.ts +0 -18
- package/src/lib/status-tone.ts +0 -43
- package/src/lib/strip-fade.ts +0 -287
- package/src/lib/table-text.ts +0 -554
- package/src/lib/text-case.ts +0 -19
- package/src/lib/text-match.ts +0 -169
- package/src/lib/thenable.ts +0 -19
- package/src/lib/translation-review.ts +0 -717
- package/src/notifications/device-zone-report.ts +0 -67
- package/src/notifications/feedback-notification-types-labels.ts +0 -33
- package/src/notifications/feedback-notification-types.ts +0 -71
- package/src/notifications/notification-bell.tsx +0 -119
- package/src/notifications/notification-inbox.tsx +0 -309
- package/src/notifications/notification-parts.tsx +0 -81
- package/src/notifications/notification-types-setting.tsx +0 -406
- package/src/notifications/notification-types.ts +0 -89
- package/src/notifications/notifications-labels.ts +0 -287
- package/src/notifications/push-device-setting.tsx +0 -230
- package/src/notifications/push-devices-setting.tsx +0 -374
- package/src/notifications/push-worker.ts +0 -168
- package/src/notifications/quiet-hours-setting.tsx +0 -238
- package/src/notifications/unsubscribe-panel.tsx +0 -200
- package/src/notifications/use-push-device.ts +0 -448
- package/src/offline/local-first.ts +0 -100
- package/src/offline/pwa-update-prompt-labels.ts +0 -9
- package/src/offline/pwa-update-prompt.tsx +0 -78
- package/src/offline/query-persistence.ts +0 -391
- package/src/offline/service-worker-updates.ts +0 -81
- package/src/offline/shared-computer-labels.ts +0 -8
- package/src/offline/shared-computer.tsx +0 -87
- package/src/offline/sign-out-pending-dialog-labels.ts +0 -21
- package/src/offline/sign-out-pending-dialog.tsx +0 -135
- package/src/offline.ts +0 -37
- package/src/push-worker.ts +0 -25
- package/src/rhf/fields.tsx +0 -2187
- package/src/rhf/form-core.tsx +0 -324
- package/src/rhf/form.tsx +0 -12
- package/src/rhf/line-items.tsx +0 -135
- package/src/rhf/use-rhf-wizard-step.ts +0 -104
- package/src/rhf.ts +0 -21
- package/src/search/command-palette-labels.ts +0 -16
- package/src/search/command-palette.tsx +0 -624
- package/src/search/global-search-labels.ts +0 -13
- package/src/search/global-search.tsx +0 -660
- package/src/search/search-index.ts +0 -283
- package/src/search/use-command-key.ts +0 -23
- package/src/search.ts +0 -14
- package/src/settings/device-reset-setting-labels.ts +0 -20
- package/src/settings/device-reset-setting.tsx +0 -116
- package/src/settings/settings-card-classes.ts +0 -11
- package/src/settings/settings-catalogue.ts +0 -150
- package/src/settings/settings-context.ts +0 -33
- package/src/settings/settings-heading.tsx +0 -67
- package/src/settings/settings-labels.ts +0 -87
- package/src/settings/settings-layout.tsx +0 -471
- package/src/settings/settings-section.tsx +0 -108
- package/src/settings/use-account-appearance.ts +0 -148
- package/src/settings/use-account-language.ts +0 -146
- package/src/settings/use-settings-focus.ts +0 -128
- package/src/settings/use-settings-route.ts +0 -258
- package/src/shell/account-menu-language.tsx +0 -130
- package/src/shell/account-menu-text-size.tsx +0 -80
- package/src/shell/admin-menu.tsx +0 -183
- package/src/shell/app-shell-labels.ts +0 -11
- package/src/shell/app-shell.tsx +0 -1560
- package/src/shell/auth-layout.tsx +0 -186
- package/src/shell/company-switcher-labels.ts +0 -13
- package/src/shell/company-switcher.tsx +0 -136
- package/src/shell/dev-menu.tsx +0 -257
- package/src/shell/fill-height.tsx +0 -45
- package/src/shell/language-code.ts +0 -5
- package/src/shell/option-switcher-menu.tsx +0 -84
- package/src/shell/sync-status-indicator.tsx +0 -303
- package/src/shell/top-bar-actions.tsx +0 -87
- package/src/shell/top-bar-brand.tsx +0 -78
- package/src/shell/top-bar-parts-labels.ts +0 -148
- package/src/shell/top-bar.tsx +0 -40
- package/src/shell/topbar-action-menu.tsx +0 -567
- package/src/shell/topbar-controls.tsx +0 -269
- package/src/shell/tour-menu.tsx +0 -187
- package/src/shell.ts +0 -108
- package/src/sync/db.ts +0 -129
- package/src/sync/engine.ts +0 -1011
- package/src/sync/hooks.ts +0 -98
- package/src/sync/testing.ts +0 -73
- package/src/sync/wire.ts +0 -61
- package/src/sync.ts +0 -37
- package/src/table-text.ts +0 -8
- package/src/testing/css-variables.generated.ts +0 -112
- package/src/testing/css-variables.ts +0 -189
- package/src/testing/index.ts +0 -29
- package/src/testing/react.ts +0 -58
- package/src/testing/tour.ts +0 -41
- package/src/testing/viewport.ts +0 -357
- package/src/theme/chart-palette.ts +0 -232
- package/src/theme/color.ts +0 -272
- package/src/theme/contrast-tokens.ts +0 -268
- package/src/theme/contrast.ts +0 -250
- package/src/theme/palette-derive.ts +0 -825
- package/src/theme/palette-presets.ts +0 -544
- package/src/theme/palette-store.ts +0 -108
- package/src/theme/text-size.ts +0 -289
- package/src/theme/theme-store.ts +0 -157
- package/src/tour/tour-catalogue-labels.ts +0 -75
- package/src/tour/tour-catalogue.tsx +0 -335
- package/src/tour/tour-dock.ts +0 -134
- package/src/tour/tour-engine.ts +0 -37
- package/src/tour/tour-gates.ts +0 -218
- package/src/tour/tour-guide-button.tsx +0 -64
- package/src/tour/tour-guide-labels.ts +0 -15
- package/src/tour/tour-info.ts +0 -144
- package/src/tour/tour-labels.ts +0 -23
- package/src/tour/tour-overlay.tsx +0 -1170
- package/src/tour/tour.tsx +0 -750
- package/src/tour/use-tour-progress.ts +0 -671
- package/src/tour/use-tour-url-sync.ts +0 -266
- package/src/tour.ts +0 -21
- package/src/vite-env.d.ts +0 -23
- package/src/wizard/stepper-nav.tsx +0 -360
- package/src/wizard/types.ts +0 -275
- package/src/wizard/use-wizard.ts +0 -560
- package/src/wizard/validation.ts +0 -39
- package/src/wizard/wizard-context.tsx +0 -74
- package/src/wizard/wizard-labels.ts +0 -29
- package/src/wizard/wizard-step.tsx +0 -76
- package/src/wizard/wizard-summary.tsx +0 -84
- package/src/wizard.ts +0 -15
|
@@ -7,6 +7,7 @@ import { cn } from "../lib/cn.js";
|
|
|
7
7
|
import { FOCUS_RING } from "./focus-ring.js";
|
|
8
8
|
import { dirOf } from "../lib/direction.js";
|
|
9
9
|
import { registerFloating } from "../lib/floating-stack.js";
|
|
10
|
+
import { returnFocus } from "../lib/focus-left.js";
|
|
10
11
|
import { useFloatClearance } from "../lib/float-clearance.js";
|
|
11
12
|
import { isComposing } from "../lib/ime.js";
|
|
12
13
|
import { takeEscape, useOverlayOpen } from "../hooks/use-dismiss.js";
|
|
@@ -170,7 +171,6 @@ function FloatingPanel({
|
|
|
170
171
|
initialFocusRef.current = initialFocus;
|
|
171
172
|
});
|
|
172
173
|
const wasOpen = useRef(open);
|
|
173
|
-
const focusInside = useRef(false);
|
|
174
174
|
useEffect(() => {
|
|
175
175
|
const changed = wasOpen.current !== open;
|
|
176
176
|
wasOpen.current = open;
|
|
@@ -179,17 +179,13 @@ function FloatingPanel({
|
|
|
179
179
|
(initialFocusRef.current?.() ?? panelRef.current)?.focus();
|
|
180
180
|
return;
|
|
181
181
|
}
|
|
182
|
-
|
|
183
|
-
const lost = active === null || active === document.body;
|
|
184
|
-
if (focusInside.current || lost) fabRef.current?.focus();
|
|
185
|
-
focusInside.current = false;
|
|
182
|
+
returnFocus({ opener: fabRef.current });
|
|
186
183
|
}, [open]);
|
|
187
184
|
const onPanelKeyDown = (e) => {
|
|
188
185
|
if (e.key !== "Escape") return;
|
|
189
186
|
if (takeEscape(e)) return;
|
|
190
187
|
if (e.defaultPrevented || isComposing(e)) return;
|
|
191
188
|
e.stopPropagation();
|
|
192
|
-
focusInside.current = true;
|
|
193
189
|
setOpen(false);
|
|
194
190
|
};
|
|
195
191
|
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
@@ -209,7 +205,6 @@ function FloatingPanel({
|
|
|
209
205
|
"aria-expanded": open,
|
|
210
206
|
"aria-controls": open && !hidden ? panelId : void 0,
|
|
211
207
|
onClick: () => {
|
|
212
|
-
focusInside.current = false;
|
|
213
208
|
setOpen(!open);
|
|
214
209
|
}
|
|
215
210
|
}
|
|
@@ -228,12 +223,6 @@ function FloatingPanel({
|
|
|
228
223
|
dir,
|
|
229
224
|
"data-corner": corner,
|
|
230
225
|
onKeyDown: onPanelKeyDown,
|
|
231
|
-
onFocus: () => {
|
|
232
|
-
focusInside.current = true;
|
|
233
|
-
},
|
|
234
|
-
onBlur: (e) => {
|
|
235
|
-
if (!e.currentTarget.contains(e.relatedTarget)) focusInside.current = false;
|
|
236
|
-
},
|
|
237
226
|
style: {
|
|
238
227
|
"--fp-bottom": `calc(${BOTTOM_CLEARANCE})`,
|
|
239
228
|
"--fp-bottom-md": `calc(${BOTTOM_CLEARANCE} + ${offset} + 3.75rem)`,
|
|
@@ -257,10 +246,7 @@ function FloatingPanel({
|
|
|
257
246
|
"button",
|
|
258
247
|
{
|
|
259
248
|
type: "button",
|
|
260
|
-
onClick: () =>
|
|
261
|
-
focusInside.current = true;
|
|
262
|
-
setOpen(false);
|
|
263
|
-
},
|
|
249
|
+
onClick: () => setOpen(false),
|
|
264
250
|
"aria-label": labels.close,
|
|
265
251
|
className: cn(
|
|
266
252
|
"-me-1.5 shrink-0 rounded p-1.5 text-muted outline-none hover:bg-hover hover:text-secondary",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/floating-panel.tsx"],"sourcesContent":["import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type {\n AnchorHTMLAttributes,\n ButtonHTMLAttributes,\n CSSProperties,\n HTMLAttributes,\n KeyboardEvent,\n MouseEvent,\n ReactElement,\n ReactNode,\n Ref,\n} from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { registerFloating } from \"../lib/floating-stack\";\nimport { useFloatClearance } from \"../lib/float-clearance\";\nimport { isComposing } from \"../lib/ime\";\nimport { takeEscape, useOverlayOpen } from \"../hooks/use-dismiss\";\nimport { useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport { assignRef } from \"./field-parts\";\nimport { pickLinkRenderer } from \"./text-link\";\nimport { Tooltip, type TooltipSide } from \"./tooltip\";\nimport { DEFAULT_FLOATING_PANEL_LABELS } from \"./floating-panel-labels\";\n\nexport interface FloatingPanelLabels {\n /** Accessible name of the panel header's X. */\n close: string;\n /**\n * What a {@link FloatingAction}'s count badge adds to its accessible name (\"3 new\"),\n * read after the label: \"Invoices, 3 new\". The dot itself is hidden from a reader —\n * a bare \"3\" read out of context says nothing about what is counted.\n */\n badge: (count: number) => string;\n}\n\n// Defined in ./floating-panel-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_FLOATING_PANEL_LABELS };\n\n/** A LOGICAL corner: `bottom-end` is bottom-right in LTR and bottom-left in RTL. */\nexport type FloatingCorner = \"bottom-end\" | \"bottom-start\";\n\n/**\n * How far off the bottom edge anything floating here sits: clear of `AppShell`'s phone\n * nav (`--app-nav-h`, `0px` above `md` where the nav is not rendered) and of the home\n * indicator, whichever reaches higher — the nav does not pad itself for the inset, so\n * with no nav on screen the inset is all there is to clear. `max`, not a sum: where the\n * nav does cover the inset, adding both would float the button a nav-height too high.\n */\nconst BOTTOM_CLEARANCE = \"max(var(--app-nav-h, 0px), env(safe-area-inset-bottom, 0px))\";\n/** The side gutter, widened for a landscape notch. Both insets, because a logical\n * side has no one physical inset to name. */\nconst SIDE_GUTTER = \"max(1rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))\";\n\nfunction cornerStyle(corner: FloatingCorner, lift: string): CSSProperties {\n return {\n bottom: `calc(${BOTTOM_CLEARANCE} + ${lift})`,\n // Logical properties, so the corner follows the reading direction with no JS. The\n // portal carries the `dir` across (see `usePortalDir`).\n [corner === \"bottom-start\" ? \"insetInlineStart\" : \"insetInlineEnd\"]: SIDE_GUTTER,\n };\n}\n\n/**\n * The reading direction where the component was RENDERED, for its portalled root: a\n * portal to `<body>` leaves the subtree whose `dir` it inherited, and a logical\n * `inset-inline-end` resolves against the portal's own direction. Read off a hidden\n * inline marker in a layout effect, so the first painted frame already has it.\n */\nfunction usePortalDir(): [Ref<HTMLSpanElement>, Direction | undefined] {\n const marker = useRef<HTMLSpanElement>(null);\n const [dir, setDir] = useState<Direction | undefined>(undefined);\n useLayoutEffect(() => {\n setDir(dirOf(marker.current));\n }, []);\n return [marker, dir];\n}\n\nexport interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"children\"> {\n /** The accessible name — the button shows only its icon. */\n label: string;\n icon: ReactNode;\n /** Default `bottom-end`. */\n corner?: FloatingCorner;\n /**\n * Distance above the nav / home indicator, as a CSS length. Default `1rem`. Raise it\n * to stack above another floating control in the same corner — keksdose's assistant\n * clears the transactions page's own action group this way (`calc(1rem + 4rem)`).\n */\n offset?: string;\n /**\n * Whether the button carries the browser's own tooltip (`title`, the `label` unless a\n * `title` is passed). Default `true`, so an icon-only button keeps a hover hint for a\n * mouse user with no other way to learn what it is. `false` renders no `title` at all:\n * keksdose has ONE tooltip, the kit's `Tooltip` (dev#523), and its source scan fails a\n * native `title=` anywhere — so a flag, not `title=\"\"`, which the scan would still\n * see. An empty `title` renders no attribute either, for the caller who has no scan.\n *\n * Not flipped to off by default: a FAB wrapped in nothing would lose the only hover\n * hint it has, silently, in every app already on it. And the kit cannot wrap itself\n * in its `Tooltip` — the bubble's anchor is a `relative inline-flex` span, and the\n * button is `fixed` and portalled — so the one-tooltip caller wraps it.\n */\n nativeTitle?: boolean;\n /**\n * Make it a toggle. `true`/`false` set `aria-pressed` and swap the solid brand disc for\n * a surface disc — the glyph in brand on the quiet brand fill when on, the secondary\n * text colour when off — the \"on\" look of `IconButton`'s `pressed` and of a selected\n * Chip. Left out, it is the ordinary action FAB. For keksdose's corner filter toggles\n * (feedback-page's \"awaiting only\", the /transactions pending and upcoming toggles),\n * which paint `text-[var(--brand)]` on a surface button by hand. Keep `label` the same\n * in both states; `aria-pressed` already says which one it is in.\n */\n pressed?: boolean;\n /**\n * Show `label` beside the icon: the EXTENDED FAB, a pill rather than a disc. For a\n * corner control whose words are the point — kastlan's offline indicator\n * (shared/offline/offline-indicator.tsx:39) is a pill saying \"Offline — 2 photo(s)\n * queued\" / \"Syncing…\", hand-placed at `fixed bottom-4 left-4`, i.e. over the\n * bottom nav and on the wrong side in RTL. Same height as the disc (3rem), so a\n * {@link FloatingPanel} card above it still clears it.\n */\n extended?: boolean;\n /**\n * Announce `label` whenever it changes, through a polite `role=\"status\"` region — for\n * an extended FAB that REPORTS something (kastlan's offline/sync state), where a\n * sighted user sees the words change and a screen reader user otherwise hears\n * nothing until they happen to land on the button.\n *\n * ⚠️ A live region announces changes, not its own arrival: a FAB that mounts already\n * saying \"Offline\" says it to nobody. Keep it mounted and pass `hidden` while there\n * is nothing to report (kastlan returns `null` when online and idle); the region\n * stays in the page, and the first real status is heard.\n */\n live?: boolean;\n /**\n * `accent` (default): the solid brand disc of a page's main action, as Button's\n * `accent`. `surface`: the page's surface with a hairline border — for a FAB that is a\n * status rather than a call to action, as kastlan's offline pill is (a brand pill would\n * shout \"press me\" at a user who is merely offline). Ignored while `pressed` is set; a\n * toggle has its own two looks.\n */\n variant?: \"accent\" | \"surface\";\n /**\n * Hover and focus label in the kit {@link Tooltip}, instead of the native `title` —\n * keksdose has ONE tooltip (dev#523) and wrapped its assistant launcher by hand,\n * which cannot work: the tooltip's anchor is a `relative inline-flex` span, and this\n * button is `fixed` and portalled. With `tooltip` the FAB positions a fixed wrapper\n * instead and the button sits in the tooltip inside it. `true` shows the `label`;\n * any other content shows that. Implies no native `title` (whatever `nativeTitle`\n * says): one hover hint, not two. `style` stays on the button; the corner and\n * `offset` move to the wrapper.\n */\n tooltip?: ReactNode;\n /** Where the {@link tooltip} opens. Default `top`; the bubble is portalled and kept on\n * screen, so a corner button's label is not pushed off the edge. */\n tooltipSide?: TooltipSide;\n /**\n * 0.49 KIT-11: step aside (as `hidden` does: off screen, the room kept) while a dialog,\n * a sheet, a popover, a hover menu or a list is open, instead of floating over it or\n * beside it. Default `true`; `false` keeps it on screen. A tooltip does not count.\n */\n hideWhenOverlay?: boolean;\n ref?: Ref<HTMLButtonElement>;\n}\n\n/**\n * A round, icon-only button pinned to a corner of the viewport — the trigger of a\n * {@link FloatingPanel}, and usable alone for a page's one primary action.\n *\n * keksdose hand-placed each of its floating controls: the assistant launcher at\n * `fixed bottom-36 right-4 md:bottom-6`, the feedback page's and the transactions\n * page's action groups at `right-4 bottom-20`. Every one of those numbers is a guess\n * at the phone nav's height plus whatever else floats there (Keksdose live #314 found\n * out what such a guess is worth for a footer), `right` rather than `end` puts it over\n * the page's leading edge in RTL, and none allows for the home indicator. Here the\n * base is `--app-nav-h` and the safe-area inset, the corner is logical, and\n * {@link FloatingActionButtonProps.offset} is the one number left to the caller — the\n * height of whatever else it has to clear, not the nav's.\n *\n * Portalled, because `position: fixed` inside any transformed ancestor (an animated\n * page transition) is fixed to that ancestor, not to the screen.\n *\n * While mounted it reports its box (`lib/floating-stack.ts`), so `ServerWakeNotice`\n * stacks above it rather than over it.\n *\n * 0.48: while mounted, `hidden` or not, it also publishes the room it takes above the\n * nav (`offset` + 3rem + a 0.5rem gap) as `--app-float-clearance` on `<html>` (the\n * largest of several wins), and `AppShell` pads its `<main>` past it on a phone, so the\n * page's last row no longer ends under the button (keksdose dev#612).\n */\nexport function FloatingActionButton({\n label,\n icon,\n corner = \"bottom-end\",\n offset = \"1rem\",\n nativeTitle = true,\n pressed,\n extended = false,\n live = false,\n variant = \"accent\",\n tooltip,\n tooltipSide = \"top\",\n hideWhenOverlay = true,\n className,\n style,\n type = \"button\",\n title,\n hidden: hiddenProp,\n ref,\n ...rest\n}: FloatingActionButtonProps) {\n const overlay = useOverlayOpen(hideWhenOverlay);\n const hidden = hiddenProp || overlay || undefined;\n const [marker, dir] = usePortalDir();\n const toggle = pressed !== undefined;\n const tip = tooltip === true ? label : tooltip === false || tooltip === \"\" ? undefined : tooltip;\n const wrapped = tip !== undefined && tip !== null;\n // The fixed element — the button, or the wrapper when there is a tooltip.\n const [fixed, setFixed] = useState<HTMLElement | null>(null);\n useEffect(() => (fixed ? registerFloating(fixed) : undefined), [fixed]);\n // The room it takes above the nav, for AppShell's <main> to pad past (keksdose\n // dev#612). Hidden too: the room is the launcher's while it is mounted, so the page\n // does not change length under the reader as it hides and shows.\n useFloatClearance(offset);\n const buttonRef = useCallback(\n (node: HTMLButtonElement | null) => {\n assignRef(ref, node);\n if (!wrapped) setFixed(node);\n },\n [ref, wrapped],\n );\n // An empty title is \"no tooltip\", not an attribute with nothing in it. The kit\n // tooltip replaces it outright.\n const nativeTip = !wrapped && nativeTitle && title !== \"\" ? (title ?? label) : undefined;\n const button = (\n <button\n {...rest}\n hidden={wrapped ? undefined : hidden}\n ref={buttonRef}\n type={type}\n dir={dir}\n aria-label={label}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n title={nativeTip}\n style={wrapped ? style : { ...cornerStyle(corner, offset), ...style }}\n className={cn(\n // Wrapped, the wrapper is the fixed thing and the button sits in it.\n !wrapped && \"fixed z-40\",\n \"inline-flex items-center justify-center rounded-full\",\n extended ? \"h-12 gap-2 px-4 text-sm font-medium [&_svg]:size-5\" : \"size-12 [&_svg]:size-6\",\n \"shadow-lg transition-colors disabled:cursor-default\",\n !toggle && variant === \"accent\" && \"bg-brand text-brand-contrast hover:bg-brand-hover\",\n !toggle &&\n variant === \"surface\" &&\n \"border border-subtle bg-surface text-primary hover:bg-hover disabled:hover:bg-surface\",\n // A toggle sits on the page's surface — a solid brand disc would read as\n // \"on\" in both states — and says \"on\" in the brand family.\n toggle && \"border border-subtle\",\n pressed === false && \"bg-surface text-secondary hover:bg-hover hover:text-primary\",\n pressed === true && \"border-brand bg-brand-soft text-brand hover:bg-brand-soft-hover hover:text-brand-muted\",\n \"outline-none focus-visible:ring-offset-2\",\n FOCUS_RING,\n className,\n )}\n >\n {icon}\n {/* Visible words, not the name: `aria-label` already is the name, and the\n span would only repeat it. */}\n {extended && (\n <span aria-hidden className=\"whitespace-nowrap\">\n {label}\n </span>\n )}\n </button>\n );\n return (\n <>\n <span ref={marker} hidden />\n {typeof document !== \"undefined\" &&\n createPortal(\n <>\n {wrapped ? (\n <div\n ref={setFixed}\n dir={dir}\n hidden={hidden}\n style={cornerStyle(corner, offset)}\n className=\"fixed z-40 inline-flex\"\n >\n <Tooltip label={tip} side={tooltipSide} portal>\n {/* A fragment when the bubble says the name again, so the tooltip\n does not describe the button with its own label — a reader would\n hear it twice. Other text is a real description. */}\n {tip === label ? <>{button}</> : button}\n </Tooltip>\n </div>\n ) : (\n button\n )}\n {live && (\n <span role=\"status\" className=\"sr-only\">\n {label}\n </span>\n )}\n </>,\n document.body,\n )}\n </>\n );\n}\n\nexport interface FloatingPanelProps {\n /** The panel's heading, and its accessible name. */\n title: ReactNode;\n /** The trigger's accessible name (\"Assistant\", \"Send feedback\"). */\n fabLabel: string;\n fabIcon: ReactNode;\n /** Controlled open state. Leave it out to let the panel own it. */\n open?: boolean;\n /** Uncontrolled initial state. Default closed. */\n defaultOpen?: boolean;\n /** Every request to open or close — the FAB, the X, Escape. */\n onOpenChange?: (open: boolean) => void;\n /** Default `bottom-end`. */\n corner?: FloatingCorner;\n /** The FAB's distance above the nav — see {@link FloatingActionButtonProps.offset}.\n * From `md` up the card sits above the FAB, so it moves with it. */\n offset?: string;\n /** The FAB's native `title` — see {@link FloatingActionButtonProps.nativeTitle}.\n * `false` for keksdose's assistant launcher, under its one-tooltip rule (dev#523). */\n fabNativeTitle?: boolean;\n /** The FAB's kit tooltip — see {@link FloatingActionButtonProps.tooltip}. `true` for\n * keksdose's assistant launcher, which wanted the kit bubble and had no way to\n * put one on a fixed, portalled button (dev#523). */\n fabTooltip?: ReactNode;\n /** Default: `floatingPanel.close` from the {@link UiKitProvider}, else \"Close\". */\n closeLabel?: string;\n /**\n * Where focus goes on open. Default the panel itself, so a phone's keyboard does not\n * rise over the sheet before the user has asked to type; pass the chat field for a\n * panel whose one job is typing.\n */\n initialFocus?: () => HTMLElement | null;\n /** Extra classes for the panel. */\n className?: string;\n /** Extra classes for the scrolling body. */\n bodyClassName?: string;\n /**\n * 0.48: take the FAB and the panel off screen while keeping both mounted — the open\n * state and the bottom room (`--app-float-clearance`) stay, and an open panel is back\n * when this turns `false`. keksdose's assistant launcher steps aside while an editor\n * or a modal covers its corner (dev#612); unmounting it instead would give the room\n * back and shorten the page under the reader's thumb.\n */\n hidden?: boolean;\n /**\n * 0.49 KIT-11: the FAB steps aside while a dialog, a sheet, a popover, a hover menu or\n * a list is open elsewhere (see {@link FloatingActionButtonProps.hideWhenOverlay}).\n * Default `true`. An OPEN panel stays: what opens from inside it (a select, a date\n * picker) is its own content.\n */\n hideWhenOverlay?: boolean;\n children: ReactNode;\n}\n\n/**\n * A docked, NON-MODAL panel in a corner of the screen, opened by a\n * {@link FloatingActionButton} — keksdose's assistant launcher and its feedback page,\n * which each built one.\n *\n * ## Not a Modal\n *\n * The page stays usable while it is open: that is the point of an assistant beside the\n * data it is asked about. So there is no backdrop, no scroll lock, no `aria-modal` and\n * no focus trap — Tab walks out of the panel into the page and back, and a click on the\n * page does not close it. It is `role=\"dialog\"` without `aria-modal`, which is what\n * ARIA calls a non-modal dialog, named by its heading.\n *\n * What it keeps from a dialog is the focus HANDOFF: opening moves focus into it (a\n * keyboard user who pressed the FAB should not have to hunt for what appeared), and\n * closing gives it back to the FAB — but only when focus was in the panel, so a panel\n * closed from the page (a controlled owner) never pulls focus out of the field the\n * user is typing in. Escape closes it while focus is inside, and only then: on the\n * panel element, not the document, so Escape in the page keeps meaning whatever the\n * page means by it. A layer of the kit's Escape stack open over it — a tooltip, a list —\n * is handed the key first.\n *\n * ## Layout\n *\n * From `md` up, a card above the FAB in its corner. Below `md`, a full-width sheet\n * docked to the bottom, sitting ON the `AppShell` bottom nav (`--app-nav-h`) and\n * clear of the home indicator. Pure CSS — no width is measured in JavaScript.\n *\n * The FAB carries `aria-expanded` and, while the panel exists, `aria-controls`. The\n * panel unmounts when closed; an app that must keep a draft across a close keeps it in\n * its own state, where it also survives a reload.\n */\nexport function FloatingPanel({\n title,\n fabLabel,\n fabIcon,\n open: openProp,\n defaultOpen = false,\n onOpenChange,\n corner = \"bottom-end\",\n offset = \"1rem\",\n fabNativeTitle,\n fabTooltip,\n closeLabel,\n initialFocus,\n className,\n bodyClassName,\n hidden: hiddenProp = false,\n hideWhenOverlay = true,\n children,\n}: FloatingPanelProps) {\n const labels = useKitLabels(\n \"floatingPanel\",\n DEFAULT_FLOATING_PANEL_LABELS,\n closeLabel === undefined ? undefined : { close: closeLabel },\n );\n const [ownOpen, setOwnOpen] = useState(defaultOpen);\n const open = openProp ?? ownOpen;\n const overlay = useOverlayOpen(hideWhenOverlay && !open);\n const hidden = hiddenProp || overlay;\n const setOpen = (next: boolean) => {\n if (openProp === undefined) setOwnOpen(next);\n onOpenChange?.(next);\n };\n\n const uid = useId();\n const panelId = `${uid}-panel`;\n const titleId = `${uid}-title`;\n const fabRef = useRef<HTMLButtonElement>(null);\n const panelRef = useRef<HTMLDivElement>(null);\n const [marker, dir] = usePortalDir();\n\n const initialFocusRef = useRef(initialFocus);\n useEffect(() => {\n initialFocusRef.current = initialFocus;\n });\n\n // Focus follows the open/closed TRANSITION, never the mount: a panel that renders\n // open (`defaultOpen`, a restored route) does not take the page's focus by existing.\n const wasOpen = useRef(open);\n // Whether focus was inside the panel when it last changed — read at close time, when\n // the panel (and whatever had focus in it) is already gone.\n const focusInside = useRef(false);\n useEffect(() => {\n const changed = wasOpen.current !== open;\n wasOpen.current = open;\n if (!changed) return;\n if (open) {\n (initialFocusRef.current?.() ?? panelRef.current)?.focus();\n return;\n }\n const active = document.activeElement;\n const lost = active === null || active === document.body;\n if (focusInside.current || lost) fabRef.current?.focus();\n focusInside.current = false;\n }, [open]);\n\n const onPanelKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key !== \"Escape\") return;\n // The kit's one Escape stack first (round 0.34 Wave 2, DUP-1): a layer open above the\n // panel without focus — a tooltip, the numpad, a hover menu — is the topmost thing on\n // screen and is handed the key. This took it for itself and stopped it, so the\n // tooltip never heard it and one press closed the whole panel instead.\n if (takeEscape(e)) return;\n // Not a layer itself: it answers only while focus is inside it, and a layer would\n // also be handed an Escape pressed in the page. Kept by a child's claim (ChipInput\n // clearing its draft, `claimEscape`), and by an IME composition, as a layer is.\n if (e.defaultPrevented || isComposing(e)) return;\n // Stopped here, so an overlay underneath with a document-level Escape listener does\n // not close too — the innermost thing that has focus is the one that closes.\n e.stopPropagation();\n focusInside.current = true;\n setOpen(false);\n };\n\n return (\n <>\n <span ref={marker} hidden />\n <FloatingActionButton\n ref={fabRef}\n label={fabLabel}\n icon={fabIcon}\n corner={corner}\n offset={offset}\n nativeTitle={fabNativeTitle}\n tooltip={fabTooltip}\n hidden={hidden || undefined}\n hideWhenOverlay={false}\n aria-expanded={open}\n aria-controls={open && !hidden ? panelId : undefined}\n onClick={() => {\n // A press ON the FAB closing the panel: the FAB keeps focus by being clicked,\n // nothing to hand back.\n focusInside.current = false;\n setOpen(!open);\n }}\n />\n {open &&\n !hidden &&\n typeof document !== \"undefined\" &&\n createPortal(\n // A container for Escape bubbling up from anything inside it, as a dialog is.\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n <div\n ref={panelRef}\n id={panelId}\n role=\"dialog\"\n aria-labelledby={titleId}\n tabIndex={-1}\n dir={dir}\n data-corner={corner}\n onKeyDown={onPanelKeyDown}\n onFocus={() => {\n focusInside.current = true;\n }}\n onBlur={(e) => {\n if (!e.currentTarget.contains(e.relatedTarget as Node | null)) focusInside.current = false;\n }}\n // The phone's sheet sits on the nav; from `md` up the card floats above the\n // FAB (its offset + the 3rem button + a 0.75rem gap).\n style={\n {\n \"--fp-bottom\": `calc(${BOTTOM_CLEARANCE})`,\n \"--fp-bottom-md\": `calc(${BOTTOM_CLEARANCE} + ${offset} + 3.75rem)`,\n \"--fp-side\": SIDE_GUTTER,\n } as CSSProperties\n }\n className={cn(\n \"animate-sheet fixed z-40 flex flex-col overflow-hidden outline-none\",\n \"border border-subtle bg-surface shadow-2xl\",\n // Phone: full width, docked, rounded only where it meets the page.\n \"inset-x-0 bottom-[var(--fp-bottom)] max-h-[min(85dvh,calc(100dvh_-_var(--fp-bottom)_-_1rem))] rounded-t-xl border-x-0 border-b-0\",\n \"pb-[env(safe-area-inset-bottom,0px)] md:pb-0\",\n // md+: a card in the corner, above the FAB.\n \"md:inset-x-auto md:bottom-[var(--fp-bottom-md)] md:w-[24rem] md:max-h-[min(36rem,calc(100dvh_-_var(--fp-bottom-md)_-_1rem))] md:rounded-xl md:border\",\n corner === \"bottom-start\" ? \"md:start-[var(--fp-side)]\" : \"md:end-[var(--fp-side)]\",\n className,\n )}\n >\n <div className=\"flex shrink-0 items-center justify-between gap-2 border-b border-subtle px-4 py-3\">\n <h2 id={titleId} className=\"min-w-0 truncate text-base font-semibold text-primary\">\n {title}\n </h2>\n <button\n type=\"button\"\n onClick={() => {\n focusInside.current = true;\n setOpen(false);\n }}\n aria-label={labels.close}\n className={cn(\n \"-me-1.5 shrink-0 rounded p-1.5 text-muted outline-none hover:bg-hover hover:text-secondary\",\n FOCUS_RING,\n )}\n >\n <X aria-hidden className=\"size-5\" />\n </button>\n </div>\n <div className={cn(\"min-h-0 flex-1 overflow-y-auto overscroll-contain p-4\", bodyClassName)}>{children}</div>\n </div>,\n document.body,\n )}\n </>\n );\n}\n\nexport interface FloatingActionGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, \"role\"> {\n /** Name the group (\"Register actions\"); a reader then introduces the members as one\n * set. A group with no name is one more unlabelled wrapper. */\n \"aria-label\"?: string;\n /** Default `bottom-end`. */\n corner?: FloatingCorner;\n /** Distance above the nav / home indicator — see {@link FloatingActionButtonProps.offset}. */\n offset?: string;\n /** {@link FloatingAction}s, in reading order. */\n children: ReactNode;\n /** 0.49 KIT-11: step aside while a dialog, a sheet, a popover, a hover menu or a list\n * is open — see {@link FloatingActionButtonProps.hideWhenOverlay}. Default `true`. */\n hideWhenOverlay?: boolean;\n ref?: Ref<HTMLDivElement>;\n}\n\n/**\n * A floating pill of {@link FloatingAction}s — one to four `size-12` toggles, actions\n * or links with hairline dividers — pinned to a corner exactly as the\n * {@link FloatingActionButton} is: `--app-nav-h` and the home indicator cleared, a\n * logical corner, portalled.\n *\n * keksdose built it twice by hand: the /transactions phone toolbar\n * (transactions-page.tsx:644 — upcoming and pending toggles, an invoices link with a\n * pending count, and the brand \"+\") and the feedback page's \"awaiting only\" toggle\n * (feedback-page.tsx:854, one button in the group anyway, so the two pages look\n * alike). Both at `right-4 bottom-20` — `right`, not `end`, so over the page's leading\n * edge in RTL — with `divide-x`, slate hovers, and each member's `Tooltip` spelled out.\n *\n * `role=\"group\"`, NOT a toolbar: every member keeps its own tab stop, as the\n * hand-built ones did, and there is no arrow-key roving to learn. Hide it where it\n * does not belong with a class (`md:hidden`, as both keksdose pages do).\n */\nexport function FloatingActionGroup({\n corner = \"bottom-end\",\n offset = \"1rem\",\n hideWhenOverlay = true,\n hidden,\n className,\n style,\n children,\n ...rest\n}: FloatingActionGroupProps) {\n const [marker, dir] = usePortalDir();\n const overlay = useOverlayOpen(hideWhenOverlay);\n // As the FAB's (keksdose dev#612): its 3rem members plus the frame's 1px borders.\n useFloatClearance(offset, \"2px\");\n return (\n <>\n <span ref={marker} hidden />\n {typeof document !== \"undefined\" &&\n createPortal(\n <div\n {...rest}\n hidden={hidden || overlay || undefined}\n role=\"group\"\n dir={dir}\n data-corner={corner}\n style={{ ...cornerStyle(corner, offset), ...style }}\n className={cn(\n \"fixed z-40 inline-flex flex-row overflow-hidden rounded-2xl\",\n \"border border-subtle bg-surface shadow-lg\",\n // Dividers on the logical start edge of every member but the first, so\n // they sit between the same two members in RTL. The member is the\n // tooltip's wrapper span, which is the direct child.\n \"[&>*:not(:first-child)]:border-s [&>*]:border-subtle\",\n className,\n )}\n >\n {children}\n </div>,\n document.body,\n )}\n </>\n );\n}\n\n/** What {@link FloatingActionProps.renderLink} is handed. Spread it onto your router's\n * link, mapping `href` — `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`. */\nexport interface FloatingActionLinkProps {\n href: string;\n /** The member's whole look — keep it. */\n className: string;\n children: ReactNode;\n \"aria-label\": string;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\nexport interface FloatingActionProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"children\" | \"onClick\"> {\n /** The accessible name, and the tooltip's text. Keep it the same in both states of a\n * toggle; `aria-pressed` says which one it is in. */\n label: string;\n icon: ReactNode;\n /** Make it a toggle: `aria-pressed` and the brand \"on\" look (the brand glyph on the\n * quiet brand fill), as {@link FloatingActionButtonProps.pressed}. Ignored on a link.\n * For the state without the tint, see {@link pressedStyle}. */\n pressed?: boolean;\n /**\n * How `pressed` looks. `tint` (default): the brand \"on\" look. `plain`: the member\n * keeps its quiet look in both states and only `aria-pressed` changes — for a toggle\n * whose icon already says which state it is in (keksdose's /transactions \"Upcoming\"\n * swaps two icons; a tint on top said it twice). The same as passing `aria-pressed`\n * without `pressed`, which also rides through untinted.\n */\n pressedStyle?: \"tint\" | \"plain\";\n /**\n * `accent`: the brand-filled member, for the group's main action (the /transactions\n * \"+\"), as Button's `accent`. `default` (default): the quiet one.\n */\n variant?: \"default\" | \"accent\";\n /**\n * A count in a dot at the member's top end corner — the /transactions invoices link's\n * pending count. Nothing is drawn for `0` or less; above 99 it reads `99+`. The count\n * joins the accessible name through `floatingPanel.badge` (\"Invoices, 3 new\"), or\n * `badgeLabel` when passed.\n */\n badge?: number;\n /** The badge's words for a reader, replacing `floatingPanel.badge` — \"3 to review\". */\n badgeLabel?: string;\n /** The dot's colour. Default `warning`, the pending count's. */\n badgeTone?: \"warning\" | \"danger\" | \"brand\";\n /** Show `label` in the kit {@link Tooltip} on hover and focus. Default `true`: the\n * members are icon-only and this is their only visible label. */\n tooltip?: boolean;\n /** Where the tooltip opens. Default `top` — the group sits at the bottom of the screen. */\n tooltipSide?: TooltipSide;\n /** Make it a link — the /transactions invoices member. Drawn by `renderLink`, else\n * the `<UiKitProvider linkComponent>` (an in-app `href`), else a plain `<a>`. */\n href?: string;\n /** Render the link through your router's `Link` — see {@link FloatingActionLinkProps}. */\n renderLink?: (props: FloatingActionLinkProps) => ReactElement;\n onClick?: (event: MouseEvent<HTMLElement>) => void;\n ref?: Ref<HTMLButtonElement>;\n}\n\n// Each fill under its own foreground (0.33): warning borrowed `--text-inverse`, the\n// surface colour, which was 4.24:1 on the light warning fill.\nconst BADGE_TONE: Record<NonNullable<FloatingActionProps[\"badgeTone\"]>, string> = {\n warning: \"bg-warning text-warning-contrast\",\n danger: \"bg-danger text-danger-contrast\",\n brand: \"bg-brand text-brand-contrast\",\n};\n\n/**\n * One member of a {@link FloatingActionGroup}: a `size-12` icon button, toggle or link\n * with its label in a styled tooltip — what each keksdose member wrapped by hand in\n * `<Tooltip portal>`. The tooltip is portalled because the group clips (`overflow-hidden`\n * keeps the brand member's fill inside the pill's corners), and the focus ring is inset\n * for the same reason.\n */\nexport function FloatingAction({\n label,\n icon,\n pressed,\n pressedStyle = \"tint\",\n variant = \"default\",\n badge,\n badgeLabel,\n badgeTone = \"warning\",\n tooltip = true,\n tooltipSide = \"top\",\n href,\n renderLink,\n className,\n type = \"button\",\n onClick,\n ref,\n ...rest\n}: FloatingActionProps) {\n const labels = useKitLabels(\"floatingPanel\", DEFAULT_FLOATING_PANEL_LABELS);\n const kitLink = useKitLink();\n const counted = badge !== undefined && badge > 0;\n const name = counted ? `${label}, ${badgeLabel ?? labels.badge(badge)}` : label;\n const isLink = href !== undefined;\n const look = cn(\n \"relative flex size-12 items-center justify-center transition-colors\",\n \"outline-none focus-visible:ring-inset\",\n FOCUS_RING,\n \"disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-5\",\n variant === \"accent\"\n ? \"bg-brand text-brand-contrast hover:bg-brand-hover\"\n : pressed && !isLink && pressedStyle === \"tint\"\n ? \"bg-brand-soft text-brand hover:bg-brand-soft-hover hover:text-brand-muted\"\n : \"text-secondary hover:bg-hover hover:text-primary\",\n className,\n );\n const body = (\n <>\n {icon}\n {counted && (\n <span\n aria-hidden\n data-badge=\"\"\n className={cn(\n \"pointer-events-none absolute end-1 top-1 h-4 min-w-4 rounded-full px-1 text-center text-micro leading-4 font-semibold\",\n BADGE_TONE[badgeTone],\n )}\n >\n {badge > 99 ? \"99+\" : badge}\n </span>\n )}\n </>\n );\n\n let member: ReactElement;\n if (isLink) {\n // The button-only attributes stay behind; `aria-*`, `data-*` and the like ride along.\n const { disabled: _disabled, form: _form, value: _value, name: _name, ...anchorRest } = rest;\n const linkProps: FloatingActionLinkProps = {\n ...(anchorRest as AnchorHTMLAttributes<HTMLAnchorElement>),\n href,\n \"aria-label\": name,\n onClick,\n className: look,\n children: body,\n };\n const { children: content, ...anchor } = linkProps;\n const render = pickLinkRenderer(renderLink, kitLink, href);\n member = render ? <RenderedFloatingLink render={render} {...linkProps} /> : <a {...anchor}>{content}</a>;\n } else {\n member = (\n <button\n {...rest}\n ref={ref}\n type={type}\n aria-label={name}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n onClick={onClick}\n className={look}\n >\n {body}\n </button>\n );\n }\n if (!tooltip) return member;\n return (\n // A fragment: the bubble says the member's name, and a description that repeats\n // the name is read twice.\n <Tooltip label={label} side={tooltipSide} portal>\n <>{member}</>\n </Tooltip>\n );\n}\n\n/** Calls `renderLink` as a component, so its hooks belong to the link, not to us. */\nfunction RenderedFloatingLink({\n render,\n ...props\n}: FloatingActionLinkProps & { render: (props: FloatingActionLinkProps) => ReactElement }) {\n return render(props);\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/floating-panel.tsx"],"sourcesContent":["import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type {\n AnchorHTMLAttributes,\n ButtonHTMLAttributes,\n CSSProperties,\n HTMLAttributes,\n KeyboardEvent,\n MouseEvent,\n ReactElement,\n ReactNode,\n Ref,\n} from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { registerFloating } from \"../lib/floating-stack\";\nimport { returnFocus } from \"../lib/focus-left\";\nimport { useFloatClearance } from \"../lib/float-clearance\";\nimport { isComposing } from \"../lib/ime\";\nimport { takeEscape, useOverlayOpen } from \"../hooks/use-dismiss\";\nimport { useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport { assignRef } from \"./field-parts\";\nimport { pickLinkRenderer } from \"./text-link\";\nimport { Tooltip, type TooltipSide } from \"./tooltip\";\nimport { DEFAULT_FLOATING_PANEL_LABELS } from \"./floating-panel-labels\";\n\nexport interface FloatingPanelLabels {\n /** Accessible name of the panel header's X. */\n close: string;\n /**\n * What a {@link FloatingAction}'s count badge adds to its accessible name (\"3 new\"),\n * read after the label: \"Invoices, 3 new\". The dot itself is hidden from a reader —\n * a bare \"3\" read out of context says nothing about what is counted.\n */\n badge: (count: number) => string;\n}\n\n// Defined in ./floating-panel-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_FLOATING_PANEL_LABELS };\n\n/** A LOGICAL corner: `bottom-end` is bottom-right in LTR and bottom-left in RTL. */\nexport type FloatingCorner = \"bottom-end\" | \"bottom-start\";\n\n/**\n * How far off the bottom edge anything floating here sits: clear of `AppShell`'s phone\n * nav (`--app-nav-h`, `0px` above `md` where the nav is not rendered) and of the home\n * indicator, whichever reaches higher — the nav does not pad itself for the inset, so\n * with no nav on screen the inset is all there is to clear. `max`, not a sum: where the\n * nav does cover the inset, adding both would float the button a nav-height too high.\n */\nconst BOTTOM_CLEARANCE = \"max(var(--app-nav-h, 0px), env(safe-area-inset-bottom, 0px))\";\n/** The side gutter, widened for a landscape notch. Both insets, because a logical\n * side has no one physical inset to name. */\nconst SIDE_GUTTER = \"max(1rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))\";\n\nfunction cornerStyle(corner: FloatingCorner, lift: string): CSSProperties {\n return {\n bottom: `calc(${BOTTOM_CLEARANCE} + ${lift})`,\n // Logical properties, so the corner follows the reading direction with no JS. The\n // portal carries the `dir` across (see `usePortalDir`).\n [corner === \"bottom-start\" ? \"insetInlineStart\" : \"insetInlineEnd\"]: SIDE_GUTTER,\n };\n}\n\n/**\n * The reading direction where the component was RENDERED, for its portalled root: a\n * portal to `<body>` leaves the subtree whose `dir` it inherited, and a logical\n * `inset-inline-end` resolves against the portal's own direction. Read off a hidden\n * inline marker in a layout effect, so the first painted frame already has it.\n */\nfunction usePortalDir(): [Ref<HTMLSpanElement>, Direction | undefined] {\n const marker = useRef<HTMLSpanElement>(null);\n const [dir, setDir] = useState<Direction | undefined>(undefined);\n useLayoutEffect(() => {\n setDir(dirOf(marker.current));\n }, []);\n return [marker, dir];\n}\n\nexport interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"children\"> {\n /** The accessible name — the button shows only its icon. */\n label: string;\n icon: ReactNode;\n /** Default `bottom-end`. */\n corner?: FloatingCorner;\n /**\n * Distance above the nav / home indicator, as a CSS length. Default `1rem`. Raise it\n * to stack above another floating control in the same corner — keksdose's assistant\n * clears the transactions page's own action group this way (`calc(1rem + 4rem)`).\n */\n offset?: string;\n /**\n * Whether the button carries the browser's own tooltip (`title`, the `label` unless a\n * `title` is passed). Default `true`, so an icon-only button keeps a hover hint for a\n * mouse user with no other way to learn what it is. `false` renders no `title` at all:\n * keksdose has ONE tooltip, the kit's `Tooltip` (dev#523), and its source scan fails a\n * native `title=` anywhere — so a flag, not `title=\"\"`, which the scan would still\n * see. An empty `title` renders no attribute either, for the caller who has no scan.\n *\n * Not flipped to off by default: a FAB wrapped in nothing would lose the only hover\n * hint it has, silently, in every app already on it. And the kit cannot wrap itself\n * in its `Tooltip` — the bubble's anchor is a `relative inline-flex` span, and the\n * button is `fixed` and portalled — so the one-tooltip caller wraps it.\n */\n nativeTitle?: boolean;\n /**\n * Make it a toggle. `true`/`false` set `aria-pressed` and swap the solid brand disc for\n * a surface disc — the glyph in brand on the quiet brand fill when on, the secondary\n * text colour when off — the \"on\" look of `IconButton`'s `pressed` and of a selected\n * Chip. Left out, it is the ordinary action FAB. For keksdose's corner filter toggles\n * (feedback-page's \"awaiting only\", the /transactions pending and upcoming toggles),\n * which paint `text-[var(--brand)]` on a surface button by hand. Keep `label` the same\n * in both states; `aria-pressed` already says which one it is in.\n */\n pressed?: boolean;\n /**\n * Show `label` beside the icon: the EXTENDED FAB, a pill rather than a disc. For a\n * corner control whose words are the point — kastlan's offline indicator\n * (shared/offline/offline-indicator.tsx:39) is a pill saying \"Offline — 2 photo(s)\n * queued\" / \"Syncing…\", hand-placed at `fixed bottom-4 left-4`, i.e. over the\n * bottom nav and on the wrong side in RTL. Same height as the disc (3rem), so a\n * {@link FloatingPanel} card above it still clears it.\n */\n extended?: boolean;\n /**\n * Announce `label` whenever it changes, through a polite `role=\"status\"` region — for\n * an extended FAB that REPORTS something (kastlan's offline/sync state), where a\n * sighted user sees the words change and a screen reader user otherwise hears\n * nothing until they happen to land on the button.\n *\n * ⚠️ A live region announces changes, not its own arrival: a FAB that mounts already\n * saying \"Offline\" says it to nobody. Keep it mounted and pass `hidden` while there\n * is nothing to report (kastlan returns `null` when online and idle); the region\n * stays in the page, and the first real status is heard.\n */\n live?: boolean;\n /**\n * `accent` (default): the solid brand disc of a page's main action, as Button's\n * `accent`. `surface`: the page's surface with a hairline border — for a FAB that is a\n * status rather than a call to action, as kastlan's offline pill is (a brand pill would\n * shout \"press me\" at a user who is merely offline). Ignored while `pressed` is set; a\n * toggle has its own two looks.\n */\n variant?: \"accent\" | \"surface\";\n /**\n * Hover and focus label in the kit {@link Tooltip}, instead of the native `title` —\n * keksdose has ONE tooltip (dev#523) and wrapped its assistant launcher by hand,\n * which cannot work: the tooltip's anchor is a `relative inline-flex` span, and this\n * button is `fixed` and portalled. With `tooltip` the FAB positions a fixed wrapper\n * instead and the button sits in the tooltip inside it. `true` shows the `label`;\n * any other content shows that. Implies no native `title` (whatever `nativeTitle`\n * says): one hover hint, not two. `style` stays on the button; the corner and\n * `offset` move to the wrapper.\n */\n tooltip?: ReactNode;\n /** Where the {@link tooltip} opens. Default `top`; the bubble is portalled and kept on\n * screen, so a corner button's label is not pushed off the edge. */\n tooltipSide?: TooltipSide;\n /**\n * 0.49 KIT-11: step aside (as `hidden` does: off screen, the room kept) while a dialog,\n * a sheet, a popover, a hover menu or a list is open, instead of floating over it or\n * beside it. Default `true`; `false` keeps it on screen. A tooltip does not count.\n */\n hideWhenOverlay?: boolean;\n ref?: Ref<HTMLButtonElement>;\n}\n\n/**\n * A round, icon-only button pinned to a corner of the viewport — the trigger of a\n * {@link FloatingPanel}, and usable alone for a page's one primary action.\n *\n * keksdose hand-placed each of its floating controls: the assistant launcher at\n * `fixed bottom-36 right-4 md:bottom-6`, the feedback page's and the transactions\n * page's action groups at `right-4 bottom-20`. Every one of those numbers is a guess\n * at the phone nav's height plus whatever else floats there (Keksdose live #314 found\n * out what such a guess is worth for a footer), `right` rather than `end` puts it over\n * the page's leading edge in RTL, and none allows for the home indicator. Here the\n * base is `--app-nav-h` and the safe-area inset, the corner is logical, and\n * {@link FloatingActionButtonProps.offset} is the one number left to the caller — the\n * height of whatever else it has to clear, not the nav's.\n *\n * Portalled, because `position: fixed` inside any transformed ancestor (an animated\n * page transition) is fixed to that ancestor, not to the screen.\n *\n * While mounted it reports its box (`lib/floating-stack.ts`), so `ServerWakeNotice`\n * stacks above it rather than over it.\n *\n * 0.48: while mounted, `hidden` or not, it also publishes the room it takes above the\n * nav (`offset` + 3rem + a 0.5rem gap) as `--app-float-clearance` on `<html>` (the\n * largest of several wins), and `AppShell` pads its `<main>` past it on a phone, so the\n * page's last row no longer ends under the button (keksdose dev#612).\n */\nexport function FloatingActionButton({\n label,\n icon,\n corner = \"bottom-end\",\n offset = \"1rem\",\n nativeTitle = true,\n pressed,\n extended = false,\n live = false,\n variant = \"accent\",\n tooltip,\n tooltipSide = \"top\",\n hideWhenOverlay = true,\n className,\n style,\n type = \"button\",\n title,\n hidden: hiddenProp,\n ref,\n ...rest\n}: FloatingActionButtonProps) {\n const overlay = useOverlayOpen(hideWhenOverlay);\n const hidden = hiddenProp || overlay || undefined;\n const [marker, dir] = usePortalDir();\n const toggle = pressed !== undefined;\n const tip = tooltip === true ? label : tooltip === false || tooltip === \"\" ? undefined : tooltip;\n const wrapped = tip !== undefined && tip !== null;\n // The fixed element — the button, or the wrapper when there is a tooltip.\n const [fixed, setFixed] = useState<HTMLElement | null>(null);\n useEffect(() => (fixed ? registerFloating(fixed) : undefined), [fixed]);\n // The room it takes above the nav, for AppShell's <main> to pad past (keksdose\n // dev#612). Hidden too: the room is the launcher's while it is mounted, so the page\n // does not change length under the reader as it hides and shows.\n useFloatClearance(offset);\n const buttonRef = useCallback(\n (node: HTMLButtonElement | null) => {\n assignRef(ref, node);\n if (!wrapped) setFixed(node);\n },\n [ref, wrapped],\n );\n // An empty title is \"no tooltip\", not an attribute with nothing in it. The kit\n // tooltip replaces it outright.\n const nativeTip = !wrapped && nativeTitle && title !== \"\" ? (title ?? label) : undefined;\n const button = (\n <button\n {...rest}\n hidden={wrapped ? undefined : hidden}\n ref={buttonRef}\n type={type}\n dir={dir}\n aria-label={label}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n title={nativeTip}\n style={wrapped ? style : { ...cornerStyle(corner, offset), ...style }}\n className={cn(\n // Wrapped, the wrapper is the fixed thing and the button sits in it.\n !wrapped && \"fixed z-40\",\n \"inline-flex items-center justify-center rounded-full\",\n extended ? \"h-12 gap-2 px-4 text-sm font-medium [&_svg]:size-5\" : \"size-12 [&_svg]:size-6\",\n \"shadow-lg transition-colors disabled:cursor-default\",\n !toggle && variant === \"accent\" && \"bg-brand text-brand-contrast hover:bg-brand-hover\",\n !toggle &&\n variant === \"surface\" &&\n \"border border-subtle bg-surface text-primary hover:bg-hover disabled:hover:bg-surface\",\n // A toggle sits on the page's surface — a solid brand disc would read as\n // \"on\" in both states — and says \"on\" in the brand family.\n toggle && \"border border-subtle\",\n pressed === false && \"bg-surface text-secondary hover:bg-hover hover:text-primary\",\n pressed === true && \"border-brand bg-brand-soft text-brand hover:bg-brand-soft-hover hover:text-brand-muted\",\n \"outline-none focus-visible:ring-offset-2\",\n FOCUS_RING,\n className,\n )}\n >\n {icon}\n {/* Visible words, not the name: `aria-label` already is the name, and the\n span would only repeat it. */}\n {extended && (\n <span aria-hidden className=\"whitespace-nowrap\">\n {label}\n </span>\n )}\n </button>\n );\n return (\n <>\n <span ref={marker} hidden />\n {typeof document !== \"undefined\" &&\n createPortal(\n <>\n {wrapped ? (\n <div\n ref={setFixed}\n dir={dir}\n hidden={hidden}\n style={cornerStyle(corner, offset)}\n className=\"fixed z-40 inline-flex\"\n >\n <Tooltip label={tip} side={tooltipSide} portal>\n {/* A fragment when the bubble says the name again, so the tooltip\n does not describe the button with its own label — a reader would\n hear it twice. Other text is a real description. */}\n {tip === label ? <>{button}</> : button}\n </Tooltip>\n </div>\n ) : (\n button\n )}\n {live && (\n <span role=\"status\" className=\"sr-only\">\n {label}\n </span>\n )}\n </>,\n document.body,\n )}\n </>\n );\n}\n\nexport interface FloatingPanelProps {\n /** The panel's heading, and its accessible name. */\n title: ReactNode;\n /** The trigger's accessible name (\"Assistant\", \"Send feedback\"). */\n fabLabel: string;\n fabIcon: ReactNode;\n /** Controlled open state. Leave it out to let the panel own it. */\n open?: boolean;\n /** Uncontrolled initial state. Default closed. */\n defaultOpen?: boolean;\n /** Every request to open or close — the FAB, the X, Escape. */\n onOpenChange?: (open: boolean) => void;\n /** Default `bottom-end`. */\n corner?: FloatingCorner;\n /** The FAB's distance above the nav — see {@link FloatingActionButtonProps.offset}.\n * From `md` up the card sits above the FAB, so it moves with it. */\n offset?: string;\n /** The FAB's native `title` — see {@link FloatingActionButtonProps.nativeTitle}.\n * `false` for keksdose's assistant launcher, under its one-tooltip rule (dev#523). */\n fabNativeTitle?: boolean;\n /** The FAB's kit tooltip — see {@link FloatingActionButtonProps.tooltip}. `true` for\n * keksdose's assistant launcher, which wanted the kit bubble and had no way to\n * put one on a fixed, portalled button (dev#523). */\n fabTooltip?: ReactNode;\n /** Default: `floatingPanel.close` from the {@link UiKitProvider}, else \"Close\". */\n closeLabel?: string;\n /**\n * Where focus goes on open. Default the panel itself, so a phone's keyboard does not\n * rise over the sheet before the user has asked to type; pass the chat field for a\n * panel whose one job is typing.\n */\n initialFocus?: () => HTMLElement | null;\n /** Extra classes for the panel. */\n className?: string;\n /** Extra classes for the scrolling body. */\n bodyClassName?: string;\n /**\n * 0.48: take the FAB and the panel off screen while keeping both mounted — the open\n * state and the bottom room (`--app-float-clearance`) stay, and an open panel is back\n * when this turns `false`. keksdose's assistant launcher steps aside while an editor\n * or a modal covers its corner (dev#612); unmounting it instead would give the room\n * back and shorten the page under the reader's thumb.\n */\n hidden?: boolean;\n /**\n * 0.49 KIT-11: the FAB steps aside while a dialog, a sheet, a popover, a hover menu or\n * a list is open elsewhere (see {@link FloatingActionButtonProps.hideWhenOverlay}).\n * Default `true`. An OPEN panel stays: what opens from inside it (a select, a date\n * picker) is its own content.\n */\n hideWhenOverlay?: boolean;\n children: ReactNode;\n}\n\n/**\n * A docked, NON-MODAL panel in a corner of the screen, opened by a\n * {@link FloatingActionButton} — keksdose's assistant launcher and its feedback page,\n * which each built one.\n *\n * ## Not a Modal\n *\n * The page stays usable while it is open: that is the point of an assistant beside the\n * data it is asked about. So there is no backdrop, no scroll lock, no `aria-modal` and\n * no focus trap — Tab walks out of the panel into the page and back, and a click on the\n * page does not close it. It is `role=\"dialog\"` without `aria-modal`, which is what\n * ARIA calls a non-modal dialog, named by its heading.\n *\n * What it keeps from a dialog is the focus HANDOFF: opening moves focus into it (a\n * keyboard user who pressed the FAB should not have to hunt for what appeared), and\n * closing gives it back to the FAB — but only when focus went down with the panel (the\n * kit's one rule, lib/focus-left `returnFocus`, 0.55), so a panel closed from the page (a\n * controlled owner) never pulls focus out of the field the user is typing in, nor off\n * what the app focused in `onOpenChange`. Escape closes it while focus is inside, and\n * only then: on the panel element, not the document, so Escape in the page keeps\n * meaning whatever the page means by it. A layer of the kit's Escape stack open over it\n * — a tooltip, a list — is handed the key first.\n *\n * ## Layout\n *\n * From `md` up, a card above the FAB in its corner. Below `md`, a full-width sheet\n * docked to the bottom, sitting ON the `AppShell` bottom nav (`--app-nav-h`) and\n * clear of the home indicator. Pure CSS — no width is measured in JavaScript.\n *\n * The FAB carries `aria-expanded` and, while the panel exists, `aria-controls`. The\n * panel unmounts when closed; an app that must keep a draft across a close keeps it in\n * its own state, where it also survives a reload.\n */\nexport function FloatingPanel({\n title,\n fabLabel,\n fabIcon,\n open: openProp,\n defaultOpen = false,\n onOpenChange,\n corner = \"bottom-end\",\n offset = \"1rem\",\n fabNativeTitle,\n fabTooltip,\n closeLabel,\n initialFocus,\n className,\n bodyClassName,\n hidden: hiddenProp = false,\n hideWhenOverlay = true,\n children,\n}: FloatingPanelProps) {\n const labels = useKitLabels(\n \"floatingPanel\",\n DEFAULT_FLOATING_PANEL_LABELS,\n closeLabel === undefined ? undefined : { close: closeLabel },\n );\n const [ownOpen, setOwnOpen] = useState(defaultOpen);\n const open = openProp ?? ownOpen;\n const overlay = useOverlayOpen(hideWhenOverlay && !open);\n const hidden = hiddenProp || overlay;\n const setOpen = (next: boolean) => {\n if (openProp === undefined) setOwnOpen(next);\n onOpenChange?.(next);\n };\n\n const uid = useId();\n const panelId = `${uid}-panel`;\n const titleId = `${uid}-title`;\n const fabRef = useRef<HTMLButtonElement>(null);\n const panelRef = useRef<HTMLDivElement>(null);\n const [marker, dir] = usePortalDir();\n\n const initialFocusRef = useRef(initialFocus);\n useEffect(() => {\n initialFocusRef.current = initialFocus;\n });\n\n // Focus follows the open/closed TRANSITION, never the mount: a panel that renders\n // open (`defaultOpen`, a restored route) does not take the page's focus by existing.\n const wasOpen = useRef(open);\n useEffect(() => {\n const changed = wasOpen.current !== open;\n wasOpen.current = open;\n if (!changed) return;\n if (open) {\n (initialFocusRef.current?.() ?? panelRef.current)?.focus();\n return;\n }\n // Back to the FAB by the kit's one rule (lib/focus-left `returnFocus`; 0.52 code\n // review 2026-10-31, held to 0.55), here in the closing commit, with the panel gone:\n // a focus that fell with it is moved, one the page or the app holds is not. It kept a\n // note of \"focus was inside when it last changed\" for this, which the panel's removal\n // already answers — and which put the FAB over a focus the app's `onOpenChange` had\n // placed.\n returnFocus({ opener: fabRef.current });\n }, [open]);\n\n const onPanelKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key !== \"Escape\") return;\n // The kit's one Escape stack first (round 0.34 Wave 2, DUP-1): a layer open above the\n // panel without focus — a tooltip, the numpad, a hover menu — is the topmost thing on\n // screen and is handed the key. This took it for itself and stopped it, so the\n // tooltip never heard it and one press closed the whole panel instead.\n if (takeEscape(e)) return;\n // Not a layer itself: it answers only while focus is inside it, and a layer would\n // also be handed an Escape pressed in the page. Kept by a child's claim (ChipInput\n // clearing its draft, `claimEscape`), and by an IME composition, as a layer is.\n if (e.defaultPrevented || isComposing(e)) return;\n // Stopped here, so an overlay underneath with a document-level Escape listener does\n // not close too — the innermost thing that has focus is the one that closes.\n e.stopPropagation();\n setOpen(false);\n };\n\n return (\n <>\n <span ref={marker} hidden />\n <FloatingActionButton\n ref={fabRef}\n label={fabLabel}\n icon={fabIcon}\n corner={corner}\n offset={offset}\n nativeTitle={fabNativeTitle}\n tooltip={fabTooltip}\n hidden={hidden || undefined}\n hideWhenOverlay={false}\n aria-expanded={open}\n aria-controls={open && !hidden ? panelId : undefined}\n onClick={() => {\n // A press ON the FAB closing the panel: the FAB keeps focus by being clicked,\n // nothing to hand back (the rule leaves a focus that is somewhere real).\n setOpen(!open);\n }}\n />\n {open &&\n !hidden &&\n typeof document !== \"undefined\" &&\n createPortal(\n // A container for Escape bubbling up from anything inside it, as a dialog is.\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n <div\n ref={panelRef}\n id={panelId}\n role=\"dialog\"\n aria-labelledby={titleId}\n tabIndex={-1}\n dir={dir}\n data-corner={corner}\n onKeyDown={onPanelKeyDown}\n // The phone's sheet sits on the nav; from `md` up the card floats above the\n // FAB (its offset + the 3rem button + a 0.75rem gap).\n style={\n {\n \"--fp-bottom\": `calc(${BOTTOM_CLEARANCE})`,\n \"--fp-bottom-md\": `calc(${BOTTOM_CLEARANCE} + ${offset} + 3.75rem)`,\n \"--fp-side\": SIDE_GUTTER,\n } as CSSProperties\n }\n className={cn(\n \"animate-sheet fixed z-40 flex flex-col overflow-hidden outline-none\",\n \"border border-subtle bg-surface shadow-2xl\",\n // Phone: full width, docked, rounded only where it meets the page.\n \"inset-x-0 bottom-[var(--fp-bottom)] max-h-[min(85dvh,calc(100dvh_-_var(--fp-bottom)_-_1rem))] rounded-t-xl border-x-0 border-b-0\",\n \"pb-[env(safe-area-inset-bottom,0px)] md:pb-0\",\n // md+: a card in the corner, above the FAB.\n \"md:inset-x-auto md:bottom-[var(--fp-bottom-md)] md:w-[24rem] md:max-h-[min(36rem,calc(100dvh_-_var(--fp-bottom-md)_-_1rem))] md:rounded-xl md:border\",\n corner === \"bottom-start\" ? \"md:start-[var(--fp-side)]\" : \"md:end-[var(--fp-side)]\",\n className,\n )}\n >\n <div className=\"flex shrink-0 items-center justify-between gap-2 border-b border-subtle px-4 py-3\">\n <h2 id={titleId} className=\"min-w-0 truncate text-base font-semibold text-primary\">\n {title}\n </h2>\n <button\n type=\"button\"\n onClick={() => setOpen(false)}\n aria-label={labels.close}\n className={cn(\n \"-me-1.5 shrink-0 rounded p-1.5 text-muted outline-none hover:bg-hover hover:text-secondary\",\n FOCUS_RING,\n )}\n >\n <X aria-hidden className=\"size-5\" />\n </button>\n </div>\n <div className={cn(\"min-h-0 flex-1 overflow-y-auto overscroll-contain p-4\", bodyClassName)}>{children}</div>\n </div>,\n document.body,\n )}\n </>\n );\n}\n\nexport interface FloatingActionGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, \"role\"> {\n /** Name the group (\"Register actions\"); a reader then introduces the members as one\n * set. A group with no name is one more unlabelled wrapper. */\n \"aria-label\"?: string;\n /** Default `bottom-end`. */\n corner?: FloatingCorner;\n /** Distance above the nav / home indicator — see {@link FloatingActionButtonProps.offset}. */\n offset?: string;\n /** {@link FloatingAction}s, in reading order. */\n children: ReactNode;\n /** 0.49 KIT-11: step aside while a dialog, a sheet, a popover, a hover menu or a list\n * is open — see {@link FloatingActionButtonProps.hideWhenOverlay}. Default `true`. */\n hideWhenOverlay?: boolean;\n ref?: Ref<HTMLDivElement>;\n}\n\n/**\n * A floating pill of {@link FloatingAction}s — one to four `size-12` toggles, actions\n * or links with hairline dividers — pinned to a corner exactly as the\n * {@link FloatingActionButton} is: `--app-nav-h` and the home indicator cleared, a\n * logical corner, portalled.\n *\n * keksdose built it twice by hand: the /transactions phone toolbar\n * (transactions-page.tsx:644 — upcoming and pending toggles, an invoices link with a\n * pending count, and the brand \"+\") and the feedback page's \"awaiting only\" toggle\n * (feedback-page.tsx:854, one button in the group anyway, so the two pages look\n * alike). Both at `right-4 bottom-20` — `right`, not `end`, so over the page's leading\n * edge in RTL — with `divide-x`, slate hovers, and each member's `Tooltip` spelled out.\n *\n * `role=\"group\"`, NOT a toolbar: every member keeps its own tab stop, as the\n * hand-built ones did, and there is no arrow-key roving to learn. Hide it where it\n * does not belong with a class (`md:hidden`, as both keksdose pages do).\n */\nexport function FloatingActionGroup({\n corner = \"bottom-end\",\n offset = \"1rem\",\n hideWhenOverlay = true,\n hidden,\n className,\n style,\n children,\n ...rest\n}: FloatingActionGroupProps) {\n const [marker, dir] = usePortalDir();\n const overlay = useOverlayOpen(hideWhenOverlay);\n // As the FAB's (keksdose dev#612): its 3rem members plus the frame's 1px borders.\n useFloatClearance(offset, \"2px\");\n return (\n <>\n <span ref={marker} hidden />\n {typeof document !== \"undefined\" &&\n createPortal(\n <div\n {...rest}\n hidden={hidden || overlay || undefined}\n role=\"group\"\n dir={dir}\n data-corner={corner}\n style={{ ...cornerStyle(corner, offset), ...style }}\n className={cn(\n \"fixed z-40 inline-flex flex-row overflow-hidden rounded-2xl\",\n \"border border-subtle bg-surface shadow-lg\",\n // Dividers on the logical start edge of every member but the first, so\n // they sit between the same two members in RTL. The member is the\n // tooltip's wrapper span, which is the direct child.\n \"[&>*:not(:first-child)]:border-s [&>*]:border-subtle\",\n className,\n )}\n >\n {children}\n </div>,\n document.body,\n )}\n </>\n );\n}\n\n/** What {@link FloatingActionProps.renderLink} is handed. Spread it onto your router's\n * link, mapping `href` — `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`. */\nexport interface FloatingActionLinkProps {\n href: string;\n /** The member's whole look — keep it. */\n className: string;\n children: ReactNode;\n \"aria-label\": string;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\nexport interface FloatingActionProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"children\" | \"onClick\"> {\n /** The accessible name, and the tooltip's text. Keep it the same in both states of a\n * toggle; `aria-pressed` says which one it is in. */\n label: string;\n icon: ReactNode;\n /** Make it a toggle: `aria-pressed` and the brand \"on\" look (the brand glyph on the\n * quiet brand fill), as {@link FloatingActionButtonProps.pressed}. Ignored on a link.\n * For the state without the tint, see {@link pressedStyle}. */\n pressed?: boolean;\n /**\n * How `pressed` looks. `tint` (default): the brand \"on\" look. `plain`: the member\n * keeps its quiet look in both states and only `aria-pressed` changes — for a toggle\n * whose icon already says which state it is in (keksdose's /transactions \"Upcoming\"\n * swaps two icons; a tint on top said it twice). The same as passing `aria-pressed`\n * without `pressed`, which also rides through untinted.\n */\n pressedStyle?: \"tint\" | \"plain\";\n /**\n * `accent`: the brand-filled member, for the group's main action (the /transactions\n * \"+\"), as Button's `accent`. `default` (default): the quiet one.\n */\n variant?: \"default\" | \"accent\";\n /**\n * A count in a dot at the member's top end corner — the /transactions invoices link's\n * pending count. Nothing is drawn for `0` or less; above 99 it reads `99+`. The count\n * joins the accessible name through `floatingPanel.badge` (\"Invoices, 3 new\"), or\n * `badgeLabel` when passed.\n */\n badge?: number;\n /** The badge's words for a reader, replacing `floatingPanel.badge` — \"3 to review\". */\n badgeLabel?: string;\n /** The dot's colour. Default `warning`, the pending count's. */\n badgeTone?: \"warning\" | \"danger\" | \"brand\";\n /** Show `label` in the kit {@link Tooltip} on hover and focus. Default `true`: the\n * members are icon-only and this is their only visible label. */\n tooltip?: boolean;\n /** Where the tooltip opens. Default `top` — the group sits at the bottom of the screen. */\n tooltipSide?: TooltipSide;\n /** Make it a link — the /transactions invoices member. Drawn by `renderLink`, else\n * the `<UiKitProvider linkComponent>` (an in-app `href`), else a plain `<a>`. */\n href?: string;\n /** Render the link through your router's `Link` — see {@link FloatingActionLinkProps}. */\n renderLink?: (props: FloatingActionLinkProps) => ReactElement;\n onClick?: (event: MouseEvent<HTMLElement>) => void;\n ref?: Ref<HTMLButtonElement>;\n}\n\n// Each fill under its own foreground (0.33): warning borrowed `--text-inverse`, the\n// surface colour, which was 4.24:1 on the light warning fill.\nconst BADGE_TONE: Record<NonNullable<FloatingActionProps[\"badgeTone\"]>, string> = {\n warning: \"bg-warning text-warning-contrast\",\n danger: \"bg-danger text-danger-contrast\",\n brand: \"bg-brand text-brand-contrast\",\n};\n\n/**\n * One member of a {@link FloatingActionGroup}: a `size-12` icon button, toggle or link\n * with its label in a styled tooltip — what each keksdose member wrapped by hand in\n * `<Tooltip portal>`. The tooltip is portalled because the group clips (`overflow-hidden`\n * keeps the brand member's fill inside the pill's corners), and the focus ring is inset\n * for the same reason.\n */\nexport function FloatingAction({\n label,\n icon,\n pressed,\n pressedStyle = \"tint\",\n variant = \"default\",\n badge,\n badgeLabel,\n badgeTone = \"warning\",\n tooltip = true,\n tooltipSide = \"top\",\n href,\n renderLink,\n className,\n type = \"button\",\n onClick,\n ref,\n ...rest\n}: FloatingActionProps) {\n const labels = useKitLabels(\"floatingPanel\", DEFAULT_FLOATING_PANEL_LABELS);\n const kitLink = useKitLink();\n const counted = badge !== undefined && badge > 0;\n const name = counted ? `${label}, ${badgeLabel ?? labels.badge(badge)}` : label;\n const isLink = href !== undefined;\n const look = cn(\n \"relative flex size-12 items-center justify-center transition-colors\",\n \"outline-none focus-visible:ring-inset\",\n FOCUS_RING,\n \"disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-5\",\n variant === \"accent\"\n ? \"bg-brand text-brand-contrast hover:bg-brand-hover\"\n : pressed && !isLink && pressedStyle === \"tint\"\n ? \"bg-brand-soft text-brand hover:bg-brand-soft-hover hover:text-brand-muted\"\n : \"text-secondary hover:bg-hover hover:text-primary\",\n className,\n );\n const body = (\n <>\n {icon}\n {counted && (\n <span\n aria-hidden\n data-badge=\"\"\n className={cn(\n \"pointer-events-none absolute end-1 top-1 h-4 min-w-4 rounded-full px-1 text-center text-micro leading-4 font-semibold\",\n BADGE_TONE[badgeTone],\n )}\n >\n {badge > 99 ? \"99+\" : badge}\n </span>\n )}\n </>\n );\n\n let member: ReactElement;\n if (isLink) {\n // The button-only attributes stay behind; `aria-*`, `data-*` and the like ride along.\n const { disabled: _disabled, form: _form, value: _value, name: _name, ...anchorRest } = rest;\n const linkProps: FloatingActionLinkProps = {\n ...(anchorRest as AnchorHTMLAttributes<HTMLAnchorElement>),\n href,\n \"aria-label\": name,\n onClick,\n className: look,\n children: body,\n };\n const { children: content, ...anchor } = linkProps;\n const render = pickLinkRenderer(renderLink, kitLink, href);\n member = render ? <RenderedFloatingLink render={render} {...linkProps} /> : <a {...anchor}>{content}</a>;\n } else {\n member = (\n <button\n {...rest}\n ref={ref}\n type={type}\n aria-label={name}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n onClick={onClick}\n className={look}\n >\n {body}\n </button>\n );\n }\n if (!tooltip) return member;\n return (\n // A fragment: the bubble says the member's name, and a description that repeats\n // the name is read twice.\n <Tooltip label={label} side={tooltipSide} portal>\n <>{member}</>\n </Tooltip>\n );\n}\n\n/** Calls `renderLink` as a component, so its hooks belong to the link, not to us. */\nfunction RenderedFloatingLink({\n render,\n ...props\n}: FloatingActionLinkProps & { render: (props: FloatingActionLinkProps) => ReactElement }) {\n return render(props);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -75,9 +75,10 @@ export type FormActionsStickyWithin = "viewport" | "container";
|
|
|
75
75
|
/**
|
|
76
76
|
* Extra attributes for the save button — an `id`, `data-*` hooks for tests or
|
|
77
77
|
* analytics, an `aria-describedby`. The props FormActions drives itself (type, click,
|
|
78
|
-
* disabled, variant, form, children, the busy state) are not among them
|
|
78
|
+
* disabled, variant, form, children, the busy state) are not among them; nor is
|
|
79
|
+
* `refused` (0.55), which would refuse the submit behind `submitDisabledReason`'s back.
|
|
79
80
|
*/
|
|
80
|
-
export type FormActionsSubmitProps = Omit<ButtonProps, "type" | "onClick" | "disabled" | "disabledReason" | "variant" | "form" | "children" | "aria-busy" | "commit"> & {
|
|
81
|
+
export type FormActionsSubmitProps = Omit<ButtonProps, "type" | "onClick" | "disabled" | "disabledReason" | "refused" | "variant" | "form" | "children" | "aria-busy" | "commit"> & {
|
|
81
82
|
[key: `data-${string}`]: string | number | boolean | undefined;
|
|
82
83
|
};
|
|
83
84
|
/** The start-side action of {@link FormActionsProps.destructive}, as data. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/form-actions.tsx"],"sourcesContent":["import { isValidElement, useEffect, useMemo, useRef } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode, Ref } from \"react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { useBreakpoint, usePhoneLayout } from \"../hooks/use-breakpoint\";\nimport { useLargeText } from \"../hooks/use-large-text\";\nimport { isApplePlatform, matchesHotkey, type ParsedHotkey } from \"../hooks/use-hotkey\";\nimport { isComposing } from \"../lib/ime\";\nimport { hasContent } from \"../lib/presence\";\nimport { Button, type ButtonProps } from \"./button\";\nimport { Spinner } from \"./spinner\";\nimport type { ButtonSize, ButtonVariant } from \"./button-look\";\nimport { mergeRefs } from \"./field-parts\";\nimport { useWriteLock } from \"./write-lock\";\nimport type { CommitScope } from \"./write-lock\";\nimport { DEFAULT_FORM_ACTIONS_LABELS } from \"./form-actions-labels\";\n\n/** The words {@link FormActions} renders on its own behalf — the `form` namespace. */\nexport interface FormActionsLabels {\n save: string;\n cancel: string;\n /**\n * The {@link FormActionsProps.submitShortcut} hint beside Save's text, when\n * {@link FormActionsProps.submitShortcutHint} shows it. `apple` is true on a Mac,\n * iPhone or iPad, where the key is Cmd: \"⌘ Enter\" there, \"Ctrl+Enter\" elsewhere\n * (a German keyboard says \"Strg\").\n */\n submitShortcut: (apple: boolean) => string;\n}\n\n// Defined in ./form-actions-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_FORM_ACTIONS_LABELS };\n\n/** See {@link FormActionsProps.submitShortcut}. */\nexport type FormActionsSubmitShortcut = \"mod-enter\";\n\nexport type FormActionsAlign = \"start\" | \"center\" | \"end\" | \"between\";\n\n/**\n * Where the row sits:\n * - `inline` (default): under the form's last field, `pt-4` above it.\n * - `sticky`: stuck to the bottom of the scrolling page or pane, on the surface\n * colour with a rule above, clear of the phone's home indicator — for a long form\n * whose Save should not be a scroll away.\n * - `dialog`: no spacing of its own, for a dialog's footer slot (DialogFrame,\n * FullBleedDialog's `footer`), which already pads and rules it.\n */\nexport type FormActionsPlacement = \"inline\" | \"sticky\" | \"dialog\";\n\n/**\n * When the row's buttons STACK, one per line at the row's full width (0.32,\n * docs/text-size-harmonization.md §4 \"FormActions stack full width on a phone layout\"):\n *\n * - `\"auto\"` (default): on the phone layout (`usePhoneLayout()`, `max-md:` at the text\n * size in force) at Large and Extra large. There a phone is 312 or 260 px of text\n * wide, and Cancel and a \"Save changes\" side by side wrap into a ragged pair with\n * one 48 px target squeezed against the other. Normal keeps the row the apps tuned.\n * - `\"phone\"`: on the phone layout at every text size.\n * - `\"never\"`: always the row.\n *\n * Stacked, the order is the DOM's — the start group (the destructive action), the extra\n * actions, Cancel, then Save at the bottom, under the thumb — so the visual order and\n * the tab order stay one.\n */\nexport type FormActionsStack = \"auto\" | \"phone\" | \"never\";\n\n/**\n * A placement per breakpoint, mobile first — `{ base: \"sticky\", md: \"inline\" }` is a\n * Save row stuck to the bottom of a phone's long form and in the flow under the last\n * field from 768px up, where the form fits. The breakpoints are the classes' (sm 640,\n * md 768, lg 1024, xl 1280px at Normal text size, and 1.25 / 1.5 times that at Large /\n * Extra large, as `md:` is since 0.32), as `BulkActionBar`'s `variant` breakpoints are, and\n * resolved the same way, in JS: the sticky row is positioned by inline style, which a\n * `md:` class cannot reach. keksdose switched `placement` on its own media query at\n * every long form for want of this. The breakpoints are the VIEWPORT's, not the\n * container's: a form in a narrow pane on a wide screen resolves to `md`.\n */\nexport interface ResponsiveFormActionsPlacement {\n base: FormActionsPlacement;\n sm?: FormActionsPlacement;\n md?: FormActionsPlacement;\n lg?: FormActionsPlacement;\n xl?: FormActionsPlacement;\n}\n\n/** Widest first: the first that matches and names a value wins. Each is the class's own\n * breakpoint at the text size in force (`useBreakpoint`), so `md` here is `md:` there. */\nconst BREAKPOINTS = [\"xl\", \"lg\", \"md\", \"sm\"] as const;\n\n/** The placement in force: the widest breakpoint that matches AND names one, else\n * `base`. The queries are subscribed unconditionally (hooks cannot be skipped); a\n * plain string ignores them. Without `matchMedia` (SSR, tests) none match, so the row\n * renders its `base`, the phone's. The same resolution as BulkActionBar's variant. */\nfunction useResolvedPlacement(placement: FormActionsPlacement | ResponsiveFormActionsPlacement): FormActionsPlacement {\n const matches = {\n xl: useBreakpoint(\"xl\"),\n lg: useBreakpoint(\"lg\"),\n md: useBreakpoint(\"md\"),\n sm: useBreakpoint(\"sm\"),\n };\n if (typeof placement === \"string\") return placement;\n for (const key of BREAKPOINTS) {\n const p = placement[key];\n if (matches[key] && p !== undefined) return p;\n }\n return placement.base;\n}\n\n/** A CSS length: a number is pixels. */\nfunction cssLength(value: string | number): string {\n return typeof value === \"number\" ? `${value}px` : value;\n}\n\n/**\n * What a `sticky` row sticks to — see {@link FormActionsProps.stickyWithin}.\n * - `viewport` (default): the page's (or AppShell pane's) bottom edge, lifted onto\n * the phone's bottom nav by `--app-nav-h`.\n * - `container`: the bottom of whatever scroll container it is in — a dialog body, a\n * side pane, a panel with `overflow-y-auto` — at `bottom: 0`, with no nav offset.\n */\nexport type FormActionsStickyWithin = \"viewport\" | \"container\";\n\n/**\n * Extra attributes for the save button — an `id`, `data-*` hooks for tests or\n * analytics, an `aria-describedby`. The props FormActions drives itself (type, click,\n * disabled, variant, form, children, the busy state) are not among them.\n */\nexport type FormActionsSubmitProps = Omit<\n ButtonProps,\n \"type\" | \"onClick\" | \"disabled\" | \"disabledReason\" | \"variant\" | \"form\" | \"children\" | \"aria-busy\" | \"commit\"\n> & { [key: `data-${string}`]: string | number | boolean | undefined };\n\n/** The start-side action of {@link FormActionsProps.destructive}, as data. */\nexport interface FormActionsDestructive {\n label: ReactNode;\n onClick: () => void;\n disabled?: boolean;\n /** See Button's `disabledReason`. */\n disabledReason?: ReactNode;\n}\n\nconst ALIGN_CLASS: Record<FormActionsAlign, string> = {\n start: \"justify-start\",\n center: \"justify-center\",\n end: \"justify-end\",\n between: \"justify-between\",\n};\n\nconst PLACEMENT_CLASS: Record<FormActionsPlacement, string> = {\n inline: \"pt-4\",\n sticky: \"sticky bottom-0 z-10 border-t border-subtle bg-surface pt-3\",\n dialog: \"\",\n};\n\nexport interface FormActionsProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n /** Called by Cancel. Omitted: no Cancel button (a form that only saves). */\n onCancel?: () => void;\n /**\n * Given: the save button is `type=\"button\"` and calls this — for a dialog or a row\n * that saves from a handler. Omitted: it is `type=\"submit\"` and submits the\n * enclosing `<form>` (or the one named by {@link form}).\n */\n onSubmit?: () => void;\n /** The save button's text. Default `form.save` from the provider, else \"Save\". */\n submitLabel?: ReactNode;\n /** The cancel button's text. Default `form.cancel` from the provider, else \"Cancel\". */\n cancelLabel?: ReactNode;\n /** The save button's text while `pending` (\"Saving…\"). Default: `submitLabel`. */\n pendingLabel?: ReactNode;\n /**\n * A Lucide icon before the save button's text. While `pending` the spinner takes\n * ITS place rather than being added in front of it — an icon written into\n * `submitLabel` itself stays, so the button would show the spinner AND the glyph,\n * two statuses side by side (keksdose's crop Apply needed a `pendingLabel` copy of\n * its text only to drop the check mark).\n */\n submitIcon?: LucideIcon;\n /** Attributes passed through to the save button: `id`, `data-*`, `aria-*`, a\n * `className`. See {@link FormActionsSubmitProps}. */\n submitProps?: FormActionsSubmitProps;\n /**\n * The save is running: a spinner in the save button, `aria-busy` on it, and the\n * button disabled, so a second click cannot send the form twice. Cancel stays\n * usable. The spinner goes in front of the text, or in place of {@link submitIcon}.\n */\n pending?: boolean;\n /** Disable save for a reason of the form's own (an unbalanced entry). */\n submitDisabled?: boolean;\n /** Why save is disabled — see Button's `disabledReason`. Keeps it focusable. */\n submitDisabledReason?: ReactNode;\n /**\n * Save is a commit: under a locked {@link WriteLockProvider} it is disabled with the\n * lock's reason (over `submitDisabledReason`), focusable, the reason in its tooltip —\n * Button's `commit`. The `{ label, onClick }` form of {@link destructive} is a\n * commit too and is locked with it; Cancel and `children` are not (closing a form\n * writes nothing). No provider, or an unlocked one: no effect. Default `false`.\n *\n * keksdose wrote `submitDisabled={lock.locked || …}` and\n * `submitDisabledReason={lock.locked ? lock.reason : undefined}` at each form; this is\n * that pair, read from the provider.\n *\n * 0.33: a {@link CommitScope} — `COMMIT_EXCEPT_BILLING` for a form a lapsed plan still\n * allows (the account's own settings, docs/billing-harmonization.md §12.36).\n */\n commit?: CommitScope;\n /** The save button's variant. Default `accent`; `danger` for a save that destroys\n * (kastlan's `destructive` flag on its own FormActions). */\n submitVariant?: ButtonVariant;\n /**\n * The Cancel button's variant. Default `secondary` (the outlined button, as before).\n * `ghost` for a row where Cancel should step back further than an outline — keksdose\n * G3: a quiet inline row (a confirm under a table row, a card's own small editor)\n * whose Cancel was a ghost button before it moved onto FormActions, which drew every\n * Cancel outlined.\n */\n cancelVariant?: ButtonVariant;\n /**\n * The size of every button the row draws itself — Save, Cancel and the\n * `{ label, onClick }` form of {@link destructive}: Button's `size`. Default `md`.\n * `sm` (12px text, `px-2 py-1`) for a dense row: a confirm inside a table's expansion\n * row, a toolbar card. keksdose G3: two such rows had small hand-built buttons and lost\n * them on adopting FormActions, which had no size. At `sm` the spinner and\n * {@link submitIcon} shrink with the text.\n *\n * Your own elements (`children`, an element passed as `destructive`, `start`) are\n * yours to size — pass them `size=\"sm\"` too. A `size` in {@link submitProps} still\n * wins for Save, as it always has.\n */\n size?: ButtonSize;\n /**\n * A destructive action at the START of the row, apart from Save and Cancel — the\n * \"Delete\" of an edit dialog. Pass `{ label, onClick }` for the kit's quiet danger\n * button, or an element of your own (a {@link DangerConfirm}, say). With it the row\n * is `between`-aligned unless `align` says otherwise.\n */\n destructive?: ReactNode | FormActionsDestructive;\n /**\n * Something neutral at the START of the row — a caption (\"Last saved 2 min ago\",\n * \"Drag the corners to crop\"), a \"View activity\" link. After {@link destructive}\n * when both are given; like it, makes the row `between`-aligned unless `align` says\n * otherwise. `children` are for further ACTIONS, beside Cancel.\n */\n start?: ReactNode;\n /** Horizontal alignment. Default `end` (`between` with a `destructive` action). */\n align?: FormActionsAlign;\n /** See {@link FormActionsPlacement}, or one per breakpoint — see\n * {@link ResponsiveFormActionsPlacement}. Default `inline`. */\n placement?: FormActionsPlacement | ResponsiveFormActionsPlacement;\n /**\n * While the row is `sticky`: pull it out over its container's inline padding by this\n * much (a CSS length, a number in px) and give the same back as padding — so the\n * rule above it and its surface run edge to edge, while the buttons stay on the\n * content's line. Without it a sticky row in a padded card or pane is a strip inset\n * from both sides, with the content scrolling past it in the gutters (keksdose wrote\n * `@max-md:-mx-3 @max-md:px-3` by hand). Pass the container's padding (`\"0.75rem\"`,\n * `12`, `\"var(--pane-px)\"`). Applied only while the resolved placement is sticky, so\n * `placement={{ base: \"sticky\", md: \"inline\" }}` bleeds on the phone alone. The margin\n * is `calc(-1 * X)`; jsdom folds that to `calc(-X)`, so match it loosely in tests.\n */\n bleed?: string | number;\n /**\n * With `placement=\"sticky\"`: what the row sticks to. Default `viewport`.\n *\n * CSS sticky always sticks to the NEAREST scroll container, so the default row\n * already holds inside a scrolling pane — but it offsets itself by `--app-nav-h`,\n * which AppShell publishes on the document: inside a pane or a portalled dialog\n * body on a phone it then floats a nav's height above that container's bottom,\n * with the content scrolling through the gap. `container` drops that offset.\n *\n * What neither value can fix, because it is the layout, not the row:\n * - sticky moves only within its PARENT box. Inside a DataTable expansion row the\n * parent is the expansion cell, so the row pins to the table's own scroller\n * (`overflow-auto`, bounded by `maxBodyHeight`) and only while the cell spans\n * that scroller's bottom edge — not to the page.\n * - any `overflow` other than `visible`/`clip` between the row and the scroller\n * you mean (an `overflow-x-auto` wrapper, an `overflow-hidden` card) becomes the\n * scroll container; if THAT box does not scroll, the row never sticks.\n * - the offset is resolved against the scroller's content box, so a scroller with\n * bottom padding leaves that strip uncovered; give the row a negative `bottom`\n * through `style` and the same amount back as padding.\n */\n stickyWithin?: FormActionsStickyWithin;\n /** The `id` of the `<form>` the save button submits, when the row is rendered\n * outside it (a dialog's footer slot). */\n form?: string;\n /**\n * A keyboard shortcut that presses Save from anywhere in the form. `\"mod-enter\"`:\n * Ctrl+Enter, or Cmd+Enter on a Mac — both are accepted everywhere, as keksdose's\n * editors always have. keksdose K14 (inputs audit 2026-10-02, S5): its transaction\n * editor, category editor and create card each wrote this as an `onKeyDown` on a\n * container, re-stating `!pending && !saveDisabled && !lock.locked` every time, and\n * the one that forgot the lock let the demo's refused save through from the keyboard.\n *\n * What it does is what pressing Save does — the same button is clicked, so a form\n * submits with its validation and its submitter, and `onSubmit` is called in the\n * `type=\"button\"` mode — and it does NOTHING while Save could not be pressed:\n * `pending`, `submitDisabled`, or a `commit` under a locked {@link WriteLockProvider}.\n *\n * Scoped to the form Save submits (the `form` prop's, else the enclosing `<form>`):\n * the chord counts only when the focus is inside it, so two editors on one page each\n * answer for their own fields. Without a form (an `onSubmit` row in a plain container)\n * the scope is the nearest `role=\"dialog\"`, else the row's parent element — the\n * container the row ends.\n *\n * Listened for on the document, so it runs AFTER every React handler: a field that\n * takes Ctrl+Enter for itself calls `preventDefault()` or `stopPropagation()` and\n * the row stays out of it (keksdose live #202 — the create card's Ctrl+Shift+Enter\n * must create ONE row; the chord here is exactly Ctrl/Cmd+Enter, never with Shift or\n * Alt). Not during IME composition, and not on a held key's repeats.\n *\n * The save button announces it with `aria-keyshortcuts`; {@link submitShortcutHint}\n * also shows it.\n */\n submitShortcut?: FormActionsSubmitShortcut;\n /** Show the {@link submitShortcut} beside Save's text (\"Ctrl+Enter\", \"⌘ Enter\"; the\n * `form.submitShortcut` label), dimmed. Hidden on a touch-only device, which has no\n * keyboard to press it on. Default `false`. */\n submitShortcutHint?: boolean;\n /** When the buttons stack full width — see {@link FormActionsStack}. Default `auto`:\n * on the phone layout at Large and Extra large. */\n stack?: FormActionsStack;\n /** Extra actions, placed before Cancel. */\n children?: ReactNode;\n /** The row, where the rest of the props go (0.34, API-2, refactor audit 2026-10) —\n * merged with its own ref, which `submitShortcut` finds its form from. It rode the\n * rest props before, after the row's own, and a caller's ref took the shortcut's. */\n ref?: Ref<HTMLDivElement>;\n}\n\n/**\n * Where {@link FormActionsProps.submitShortcut} listens: the form Save submits (named\n * by `formId`, else the enclosing one), else the nearest dialog, else the row's parent.\n */\nfunction shortcutScope(row: HTMLElement | null, formId: string | undefined): Element | null {\n if (!row) return null;\n const named = formId ? row.ownerDocument.getElementById(formId) : null;\n if (named instanceof HTMLFormElement) return named;\n return row.closest(\"form\") ?? row.closest('[role=\"dialog\"]') ?? row.parentElement;\n}\n\n/**\n * What {@link FormActionsProps.submitShortcut} answers, on every platform: Ctrl+Enter and\n * Cmd+Enter with nothing else held, read by `matchesHotkey` as every kit shortcut is\n * (refactor audit 2026-10-30, DUP-12). Literals, not `parseHotkey` calls: no module-scope\n * call for a bundler to keep.\n */\nconst SUBMIT_CHORDS: readonly ParsedHotkey[] = [\n { ctrl: true, meta: false, shift: false, alt: false, key: \"Enter\" },\n { ctrl: false, meta: true, shift: false, alt: false, key: \"Enter\" },\n];\n\nfunction isDestructiveData(value: unknown): value is FormActionsDestructive {\n return typeof value === \"object\" && value !== null && !isValidElement(value) && \"onClick\" in value;\n}\n\n/**\n * The Save / Cancel row every form, wizard-step form and dialog ends in — kastlan's\n * `FormActions`, with the labels from the provider, a pending state and a place for\n * the destructive action.\n *\n * ```tsx\n * <form onSubmit={form.handleSubmit(save)}>\n * …\n * <FormActions onCancel={close} pending={mutation.isPending} />\n * </form>\n * ```\n *\n * The spinner is the kit's {@link Spinner}, drawn inside the button with `label={null}`\n * so the button's name stays its text; `aria-busy` is what says it is working.\n */\nexport function FormActions({\n onCancel,\n onSubmit,\n submitLabel,\n cancelLabel,\n pendingLabel,\n submitIcon: SubmitIcon,\n submitProps,\n pending = false,\n submitDisabled = false,\n submitDisabledReason,\n commit = false,\n submitVariant = \"accent\",\n cancelVariant = \"secondary\",\n size = \"md\",\n destructive,\n start: startSlot,\n align,\n placement: placementProp = \"inline\",\n stickyWithin = \"viewport\",\n bleed,\n form,\n submitShortcut,\n submitShortcutHint = false,\n stack = \"auto\",\n children,\n className,\n style,\n ref,\n ...rest\n}: FormActionsProps) {\n const phone = usePhoneLayout();\n const large = useLargeText();\n const stacked = stack !== \"never\" && phone && (stack === \"phone\" || large);\n // Stacked, a button fills its line — also inside the reason line's wrapper, which the\n // column stretches but which keeps its control at the control's own width.\n const full = stacked ? \"w-full\" : undefined;\n const labels = useKitLabels(\"form\", DEFAULT_FORM_ACTIONS_LABELS);\n // The lock as Save sees it: with its scope, so a billing lock leaves a\n // `COMMIT_EXCEPT_BILLING` form's shortcut live too.\n const lock = useWriteLock(commit);\n const rootRef = useRef<HTMLDivElement>(null);\n const setRoot = useMemo(() => mergeRefs(rootRef, ref), [ref]);\n const saveRef = useRef<HTMLButtonElement | null>(null);\n // The caller's own `submitProps.ref` still gets the element.\n const callerSaveRef = submitProps?.ref;\n const setSaveRef = useMemo(() => mergeRefs(saveRef, callerSaveRef), [callerSaveRef]);\n // The save settled and the form is still here — it failed, and the user retries. The\n // button was natively disabled while it ran, and a focused button that turns disabled\n // drops the focus to <body> (DangerConfirm's 0.24 fix, keksdose). Only after a press\n // of THIS button (`pressed`: a click or the shortcut), so a `pending` that comes and\n // goes on its own never pulls the page's focus in.\n const pressed = useRef(false);\n const wasPending = useRef(pending);\n useEffect(() => {\n const settled = wasPending.current && !pending;\n wasPending.current = pending;\n if (!settled) return;\n const ours = pressed.current;\n pressed.current = false;\n if (!ours) return;\n if (document.activeElement === null || document.activeElement === document.body) saveRef.current?.focus();\n }, [pending]);\n // Read by the shortcut's listener at the moment of the key press, so the listener\n // itself is attached once and never sees a stale `pending`.\n const canSave = !pending && !submitDisabled && !lock.locked;\n const canSaveRef = useRef(canSave);\n useEffect(() => {\n canSaveRef.current = canSave;\n });\n useEffect(() => {\n if (submitShortcut !== \"mod-enter\") return;\n const onKeyDown = (e: KeyboardEvent) => {\n if (!SUBMIT_CHORDS.some((chord) => matchesHotkey(e, chord))) return;\n // `isComposing` is lib/ime's: Safari's composition-ending Enter (keyCode 229) too.\n if (e.repeat || isComposing(e) || e.defaultPrevented || !canSaveRef.current) return;\n const scope = shortcutScope(rootRef.current, form);\n if (!scope || !(e.target instanceof Node) || !scope.contains(e.target)) return;\n e.preventDefault();\n saveRef.current?.click();\n };\n document.addEventListener(\"keydown\", onKeyDown);\n return () => document.removeEventListener(\"keydown\", onKeyDown);\n }, [submitShortcut, form]);\n const apple = submitShortcut ? isApplePlatform() : false;\n const placement = useResolvedPlacement(placementProp);\n const bleedLength = bleed === undefined || bleed === \"\" ? undefined : cssLength(bleed);\n // `hasContent` (DUP-14): an empty string is no slot either, not an empty `me-auto` box.\n const hasDestructive = isDestructiveData(destructive) || hasContent(destructive);\n const hasStart = hasContent(startSlot);\n const justify = align ?? (hasDestructive || hasStart ? \"between\" : \"end\");\n const destructiveNode = !hasDestructive ? null : isDestructiveData(destructive) ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n tone=\"danger\"\n size={size}\n onClick={destructive.onClick}\n disabled={destructive.disabled}\n disabledReason={destructive.disabledReason}\n commit={commit}\n className={full}\n >\n {destructive.label}\n </Button>\n ) : (\n destructive\n );\n const saveLabel = submitLabel ?? labels.save;\n // The glyphs follow the text: 16px beside `md`'s 14px text, 14px beside `sm`'s 12px.\n const saveSize = submitProps?.size ?? size;\n const glyphClass = saveSize === \"sm\" ? \"size-3.5\" : \"size-4\";\n\n return (\n <div\n ref={setRoot}\n data-slot=\"form-actions\"\n data-placement={placement}\n data-sticky-within={placement === \"sticky\" ? stickyWithin : undefined}\n {...rest}\n style={\n placement === \"sticky\"\n ? {\n paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\",\n // On the phone's bottom nav, not behind it (keksdose live #361): `bottom-0`\n // pinned the row under AppShell's nav, which covers the page's last ~56px.\n // `--app-nav-h` is the nav's measured height (0px from md up, and outside an\n // AppShell); the 1px overlaps the two top borders into one line. In a\n // container the nav is not underneath: `--app-nav-h` is the document's,\n // and would hold the row that far above the container's own edge.\n bottom: stickyWithin === \"container\" ? 0 : \"max(0px, calc(var(--app-nav-h, 0px) - 1px))\",\n ...(bleedLength !== undefined && {\n marginInline: `calc(-1 * ${bleedLength})`,\n paddingInline: bleedLength,\n }),\n ...style,\n }\n : style\n }\n data-stacked={stacked || undefined}\n className={cn(\n stacked ? \"flex flex-col items-stretch gap-2\" : cn(\"flex flex-wrap items-center gap-2\", ALIGN_CLASS[justify]),\n PLACEMENT_CLASS[placement],\n className,\n )}\n >\n {(hasDestructive || hasStart) && (\n <div\n className={\n stacked ? \"flex min-w-0 flex-col items-stretch gap-2\" : \"me-auto flex min-w-0 flex-wrap items-center gap-2\"\n }\n >\n {destructiveNode}\n {hasStart && startSlot}\n </div>\n )}\n <div className={stacked ? \"flex flex-col items-stretch gap-2\" : \"flex flex-wrap items-center gap-2\"}>\n {children}\n {onCancel && (\n <Button type=\"button\" variant={cancelVariant} size={size} onClick={onCancel} className={full}>\n {cancelLabel ?? labels.cancel}\n </Button>\n )}\n <Button\n {...submitProps}\n ref={setSaveRef}\n aria-keyshortcuts={\n submitShortcut === \"mod-enter\" ? \"Control+Enter Meta+Enter\" : submitProps?.[\"aria-keyshortcuts\"]\n }\n type={onSubmit ? \"button\" : \"submit\"}\n form={form}\n onClick={() => {\n pressed.current = true;\n onSubmit?.();\n }}\n variant={submitVariant}\n size={saveSize}\n disabled={pending || submitDisabled}\n disabledReason={pending ? undefined : submitDisabledReason}\n // Not while pending: the save already left, and the lock's look over the\n // spinner would say it had not. A ternary, not `commit && !pending`, which\n // turned a `{ except }` scope into `true` (§12.36).\n commit={pending ? false : commit}\n aria-busy={pending || undefined}\n className={cn(submitProps?.className, full)}\n >\n {pending ? (\n <Spinner label={null} className={glyphClass} />\n ) : (\n SubmitIcon && <SubmitIcon aria-hidden className={cn(glyphClass, \"shrink-0\")} />\n )}\n {pending && pendingLabel !== undefined ? pendingLabel : saveLabel}\n {submitShortcut && submitShortcutHint && (\n // Hidden from the accessibility tree: `aria-keyshortcuts` already says it,\n // and in the name it would make the button \"Save Ctrl+Enter\".\n <kbd\n aria-hidden\n className=\"ms-1 font-sans text-[0.85em] font-normal whitespace-nowrap opacity-70 pointer-coarse:hidden\"\n >\n {labels.submitShortcut(apple)}\n </kbd>\n )}\n </Button>\n </div>\n </div>\n );\n}\n"],"mappings":";AA+cI,cAuDI,YAvDJ;AA/cJ,SAAS,gBAAgB,WAAW,SAAS,cAAc;AAG3D,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAC7B,SAAS,eAAe,sBAAsB;AAC9C,SAAS,oBAAoB;AAC7B,SAAS,iBAAiB,qBAAwC;AAClE,SAAS,mBAAmB;AAC5B,SAAS,kBAAkB;AAC3B,SAAS,cAAgC;AACzC,SAAS,eAAe;AAExB,SAAS,iBAAiB;AAC1B,SAAS,oBAAoB;AAE7B,SAAS,mCAAmC;AAwE5C,MAAM,cAAc,CAAC,MAAM,MAAM,MAAM,IAAI;AAM3C,SAAS,qBAAqB,WAAwF;AACpH,QAAM,UAAU;AAAA,IACd,IAAI,cAAc,IAAI;AAAA,IACtB,IAAI,cAAc,IAAI;AAAA,IACtB,IAAI,cAAc,IAAI;AAAA,IACtB,IAAI,cAAc,IAAI;AAAA,EACxB;AACA,MAAI,OAAO,cAAc,SAAU,QAAO;AAC1C,aAAW,OAAO,aAAa;AAC7B,UAAM,IAAI,UAAU,GAAG;AACvB,QAAI,QAAQ,GAAG,KAAK,MAAM,OAAW,QAAO;AAAA,EAC9C;AACA,SAAO,UAAU;AACnB;AAGA,SAAS,UAAU,OAAgC;AACjD,SAAO,OAAO,UAAU,WAAW,GAAG,KAAK,OAAO;AACpD;AA8BA,MAAM,cAAgD;AAAA,EACpD,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,KAAK;AAAA,EACL,SAAS;AACX;AAEA,MAAM,kBAAwD;AAAA,EAC5D,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AACV;AAqLA,SAAS,cAAc,KAAyB,QAA4C;AAC1F,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,QAAQ,SAAS,IAAI,cAAc,eAAe,MAAM,IAAI;AAClE,MAAI,iBAAiB,gBAAiB,QAAO;AAC7C,SAAO,IAAI,QAAQ,MAAM,KAAK,IAAI,QAAQ,iBAAiB,KAAK,IAAI;AACtE;AAQA,MAAM,gBAAyC;AAAA,EAC7C,EAAE,MAAM,MAAM,MAAM,OAAO,OAAO,OAAO,KAAK,OAAO,KAAK,QAAQ;AAAA,EAClE,EAAE,MAAM,OAAO,MAAM,MAAM,OAAO,OAAO,KAAK,OAAO,KAAK,QAAQ;AACpE;AAEA,SAAS,kBAAkB,OAAiD;AAC1E,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,eAAe,KAAK,KAAK,aAAa;AAC/F;AAiBO,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ;AAAA,EACA,UAAU;AAAA,EACV,iBAAiB;AAAA,EACjB;AAAA,EACA,SAAS;AAAA,EACT,gBAAgB;AAAA,EAChB,gBAAgB;AAAA,EAChB,OAAO;AAAA,EACP;AAAA,EACA,OAAO;AAAA,EACP;AAAA,EACA,WAAW,gBAAgB;AAAA,EAC3B,eAAe;AAAA,EACf;AAAA,EACA;AAAA,EACA;AAAA,EACA,qBAAqB;AAAA,EACrB,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAqB;AACnB,QAAM,QAAQ,eAAe;AAC7B,QAAM,QAAQ,aAAa;AAC3B,QAAM,UAAU,UAAU,WAAW,UAAU,UAAU,WAAW;AAGpE,QAAM,OAAO,UAAU,WAAW;AAClC,QAAM,SAAS,aAAa,QAAQ,2BAA2B;AAG/D,QAAM,OAAO,aAAa,MAAM;AAChC,QAAM,UAAU,OAAuB,IAAI;AAC3C,QAAM,UAAU,QAAQ,MAAM,UAAU,SAAS,GAAG,GAAG,CAAC,GAAG,CAAC;AAC5D,QAAM,UAAU,OAAiC,IAAI;AAErD,QAAM,gBAAgB,aAAa;AACnC,QAAM,aAAa,QAAQ,MAAM,UAAU,SAAS,aAAa,GAAG,CAAC,aAAa,CAAC;AAMnF,QAAM,UAAU,OAAO,KAAK;AAC5B,QAAM,aAAa,OAAO,OAAO;AACjC,YAAU,MAAM;AACd,UAAM,UAAU,WAAW,WAAW,CAAC;AACvC,eAAW,UAAU;AACrB,QAAI,CAAC,QAAS;AACd,UAAM,OAAO,QAAQ;AACrB,YAAQ,UAAU;AAClB,QAAI,CAAC,KAAM;AACX,QAAI,SAAS,kBAAkB,QAAQ,SAAS,kBAAkB,SAAS,KAAM,SAAQ,SAAS,MAAM;AAAA,EAC1G,GAAG,CAAC,OAAO,CAAC;AAGZ,QAAM,UAAU,CAAC,WAAW,CAAC,kBAAkB,CAAC,KAAK;AACrD,QAAM,aAAa,OAAO,OAAO;AACjC,YAAU,MAAM;AACd,eAAW,UAAU;AAAA,EACvB,CAAC;AACD,YAAU,MAAM;AACd,QAAI,mBAAmB,YAAa;AACpC,UAAM,YAAY,CAAC,MAAqB;AACtC,UAAI,CAAC,cAAc,KAAK,CAAC,UAAU,cAAc,GAAG,KAAK,CAAC,EAAG;AAE7D,UAAI,EAAE,UAAU,YAAY,CAAC,KAAK,EAAE,oBAAoB,CAAC,WAAW,QAAS;AAC7E,YAAM,QAAQ,cAAc,QAAQ,SAAS,IAAI;AACjD,UAAI,CAAC,SAAS,EAAE,EAAE,kBAAkB,SAAS,CAAC,MAAM,SAAS,EAAE,MAAM,EAAG;AACxE,QAAE,eAAe;AACjB,cAAQ,SAAS,MAAM;AAAA,IACzB;AACA,aAAS,iBAAiB,WAAW,SAAS;AAC9C,WAAO,MAAM,SAAS,oBAAoB,WAAW,SAAS;AAAA,EAChE,GAAG,CAAC,gBAAgB,IAAI,CAAC;AACzB,QAAM,QAAQ,iBAAiB,gBAAgB,IAAI;AACnD,QAAM,YAAY,qBAAqB,aAAa;AACpD,QAAM,cAAc,UAAU,UAAa,UAAU,KAAK,SAAY,UAAU,KAAK;AAErF,QAAM,iBAAiB,kBAAkB,WAAW,KAAK,WAAW,WAAW;AAC/E,QAAM,WAAW,WAAW,SAAS;AACrC,QAAM,UAAU,UAAU,kBAAkB,WAAW,YAAY;AACnE,QAAM,kBAAkB,CAAC,iBAAiB,OAAO,kBAAkB,WAAW,IAC5E;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,SAAQ;AAAA,MACR,MAAK;AAAA,MACL;AAAA,MACA,SAAS,YAAY;AAAA,MACrB,UAAU,YAAY;AAAA,MACtB,gBAAgB,YAAY;AAAA,MAC5B;AAAA,MACA,WAAW;AAAA,MAEV,sBAAY;AAAA;AAAA,EACf,IAEA;AAEF,QAAM,YAAY,eAAe,OAAO;AAExC,QAAM,WAAW,aAAa,QAAQ;AACtC,QAAM,aAAa,aAAa,OAAO,aAAa;AAEpD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,KAAK;AAAA,MACL,aAAU;AAAA,MACV,kBAAgB;AAAA,MAChB,sBAAoB,cAAc,WAAW,eAAe;AAAA,MAC3D,GAAG;AAAA,MACJ,OACE,cAAc,WACV;AAAA,QACE,eAAe;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAOf,QAAQ,iBAAiB,cAAc,IAAI;AAAA,QAC3C,GAAI,gBAAgB,UAAa;AAAA,UAC/B,cAAc,aAAa,WAAW;AAAA,UACtC,eAAe;AAAA,QACjB;AAAA,QACA,GAAG;AAAA,MACL,IACA;AAAA,MAEN,gBAAc,WAAW;AAAA,MACzB,WAAW;AAAA,QACT,UAAU,sCAAsC,GAAG,qCAAqC,YAAY,OAAO,CAAC;AAAA,QAC5G,gBAAgB,SAAS;AAAA,QACzB;AAAA,MACF;AAAA,MAEE;AAAA,2BAAkB,aAClB;AAAA,UAAC;AAAA;AAAA,YACC,WACE,UAAU,8CAA8C;AAAA,YAGzD;AAAA;AAAA,cACA,YAAY;AAAA;AAAA;AAAA,QACf;AAAA,QAEF,qBAAC,SAAI,WAAW,UAAU,sCAAsC,qCAC7D;AAAA;AAAA,UACA,YACC,oBAAC,UAAO,MAAK,UAAS,SAAS,eAAe,MAAY,SAAS,UAAU,WAAW,MACrF,yBAAe,OAAO,QACzB;AAAA,UAEF;AAAA,YAAC;AAAA;AAAA,cACE,GAAG;AAAA,cACJ,KAAK;AAAA,cACL,qBACE,mBAAmB,cAAc,6BAA6B,cAAc,mBAAmB;AAAA,cAEjG,MAAM,WAAW,WAAW;AAAA,cAC5B;AAAA,cACA,SAAS,MAAM;AACb,wBAAQ,UAAU;AAClB,2BAAW;AAAA,cACb;AAAA,cACA,SAAS;AAAA,cACT,MAAM;AAAA,cACN,UAAU,WAAW;AAAA,cACrB,gBAAgB,UAAU,SAAY;AAAA,cAItC,QAAQ,UAAU,QAAQ;AAAA,cAC1B,aAAW,WAAW;AAAA,cACtB,WAAW,GAAG,aAAa,WAAW,IAAI;AAAA,cAEzC;AAAA,0BACC,oBAAC,WAAQ,OAAO,MAAM,WAAW,YAAY,IAE7C,cAAc,oBAAC,cAAW,eAAW,MAAC,WAAW,GAAG,YAAY,UAAU,GAAG;AAAA,gBAE9E,WAAW,iBAAiB,SAAY,eAAe;AAAA,gBACvD,kBAAkB;AAAA;AAAA,gBAGjB;AAAA,kBAAC;AAAA;AAAA,oBACC,eAAW;AAAA,oBACX,WAAU;AAAA,oBAET,iBAAO,eAAe,KAAK;AAAA;AAAA,gBAC9B;AAAA;AAAA;AAAA,UAEJ;AAAA,WACF;AAAA;AAAA;AAAA,EACF;AAEJ;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/form-actions.tsx"],"sourcesContent":["import { isValidElement, useEffect, useMemo, useRef } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode, Ref } from \"react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { useBreakpoint, usePhoneLayout } from \"../hooks/use-breakpoint\";\nimport { useLargeText } from \"../hooks/use-large-text\";\nimport { isApplePlatform, matchesHotkey, type ParsedHotkey } from \"../hooks/use-hotkey\";\nimport { isComposing } from \"../lib/ime\";\nimport { hasContent } from \"../lib/presence\";\nimport { Button, type ButtonProps } from \"./button\";\nimport { Spinner } from \"./spinner\";\nimport type { ButtonSize, ButtonVariant } from \"./button-look\";\nimport { mergeRefs } from \"./field-parts\";\nimport { useWriteLock } from \"./write-lock\";\nimport type { CommitScope } from \"./write-lock\";\nimport { DEFAULT_FORM_ACTIONS_LABELS } from \"./form-actions-labels\";\n\n/** The words {@link FormActions} renders on its own behalf — the `form` namespace. */\nexport interface FormActionsLabels {\n save: string;\n cancel: string;\n /**\n * The {@link FormActionsProps.submitShortcut} hint beside Save's text, when\n * {@link FormActionsProps.submitShortcutHint} shows it. `apple` is true on a Mac,\n * iPhone or iPad, where the key is Cmd: \"⌘ Enter\" there, \"Ctrl+Enter\" elsewhere\n * (a German keyboard says \"Strg\").\n */\n submitShortcut: (apple: boolean) => string;\n}\n\n// Defined in ./form-actions-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_FORM_ACTIONS_LABELS };\n\n/** See {@link FormActionsProps.submitShortcut}. */\nexport type FormActionsSubmitShortcut = \"mod-enter\";\n\nexport type FormActionsAlign = \"start\" | \"center\" | \"end\" | \"between\";\n\n/**\n * Where the row sits:\n * - `inline` (default): under the form's last field, `pt-4` above it.\n * - `sticky`: stuck to the bottom of the scrolling page or pane, on the surface\n * colour with a rule above, clear of the phone's home indicator — for a long form\n * whose Save should not be a scroll away.\n * - `dialog`: no spacing of its own, for a dialog's footer slot (DialogFrame,\n * FullBleedDialog's `footer`), which already pads and rules it.\n */\nexport type FormActionsPlacement = \"inline\" | \"sticky\" | \"dialog\";\n\n/**\n * When the row's buttons STACK, one per line at the row's full width (0.32,\n * docs/text-size-harmonization.md §4 \"FormActions stack full width on a phone layout\"):\n *\n * - `\"auto\"` (default): on the phone layout (`usePhoneLayout()`, `max-md:` at the text\n * size in force) at Large and Extra large. There a phone is 312 or 260 px of text\n * wide, and Cancel and a \"Save changes\" side by side wrap into a ragged pair with\n * one 48 px target squeezed against the other. Normal keeps the row the apps tuned.\n * - `\"phone\"`: on the phone layout at every text size.\n * - `\"never\"`: always the row.\n *\n * Stacked, the order is the DOM's — the start group (the destructive action), the extra\n * actions, Cancel, then Save at the bottom, under the thumb — so the visual order and\n * the tab order stay one.\n */\nexport type FormActionsStack = \"auto\" | \"phone\" | \"never\";\n\n/**\n * A placement per breakpoint, mobile first — `{ base: \"sticky\", md: \"inline\" }` is a\n * Save row stuck to the bottom of a phone's long form and in the flow under the last\n * field from 768px up, where the form fits. The breakpoints are the classes' (sm 640,\n * md 768, lg 1024, xl 1280px at Normal text size, and 1.25 / 1.5 times that at Large /\n * Extra large, as `md:` is since 0.32), as `BulkActionBar`'s `variant` breakpoints are, and\n * resolved the same way, in JS: the sticky row is positioned by inline style, which a\n * `md:` class cannot reach. keksdose switched `placement` on its own media query at\n * every long form for want of this. The breakpoints are the VIEWPORT's, not the\n * container's: a form in a narrow pane on a wide screen resolves to `md`.\n */\nexport interface ResponsiveFormActionsPlacement {\n base: FormActionsPlacement;\n sm?: FormActionsPlacement;\n md?: FormActionsPlacement;\n lg?: FormActionsPlacement;\n xl?: FormActionsPlacement;\n}\n\n/** Widest first: the first that matches and names a value wins. Each is the class's own\n * breakpoint at the text size in force (`useBreakpoint`), so `md` here is `md:` there. */\nconst BREAKPOINTS = [\"xl\", \"lg\", \"md\", \"sm\"] as const;\n\n/** The placement in force: the widest breakpoint that matches AND names one, else\n * `base`. The queries are subscribed unconditionally (hooks cannot be skipped); a\n * plain string ignores them. Without `matchMedia` (SSR, tests) none match, so the row\n * renders its `base`, the phone's. The same resolution as BulkActionBar's variant. */\nfunction useResolvedPlacement(placement: FormActionsPlacement | ResponsiveFormActionsPlacement): FormActionsPlacement {\n const matches = {\n xl: useBreakpoint(\"xl\"),\n lg: useBreakpoint(\"lg\"),\n md: useBreakpoint(\"md\"),\n sm: useBreakpoint(\"sm\"),\n };\n if (typeof placement === \"string\") return placement;\n for (const key of BREAKPOINTS) {\n const p = placement[key];\n if (matches[key] && p !== undefined) return p;\n }\n return placement.base;\n}\n\n/** A CSS length: a number is pixels. */\nfunction cssLength(value: string | number): string {\n return typeof value === \"number\" ? `${value}px` : value;\n}\n\n/**\n * What a `sticky` row sticks to — see {@link FormActionsProps.stickyWithin}.\n * - `viewport` (default): the page's (or AppShell pane's) bottom edge, lifted onto\n * the phone's bottom nav by `--app-nav-h`.\n * - `container`: the bottom of whatever scroll container it is in — a dialog body, a\n * side pane, a panel with `overflow-y-auto` — at `bottom: 0`, with no nav offset.\n */\nexport type FormActionsStickyWithin = \"viewport\" | \"container\";\n\n/**\n * Extra attributes for the save button — an `id`, `data-*` hooks for tests or\n * analytics, an `aria-describedby`. The props FormActions drives itself (type, click,\n * disabled, variant, form, children, the busy state) are not among them; nor is\n * `refused` (0.55), which would refuse the submit behind `submitDisabledReason`'s back.\n */\nexport type FormActionsSubmitProps = Omit<\n ButtonProps,\n | \"type\"\n | \"onClick\"\n | \"disabled\"\n | \"disabledReason\"\n | \"refused\"\n | \"variant\"\n | \"form\"\n | \"children\"\n | \"aria-busy\"\n | \"commit\"\n> & { [key: `data-${string}`]: string | number | boolean | undefined };\n\n/** The start-side action of {@link FormActionsProps.destructive}, as data. */\nexport interface FormActionsDestructive {\n label: ReactNode;\n onClick: () => void;\n disabled?: boolean;\n /** See Button's `disabledReason`. */\n disabledReason?: ReactNode;\n}\n\nconst ALIGN_CLASS: Record<FormActionsAlign, string> = {\n start: \"justify-start\",\n center: \"justify-center\",\n end: \"justify-end\",\n between: \"justify-between\",\n};\n\nconst PLACEMENT_CLASS: Record<FormActionsPlacement, string> = {\n inline: \"pt-4\",\n sticky: \"sticky bottom-0 z-10 border-t border-subtle bg-surface pt-3\",\n dialog: \"\",\n};\n\nexport interface FormActionsProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n /** Called by Cancel. Omitted: no Cancel button (a form that only saves). */\n onCancel?: () => void;\n /**\n * Given: the save button is `type=\"button\"` and calls this — for a dialog or a row\n * that saves from a handler. Omitted: it is `type=\"submit\"` and submits the\n * enclosing `<form>` (or the one named by {@link form}).\n */\n onSubmit?: () => void;\n /** The save button's text. Default `form.save` from the provider, else \"Save\". */\n submitLabel?: ReactNode;\n /** The cancel button's text. Default `form.cancel` from the provider, else \"Cancel\". */\n cancelLabel?: ReactNode;\n /** The save button's text while `pending` (\"Saving…\"). Default: `submitLabel`. */\n pendingLabel?: ReactNode;\n /**\n * A Lucide icon before the save button's text. While `pending` the spinner takes\n * ITS place rather than being added in front of it — an icon written into\n * `submitLabel` itself stays, so the button would show the spinner AND the glyph,\n * two statuses side by side (keksdose's crop Apply needed a `pendingLabel` copy of\n * its text only to drop the check mark).\n */\n submitIcon?: LucideIcon;\n /** Attributes passed through to the save button: `id`, `data-*`, `aria-*`, a\n * `className`. See {@link FormActionsSubmitProps}. */\n submitProps?: FormActionsSubmitProps;\n /**\n * The save is running: a spinner in the save button, `aria-busy` on it, and the\n * button disabled, so a second click cannot send the form twice. Cancel stays\n * usable. The spinner goes in front of the text, or in place of {@link submitIcon}.\n */\n pending?: boolean;\n /** Disable save for a reason of the form's own (an unbalanced entry). */\n submitDisabled?: boolean;\n /** Why save is disabled — see Button's `disabledReason`. Keeps it focusable. */\n submitDisabledReason?: ReactNode;\n /**\n * Save is a commit: under a locked {@link WriteLockProvider} it is disabled with the\n * lock's reason (over `submitDisabledReason`), focusable, the reason in its tooltip —\n * Button's `commit`. The `{ label, onClick }` form of {@link destructive} is a\n * commit too and is locked with it; Cancel and `children` are not (closing a form\n * writes nothing). No provider, or an unlocked one: no effect. Default `false`.\n *\n * keksdose wrote `submitDisabled={lock.locked || …}` and\n * `submitDisabledReason={lock.locked ? lock.reason : undefined}` at each form; this is\n * that pair, read from the provider.\n *\n * 0.33: a {@link CommitScope} — `COMMIT_EXCEPT_BILLING` for a form a lapsed plan still\n * allows (the account's own settings, docs/billing-harmonization.md §12.36).\n */\n commit?: CommitScope;\n /** The save button's variant. Default `accent`; `danger` for a save that destroys\n * (kastlan's `destructive` flag on its own FormActions). */\n submitVariant?: ButtonVariant;\n /**\n * The Cancel button's variant. Default `secondary` (the outlined button, as before).\n * `ghost` for a row where Cancel should step back further than an outline — keksdose\n * G3: a quiet inline row (a confirm under a table row, a card's own small editor)\n * whose Cancel was a ghost button before it moved onto FormActions, which drew every\n * Cancel outlined.\n */\n cancelVariant?: ButtonVariant;\n /**\n * The size of every button the row draws itself — Save, Cancel and the\n * `{ label, onClick }` form of {@link destructive}: Button's `size`. Default `md`.\n * `sm` (12px text, `px-2 py-1`) for a dense row: a confirm inside a table's expansion\n * row, a toolbar card. keksdose G3: two such rows had small hand-built buttons and lost\n * them on adopting FormActions, which had no size. At `sm` the spinner and\n * {@link submitIcon} shrink with the text.\n *\n * Your own elements (`children`, an element passed as `destructive`, `start`) are\n * yours to size — pass them `size=\"sm\"` too. A `size` in {@link submitProps} still\n * wins for Save, as it always has.\n */\n size?: ButtonSize;\n /**\n * A destructive action at the START of the row, apart from Save and Cancel — the\n * \"Delete\" of an edit dialog. Pass `{ label, onClick }` for the kit's quiet danger\n * button, or an element of your own (a {@link DangerConfirm}, say). With it the row\n * is `between`-aligned unless `align` says otherwise.\n */\n destructive?: ReactNode | FormActionsDestructive;\n /**\n * Something neutral at the START of the row — a caption (\"Last saved 2 min ago\",\n * \"Drag the corners to crop\"), a \"View activity\" link. After {@link destructive}\n * when both are given; like it, makes the row `between`-aligned unless `align` says\n * otherwise. `children` are for further ACTIONS, beside Cancel.\n */\n start?: ReactNode;\n /** Horizontal alignment. Default `end` (`between` with a `destructive` action). */\n align?: FormActionsAlign;\n /** See {@link FormActionsPlacement}, or one per breakpoint — see\n * {@link ResponsiveFormActionsPlacement}. Default `inline`. */\n placement?: FormActionsPlacement | ResponsiveFormActionsPlacement;\n /**\n * While the row is `sticky`: pull it out over its container's inline padding by this\n * much (a CSS length, a number in px) and give the same back as padding — so the\n * rule above it and its surface run edge to edge, while the buttons stay on the\n * content's line. Without it a sticky row in a padded card or pane is a strip inset\n * from both sides, with the content scrolling past it in the gutters (keksdose wrote\n * `@max-md:-mx-3 @max-md:px-3` by hand). Pass the container's padding (`\"0.75rem\"`,\n * `12`, `\"var(--pane-px)\"`). Applied only while the resolved placement is sticky, so\n * `placement={{ base: \"sticky\", md: \"inline\" }}` bleeds on the phone alone. The margin\n * is `calc(-1 * X)`; jsdom folds that to `calc(-X)`, so match it loosely in tests.\n */\n bleed?: string | number;\n /**\n * With `placement=\"sticky\"`: what the row sticks to. Default `viewport`.\n *\n * CSS sticky always sticks to the NEAREST scroll container, so the default row\n * already holds inside a scrolling pane — but it offsets itself by `--app-nav-h`,\n * which AppShell publishes on the document: inside a pane or a portalled dialog\n * body on a phone it then floats a nav's height above that container's bottom,\n * with the content scrolling through the gap. `container` drops that offset.\n *\n * What neither value can fix, because it is the layout, not the row:\n * - sticky moves only within its PARENT box. Inside a DataTable expansion row the\n * parent is the expansion cell, so the row pins to the table's own scroller\n * (`overflow-auto`, bounded by `maxBodyHeight`) and only while the cell spans\n * that scroller's bottom edge — not to the page.\n * - any `overflow` other than `visible`/`clip` between the row and the scroller\n * you mean (an `overflow-x-auto` wrapper, an `overflow-hidden` card) becomes the\n * scroll container; if THAT box does not scroll, the row never sticks.\n * - the offset is resolved against the scroller's content box, so a scroller with\n * bottom padding leaves that strip uncovered; give the row a negative `bottom`\n * through `style` and the same amount back as padding.\n */\n stickyWithin?: FormActionsStickyWithin;\n /** The `id` of the `<form>` the save button submits, when the row is rendered\n * outside it (a dialog's footer slot). */\n form?: string;\n /**\n * A keyboard shortcut that presses Save from anywhere in the form. `\"mod-enter\"`:\n * Ctrl+Enter, or Cmd+Enter on a Mac — both are accepted everywhere, as keksdose's\n * editors always have. keksdose K14 (inputs audit 2026-10-02, S5): its transaction\n * editor, category editor and create card each wrote this as an `onKeyDown` on a\n * container, re-stating `!pending && !saveDisabled && !lock.locked` every time, and\n * the one that forgot the lock let the demo's refused save through from the keyboard.\n *\n * What it does is what pressing Save does — the same button is clicked, so a form\n * submits with its validation and its submitter, and `onSubmit` is called in the\n * `type=\"button\"` mode — and it does NOTHING while Save could not be pressed:\n * `pending`, `submitDisabled`, or a `commit` under a locked {@link WriteLockProvider}.\n *\n * Scoped to the form Save submits (the `form` prop's, else the enclosing `<form>`):\n * the chord counts only when the focus is inside it, so two editors on one page each\n * answer for their own fields. Without a form (an `onSubmit` row in a plain container)\n * the scope is the nearest `role=\"dialog\"`, else the row's parent element — the\n * container the row ends.\n *\n * Listened for on the document, so it runs AFTER every React handler: a field that\n * takes Ctrl+Enter for itself calls `preventDefault()` or `stopPropagation()` and\n * the row stays out of it (keksdose live #202 — the create card's Ctrl+Shift+Enter\n * must create ONE row; the chord here is exactly Ctrl/Cmd+Enter, never with Shift or\n * Alt). Not during IME composition, and not on a held key's repeats.\n *\n * The save button announces it with `aria-keyshortcuts`; {@link submitShortcutHint}\n * also shows it.\n */\n submitShortcut?: FormActionsSubmitShortcut;\n /** Show the {@link submitShortcut} beside Save's text (\"Ctrl+Enter\", \"⌘ Enter\"; the\n * `form.submitShortcut` label), dimmed. Hidden on a touch-only device, which has no\n * keyboard to press it on. Default `false`. */\n submitShortcutHint?: boolean;\n /** When the buttons stack full width — see {@link FormActionsStack}. Default `auto`:\n * on the phone layout at Large and Extra large. */\n stack?: FormActionsStack;\n /** Extra actions, placed before Cancel. */\n children?: ReactNode;\n /** The row, where the rest of the props go (0.34, API-2, refactor audit 2026-10) —\n * merged with its own ref, which `submitShortcut` finds its form from. It rode the\n * rest props before, after the row's own, and a caller's ref took the shortcut's. */\n ref?: Ref<HTMLDivElement>;\n}\n\n/**\n * Where {@link FormActionsProps.submitShortcut} listens: the form Save submits (named\n * by `formId`, else the enclosing one), else the nearest dialog, else the row's parent.\n */\nfunction shortcutScope(row: HTMLElement | null, formId: string | undefined): Element | null {\n if (!row) return null;\n const named = formId ? row.ownerDocument.getElementById(formId) : null;\n if (named instanceof HTMLFormElement) return named;\n return row.closest(\"form\") ?? row.closest('[role=\"dialog\"]') ?? row.parentElement;\n}\n\n/**\n * What {@link FormActionsProps.submitShortcut} answers, on every platform: Ctrl+Enter and\n * Cmd+Enter with nothing else held, read by `matchesHotkey` as every kit shortcut is\n * (refactor audit 2026-10-30, DUP-12). Literals, not `parseHotkey` calls: no module-scope\n * call for a bundler to keep.\n */\nconst SUBMIT_CHORDS: readonly ParsedHotkey[] = [\n { ctrl: true, meta: false, shift: false, alt: false, key: \"Enter\" },\n { ctrl: false, meta: true, shift: false, alt: false, key: \"Enter\" },\n];\n\nfunction isDestructiveData(value: unknown): value is FormActionsDestructive {\n return typeof value === \"object\" && value !== null && !isValidElement(value) && \"onClick\" in value;\n}\n\n/**\n * The Save / Cancel row every form, wizard-step form and dialog ends in — kastlan's\n * `FormActions`, with the labels from the provider, a pending state and a place for\n * the destructive action.\n *\n * ```tsx\n * <form onSubmit={form.handleSubmit(save)}>\n * …\n * <FormActions onCancel={close} pending={mutation.isPending} />\n * </form>\n * ```\n *\n * The spinner is the kit's {@link Spinner}, drawn inside the button with `label={null}`\n * so the button's name stays its text; `aria-busy` is what says it is working.\n */\nexport function FormActions({\n onCancel,\n onSubmit,\n submitLabel,\n cancelLabel,\n pendingLabel,\n submitIcon: SubmitIcon,\n submitProps,\n pending = false,\n submitDisabled = false,\n submitDisabledReason,\n commit = false,\n submitVariant = \"accent\",\n cancelVariant = \"secondary\",\n size = \"md\",\n destructive,\n start: startSlot,\n align,\n placement: placementProp = \"inline\",\n stickyWithin = \"viewport\",\n bleed,\n form,\n submitShortcut,\n submitShortcutHint = false,\n stack = \"auto\",\n children,\n className,\n style,\n ref,\n ...rest\n}: FormActionsProps) {\n const phone = usePhoneLayout();\n const large = useLargeText();\n const stacked = stack !== \"never\" && phone && (stack === \"phone\" || large);\n // Stacked, a button fills its line — also inside the reason line's wrapper, which the\n // column stretches but which keeps its control at the control's own width.\n const full = stacked ? \"w-full\" : undefined;\n const labels = useKitLabels(\"form\", DEFAULT_FORM_ACTIONS_LABELS);\n // The lock as Save sees it: with its scope, so a billing lock leaves a\n // `COMMIT_EXCEPT_BILLING` form's shortcut live too.\n const lock = useWriteLock(commit);\n const rootRef = useRef<HTMLDivElement>(null);\n const setRoot = useMemo(() => mergeRefs(rootRef, ref), [ref]);\n const saveRef = useRef<HTMLButtonElement | null>(null);\n // The caller's own `submitProps.ref` still gets the element.\n const callerSaveRef = submitProps?.ref;\n const setSaveRef = useMemo(() => mergeRefs(saveRef, callerSaveRef), [callerSaveRef]);\n // The save settled and the form is still here — it failed, and the user retries. The\n // button was natively disabled while it ran, and a focused button that turns disabled\n // drops the focus to <body> (DangerConfirm's 0.24 fix, keksdose). Only after a press\n // of THIS button (`pressed`: a click or the shortcut), so a `pending` that comes and\n // goes on its own never pulls the page's focus in.\n const pressed = useRef(false);\n const wasPending = useRef(pending);\n useEffect(() => {\n const settled = wasPending.current && !pending;\n wasPending.current = pending;\n if (!settled) return;\n const ours = pressed.current;\n pressed.current = false;\n if (!ours) return;\n if (document.activeElement === null || document.activeElement === document.body) saveRef.current?.focus();\n }, [pending]);\n // Read by the shortcut's listener at the moment of the key press, so the listener\n // itself is attached once and never sees a stale `pending`.\n const canSave = !pending && !submitDisabled && !lock.locked;\n const canSaveRef = useRef(canSave);\n useEffect(() => {\n canSaveRef.current = canSave;\n });\n useEffect(() => {\n if (submitShortcut !== \"mod-enter\") return;\n const onKeyDown = (e: KeyboardEvent) => {\n if (!SUBMIT_CHORDS.some((chord) => matchesHotkey(e, chord))) return;\n // `isComposing` is lib/ime's: Safari's composition-ending Enter (keyCode 229) too.\n if (e.repeat || isComposing(e) || e.defaultPrevented || !canSaveRef.current) return;\n const scope = shortcutScope(rootRef.current, form);\n if (!scope || !(e.target instanceof Node) || !scope.contains(e.target)) return;\n e.preventDefault();\n saveRef.current?.click();\n };\n document.addEventListener(\"keydown\", onKeyDown);\n return () => document.removeEventListener(\"keydown\", onKeyDown);\n }, [submitShortcut, form]);\n const apple = submitShortcut ? isApplePlatform() : false;\n const placement = useResolvedPlacement(placementProp);\n const bleedLength = bleed === undefined || bleed === \"\" ? undefined : cssLength(bleed);\n // `hasContent` (DUP-14): an empty string is no slot either, not an empty `me-auto` box.\n const hasDestructive = isDestructiveData(destructive) || hasContent(destructive);\n const hasStart = hasContent(startSlot);\n const justify = align ?? (hasDestructive || hasStart ? \"between\" : \"end\");\n const destructiveNode = !hasDestructive ? null : isDestructiveData(destructive) ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n tone=\"danger\"\n size={size}\n onClick={destructive.onClick}\n disabled={destructive.disabled}\n disabledReason={destructive.disabledReason}\n commit={commit}\n className={full}\n >\n {destructive.label}\n </Button>\n ) : (\n destructive\n );\n const saveLabel = submitLabel ?? labels.save;\n // The glyphs follow the text: 16px beside `md`'s 14px text, 14px beside `sm`'s 12px.\n const saveSize = submitProps?.size ?? size;\n const glyphClass = saveSize === \"sm\" ? \"size-3.5\" : \"size-4\";\n\n return (\n <div\n ref={setRoot}\n data-slot=\"form-actions\"\n data-placement={placement}\n data-sticky-within={placement === \"sticky\" ? stickyWithin : undefined}\n {...rest}\n style={\n placement === \"sticky\"\n ? {\n paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\",\n // On the phone's bottom nav, not behind it (keksdose live #361): `bottom-0`\n // pinned the row under AppShell's nav, which covers the page's last ~56px.\n // `--app-nav-h` is the nav's measured height (0px from md up, and outside an\n // AppShell); the 1px overlaps the two top borders into one line. In a\n // container the nav is not underneath: `--app-nav-h` is the document's,\n // and would hold the row that far above the container's own edge.\n bottom: stickyWithin === \"container\" ? 0 : \"max(0px, calc(var(--app-nav-h, 0px) - 1px))\",\n ...(bleedLength !== undefined && {\n marginInline: `calc(-1 * ${bleedLength})`,\n paddingInline: bleedLength,\n }),\n ...style,\n }\n : style\n }\n data-stacked={stacked || undefined}\n className={cn(\n stacked ? \"flex flex-col items-stretch gap-2\" : cn(\"flex flex-wrap items-center gap-2\", ALIGN_CLASS[justify]),\n PLACEMENT_CLASS[placement],\n className,\n )}\n >\n {(hasDestructive || hasStart) && (\n <div\n className={\n stacked ? \"flex min-w-0 flex-col items-stretch gap-2\" : \"me-auto flex min-w-0 flex-wrap items-center gap-2\"\n }\n >\n {destructiveNode}\n {hasStart && startSlot}\n </div>\n )}\n <div className={stacked ? \"flex flex-col items-stretch gap-2\" : \"flex flex-wrap items-center gap-2\"}>\n {children}\n {onCancel && (\n <Button type=\"button\" variant={cancelVariant} size={size} onClick={onCancel} className={full}>\n {cancelLabel ?? labels.cancel}\n </Button>\n )}\n <Button\n {...submitProps}\n ref={setSaveRef}\n aria-keyshortcuts={\n submitShortcut === \"mod-enter\" ? \"Control+Enter Meta+Enter\" : submitProps?.[\"aria-keyshortcuts\"]\n }\n type={onSubmit ? \"button\" : \"submit\"}\n form={form}\n onClick={() => {\n pressed.current = true;\n onSubmit?.();\n }}\n variant={submitVariant}\n size={saveSize}\n disabled={pending || submitDisabled}\n disabledReason={pending ? undefined : submitDisabledReason}\n // Not while pending: the save already left, and the lock's look over the\n // spinner would say it had not. A ternary, not `commit && !pending`, which\n // turned a `{ except }` scope into `true` (§12.36).\n commit={pending ? false : commit}\n aria-busy={pending || undefined}\n className={cn(submitProps?.className, full)}\n >\n {pending ? (\n <Spinner label={null} className={glyphClass} />\n ) : (\n SubmitIcon && <SubmitIcon aria-hidden className={cn(glyphClass, \"shrink-0\")} />\n )}\n {pending && pendingLabel !== undefined ? pendingLabel : saveLabel}\n {submitShortcut && submitShortcutHint && (\n // Hidden from the accessibility tree: `aria-keyshortcuts` already says it,\n // and in the name it would make the button \"Save Ctrl+Enter\".\n <kbd\n aria-hidden\n className=\"ms-1 font-sans text-[0.85em] font-normal whitespace-nowrap opacity-70 pointer-coarse:hidden\"\n >\n {labels.submitShortcut(apple)}\n </kbd>\n )}\n </Button>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { returnFocus } from "../lib/focus-left.js";
|
|
5
6
|
import { mergeRefs } from "./field-parts.js";
|
|
6
7
|
import { runOwnKeyDown, takeEscape, useDismissLayer, useOutsideClick } from "../hooks/use-dismiss.js";
|
|
7
8
|
import { anchoredInline } from "../hooks/use-anchored-panel.js";
|
|
@@ -91,9 +92,9 @@ function HoverMenu({
|
|
|
91
92
|
) ?? null;
|
|
92
93
|
const items = () => menuRef.current ? applyMenuSemantics(menuRef.current) : [];
|
|
93
94
|
const closeToTrigger = () => {
|
|
94
|
-
const
|
|
95
|
+
const wrapper = wrapperRef.current;
|
|
96
|
+
if (wrapper?.contains(document.activeElement)) returnFocus({ opener: triggerEl(), closing: wrapper });
|
|
95
97
|
close();
|
|
96
|
-
if (hadFocus) triggerEl()?.focus();
|
|
97
98
|
};
|
|
98
99
|
const layer = useDismissLayer(open, closeToTrigger);
|
|
99
100
|
useLayoutEffect(() => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/hover-menu.tsx"],"sourcesContent":["import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent as ReactKeyboardEvent, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { mergeRefs } from \"./field-parts\";\nimport { runOwnKeyDown, takeEscape, useDismissLayer, useOutsideClick } from \"../hooks/use-dismiss\";\nimport { anchoredInline } from \"../hooks/use-anchored-panel\";\nimport { dirOf } from \"../lib/direction\";\n\n/**\n * Exported and `<div>`-shaped: the top bar composes several of these side by side, and\n * telling them apart — in a test, in a guided tour, in an accessibility tree — needs an\n * attribute the kit does not know about. A closed prop list dropped every one of them.\n *\n * `children` is omitted from the div attributes because here it is a render prop taking\n * the panel's `close`, not a `ReactNode`.\n */\nexport interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n /** The trigger. `toggle` for a button's click; `close` for a trigger that is a LINK —\n * following it should shut a panel hover opened (TopBarActionMenu `href`). */\n trigger: (state: { open: boolean; toggle: () => void; close: () => void }) => ReactNode;\n /** The menu's rows. `close` shuts the menu and, when focus is in it, puts focus on the\n * trigger before returning — so a dialog the same handler opens gives it back there\n * (0.52). */\n children: (close: () => void) => ReactNode;\n /**\n * Which edge of the trigger the panel lines up with, by the reading direction: `end`\n * is the right edge in LTR and the left in RTL. Default `end` — the same right-aligned\n * panel as before in LTR, mirrored in RTL. The physical `left` / `right` went in 0.35\n * (round 0.35 R1, refactor audit 2026-10, API-6e): `right` is `end` in a left-to-right\n * page, `left` is `start`.\n */\n align?: \"start\" | \"end\";\n panelClassName?: string;\n /** Extra classes for the WRAPPER (the panel has {@link panelClassName}). */\n className?: string;\n /** The WRAPPER, where the rest of the props go (0.34, API-2, refactor audit 2026-10) —\n * merged with its own ref, which the outside click and the hover are measured on. */\n ref?: Ref<HTMLDivElement>;\n}\n\n// Hovering waits briefly before opening so sweeping the cursor across a row of\n// triggers doesn't flash menus; closing waits so the cursor can travel from\n// trigger to panel. Click/keyboard toggling is instant (no \"opening\" phase).\nconst OPEN_DELAY_MS = 120;\nconst CLOSE_DELAY_MS = 120;\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\nconst ALIGN_CLASS = { start: \"start-0\", end: \"end-0\" } as const;\n\n/** Explicit menu-item roles a caller may already have written. */\nconst ITEM_ROLES = '[role=\"menuitem\"],[role=\"menuitemcheckbox\"],[role=\"menuitemradio\"]';\n/** What the panel promotes to `menuitem` when the caller wrote plain controls. */\nconst PLAIN_ITEMS = \"button:not([role]),a[href]:not([role])\";\n/** The trigger: the first focusable in the wrapper that is not inside the panel. */\nconst FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\n/**\n * Give the caller's markup menu semantics.\n *\n * `children` is arbitrary — every caller in the kit renders a `<ul>` of `<li>`s holding\n * `<button>`s and router `<Link>`s — so the panel was a `role=\"menu\"` whose children\n * were a list, list items and plain buttons: no `menuitem` anywhere, which is a menu\n * with nothing in it as far as a screen reader is concerned. Rather than make every\n * caller rewrite its rows, the panel upgrades them after render: controls become\n * `menuitem`s out of the Tab order (the arrow keys move between them), and the list\n * markup in between becomes `role=\"none\"` so the menu owns its items directly. An\n * element that already carries a role is the caller's decision and is left alone.\n */\nfunction applyMenuSemantics(menu: HTMLElement): HTMLElement[] {\n for (const el of menu.querySelectorAll<HTMLElement>(PLAIN_ITEMS)) el.setAttribute(\"role\", \"menuitem\");\n const items = Array.from(menu.querySelectorAll<HTMLElement>(ITEM_ROLES));\n for (const item of items) {\n item.setAttribute(\"tabindex\", \"-1\");\n for (let el = item.parentElement; el && el !== menu; el = el.parentElement) {\n if (!el.hasAttribute(\"role\")) el.setAttribute(\"role\", \"none\");\n }\n }\n for (const el of menu.querySelectorAll<HTMLElement>(\"ul:not([role]),ol:not([role]),li:not([role])\")) {\n el.setAttribute(\"role\", \"none\");\n }\n return items.filter((el) => !el.hasAttribute(\"disabled\") && el.getAttribute(\"aria-disabled\") !== \"true\");\n}\n\n// Only one hover menu may show its panel at a time across the app — opening\n// one closes whichever was open, so adjacent top-bar menus can't overlap\n// (feedback #251). Module-level is fine: there is one cursor per document.\nlet activeClose: (() => void) | null = null;\n\nexport function HoverMenu({\n trigger,\n children,\n align = \"end\",\n panelClassName,\n className,\n \"aria-label\": ariaLabel,\n ref,\n ...rest\n}: HoverMenuProps) {\n const wrapperRef = useRef<HTMLDivElement>(null);\n const setWrapper = useMemo(() => mergeRefs(wrapperRef, ref), [ref]);\n const [phase, setPhase] = useState<Phase>(\"closed\");\n const open = phase === \"open\" || phase === \"closing\";\n\n // Whether the panel is open *because hover opened it*, vs. an explicit\n // click/keyboard toggle. A real mouse click on the trigger is always\n // preceded by a real mouseenter — the cursor has to land on the button\n // before it can be clicked — so once hover has opened the panel, the click\n // that inevitably follows is the same physical gesture, not a request to\n // close what was just opened (feedback #19). Toggling closed on click stays\n // for keyboard/touch activation, which reaches \"open\" without going through\n // the hover path.\n //\n // State rather than a ref: `close` is handed to the caller's render prop, and a\n // function that writes a ref must not be reachable from render.\n const [openedByHover, setOpenedByHover] = useState(false);\n\n const close = useCallback(() => {\n setOpenedByHover(false);\n setPhase(\"closed\");\n }, []);\n\n const toggle = useCallback(() => {\n if (open) {\n if (!openedByHover) setPhase(\"closed\");\n return;\n }\n setOpenedByHover(false);\n setPhase(\"open\");\n }, [open, openedByHover]);\n\n const handleMouseEnter = useCallback(\n () => setPhase((p) => (p === \"closing\" ? \"open\" : p === \"closed\" ? \"opening\" : p)),\n [],\n );\n\n const handleMouseLeave = useCallback(\n () => setPhase((p) => (p === \"opening\" ? \"closed\" : p === \"open\" ? \"closing\" : p)),\n [],\n );\n\n // Drive the delayed transitions; unmount/phase changes cancel the timer.\n useEffect(() => {\n if (phase === \"opening\") {\n const id = setTimeout(() => {\n setOpenedByHover(true);\n setPhase(\"open\");\n }, OPEN_DELAY_MS);\n return () => clearTimeout(id);\n }\n if (phase === \"closing\") {\n const id = setTimeout(() => setPhase(\"closed\"), CLOSE_DELAY_MS);\n return () => clearTimeout(id);\n }\n }, [phase]);\n\n // Claim the \"active menu\" slot while our panel is visible, closing the\n // previous holder. Cleanup releases the slot only if we still hold it\n // (a successor that displaced us has already overwritten it).\n useEffect(() => {\n if (!open) return;\n if (activeClose !== close) activeClose?.();\n activeClose = close;\n return () => {\n if (activeClose === close) activeClose = null;\n };\n }, [open, close]);\n\n // A press in a panel opened from a row (a popover, its lists) is inside (dev#623).\n const markInside = useOutsideClick(wrapperRef, close, open);\n\n const menuRef = useRef<HTMLDivElement>(null);\n /** Set by a keyboard press on the trigger, so the open that follows moves focus into\n * the menu. A hover or a mouse click opens it without taking focus off the page. */\n const focusOnOpenRef = useRef<\"first\" | \"last\" | null>(null);\n\n const triggerEl = () =>\n Array.from(wrapperRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []).find(\n (el) => !menuRef.current?.contains(el),\n ) ?? null;\n const items = () => (menuRef.current ? applyMenuSemantics(menuRef.current) : []);\n\n // Focus goes back to the trigger only when it was inside the menu: a panel opened by\n // hover while the user is typing elsewhere must not yank the caret over here on the\n // way out. At once, not after the commit: a row that closes the menu and opens a dialog\n // in one handler has the dialog record its restore target during that commit's render,\n // and the row it would record is about to go (refactor audit 2026-10-30, INP-2).\n const closeToTrigger = () => {\n const hadFocus = wrapperRef.current?.contains(document.activeElement) ?? false;\n close();\n if (hadFocus) triggerEl()?.focus();\n };\n // Escape closes, from anywhere — it did nothing at all before — as a layer on the kit's\n // Escape stack, so inside a dialog it closes the menu and not the dialog (Wave 2,\n // DUP-1).\n const layer = useDismissLayer(open, closeToTrigger);\n\n // Upgrade the markup every render while open (the caller's rows can change), then\n // honour a pending keyboard open.\n useLayoutEffect(() => {\n if (!open) return;\n const list = items();\n const want = focusOnOpenRef.current;\n focusOnOpenRef.current = null;\n if (want) (want === \"last\" ? list[list.length - 1] : list[0])?.focus();\n });\n\n // Trigger state for assistive technology, on whatever element the caller rendered.\n // Only where the caller has not said something of its own.\n useLayoutEffect(() => {\n const el = triggerEl();\n if (!el) return;\n if (!el.hasAttribute(\"aria-haspopup\")) el.setAttribute(\"aria-haspopup\", \"menu\");\n if (el.getAttribute(\"aria-haspopup\") === \"menu\") el.setAttribute(\"aria-expanded\", String(open));\n });\n\n /** ↑/↓ roving focus with wrap, Home/End, Tab leaves (and closes); on the trigger,\n * Enter/Space/↓ open onto the first item and ↑ onto the last. */\n const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n const target = e.target as HTMLElement;\n const inMenu = menuRef.current?.contains(target) ?? false;\n if (!inMenu) {\n if (target !== triggerEl()) return;\n if (e.key === \"Enter\" || e.key === \" \") {\n // The trigger's own click handler does the toggling; this only records that\n // the press came from a keyboard. Not for a link trigger: Enter follows it, and\n // a mark left here would pull focus into the panel the next time hover opens it.\n if (!open && !target.matches(\"a[href]\")) focusOnOpenRef.current = \"first\";\n } else if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n const edge = e.key === \"ArrowDown\" ? \"first\" : \"last\";\n if (open) {\n const list = items();\n (edge === \"last\" ? list[list.length - 1] : list[0])?.focus();\n } else {\n focusOnOpenRef.current = edge;\n setOpenedByHover(false);\n setPhase(\"open\");\n }\n }\n return;\n }\n if (e.key === \"Tab\") {\n close();\n return;\n }\n const list = items();\n if (list.length === 0) return;\n const at = list.indexOf(document.activeElement as HTMLElement);\n let next: number | null = null;\n if (e.key === \"ArrowDown\") next = at < 0 ? 0 : (at + 1) % list.length;\n else if (e.key === \"ArrowUp\") next = at < 0 ? list.length - 1 : (at - 1 + list.length) % list.length;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = list.length - 1;\n if (next === null) return;\n e.preventDefault();\n list[next].focus();\n };\n\n // Keep the panel inside the viewport (feedback #85): a wide panel anchored to a\n // trigger near a screen edge would otherwise spill off it (on mobile the guided-\n // tours menu ran off the left). Once open, measure and translate it horizontally\n // just enough to sit within an 8px margin of the GLASS — `anchoredInline` against\n // the panel's own natural box, as every anchored panel clamps (Wave 2, DUP-7; it\n // measured against `innerWidth`, which on a phone is the grown layout viewport).\n // Recomputed on each open; `shiftX` is subtracted first so the measurement is\n // against the natural position.\n const panelRef = useRef<HTMLDivElement>(null);\n const [shiftX, setShiftX] = useState(0);\n // Back to 0 on close, adjusted while rendering against the previous `open`.\n const [wasOpen, setWasOpen] = useState(open);\n if (open !== wasOpen) {\n setWasOpen(open);\n if (!open) setShiftX(0);\n }\n useLayoutEffect(() => {\n if (!open) return;\n const el = panelRef.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n if (rect.width === 0) return; // unmeasured (e.g. jsdom) — leave as-is\n const natural = { left: rect.left - shiftX, right: rect.right - shiftX };\n const { left } = anchoredInline(natural, rect.width, { dir: dirOf(el), align: \"left\" });\n setShiftX(left - natural.left);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- recompute only on open/close\n }, [open]);\n\n return (\n /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- never focused\n itself: the mouse handlers are the hover-to-open convenience, and `onKeyDown`\n catches keys bubbling from the caller's trigger button and the menuitems, which\n are the keyboard path (Enter/Space/arrows open, arrows move, Escape closes). */\n <div\n // `...rest` first: the hover handlers below are the whole component, and a caller\n // passing one of its own must not silently replace them.\n {...rest}\n ref={setWrapper}\n className={cn(\"relative\", className)}\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n onPointerDownCapture={(e) => {\n rest.onPointerDownCapture?.(e);\n markInside(e);\n }}\n onKeyDown={(e) => {\n // The caller's handler sees Escape only while this menu is the layer on top: a\n // tooltip on one of its rows takes that press first (round 0.35 R2, the rule\n // the dialogs got first). Every other key, and a closed menu's, as before.\n runOwnKeyDown(e, rest.onKeyDown, layer);\n // Focus on the trigger or in the menu: the key stops here, so a panel around\n // the menu does not close with it.\n if (open) takeEscape(e);\n onKeyDown(e);\n }}\n >\n {trigger({ open, toggle, close })}\n {open && (\n <div\n ref={panelRef}\n className={cn(\"absolute top-full z-40 pt-2\", ALIGN_CLASS[align])}\n style={shiftX ? { transform: `translateX(${shiftX}px)` } : undefined}\n >\n <div\n ref={menuRef}\n role=\"menu\"\n // On the element with the role. It used to sit on the role-less wrapper,\n // where no screen reader reads a name.\n aria-label={ariaLabel}\n className={cn(\n \"min-w-44 max-w-[calc(100vw-1rem)] rounded-md border border-subtle bg-surface shadow-lg\",\n panelClassName,\n )}\n >\n {\n // eslint-disable-next-line react-hooks/refs -- handed to the items' handlers: it reads the trigger and the focus when an item is chosen, never while rendering\n children(closeToTrigger)\n }\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["list"]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/hover-menu.tsx"],"sourcesContent":["import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent as ReactKeyboardEvent, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { returnFocus } from \"../lib/focus-left\";\nimport { mergeRefs } from \"./field-parts\";\nimport { runOwnKeyDown, takeEscape, useDismissLayer, useOutsideClick } from \"../hooks/use-dismiss\";\nimport { anchoredInline } from \"../hooks/use-anchored-panel\";\nimport { dirOf } from \"../lib/direction\";\n\n/**\n * Exported and `<div>`-shaped: the top bar composes several of these side by side, and\n * telling them apart — in a test, in a guided tour, in an accessibility tree — needs an\n * attribute the kit does not know about. A closed prop list dropped every one of them.\n *\n * `children` is omitted from the div attributes because here it is a render prop taking\n * the panel's `close`, not a `ReactNode`.\n */\nexport interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n /** The trigger. `toggle` for a button's click; `close` for a trigger that is a LINK —\n * following it should shut a panel hover opened (TopBarActionMenu `href`). */\n trigger: (state: { open: boolean; toggle: () => void; close: () => void }) => ReactNode;\n /** The menu's rows. `close` shuts the menu and, when focus is in it, puts focus on the\n * trigger before returning — so a dialog the same handler opens gives it back there\n * (0.52). */\n children: (close: () => void) => ReactNode;\n /**\n * Which edge of the trigger the panel lines up with, by the reading direction: `end`\n * is the right edge in LTR and the left in RTL. Default `end` — the same right-aligned\n * panel as before in LTR, mirrored in RTL. The physical `left` / `right` went in 0.35\n * (round 0.35 R1, refactor audit 2026-10, API-6e): `right` is `end` in a left-to-right\n * page, `left` is `start`.\n */\n align?: \"start\" | \"end\";\n panelClassName?: string;\n /** Extra classes for the WRAPPER (the panel has {@link panelClassName}). */\n className?: string;\n /** The WRAPPER, where the rest of the props go (0.34, API-2, refactor audit 2026-10) —\n * merged with its own ref, which the outside click and the hover are measured on. */\n ref?: Ref<HTMLDivElement>;\n}\n\n// Hovering waits briefly before opening so sweeping the cursor across a row of\n// triggers doesn't flash menus; closing waits so the cursor can travel from\n// trigger to panel. Click/keyboard toggling is instant (no \"opening\" phase).\nconst OPEN_DELAY_MS = 120;\nconst CLOSE_DELAY_MS = 120;\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\nconst ALIGN_CLASS = { start: \"start-0\", end: \"end-0\" } as const;\n\n/** Explicit menu-item roles a caller may already have written. */\nconst ITEM_ROLES = '[role=\"menuitem\"],[role=\"menuitemcheckbox\"],[role=\"menuitemradio\"]';\n/** What the panel promotes to `menuitem` when the caller wrote plain controls. */\nconst PLAIN_ITEMS = \"button:not([role]),a[href]:not([role])\";\n/** The trigger: the first focusable in the wrapper that is not inside the panel. */\nconst FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\n/**\n * Give the caller's markup menu semantics.\n *\n * `children` is arbitrary — every caller in the kit renders a `<ul>` of `<li>`s holding\n * `<button>`s and router `<Link>`s — so the panel was a `role=\"menu\"` whose children\n * were a list, list items and plain buttons: no `menuitem` anywhere, which is a menu\n * with nothing in it as far as a screen reader is concerned. Rather than make every\n * caller rewrite its rows, the panel upgrades them after render: controls become\n * `menuitem`s out of the Tab order (the arrow keys move between them), and the list\n * markup in between becomes `role=\"none\"` so the menu owns its items directly. An\n * element that already carries a role is the caller's decision and is left alone.\n */\nfunction applyMenuSemantics(menu: HTMLElement): HTMLElement[] {\n for (const el of menu.querySelectorAll<HTMLElement>(PLAIN_ITEMS)) el.setAttribute(\"role\", \"menuitem\");\n const items = Array.from(menu.querySelectorAll<HTMLElement>(ITEM_ROLES));\n for (const item of items) {\n item.setAttribute(\"tabindex\", \"-1\");\n for (let el = item.parentElement; el && el !== menu; el = el.parentElement) {\n if (!el.hasAttribute(\"role\")) el.setAttribute(\"role\", \"none\");\n }\n }\n for (const el of menu.querySelectorAll<HTMLElement>(\"ul:not([role]),ol:not([role]),li:not([role])\")) {\n el.setAttribute(\"role\", \"none\");\n }\n return items.filter((el) => !el.hasAttribute(\"disabled\") && el.getAttribute(\"aria-disabled\") !== \"true\");\n}\n\n// Only one hover menu may show its panel at a time across the app — opening\n// one closes whichever was open, so adjacent top-bar menus can't overlap\n// (feedback #251). Module-level is fine: there is one cursor per document.\nlet activeClose: (() => void) | null = null;\n\nexport function HoverMenu({\n trigger,\n children,\n align = \"end\",\n panelClassName,\n className,\n \"aria-label\": ariaLabel,\n ref,\n ...rest\n}: HoverMenuProps) {\n const wrapperRef = useRef<HTMLDivElement>(null);\n const setWrapper = useMemo(() => mergeRefs(wrapperRef, ref), [ref]);\n const [phase, setPhase] = useState<Phase>(\"closed\");\n const open = phase === \"open\" || phase === \"closing\";\n\n // Whether the panel is open *because hover opened it*, vs. an explicit\n // click/keyboard toggle. A real mouse click on the trigger is always\n // preceded by a real mouseenter — the cursor has to land on the button\n // before it can be clicked — so once hover has opened the panel, the click\n // that inevitably follows is the same physical gesture, not a request to\n // close what was just opened (feedback #19). Toggling closed on click stays\n // for keyboard/touch activation, which reaches \"open\" without going through\n // the hover path.\n //\n // State rather than a ref: `close` is handed to the caller's render prop, and a\n // function that writes a ref must not be reachable from render.\n const [openedByHover, setOpenedByHover] = useState(false);\n\n const close = useCallback(() => {\n setOpenedByHover(false);\n setPhase(\"closed\");\n }, []);\n\n const toggle = useCallback(() => {\n if (open) {\n if (!openedByHover) setPhase(\"closed\");\n return;\n }\n setOpenedByHover(false);\n setPhase(\"open\");\n }, [open, openedByHover]);\n\n const handleMouseEnter = useCallback(\n () => setPhase((p) => (p === \"closing\" ? \"open\" : p === \"closed\" ? \"opening\" : p)),\n [],\n );\n\n const handleMouseLeave = useCallback(\n () => setPhase((p) => (p === \"opening\" ? \"closed\" : p === \"open\" ? \"closing\" : p)),\n [],\n );\n\n // Drive the delayed transitions; unmount/phase changes cancel the timer.\n useEffect(() => {\n if (phase === \"opening\") {\n const id = setTimeout(() => {\n setOpenedByHover(true);\n setPhase(\"open\");\n }, OPEN_DELAY_MS);\n return () => clearTimeout(id);\n }\n if (phase === \"closing\") {\n const id = setTimeout(() => setPhase(\"closed\"), CLOSE_DELAY_MS);\n return () => clearTimeout(id);\n }\n }, [phase]);\n\n // Claim the \"active menu\" slot while our panel is visible, closing the\n // previous holder. Cleanup releases the slot only if we still hold it\n // (a successor that displaced us has already overwritten it).\n useEffect(() => {\n if (!open) return;\n if (activeClose !== close) activeClose?.();\n activeClose = close;\n return () => {\n if (activeClose === close) activeClose = null;\n };\n }, [open, close]);\n\n // A press in a panel opened from a row (a popover, its lists) is inside (dev#623).\n const markInside = useOutsideClick(wrapperRef, close, open);\n\n const menuRef = useRef<HTMLDivElement>(null);\n /** Set by a keyboard press on the trigger, so the open that follows moves focus into\n * the menu. A hover or a mouse click opens it without taking focus off the page. */\n const focusOnOpenRef = useRef<\"first\" | \"last\" | null>(null);\n\n const triggerEl = () =>\n Array.from(wrapperRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []).find(\n (el) => !menuRef.current?.contains(el),\n ) ?? null;\n const items = () => (menuRef.current ? applyMenuSemantics(menuRef.current) : []);\n\n // Focus goes back to the trigger only when it was inside the menu: a panel opened by\n // hover while the user is typing elsewhere must not yank the caret over here on the\n // way out. At once, not after the commit: a row that closes the menu and opens a dialog\n // in one handler has the dialog record its restore target during that commit's render,\n // and the row it would record is about to go (refactor audit 2026-10-30, INP-2). By the\n // kit's one rule (lib/focus-left `returnFocus`; 0.52 code review 2026-10-31, held to\n // 0.55), asked only with the focus inside: a menu a hover opened never took the focus,\n // so one on <body> was not lost by it, and the rule's \"from nowhere\" is not its to use.\n const closeToTrigger = () => {\n const wrapper = wrapperRef.current;\n if (wrapper?.contains(document.activeElement)) returnFocus({ opener: triggerEl(), closing: wrapper });\n close();\n };\n // Escape closes, from anywhere — it did nothing at all before — as a layer on the kit's\n // Escape stack, so inside a dialog it closes the menu and not the dialog (Wave 2,\n // DUP-1).\n const layer = useDismissLayer(open, closeToTrigger);\n\n // Upgrade the markup every render while open (the caller's rows can change), then\n // honour a pending keyboard open.\n useLayoutEffect(() => {\n if (!open) return;\n const list = items();\n const want = focusOnOpenRef.current;\n focusOnOpenRef.current = null;\n if (want) (want === \"last\" ? list[list.length - 1] : list[0])?.focus();\n });\n\n // Trigger state for assistive technology, on whatever element the caller rendered.\n // Only where the caller has not said something of its own.\n useLayoutEffect(() => {\n const el = triggerEl();\n if (!el) return;\n if (!el.hasAttribute(\"aria-haspopup\")) el.setAttribute(\"aria-haspopup\", \"menu\");\n if (el.getAttribute(\"aria-haspopup\") === \"menu\") el.setAttribute(\"aria-expanded\", String(open));\n });\n\n /** ↑/↓ roving focus with wrap, Home/End, Tab leaves (and closes); on the trigger,\n * Enter/Space/↓ open onto the first item and ↑ onto the last. */\n const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n const target = e.target as HTMLElement;\n const inMenu = menuRef.current?.contains(target) ?? false;\n if (!inMenu) {\n if (target !== triggerEl()) return;\n if (e.key === \"Enter\" || e.key === \" \") {\n // The trigger's own click handler does the toggling; this only records that\n // the press came from a keyboard. Not for a link trigger: Enter follows it, and\n // a mark left here would pull focus into the panel the next time hover opens it.\n if (!open && !target.matches(\"a[href]\")) focusOnOpenRef.current = \"first\";\n } else if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n const edge = e.key === \"ArrowDown\" ? \"first\" : \"last\";\n if (open) {\n const list = items();\n (edge === \"last\" ? list[list.length - 1] : list[0])?.focus();\n } else {\n focusOnOpenRef.current = edge;\n setOpenedByHover(false);\n setPhase(\"open\");\n }\n }\n return;\n }\n if (e.key === \"Tab\") {\n close();\n return;\n }\n const list = items();\n if (list.length === 0) return;\n const at = list.indexOf(document.activeElement as HTMLElement);\n let next: number | null = null;\n if (e.key === \"ArrowDown\") next = at < 0 ? 0 : (at + 1) % list.length;\n else if (e.key === \"ArrowUp\") next = at < 0 ? list.length - 1 : (at - 1 + list.length) % list.length;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = list.length - 1;\n if (next === null) return;\n e.preventDefault();\n list[next].focus();\n };\n\n // Keep the panel inside the viewport (feedback #85): a wide panel anchored to a\n // trigger near a screen edge would otherwise spill off it (on mobile the guided-\n // tours menu ran off the left). Once open, measure and translate it horizontally\n // just enough to sit within an 8px margin of the GLASS — `anchoredInline` against\n // the panel's own natural box, as every anchored panel clamps (Wave 2, DUP-7; it\n // measured against `innerWidth`, which on a phone is the grown layout viewport).\n // Recomputed on each open; `shiftX` is subtracted first so the measurement is\n // against the natural position.\n const panelRef = useRef<HTMLDivElement>(null);\n const [shiftX, setShiftX] = useState(0);\n // Back to 0 on close, adjusted while rendering against the previous `open`.\n const [wasOpen, setWasOpen] = useState(open);\n if (open !== wasOpen) {\n setWasOpen(open);\n if (!open) setShiftX(0);\n }\n useLayoutEffect(() => {\n if (!open) return;\n const el = panelRef.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n if (rect.width === 0) return; // unmeasured (e.g. jsdom) — leave as-is\n const natural = { left: rect.left - shiftX, right: rect.right - shiftX };\n const { left } = anchoredInline(natural, rect.width, { dir: dirOf(el), align: \"left\" });\n setShiftX(left - natural.left);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- recompute only on open/close\n }, [open]);\n\n return (\n /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- never focused\n itself: the mouse handlers are the hover-to-open convenience, and `onKeyDown`\n catches keys bubbling from the caller's trigger button and the menuitems, which\n are the keyboard path (Enter/Space/arrows open, arrows move, Escape closes). */\n <div\n // `...rest` first: the hover handlers below are the whole component, and a caller\n // passing one of its own must not silently replace them.\n {...rest}\n ref={setWrapper}\n className={cn(\"relative\", className)}\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n onPointerDownCapture={(e) => {\n rest.onPointerDownCapture?.(e);\n markInside(e);\n }}\n onKeyDown={(e) => {\n // The caller's handler sees Escape only while this menu is the layer on top: a\n // tooltip on one of its rows takes that press first (round 0.35 R2, the rule\n // the dialogs got first). Every other key, and a closed menu's, as before.\n runOwnKeyDown(e, rest.onKeyDown, layer);\n // Focus on the trigger or in the menu: the key stops here, so a panel around\n // the menu does not close with it.\n if (open) takeEscape(e);\n onKeyDown(e);\n }}\n >\n {trigger({ open, toggle, close })}\n {open && (\n <div\n ref={panelRef}\n className={cn(\"absolute top-full z-40 pt-2\", ALIGN_CLASS[align])}\n style={shiftX ? { transform: `translateX(${shiftX}px)` } : undefined}\n >\n <div\n ref={menuRef}\n role=\"menu\"\n // On the element with the role. It used to sit on the role-less wrapper,\n // where no screen reader reads a name.\n aria-label={ariaLabel}\n className={cn(\n \"min-w-44 max-w-[calc(100vw-1rem)] rounded-md border border-subtle bg-surface shadow-lg\",\n panelClassName,\n )}\n >\n {\n // eslint-disable-next-line react-hooks/refs -- handed to the items' handlers: it reads the trigger and the focus when an item is chosen, never while rendering\n children(closeToTrigger)\n }\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["list"]}
|