@eifi1/ui-kit 0.51.3 → 0.51.5
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 +6 -6
- package/dist/account/account-settings-labels.js.map +1 -1
- package/dist/account/account-settings.js +11 -9
- package/dist/account/account-settings.js.map +1 -1
- package/dist/account/delete-account-setting.js +56 -65
- package/dist/account/delete-account-setting.js.map +1 -1
- package/dist/account/email-change-setting-labels.js.map +1 -1
- package/dist/account/email-change-setting.js.map +1 -1
- package/dist/account/passkeys-setting.js.map +1 -1
- package/dist/account/sessions-setting.js.map +1 -1
- package/dist/admin/admin-action-confirm.js +1 -3
- package/dist/admin/admin-action-confirm.js.map +1 -1
- package/dist/admin/admin-action-log.js.map +1 -1
- package/dist/admin/admin-area-labels.js +4 -1
- package/dist/admin/admin-area-labels.js.map +1 -1
- package/dist/admin/admin-overview.js +19 -11
- package/dist/admin/admin-overview.js.map +1 -1
- package/dist/admin/admin-sections.js.map +1 -1
- package/dist/admin/admin-tool-links.d.ts +1 -1
- package/dist/admin/admin-tool-links.js +7 -1
- package/dist/admin/admin-tool-links.js.map +1 -1
- package/dist/admin/invitations-panel.js +2 -13
- package/dist/admin/invitations-panel.js.map +1 -1
- package/dist/admin/mail-test-card.js.map +1 -1
- package/dist/admin/reviewer-scope-editor.js +4 -11
- package/dist/admin/reviewer-scope-editor.js.map +1 -1
- package/dist/admin/role-select.js.map +1 -1
- package/dist/admin/user-roster.js +1 -9
- package/dist/admin/user-roster.js.map +1 -1
- package/dist/auth/accept-invitation.js.map +1 -1
- package/dist/auth/auth-errors.js.map +1 -1
- package/dist/auth/forgot-password-form.js +23 -31
- package/dist/auth/forgot-password-form.js.map +1 -1
- package/dist/auth/register-form.js.map +1 -1
- package/dist/auth/reset-password-form.js.map +1 -1
- package/dist/auth/sign-in-form.js +13 -5
- package/dist/auth/sign-in-form.js.map +1 -1
- package/dist/auth/verify-email.js +1 -8
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/billing/billing-banner.js.map +1 -1
- package/dist/billing/billing-labels.js.map +1 -1
- package/dist/billing/checkout-processing.js.map +1 -1
- package/dist/billing/plan-card.js +91 -85
- package/dist/billing/plan-card.js.map +1 -1
- package/dist/billing/plan-limit-toast.js.map +1 -1
- package/dist/billing/plan-limit.js.map +1 -1
- package/dist/billing/plan-price.js.map +1 -1
- package/dist/billing/plan-switch.js.map +1 -1
- package/dist/billing/subscription-actions.js.map +1 -1
- package/dist/bin/eifi1-pay-page.mjs +6 -1
- package/dist/components/account-chips.js.map +1 -1
- package/dist/components/alert-banner.js +1 -5
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.js +29 -12
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/authed-image.js.map +1 -1
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/bulk-action-bar.js +1 -4
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/button-look.js.map +1 -1
- package/dist/components/button.js +1 -7
- package/dist/components/button.js.map +1 -1
- package/dist/components/calculator.js +5 -5
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/calendar-heatmap.js +4 -10
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/card.js +1 -8
- package/dist/components/card.js.map +1 -1
- package/dist/components/chart-zoom-core.js +3 -17
- package/dist/components/chart-zoom-core.js.map +1 -1
- package/dist/components/chart.d.ts +1 -1
- package/dist/components/chart.js +4 -22
- package/dist/components/chart.js.map +1 -1
- package/dist/components/checkbox-group.js +1 -4
- package/dist/components/checkbox-group.js.map +1 -1
- package/dist/components/chip-input.js +4 -1
- package/dist/components/chip-input.js.map +1 -1
- package/dist/components/chip.js +10 -5
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.js +58 -74
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/column-mapper-labels.js.map +1 -1
- package/dist/components/column-mapper.js +167 -227
- package/dist/components/column-mapper.js.map +1 -1
- package/dist/components/combobox-core.js +5 -9
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.js +1 -4
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/country-select.d.ts +5 -4
- package/dist/components/country-select.js +3 -19
- package/dist/components/country-select.js.map +1 -1
- package/dist/components/currency-option-list.js +13 -11
- package/dist/components/currency-option-list.js.map +1 -1
- package/dist/components/currency-select.js +1 -6
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-cells.js.map +1 -1
- package/dist/components/data-table-column-settings.js.map +1 -1
- package/dist/components/data-table-filter-popover.js +2 -18
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-fit.js +7 -5
- package/dist/components/data-table-fit.js.map +1 -1
- package/dist/components/data-table-groups.js +3 -1
- package/dist/components/data-table-groups.js.map +1 -1
- package/dist/components/data-table-head.js +49 -57
- package/dist/components/data-table-head.js.map +1 -1
- package/dist/components/data-table-mobile.js +31 -44
- package/dist/components/data-table-mobile.js.map +1 -1
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-row-actions.js +19 -16
- package/dist/components/data-table-row-actions.js.map +1 -1
- package/dist/components/data-table-row.js.map +1 -1
- package/dist/components/data-table-shared.js.map +1 -1
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table-virtual.js +4 -1
- package/dist/components/data-table-virtual.js.map +1 -1
- package/dist/components/data-table.d.ts +8 -0
- package/dist/components/data-table.js +30 -39
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.js +1 -6
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/date-range-picker.js.map +1 -1
- package/dist/components/description-list.js +10 -10
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog-frame.js +9 -2
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/disclosure.js +17 -27
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/dropdown.js +25 -35
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/empty-state.js.map +1 -1
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/field-base.js +5 -5
- package/dist/components/field-base.js.map +1 -1
- package/dist/components/field-parts.d.ts +1 -1
- package/dist/components/field-parts.js +10 -14
- package/dist/components/field-parts.js.map +1 -1
- package/dist/components/field-sync.js +8 -10
- package/dist/components/field-sync.js.map +1 -1
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.js +4 -1
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/focus-ring.js.map +1 -1
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/grouped-picker.js +11 -12
- package/dist/components/grouped-picker.js.map +1 -1
- package/dist/components/hover-menu.js +1 -4
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/iban-input-labels.js.map +1 -1
- package/dist/components/icon-button.js +4 -1
- package/dist/components/icon-button.js.map +1 -1
- package/dist/components/icon-picker.js +6 -10
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/image-grid.js +41 -50
- package/dist/components/image-grid.js.map +1 -1
- package/dist/components/inline-edit-field.js.map +1 -1
- package/dist/components/inline-entity-combobox.js +6 -12
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/components/input.js +14 -2
- package/dist/components/input.js.map +1 -1
- package/dist/components/jump-calendar.js +1 -7
- package/dist/components/jump-calendar.js.map +1 -1
- package/dist/components/legal-labels.js +8 -2
- package/dist/components/legal-labels.js.map +1 -1
- package/dist/components/legal-page.d.ts +18 -1
- package/dist/components/legal-page.js +27 -2
- package/dist/components/legal-page.js.map +1 -1
- package/dist/components/legal.d.ts +40 -5
- package/dist/components/legal.js +41 -5
- package/dist/components/legal.js.map +1 -1
- package/dist/components/lightbox.js.map +1 -1
- package/dist/components/line-items.js +26 -36
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list-labels.js +3 -1
- package/dist/components/list-labels.js.map +1 -1
- package/dist/components/list.d.ts +18 -1
- package/dist/components/list.js +54 -13
- package/dist/components/list.js.map +1 -1
- package/dist/components/measured-grid.js +29 -53
- package/dist/components/measured-grid.js.map +1 -1
- package/dist/components/menu-item.js.map +1 -1
- package/dist/components/mini-calendar.js +13 -31
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/money-field.js.map +1 -1
- package/dist/components/month-picker.js +10 -21
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.js +3 -16
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/nav-pills.js +10 -1
- package/dist/components/nav-pills.js.map +1 -1
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.js +1 -7
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.js +1 -4
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/one-time-code-input.js +1 -7
- package/dist/components/one-time-code-input.js.map +1 -1
- package/dist/components/page-contents.js +13 -22
- package/dist/components/page-contents.js.map +1 -1
- package/dist/components/page-header.js +1 -11
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/password-strength-labels.js.map +1 -1
- package/dist/components/password-strength.js +2 -8
- package/dist/components/password-strength.js.map +1 -1
- package/dist/components/phone-input.js +42 -57
- package/dist/components/phone-input.js.map +1 -1
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/pie-chart.js +1 -8
- package/dist/components/pie-chart.js.map +1 -1
- package/dist/components/popover.js.map +1 -1
- package/dist/components/progress-bar.js +16 -9
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/qr-code.js +1 -8
- package/dist/components/qr-code.js.map +1 -1
- package/dist/components/row-actions.js +5 -1
- package/dist/components/row-actions.js.map +1 -1
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/select.js +28 -38
- package/dist/components/select.js.map +1 -1
- package/dist/components/separator.js +1 -5
- package/dist/components/separator.js.map +1 -1
- package/dist/components/series-chart-axes.js +4 -1
- package/dist/components/series-chart-axes.js.map +1 -1
- package/dist/components/series-chart-budget.js.map +1 -1
- package/dist/components/series-chart-ticks.js.map +1 -1
- package/dist/components/series-chart.js +29 -45
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.js +21 -17
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/share-card.js +4 -9
- package/dist/components/share-card.js.map +1 -1
- package/dist/components/sign-chip.js.map +1 -1
- package/dist/components/signature-pad.js +4 -13
- package/dist/components/signature-pad.js.map +1 -1
- package/dist/components/signed-amount.js +12 -5
- package/dist/components/signed-amount.js.map +1 -1
- package/dist/components/skeleton.js +2 -17
- package/dist/components/skeleton.js.map +1 -1
- package/dist/components/slider.js +13 -36
- package/dist/components/slider.js.map +1 -1
- package/dist/components/sparkline.js.map +1 -1
- package/dist/components/stat-tile.js +14 -10
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/swatch-picker.js +14 -4
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/swipeable-row.d.ts +1 -1
- package/dist/components/swipeable-row.js +1 -8
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/table-skeleton.js +4 -1
- package/dist/components/table-skeleton.js.map +1 -1
- package/dist/components/table.js +12 -9
- package/dist/components/table.js.map +1 -1
- package/dist/components/tabs.js +5 -24
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/text-link.js.map +1 -1
- package/dist/components/text.js.map +1 -1
- package/dist/components/tile-radio.js +1 -7
- package/dist/components/tile-radio.js.map +1 -1
- package/dist/components/time-input.js.map +1 -1
- package/dist/components/toast.js.map +1 -1
- package/dist/components/toggle-group.js +6 -1
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.js +2 -11
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tooltip.js +2 -22
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/translation-review-editor.js +1 -4
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review-parts.d.ts +1 -1
- package/dist/components/translation-review-parts.js +7 -1
- package/dist/components/translation-review-parts.js.map +1 -1
- package/dist/components/translation-review.js +18 -7
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/tree-view.js +14 -10
- package/dist/components/tree-view.js.map +1 -1
- package/dist/components/treemap.js +1 -13
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/trigger-aria.js.map +1 -1
- package/dist/components/use-table-state.js +14 -11
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/components/user-avatar.js +3 -26
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/demo/demo-start.js +8 -4
- package/dist/demo/demo-start.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-attachment-labels.js.map +1 -1
- package/dist/feedback/feedback-attachment.js +1 -8
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-capture.d.ts +1 -1
- package/dist/feedback/feedback-capture.js +3 -1
- package/dist/feedback/feedback-capture.js.map +1 -1
- package/dist/feedback/feedback-comment-thread.js +27 -13
- package/dist/feedback/feedback-comment-thread.js.map +1 -1
- package/dist/feedback/feedback-context.js +1 -4
- package/dist/feedback/feedback-context.js.map +1 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-public-list.js +7 -1
- package/dist/feedback/feedback-public-list.js.map +1 -1
- package/dist/feedback/feedback-record.js +1 -5
- package/dist/feedback/feedback-record.js.map +1 -1
- package/dist/feedback/feedback-replies.js +25 -10
- package/dist/feedback/feedback-replies.js.map +1 -1
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-status-undo.js.map +1 -1
- package/dist/feedback/feedback-submit-dialog.js.map +1 -1
- package/dist/feedback/feedback-table.d.ts +3 -1
- package/dist/feedback/feedback-table.js +22 -5
- package/dist/feedback/feedback-table.js.map +1 -1
- package/dist/feedback/feedback-thread.js +2 -13
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/feedback/feedback-vocabulary.js.map +1 -1
- package/dist/feedback/feedback-vote-button.js.map +1 -1
- package/dist/hooks/use-anchored-rect.js.map +1 -1
- package/dist/hooks/use-document-title.js.map +1 -1
- package/dist/hooks/use-file-drop.js.map +1 -1
- package/dist/hooks/use-file-picker-labels.js.map +1 -1
- package/dist/hooks/use-file-picker.js.map +1 -1
- package/dist/hooks/use-focus-trap.js.map +1 -1
- package/dist/hooks/use-overlay-history.js +10 -5
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/hooks/use-row-swipe.d.ts +1 -1
- package/dist/hooks/use-row-swipe.js +1 -6
- package/dist/hooks/use-row-swipe.js.map +1 -1
- package/dist/i18n/defaults.js +1 -4
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +15 -4
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.js +2 -8
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.js.map +1 -1
- package/dist/i18n/locales/en.js +19 -4
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +15 -4
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +15 -4
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +15 -4
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +15 -4
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +11 -3
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/plurals.js.map +1 -1
- package/dist/i18n/review.js +15 -7
- package/dist/i18n/review.js.map +1 -1
- package/dist/i18n/shared-labels.js.map +1 -1
- package/dist/index.d.ts +17 -17
- package/dist/index.js +40 -23
- package/dist/index.js.map +1 -1
- package/dist/landing/access.js +1 -4
- package/dist/landing/access.js.map +1 -1
- package/dist/landing/landing-sections.js +35 -38
- package/dist/landing/landing-sections.js.map +1 -1
- package/dist/landing/page-seo.js.map +1 -1
- package/dist/lib/calc.js.map +1 -1
- package/dist/lib/clipboard-minus.js.map +1 -1
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/decimal-marks.js.map +1 -1
- package/dist/lib/device-settings.js +2 -1
- package/dist/lib/device-settings.js.map +1 -1
- package/dist/lib/direction.js.map +1 -1
- package/dist/lib/iban.js.map +1 -1
- package/dist/lib/logger.js +1 -5
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/person-name.js.map +1 -1
- package/dist/lib/phone.js.map +1 -1
- package/dist/lib/qr-encode.js +344 -8
- package/dist/lib/qr-encode.js.map +1 -1
- package/dist/lib/session-refresh.js.map +1 -1
- package/dist/lib/table-text.js +15 -3
- package/dist/lib/table-text.js.map +1 -1
- package/dist/lib/text-match.d.ts +20 -0
- package/dist/lib/text-match.js +11 -1
- package/dist/lib/text-match.js.map +1 -1
- package/dist/lib/translation-review.js.map +1 -1
- package/dist/notifications/notification-inbox.js +63 -44
- package/dist/notifications/notification-inbox.js.map +1 -1
- package/dist/notifications/notification-parts.js.map +1 -1
- package/dist/notifications/notification-types-setting.js.map +1 -1
- package/dist/notifications/notifications-labels.js.map +1 -1
- package/dist/notifications/push-device-setting.js +6 -1
- package/dist/notifications/push-device-setting.js.map +1 -1
- package/dist/notifications/push-devices-setting.js.map +1 -1
- package/dist/notifications/push-worker.js.map +1 -1
- package/dist/notifications/quiet-hours-setting.js.map +1 -1
- package/dist/notifications/use-push-device.js +4 -1
- package/dist/notifications/use-push-device.js.map +1 -1
- package/dist/offline/pwa-update-prompt.d.ts +1 -1
- package/dist/offline/pwa-update-prompt.js +7 -1
- package/dist/offline/pwa-update-prompt.js.map +1 -1
- package/dist/offline/query-persistence.js.map +1 -1
- package/dist/offline.d.ts +1 -1
- package/dist/offline.js +6 -1
- package/dist/offline.js.map +1 -1
- package/dist/pay/pay.css +8 -1
- package/dist/rhf/fields.d.ts +1 -2
- package/dist/rhf/fields.js +1 -10
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form-core.js +1 -4
- package/dist/rhf/form-core.js.map +1 -1
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf/use-rhf-wizard-step.js.map +1 -1
- package/dist/search/command-palette.js +71 -62
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search/global-search.js +34 -5
- package/dist/search/global-search.js.map +1 -1
- package/dist/search/search-index.js.map +1 -1
- package/dist/settings/device-reset-setting.js +1 -6
- package/dist/settings/device-reset-setting.js.map +1 -1
- package/dist/settings/settings-heading.js.map +1 -1
- package/dist/settings/settings-layout.js +9 -20
- package/dist/settings/settings-layout.js.map +1 -1
- package/dist/settings/use-settings-focus.d.ts +7 -0
- package/dist/settings/use-settings-focus.js +2 -0
- package/dist/settings/use-settings-focus.js.map +1 -1
- package/dist/settings/use-settings-route.js.map +1 -1
- package/dist/shell/app-shell.js +62 -31
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/auth-layout.js +1 -10
- package/dist/shell/auth-layout.js.map +1 -1
- package/dist/shell/dev-menu.d.ts +5 -0
- package/dist/shell/dev-menu.js +14 -7
- package/dist/shell/dev-menu.js.map +1 -1
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/sync-status-indicator.js +7 -6
- package/dist/shell/sync-status-indicator.js.map +1 -1
- package/dist/shell/top-bar-parts-labels.js.map +1 -1
- package/dist/shell/topbar-action-menu.d.ts +14 -1
- package/dist/shell/topbar-action-menu.js +85 -34
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.js +1 -10
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/shell/tour-menu.js +9 -1
- package/dist/shell/tour-menu.js.map +1 -1
- package/dist/shell.d.ts +1 -1
- package/dist/shell.js +2 -1
- package/dist/shell.js.map +1 -1
- package/dist/sync/db.js.map +1 -1
- package/dist/sync/engine.js +24 -6
- package/dist/sync/engine.js.map +1 -1
- package/dist/sync.d.ts +2 -2
- package/dist/sync.js +7 -1
- package/dist/sync.js.map +1 -1
- package/dist/testing/css-variables.js.map +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -3
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/react.js.map +1 -1
- package/dist/testing/viewport.js +7 -4
- package/dist/testing/viewport.js.map +1 -1
- package/dist/theme/contrast-tokens.js +13 -2
- package/dist/theme/contrast-tokens.js.map +1 -1
- package/dist/theme/palette-derive.d.ts +1 -1
- package/dist/theme/palette-derive.js +18 -29
- package/dist/theme/palette-derive.js.map +1 -1
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/theme/palette-store.js.map +1 -1
- package/dist/theme/text-size.js.map +1 -1
- package/dist/theme/theme-store.js +1 -3
- package/dist/theme/theme-store.js.map +1 -1
- package/dist/tour/tour-catalogue.js.map +1 -1
- package/dist/tour/tour-dock.js +5 -1
- package/dist/tour/tour-dock.js.map +1 -1
- package/dist/tour/tour-engine.js +1 -4
- package/dist/tour/tour-engine.js.map +1 -1
- package/dist/tour/tour.js +21 -12
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-progress.js.map +1 -1
- package/dist/tour/use-tour-url-sync.js.map +1 -1
- package/dist/wizard/stepper-nav.js +3 -24
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +24 -28
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/validation.js.map +1 -1
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.d.ts +1 -1
- package/dist/wizard/wizard-step.js +1 -8
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/package.json +5 -2
- package/src/account/account-settings-labels.ts +1 -2
- package/src/account/account-settings.tsx +11 -17
- package/src/account/delete-account-setting.tsx +1 -6
- package/src/account/email-change-setting-labels.ts +1 -2
- package/src/account/email-change-setting.tsx +3 -1
- package/src/account/passkeys-setting.tsx +1 -3
- package/src/account/sessions-setting.tsx +4 -2
- package/src/admin/admin-action-confirm.tsx +4 -7
- package/src/admin/admin-action-log.tsx +29 -29
- package/src/admin/admin-area-labels.ts +4 -1
- package/src/admin/admin-overview.tsx +8 -2
- package/src/admin/admin-sections.tsx +3 -1
- package/src/admin/admin-tool-links.tsx +7 -1
- package/src/admin/invitations-panel.tsx +5 -11
- package/src/admin/mail-test-card.tsx +1 -2
- package/src/admin/reviewer-scope-editor.tsx +4 -9
- package/src/admin/role-select.tsx +4 -2
- package/src/admin/user-roster.tsx +2 -11
- package/src/auth/accept-invitation.tsx +1 -7
- package/src/auth/auth-errors.ts +1 -6
- package/src/auth/forgot-password-form.tsx +2 -7
- package/src/auth/register-form.tsx +7 -8
- package/src/auth/reset-password-form.tsx +2 -5
- package/src/auth/sign-in-form.tsx +15 -14
- package/src/auth/verify-email.tsx +2 -7
- package/src/billing/billing-banner.tsx +3 -7
- package/src/billing/billing-labels.ts +6 -4
- package/src/billing/checkout-processing.ts +0 -1
- package/src/billing/plan-card.tsx +9 -8
- package/src/billing/plan-limit-toast.tsx +1 -2
- package/src/billing/plan-limit.ts +1 -2
- package/src/billing/plan-price.ts +1 -4
- package/src/billing/plan-switch.tsx +1 -2
- package/src/billing/subscription-actions.tsx +5 -2
- package/src/components/account-chips.tsx +1 -7
- package/src/components/alert-banner.tsx +2 -8
- package/src/components/amount-input.tsx +206 -178
- package/src/components/authed-image.tsx +7 -4
- package/src/components/autocomplete.tsx +7 -9
- package/src/components/bulk-action-bar.tsx +2 -7
- package/src/components/button-group.tsx +1 -2
- package/src/components/button-look.ts +6 -17
- package/src/components/button.tsx +1 -4
- package/src/components/calculator.tsx +6 -9
- package/src/components/calendar-heatmap.tsx +5 -10
- package/src/components/card.tsx +9 -13
- package/src/components/chart-zoom-core.tsx +6 -33
- package/src/components/chart.tsx +8 -31
- package/src/components/checkbox-group.tsx +1 -4
- package/src/components/chip-input.tsx +6 -6
- package/src/components/chip.tsx +14 -26
- package/src/components/choice-card.tsx +4 -18
- package/src/components/column-mapper-labels.ts +3 -10
- package/src/components/column-mapper.tsx +33 -122
- package/src/components/combobox-core.tsx +17 -47
- package/src/components/combobox.tsx +2 -6
- package/src/components/confirm-dialog.tsx +1 -3
- package/src/components/copy-button.tsx +2 -4
- package/src/components/country-select.tsx +9 -21
- package/src/components/currency-option-list.tsx +16 -18
- package/src/components/currency-select.tsx +2 -7
- package/src/components/danger-confirm.tsx +8 -9
- package/src/components/data-table-cells.tsx +1 -2
- package/src/components/data-table-column-settings.tsx +1 -3
- package/src/components/data-table-filter-popover.tsx +5 -19
- package/src/components/data-table-filters.ts +1 -6
- package/src/components/data-table-fit.ts +7 -7
- package/src/components/data-table-groups.tsx +3 -1
- package/src/components/data-table-head.tsx +32 -46
- package/src/components/data-table-mobile.tsx +99 -108
- package/src/components/data-table-pagination.tsx +2 -6
- package/src/components/data-table-row-actions.tsx +25 -24
- package/src/components/data-table-row.tsx +16 -17
- package/src/components/data-table-shared.tsx +10 -16
- package/src/components/data-table-sort.ts +1 -2
- package/src/components/data-table-virtual.ts +9 -4
- package/src/components/data-table.tsx +497 -501
- package/src/components/date-picker.tsx +1 -7
- package/src/components/date-range-picker.tsx +3 -13
- package/src/components/description-list.tsx +7 -13
- package/src/components/dialog-frame.tsx +12 -3
- package/src/components/disclosure.tsx +8 -12
- package/src/components/dropdown.tsx +3 -11
- package/src/components/empty-state.tsx +1 -3
- package/src/components/entity-combobox.tsx +2 -6
- package/src/components/error-boundary.tsx +7 -7
- package/src/components/field-base.tsx +8 -14
- package/src/components/field-parts.tsx +12 -23
- package/src/components/field-sync.tsx +7 -11
- package/src/components/file-button.tsx +7 -3
- package/src/components/file-dropzone.tsx +7 -12
- package/src/components/floating-panel.tsx +44 -51
- package/src/components/focus-ring.ts +1 -2
- package/src/components/form-actions.tsx +1 -4
- package/src/components/grouped-picker.tsx +21 -16
- package/src/components/hover-menu.tsx +1 -4
- package/src/components/iban-input-labels.ts +1 -2
- package/src/components/icon-button.tsx +46 -48
- package/src/components/icon-picker.tsx +9 -20
- package/src/components/image-grid.tsx +1 -6
- package/src/components/inline-edit-field.tsx +1 -5
- package/src/components/inline-entity-combobox.tsx +11 -25
- package/src/components/input.tsx +14 -2
- package/src/components/jump-calendar.tsx +2 -6
- package/src/components/legal-labels.ts +8 -3
- package/src/components/legal-page.tsx +56 -6
- package/src/components/legal.tsx +83 -10
- package/src/components/lightbox.tsx +1 -3
- package/src/components/line-items.tsx +7 -15
- package/src/components/list-labels.ts +2 -0
- package/src/components/list.tsx +81 -27
- package/src/components/measured-grid.tsx +8 -30
- package/src/components/menu-item.tsx +1 -3
- package/src/components/mini-calendar.tsx +129 -159
- package/src/components/money-field.tsx +4 -2
- package/src/components/month-picker.tsx +14 -21
- package/src/components/multi-entity-combobox.tsx +6 -26
- package/src/components/multi-select.tsx +5 -9
- package/src/components/nav-pills.tsx +10 -2
- package/src/components/number-field.tsx +18 -15
- package/src/components/number-input.tsx +3 -19
- package/src/components/numpad-sheet.tsx +9 -13
- package/src/components/one-time-code-input.tsx +6 -11
- package/src/components/page-contents.tsx +2 -9
- package/src/components/page-header.tsx +5 -10
- package/src/components/password-strength-labels.ts +1 -2
- package/src/components/password-strength.tsx +6 -9
- package/src/components/phone-input.tsx +2 -13
- package/src/components/picker-sheet.tsx +2 -5
- package/src/components/pie-chart.tsx +6 -12
- package/src/components/popover.tsx +2 -1
- package/src/components/progress-bar.tsx +16 -25
- package/src/components/qr-code.tsx +2 -9
- package/src/components/row-actions.tsx +9 -1
- package/src/components/search-field.tsx +4 -5
- package/src/components/select.tsx +2 -10
- package/src/components/separator.tsx +1 -5
- package/src/components/series-chart-axes.ts +5 -5
- package/src/components/series-chart-budget.ts +1 -4
- package/src/components/series-chart-ticks.ts +1 -4
- package/src/components/series-chart.tsx +43 -83
- package/src/components/settings-fields.tsx +20 -15
- package/src/components/share-card.tsx +6 -14
- package/src/components/sign-chip.tsx +16 -16
- package/src/components/signature-pad.tsx +21 -13
- package/src/components/signed-amount.tsx +6 -14
- package/src/components/skeleton.tsx +2 -13
- package/src/components/slider.tsx +9 -20
- package/src/components/sparkline.tsx +2 -13
- package/src/components/stat-tile.tsx +13 -24
- package/src/components/swatch-picker.tsx +8 -3
- package/src/components/swipeable-row.tsx +2 -11
- package/src/components/table-skeleton.tsx +4 -1
- package/src/components/table.tsx +12 -8
- package/src/components/tabs.tsx +24 -39
- package/src/components/text-link.tsx +6 -6
- package/src/components/text.tsx +1 -2
- package/src/components/tile-radio.tsx +6 -19
- package/src/components/time-input.tsx +4 -5
- package/src/components/toast.tsx +3 -12
- package/src/components/toggle-group.tsx +9 -9
- package/src/components/toggle-legend.tsx +4 -16
- package/src/components/tooltip.tsx +9 -48
- package/src/components/translation-review-editor.tsx +4 -4
- package/src/components/translation-review-parts.tsx +7 -1
- package/src/components/translation-review.tsx +15 -16
- package/src/components/tree-view.tsx +17 -20
- package/src/components/treemap.tsx +3 -15
- package/src/components/trigger-aria.ts +1 -4
- package/src/components/use-table-state.ts +16 -17
- package/src/components/user-avatar.tsx +4 -21
- package/src/demo/demo-start.tsx +8 -4
- package/src/feedback/feedback-attachment-labels.ts +2 -1
- package/src/feedback/feedback-attachment.tsx +16 -29
- package/src/feedback/feedback-capture.ts +3 -1
- package/src/feedback/feedback-comment-thread.tsx +9 -6
- package/src/feedback/feedback-context.tsx +1 -4
- package/src/feedback/feedback-dialog.tsx +3 -4
- package/src/feedback/feedback-inbox.tsx +3 -9
- package/src/feedback/feedback-public-list.tsx +7 -1
- package/src/feedback/feedback-record.ts +6 -14
- package/src/feedback/feedback-replies.tsx +10 -5
- package/src/feedback/feedback-row-detail.tsx +2 -5
- package/src/feedback/feedback-status-undo.ts +1 -4
- package/src/feedback/feedback-submit-dialog.tsx +4 -4
- package/src/feedback/feedback-table.tsx +29 -9
- package/src/feedback/feedback-thread.tsx +4 -16
- package/src/feedback/feedback-vocabulary.ts +72 -82
- package/src/feedback/feedback-vote-button.tsx +9 -8
- package/src/hooks/use-anchored-rect.ts +1 -4
- package/src/hooks/use-document-title.ts +3 -1
- package/src/hooks/use-file-drop.ts +1 -3
- package/src/hooks/use-file-picker-labels.ts +1 -2
- package/src/hooks/use-file-picker.tsx +1 -5
- package/src/hooks/use-focus-trap.ts +1 -2
- package/src/hooks/use-overlay-history.ts +10 -5
- package/src/hooks/use-row-swipe.ts +1 -6
- package/src/i18n/defaults.ts +1 -4
- package/src/i18n/german.ts +42 -50
- package/src/i18n/kit-labels.tsx +5 -20
- package/src/i18n/languages.ts +2 -8
- package/src/i18n/locales/en.ts +15 -31
- package/src/i18n/locales/es.ts +44 -50
- package/src/i18n/locales/fr.ts +43 -52
- package/src/i18n/locales/hu.ts +35 -38
- package/src/i18n/locales/it.ts +44 -53
- package/src/i18n/locales/zh.ts +23 -33
- package/src/i18n/plurals.ts +4 -1
- package/src/i18n/review.ts +20 -7
- package/src/i18n/shared-labels.ts +1 -2
- package/src/index.ts +107 -32
- package/src/landing/access.ts +2 -7
- package/src/landing/landing-sections.tsx +14 -15
- package/src/landing/page-seo.ts +1 -3
- package/src/lib/calc.ts +4 -1
- package/src/lib/clipboard-minus.ts +4 -1
- package/src/lib/dates.ts +2 -10
- package/src/lib/decimal-marks.ts +1 -3
- package/src/lib/device-settings.ts +2 -1
- package/src/lib/direction.ts +1 -3
- package/src/lib/iban.ts +89 -9
- package/src/lib/logger.ts +1 -5
- package/src/lib/person-name.ts +1 -3
- package/src/lib/phone.ts +5 -8
- package/src/lib/qr-encode.ts +32 -8
- package/src/lib/session-refresh.ts +5 -6
- package/src/lib/table-text.ts +24 -6
- package/src/lib/text-match.ts +43 -1
- package/src/lib/translation-review.ts +1 -3
- package/src/notifications/notification-inbox.tsx +17 -4
- package/src/notifications/notification-parts.tsx +1 -2
- package/src/notifications/notification-types-setting.tsx +19 -18
- package/src/notifications/notifications-labels.ts +1 -2
- package/src/notifications/push-device-setting.tsx +7 -1
- package/src/notifications/push-devices-setting.tsx +7 -3
- package/src/notifications/push-worker.ts +10 -2
- package/src/notifications/quiet-hours-setting.tsx +4 -1
- package/src/notifications/use-push-device.ts +4 -1
- package/src/offline/pwa-update-prompt.tsx +7 -1
- package/src/offline/query-persistence.ts +9 -6
- package/src/offline.ts +6 -1
- package/src/rhf/fields.tsx +50 -19
- package/src/rhf/form-core.tsx +2 -6
- package/src/rhf/line-items.tsx +5 -7
- package/src/rhf/use-rhf-wizard-step.ts +3 -12
- package/src/search/command-palette.tsx +15 -23
- package/src/search/global-search.tsx +38 -8
- package/src/search/search-index.ts +5 -1
- package/src/settings/device-reset-setting.tsx +5 -8
- package/src/settings/settings-heading.tsx +3 -1
- package/src/settings/settings-layout.tsx +6 -13
- package/src/settings/use-settings-focus.ts +8 -0
- package/src/settings/use-settings-route.ts +4 -3
- package/src/shell/app-shell.tsx +48 -24
- package/src/shell/auth-layout.tsx +1 -6
- package/src/shell/dev-menu.tsx +18 -6
- package/src/shell/option-switcher-menu.tsx +1 -3
- package/src/shell/sync-status-indicator.tsx +14 -12
- package/src/shell/top-bar-parts-labels.ts +1 -3
- package/src/shell/topbar-action-menu.tsx +130 -45
- package/src/shell/topbar-controls.tsx +5 -19
- package/src/shell/tour-menu.tsx +9 -1
- package/src/shell.ts +1 -1
- package/src/sync/db.ts +5 -1
- package/src/sync/engine.ts +44 -12
- package/src/sync.ts +16 -2
- package/src/testing/css-variables.ts +1 -2
- package/src/testing/index.ts +1 -6
- package/src/testing/react.ts +1 -2
- package/src/testing/viewport.ts +9 -9
- package/src/theme/contrast-tokens.ts +13 -2
- package/src/theme/palette-derive.ts +24 -50
- package/src/theme/palette-presets.ts +0 -1
- package/src/theme/palette-store.ts +1 -4
- package/src/theme/text-size.ts +1 -1
- package/src/theme/theme-store.ts +2 -6
- package/src/tour/tour-catalogue.tsx +4 -2
- package/src/tour/tour-dock.ts +5 -1
- package/src/tour/tour-engine.ts +2 -6
- package/src/tour/tour.tsx +28 -12
- package/src/tour/use-tour-progress.ts +8 -2
- package/src/tour/use-tour-url-sync.ts +3 -1
- package/src/vite-env.d.ts +1 -4
- package/src/wizard/stepper-nav.tsx +10 -35
- package/src/wizard/types.ts +1 -4
- package/src/wizard/use-wizard.ts +28 -43
- package/src/wizard/validation.ts +1 -5
- package/src/wizard/wizard-context.tsx +2 -6
- package/src/wizard/wizard-step.tsx +1 -8
- package/src/wizard/wizard-summary.tsx +2 -6
- package/tokens.css +393 -152
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/feedback/feedback-vocabulary.ts"],"sourcesContent":["import {\n Ban,\n Bug,\n CheckCircle2,\n ClipboardCheck,\n CloudUpload,\n Eye,\n HelpCircle,\n Inbox,\n Lightbulb,\n MoreHorizontal,\n OctagonAlert,\n PauseCircle,\n RotateCcw,\n Wrench,\n} from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\n\n/**\n * The feedback **vocabulary**: the nine statuses and five categories, the glyph and tone\n * of each, their orders, and the policy for how a report moves from status to status.\n *\n * Data and plain functions, no components. Until 0.34 this lived at the top of\n * feedback-inbox.tsx, so the feedback menu every app mounts in its top bar imported the\n * inbox — its components and the attachment field they render — for one constant (a\n * category's glyph), and only tree-shaking kept the rest out of every app's entry chunk.\n * From here the top bar's imports reach none of it (MOD-1, refactor audit 2026-10).\n * feedback-inbox.tsx re-exports every name here, so nothing an app imports has moved.\n */\n\n/** The nine states a report can be in.\n *\n * Shared value for value across the apps on purpose: it is what lets one habit,\n * and one agent prompt, work on any repo. Five are a chain and three sit off\n * it — see {@link visibleFeedbackStatuses} for what that buys. `READY` joined in 0.31\n * (feedback contract §8.2): OPEN is \"filed, not yet triaged\", READY \"released for\n * implementation\", and nobody works an OPEN row. `REWORK` joined in 0.40 (§10.2\n * decisions 2, 4): \"sent back, released for work again\" — a READY that came back. */\nexport type FeedbackStatus =\n | \"OPEN\"\n | \"READY\"\n | \"REWORK\"\n | \"IN_PROGRESS\"\n | \"IN_EVALUATION\"\n | \"NEEDS_LIVE_TEST\"\n | \"POSTPONED\"\n | \"DONE\"\n | \"WONT_DO\";\n\n/** What a report is about. `CRASH` is filed by an error boundary and never\n * chosen, which is exactly why it is loud below and absent from every picker. */\nexport type FeedbackCategory = \"CRASH\" | \"BUG\" | \"IDEA\" | \"QUESTION\" | \"OTHER\";\n\n/**\n * A glyph and a tone per status.\n *\n * Colour alone is not a label — two of these sit a hue apart, and a consumer who\n * moves `--brand` can park it beside any of the others — so every status carries\n * its own shape as well. Declaration order is chain order, and\n * {@link FEEDBACK_STATUS_ORDER} is derived from it rather than restated, because\n * a restated list cannot be checked for exhaustiveness: an eighth status would\n * break the build here and leave a literal eight long (as READY did in 0.31).\n */\nexport const FEEDBACK_STATUS_META: Record<\n FeedbackStatus,\n { icon: LucideIcon; activeBg: string; activeText: string }\n> = {\n OPEN: {\n icon: Inbox,\n activeBg: \"bg-active\",\n activeText: \"text-primary\",\n },\n // Triaged and released for implementation (feedback contract §8.2, keksdose live\n // #396): OPEN is \"filed, nobody has looked yet\", READY is \"go\". The brand tint at full\n // strength, so it reads as the go-ahead beside OPEN's neutral fill and\n // NEEDS_LIVE_TEST's muted brand.\n READY: {\n icon: ClipboardCheck,\n activeBg: \"bg-brand-soft\",\n activeText: \"text-brand\",\n },\n // Sent back by its author or an admin and released for work again (0.40, feedback\n // contract §10.2 decision 4): the Rework button's `RotateCcw`, in the danger tint so the\n // queue shows reworks apart from new reports. Not IN_PROGRESS's warning tint — that is\n // where a REWORK row moves next — and WONT_DO, the only other red, sits at the far end\n // with `Ban`.\n REWORK: {\n icon: RotateCcw,\n activeBg: \"bg-danger-soft\",\n activeText: \"text-danger\",\n },\n IN_PROGRESS: {\n icon: Wrench,\n activeBg: \"bg-warning-soft\",\n activeText: \"text-warning\",\n },\n // Where solved work is parked, waiting on the person who reported it. Its own\n // colour, because \"somebody has to check this\" is a state you want to find by\n // scanning rather than by reading.\n IN_EVALUATION: {\n icon: Eye,\n activeBg: \"bg-info-soft\",\n activeText: \"text-info\",\n },\n // Resolved but only checkable on a deployed build — the SOFT brand tint, so it\n // reads as \"waiting on something\" rather than as a done or a refusal, and it\n // follows whatever the consumer's brand is instead of pinning an indigo island\n // beside it.\n NEEDS_LIVE_TEST: {\n icon: CloudUpload,\n activeBg: \"bg-brand-soft\",\n activeText: \"text-brand-muted\",\n },\n // Parked on purpose. Muted, not `--danger`: it is not a refusal.\n POSTPONED: {\n icon: PauseCircle,\n activeBg: \"bg-active\",\n activeText: \"text-secondary\",\n },\n // The one status with nowhere to move to: the vocabulary has a brand, an info, a\n // warning and a danger, and nothing that means \"succeeded\". Deliberately NOT\n // borrowed from a family that means something else — `--status-synced` is a\n // FIELD's trip to the database rather than an outcome, and tokens.css says so in\n // as many words. Green beside WONT_DO's red is the classic CVD-unsafe pair, which\n // is why both carry a glyph and neither leans on colour alone.\n DONE: {\n icon: CheckCircle2,\n activeBg: \"bg-success-soft\",\n activeText: \"text-success\",\n },\n WONT_DO: {\n icon: Ban,\n activeBg: \"bg-danger-soft\",\n activeText: \"text-danger\",\n },\n};\n\n/** Chain order, then the three off it. The order a picker offers, and the rank a\n * status column sorts by — so a list sorted by status reads as a queue rather\n * than as an alphabet. */\nexport const FEEDBACK_STATUS_ORDER = Object.keys(FEEDBACK_STATUS_META) as FeedbackStatus[];\n\n/** States that sit OFF the linear chain: reachable from anywhere, leading\n * nowhere by themselves. `WONT_DO` has always worked this way; `POSTPONED`\n * (\"not now\") and `NEEDS_LIVE_TEST` (\"resolved, but only provable on a deployed\n * build\") are the same shape — a row can be parked or handed to the next deploy\n * from any point, and comes back to the chain when somebody picks a real state\n * for it.\n *\n * Kept out of the forward chain deliberately: putting them in it would mean an\n * \"advance\" gesture could park an item, and every row would have to pass\n * through them to reach `DONE`. */\nconst OFF_CHAIN: FeedbackStatus[] = [\"NEEDS_LIVE_TEST\", \"POSTPONED\", \"WONT_DO\"];\n\nconst FORWARD_CHAIN: FeedbackStatus[] = [\"OPEN\", \"READY\", \"IN_PROGRESS\", \"IN_EVALUATION\", \"DONE\"];\n\n/** The next status along the chain, or null at the end (or off it, as `WONT_DO`\n * is). What a swipe-to-advance gesture commits. `REWORK` (0.40) is not in the chain —\n * \"advance\" from READY still goes to IN_PROGRESS — but it leads there too: a reworked\n * row is READY's twin. */\nexport function nextFeedbackStatus(current: FeedbackStatus): FeedbackStatus | null {\n if (current === \"REWORK\") return \"IN_PROGRESS\";\n const at = FORWARD_CHAIN.indexOf(current);\n return at !== -1 && at < FORWARD_CHAIN.length - 1 ? FORWARD_CHAIN[at + 1] : null;\n}\n\n/**\n * The steps that make sense from where a row currently stands.\n *\n * For the compact control in a table cell: it is a glanceable triage affordance,\n * and a row of nine icons in a cell is noise. One step forward, one step back\n * (so an item can be sent back for rework), and any of the three off-chain\n * verdicts — which is what makes those usable as verdicts at all.\n *\n * The two parking states are not dead ends: each offers the step that resumes\n * work and the ones that close it, but deliberately **not each other** —\n * \"postponed\" and \"waiting on a deploy\" are different answers to different\n * questions, and a row moving between them directly is a re-triage, which starts\n * by picking the work back up.\n */\nexport function visibleFeedbackStatuses(current: FeedbackStatus): FeedbackStatus[] {\n const set = new Set<FeedbackStatus>([current]);\n const at = FORWARD_CHAIN.indexOf(current);\n if (current === \"REWORK\") {\n // READY's twin (0.40, §10.2 decision 4): the step that picks the work up, and the\n // verdicts a released row can still get.\n set.add(\"IN_PROGRESS\");\n for (const off of OFF_CHAIN) set.add(off);\n } else if (at !== -1) {\n if (at < FORWARD_CHAIN.length - 1) set.add(FORWARD_CHAIN[at + 1]);\n if (at > 0) set.add(FORWARD_CHAIN[at - 1]);\n for (const off of OFF_CHAIN) set.add(off);\n } else if (current !== \"WONT_DO\") {\n set.add(\"IN_PROGRESS\");\n set.add(\"DONE\");\n set.add(\"WONT_DO\");\n }\n // WONT_DO falls through with nothing added: it stays terminal, reopened by a\n // note from its author rather than by a status pill.\n return FEEDBACK_STATUS_ORDER.filter((status) => set.has(status));\n}\n\n/**\n * Every status, in chain order — what an EXPANDED row offers.\n *\n * {@link visibleFeedbackStatuses} narrows the choice to the steps that make\n * sense from where a row is, which is right for a table cell. Once the row is\n * open in front of you, that same narrowing turns every non-adjacent move into a\n * walk: OPEN to DONE meant three round trips, and WONT_DO could not be left at\n * all except through a rework note.\n *\n * Takes a `current` it does not use, so the two policies read as one pair at the\n * call sites and a future rule (\"terminal rows still cannot jump to X\") has a\n * place to live.\n */\nexport function selectableFeedbackStatuses(_current: FeedbackStatus): FeedbackStatus[] {\n return [...FEEDBACK_STATUS_ORDER];\n}\n\n/**\n * Per-category badge treatment, the counterpart to the status meta above.\n *\n * A category cell that is one grey pill holding the raw enum makes a CRASH —\n * filed automatically, by somebody staring at a broken page right now — read\n * exactly like a QUESTION, and sit unnoticed in the queue. `--danger` plus its own\n * glyph is what makes that impossible; the hand-filed categories stay\n * deliberately quiet so that the loud one means something.\n *\n * Declaration order is triage order, CRASH first.\n */\nexport const FEEDBACK_CATEGORY_META: Record<\n FeedbackCategory,\n { icon: LucideIcon; badgeBg: string; badgeText: string }\n> = {\n CRASH: {\n icon: OctagonAlert,\n badgeBg: \"bg-danger-soft\",\n badgeText: \"text-danger\",\n },\n BUG: {\n icon: Bug,\n badgeBg: \"bg-warning-soft\",\n badgeText: \"text-warning\",\n },\n IDEA: {\n icon: Lightbulb,\n badgeBg: \"bg-surface-2\",\n badgeText: \"text-secondary\",\n },\n QUESTION: {\n icon: HelpCircle,\n badgeBg: \"bg-surface-2\",\n badgeText: \"text-secondary\",\n },\n OTHER: {\n icon: MoreHorizontal,\n badgeBg: \"bg-surface-2\",\n badgeText: \"text-secondary\",\n },\n};\n\n/** Triage order, CRASH first — also a category column's sort key and the option\n * order in its filter. Sorting by the raw enum is alphabetical, which parks\n * CRASH between BUG and IDEA. */\nexport const FEEDBACK_CATEGORY_ORDER = Object.keys(FEEDBACK_CATEGORY_META) as FeedbackCategory[];\n\n/** A category's rank for a column sort. Anything this build has never heard of —\n * an older client against a newer API — sorts LAST rather than to `indexOf`'s\n * −1, which would rank it above CRASH. */\nexport function feedbackCategoryRank(category: FeedbackCategory): number {\n const at = FEEDBACK_CATEGORY_ORDER.indexOf(category);\n return at === -1 ? FEEDBACK_CATEGORY_ORDER.length : at;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/feedback/feedback-vocabulary.ts"],"sourcesContent":["import {\n Ban,\n Bug,\n CheckCircle2,\n ClipboardCheck,\n CloudUpload,\n Eye,\n HelpCircle,\n Inbox,\n Lightbulb,\n MoreHorizontal,\n OctagonAlert,\n PauseCircle,\n RotateCcw,\n Wrench,\n} from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\n\n/**\n * The feedback **vocabulary**: the nine statuses and five categories, the glyph and tone\n * of each, their orders, and the policy for how a report moves from status to status.\n *\n * Data and plain functions, no components. Until 0.34 this lived at the top of\n * feedback-inbox.tsx, so the feedback menu every app mounts in its top bar imported the\n * inbox — its components and the attachment field they render — for one constant (a\n * category's glyph), and only tree-shaking kept the rest out of every app's entry chunk.\n * From here the top bar's imports reach none of it (MOD-1, refactor audit 2026-10).\n * feedback-inbox.tsx re-exports every name here, so nothing an app imports has moved.\n */\n\n/** The nine states a report can be in.\n *\n * Shared value for value across the apps on purpose: it is what lets one habit,\n * and one agent prompt, work on any repo. Five are a chain and three sit off\n * it — see {@link visibleFeedbackStatuses} for what that buys. `READY` joined in 0.31\n * (feedback contract §8.2): OPEN is \"filed, not yet triaged\", READY \"released for\n * implementation\", and nobody works an OPEN row. `REWORK` joined in 0.40 (§10.2\n * decisions 2, 4): \"sent back, released for work again\" — a READY that came back. */\nexport type FeedbackStatus =\n \"OPEN\" | \"READY\" | \"REWORK\" | \"IN_PROGRESS\" | \"IN_EVALUATION\" | \"NEEDS_LIVE_TEST\" | \"POSTPONED\" | \"DONE\" | \"WONT_DO\";\n\n/** What a report is about. `CRASH` is filed by an error boundary and never\n * chosen, which is exactly why it is loud below and absent from every picker. */\nexport type FeedbackCategory = \"CRASH\" | \"BUG\" | \"IDEA\" | \"QUESTION\" | \"OTHER\";\n\n/**\n * A glyph and a tone per status.\n *\n * Colour alone is not a label — two of these sit a hue apart, and a consumer who\n * moves `--brand` can park it beside any of the others — so every status carries\n * its own shape as well. Declaration order is chain order, and\n * {@link FEEDBACK_STATUS_ORDER} is derived from it rather than restated, because\n * a restated list cannot be checked for exhaustiveness: an eighth status would\n * break the build here and leave a literal eight long (as READY did in 0.31).\n */\nexport const FEEDBACK_STATUS_META: Record<FeedbackStatus, { icon: LucideIcon; activeBg: string; activeText: string }> =\n {\n OPEN: {\n icon: Inbox,\n activeBg: \"bg-active\",\n activeText: \"text-primary\",\n },\n // Triaged and released for implementation (feedback contract §8.2, keksdose live\n // #396): OPEN is \"filed, nobody has looked yet\", READY is \"go\". The brand tint at full\n // strength, so it reads as the go-ahead beside OPEN's neutral fill and\n // NEEDS_LIVE_TEST's muted brand.\n READY: {\n icon: ClipboardCheck,\n activeBg: \"bg-brand-soft\",\n activeText: \"text-brand\",\n },\n // Sent back by its author or an admin and released for work again (0.40, feedback\n // contract §10.2 decision 4): the Rework button's `RotateCcw`, in the danger tint so the\n // queue shows reworks apart from new reports. Not IN_PROGRESS's warning tint — that is\n // where a REWORK row moves next — and WONT_DO, the only other red, sits at the far end\n // with `Ban`.\n REWORK: {\n icon: RotateCcw,\n activeBg: \"bg-danger-soft\",\n activeText: \"text-danger\",\n },\n IN_PROGRESS: {\n icon: Wrench,\n activeBg: \"bg-warning-soft\",\n activeText: \"text-warning\",\n },\n // Where solved work is parked, waiting on the person who reported it. Its own\n // colour, because \"somebody has to check this\" is a state you want to find by\n // scanning rather than by reading.\n IN_EVALUATION: {\n icon: Eye,\n activeBg: \"bg-info-soft\",\n activeText: \"text-info\",\n },\n // Resolved but only checkable on a deployed build — the SOFT brand tint, so it\n // reads as \"waiting on something\" rather than as a done or a refusal, and it\n // follows whatever the consumer's brand is instead of pinning an indigo island\n // beside it.\n NEEDS_LIVE_TEST: {\n icon: CloudUpload,\n activeBg: \"bg-brand-soft\",\n activeText: \"text-brand-muted\",\n },\n // Parked on purpose. Muted, not `--danger`: it is not a refusal.\n POSTPONED: {\n icon: PauseCircle,\n activeBg: \"bg-active\",\n activeText: \"text-secondary\",\n },\n // The one status with nowhere to move to: the vocabulary has a brand, an info, a\n // warning and a danger, and nothing that means \"succeeded\". Deliberately NOT\n // borrowed from a family that means something else — `--status-synced` is a\n // FIELD's trip to the database rather than an outcome, and tokens.css says so in\n // as many words. Green beside WONT_DO's red is the classic CVD-unsafe pair, which\n // is why both carry a glyph and neither leans on colour alone.\n DONE: {\n icon: CheckCircle2,\n activeBg: \"bg-success-soft\",\n activeText: \"text-success\",\n },\n WONT_DO: {\n icon: Ban,\n activeBg: \"bg-danger-soft\",\n activeText: \"text-danger\",\n },\n };\n\n/** Chain order, then the three off it. The order a picker offers, and the rank a\n * status column sorts by — so a list sorted by status reads as a queue rather\n * than as an alphabet. */\nexport const FEEDBACK_STATUS_ORDER = Object.keys(FEEDBACK_STATUS_META) as FeedbackStatus[];\n\n/** States that sit OFF the linear chain: reachable from anywhere, leading\n * nowhere by themselves. `WONT_DO` has always worked this way; `POSTPONED`\n * (\"not now\") and `NEEDS_LIVE_TEST` (\"resolved, but only provable on a deployed\n * build\") are the same shape — a row can be parked or handed to the next deploy\n * from any point, and comes back to the chain when somebody picks a real state\n * for it.\n *\n * Kept out of the forward chain deliberately: putting them in it would mean an\n * \"advance\" gesture could park an item, and every row would have to pass\n * through them to reach `DONE`. */\nconst OFF_CHAIN: FeedbackStatus[] = [\"NEEDS_LIVE_TEST\", \"POSTPONED\", \"WONT_DO\"];\n\nconst FORWARD_CHAIN: FeedbackStatus[] = [\"OPEN\", \"READY\", \"IN_PROGRESS\", \"IN_EVALUATION\", \"DONE\"];\n\n/** The next status along the chain, or null at the end (or off it, as `WONT_DO`\n * is). What a swipe-to-advance gesture commits. `REWORK` (0.40) is not in the chain —\n * \"advance\" from READY still goes to IN_PROGRESS — but it leads there too: a reworked\n * row is READY's twin. */\nexport function nextFeedbackStatus(current: FeedbackStatus): FeedbackStatus | null {\n if (current === \"REWORK\") return \"IN_PROGRESS\";\n const at = FORWARD_CHAIN.indexOf(current);\n return at !== -1 && at < FORWARD_CHAIN.length - 1 ? FORWARD_CHAIN[at + 1] : null;\n}\n\n/**\n * The steps that make sense from where a row currently stands.\n *\n * For the compact control in a table cell: it is a glanceable triage affordance,\n * and a row of nine icons in a cell is noise. One step forward, one step back\n * (so an item can be sent back for rework), and any of the three off-chain\n * verdicts — which is what makes those usable as verdicts at all.\n *\n * The two parking states are not dead ends: each offers the step that resumes\n * work and the ones that close it, but deliberately **not each other** —\n * \"postponed\" and \"waiting on a deploy\" are different answers to different\n * questions, and a row moving between them directly is a re-triage, which starts\n * by picking the work back up.\n */\nexport function visibleFeedbackStatuses(current: FeedbackStatus): FeedbackStatus[] {\n const set = new Set<FeedbackStatus>([current]);\n const at = FORWARD_CHAIN.indexOf(current);\n if (current === \"REWORK\") {\n // READY's twin (0.40, §10.2 decision 4): the step that picks the work up, and the\n // verdicts a released row can still get.\n set.add(\"IN_PROGRESS\");\n for (const off of OFF_CHAIN) set.add(off);\n } else if (at !== -1) {\n if (at < FORWARD_CHAIN.length - 1) set.add(FORWARD_CHAIN[at + 1]);\n if (at > 0) set.add(FORWARD_CHAIN[at - 1]);\n for (const off of OFF_CHAIN) set.add(off);\n } else if (current !== \"WONT_DO\") {\n set.add(\"IN_PROGRESS\");\n set.add(\"DONE\");\n set.add(\"WONT_DO\");\n }\n // WONT_DO falls through with nothing added: it stays terminal, reopened by a\n // note from its author rather than by a status pill.\n return FEEDBACK_STATUS_ORDER.filter((status) => set.has(status));\n}\n\n/**\n * Every status, in chain order — what an EXPANDED row offers.\n *\n * {@link visibleFeedbackStatuses} narrows the choice to the steps that make\n * sense from where a row is, which is right for a table cell. Once the row is\n * open in front of you, that same narrowing turns every non-adjacent move into a\n * walk: OPEN to DONE meant three round trips, and WONT_DO could not be left at\n * all except through a rework note.\n *\n * Takes a `current` it does not use, so the two policies read as one pair at the\n * call sites and a future rule (\"terminal rows still cannot jump to X\") has a\n * place to live.\n */\nexport function selectableFeedbackStatuses(_current: FeedbackStatus): FeedbackStatus[] {\n return [...FEEDBACK_STATUS_ORDER];\n}\n\n/**\n * Per-category badge treatment, the counterpart to the status meta above.\n *\n * A category cell that is one grey pill holding the raw enum makes a CRASH —\n * filed automatically, by somebody staring at a broken page right now — read\n * exactly like a QUESTION, and sit unnoticed in the queue. `--danger` plus its own\n * glyph is what makes that impossible; the hand-filed categories stay\n * deliberately quiet so that the loud one means something.\n *\n * Declaration order is triage order, CRASH first.\n */\nexport const FEEDBACK_CATEGORY_META: Record<\n FeedbackCategory,\n { icon: LucideIcon; badgeBg: string; badgeText: string }\n> = {\n CRASH: {\n icon: OctagonAlert,\n badgeBg: \"bg-danger-soft\",\n badgeText: \"text-danger\",\n },\n BUG: {\n icon: Bug,\n badgeBg: \"bg-warning-soft\",\n badgeText: \"text-warning\",\n },\n IDEA: {\n icon: Lightbulb,\n badgeBg: \"bg-surface-2\",\n badgeText: \"text-secondary\",\n },\n QUESTION: {\n icon: HelpCircle,\n badgeBg: \"bg-surface-2\",\n badgeText: \"text-secondary\",\n },\n OTHER: {\n icon: MoreHorizontal,\n badgeBg: \"bg-surface-2\",\n badgeText: \"text-secondary\",\n },\n};\n\n/** Triage order, CRASH first — also a category column's sort key and the option\n * order in its filter. Sorting by the raw enum is alphabetical, which parks\n * CRASH between BUG and IDEA. */\nexport const FEEDBACK_CATEGORY_ORDER = Object.keys(FEEDBACK_CATEGORY_META) as FeedbackCategory[];\n\n/** A category's rank for a column sort. Anything this build has never heard of —\n * an older client against a newer API — sorts LAST rather than to `indexOf`'s\n * −1, which would rank it above CRASH. */\nexport function feedbackCategoryRank(category: FeedbackCategory): number {\n const at = FEEDBACK_CATEGORY_ORDER.indexOf(category);\n return at === -1 ? FEEDBACK_CATEGORY_ORDER.length : at;\n}\n"],"mappings":";AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAwCA,MAAM,uBACX;AAAA,EACE,MAAM;AAAA,IACJ,MAAM;AAAA,IACN,UAAU;AAAA,IACV,YAAY;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA,EAKA,OAAO;AAAA,IACL,MAAM;AAAA,IACN,UAAU;AAAA,IACV,YAAY;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,QAAQ;AAAA,IACN,MAAM;AAAA,IACN,UAAU;AAAA,IACV,YAAY;AAAA,EACd;AAAA,EACA,aAAa;AAAA,IACX,MAAM;AAAA,IACN,UAAU;AAAA,IACV,YAAY;AAAA,EACd;AAAA;AAAA;AAAA;AAAA,EAIA,eAAe;AAAA,IACb,MAAM;AAAA,IACN,UAAU;AAAA,IACV,YAAY;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA,EAKA,iBAAiB;AAAA,IACf,MAAM;AAAA,IACN,UAAU;AAAA,IACV,YAAY;AAAA,EACd;AAAA;AAAA,EAEA,WAAW;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,YAAY;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM;AAAA,IACJ,MAAM;AAAA,IACN,UAAU;AAAA,IACV,YAAY;AAAA,EACd;AAAA,EACA,SAAS;AAAA,IACP,MAAM;AAAA,IACN,UAAU;AAAA,IACV,YAAY;AAAA,EACd;AACF;AAKK,MAAM,wBAAwB,OAAO,KAAK,oBAAoB;AAYrE,MAAM,YAA8B,CAAC,mBAAmB,aAAa,SAAS;AAE9E,MAAM,gBAAkC,CAAC,QAAQ,SAAS,eAAe,iBAAiB,MAAM;AAMzF,SAAS,mBAAmB,SAAgD;AACjF,MAAI,YAAY,SAAU,QAAO;AACjC,QAAM,KAAK,cAAc,QAAQ,OAAO;AACxC,SAAO,OAAO,MAAM,KAAK,cAAc,SAAS,IAAI,cAAc,KAAK,CAAC,IAAI;AAC9E;AAgBO,SAAS,wBAAwB,SAA2C;AACjF,QAAM,MAAM,oBAAI,IAAoB,CAAC,OAAO,CAAC;AAC7C,QAAM,KAAK,cAAc,QAAQ,OAAO;AACxC,MAAI,YAAY,UAAU;AAGxB,QAAI,IAAI,aAAa;AACrB,eAAW,OAAO,UAAW,KAAI,IAAI,GAAG;AAAA,EAC1C,WAAW,OAAO,IAAI;AACpB,QAAI,KAAK,cAAc,SAAS,EAAG,KAAI,IAAI,cAAc,KAAK,CAAC,CAAC;AAChE,QAAI,KAAK,EAAG,KAAI,IAAI,cAAc,KAAK,CAAC,CAAC;AACzC,eAAW,OAAO,UAAW,KAAI,IAAI,GAAG;AAAA,EAC1C,WAAW,YAAY,WAAW;AAChC,QAAI,IAAI,aAAa;AACrB,QAAI,IAAI,MAAM;AACd,QAAI,IAAI,SAAS;AAAA,EACnB;AAGA,SAAO,sBAAsB,OAAO,CAAC,WAAW,IAAI,IAAI,MAAM,CAAC;AACjE;AAeO,SAAS,2BAA2B,UAA4C;AACrF,SAAO,CAAC,GAAG,qBAAqB;AAClC;AAaO,MAAM,yBAGT;AAAA,EACF,OAAO;AAAA,IACL,MAAM;AAAA,IACN,SAAS;AAAA,IACT,WAAW;AAAA,EACb;AAAA,EACA,KAAK;AAAA,IACH,MAAM;AAAA,IACN,SAAS;AAAA,IACT,WAAW;AAAA,EACb;AAAA,EACA,MAAM;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,WAAW;AAAA,EACb;AAAA,EACA,UAAU;AAAA,IACR,MAAM;AAAA,IACN,SAAS;AAAA,IACT,WAAW;AAAA,EACb;AAAA,EACA,OAAO;AAAA,IACL,MAAM;AAAA,IACN,SAAS;AAAA,IACT,WAAW;AAAA,EACb;AACF;AAKO,MAAM,0BAA0B,OAAO,KAAK,sBAAsB;AAKlE,SAAS,qBAAqB,UAAoC;AACvE,QAAM,KAAK,wBAAwB,QAAQ,QAAQ;AACnD,SAAO,OAAO,KAAK,wBAAwB,SAAS;AACtD;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/feedback/feedback-vote-button.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport { ThumbsUp } from \"lucide-react\";\nimport { Button } from \"../components/button\";\nimport { Tooltip } from \"../components/tooltip\";\nimport type { CommitScope } from \"../components/write-lock\";\nimport { useKitLabels, useKitLocale, type LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { formatNumber } from \"../lib/format\";\nimport type { FeedbackVoteState } from \"./feedback-public-types\";\nimport { DEFAULT_FEEDBACK_VOTE_LABELS } from \"./feedback-thread-parts-labels\";\n\n/**\n * \"I want this too\" — the one rating a report has (docs/feedback-harmonization.md §9.6,\n * decision 2): an upvote, one per person, a count. No down-vote, no stars.\n *\n * - **A toggle** (`aria-pressed`): the thumb, the count, and \"I want this too\" as its\n * name — written out at `size=\"md\"`, the Tooltip and the accessible name at `\"sm\"`\n * (a list row's cell).\n * - **Optimistic with rollback:** the press shows the new state at once, `onVote(next)`\n * sends `PUT` (`true`) or `DELETE` (`false`) and its answer — the server's count —\n * replaces the guess; a rejection puts back what was there. The error is the app's\n * to toast (its mutation's `onError`), as with every kit part that takes a promise —\n * the server's `detail`, else `feedbackToast.voteFailed` (0.38.1).\n * - **The author's own report** counts as their vote (§9.6, decision 20): `own` shows it\n * pressed and `aria-disabled` with \"Your report\" as the reason (the server would\n * answer 409).\n * - A new `votes` / `voted` from the app (a refetch) wins over the last answer.\n */\n\n/** The `feedbackVote` namespace of `<UiKitProvider labels>`. */\nexport interface FeedbackVoteLabels {\n /** The button's name. de-CH: \"Will ich auch\" */\n vote: string;\n /** Why the author's own button is disabled. de-CH: \"Ihr Bericht\" */\n own: string;\n}\n\nexport { DEFAULT_FEEDBACK_VOTE_LABELS };\n\nexport interface FeedbackVoteButtonProps {\n /** People who want this — the author's own vote included. */\n votes: number;\n /** The viewer has voted. */\n voted: boolean;\n /** The viewer filed it: pressed, disabled, \"Your report\". */\n own?: boolean;\n /** `PUT` (`next` true) or `DELETE` (`false`) `/feedback/{id}/vote`; resolve with the\n * answer, reject to roll back. */\n onVote: (next: boolean) => Promise<FeedbackVoteState>;\n /** Not now (e.g. a demo): plain `disabled`. A string is the reason, as Button's. */\n disabled?: boolean | string;\n /** The vote COMMITS — see Button's `commit`. */\n commit?: CommitScope;\n /** `\"sm\"`: the count alone, for a table cell. Default `\"md\"`. */\n size?: \"sm\" | \"md\";\n labels?: LabelOverride<FeedbackVoteLabels>;\n className?: string;\n}\n\nexport function FeedbackVoteButton({\n votes,\n voted,\n own = false,\n onVote,\n disabled = false,\n commit,\n size = \"md\",\n labels: labelsProp,\n className,\n}: FeedbackVoteButtonProps) {\n const labels = useKitLabels(\"feedbackVote\", DEFAULT_FEEDBACK_VOTE_LABELS, labelsProp);\n const locale = useKitLocale();\n // The last press's state, valid only while the app's props are still the ones it was\n // pressed against: a refetch that brings new numbers is the truth.\n const [shown, setShown] = useState<{ base: FeedbackVoteState; state: FeedbackVoteState } | null>(null);\n const busy = useRef(false);\n const current
|
|
1
|
+
{"version":3,"sources":["../../src/feedback/feedback-vote-button.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport { ThumbsUp } from \"lucide-react\";\nimport { Button } from \"../components/button\";\nimport { Tooltip } from \"../components/tooltip\";\nimport type { CommitScope } from \"../components/write-lock\";\nimport { useKitLabels, useKitLocale, type LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { formatNumber } from \"../lib/format\";\nimport type { FeedbackVoteState } from \"./feedback-public-types\";\nimport { DEFAULT_FEEDBACK_VOTE_LABELS } from \"./feedback-thread-parts-labels\";\n\n/**\n * \"I want this too\" — the one rating a report has (docs/feedback-harmonization.md §9.6,\n * decision 2): an upvote, one per person, a count. No down-vote, no stars.\n *\n * - **A toggle** (`aria-pressed`): the thumb, the count, and \"I want this too\" as its\n * name — written out at `size=\"md\"`, the Tooltip and the accessible name at `\"sm\"`\n * (a list row's cell).\n * - **Optimistic with rollback:** the press shows the new state at once, `onVote(next)`\n * sends `PUT` (`true`) or `DELETE` (`false`) and its answer — the server's count —\n * replaces the guess; a rejection puts back what was there. The error is the app's\n * to toast (its mutation's `onError`), as with every kit part that takes a promise —\n * the server's `detail`, else `feedbackToast.voteFailed` (0.38.1).\n * - **The author's own report** counts as their vote (§9.6, decision 20): `own` shows it\n * pressed and `aria-disabled` with \"Your report\" as the reason (the server would\n * answer 409).\n * - A new `votes` / `voted` from the app (a refetch) wins over the last answer.\n */\n\n/** The `feedbackVote` namespace of `<UiKitProvider labels>`. */\nexport interface FeedbackVoteLabels {\n /** The button's name. de-CH: \"Will ich auch\" */\n vote: string;\n /** Why the author's own button is disabled. de-CH: \"Ihr Bericht\" */\n own: string;\n}\n\nexport { DEFAULT_FEEDBACK_VOTE_LABELS };\n\nexport interface FeedbackVoteButtonProps {\n /** People who want this — the author's own vote included. */\n votes: number;\n /** The viewer has voted. */\n voted: boolean;\n /** The viewer filed it: pressed, disabled, \"Your report\". */\n own?: boolean;\n /** `PUT` (`next` true) or `DELETE` (`false`) `/feedback/{id}/vote`; resolve with the\n * answer, reject to roll back. */\n onVote: (next: boolean) => Promise<FeedbackVoteState>;\n /** Not now (e.g. a demo): plain `disabled`. A string is the reason, as Button's. */\n disabled?: boolean | string;\n /** The vote COMMITS — see Button's `commit`. */\n commit?: CommitScope;\n /** `\"sm\"`: the count alone, for a table cell. Default `\"md\"`. */\n size?: \"sm\" | \"md\";\n labels?: LabelOverride<FeedbackVoteLabels>;\n className?: string;\n}\n\nexport function FeedbackVoteButton({\n votes,\n voted,\n own = false,\n onVote,\n disabled = false,\n commit,\n size = \"md\",\n labels: labelsProp,\n className,\n}: FeedbackVoteButtonProps) {\n const labels = useKitLabels(\"feedbackVote\", DEFAULT_FEEDBACK_VOTE_LABELS, labelsProp);\n const locale = useKitLocale();\n // The last press's state, valid only while the app's props are still the ones it was\n // pressed against: a refetch that brings new numbers is the truth.\n const [shown, setShown] = useState<{ base: FeedbackVoteState; state: FeedbackVoteState } | null>(null);\n const busy = useRef(false);\n const current = shown && shown.base.votes === votes && shown.base.voted === voted ? shown.state : { votes, voted };\n const pressed = own || current.voted;\n\n const press = () => {\n if (own || disabled || busy.current) return;\n const base = { votes, voted };\n const before = current;\n const next = !before.voted;\n busy.current = true;\n setShown({ base, state: { votes: Math.max(0, before.votes + (next ? 1 : -1)), voted: next } });\n onVote(next)\n .then(\n (answer) => setShown({ base, state: { votes: answer.votes, voted: answer.voted } }),\n () => setShown({ base, state: before }),\n )\n .finally(() => {\n busy.current = false;\n });\n };\n\n const count = formatNumber(current.votes, { locale });\n const button = (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n pressed={pressed}\n commit={own ? undefined : commit}\n disabled={disabled === true}\n disabledReason={own ? labels.own : typeof disabled === \"string\" ? disabled : undefined}\n onClick={(event) => {\n // A row around it may expand on a click.\n event.stopPropagation();\n press();\n }}\n data-feedback-vote=\"\"\n className={cn(\"gap-1.5\", className)}\n >\n <ThumbsUp className=\"size-3.5 shrink-0\" aria-hidden />\n <span className={size === \"sm\" ? \"sr-only\" : undefined}>{labels.vote}</span>\n <span className=\"tabular-nums\" data-feedback-votes=\"\">\n {count}\n </span>\n </Button>\n );\n // At \"sm\" the name is not on screen: the Tooltip shows it (the own reason has its own).\n return size === \"sm\" && !own ? <Tooltip label={labels.vote}>{button}</Tooltip> : button;\n}\n"],"mappings":";AAkGI,SAgBE,KAhBF;AAlGJ,SAAS,QAAQ,gBAAgB;AACjC,SAAS,gBAAgB;AACzB,SAAS,cAAc;AACvB,SAAS,eAAe;AAExB,SAAS,cAAc,oBAAwC;AAC/D,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAE7B,SAAS,oCAAoC;AAkDtC,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN;AAAA,EACA,WAAW;AAAA,EACX;AAAA,EACA,OAAO;AAAA,EACP,QAAQ;AAAA,EACR;AACF,GAA4B;AAC1B,QAAM,SAAS,aAAa,gBAAgB,8BAA8B,UAAU;AACpF,QAAM,SAAS,aAAa;AAG5B,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAuE,IAAI;AACrG,QAAM,OAAO,OAAO,KAAK;AACzB,QAAM,UAAU,SAAS,MAAM,KAAK,UAAU,SAAS,MAAM,KAAK,UAAU,QAAQ,MAAM,QAAQ,EAAE,OAAO,MAAM;AACjH,QAAM,UAAU,OAAO,QAAQ;AAE/B,QAAM,QAAQ,MAAM;AAClB,QAAI,OAAO,YAAY,KAAK,QAAS;AACrC,UAAM,OAAO,EAAE,OAAO,MAAM;AAC5B,UAAM,SAAS;AACf,UAAM,OAAO,CAAC,OAAO;AACrB,SAAK,UAAU;AACf,aAAS,EAAE,MAAM,OAAO,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,SAAS,OAAO,IAAI,GAAG,GAAG,OAAO,KAAK,EAAE,CAAC;AAC7F,WAAO,IAAI,EACR;AAAA,MACC,CAAC,WAAW,SAAS,EAAE,MAAM,OAAO,EAAE,OAAO,OAAO,OAAO,OAAO,OAAO,MAAM,EAAE,CAAC;AAAA,MAClF,MAAM,SAAS,EAAE,MAAM,OAAO,OAAO,CAAC;AAAA,IACxC,EACC,QAAQ,MAAM;AACb,WAAK,UAAU;AAAA,IACjB,CAAC;AAAA,EACL;AAEA,QAAM,QAAQ,aAAa,QAAQ,OAAO,EAAE,OAAO,CAAC;AACpD,QAAM,SACJ;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,SAAQ;AAAA,MACR,MAAK;AAAA,MACL;AAAA,MACA,QAAQ,MAAM,SAAY;AAAA,MAC1B,UAAU,aAAa;AAAA,MACvB,gBAAgB,MAAM,OAAO,MAAM,OAAO,aAAa,WAAW,WAAW;AAAA,MAC7E,SAAS,CAAC,UAAU;AAElB,cAAM,gBAAgB;AACtB,cAAM;AAAA,MACR;AAAA,MACA,sBAAmB;AAAA,MACnB,WAAW,GAAG,WAAW,SAAS;AAAA,MAElC;AAAA,4BAAC,YAAS,WAAU,qBAAoB,eAAW,MAAC;AAAA,QACpD,oBAAC,UAAK,WAAW,SAAS,OAAO,YAAY,QAAY,iBAAO,MAAK;AAAA,QACrE,oBAAC,UAAK,WAAU,gBAAe,uBAAoB,IAChD,iBACH;AAAA;AAAA;AAAA,EACF;AAGF,SAAO,SAAS,QAAQ,CAAC,MAAM,oBAAC,WAAQ,OAAO,OAAO,MAAO,kBAAO,IAAa;AACnF;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-anchored-rect.ts"],"sourcesContent":["import { useLayoutEffect, useState } from \"react\";\nimport type { RefObject } from \"react\";\n\n/** The subset of an element's viewport rect used for anchored positioning. */\nexport interface AnchorRect {\n top: number;\n left: number;\n right: number;\n bottom: number;\n width: number;\n height: number;\n}\n\n/**\n * Whether two measurements are the same box, so a re-measure that moved nothing keeps the\n * state it had (no render). Top, left, width and height decide it: an `AnchorRect`'s\n * right and bottom are those sums. The kit's one copy — the tour's spotlight asks it too\n * (DUP-11, refactor audit 2026-10).\n */\nexport function sameRect<R extends { top: number; left: number; width: number; height: number }>(\n a: R | null,\n b: R | null,\n): boolean {\n if (a === b) return true;\n if (!a || !b) return false;\n return a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height;\n}\n\n/**\n * Tracks an anchor element's viewport rect while `open`, re-measuring on scroll\n * (capture phase, so nested scroll containers are caught too) and resize, and\n * returning null when closed or unmeasured. Callers derive their own panel\n * position from the rect — this hook owns only the measure + listener lifecycle\n * that every portalled, position:fixed popover/flyout otherwise re-implements.\n *\n * Measured in a layout effect so the position is ready before paint (no flicker\n * on open).\n */\nexport function useAnchoredRect<T extends HTMLElement>(
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-anchored-rect.ts"],"sourcesContent":["import { useLayoutEffect, useState } from \"react\";\nimport type { RefObject } from \"react\";\n\n/** The subset of an element's viewport rect used for anchored positioning. */\nexport interface AnchorRect {\n top: number;\n left: number;\n right: number;\n bottom: number;\n width: number;\n height: number;\n}\n\n/**\n * Whether two measurements are the same box, so a re-measure that moved nothing keeps the\n * state it had (no render). Top, left, width and height decide it: an `AnchorRect`'s\n * right and bottom are those sums. The kit's one copy — the tour's spotlight asks it too\n * (DUP-11, refactor audit 2026-10).\n */\nexport function sameRect<R extends { top: number; left: number; width: number; height: number }>(\n a: R | null,\n b: R | null,\n): boolean {\n if (a === b) return true;\n if (!a || !b) return false;\n return a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height;\n}\n\n/**\n * Tracks an anchor element's viewport rect while `open`, re-measuring on scroll\n * (capture phase, so nested scroll containers are caught too) and resize, and\n * returning null when closed or unmeasured. Callers derive their own panel\n * position from the rect — this hook owns only the measure + listener lifecycle\n * that every portalled, position:fixed popover/flyout otherwise re-implements.\n *\n * Measured in a layout effect so the position is ready before paint (no flicker\n * on open).\n */\nexport function useAnchoredRect<T extends HTMLElement>(ref: RefObject<T | null>, open: boolean): AnchorRect | null {\n const [rect, setRect] = useState<AnchorRect | null>(null);\n\n useLayoutEffect(() => {\n if (!open) {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- reset when closed\n setRect(null);\n return;\n }\n const measure = () => {\n const r = ref.current?.getBoundingClientRect();\n if (!r) return;\n const next = {\n top: r.top,\n left: r.left,\n right: r.right,\n bottom: r.bottom,\n width: r.width,\n height: r.height,\n };\n // Only publish a rect that actually MOVED. Every measurement allocates a fresh\n // object and React compares state by identity, so an unguarded `setRect` here\n // re-rendered the anchored panel on every scroll and resize event whether or not\n // the anchor had moved — and this listens on the capture phase, so that is every\n // ancestor scroll container, roughly per frame through a momentum scroll.\n // `tour.tsx` already does exactly this on the same problem.\n setRect((prev) => (sameRect(prev, next) ? prev : next));\n };\n measure();\n window.addEventListener(\"resize\", measure);\n window.addEventListener(\"scroll\", measure, true);\n return () => {\n window.removeEventListener(\"resize\", measure);\n window.removeEventListener(\"scroll\", measure, true);\n };\n }, [open, ref]);\n\n return rect;\n}\n"],"mappings":";AAAA,SAAS,iBAAiB,gBAAgB;AAmBnC,SAAS,SACd,GACA,GACS;AACT,MAAI,MAAM,EAAG,QAAO;AACpB,MAAI,CAAC,KAAK,CAAC,EAAG,QAAO;AACrB,SAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,WAAW,EAAE;AACvF;AAYO,SAAS,gBAAuC,KAA0B,MAAkC;AACjH,QAAM,CAAC,MAAM,OAAO,IAAI,SAA4B,IAAI;AAExD,kBAAgB,MAAM;AACpB,QAAI,CAAC,MAAM;AAET,cAAQ,IAAI;AACZ;AAAA,IACF;AACA,UAAM,UAAU,MAAM;AACpB,YAAM,IAAI,IAAI,SAAS,sBAAsB;AAC7C,UAAI,CAAC,EAAG;AACR,YAAM,OAAO;AAAA,QACX,KAAK,EAAE;AAAA,QACP,MAAM,EAAE;AAAA,QACR,OAAO,EAAE;AAAA,QACT,QAAQ,EAAE;AAAA,QACV,OAAO,EAAE;AAAA,QACT,QAAQ,EAAE;AAAA,MACZ;AAOA,cAAQ,CAAC,SAAU,SAAS,MAAM,IAAI,IAAI,OAAO,IAAK;AAAA,IACxD;AACA,YAAQ;AACR,WAAO,iBAAiB,UAAU,OAAO;AACzC,WAAO,iBAAiB,UAAU,SAAS,IAAI;AAC/C,WAAO,MAAM;AACX,aAAO,oBAAoB,UAAU,OAAO;AAC5C,aAAO,oBAAoB,UAAU,SAAS,IAAI;AAAA,IACpD;AAAA,EACF,GAAG,CAAC,MAAM,GAAG,CAAC;AAEd,SAAO;AACT;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-document-title.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport { useKitDocumentTitle } from \"../i18n/kit-labels\";\nimport { DEFAULT_DOCUMENT_TITLE_FORMAT, formatDocumentTitle } from \"../lib/document-title\";\n\n/**\n * Writes the window's title (`document.title`) while the calling component is mounted,\n * and puts back the title it found when it unmounts (0.49 KIT-9).\n *\n * `parts` are positional: `[page, section, app]`. The app falls back to the provider's\n * `appName`, the format to the provider's `documentTitleFormat` (default\n * \"{page} · {section} · {app}\"); a missing part drops with its separator\n * ({@link formatDocumentTitle}). Parts that are ALL empty write nothing — the provider's\n * app alone is not a page's title.\n *\n * `PageHeader` calls it for its `h1` (`documentTitle`); call it yourself for a page\n * without one.\n */\nexport function useDocumentTitle(parts: ReadonlyArray<string | null | undefined | false>): void {\n const { format, appName } = useKitDocumentTitle();\n const [page, section, app] = parts.map((p) => (typeof p === \"string\" && p.trim() !== \"\" ? p : undefined));\n const any = parts.some((p) => typeof p === \"string\" && p.trim() !== \"\");\n const title = any
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-document-title.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport { useKitDocumentTitle } from \"../i18n/kit-labels\";\nimport { DEFAULT_DOCUMENT_TITLE_FORMAT, formatDocumentTitle } from \"../lib/document-title\";\n\n/**\n * Writes the window's title (`document.title`) while the calling component is mounted,\n * and puts back the title it found when it unmounts (0.49 KIT-9).\n *\n * `parts` are positional: `[page, section, app]`. The app falls back to the provider's\n * `appName`, the format to the provider's `documentTitleFormat` (default\n * \"{page} · {section} · {app}\"); a missing part drops with its separator\n * ({@link formatDocumentTitle}). Parts that are ALL empty write nothing — the provider's\n * app alone is not a page's title.\n *\n * `PageHeader` calls it for its `h1` (`documentTitle`); call it yourself for a page\n * without one.\n */\nexport function useDocumentTitle(parts: ReadonlyArray<string | null | undefined | false>): void {\n const { format, appName } = useKitDocumentTitle();\n const [page, section, app] = parts.map((p) => (typeof p === \"string\" && p.trim() !== \"\" ? p : undefined));\n const any = parts.some((p) => typeof p === \"string\" && p.trim() !== \"\");\n const title = any\n ? formatDocumentTitle(format ?? DEFAULT_DOCUMENT_TITLE_FORMAT, { page, section, app: app ?? appName })\n : null;\n useEffect(() => {\n if (title === null || title === \"\") return;\n const before = document.title;\n document.title = title;\n return () => {\n document.title = before;\n };\n }, [title]);\n}\n"],"mappings":";AAAA,SAAS,iBAAiB;AAC1B,SAAS,2BAA2B;AACpC,SAAS,+BAA+B,2BAA2B;AAe5D,SAAS,iBAAiB,OAA+D;AAC9F,QAAM,EAAE,QAAQ,QAAQ,IAAI,oBAAoB;AAChD,QAAM,CAAC,MAAM,SAAS,GAAG,IAAI,MAAM,IAAI,CAAC,MAAO,OAAO,MAAM,YAAY,EAAE,KAAK,MAAM,KAAK,IAAI,MAAU;AACxG,QAAM,MAAM,MAAM,KAAK,CAAC,MAAM,OAAO,MAAM,YAAY,EAAE,KAAK,MAAM,EAAE;AACtE,QAAM,QAAQ,MACV,oBAAoB,UAAU,+BAA+B,EAAE,MAAM,SAAS,KAAK,OAAO,QAAQ,CAAC,IACnG;AACJ,YAAU,MAAM;AACd,QAAI,UAAU,QAAQ,UAAU,GAAI;AACpC,UAAM,SAAS,SAAS;AACxB,aAAS,QAAQ;AACjB,WAAO,MAAM;AACX,eAAS,QAAQ;AAAA,IACnB;AAAA,EACF,GAAG,CAAC,KAAK,CAAC;AACZ;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-file-drop.ts"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport type { DragEvent, ReactElement } from \"react\";\nimport { useFilePicker } from \"./use-file-picker\";\nimport type { UseFilePickerOptions } from \"./use-file-picker\";\n\n/**\n * Turn ANY element into a file drop target — kastlan's ask, for the document list of\n * a booking and the rows of its gantt, which are drop targets without being a\n * `FileDropzone` (a dashed box with a Browse button inside would be the wrong thing\n * to draw over a list).\n *\n * ```tsx\n * const { dropProps, isOver, element } = useFileDrop({ accept: \".pdf\", multiple: true,\n * maxSize: 10_000_000, onFiles: attach, onReject: ([r]) => setError(r.message) });\n * return <>{element}<ul {...dropProps} data-drop-over={isOver || undefined}>…</ul></>;\n * ```\n *\n * The screening is `useFilePicker`'s own — `accept` re-checked (a drop ignores the\n * dialog's filter), `maxSize`, `maxFiles`, `isValid`, `onPick` — so a file refused by\n * `FileButton` is refused here in the same words. It comes with `open()` as well, so\n * the same options can also back a \"choose a file\" menu item beside the drop target.\n */\n\n/** `useFilePicker`'s options, minus `capture` (a camera does not take drops). */\nexport type UseFileDropOptions = Omit<UseFilePickerOptions, \"capture\">;\n\n/** Spread onto the drop target. Four handlers, nothing else: the element keeps its\n * own role, name and focus behaviour. */\nexport interface FileDropProps<E extends Element = Element> {\n onDragEnter: (e: DragEvent<E>) => void;\n onDragOver: (e: DragEvent<E>) => void;\n onDragLeave: (e: DragEvent<E>) => void;\n onDrop: (e: DragEvent<E>) => void;\n}\n\nexport interface UseFileDropReturn<E extends Element = Element> {\n dropProps: FileDropProps<E>;\n /** A FILE drag is over the target (and it is not `disabled`). Style from this. */\n isOver: boolean;\n /** Open the system picker with the same options — from a click handler only. */\n open: () => void;\n /** Screen and deliver files that arrived another way (a paste). */\n take: (files: ArrayLike<File> | null | undefined) => void;\n /** The live region that speaks a refusal (and the picker's hidden input). Render it\n * once, anywhere; without it a refusal reaches `onReject` but is not announced. */\n element: ReactElement;\n}\n\n/** Whether a drag carries files — a dragged link, text or a row of the page does not,\n * and must not light the target up or be swallowed by it. */\nexport function dragHasFiles(e: DragEvent): boolean {\n return Array.from(e.dataTransfer?.types ?? []).includes(\"Files\");\n}\n\n/**\n * @internal The drag half on its own, shared with `FileDropzone`.\n *\n * `dragenter`/`dragleave` fire for every CHILD the pointer crosses: moving from the\n * target onto a label inside it is a leave of the target followed by an enter of the\n * label, which bubbles. A boolean flipped by each one flickers off and on across\n * every child, and `relatedTarget` — the usual fix — is `null` in Safari's drag\n * events. So this counts: +1 per enter, -1 per leave, over while above zero, and a\n * drop resets it (the drop replaces the leave that would have balanced the enter).\n *\n * A file drag stops propagating here, so a target nested in another (a row inside a\n * page-wide drop area) takes its own drop, and the outer one's count falls back to\n * zero while the pointer is over the inner one — only one of them lights up.\n */\nexport function useDragTarget<E extends Element = Element>({\n disabled,\n onDropFiles,\n}: {\n disabled?: boolean;\n onDropFiles: (files: FileList | File[]) => void;\n}): { dropProps: FileDropProps<E>; isOver: boolean } {\n const depth = useRef(0);\n const [over, setOver] = useState(false);\n\n const dropProps: FileDropProps<E> = {\n onDragEnter: (e) => {\n if (!dragHasFiles(e)) return;\n e.stopPropagation();\n e.preventDefault();\n depth.current += 1;\n if (!disabled) setOver(true);\n },\n onDragOver: (e) => {\n if (!dragHasFiles(e)) return;\n e.stopPropagation();\n // Always cancelled for a file drag, disabled or not: an uncancelled drop makes\n // the browser NAVIGATE to the file, which loses the page.\n e.preventDefault();\n e.dataTransfer.dropEffect = disabled ? \"none\" : \"copy\";\n },\n onDragLeave: (e) => {\n if (!dragHasFiles(e)) return;\n e.stopPropagation();\n depth.current = Math.max(0, depth.current - 1);\n if (depth.current === 0) setOver(false);\n },\n onDrop: (e) => {\n if (!dragHasFiles(e)) return;\n e.stopPropagation();\n e.preventDefault();\n depth.current = 0;\n setOver(false);\n if (!disabled) onDropFiles(e.dataTransfer.files);\n },\n };\n\n return { dropProps, isOver: over && !disabled };\n}\n\nexport function useFileDrop<E extends Element = Element>(
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-file-drop.ts"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport type { DragEvent, ReactElement } from \"react\";\nimport { useFilePicker } from \"./use-file-picker\";\nimport type { UseFilePickerOptions } from \"./use-file-picker\";\n\n/**\n * Turn ANY element into a file drop target — kastlan's ask, for the document list of\n * a booking and the rows of its gantt, which are drop targets without being a\n * `FileDropzone` (a dashed box with a Browse button inside would be the wrong thing\n * to draw over a list).\n *\n * ```tsx\n * const { dropProps, isOver, element } = useFileDrop({ accept: \".pdf\", multiple: true,\n * maxSize: 10_000_000, onFiles: attach, onReject: ([r]) => setError(r.message) });\n * return <>{element}<ul {...dropProps} data-drop-over={isOver || undefined}>…</ul></>;\n * ```\n *\n * The screening is `useFilePicker`'s own — `accept` re-checked (a drop ignores the\n * dialog's filter), `maxSize`, `maxFiles`, `isValid`, `onPick` — so a file refused by\n * `FileButton` is refused here in the same words. It comes with `open()` as well, so\n * the same options can also back a \"choose a file\" menu item beside the drop target.\n */\n\n/** `useFilePicker`'s options, minus `capture` (a camera does not take drops). */\nexport type UseFileDropOptions = Omit<UseFilePickerOptions, \"capture\">;\n\n/** Spread onto the drop target. Four handlers, nothing else: the element keeps its\n * own role, name and focus behaviour. */\nexport interface FileDropProps<E extends Element = Element> {\n onDragEnter: (e: DragEvent<E>) => void;\n onDragOver: (e: DragEvent<E>) => void;\n onDragLeave: (e: DragEvent<E>) => void;\n onDrop: (e: DragEvent<E>) => void;\n}\n\nexport interface UseFileDropReturn<E extends Element = Element> {\n dropProps: FileDropProps<E>;\n /** A FILE drag is over the target (and it is not `disabled`). Style from this. */\n isOver: boolean;\n /** Open the system picker with the same options — from a click handler only. */\n open: () => void;\n /** Screen and deliver files that arrived another way (a paste). */\n take: (files: ArrayLike<File> | null | undefined) => void;\n /** The live region that speaks a refusal (and the picker's hidden input). Render it\n * once, anywhere; without it a refusal reaches `onReject` but is not announced. */\n element: ReactElement;\n}\n\n/** Whether a drag carries files — a dragged link, text or a row of the page does not,\n * and must not light the target up or be swallowed by it. */\nexport function dragHasFiles(e: DragEvent): boolean {\n return Array.from(e.dataTransfer?.types ?? []).includes(\"Files\");\n}\n\n/**\n * @internal The drag half on its own, shared with `FileDropzone`.\n *\n * `dragenter`/`dragleave` fire for every CHILD the pointer crosses: moving from the\n * target onto a label inside it is a leave of the target followed by an enter of the\n * label, which bubbles. A boolean flipped by each one flickers off and on across\n * every child, and `relatedTarget` — the usual fix — is `null` in Safari's drag\n * events. So this counts: +1 per enter, -1 per leave, over while above zero, and a\n * drop resets it (the drop replaces the leave that would have balanced the enter).\n *\n * A file drag stops propagating here, so a target nested in another (a row inside a\n * page-wide drop area) takes its own drop, and the outer one's count falls back to\n * zero while the pointer is over the inner one — only one of them lights up.\n */\nexport function useDragTarget<E extends Element = Element>({\n disabled,\n onDropFiles,\n}: {\n disabled?: boolean;\n onDropFiles: (files: FileList | File[]) => void;\n}): { dropProps: FileDropProps<E>; isOver: boolean } {\n const depth = useRef(0);\n const [over, setOver] = useState(false);\n\n const dropProps: FileDropProps<E> = {\n onDragEnter: (e) => {\n if (!dragHasFiles(e)) return;\n e.stopPropagation();\n e.preventDefault();\n depth.current += 1;\n if (!disabled) setOver(true);\n },\n onDragOver: (e) => {\n if (!dragHasFiles(e)) return;\n e.stopPropagation();\n // Always cancelled for a file drag, disabled or not: an uncancelled drop makes\n // the browser NAVIGATE to the file, which loses the page.\n e.preventDefault();\n e.dataTransfer.dropEffect = disabled ? \"none\" : \"copy\";\n },\n onDragLeave: (e) => {\n if (!dragHasFiles(e)) return;\n e.stopPropagation();\n depth.current = Math.max(0, depth.current - 1);\n if (depth.current === 0) setOver(false);\n },\n onDrop: (e) => {\n if (!dragHasFiles(e)) return;\n e.stopPropagation();\n e.preventDefault();\n depth.current = 0;\n setOver(false);\n if (!disabled) onDropFiles(e.dataTransfer.files);\n },\n };\n\n return { dropProps, isOver: over && !disabled };\n}\n\nexport function useFileDrop<E extends Element = Element>(options: UseFileDropOptions): UseFileDropReturn<E> {\n const picker = useFilePicker(options);\n const { dropProps, isOver } = useDragTarget<E>({\n disabled: options.disabled,\n onDropFiles: picker.take,\n });\n return { dropProps, isOver, open: picker.open, take: picker.take, element: picker.element };\n}\n"],"mappings":";AAAA,SAAS,QAAQ,gBAAgB;AAEjC,SAAS,qBAAqB;AAgDvB,SAAS,aAAa,GAAuB;AAClD,SAAO,MAAM,KAAK,EAAE,cAAc,SAAS,CAAC,CAAC,EAAE,SAAS,OAAO;AACjE;AAgBO,SAAS,cAA2C;AAAA,EACzD;AAAA,EACA;AACF,GAGqD;AACnD,QAAM,QAAQ,OAAO,CAAC;AACtB,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AAEtC,QAAM,YAA8B;AAAA,IAClC,aAAa,CAAC,MAAM;AAClB,UAAI,CAAC,aAAa,CAAC,EAAG;AACtB,QAAE,gBAAgB;AAClB,QAAE,eAAe;AACjB,YAAM,WAAW;AACjB,UAAI,CAAC,SAAU,SAAQ,IAAI;AAAA,IAC7B;AAAA,IACA,YAAY,CAAC,MAAM;AACjB,UAAI,CAAC,aAAa,CAAC,EAAG;AACtB,QAAE,gBAAgB;AAGlB,QAAE,eAAe;AACjB,QAAE,aAAa,aAAa,WAAW,SAAS;AAAA,IAClD;AAAA,IACA,aAAa,CAAC,MAAM;AAClB,UAAI,CAAC,aAAa,CAAC,EAAG;AACtB,QAAE,gBAAgB;AAClB,YAAM,UAAU,KAAK,IAAI,GAAG,MAAM,UAAU,CAAC;AAC7C,UAAI,MAAM,YAAY,EAAG,SAAQ,KAAK;AAAA,IACxC;AAAA,IACA,QAAQ,CAAC,MAAM;AACb,UAAI,CAAC,aAAa,CAAC,EAAG;AACtB,QAAE,gBAAgB;AAClB,QAAE,eAAe;AACjB,YAAM,UAAU;AAChB,cAAQ,KAAK;AACb,UAAI,CAAC,SAAU,aAAY,EAAE,aAAa,KAAK;AAAA,IACjD;AAAA,EACF;AAEA,SAAO,EAAE,WAAW,QAAQ,QAAQ,CAAC,SAAS;AAChD;AAEO,SAAS,YAAyC,SAAmD;AAC1G,QAAM,SAAS,cAAc,OAAO;AACpC,QAAM,EAAE,WAAW,OAAO,IAAI,cAAiB;AAAA,IAC7C,UAAU,QAAQ;AAAA,IAClB,aAAa,OAAO;AAAA,EACtB,CAAC;AACD,SAAO,EAAE,WAAW,QAAQ,MAAM,OAAO,MAAM,MAAM,OAAO,MAAM,SAAS,OAAO,QAAQ;AAC5F;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-file-picker-labels.ts"],"sourcesContent":["// The English words of the file pickers (./use-file-picker), in a module with no component\n// code: the English catalogue (src/i18n/defaults.ts) imports them without the component, so\n// `en` loads as one chunk like the other languages (0.34, KS U8). In hooks/ beside the hook\n// since 0.34 (MOD-8); components/file-button re-exports them.\n\nimport type { FilePickerLabels } from \"./use-file-picker\";\n\nexport const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {\n rejectedType: (name) => `“${name}” is not a supported file type`,\n rejectedTypeOnly: (accept) => `Only ${accept} files`,\n rejectedSize: (name, maxSize) => `“${name}” is larger than ${maxSize}`,\n rejectedCount: (name, maxFiles) =>\n `“${name}” was not added: at most ${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"}`,\n rejectedInvalid: (name) => `“${name}” cannot be used here`,\n rejectedMany: (count) => `${count} files were not added`,\n rejectedPick: (count)
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-file-picker-labels.ts"],"sourcesContent":["// The English words of the file pickers (./use-file-picker), in a module with no component\n// code: the English catalogue (src/i18n/defaults.ts) imports them without the component, so\n// `en` loads as one chunk like the other languages (0.34, KS U8). In hooks/ beside the hook\n// since 0.34 (MOD-8); components/file-button re-exports them.\n\nimport type { FilePickerLabels } from \"./use-file-picker\";\n\nexport const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {\n rejectedType: (name) => `“${name}” is not a supported file type`,\n rejectedTypeOnly: (accept) => `Only ${accept} files`,\n rejectedSize: (name, maxSize) => `“${name}” is larger than ${maxSize}`,\n rejectedCount: (name, maxFiles) =>\n `“${name}” was not added: at most ${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"}`,\n rejectedInvalid: (name) => `“${name}” cannot be used here`,\n rejectedMany: (count) => `${count} files were not added`,\n rejectedPick: (count) => (count === 1 ? \"The file was not added\" : `None of the ${count} files were added`),\n selected: (count, firstName) => (count === 1 ? `“${firstName}” selected` : `${count} files selected`),\n remove: (name) => `Remove “${name}”`,\n clearAll: \"Remove all files\",\n removed: (name) => `“${name}” removed`,\n cleared: \"All files removed\",\n dropzone: \"File upload\",\n browse: \"Browse\",\n empty: \"Drop a file here\",\n emptyMultiple: \"Drop files here\",\n hint: (accept) => (accept ? `Accepted: ${accept}` : \"Any file type\"),\n busy: \"Uploading…\",\n};\n"],"mappings":";AAOO,MAAM,6BAA+C;AAAA,EAC1D,cAAc,CAAC,SAAS,SAAI,IAAI;AAAA,EAChC,kBAAkB,CAAC,WAAW,QAAQ,MAAM;AAAA,EAC5C,cAAc,CAAC,MAAM,YAAY,SAAI,IAAI,yBAAoB,OAAO;AAAA,EACpE,eAAe,CAAC,MAAM,aACpB,SAAI,IAAI,iCAA4B,QAAQ,IAAI,aAAa,IAAI,SAAS,OAAO;AAAA,EACnF,iBAAiB,CAAC,SAAS,SAAI,IAAI;AAAA,EACnC,cAAc,CAAC,UAAU,GAAG,KAAK;AAAA,EACjC,cAAc,CAAC,UAAW,UAAU,IAAI,2BAA2B,eAAe,KAAK;AAAA,EACvF,UAAU,CAAC,OAAO,cAAe,UAAU,IAAI,SAAI,SAAS,oBAAe,GAAG,KAAK;AAAA,EACnF,QAAQ,CAAC,SAAS,gBAAW,IAAI;AAAA,EACjC,UAAU;AAAA,EACV,SAAS,CAAC,SAAS,SAAI,IAAI;AAAA,EAC3B,SAAS;AAAA,EACT,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,eAAe;AAAA,EACf,MAAM,CAAC,WAAY,SAAS,aAAa,MAAM,KAAK;AAAA,EACpD,MAAM;AACR;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-file-picker.tsx"],"sourcesContent":["import { useRef } from \"react\";\nimport type { ReactElement } from \"react\";\nimport { useAnnounce } from \"./use-announce\";\nimport { useKitFileLabels, useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { DEFAULT_FILE_PICKER_LABELS } from \"./use-file-picker-labels\";\n\n/**\n * The file pickers' headless half: the hidden input, the screening of a pick and the\n * announcement of a refusal, which {@link FileButton}, `FileDropzone` and {@link useFileDrop}\n * are built on.\n *\n * In hooks/ since 0.34 (MOD-8, refactor audit 2026-10). It lived in\n * components/file-button.tsx, and `useFileDrop` — a hook — imported it from there, so\n * hooks/ reached up into components/. components/file-button re-exports all of it, so an\n * import from there keeps working.\n */\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the file pickers ({@link FileButton}, `FileDropzone`) render or speak.\n * Messages are functions of the file name so a translation can put it anywhere. */\nexport interface FilePickerLabels {\n /** The file's type is not in `accept`, when no `accept` is known to name (see\n * `rejectedTypeOnly`). */\n rejectedType: (name: string) => string;\n /** The file's type is not in `accept`, naming what IS accepted: `accept`'s tokens,\n * lower-cased and joined with \", \" (`\".csv\"` => \"Only .csv files\"). Used whenever\n * `accept` is set — kastlan kept a custom `isValid` + `invalidMessage` per form only\n * to say this, and `accept` already knows it. */\n rejectedTypeOnly: (accept: string, name: string) => string;\n /** The file is larger than `maxSize`; `maxSize` arrives formatted (\"5 MB\"). */\n rejectedSize: (name: string, maxSize: string) => string;\n /** The file was one too many for `maxFiles`. */\n rejectedCount: (name: string, maxFiles: number) => string;\n /** `isValid` said no and the caller gave no `invalidMessage`. */\n rejectedInvalid: (name: string) => string;\n /** Spoken instead of the per-file message when more than one file was refused. */\n rejectedMany: (count: number) => string;\n /** Spoken (and shown, on a dropzone) when `onPick` refused the WHOLE pick.\n * Receives the number of files in it. */\n rejectedPick: (count: number) => string;\n /** Spoken after a pick the component itself echoes (the dropzone). */\n selected: (count: number, firstName: string) => string;\n /** The dropzone's remove button for one file. */\n remove: (name: string) => string;\n /** The dropzone's remove-everything button in `multiple` mode. */\n clearAll: string;\n /** Spoken after a remove / clear, since the button that was pressed is gone. */\n removed: (name: string) => string;\n cleared: string;\n /** The dropzone's accessible name, when the host passes no `dropLabel`. */\n dropzone: string;\n /** The dropzone's Browse button, when the host passes no `browseLabel`. */\n browse: string;\n /** What an empty single-file dropzone says (no `emptyLabel` given). */\n empty: string;\n /** …and an empty `multiple` one. */\n emptyMultiple: string;\n /** The dropzone's second line (no `hint` given). Receives `accept` as a person\n * reads it (`\".pdf, image/png\"`), or `\"\"` when anything goes. */\n hint: (accept: string) => string;\n /** What a `busy` dropzone says in place of its body (\"Uploading…\"). */\n busy: string;\n}\n\n/* ── Screening ───────────────────────────────────────────────────────────── */\n\nexport type FileRejectionReason = \"type\" | \"size\" | \"count\" | \"invalid\";\n\n/** One file the picker refused, and why. `message` is already translated (see\n * {@link FilePickerLabels}, or the caller's `invalidMessage`), so a host that just\n * wants to show it can render `rejections[0].message` as is. */\nexport interface FileRejection {\n file: File;\n reason: FileRejectionReason;\n message: string;\n}\n\n/**\n * Does `file` satisfy an `accept` string, the way the browser's dialog reads it?\n * Comma-separated tokens: `.ext` (case-insensitive suffix of the name), `type/*`\n * (a MIME family) or an exact MIME type. An empty or absent `accept` takes anything.\n *\n * A file with no `type` — common for `.step`, `.dat`, anything the OS has no MIME\n * mapping for — can only match by extension, which is why lenkbank lists `.stp` AND\n * `model/step`: that is how `accept` has to be written for the dialog anyway.\n */\nexport function matchesAccept(file: File, accept: string | undefined): boolean {\n const tokens = (accept ?? \"\")\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean);\n if (tokens.length === 0) return true;\n const name = file.name.toLowerCase();\n // An EMPTY type is the browser saying \"I don't know\", not \"it's something else\": HEIC\n // photos on Windows without the codec arrive with `type === \"\"`. Refusing them\n // (0.6.0) refused iPhone photos the picker itself had just offered (keksdose). So a\n // missing type is inferred from the extension where that is unambiguous, and a file\n // whose type cannot be known at all is given the benefit of the doubt — nothing here\n // proves it does not match, and the server validates what it receives anyway.\n const type = (file.type || typeFromExtension(name) || \"\").toLowerCase();\n if (!type && !tokens.every((t) => t.startsWith(\".\"))) return true;\n return tokens.some((token) => {\n if (token.startsWith(\".\")) return name.endsWith(token);\n if (!type) return false;\n if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1));\n return type === token;\n });\n}\n\n/** MIME types for the extensions a browser most often leaves untyped. */\nconst EXTENSION_TYPES: Record<string, string> = {\n heic: \"image/heic\",\n heif: \"image/heif\",\n avif: \"image/avif\",\n webp: \"image/webp\",\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n gif: \"image/gif\",\n pdf: \"application/pdf\",\n csv: \"text/csv\",\n txt: \"text/plain\",\n};\n\nfunction typeFromExtension(name: string): string | undefined {\n const dot = name.lastIndexOf(\".\");\n return dot === -1 ? undefined : EXTENSION_TYPES[name.slice(dot + 1)];\n}\n\n/** @internal `\" .PDF, image/png ,\"` => `\".pdf, image/png\"` — `accept` as a person reads\n * it. Also the dropzone's default hint. */\nexport function formatAccept(accept: string): string {\n return accept\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean)\n .join(\", \");\n}\n\n/** What the pickers screen a pick with. All optional; nothing set accepts everything. */\nexport interface FileScreenOptions {\n accept?: string;\n /** Bytes. Larger files are refused with reason `\"size\"`. */\n maxSize?: number;\n /** How many files one pick may deliver. The rest are refused with reason `\"count\"`\n * — first come, first kept. For a running cap (\"at most 5 attachments\") pass what\n * is LEFT: `maxFiles={5 - attachments.length}`. */\n maxFiles?: number;\n /** The caller's own check, after `accept` and `maxSize`. */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal; `labels.rejectedInvalid` otherwise. */\n invalidMessage?: string;\n}\n\n/** @internal Split a pick into the files that pass and the ones that do not. */\nexport function screenFiles(\n files: readonly File[],\n opts: FileScreenOptions,\n labels: FilePickerLabels,\n formatSize: (bytes: number) => string,\n): { accepted: File[]; rejected: FileRejection[] } {\n const accepted: File[] = [];\n const rejected: FileRejection[] = [];\n // Name what `accept` takes (\"Only .csv files\") — unless the host translated\n // `rejectedType` but not yet `rejectedTypeOnly`: its own sentence beats an English one.\n const acceptText = opts.accept ? formatAccept(opts.accept) : \"\";\n const useOnly =\n acceptText !== \"\" &&\n (labels.rejectedTypeOnly !== DEFAULT_FILE_PICKER_LABELS.rejectedTypeOnly ||\n labels.rejectedType === DEFAULT_FILE_PICKER_LABELS.rejectedType);\n const typeMessage = (name: string) =>\n useOnly ? labels.rejectedTypeOnly(acceptText, name) : labels.rejectedType(name);\n for (const file of files) {\n let reason: FileRejectionReason | null = null;\n if (!matchesAccept(file, opts.accept)) reason = \"type\";\n else if (opts.maxSize !== undefined && file.size > opts.maxSize) reason = \"size\";\n else if (opts.isValid && !opts.isValid(file)) reason = \"invalid\";\n // Count last, and only against files that passed everything else: a refused\n // file should not use up one of the slots a good one could have had.\n else if (opts.maxFiles !== undefined && accepted.length >= opts.maxFiles) reason = \"count\";\n if (reason === null) {\n accepted.push(file);\n continue;\n }\n const message =\n reason === \"type\"\n ? typeMessage(file.name)\n : reason === \"size\"\n ? labels.rejectedSize(file.name, formatSize(opts.maxSize ?? 0))\n : reason === \"count\"\n ? labels.rejectedCount(file.name, Math.max(0, opts.maxFiles ?? 0))\n : (opts.invalidMessage ?? labels.rejectedInvalid(file.name));\n rejected.push({ file, reason, message });\n }\n return { accepted, rejected };\n}\n\n/**\n * Judges a pick as a whole — keksdose's \"refuse the pick if any file is bad\", which\n * with `onFiles` + `onReject` alone had to be rebuilt from two calls in a microtask.\n *\n * Called ONCE per pick, after screening and before `onFiles` / `onReject`, with both\n * halves (either may be empty). Return `false` to refuse the whole pick: the accepted\n * files are not delivered, `onReject` still receives what screening refused, and one\n * sentence (`labels.rejectedPick`) is spoken for the pick instead of a per-file one.\n * Return nothing to let the pick through as usual.\n *\n * ```tsx\n * onPick={(ok, bad) => bad.length === 0 || false} // all or nothing\n * ```\n */\nexport type FilePickHandler = (accepted: File[], rejected: FileRejection[]) => boolean | void;\n\n/** @internal The shared tail of both pickers: run `onPick`, and say whether the pick\n * stands. A pick with nothing accepted has nothing to refuse, so `false` there is\n * treated as no verdict and the usual per-file messages are kept. */\nexport function judgePick(\n onPick: FilePickHandler | undefined,\n accepted: File[],\n rejected: FileRejection[],\n): boolean {\n if (!onPick) return true;\n return onPick(accepted, rejected) !== false || accepted.length === 0;\n}\n\n/** One sentence for a whole batch of refusals: the file's own message for one, a\n * count for several — reading out five sentences in a row helps nobody. */\nexport function summariseRejections(rejected: readonly FileRejection[], labels: FilePickerLabels): string {\n if (rejected.length === 0) return \"\";\n if (rejected.length === 1) return rejected[0].message;\n return labels.rejectedMany(rejected.length);\n}\n\n/* ── useFilePicker ───────────────────────────────────────────────────────── */\n\nexport interface UseFilePickerOptions extends FileScreenOptions {\n /** Let one pick deliver several files. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /**\n * Ask a phone for its camera instead of the file chooser: `\"environment\"` is the\n * back camera, `\"user\"` the front. A HINT — desktop browsers ignore it, and Chrome\n * drops it when `multiple` is also set (a camera cannot deliver a list), which is why\n * keksdose's camera input has no `multiple`. Pass one or the other.\n */\n capture?: boolean | \"user\" | \"environment\";\n /** The files that passed, in pick order. Never called with an empty array.\n * Optional since 0.7 for a caller that takes the pick through `onPick` alone. */\n onFiles?: (files: File[]) => void;\n /** See {@link FilePickHandler}: the whole pick at once, with the power to refuse it. */\n onPick?: FilePickHandler;\n /** The files that did not, with a translated message each. The refusals are also\n * spoken through a live region, so this is for SHOWING them, not for a11y. */\n onReject?: (rejections: FileRejection[]) => void;\n /** `open()` and `take()` do nothing while set. */\n disabled?: boolean;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: LabelOverride<FilePickerLabels>;\n}\n\nexport interface UseFilePickerReturn {\n /** Open the system picker. Call it from a click handler — browsers only open a file\n * dialog in response to a user gesture. */\n open: () => void;\n /** Screen and deliver files that arrived some other way — a drop, a paste. Honours\n * `multiple` (only the first file without it) and every check. */\n take: (files: ArrayLike<File> | null | undefined) => void;\n /** The hidden input and the live region. Render it once, anywhere — it takes no\n * space and needs no positioned ancestor. */\n element: ReactElement;\n}\n\n/**\n * The headless half of {@link FileButton}: a hidden file input you can open from any\n * control, with the reset, the screening and the announcement built in.\n *\n * ```tsx\n * const picker = useFilePicker({ accept: \".pdf\", onFiles: ([f]) => upload(f) });\n * return <>{picker.element}<DetailTableCard onAdd={picker.open} … /></>;\n * ```\n */\nexport function useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onPick,\n onReject,\n disabled,\n labels: labelsProp,\n}: UseFilePickerOptions): UseFilePickerReturn {\n const inputRef = useRef<HTMLInputElement>(null);\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n const fileText = useKitFileLabels();\n // Assertive: a refusal is a failure the user has to hear before they move on, and\n // it is the only thing this region ever says.\n const { announce, regionProps } = useAnnounce({ politeness: \"assertive\" });\n\n const take = (list: ArrayLike<File> | null | undefined) => {\n if (disabled || !list || list.length === 0) return;\n const all = Array.from(list);\n const files = multiple ? all : all.slice(0, 1);\n const { accepted, rejected } = screenFiles(\n files,\n // A single-file picker holds one file by definition; `maxFiles` is a multi-mode\n // cap and would only ever say \"0\" there if a caller passed it by mistake.\n { accept, maxSize, maxFiles: multiple ? maxFiles : undefined, isValid, invalidMessage },\n labels,\n fileText.size,\n );\n if (!judgePick(onPick, accepted, rejected)) {\n if (rejected.length > 0) onReject?.(rejected);\n announce(labels.rejectedPick(files.length));\n return;\n }\n if (accepted.length > 0) onFiles?.(accepted);\n if (rejected.length > 0) {\n onReject?.(rejected);\n announce(summariseRejections(rejected, labels));\n }\n };\n\n const element = (\n <>\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n capture={capture}\n disabled={disabled}\n // `hidden` (display: none), not `sr-only`: the control a user reaches is the\n // button, and a second, nameless tab stop for the same action is noise. A\n // display:none file input still opens on `.click()` in every current browser —\n // and, unlike `sr-only`, it cannot escape to the initial containing block and\n // stretch the page (see the note in `file-dropzone.tsx`).\n className=\"hidden\"\n tabIndex={-1}\n aria-hidden\n onChange={(e) => {\n // Copy BEFORE the reset: `files` is a live view of the input's value, and\n // clearing the value empties it.\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n take(picked);\n }}\n />\n <span {...regionProps} />\n </>\n );\n\n return {\n open: () => {\n if (!disabled) inputRef.current?.click();\n },\n take,\n element,\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-file-picker.tsx"],"sourcesContent":["import { useRef } from \"react\";\nimport type { ReactElement } from \"react\";\nimport { useAnnounce } from \"./use-announce\";\nimport { useKitFileLabels, useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { DEFAULT_FILE_PICKER_LABELS } from \"./use-file-picker-labels\";\n\n/**\n * The file pickers' headless half: the hidden input, the screening of a pick and the\n * announcement of a refusal, which {@link FileButton}, `FileDropzone` and {@link useFileDrop}\n * are built on.\n *\n * In hooks/ since 0.34 (MOD-8, refactor audit 2026-10). It lived in\n * components/file-button.tsx, and `useFileDrop` — a hook — imported it from there, so\n * hooks/ reached up into components/. components/file-button re-exports all of it, so an\n * import from there keeps working.\n */\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the file pickers ({@link FileButton}, `FileDropzone`) render or speak.\n * Messages are functions of the file name so a translation can put it anywhere. */\nexport interface FilePickerLabels {\n /** The file's type is not in `accept`, when no `accept` is known to name (see\n * `rejectedTypeOnly`). */\n rejectedType: (name: string) => string;\n /** The file's type is not in `accept`, naming what IS accepted: `accept`'s tokens,\n * lower-cased and joined with \", \" (`\".csv\"` => \"Only .csv files\"). Used whenever\n * `accept` is set — kastlan kept a custom `isValid` + `invalidMessage` per form only\n * to say this, and `accept` already knows it. */\n rejectedTypeOnly: (accept: string, name: string) => string;\n /** The file is larger than `maxSize`; `maxSize` arrives formatted (\"5 MB\"). */\n rejectedSize: (name: string, maxSize: string) => string;\n /** The file was one too many for `maxFiles`. */\n rejectedCount: (name: string, maxFiles: number) => string;\n /** `isValid` said no and the caller gave no `invalidMessage`. */\n rejectedInvalid: (name: string) => string;\n /** Spoken instead of the per-file message when more than one file was refused. */\n rejectedMany: (count: number) => string;\n /** Spoken (and shown, on a dropzone) when `onPick` refused the WHOLE pick.\n * Receives the number of files in it. */\n rejectedPick: (count: number) => string;\n /** Spoken after a pick the component itself echoes (the dropzone). */\n selected: (count: number, firstName: string) => string;\n /** The dropzone's remove button for one file. */\n remove: (name: string) => string;\n /** The dropzone's remove-everything button in `multiple` mode. */\n clearAll: string;\n /** Spoken after a remove / clear, since the button that was pressed is gone. */\n removed: (name: string) => string;\n cleared: string;\n /** The dropzone's accessible name, when the host passes no `dropLabel`. */\n dropzone: string;\n /** The dropzone's Browse button, when the host passes no `browseLabel`. */\n browse: string;\n /** What an empty single-file dropzone says (no `emptyLabel` given). */\n empty: string;\n /** …and an empty `multiple` one. */\n emptyMultiple: string;\n /** The dropzone's second line (no `hint` given). Receives `accept` as a person\n * reads it (`\".pdf, image/png\"`), or `\"\"` when anything goes. */\n hint: (accept: string) => string;\n /** What a `busy` dropzone says in place of its body (\"Uploading…\"). */\n busy: string;\n}\n\n/* ── Screening ───────────────────────────────────────────────────────────── */\n\nexport type FileRejectionReason = \"type\" | \"size\" | \"count\" | \"invalid\";\n\n/** One file the picker refused, and why. `message` is already translated (see\n * {@link FilePickerLabels}, or the caller's `invalidMessage`), so a host that just\n * wants to show it can render `rejections[0].message` as is. */\nexport interface FileRejection {\n file: File;\n reason: FileRejectionReason;\n message: string;\n}\n\n/**\n * Does `file` satisfy an `accept` string, the way the browser's dialog reads it?\n * Comma-separated tokens: `.ext` (case-insensitive suffix of the name), `type/*`\n * (a MIME family) or an exact MIME type. An empty or absent `accept` takes anything.\n *\n * A file with no `type` — common for `.step`, `.dat`, anything the OS has no MIME\n * mapping for — can only match by extension, which is why lenkbank lists `.stp` AND\n * `model/step`: that is how `accept` has to be written for the dialog anyway.\n */\nexport function matchesAccept(file: File, accept: string | undefined): boolean {\n const tokens = (accept ?? \"\")\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean);\n if (tokens.length === 0) return true;\n const name = file.name.toLowerCase();\n // An EMPTY type is the browser saying \"I don't know\", not \"it's something else\": HEIC\n // photos on Windows without the codec arrive with `type === \"\"`. Refusing them\n // (0.6.0) refused iPhone photos the picker itself had just offered (keksdose). So a\n // missing type is inferred from the extension where that is unambiguous, and a file\n // whose type cannot be known at all is given the benefit of the doubt — nothing here\n // proves it does not match, and the server validates what it receives anyway.\n const type = (file.type || typeFromExtension(name) || \"\").toLowerCase();\n if (!type && !tokens.every((t) => t.startsWith(\".\"))) return true;\n return tokens.some((token) => {\n if (token.startsWith(\".\")) return name.endsWith(token);\n if (!type) return false;\n if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1));\n return type === token;\n });\n}\n\n/** MIME types for the extensions a browser most often leaves untyped. */\nconst EXTENSION_TYPES: Record<string, string> = {\n heic: \"image/heic\",\n heif: \"image/heif\",\n avif: \"image/avif\",\n webp: \"image/webp\",\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n gif: \"image/gif\",\n pdf: \"application/pdf\",\n csv: \"text/csv\",\n txt: \"text/plain\",\n};\n\nfunction typeFromExtension(name: string): string | undefined {\n const dot = name.lastIndexOf(\".\");\n return dot === -1 ? undefined : EXTENSION_TYPES[name.slice(dot + 1)];\n}\n\n/** @internal `\" .PDF, image/png ,\"` => `\".pdf, image/png\"` — `accept` as a person reads\n * it. Also the dropzone's default hint. */\nexport function formatAccept(accept: string): string {\n return accept\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean)\n .join(\", \");\n}\n\n/** What the pickers screen a pick with. All optional; nothing set accepts everything. */\nexport interface FileScreenOptions {\n accept?: string;\n /** Bytes. Larger files are refused with reason `\"size\"`. */\n maxSize?: number;\n /** How many files one pick may deliver. The rest are refused with reason `\"count\"`\n * — first come, first kept. For a running cap (\"at most 5 attachments\") pass what\n * is LEFT: `maxFiles={5 - attachments.length}`. */\n maxFiles?: number;\n /** The caller's own check, after `accept` and `maxSize`. */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal; `labels.rejectedInvalid` otherwise. */\n invalidMessage?: string;\n}\n\n/** @internal Split a pick into the files that pass and the ones that do not. */\nexport function screenFiles(\n files: readonly File[],\n opts: FileScreenOptions,\n labels: FilePickerLabels,\n formatSize: (bytes: number) => string,\n): { accepted: File[]; rejected: FileRejection[] } {\n const accepted: File[] = [];\n const rejected: FileRejection[] = [];\n // Name what `accept` takes (\"Only .csv files\") — unless the host translated\n // `rejectedType` but not yet `rejectedTypeOnly`: its own sentence beats an English one.\n const acceptText = opts.accept ? formatAccept(opts.accept) : \"\";\n const useOnly =\n acceptText !== \"\" &&\n (labels.rejectedTypeOnly !== DEFAULT_FILE_PICKER_LABELS.rejectedTypeOnly ||\n labels.rejectedType === DEFAULT_FILE_PICKER_LABELS.rejectedType);\n const typeMessage = (name: string) =>\n useOnly ? labels.rejectedTypeOnly(acceptText, name) : labels.rejectedType(name);\n for (const file of files) {\n let reason: FileRejectionReason | null = null;\n if (!matchesAccept(file, opts.accept)) reason = \"type\";\n else if (opts.maxSize !== undefined && file.size > opts.maxSize) reason = \"size\";\n else if (opts.isValid && !opts.isValid(file)) reason = \"invalid\";\n // Count last, and only against files that passed everything else: a refused\n // file should not use up one of the slots a good one could have had.\n else if (opts.maxFiles !== undefined && accepted.length >= opts.maxFiles) reason = \"count\";\n if (reason === null) {\n accepted.push(file);\n continue;\n }\n const message =\n reason === \"type\"\n ? typeMessage(file.name)\n : reason === \"size\"\n ? labels.rejectedSize(file.name, formatSize(opts.maxSize ?? 0))\n : reason === \"count\"\n ? labels.rejectedCount(file.name, Math.max(0, opts.maxFiles ?? 0))\n : (opts.invalidMessage ?? labels.rejectedInvalid(file.name));\n rejected.push({ file, reason, message });\n }\n return { accepted, rejected };\n}\n\n/**\n * Judges a pick as a whole — keksdose's \"refuse the pick if any file is bad\", which\n * with `onFiles` + `onReject` alone had to be rebuilt from two calls in a microtask.\n *\n * Called ONCE per pick, after screening and before `onFiles` / `onReject`, with both\n * halves (either may be empty). Return `false` to refuse the whole pick: the accepted\n * files are not delivered, `onReject` still receives what screening refused, and one\n * sentence (`labels.rejectedPick`) is spoken for the pick instead of a per-file one.\n * Return nothing to let the pick through as usual.\n *\n * ```tsx\n * onPick={(ok, bad) => bad.length === 0 || false} // all or nothing\n * ```\n */\nexport type FilePickHandler = (accepted: File[], rejected: FileRejection[]) => boolean | void;\n\n/** @internal The shared tail of both pickers: run `onPick`, and say whether the pick\n * stands. A pick with nothing accepted has nothing to refuse, so `false` there is\n * treated as no verdict and the usual per-file messages are kept. */\nexport function judgePick(onPick: FilePickHandler | undefined, accepted: File[], rejected: FileRejection[]): boolean {\n if (!onPick) return true;\n return onPick(accepted, rejected) !== false || accepted.length === 0;\n}\n\n/** One sentence for a whole batch of refusals: the file's own message for one, a\n * count for several — reading out five sentences in a row helps nobody. */\nexport function summariseRejections(rejected: readonly FileRejection[], labels: FilePickerLabels): string {\n if (rejected.length === 0) return \"\";\n if (rejected.length === 1) return rejected[0].message;\n return labels.rejectedMany(rejected.length);\n}\n\n/* ── useFilePicker ───────────────────────────────────────────────────────── */\n\nexport interface UseFilePickerOptions extends FileScreenOptions {\n /** Let one pick deliver several files. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /**\n * Ask a phone for its camera instead of the file chooser: `\"environment\"` is the\n * back camera, `\"user\"` the front. A HINT — desktop browsers ignore it, and Chrome\n * drops it when `multiple` is also set (a camera cannot deliver a list), which is why\n * keksdose's camera input has no `multiple`. Pass one or the other.\n */\n capture?: boolean | \"user\" | \"environment\";\n /** The files that passed, in pick order. Never called with an empty array.\n * Optional since 0.7 for a caller that takes the pick through `onPick` alone. */\n onFiles?: (files: File[]) => void;\n /** See {@link FilePickHandler}: the whole pick at once, with the power to refuse it. */\n onPick?: FilePickHandler;\n /** The files that did not, with a translated message each. The refusals are also\n * spoken through a live region, so this is for SHOWING them, not for a11y. */\n onReject?: (rejections: FileRejection[]) => void;\n /** `open()` and `take()` do nothing while set. */\n disabled?: boolean;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: LabelOverride<FilePickerLabels>;\n}\n\nexport interface UseFilePickerReturn {\n /** Open the system picker. Call it from a click handler — browsers only open a file\n * dialog in response to a user gesture. */\n open: () => void;\n /** Screen and deliver files that arrived some other way — a drop, a paste. Honours\n * `multiple` (only the first file without it) and every check. */\n take: (files: ArrayLike<File> | null | undefined) => void;\n /** The hidden input and the live region. Render it once, anywhere — it takes no\n * space and needs no positioned ancestor. */\n element: ReactElement;\n}\n\n/**\n * The headless half of {@link FileButton}: a hidden file input you can open from any\n * control, with the reset, the screening and the announcement built in.\n *\n * ```tsx\n * const picker = useFilePicker({ accept: \".pdf\", onFiles: ([f]) => upload(f) });\n * return <>{picker.element}<DetailTableCard onAdd={picker.open} … /></>;\n * ```\n */\nexport function useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onPick,\n onReject,\n disabled,\n labels: labelsProp,\n}: UseFilePickerOptions): UseFilePickerReturn {\n const inputRef = useRef<HTMLInputElement>(null);\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n const fileText = useKitFileLabels();\n // Assertive: a refusal is a failure the user has to hear before they move on, and\n // it is the only thing this region ever says.\n const { announce, regionProps } = useAnnounce({ politeness: \"assertive\" });\n\n const take = (list: ArrayLike<File> | null | undefined) => {\n if (disabled || !list || list.length === 0) return;\n const all = Array.from(list);\n const files = multiple ? all : all.slice(0, 1);\n const { accepted, rejected } = screenFiles(\n files,\n // A single-file picker holds one file by definition; `maxFiles` is a multi-mode\n // cap and would only ever say \"0\" there if a caller passed it by mistake.\n { accept, maxSize, maxFiles: multiple ? maxFiles : undefined, isValid, invalidMessage },\n labels,\n fileText.size,\n );\n if (!judgePick(onPick, accepted, rejected)) {\n if (rejected.length > 0) onReject?.(rejected);\n announce(labels.rejectedPick(files.length));\n return;\n }\n if (accepted.length > 0) onFiles?.(accepted);\n if (rejected.length > 0) {\n onReject?.(rejected);\n announce(summariseRejections(rejected, labels));\n }\n };\n\n const element = (\n <>\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n capture={capture}\n disabled={disabled}\n // `hidden` (display: none), not `sr-only`: the control a user reaches is the\n // button, and a second, nameless tab stop for the same action is noise. A\n // display:none file input still opens on `.click()` in every current browser —\n // and, unlike `sr-only`, it cannot escape to the initial containing block and\n // stretch the page (see the note in `file-dropzone.tsx`).\n className=\"hidden\"\n tabIndex={-1}\n aria-hidden\n onChange={(e) => {\n // Copy BEFORE the reset: `files` is a live view of the input's value, and\n // clearing the value empties it.\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n take(picked);\n }}\n />\n <span {...regionProps} />\n </>\n );\n\n return {\n open: () => {\n if (!disabled) inputRef.current?.click();\n },\n take,\n element,\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-focus-trap.ts"],"sourcesContent":["import { useEffect, useLayoutEffect, useRef, useState } from \"react\";\n\n/**\n * Keep keyboard focus inside an open overlay, and give it back when it closes.\n *\n * Extracted from `Modal`, which was the ONLY component in this package that did any of\n * this. Every other overlay — the full-bleed dialog a phone's row editor opens, the\n * picker sheet, the numpad, the tour card — declared `role=\"dialog\" aria-modal=\"true\"`\n * and then left focus on the page behind it. That is the worst of the two possible\n * bugs: assistive technology hides the page because of `aria-modal`, while the user's\n * focus is still down there in the part that is now hidden, so Tab walks through\n * controls the screen reader refuses to describe.\n *\n * What this does beyond Modal's original copy:\n *\n * - **Recomputes the tabbable list on every Tab**, not once when the overlay opens.\n * These panels have conditionally-rendered contents — a search box that appears\n * once options load, a \"clear\" button that exists only while a filter is set — and\n * a list captured at open time traps focus against elements that have since gone.\n * - **Guards the restore target.** The element focused before opening may have left\n * the DOM by the time the overlay closes (the row that owned the button was\n * filtered away). Calling `.focus()` on a detached node silently drops focus to\n * `<body>`, which for a keyboard user means losing their place in the page\n * entirely. When the target is gone, focus falls back to the nearest thing that\n * still exists rather than to nothing.\n * - **Leaves an app's own autofocus alone** (0.33.1). React honours a child's\n * `autoFocus` while it commits, and this hook's effect runs AFTER that commit, so\n * focusing the container there took focus straight back off the field the app had\n * asked for. See `initialFocus`.\n * - **Nests.** An overlay can open another — a picker inside a dialog. Only the\n * innermost trap handles a keystroke, and closing it hands control back to the one\n * underneath. Innermost is decided by DOM containment rather than by mount order;\n * see the note on `TRAP_ATTR` for why the obvious stack is wrong.\n * `use-overlay-history.ts` reasons about the same nesting problem for the back\n * button; this is the focus half of it.\n */\n\n/**\n * Candidate tab stops, in DOM order — narrowed by {@link isTabStop} to the ones a Tab\n * really reaches.\n *\n * The selector is the one `Modal` used. On its own it was not enough (round 0.34\n * BUG-4): only the bare `[tabindex]` clause excluded `-1`, so a `button`, an `input` or\n * a link taken out of the tab order still counted. A panel that ends in a roving grid —\n * the calendar's days, the month grid, the picker sheet's rows, every one of them `-1`\n * but the active cell — then had a \"last\" stop no Tab ever reaches: Tab from the real\n * last stop was not wrapped and walked out of the open panel, and Shift+Tab from the\n * first landed on a cell that is not a stop at all.\n */\nconst FOCUSABLE =\n 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\n/**\n * A stop the browser's own Tab would land on: in the tab order (`tabIndex` ≥ 0) and not\n * inside something `hidden` or `inert`. Removes only elements a native Tab never\n * reaches, so no correct order changes. `visibility: hidden` and closed `<details>` are\n * still not walked — they need layout, which this list has never read.\n */\nconst isTabStop = (el: HTMLElement): boolean =>\n el.tabIndex >= 0 && !el.closest(\"[hidden],[inert]\");\n\n/**\n * Marks a container whose trap is currently active.\n *\n * \"Which trap is innermost?\" is answered by DOM CONTAINMENT, not by mount order. The\n * obvious implementation — a module-level stack pushed in the effect — is wrong, and\n * wrong in a way that passes a casual reading: React runs CHILD effects before PARENT\n * effects, so a nested overlay pushes first and the outer one ends up on top of the\n * stack, claiming to be innermost. The outer trap then handles the keystroke and wraps\n * focus to its own first element, jumping the user out of the picker they had open.\n *\n * An attribute on the live DOM cannot disagree with the DOM.\n */\nconst TRAP_ATTR = \"data-focus-trap\";\n\nexport interface FocusTrapOptions {\n /** Trap only while this is true. Mount-time `true` is the common case. */\n active?: boolean;\n /** Return focus to whatever had it before the trap engaged. Default `true`. */\n restoreFocus?: boolean;\n /**\n * What to focus when the trap engages. Default is the container itself, which is\n * what `Modal` does and why: focusing the first FIELD pops the software keyboard on\n * a phone the moment a dialog opens, before the user has asked to type.\n * The container needs `tabIndex={-1}` for this to work.\n *\n * `\"container\"` and `\"first\"` both step aside when focus is ALREADY inside the\n * container, on something other than the container itself. That is a child the app\n * focused on purpose — an `autoFocus`, which React honours before this hook's effect\n * runs — and the keyboard it brings up is the one the user was promised. Moving focus\n * off it lost keksdose's create card its amount field the instant it got it, and on a\n * phone the numpad sheet with it, which shows only while the field is focused (live\n * #402). A function is the caller's explicit choice and is always followed.\n */\n initialFocus?: \"container\" | \"first\" | (() => HTMLElement | null);\n}\n\n/** Whatever has focus right now; nothing on the server, where there is no document. */\nfunction focusedElement(): Element | null {\n return typeof document === \"undefined\" ? null : document.activeElement;\n}\n\nexport function useFocusTrap(\n containerRef: React.RefObject<HTMLElement | null>,\n { active = true, restoreFocus = true, initialFocus = \"container\" }: FocusTrapOptions = {},\n): void {\n // Latest-ref so a caller passing an inline `initialFocus` thunk — which every caller\n // will — does not tear the trap down and re-engage it on each render, stealing focus\n // back to the top of the panel mid-interaction. Written in a LAYOUT effect so that\n // the trap's cleanup, which runs after the layout effects of the same commit, reads\n // this render's `active` rather than the last one's — see the cleanup for why.\n const optsRef = useRef({ active, restoreFocus, initialFocus });\n useLayoutEffect(() => {\n optsRef.current = { active, restoreFocus, initialFocus };\n });\n\n // Where focus was BEFORE the trap engaged: the element it is given back to on close.\n //\n // Read DURING the render that turns the trap on, and that timing is the point. React\n // honours a child's `autoFocus` while it commits that render, before any effect runs,\n // so the effect below asking `document.activeElement` would get the child, inside the\n // overlay. Restoring to that on close aimed focus at the dialog that was closing —\n // already out of the DOM, so focus fell to <body> rather than going back to the\n // button that opened it.\n //\n // State adjusted during render (the pattern `Modal` uses for `open`) rather than a\n // ref written there: set once per opening and cleared on release, so a re-render\n // while open cannot replace it with something inside, and StrictMode's second render\n // and second effect both see the same element. `undefined` means \"not captured\";\n // `null` is a capture that found nothing.\n const [opener, setOpener] = useState<Element | null | undefined>(undefined);\n if (active && opener === undefined) setOpener(focusedElement());\n else if (!active && opener !== undefined) setOpener(undefined);\n\n useEffect(() => {\n if (!active) return;\n const container = containerRef.current;\n if (!container) return;\n\n container.setAttribute(TRAP_ATTR, \"\");\n\n /** Is `node` inside a trap nested within this one? */\n const inNestedTrap = (node: Element | null) => {\n if (!node) return false;\n const owner = node.closest(`[${TRAP_ATTR}]`);\n return owner !== null && owner !== container;\n };\n\n const tabbables = () =>\n Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(\n // A tabbable inside a NESTED open overlay belongs to that overlay's trap, not\n // to this one — otherwise the outer list spans both panels and Tab from the\n // inner panel's last control wraps to the OUTER panel's first.\n (el) => isTabStop(el) && !inNestedTrap(el),\n );\n\n const { initialFocus: how } = optsRef.current;\n const focused = document.activeElement;\n // A child that already has focus was given it on purpose — see `initialFocus`.\n const childHasFocus = focused !== container && container.contains(focused);\n if (typeof how === \"function\") {\n (how() ?? container).focus();\n } else if (!childHasFocus) {\n (how === \"first\" ? (tabbables()[0] ?? container) : container).focus();\n }\n\n const onKeyDown = (e: KeyboardEvent) => {\n if (e.key !== \"Tab\") return;\n // Only the innermost trap acts. The event bubbles through every ancestor\n // container, so without this an outer dialog and an inner picker both handle one\n // Tab and fight over where focus lands.\n if (inNestedTrap(document.activeElement)) return;\n\n // Recomputed per keystroke — see the note at the top of this file.\n const items = tabbables();\n if (items.length === 0) {\n e.preventDefault();\n container.focus();\n return;\n }\n const first = items[0];\n const last = items[items.length - 1];\n const activeEl = document.activeElement;\n if (e.shiftKey && (activeEl === first || activeEl === container)) {\n e.preventDefault();\n last.focus();\n } else if (!e.shiftKey && activeEl === last) {\n e.preventDefault();\n first.focus();\n }\n };\n\n // On the container, not the document: a portalled child panel (an open dropdown\n // rendered to <body>) is outside this subtree and manages its own focus, which is\n // the behaviour Modal's comment describes and consumers already depend on.\n container.addEventListener(\"keydown\", onKeyDown);\n\n return () => {\n container.removeEventListener(\"keydown\", onKeyDown);\n container.removeAttribute(TRAP_ATTR);\n\n // Only when the trap is really being released: the container has left the page,\n // or `active` has turned false. StrictMode runs this cleanup once more right after\n // mounting, with the overlay still up, and then the effect again — and React does\n // not re-apply `autoFocus` for that second run. Restoring here would pull focus\n // out of the autofocused field for good: live #402 again, in development.\n if (container.isConnected && optsRef.current.active) return;\n if (!optsRef.current.restoreFocus) return;\n // `isConnected` is the whole point: restoring to a node that has been removed\n // sends focus to <body> and the user loses their place entirely. When the\n // original is gone, leave focus where the browser put it rather than moving it\n // somewhere arbitrary — a wrong guess is worse than no move.\n if (opener?.isConnected) (opener as HTMLElement).focus?.();\n };\n }, [active, containerRef, opener]);\n}\n"],"mappings":";AAAA,SAAS,WAAW,iBAAiB,QAAQ,gBAAgB;AAiD7D,MAAM,YACJ;AAQF,MAAM,YAAY,CAAC,OACjB,GAAG,YAAY,KAAK,CAAC,GAAG,QAAQ,kBAAkB;AAcpD,MAAM,YAAY;AAyBlB,SAAS,iBAAiC;AACxC,SAAO,OAAO,aAAa,cAAc,OAAO,SAAS;AAC3D;AAEO,SAAS,aACd,cACA,EAAE,SAAS,MAAM,eAAe,MAAM,eAAe,YAAY,IAAsB,CAAC,GAClF;AAMN,QAAM,UAAU,OAAO,EAAE,QAAQ,cAAc,aAAa,CAAC;AAC7D,kBAAgB,MAAM;AACpB,YAAQ,UAAU,EAAE,QAAQ,cAAc,aAAa;AAAA,EACzD,CAAC;AAgBD,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAqC,MAAS;AAC1E,MAAI,UAAU,WAAW,OAAW,WAAU,eAAe,CAAC;AAAA,WACrD,CAAC,UAAU,WAAW,OAAW,WAAU,MAAS;AAE7D,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AACb,UAAM,YAAY,aAAa;AAC/B,QAAI,CAAC,UAAW;AAEhB,cAAU,aAAa,WAAW,EAAE;AAGpC,UAAM,eAAe,CAAC,SAAyB;AAC7C,UAAI,CAAC,KAAM,QAAO;AAClB,YAAM,QAAQ,KAAK,QAAQ,IAAI,SAAS,GAAG;AAC3C,aAAO,UAAU,QAAQ,UAAU;AAAA,IACrC;AAEA,UAAM,YAAY,MAChB,MAAM,KAAK,UAAU,iBAA8B,SAAS,CAAC,EAAE;AAAA;AAAA;AAAA;AAAA,MAI7D,CAAC,OAAO,UAAU,EAAE,KAAK,CAAC,aAAa,EAAE;AAAA,IAC3C;AAEF,UAAM,EAAE,cAAc,IAAI,IAAI,QAAQ;AACtC,UAAM,UAAU,SAAS;AAEzB,UAAM,gBAAgB,YAAY,aAAa,UAAU,SAAS,OAAO;AACzE,QAAI,OAAO,QAAQ,YAAY;AAC7B,OAAC,IAAI,KAAK,WAAW,MAAM;AAAA,IAC7B,WAAW,CAAC,eAAe;AACzB,OAAC,QAAQ,UAAW,UAAU,EAAE,CAAC,KAAK,YAAa,WAAW,MAAM;AAAA,IACtE;AAEA,UAAM,YAAY,CAAC,MAAqB;AACtC,UAAI,EAAE,QAAQ,MAAO;AAIrB,UAAI,aAAa,SAAS,aAAa,EAAG;AAG1C,YAAM,QAAQ,UAAU;AACxB,UAAI,MAAM,WAAW,GAAG;AACtB,UAAE,eAAe;AACjB,kBAAU,MAAM;AAChB;AAAA,MACF;AACA,YAAM,QAAQ,MAAM,CAAC;AACrB,YAAM,OAAO,MAAM,MAAM,SAAS,CAAC;AACnC,YAAM,WAAW,SAAS;AAC1B,UAAI,EAAE,aAAa,aAAa,SAAS,aAAa,YAAY;AAChE,UAAE,eAAe;AACjB,aAAK,MAAM;AAAA,MACb,WAAW,CAAC,EAAE,YAAY,aAAa,MAAM;AAC3C,UAAE,eAAe;AACjB,cAAM,MAAM;AAAA,MACd;AAAA,IACF;AAKA,cAAU,iBAAiB,WAAW,SAAS;AAE/C,WAAO,MAAM;AACX,gBAAU,oBAAoB,WAAW,SAAS;AAClD,gBAAU,gBAAgB,SAAS;AAOnC,UAAI,UAAU,eAAe,QAAQ,QAAQ,OAAQ;AACrD,UAAI,CAAC,QAAQ,QAAQ,aAAc;AAKnC,UAAI,QAAQ,YAAa,CAAC,OAAuB,QAAQ;AAAA,IAC3D;AAAA,EACF,GAAG,CAAC,QAAQ,cAAc,MAAM,CAAC;AACnC;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-focus-trap.ts"],"sourcesContent":["import { useEffect, useLayoutEffect, useRef, useState } from \"react\";\n\n/**\n * Keep keyboard focus inside an open overlay, and give it back when it closes.\n *\n * Extracted from `Modal`, which was the ONLY component in this package that did any of\n * this. Every other overlay — the full-bleed dialog a phone's row editor opens, the\n * picker sheet, the numpad, the tour card — declared `role=\"dialog\" aria-modal=\"true\"`\n * and then left focus on the page behind it. That is the worst of the two possible\n * bugs: assistive technology hides the page because of `aria-modal`, while the user's\n * focus is still down there in the part that is now hidden, so Tab walks through\n * controls the screen reader refuses to describe.\n *\n * What this does beyond Modal's original copy:\n *\n * - **Recomputes the tabbable list on every Tab**, not once when the overlay opens.\n * These panels have conditionally-rendered contents — a search box that appears\n * once options load, a \"clear\" button that exists only while a filter is set — and\n * a list captured at open time traps focus against elements that have since gone.\n * - **Guards the restore target.** The element focused before opening may have left\n * the DOM by the time the overlay closes (the row that owned the button was\n * filtered away). Calling `.focus()` on a detached node silently drops focus to\n * `<body>`, which for a keyboard user means losing their place in the page\n * entirely. When the target is gone, focus falls back to the nearest thing that\n * still exists rather than to nothing.\n * - **Leaves an app's own autofocus alone** (0.33.1). React honours a child's\n * `autoFocus` while it commits, and this hook's effect runs AFTER that commit, so\n * focusing the container there took focus straight back off the field the app had\n * asked for. See `initialFocus`.\n * - **Nests.** An overlay can open another — a picker inside a dialog. Only the\n * innermost trap handles a keystroke, and closing it hands control back to the one\n * underneath. Innermost is decided by DOM containment rather than by mount order;\n * see the note on `TRAP_ATTR` for why the obvious stack is wrong.\n * `use-overlay-history.ts` reasons about the same nesting problem for the back\n * button; this is the focus half of it.\n */\n\n/**\n * Candidate tab stops, in DOM order — narrowed by {@link isTabStop} to the ones a Tab\n * really reaches.\n *\n * The selector is the one `Modal` used. On its own it was not enough (round 0.34\n * BUG-4): only the bare `[tabindex]` clause excluded `-1`, so a `button`, an `input` or\n * a link taken out of the tab order still counted. A panel that ends in a roving grid —\n * the calendar's days, the month grid, the picker sheet's rows, every one of them `-1`\n * but the active cell — then had a \"last\" stop no Tab ever reaches: Tab from the real\n * last stop was not wrapped and walked out of the open panel, and Shift+Tab from the\n * first landed on a cell that is not a stop at all.\n */\nconst FOCUSABLE =\n 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\n/**\n * A stop the browser's own Tab would land on: in the tab order (`tabIndex` ≥ 0) and not\n * inside something `hidden` or `inert`. Removes only elements a native Tab never\n * reaches, so no correct order changes. `visibility: hidden` and closed `<details>` are\n * still not walked — they need layout, which this list has never read.\n */\nconst isTabStop = (el: HTMLElement): boolean => el.tabIndex >= 0 && !el.closest(\"[hidden],[inert]\");\n\n/**\n * Marks a container whose trap is currently active.\n *\n * \"Which trap is innermost?\" is answered by DOM CONTAINMENT, not by mount order. The\n * obvious implementation — a module-level stack pushed in the effect — is wrong, and\n * wrong in a way that passes a casual reading: React runs CHILD effects before PARENT\n * effects, so a nested overlay pushes first and the outer one ends up on top of the\n * stack, claiming to be innermost. The outer trap then handles the keystroke and wraps\n * focus to its own first element, jumping the user out of the picker they had open.\n *\n * An attribute on the live DOM cannot disagree with the DOM.\n */\nconst TRAP_ATTR = \"data-focus-trap\";\n\nexport interface FocusTrapOptions {\n /** Trap only while this is true. Mount-time `true` is the common case. */\n active?: boolean;\n /** Return focus to whatever had it before the trap engaged. Default `true`. */\n restoreFocus?: boolean;\n /**\n * What to focus when the trap engages. Default is the container itself, which is\n * what `Modal` does and why: focusing the first FIELD pops the software keyboard on\n * a phone the moment a dialog opens, before the user has asked to type.\n * The container needs `tabIndex={-1}` for this to work.\n *\n * `\"container\"` and `\"first\"` both step aside when focus is ALREADY inside the\n * container, on something other than the container itself. That is a child the app\n * focused on purpose — an `autoFocus`, which React honours before this hook's effect\n * runs — and the keyboard it brings up is the one the user was promised. Moving focus\n * off it lost keksdose's create card its amount field the instant it got it, and on a\n * phone the numpad sheet with it, which shows only while the field is focused (live\n * #402). A function is the caller's explicit choice and is always followed.\n */\n initialFocus?: \"container\" | \"first\" | (() => HTMLElement | null);\n}\n\n/** Whatever has focus right now; nothing on the server, where there is no document. */\nfunction focusedElement(): Element | null {\n return typeof document === \"undefined\" ? null : document.activeElement;\n}\n\nexport function useFocusTrap(\n containerRef: React.RefObject<HTMLElement | null>,\n { active = true, restoreFocus = true, initialFocus = \"container\" }: FocusTrapOptions = {},\n): void {\n // Latest-ref so a caller passing an inline `initialFocus` thunk — which every caller\n // will — does not tear the trap down and re-engage it on each render, stealing focus\n // back to the top of the panel mid-interaction. Written in a LAYOUT effect so that\n // the trap's cleanup, which runs after the layout effects of the same commit, reads\n // this render's `active` rather than the last one's — see the cleanup for why.\n const optsRef = useRef({ active, restoreFocus, initialFocus });\n useLayoutEffect(() => {\n optsRef.current = { active, restoreFocus, initialFocus };\n });\n\n // Where focus was BEFORE the trap engaged: the element it is given back to on close.\n //\n // Read DURING the render that turns the trap on, and that timing is the point. React\n // honours a child's `autoFocus` while it commits that render, before any effect runs,\n // so the effect below asking `document.activeElement` would get the child, inside the\n // overlay. Restoring to that on close aimed focus at the dialog that was closing —\n // already out of the DOM, so focus fell to <body> rather than going back to the\n // button that opened it.\n //\n // State adjusted during render (the pattern `Modal` uses for `open`) rather than a\n // ref written there: set once per opening and cleared on release, so a re-render\n // while open cannot replace it with something inside, and StrictMode's second render\n // and second effect both see the same element. `undefined` means \"not captured\";\n // `null` is a capture that found nothing.\n const [opener, setOpener] = useState<Element | null | undefined>(undefined);\n if (active && opener === undefined) setOpener(focusedElement());\n else if (!active && opener !== undefined) setOpener(undefined);\n\n useEffect(() => {\n if (!active) return;\n const container = containerRef.current;\n if (!container) return;\n\n container.setAttribute(TRAP_ATTR, \"\");\n\n /** Is `node` inside a trap nested within this one? */\n const inNestedTrap = (node: Element | null) => {\n if (!node) return false;\n const owner = node.closest(`[${TRAP_ATTR}]`);\n return owner !== null && owner !== container;\n };\n\n const tabbables = () =>\n Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(\n // A tabbable inside a NESTED open overlay belongs to that overlay's trap, not\n // to this one — otherwise the outer list spans both panels and Tab from the\n // inner panel's last control wraps to the OUTER panel's first.\n (el) => isTabStop(el) && !inNestedTrap(el),\n );\n\n const { initialFocus: how } = optsRef.current;\n const focused = document.activeElement;\n // A child that already has focus was given it on purpose — see `initialFocus`.\n const childHasFocus = focused !== container && container.contains(focused);\n if (typeof how === \"function\") {\n (how() ?? container).focus();\n } else if (!childHasFocus) {\n (how === \"first\" ? (tabbables()[0] ?? container) : container).focus();\n }\n\n const onKeyDown = (e: KeyboardEvent) => {\n if (e.key !== \"Tab\") return;\n // Only the innermost trap acts. The event bubbles through every ancestor\n // container, so without this an outer dialog and an inner picker both handle one\n // Tab and fight over where focus lands.\n if (inNestedTrap(document.activeElement)) return;\n\n // Recomputed per keystroke — see the note at the top of this file.\n const items = tabbables();\n if (items.length === 0) {\n e.preventDefault();\n container.focus();\n return;\n }\n const first = items[0];\n const last = items[items.length - 1];\n const activeEl = document.activeElement;\n if (e.shiftKey && (activeEl === first || activeEl === container)) {\n e.preventDefault();\n last.focus();\n } else if (!e.shiftKey && activeEl === last) {\n e.preventDefault();\n first.focus();\n }\n };\n\n // On the container, not the document: a portalled child panel (an open dropdown\n // rendered to <body>) is outside this subtree and manages its own focus, which is\n // the behaviour Modal's comment describes and consumers already depend on.\n container.addEventListener(\"keydown\", onKeyDown);\n\n return () => {\n container.removeEventListener(\"keydown\", onKeyDown);\n container.removeAttribute(TRAP_ATTR);\n\n // Only when the trap is really being released: the container has left the page,\n // or `active` has turned false. StrictMode runs this cleanup once more right after\n // mounting, with the overlay still up, and then the effect again — and React does\n // not re-apply `autoFocus` for that second run. Restoring here would pull focus\n // out of the autofocused field for good: live #402 again, in development.\n if (container.isConnected && optsRef.current.active) return;\n if (!optsRef.current.restoreFocus) return;\n // `isConnected` is the whole point: restoring to a node that has been removed\n // sends focus to <body> and the user loses their place entirely. When the\n // original is gone, leave focus where the browser put it rather than moving it\n // somewhere arbitrary — a wrong guess is worse than no move.\n if (opener?.isConnected) (opener as HTMLElement).focus?.();\n };\n }, [active, containerRef, opener]);\n}\n"],"mappings":";AAAA,SAAS,WAAW,iBAAiB,QAAQ,gBAAgB;AAiD7D,MAAM,YACJ;AAQF,MAAM,YAAY,CAAC,OAA6B,GAAG,YAAY,KAAK,CAAC,GAAG,QAAQ,kBAAkB;AAclG,MAAM,YAAY;AAyBlB,SAAS,iBAAiC;AACxC,SAAO,OAAO,aAAa,cAAc,OAAO,SAAS;AAC3D;AAEO,SAAS,aACd,cACA,EAAE,SAAS,MAAM,eAAe,MAAM,eAAe,YAAY,IAAsB,CAAC,GAClF;AAMN,QAAM,UAAU,OAAO,EAAE,QAAQ,cAAc,aAAa,CAAC;AAC7D,kBAAgB,MAAM;AACpB,YAAQ,UAAU,EAAE,QAAQ,cAAc,aAAa;AAAA,EACzD,CAAC;AAgBD,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAqC,MAAS;AAC1E,MAAI,UAAU,WAAW,OAAW,WAAU,eAAe,CAAC;AAAA,WACrD,CAAC,UAAU,WAAW,OAAW,WAAU,MAAS;AAE7D,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AACb,UAAM,YAAY,aAAa;AAC/B,QAAI,CAAC,UAAW;AAEhB,cAAU,aAAa,WAAW,EAAE;AAGpC,UAAM,eAAe,CAAC,SAAyB;AAC7C,UAAI,CAAC,KAAM,QAAO;AAClB,YAAM,QAAQ,KAAK,QAAQ,IAAI,SAAS,GAAG;AAC3C,aAAO,UAAU,QAAQ,UAAU;AAAA,IACrC;AAEA,UAAM,YAAY,MAChB,MAAM,KAAK,UAAU,iBAA8B,SAAS,CAAC,EAAE;AAAA;AAAA;AAAA;AAAA,MAI7D,CAAC,OAAO,UAAU,EAAE,KAAK,CAAC,aAAa,EAAE;AAAA,IAC3C;AAEF,UAAM,EAAE,cAAc,IAAI,IAAI,QAAQ;AACtC,UAAM,UAAU,SAAS;AAEzB,UAAM,gBAAgB,YAAY,aAAa,UAAU,SAAS,OAAO;AACzE,QAAI,OAAO,QAAQ,YAAY;AAC7B,OAAC,IAAI,KAAK,WAAW,MAAM;AAAA,IAC7B,WAAW,CAAC,eAAe;AACzB,OAAC,QAAQ,UAAW,UAAU,EAAE,CAAC,KAAK,YAAa,WAAW,MAAM;AAAA,IACtE;AAEA,UAAM,YAAY,CAAC,MAAqB;AACtC,UAAI,EAAE,QAAQ,MAAO;AAIrB,UAAI,aAAa,SAAS,aAAa,EAAG;AAG1C,YAAM,QAAQ,UAAU;AACxB,UAAI,MAAM,WAAW,GAAG;AACtB,UAAE,eAAe;AACjB,kBAAU,MAAM;AAChB;AAAA,MACF;AACA,YAAM,QAAQ,MAAM,CAAC;AACrB,YAAM,OAAO,MAAM,MAAM,SAAS,CAAC;AACnC,YAAM,WAAW,SAAS;AAC1B,UAAI,EAAE,aAAa,aAAa,SAAS,aAAa,YAAY;AAChE,UAAE,eAAe;AACjB,aAAK,MAAM;AAAA,MACb,WAAW,CAAC,EAAE,YAAY,aAAa,MAAM;AAC3C,UAAE,eAAe;AACjB,cAAM,MAAM;AAAA,MACd;AAAA,IACF;AAKA,cAAU,iBAAiB,WAAW,SAAS;AAE/C,WAAO,MAAM;AACX,gBAAU,oBAAoB,WAAW,SAAS;AAClD,gBAAU,gBAAgB,SAAS;AAOnC,UAAI,UAAU,eAAe,QAAQ,QAAQ,OAAQ;AACrD,UAAI,CAAC,QAAQ,QAAQ,aAAc;AAKnC,UAAI,QAAQ,YAAa,CAAC,OAAuB,QAAQ;AAAA,IAC3D;AAAA,EACF,GAAG,CAAC,QAAQ,cAAc,MAAM,CAAC;AACnC;","names":[]}
|
|
@@ -116,10 +116,7 @@ function taggedSentinel() {
|
|
|
116
116
|
}
|
|
117
117
|
function retag(id) {
|
|
118
118
|
const prev = window.history.state;
|
|
119
|
-
window.history.replaceState(
|
|
120
|
-
{ ...prev && typeof prev === "object" ? prev : {}, [SENTINEL_KEY]: id },
|
|
121
|
-
""
|
|
122
|
-
);
|
|
119
|
+
window.history.replaceState({ ...prev && typeof prev === "object" ? prev : {}, [SENTINEL_KEY]: id }, "");
|
|
123
120
|
}
|
|
124
121
|
function currentSentinel() {
|
|
125
122
|
const tag = taggedSentinel();
|
|
@@ -209,7 +206,15 @@ function useOverlayHistory(open, onClose) {
|
|
|
209
206
|
window.history.replaceState(marked, "");
|
|
210
207
|
for (const owed of inherited.slice(0, -1)) pushed.push({ ...owed, dead: true });
|
|
211
208
|
const was = inherited[inherited.length - 1];
|
|
212
|
-
pushed.push({
|
|
209
|
+
pushed.push({
|
|
210
|
+
id,
|
|
211
|
+
href: currentHref(),
|
|
212
|
+
seen: currentHref(),
|
|
213
|
+
dead: false,
|
|
214
|
+
idx: was.idx,
|
|
215
|
+
key: was.key,
|
|
216
|
+
route: was.route
|
|
217
|
+
});
|
|
213
218
|
standing = id;
|
|
214
219
|
} else {
|
|
215
220
|
const under = pushed.findIndex((p) => p.id === currentSentinel());
|