@eifi1/ui-kit 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +32 -0
- package/README.md +498 -57
- package/dist/chart.d.ts +10 -0
- package/dist/chart.js +10 -0
- package/dist/chart.js.map +1 -0
- package/dist/components/account-settings.d.ts +11 -0
- package/dist/components/account-settings.js +19 -12
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/alert-banner.d.ts +10 -6
- package/dist/components/alert-banner.js +20 -16
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +30 -4
- package/dist/components/amount-input.js +36 -14
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/calculator.d.ts +24 -7
- package/dist/components/calculator.js +37 -20
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/chart-zoom.d.ts +156 -0
- package/dist/components/chart-zoom.js +262 -0
- package/dist/components/chart-zoom.js.map +1 -0
- package/dist/components/chart.d.ts +28 -3
- package/dist/components/chart.js +71 -32
- package/dist/components/chart.js.map +1 -1
- package/dist/components/checkbox.d.ts +51 -0
- package/dist/components/checkbox.js +123 -0
- package/dist/components/checkbox.js.map +1 -0
- package/dist/components/chip.d.ts +128 -0
- package/dist/components/chip.js +357 -0
- package/dist/components/chip.js.map +1 -0
- package/dist/components/choice-parts.d.ts +24 -0
- package/dist/components/choice-parts.js +18 -0
- package/dist/components/choice-parts.js.map +1 -0
- package/dist/components/combobox-core.d.ts +52 -9
- package/dist/components/combobox-core.js +91 -20
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +43 -33
- package/dist/components/combobox.js +409 -340
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/currency-select.d.ts +48 -8
- package/dist/components/currency-select.js +107 -65
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +15 -3
- package/dist/components/data-table-filter-popover.js +23 -17
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-filters.js +3 -2
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +53 -2
- package/dist/components/data-table-labels.js +31 -2
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-pagination.d.ts +3 -1
- package/dist/components/data-table-pagination.js +16 -10
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-sort.js +1 -0
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +201 -85
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +25 -9
- package/dist/components/date-picker.js +232 -94
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/dropdown.d.ts +96 -20
- package/dist/components/dropdown.js +77 -33
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +5 -4
- package/dist/components/entity-combobox.js +107 -76
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +36 -0
- package/dist/components/facing-pair.js +40 -0
- package/dist/components/facing-pair.js.map +1 -0
- package/dist/components/field-sync.d.ts +164 -0
- package/dist/components/field-sync.js +264 -0
- package/dist/components/field-sync.js.map +1 -0
- package/dist/components/file-dropzone.d.ts +38 -6
- package/dist/components/file-dropzone.js +34 -9
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/full-bleed-dialog.d.ts +37 -26
- package/dist/components/full-bleed-dialog.js +20 -5
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/grouped-picker.d.ts +21 -10
- package/dist/components/grouped-picker.js +66 -52
- package/dist/components/grouped-picker.js.map +1 -1
- package/dist/components/hover-menu.d.ts +20 -4
- package/dist/components/hover-menu.js +15 -4
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +102 -14
- package/dist/components/mini-calendar.js +256 -70
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +9 -3
- package/dist/components/modal.js +34 -45
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +79 -0
- package/dist/components/month-picker.js +342 -0
- package/dist/components/month-picker.js.map +1 -0
- package/dist/components/multi-entity-combobox.d.ts +7 -6
- package/dist/components/multi-entity-combobox.js +112 -72
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +13 -11
- package/dist/components/multi-select.js +161 -84
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +92 -0
- package/dist/components/number-field.js +111 -0
- package/dist/components/number-field.js.map +1 -0
- package/dist/components/number-input.d.ts +28 -2
- package/dist/components/number-input.js +12 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +32 -4
- package/dist/components/numpad-sheet.js +30 -13
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/page-contents.d.ts +90 -0
- package/dist/components/page-contents.js +170 -0
- package/dist/components/page-contents.js.map +1 -0
- package/dist/components/password-strength.d.ts +106 -0
- package/dist/components/password-strength.js +132 -0
- package/dist/components/password-strength.js.map +1 -0
- package/dist/components/picker-sheet.d.ts +23 -15
- package/dist/components/picker-sheet.js +34 -14
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/popover.d.ts +62 -4
- package/dist/components/popover.js +52 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/search-field.d.ts +25 -7
- package/dist/components/search-field.js +7 -5
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/series-chart-labels.d.ts +14 -0
- package/dist/components/series-chart-labels.js +11 -0
- package/dist/components/series-chart-labels.js.map +1 -0
- package/dist/components/series-chart.d.ts +207 -0
- package/dist/components/series-chart.js +223 -0
- package/dist/components/series-chart.js.map +1 -0
- package/dist/components/settings-fields.d.ts +14 -4
- package/dist/components/settings-fields.js +19 -8
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/signature-pad.d.ts +119 -0
- package/dist/components/signature-pad.js +405 -0
- package/dist/components/signature-pad.js.map +1 -0
- package/dist/components/slider.d.ts +80 -0
- package/dist/components/slider.js +155 -0
- package/dist/components/slider.js.map +1 -0
- package/dist/components/sparkline.d.ts +93 -0
- package/dist/components/sparkline.js +205 -0
- package/dist/components/sparkline.js.map +1 -0
- package/dist/components/stat-tile.d.ts +151 -0
- package/dist/components/stat-tile.js +236 -0
- package/dist/components/stat-tile.js.map +1 -0
- package/dist/components/swipeable-row.d.ts +25 -2
- package/dist/components/swipeable-row.js +31 -0
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/switch.d.ts +60 -0
- package/dist/components/switch.js +81 -0
- package/dist/components/switch.js.map +1 -0
- package/dist/components/time-input.d.ts +58 -0
- package/dist/components/time-input.js +50 -0
- package/dist/components/time-input.js.map +1 -0
- package/dist/components/toggle-group.d.ts +18 -3
- package/dist/components/toggle-group.js +10 -6
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +115 -0
- package/dist/components/toggle-legend.js +114 -0
- package/dist/components/toggle-legend.js.map +1 -0
- package/dist/components/tooltip.d.ts +40 -11
- package/dist/components/tooltip.js +77 -20
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/treemap.d.ts +132 -0
- package/dist/components/treemap.js +179 -0
- package/dist/components/treemap.js.map +1 -0
- package/dist/components/ui.d.ts +159 -46
- package/dist/components/ui.js +220 -115
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-mobile-reveal.js +1 -0
- package/dist/components/use-mobile-reveal.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/use-table-state.js +38 -17
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/components/user-avatar.d.ts +15 -4
- package/dist/components/user-avatar.js +5 -4
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/components/wizard-stepper.js +3 -2
- package/dist/components/wizard-stepper.js.map +1 -1
- package/dist/{data-table-filters-aXyf0Xub.d.ts → data-table-filters-DM1auUfH.d.ts} +27 -5
- package/dist/data-table.d.ts +7 -0
- package/dist/data-table.js +12 -0
- package/dist/data-table.js.map +1 -0
- package/dist/feedback/feedback-attachment.js +12 -11
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.js +2 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +33 -7
- package/dist/feedback/feedback-inbox.js +49 -37
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback.d.ts +5 -0
- package/dist/feedback.js +5 -0
- package/dist/feedback.js.map +1 -0
- package/dist/hooks/use-anchored-panel.js +1 -0
- package/dist/hooks/use-anchored-panel.js.map +1 -1
- package/dist/hooks/use-anchored-rect.js +1 -0
- package/dist/hooks/use-anchored-rect.js.map +1 -1
- package/dist/hooks/use-announce.d.ts +46 -0
- package/dist/hooks/use-announce.js +37 -0
- package/dist/hooks/use-announce.js.map +1 -0
- package/dist/hooks/use-body-scroll-lock.js +1 -0
- package/dist/hooks/use-body-scroll-lock.js.map +1 -1
- package/dist/hooks/use-close-transition.js +1 -0
- package/dist/hooks/use-close-transition.js.map +1 -1
- package/dist/hooks/use-dismiss.js +1 -0
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/hooks/use-focus-trap.d.ts +16 -0
- package/dist/hooks/use-focus-trap.js +67 -0
- package/dist/hooks/use-focus-trap.js.map +1 -0
- package/dist/hooks/use-media-query.js +1 -0
- package/dist/hooks/use-media-query.js.map +1 -1
- package/dist/hooks/use-overlay-history.js +27 -1
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/hooks/use-row-swipe.js +1 -0
- package/dist/hooks/use-row-swipe.js.map +1 -1
- package/dist/i18n/defaults.d.ts +31 -0
- package/dist/i18n/defaults.js +63 -0
- package/dist/i18n/defaults.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +250 -0
- package/dist/i18n/kit-labels.js +184 -0
- package/dist/i18n/kit-labels.js.map +1 -0
- package/dist/index.d.ts +47 -22
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/calc.js +1 -0
- package/dist/lib/calc.js.map +1 -1
- package/dist/lib/cn.js +1 -0
- package/dist/lib/cn.js.map +1 -1
- package/dist/lib/dates.js +1 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/logger.js +1 -0
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/safe-storage.js +1 -0
- package/dist/lib/safe-storage.js.map +1 -1
- package/dist/search/command-palette.d.ts +11 -1
- package/dist/search/command-palette.js +21 -15
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search.d.ts +2 -0
- package/dist/search.js +3 -0
- package/dist/search.js.map +1 -0
- package/dist/shell/app-shell.d.ts +33 -4
- package/dist/shell/app-shell.js +275 -78
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/option-switcher-menu.js +3 -2
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/role-switcher.d.ts +6 -0
- package/dist/shell/role-switcher.js +7 -4
- package/dist/shell/role-switcher.js.map +1 -1
- package/dist/shell/top-bar.d.ts +15 -7
- package/dist/shell/top-bar.js +3 -5
- package/dist/shell/top-bar.js.map +1 -1
- package/dist/shell/topbar-action-menu.js +3 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +5 -2
- package/dist/shell/topbar-controls.js +20 -15
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/shell.d.ts +13 -0
- package/dist/shell.js +8 -0
- package/dist/shell.js.map +1 -0
- package/dist/theme/chart-palette.d.ts +19 -12
- package/dist/theme/chart-palette.js +24 -30
- package/dist/theme/chart-palette.js.map +1 -1
- package/dist/theme/color.d.ts +78 -0
- package/dist/theme/color.js +148 -0
- package/dist/theme/color.js.map +1 -0
- package/dist/theme/palette-derive.d.ts +140 -0
- package/dist/theme/palette-derive.js +376 -0
- package/dist/theme/palette-derive.js.map +1 -0
- package/dist/theme/palette-presets.d.ts +27 -5
- package/dist/theme/palette-presets.js +231 -33
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/theme/palette-store.js +1 -0
- package/dist/theme/palette-store.js.map +1 -1
- package/dist/theme/theme-store.js +1 -0
- package/dist/theme/theme-store.js.map +1 -1
- package/dist/tour/tour.d.ts +2 -1
- package/dist/tour/tour.js +15 -9
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour.d.ts +2 -0
- package/dist/tour.js +3 -0
- package/dist/tour.js.map +1 -0
- package/dist/wizard/stepper-nav.js +10 -8
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +18 -2
- package/dist/wizard/types.js +4 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +12 -4
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/validation.js +1 -0
- package/dist/wizard/validation.js.map +1 -1
- package/dist/wizard/wizard-context.js +1 -0
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.js +1 -0
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard/wizard-summary.js +5 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +8 -0
- package/dist/wizard.js +9 -0
- package/dist/wizard.js.map +1 -0
- package/package.json +67 -14
- package/src/chart.ts +22 -0
- package/src/components/account-settings.tsx +275 -0
- package/src/components/alert-banner.tsx +75 -0
- package/src/components/amount-input.tsx +471 -0
- package/src/components/calculator.tsx +252 -0
- package/src/components/chart-zoom.tsx +499 -0
- package/src/components/chart.tsx +363 -0
- package/src/components/checkbox.tsx +222 -0
- package/src/components/chip.tsx +570 -0
- package/src/components/choice-parts.ts +33 -0
- package/src/components/combobox-core.tsx +540 -0
- package/src/components/combobox.tsx +1026 -0
- package/src/components/currency-select.tsx +269 -0
- package/src/components/data-table-filter-popover.tsx +252 -0
- package/src/components/data-table-filters.ts +176 -0
- package/src/components/data-table-labels.ts +190 -0
- package/src/components/data-table-pagination.tsx +126 -0
- package/src/components/data-table-sort.ts +70 -0
- package/src/components/data-table.tsx +1740 -0
- package/src/components/date-picker.tsx +631 -0
- package/src/components/dropdown.tsx +341 -0
- package/src/components/entity-combobox.tsx +215 -0
- package/src/components/facing-pair.ts +86 -0
- package/src/components/field-sync.tsx +472 -0
- package/src/components/file-dropzone.tsx +181 -0
- package/src/components/full-bleed-dialog.tsx +179 -0
- package/src/components/grouped-picker.tsx +157 -0
- package/src/components/hover-menu.tsx +183 -0
- package/src/components/mini-calendar.tsx +554 -0
- package/src/components/modal.tsx +287 -0
- package/src/components/month-picker.tsx +549 -0
- package/src/components/multi-entity-combobox.tsx +226 -0
- package/src/components/multi-select.tsx +303 -0
- package/src/components/number-field.tsx +225 -0
- package/src/components/number-input.tsx +211 -0
- package/src/components/numpad-sheet.tsx +264 -0
- package/src/components/page-contents.tsx +289 -0
- package/src/components/password-strength.tsx +258 -0
- package/src/components/picker-sheet.tsx +251 -0
- package/src/components/popover.tsx +195 -0
- package/src/components/search-field.tsx +146 -0
- package/src/components/series-chart-labels.ts +26 -0
- package/src/components/series-chart.tsx +481 -0
- package/src/components/settings-fields.tsx +68 -0
- package/src/components/signature-pad.tsx +630 -0
- package/src/components/slider.tsx +313 -0
- package/src/components/sparkline.tsx +329 -0
- package/src/components/stat-tile.tsx +448 -0
- package/src/components/swipeable-row.tsx +271 -0
- package/src/components/switch.tsx +170 -0
- package/src/components/time-input.tsx +107 -0
- package/src/components/toggle-group.tsx +153 -0
- package/src/components/toggle-legend.tsx +229 -0
- package/src/components/tooltip.tsx +477 -0
- package/src/components/treemap.tsx +390 -0
- package/src/components/ui.tsx +1284 -0
- package/src/components/use-mobile-reveal.ts +54 -0
- package/src/components/use-table-state.ts +295 -0
- package/src/components/user-avatar.tsx +61 -0
- package/src/components/wizard-stepper.tsx +37 -0
- package/src/data-table.ts +19 -0
- package/src/feedback/feedback-attachment.tsx +246 -0
- package/src/feedback/feedback-dialog.tsx +209 -0
- package/src/feedback/feedback-inbox.tsx +615 -0
- package/src/feedback.ts +8 -0
- package/src/hooks/use-anchored-panel.ts +169 -0
- package/src/hooks/use-anchored-rect.ts +78 -0
- package/src/hooks/use-announce.ts +85 -0
- package/src/hooks/use-body-scroll-lock.ts +94 -0
- package/src/hooks/use-close-transition.ts +99 -0
- package/src/hooks/use-dismiss.ts +77 -0
- package/src/hooks/use-focus-trap.ts +160 -0
- package/src/hooks/use-media-query.ts +23 -0
- package/src/hooks/use-overlay-history.ts +345 -0
- package/src/hooks/use-row-swipe.ts +212 -0
- package/src/i18n/defaults.ts +68 -0
- package/src/i18n/kit-labels.tsx +447 -0
- package/src/index.ts +169 -0
- package/src/lib/calc.ts +309 -0
- package/src/lib/cn.ts +5 -0
- package/src/lib/dates.ts +187 -0
- package/src/lib/logger.ts +73 -0
- package/src/lib/safe-storage.ts +37 -0
- package/src/search/command-palette.tsx +342 -0
- package/src/search.ts +3 -0
- package/src/shell/app-shell.tsx +772 -0
- package/src/shell/option-switcher-menu.tsx +72 -0
- package/src/shell/role-switcher.tsx +46 -0
- package/src/shell/top-bar.tsx +39 -0
- package/src/shell/topbar-action-menu.tsx +91 -0
- package/src/shell/topbar-controls.tsx +229 -0
- package/src/shell.ts +10 -0
- package/src/theme/chart-palette.ts +145 -0
- package/src/theme/color.ts +266 -0
- package/src/theme/palette-derive.ts +681 -0
- package/src/theme/palette-presets.ts +760 -0
- package/src/theme/palette-store.ts +94 -0
- package/src/theme/theme-store.ts +145 -0
- package/src/tour/tour.tsx +586 -0
- package/src/tour.ts +3 -0
- package/src/vite-env.d.ts +26 -0
- package/src/wizard/stepper-nav.tsx +221 -0
- package/src/wizard/types.ts +178 -0
- package/src/wizard/use-wizard.ts +345 -0
- package/src/wizard/validation.ts +37 -0
- package/src/wizard/wizard-context.tsx +29 -0
- package/src/wizard/wizard-step.tsx +14 -0
- package/src/wizard/wizard-summary.tsx +69 -0
- package/src/wizard.ts +14 -0
- package/tokens.css +222 -20
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/feedback/feedback-inbox.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport {\n Ban,\n Bug,\n CheckCircle2,\n CloudUpload,\n Eye,\n HelpCircle,\n Inbox,\n Lightbulb,\n MoreHorizontal,\n OctagonAlert,\n PauseCircle,\n Wrench,\n} from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Button, Textarea } from \"../components/ui\";\nimport { Tooltip } from \"../components/tooltip\";\nimport { FeedbackAttachmentField } from \"./feedback-attachment\";\nimport type { FeedbackAttachmentLabels } from \"./feedback-dialog\";\n\n/**\n * The feedback **inbox**, as the parts two apps were each writing separately.\n *\n * `feedback-dialog.tsx` next door is the other half — the form somebody files a\n * report with — and the split between them is the same one: this owns the\n * *vocabulary*, the *policy* about how a report moves, and the *look*; the app\n * owns the data, the API and every string.\n *\n * It exists because both apps had grown their own copy. Keksdose's is the one\n * this is lifted from, comments and all, because it is the one that had been\n * argued with users for a year — the glyph per status, the two sizes of the\n * status control, what a row offers from where it currently stands. Steering\n * Design's was thinner in every one of those places and read as a different\n * product for no reason anyone chose.\n *\n * **What is deliberately NOT here.** The two apps store a report differently and\n * are meant to: one keeps the reporter's context in a JSON column and the\n * screenshot in an object store, the other keeps a `page_path` and the bytes in\n * the row. So there is no `Feedback` type in this file and nothing here takes\n * one. Every component takes the values it draws, and the detail panel is a\n * *shell* the app fills — which is what lets each keep the shape that suits it\n * without either of them inventing a second look for a status pill.\n */\n\n/** The seven states a report can be in.\n *\n * Shared value for value across both apps on purpose: it is what lets one habit,\n * and one agent prompt, work on either repo. Four are a chain and three sit off\n * it — see {@link visibleFeedbackStatuses} for what that buys. */\nexport type FeedbackStatus =\n | \"OPEN\"\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 are a violet and an indigo apart —\n * so every status carries its own shape as well. Declaration order is chain\n * order, and {@link FEEDBACK_STATUS_ORDER} is derived from it rather than\n * restated, because a restated list cannot be checked for exhaustiveness: an\n * eighth status would break the build here and leave a literal seven long.\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-slate-200 dark:bg-slate-700\",\n activeText: \"text-slate-900 dark:text-white\",\n },\n IN_PROGRESS: {\n icon: Wrench,\n activeBg: \"bg-amber-100 dark:bg-amber-500/20\",\n activeText: \"text-amber-700 dark:text-amber-300\",\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-sky-100 dark:bg-sky-500/20\",\n activeText: \"text-sky-700 dark:text-sky-300\",\n },\n // Resolved but only checkable on a deployed build — indigo, so it reads as\n // \"waiting on something\" rather than as a done or a refusal.\n NEEDS_LIVE_TEST: {\n icon: CloudUpload,\n activeBg: \"bg-indigo-100 dark:bg-indigo-500/20\",\n activeText: \"text-indigo-700 dark:text-indigo-300\",\n },\n // Parked on purpose. Muted, not red: it is not a refusal.\n POSTPONED: {\n icon: PauseCircle,\n activeBg: \"bg-slate-200 dark:bg-slate-700\",\n activeText: \"text-slate-600 dark:text-slate-300\",\n },\n DONE: {\n icon: CheckCircle2,\n activeBg: \"bg-emerald-100 dark:bg-emerald-500/20\",\n activeText: \"text-emerald-700 dark:text-emerald-300\",\n },\n WONT_DO: {\n icon: Ban,\n activeBg: \"bg-rose-100 dark:bg-rose-500/20\",\n activeText: \"text-rose-700 dark:text-rose-300\",\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\", \"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. */\nexport function nextFeedbackStatus(current: FeedbackStatus): FeedbackStatus | null {\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 seven 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 (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. Red 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-red-100 dark:bg-red-500/20\",\n badgeText: \"text-red-700 dark:text-red-300\",\n },\n BUG: {\n icon: Bug,\n badgeBg: \"bg-amber-100 dark:bg-amber-500/20\",\n badgeText: \"text-amber-700 dark:text-amber-300\",\n },\n IDEA: {\n icon: Lightbulb,\n badgeBg: \"bg-slate-100 dark:bg-slate-800\",\n badgeText: \"text-slate-700 dark:text-slate-300\",\n },\n QUESTION: {\n icon: HelpCircle,\n badgeBg: \"bg-slate-100 dark:bg-slate-800\",\n badgeText: \"text-slate-700 dark:text-slate-300\",\n },\n OTHER: {\n icon: MoreHorizontal,\n badgeBg: \"bg-slate-100 dark:bg-slate-800\",\n badgeText: \"text-slate-700 dark:text-slate-300\",\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\n/**\n * The category as a badge.\n *\n * Falls back to OTHER's treatment for a category this build has never heard of,\n * so an older client against a newer API degrades to a readable neutral pill\n * instead of throwing on `meta.icon` — a crash report must not be able to cause\n * one. The *label* is the caller's, and for the same reason it needs the same\n * guard: pass what OTHER is called if you cannot name the value you were given.\n */\nexport function FeedbackCategoryBadge({\n category,\n label,\n compact = false,\n className,\n}: {\n category: FeedbackCategory;\n label: ReactNode;\n compact?: boolean;\n className?: string;\n}) {\n // Annotated `| undefined` because the Record's index signature promises a hit\n // for every FeedbackCategory, and at runtime the API can hand us one that is not.\n const known: (typeof FEEDBACK_CATEGORY_META)[\"OTHER\"] | undefined = FEEDBACK_CATEGORY_META[category];\n const meta = known ?? FEEDBACK_CATEGORY_META.OTHER;\n const Icon = meta.icon;\n return (\n <span\n className={cn(\n \"inline-flex items-center gap-1 rounded font-medium\",\n compact ? \"px-1.5 py-0.5 text-[11px]\" : \"px-2 py-0.5 text-xs\",\n meta.badgeBg,\n meta.badgeText,\n className,\n )}\n >\n <Icon className={compact ? \"size-3\" : \"size-3.5\"} aria-hidden />\n {label}\n </span>\n );\n}\n\n/** Where a report stands, as a badge — for the places that only *report* the\n * status rather than offering to change it. */\nexport function FeedbackStatusBadge({\n status,\n label,\n className,\n}: {\n status: FeedbackStatus;\n label: ReactNode;\n className?: string;\n}) {\n const meta = FEEDBACK_STATUS_META[status] ?? FEEDBACK_STATUS_META.OPEN;\n const Icon = meta.icon;\n return (\n <span\n className={cn(\n \"inline-flex items-center gap-1 whitespace-nowrap rounded px-1.5 py-0.5 text-[11px] font-medium\",\n meta.activeBg,\n meta.activeText,\n className,\n )}\n >\n <Icon className=\"size-3\" aria-hidden />\n {label}\n </span>\n );\n}\n\n/**\n * A row of statuses to switch a report to — either a table column's icon row or\n * an expanded panel's labelled pills.\n *\n * One component for both, because everything except the class strings is the\n * same decision: which one is current, and when the buttons are disabled. Two\n * copies had already drifted — only the icon variant carried `aria-label` and\n * `aria-pressed`, so the pill row was unlabelled for a screen reader.\n *\n * WHICH statuses to offer is the caller's, not this component's:\n * {@link visibleFeedbackStatuses} for a compact column,\n * {@link selectableFeedbackStatuses} for an expanded row. Passing the list keeps\n * that policy readable at the two call sites instead of hiding it behind\n * `variant`.\n */\nexport function FeedbackStatusTransitions({\n status,\n statuses,\n canEdit,\n onPick,\n variant,\n label,\n className,\n}: {\n status: FeedbackStatus;\n statuses: FeedbackStatus[];\n canEdit: boolean;\n onPick: (status: FeedbackStatus) => void;\n variant: \"icon\" | \"pill\";\n /** What each status is called, translated by the app. */\n label: (status: FeedbackStatus) => string;\n className?: string;\n}) {\n return (\n <div\n className={cn(variant === \"icon\" ? \"flex items-center gap-0.5\" : \"flex flex-wrap gap-1.5\", className)}\n // A status control inside a clickable row must not also open the row.\n onClick={(event) => event.stopPropagation()}\n >\n {statuses.map((value) => {\n const meta = FEEDBACK_STATUS_META[value];\n const Icon = meta.icon;\n const active = status === value;\n const name = label(value);\n const button = (\n <button\n key={value}\n type=\"button\"\n disabled={!canEdit || active}\n onClick={() => onPick(value)}\n aria-label={name}\n aria-pressed={active}\n className={cn(\n \"transition-colors\",\n variant === \"icon\"\n ? cn(\n \"flex size-7 items-center justify-center rounded\",\n active\n ? cn(meta.activeBg, meta.activeText)\n : \"text-slate-400 hover:text-slate-700 dark:text-slate-500 dark:hover:text-slate-200\",\n )\n : cn(\n \"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium\",\n active\n ? cn(meta.activeBg, meta.activeText, \"border-transparent\")\n : \"border-[var(--border)] bg-transparent text-[var(--text-primary)] hover:bg-[var(--bg-surface-2)]\",\n ),\n // Both stay unclickable, but only the read-only case is DIMMED: the\n // current status is the one thing in the row that has to be legible\n // at a glance, and among seven pills a faded active one reads as\n // \"unavailable\" rather than as \"this is where the row stands\".\n (!canEdit || active) && \"cursor-default\",\n !canEdit && \"opacity-60\",\n )}\n >\n <Icon className={variant === \"icon\" ? \"size-4\" : \"size-3.5\"} aria-hidden />\n {variant === \"pill\" && name}\n </button>\n );\n // The icon row has no visible label, so it needs the tooltip; the pills\n // carry theirs inline.\n //\n // ⚠️ ABOVE, and portalled (Keksdose live #339). `side=\"bottom\"` put the bubble\n // straight over the NEXT ROW's icons — in a queue you triage by running down it\n // with the pointer, that is the row you are about to reach, and it was covered\n // every time you paused on one. Above covers the row you have just left instead.\n //\n // `portal` is not cosmetic here. An absolutely-positioned bubble is clipped by\n // any ancestor that scrolls — this cell lives inside the DataTable's own\n // scroller — and it cannot flip: `placeTooltip` is what moves the bubble below\n // the icon for the FIRST row, where there is no room above it, instead of\n // painting it off the top of the list.\n return variant === \"icon\" ? (\n <Tooltip key={value} label={name} side=\"top\" portal>\n {button}\n </Tooltip>\n ) : (\n button\n );\n })}\n </div>\n );\n}\n\n/**\n * A textarea and its two buttons — for an outcome, for a note sent back with a\n * report, for a body being corrected.\n *\n * One component because all three are the same gesture: a draft that is not\n * committed until it is saved. Ctrl/⌘+Enter submits, which is the same shortcut\n * the compose dialog next door uses, so the habit carries across the feature.\n */\nexport function FeedbackNoteEditor({\n initial,\n pending,\n onSave,\n onCancel,\n saveLabel,\n cancelLabel,\n placeholder,\n rows = 3,\n attachment,\n}: {\n initial: string;\n pending: boolean;\n onSave: (value: string, attachment?: File | null) => void;\n onCancel: () => void;\n saveLabel: ReactNode;\n cancelLabel: ReactNode;\n /** A line above the field saying what to write, not an in-field placeholder:\n * a hint that disappears the moment somebody starts typing is a hint that is\n * gone exactly when it is being followed. */\n placeholder?: ReactNode;\n rows?: number;\n /** Offer a picture with the note (Steering Design feedback #128). Omitted,\n * the editor is exactly the text box it always was — which is what the\n * *outcome* editor beside it wants, since an outcome is the answer rather\n * than the evidence. */\n attachment?: FeedbackNoteAttachment;\n}) {\n const [draft, setDraft] = useState(initial);\n const [file, setFile] = useState<File | null>(null);\n // The editor's own root, which is where a paste made in the text box bubbles\n // to: the attachment field below is the box's sibling, so a paste in the box\n // never passes through the field's own subtree. It listens here instead\n // (Steering Design feedback #140).\n const root = useRef<HTMLDivElement>(null);\n useEffect(() => {\n // Re-seed when the editor is reopened with different content.\n setDraft(initial);\n setFile(null);\n }, [initial]);\n const submit = () => onSave(draft, file);\n return (\n <div\n ref={root}\n className=\"space-y-2\"\n onKeyDown={(event) => {\n if ((event.ctrlKey || event.metaKey) && event.key === \"Enter\" && !pending) {\n event.preventDefault();\n submit();\n }\n }}\n >\n {placeholder && <p className=\"text-xs text-slate-500 dark:text-slate-400\">{placeholder}</p>}\n <Textarea rows={rows} value={draft} onChange={(event) => setDraft(event.target.value)} />\n {attachment && (\n <FeedbackAttachmentField\n value={file}\n onChange={setFile}\n labels={attachment.labels}\n accept={attachment.accept}\n maxBytes={attachment.maxBytes}\n onError={attachment.onError}\n onCaptureScreenshot={attachment.onCaptureScreenshot}\n // Within THIS editor's subtree, not on `document`: the editor sits\n // inline on a page that has other fields, so a paste made in one of\n // them is meant for that one. The textarea above is where the caret\n // already is, and it is the field's sibling — so the field is told\n // to listen on their common parent, where the paste bubbles to.\n pasteFrom={root}\n />\n )}\n <div className=\"flex justify-end gap-2\">\n <Button variant=\"ghost\" onClick={onCancel}>\n {cancelLabel}\n </Button>\n <Button variant=\"brand\" disabled={pending} onClick={submit}>\n {saveLabel}\n </Button>\n </div>\n </div>\n );\n}\n\n/** What {@link FeedbackNoteEditor} needs in order to offer a picture with the\n * note: the same four things {@link FeedbackAttachmentField} takes, so the\n * reply path is held to the app's own limits rather than to the defaults. */\nexport interface FeedbackNoteAttachment {\n labels: FeedbackAttachmentLabels;\n accept?: string[];\n maxBytes?: number;\n onError?: (kind: \"type\" | \"size\") => void;\n onCaptureScreenshot?: () => Promise<File | null>;\n}\n\n/**\n * One labelled block of an opened report.\n *\n * The detail panel is a stack of these, and it is a stack rather than a\n * component with fixed fields on purpose: the two apps keep a report's context\n * and its screenshot in genuinely different places, and a shell that insisted on\n * both would force one of them to invent a shape it does not have. What is\n * shared is what a section *looks* like — and that is all that made the two\n * panels read as different products.\n */\nexport function FeedbackDetailSection({\n title,\n action,\n children,\n}: {\n title: ReactNode;\n /** Something on the title's own line — an edit button, a resolved-at date. */\n action?: ReactNode;\n children: ReactNode;\n}) {\n return (\n <div>\n <div className=\"mb-1 flex items-center justify-between gap-2\">\n <div className=\"text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400\">\n {title}\n </div>\n {action}\n </div>\n {children}\n </div>\n );\n}\n\n/** The stack a detail panel is. Here so the spacing between sections is decided\n * once rather than by whichever app was written second. */\nexport function FeedbackDetail({ children }: { children: ReactNode }) {\n return <div className=\"space-y-3 text-sm\">{children}</div>;\n}\n\n/** Prose inside a section — a body, an outcome, a note.\n *\n * `whitespace-pre-wrap` is the whole component: a report is written in\n * paragraphs, and a note appended to it later is separated by blank lines that\n * carry the entire \"this arrived after the answer\" reading. */\nexport function FeedbackProse({ children, empty }: { children?: string; empty?: ReactNode }) {\n if (!children) return <p className=\"text-slate-400 dark:text-slate-600\">{empty ?? \"—\"}</p>;\n return (\n <p className=\"whitespace-pre-wrap text-slate-700 dark:text-slate-300\">{children}</p>\n );\n}\n"],"mappings":"AAoRI,SASE,KATF;AApRJ,SAAS,WAAW,QAAQ,gBAAgB;AAE5C;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,OACK;AAEP,SAAS,UAAU;AACnB,SAAS,QAAQ,gBAAgB;AACjC,SAAS,eAAe;AACxB,SAAS,+BAA+B;AAsDjC,MAAM,uBAGT;AAAA,EACF,MAAM;AAAA,IACJ,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,EAGA,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,EACA,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;AAKO,MAAM,wBAAwB,OAAO,KAAK,oBAAoB;AAYrE,MAAM,YAA8B,CAAC,mBAAmB,aAAa,SAAS;AAE9E,MAAM,gBAAkC,CAAC,QAAQ,eAAe,iBAAiB,MAAM;AAIhF,SAAS,mBAAmB,SAAgD;AACjF,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,OAAO,IAAI;AACb,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;AAWO,SAAS,sBAAsB;AAAA,EACpC;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV;AACF,GAKG;AAGD,QAAM,QAA8D,uBAAuB,QAAQ;AACnG,QAAM,OAAO,SAAS,uBAAuB;AAC7C,QAAM,OAAO,KAAK;AAClB,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA,UAAU,8BAA8B;AAAA,QACxC,KAAK;AAAA,QACL,KAAK;AAAA,QACL;AAAA,MACF;AAAA,MAEA;AAAA,4BAAC,QAAK,WAAW,UAAU,WAAW,YAAY,eAAW,MAAC;AAAA,QAC7D;AAAA;AAAA;AAAA,EACH;AAEJ;AAIO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,QAAM,OAAO,qBAAqB,MAAM,KAAK,qBAAqB;AAClE,QAAM,OAAO,KAAK;AAClB,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA,KAAK;AAAA,QACL,KAAK;AAAA,QACL;AAAA,MACF;AAAA,MAEA;AAAA,4BAAC,QAAK,WAAU,UAAS,eAAW,MAAC;AAAA,QACpC;AAAA;AAAA;AAAA,EACH;AAEJ;AAiBO,SAAS,0BAA0B;AAAA,EACxC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GASG;AACD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW,GAAG,YAAY,SAAS,8BAA8B,0BAA0B,SAAS;AAAA,MAEpG,SAAS,CAAC,UAAU,MAAM,gBAAgB;AAAA,MAEzC,mBAAS,IAAI,CAAC,UAAU;AACvB,cAAM,OAAO,qBAAqB,KAAK;AACvC,cAAM,OAAO,KAAK;AAClB,cAAM,SAAS,WAAW;AAC1B,cAAM,OAAO,MAAM,KAAK;AACxB,cAAM,SACJ;AAAA,UAAC;AAAA;AAAA,YAEC,MAAK;AAAA,YACL,UAAU,CAAC,WAAW;AAAA,YACtB,SAAS,MAAM,OAAO,KAAK;AAAA,YAC3B,cAAY;AAAA,YACZ,gBAAc;AAAA,YACd,WAAW;AAAA,cACT;AAAA,cACA,YAAY,SACR;AAAA,gBACE;AAAA,gBACA,SACI,GAAG,KAAK,UAAU,KAAK,UAAU,IACjC;AAAA,cACN,IACA;AAAA,gBACE;AAAA,gBACA,SACI,GAAG,KAAK,UAAU,KAAK,YAAY,oBAAoB,IACvD;AAAA,cACN;AAAA;AAAA;AAAA;AAAA;AAAA,eAKH,CAAC,WAAW,WAAW;AAAA,cACxB,CAAC,WAAW;AAAA,YACd;AAAA,YAEA;AAAA,kCAAC,QAAK,WAAW,YAAY,SAAS,WAAW,YAAY,eAAW,MAAC;AAAA,cACxE,YAAY,UAAU;AAAA;AAAA;AAAA,UA9BlB;AAAA,QA+BP;AAeF,eAAO,YAAY,SACjB,oBAAC,WAAoB,OAAO,MAAM,MAAK,OAAM,QAAM,MAChD,oBADW,KAEd,IAEA;AAAA,MAEJ,CAAC;AAAA;AAAA,EACH;AAEJ;AAUO,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP;AACF,GAiBG;AACD,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAS,OAAO;AAC1C,QAAM,CAAC,MAAM,OAAO,IAAI,SAAsB,IAAI;AAKlD,QAAM,OAAO,OAAuB,IAAI;AACxC,YAAU,MAAM;AAEd,aAAS,OAAO;AAChB,YAAQ,IAAI;AAAA,EACd,GAAG,CAAC,OAAO,CAAC;AACZ,QAAM,SAAS,MAAM,OAAO,OAAO,IAAI;AACvC,SACE;AAAA,IAAC;AAAA;AAAA,MACC,KAAK;AAAA,MACL,WAAU;AAAA,MACV,WAAW,CAAC,UAAU;AACpB,aAAK,MAAM,WAAW,MAAM,YAAY,MAAM,QAAQ,WAAW,CAAC,SAAS;AACzE,gBAAM,eAAe;AACrB,iBAAO;AAAA,QACT;AAAA,MACF;AAAA,MAEC;AAAA,uBAAe,oBAAC,OAAE,WAAU,8CAA8C,uBAAY;AAAA,QACvF,oBAAC,YAAS,MAAY,OAAO,OAAO,UAAU,CAAC,UAAU,SAAS,MAAM,OAAO,KAAK,GAAG;AAAA,QACtF,cACC;AAAA,UAAC;AAAA;AAAA,YACC,OAAO;AAAA,YACP,UAAU;AAAA,YACV,QAAQ,WAAW;AAAA,YACnB,QAAQ,WAAW;AAAA,YACnB,UAAU,WAAW;AAAA,YACrB,SAAS,WAAW;AAAA,YACpB,qBAAqB,WAAW;AAAA,YAMhC,WAAW;AAAA;AAAA,QACb;AAAA,QAEF,qBAAC,SAAI,WAAU,0BACb;AAAA,8BAAC,UAAO,SAAQ,SAAQ,SAAS,UAC9B,uBACH;AAAA,UACA,oBAAC,UAAO,SAAQ,SAAQ,UAAU,SAAS,SAAS,QACjD,qBACH;AAAA,WACF;AAAA;AAAA;AAAA,EACF;AAEJ;AAuBO,SAAS,sBAAsB;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AACF,GAKG;AACD,SACE,qBAAC,SACC;AAAA,yBAAC,SAAI,WAAU,gDACb;AAAA,0BAAC,SAAI,WAAU,kFACZ,iBACH;AAAA,MACC;AAAA,OACH;AAAA,IACC;AAAA,KACH;AAEJ;AAIO,SAAS,eAAe,EAAE,SAAS,GAA4B;AACpE,SAAO,oBAAC,SAAI,WAAU,qBAAqB,UAAS;AACtD;AAOO,SAAS,cAAc,EAAE,UAAU,MAAM,GAA6C;AAC3F,MAAI,CAAC,SAAU,QAAO,oBAAC,OAAE,WAAU,sCAAsC,mBAAS,UAAI;AACtF,SACE,oBAAC,OAAE,WAAU,0DAA0D,UAAS;AAEpF;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/feedback/feedback-inbox.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport {\n Ban,\n Bug,\n CheckCircle2,\n CloudUpload,\n Eye,\n HelpCircle,\n Inbox,\n Lightbulb,\n MoreHorizontal,\n OctagonAlert,\n PauseCircle,\n Wrench,\n} from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Button, Textarea } from \"../components/ui\";\nimport { Tooltip } from \"../components/tooltip\";\nimport { FeedbackAttachmentField } from \"./feedback-attachment\";\nimport type { FeedbackAttachmentLabels } from \"./feedback-dialog\";\n\n/**\n * The feedback **inbox**, as the parts two apps were each writing separately.\n *\n * `feedback-dialog.tsx` next door is the other half — the form somebody files a\n * report with — and the split between them is the same one: this owns the\n * *vocabulary*, the *policy* about how a report moves, and the *look*; the app\n * owns the data, the API and every string.\n *\n * It exists because both apps had grown their own copy. Keksdose's is the one\n * this is lifted from, comments and all, because it is the one that had been\n * argued with users for a year — the glyph per status, the two sizes of the\n * status control, what a row offers from where it currently stands. Steering\n * Design's was thinner in every one of those places and read as a different\n * product for no reason anyone chose.\n *\n * **What is deliberately NOT here.** The two apps store a report differently and\n * are meant to: one keeps the reporter's context in a JSON column and the\n * screenshot in an object store, the other keeps a `page_path` and the bytes in\n * the row. So there is no `Feedback` type in this file and nothing here takes\n * one. Every component takes the values it draws, and the detail panel is a\n * *shell* the app fills — which is what lets each keep the shape that suits it\n * without either of them inventing a second look for a status pill.\n */\n\n/** The seven states a report can be in.\n *\n * Shared value for value across both apps on purpose: it is what lets one habit,\n * and one agent prompt, work on either repo. Four are a chain and three sit off\n * it — see {@link visibleFeedbackStatuses} for what that buys. */\nexport type FeedbackStatus =\n | \"OPEN\"\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 seven long.\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-[var(--bg-active)]\",\n activeText: \"text-[var(--text-primary)]\",\n },\n IN_PROGRESS: {\n icon: Wrench,\n activeBg: \"bg-[var(--warning-bg)]\",\n activeText: \"text-[var(--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-[var(--info-bg)]\",\n activeText: \"text-[var(--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-[var(--brand-bg)]\",\n activeText: \"text-[var(--brand-muted)]\",\n },\n // Parked on purpose. Muted, not `--danger`: it is not a refusal.\n POSTPONED: {\n icon: PauseCircle,\n activeBg: \"bg-[var(--bg-active)]\",\n activeText: \"text-[var(--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-[var(--success-bg)]\",\n activeText: \"text-[var(--success)]\",\n },\n WONT_DO: {\n icon: Ban,\n activeBg: \"bg-[var(--danger-bg)]\",\n activeText: \"text-[var(--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\", \"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. */\nexport function nextFeedbackStatus(current: FeedbackStatus): FeedbackStatus | null {\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 seven 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 (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-[var(--danger-bg)]\",\n badgeText: \"text-[var(--danger)]\",\n },\n BUG: {\n icon: Bug,\n badgeBg: \"bg-[var(--warning-bg)]\",\n badgeText: \"text-[var(--warning)]\",\n },\n IDEA: {\n icon: Lightbulb,\n badgeBg: \"bg-[var(--bg-surface-2)]\",\n badgeText: \"text-[var(--text-secondary)]\",\n },\n QUESTION: {\n icon: HelpCircle,\n badgeBg: \"bg-[var(--bg-surface-2)]\",\n badgeText: \"text-[var(--text-secondary)]\",\n },\n OTHER: {\n icon: MoreHorizontal,\n badgeBg: \"bg-[var(--bg-surface-2)]\",\n badgeText: \"text-[var(--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\n/**\n * The category as a badge.\n *\n * Falls back to OTHER's treatment for a category this build has never heard of,\n * so an older client against a newer API degrades to a readable neutral pill\n * instead of throwing on `meta.icon` — a crash report must not be able to cause\n * one. The *label* is the caller's, and for the same reason it needs the same\n * guard: pass what OTHER is called if you cannot name the value you were given.\n */\nexport function FeedbackCategoryBadge({\n category,\n label,\n compact = false,\n className,\n}: {\n category: FeedbackCategory;\n label: ReactNode;\n compact?: boolean;\n className?: string;\n}) {\n // Annotated `| undefined` because the Record's index signature promises a hit\n // for every FeedbackCategory, and at runtime the API can hand us one that is not.\n const known: (typeof FEEDBACK_CATEGORY_META)[\"OTHER\"] | undefined = FEEDBACK_CATEGORY_META[category];\n const meta = known ?? FEEDBACK_CATEGORY_META.OTHER;\n const Icon = meta.icon;\n return (\n <span\n className={cn(\n \"inline-flex items-center gap-1 rounded font-medium\",\n compact ? \"px-1.5 py-0.5 text-[11px]\" : \"px-2 py-0.5 text-xs\",\n meta.badgeBg,\n meta.badgeText,\n className,\n )}\n >\n <Icon className={compact ? \"size-3\" : \"size-3.5\"} aria-hidden />\n {label}\n </span>\n );\n}\n\n/** Where a report stands, as a badge — for the places that only *report* the\n * status rather than offering to change it. */\nexport function FeedbackStatusBadge({\n status,\n label,\n className,\n}: {\n status: FeedbackStatus;\n label: ReactNode;\n className?: string;\n}) {\n const meta = FEEDBACK_STATUS_META[status] ?? FEEDBACK_STATUS_META.OPEN;\n const Icon = meta.icon;\n return (\n <span\n className={cn(\n \"inline-flex items-center gap-1 whitespace-nowrap rounded px-1.5 py-0.5 text-[11px] font-medium\",\n meta.activeBg,\n meta.activeText,\n className,\n )}\n >\n <Icon className=\"size-3\" aria-hidden />\n {label}\n </span>\n );\n}\n\n/**\n * A row of statuses to switch a report to — either a table column's icon row or\n * an expanded panel's labelled pills.\n *\n * One component for both, because everything except the class strings is the\n * same decision: which one is current, and when the buttons are disabled. Two\n * copies had already drifted — only the icon variant carried `aria-label` and\n * `aria-pressed`, so the pill row was unlabelled for a screen reader.\n *\n * WHICH statuses to offer is the caller's, not this component's:\n * {@link visibleFeedbackStatuses} for a compact column,\n * {@link selectableFeedbackStatuses} for an expanded row. Passing the list keeps\n * that policy readable at the two call sites instead of hiding it behind\n * `variant`.\n */\nexport function FeedbackStatusTransitions({\n status,\n statuses,\n canEdit,\n onPick,\n variant,\n label,\n className,\n}: {\n status: FeedbackStatus;\n statuses: FeedbackStatus[];\n canEdit: boolean;\n onPick: (status: FeedbackStatus) => void;\n variant: \"icon\" | \"pill\";\n /** What each status is called, translated by the app. */\n label: (status: FeedbackStatus) => string;\n className?: string;\n}) {\n return (\n <div\n className={cn(variant === \"icon\" ? \"flex items-center gap-0.5\" : \"flex flex-wrap gap-1.5\", className)}\n // A status control inside a clickable row must not also open the row.\n onClick={(event) => event.stopPropagation()}\n >\n {statuses.map((value) => {\n const meta = FEEDBACK_STATUS_META[value];\n const Icon = meta.icon;\n const active = status === value;\n const name = label(value);\n const button = (\n <button\n key={value}\n type=\"button\"\n disabled={!canEdit || active}\n onClick={() => onPick(value)}\n aria-label={name}\n aria-pressed={active}\n className={cn(\n \"transition-colors\",\n variant === \"icon\"\n ? cn(\n \"flex size-7 items-center justify-center rounded\",\n active\n ? cn(meta.activeBg, meta.activeText)\n : \"text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]\",\n )\n : cn(\n \"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium\",\n active\n ? cn(meta.activeBg, meta.activeText, \"border-transparent\")\n : \"border-[var(--border)] bg-transparent text-[var(--text-primary)] hover:bg-[var(--bg-surface-2)]\",\n ),\n // Both stay unclickable, but only the read-only case is DIMMED: the\n // current status is the one thing in the row that has to be legible\n // at a glance, and among seven pills a faded active one reads as\n // \"unavailable\" rather than as \"this is where the row stands\".\n (!canEdit || active) && \"cursor-default\",\n !canEdit && \"opacity-60\",\n )}\n >\n <Icon className={variant === \"icon\" ? \"size-4\" : \"size-3.5\"} aria-hidden />\n {variant === \"pill\" && name}\n </button>\n );\n // The icon row has no visible label, so it needs the tooltip; the pills\n // carry theirs inline.\n //\n // ⚠️ ABOVE, and portalled (Keksdose live #339). `side=\"bottom\"` put the bubble\n // straight over the NEXT ROW's icons — in a queue you triage by running down it\n // with the pointer, that is the row you are about to reach, and it was covered\n // every time you paused on one. Above covers the row you have just left instead.\n //\n // `portal` is not cosmetic here. An absolutely-positioned bubble is clipped by\n // any ancestor that scrolls — this cell lives inside the DataTable's own\n // scroller — and it cannot flip: `placeTooltip` is what moves the bubble below\n // the icon for the FIRST row, where there is no room above it, instead of\n // painting it off the top of the list.\n return variant === \"icon\" ? (\n <Tooltip key={value} label={name} side=\"top\" portal>\n {button}\n </Tooltip>\n ) : (\n button\n );\n })}\n </div>\n );\n}\n\n/**\n * A textarea and its two buttons — for an outcome, for a note sent back with a\n * report, for a body being corrected.\n *\n * One component because all three are the same gesture: a draft that is not\n * committed until it is saved. Ctrl/⌘+Enter submits, which is the same shortcut\n * the compose dialog next door uses, so the habit carries across the feature.\n *\n * ## `initial` seeds the draft; it does not own it (changed 2026-09-22)\n *\n * This used to re-seed from `initial` in an effect, and that effect threw away\n * whatever was in the box — **and the picture attached to it** — every time the\n * prop arrived with a different value. `initial` is normally the saved note, so\n * the list refreshing under the editor, or the owner re-deriving the same string,\n * was enough: a triager halfway through a reply, with a screenshot picked out,\n * lost both and got the saved text back with no way to undo it.\n *\n * A draft belongs to the thing being edited, so switching to a different note is a\n * different editor — say so with a `key`:\n *\n * ```tsx\n * <FeedbackNoteEditor key={note.id} initial={note.body} … />\n * ```\n *\n * Where the id is not in hand at the call site, pass {@link resetKey} instead and\n * the editor re-seeds when THAT changes. Either way the decision is the caller's,\n * which is the point: the editor cannot tell a new subject from a new render.\n */\nexport function FeedbackNoteEditor({\n initial,\n pending,\n onSave,\n onCancel,\n saveLabel,\n cancelLabel,\n placeholder,\n rows = 3,\n attachment,\n resetKey,\n}: {\n initial: string;\n pending: boolean;\n onSave: (value: string, attachment?: File | null) => void;\n onCancel: () => void;\n saveLabel: ReactNode;\n cancelLabel: ReactNode;\n /** A line above the field saying what to write, not an in-field placeholder:\n * a hint that disappears the moment somebody starts typing is a hint that is\n * gone exactly when it is being followed. */\n placeholder?: ReactNode;\n rows?: number;\n /** Offer a picture with the note (Steering Design feedback #128). Omitted,\n * the editor is exactly the text box it always was — which is what the\n * *outcome* editor beside it wants, since an outcome is the answer rather\n * than the evidence. */\n attachment?: FeedbackNoteAttachment;\n /** Change this to say \"the editor is now editing something ELSE\", and the draft\n * and its attachment are dropped and re-seeded from `initial`. For call sites\n * that cannot put a `key` on the editor (see the note above). Leave it out and\n * the draft is never thrown away behind the user's back. */\n resetKey?: string | number;\n}) {\n const [draft, setDraft] = useState(initial);\n const [file, setFile] = useState<File | null>(null);\n // Adjusted during render rather than in an effect: an effect would paint the old\n // draft first and then replace it, and — far worse — it is a second definition of\n // when a draft dies that the caller cannot see. React re-runs this component\n // immediately, before anything is committed to the screen.\n const [seededFor, setSeededFor] = useState(resetKey);\n if (resetKey !== seededFor) {\n setSeededFor(resetKey);\n setDraft(initial);\n setFile(null);\n }\n // The editor's own root, which is where a paste made in the text box bubbles\n // to: the attachment field below is the box's sibling, so a paste in the box\n // never passes through the field's own subtree. It listens here instead\n // (Steering Design feedback #140).\n const root = useRef<HTMLDivElement>(null);\n const submit = () => onSave(draft, file);\n return (\n <div\n ref={root}\n className=\"space-y-2\"\n onKeyDown={(event) => {\n if ((event.ctrlKey || event.metaKey) && event.key === \"Enter\" && !pending) {\n event.preventDefault();\n submit();\n }\n }}\n >\n {placeholder && <p className=\"text-xs text-[var(--text-muted)]\">{placeholder}</p>}\n <Textarea rows={rows} value={draft} onChange={(event) => setDraft(event.target.value)} />\n {attachment && (\n <FeedbackAttachmentField\n value={file}\n onChange={setFile}\n labels={attachment.labels}\n accept={attachment.accept}\n maxBytes={attachment.maxBytes}\n onError={attachment.onError}\n onCaptureScreenshot={attachment.onCaptureScreenshot}\n // Within THIS editor's subtree, not on `document`: the editor sits\n // inline on a page that has other fields, so a paste made in one of\n // them is meant for that one. The textarea above is where the caret\n // already is, and it is the field's sibling — so the field is told\n // to listen on their common parent, where the paste bubbles to.\n pasteFrom={root}\n />\n )}\n <div className=\"flex justify-end gap-2\">\n <Button variant=\"ghost\" onClick={onCancel}>\n {cancelLabel}\n </Button>\n <Button variant=\"brand\" disabled={pending} onClick={submit}>\n {saveLabel}\n </Button>\n </div>\n </div>\n );\n}\n\n/** What {@link FeedbackNoteEditor} needs in order to offer a picture with the\n * note: the same four things {@link FeedbackAttachmentField} takes, so the\n * reply path is held to the app's own limits rather than to the defaults. */\nexport interface FeedbackNoteAttachment {\n labels: FeedbackAttachmentLabels;\n accept?: string[];\n maxBytes?: number;\n onError?: (kind: \"type\" | \"size\") => void;\n onCaptureScreenshot?: () => Promise<File | null>;\n}\n\n/**\n * One labelled block of an opened report.\n *\n * The detail panel is a stack of these, and it is a stack rather than a\n * component with fixed fields on purpose: the two apps keep a report's context\n * and its screenshot in genuinely different places, and a shell that insisted on\n * both would force one of them to invent a shape it does not have. What is\n * shared is what a section *looks* like — and that is all that made the two\n * panels read as different products.\n */\nexport function FeedbackDetailSection({\n title,\n action,\n children,\n}: {\n title: ReactNode;\n /** Something on the title's own line — an edit button, a resolved-at date. */\n action?: ReactNode;\n children: ReactNode;\n}) {\n return (\n <div>\n <div className=\"mb-1 flex items-center justify-between gap-2\">\n <div className=\"text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]\">\n {title}\n </div>\n {action}\n </div>\n {children}\n </div>\n );\n}\n\n/** The stack a detail panel is. Here so the spacing between sections is decided\n * once rather than by whichever app was written second. */\nexport function FeedbackDetail({ children }: { children: ReactNode }) {\n return <div className=\"space-y-3 text-sm\">{children}</div>;\n}\n\n/** Prose inside a section — a body, an outcome, a note.\n *\n * `whitespace-pre-wrap` is the whole component: a report is written in\n * paragraphs, and a note appended to it later is separated by blank lines that\n * carry the entire \"this arrived after the answer\" reading. */\nexport function FeedbackProse({ children, empty }: { children?: string; empty?: ReactNode }) {\n if (!children) return <p className=\"text-[var(--text-placeholder)]\">{empty ?? \"—\"}</p>;\n return (\n <p className=\"whitespace-pre-wrap text-[var(--text-secondary)]\">{children}</p>\n );\n}\n"],"mappings":";AA6RI,SASE,KATF;AA7RJ,SAAS,QAAQ,gBAAgB;AAEjC;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,OACK;AAEP,SAAS,UAAU;AACnB,SAAS,QAAQ,gBAAgB;AACjC,SAAS,eAAe;AACxB,SAAS,+BAA+B;AAuDjC,MAAM,uBAGT;AAAA,EACF,MAAM;AAAA,IACJ,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;AAKO,MAAM,wBAAwB,OAAO,KAAK,oBAAoB;AAYrE,MAAM,YAA8B,CAAC,mBAAmB,aAAa,SAAS;AAE9E,MAAM,gBAAkC,CAAC,QAAQ,eAAe,iBAAiB,MAAM;AAIhF,SAAS,mBAAmB,SAAgD;AACjF,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,OAAO,IAAI;AACb,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;AAWO,SAAS,sBAAsB;AAAA,EACpC;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV;AACF,GAKG;AAGD,QAAM,QAA8D,uBAAuB,QAAQ;AACnG,QAAM,OAAO,SAAS,uBAAuB;AAC7C,QAAM,OAAO,KAAK;AAClB,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA,UAAU,8BAA8B;AAAA,QACxC,KAAK;AAAA,QACL,KAAK;AAAA,QACL;AAAA,MACF;AAAA,MAEA;AAAA,4BAAC,QAAK,WAAW,UAAU,WAAW,YAAY,eAAW,MAAC;AAAA,QAC7D;AAAA;AAAA;AAAA,EACH;AAEJ;AAIO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,QAAM,OAAO,qBAAqB,MAAM,KAAK,qBAAqB;AAClE,QAAM,OAAO,KAAK;AAClB,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA,KAAK;AAAA,QACL,KAAK;AAAA,QACL;AAAA,MACF;AAAA,MAEA;AAAA,4BAAC,QAAK,WAAU,UAAS,eAAW,MAAC;AAAA,QACpC;AAAA;AAAA;AAAA,EACH;AAEJ;AAiBO,SAAS,0BAA0B;AAAA,EACxC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GASG;AACD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW,GAAG,YAAY,SAAS,8BAA8B,0BAA0B,SAAS;AAAA,MAEpG,SAAS,CAAC,UAAU,MAAM,gBAAgB;AAAA,MAEzC,mBAAS,IAAI,CAAC,UAAU;AACvB,cAAM,OAAO,qBAAqB,KAAK;AACvC,cAAM,OAAO,KAAK;AAClB,cAAM,SAAS,WAAW;AAC1B,cAAM,OAAO,MAAM,KAAK;AACxB,cAAM,SACJ;AAAA,UAAC;AAAA;AAAA,YAEC,MAAK;AAAA,YACL,UAAU,CAAC,WAAW;AAAA,YACtB,SAAS,MAAM,OAAO,KAAK;AAAA,YAC3B,cAAY;AAAA,YACZ,gBAAc;AAAA,YACd,WAAW;AAAA,cACT;AAAA,cACA,YAAY,SACR;AAAA,gBACE;AAAA,gBACA,SACI,GAAG,KAAK,UAAU,KAAK,UAAU,IACjC;AAAA,cACN,IACA;AAAA,gBACE;AAAA,gBACA,SACI,GAAG,KAAK,UAAU,KAAK,YAAY,oBAAoB,IACvD;AAAA,cACN;AAAA;AAAA;AAAA;AAAA;AAAA,eAKH,CAAC,WAAW,WAAW;AAAA,cACxB,CAAC,WAAW;AAAA,YACd;AAAA,YAEA;AAAA,kCAAC,QAAK,WAAW,YAAY,SAAS,WAAW,YAAY,eAAW,MAAC;AAAA,cACxE,YAAY,UAAU;AAAA;AAAA;AAAA,UA9BlB;AAAA,QA+BP;AAeF,eAAO,YAAY,SACjB,oBAAC,WAAoB,OAAO,MAAM,MAAK,OAAM,QAAM,MAChD,oBADW,KAEd,IAEA;AAAA,MAEJ,CAAC;AAAA;AAAA,EACH;AAEJ;AA8BO,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP;AAAA,EACA;AACF,GAsBG;AACD,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAS,OAAO;AAC1C,QAAM,CAAC,MAAM,OAAO,IAAI,SAAsB,IAAI;AAKlD,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,QAAQ;AACnD,MAAI,aAAa,WAAW;AAC1B,iBAAa,QAAQ;AACrB,aAAS,OAAO;AAChB,YAAQ,IAAI;AAAA,EACd;AAKA,QAAM,OAAO,OAAuB,IAAI;AACxC,QAAM,SAAS,MAAM,OAAO,OAAO,IAAI;AACvC,SACE;AAAA,IAAC;AAAA;AAAA,MACC,KAAK;AAAA,MACL,WAAU;AAAA,MACV,WAAW,CAAC,UAAU;AACpB,aAAK,MAAM,WAAW,MAAM,YAAY,MAAM,QAAQ,WAAW,CAAC,SAAS;AACzE,gBAAM,eAAe;AACrB,iBAAO;AAAA,QACT;AAAA,MACF;AAAA,MAEC;AAAA,uBAAe,oBAAC,OAAE,WAAU,oCAAoC,uBAAY;AAAA,QAC7E,oBAAC,YAAS,MAAY,OAAO,OAAO,UAAU,CAAC,UAAU,SAAS,MAAM,OAAO,KAAK,GAAG;AAAA,QACtF,cACC;AAAA,UAAC;AAAA;AAAA,YACC,OAAO;AAAA,YACP,UAAU;AAAA,YACV,QAAQ,WAAW;AAAA,YACnB,QAAQ,WAAW;AAAA,YACnB,UAAU,WAAW;AAAA,YACrB,SAAS,WAAW;AAAA,YACpB,qBAAqB,WAAW;AAAA,YAMhC,WAAW;AAAA;AAAA,QACb;AAAA,QAEF,qBAAC,SAAI,WAAU,0BACb;AAAA,8BAAC,UAAO,SAAQ,SAAQ,SAAS,UAC9B,uBACH;AAAA,UACA,oBAAC,UAAO,SAAQ,SAAQ,UAAU,SAAS,SAAS,QACjD,qBACH;AAAA,WACF;AAAA;AAAA;AAAA,EACF;AAEJ;AAuBO,SAAS,sBAAsB;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AACF,GAKG;AACD,SACE,qBAAC,SACC;AAAA,yBAAC,SAAI,WAAU,gDACb;AAAA,0BAAC,SAAI,WAAU,wEACZ,iBACH;AAAA,MACC;AAAA,OACH;AAAA,IACC;AAAA,KACH;AAEJ;AAIO,SAAS,eAAe,EAAE,SAAS,GAA4B;AACpE,SAAO,oBAAC,SAAI,WAAU,qBAAqB,UAAS;AACtD;AAOO,SAAS,cAAc,EAAE,UAAU,MAAM,GAA6C;AAC3F,MAAI,CAAC,SAAU,QAAO,oBAAC,OAAE,WAAU,kCAAkC,mBAAS,UAAI;AAClF,SACE,oBAAC,OAAE,WAAU,oDAAoD,UAAS;AAE9E;","names":[]}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { DEFAULT_ATTACHMENT_ACCEPT, DEFAULT_MAX_ATTACHMENT_BYTES, FeedbackAttachmentField, pastedName } from './feedback/feedback-attachment.js';
|
|
2
|
+
export { FeedbackAttachmentLabels, FeedbackCategoryOption, FeedbackDialog, FeedbackDialogLabels, FeedbackSubmission } from './feedback/feedback-dialog.js';
|
|
3
|
+
export { FEEDBACK_CATEGORY_META, FEEDBACK_CATEGORY_ORDER, FEEDBACK_STATUS_META, FEEDBACK_STATUS_ORDER, FeedbackCategory, FeedbackCategoryBadge, FeedbackDetail, FeedbackDetailSection, FeedbackNoteAttachment, FeedbackNoteEditor, FeedbackProse, FeedbackStatus, FeedbackStatusBadge, FeedbackStatusTransitions, feedbackCategoryRank, nextFeedbackStatus, selectableFeedbackStatuses, visibleFeedbackStatuses } from './feedback/feedback-inbox.js';
|
|
4
|
+
import 'react';
|
|
5
|
+
import 'lucide-react';
|
package/dist/feedback.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/feedback.ts"],"sourcesContent":["// `@eifi1/ui-kit/feedback` — the report form and the parts an inbox is built from.\n//\n// A re-slicing of the main barrel, not a new API. The kit owns the status vocabulary,\n// the transition policy and the look; each app still wires its own API, columns,\n// strings and permissions — see the note at the top of feedback/feedback-inbox.tsx.\nexport * from \"./feedback/feedback-attachment\";\nexport * from \"./feedback/feedback-dialog\";\nexport * from \"./feedback/feedback-inbox\";\n"],"mappings":";AAKA,cAAc;AACd,cAAc;AACd,cAAc;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-anchored-panel.ts"],"sourcesContent":["import { useLayoutEffect, useState } from \"react\";\nimport type { RefObject } from \"react\";\nimport { useAnchoredRect, type AnchorRect } from \"./use-anchored-rect\";\n\n/**\n * Vertical placement for a portalled, `position: fixed` panel anchored to a trigger.\n *\n * The problem it solves (feedback #135): on a phone, opening a picker focuses its\n * search box, the on-screen keyboard slides up, and a panel pinned below a trigger in\n * the lower half of the screen ends up entirely behind that keyboard — with nothing\n * the user can do about it, because `position: fixed` does not scroll.\n *\n * The keyboard is invisible to the usual measurements: on Android Chrome the LAYOUT\n * viewport (`window.innerHeight`, and the origin `getBoundingClientRect` and\n * `position: fixed` are relative to) does not shrink when it opens. Only\n * `visualViewport` reports the region actually on screen, so that is what this reads.\n */\n\n/** The on-screen region, in layout-viewport coordinates. */\nexport interface ViewportBox {\n top: number;\n height: number;\n}\n\nfunction readViewport(): ViewportBox {\n if (typeof window === \"undefined\") return { top: 0, height: 0 };\n const vv = window.visualViewport;\n // offsetTop, not 0: with the keyboard up the user can also pan the visual viewport\n // within the layout viewport, which shifts where \"visible\" starts.\n return vv ? { top: vv.offsetTop, height: vv.height } : { top: 0, height: window.innerHeight };\n}\n\nexport interface AnchoredPanelOptions {\n /** Space between trigger and panel. */\n gap?: number;\n /** Space kept clear at the viewport edges. */\n margin?: number;\n /** The height the panel would like, when there is room. */\n preferredHeight?: number;\n /** Below this, the space under the trigger counts as unusable and a flip is considered. */\n minHeight?: number;\n}\n\nexport interface AnchoredPanel {\n /** The trigger's rect, or null while closed/unmeasured. */\n rect: AnchorRect | null;\n /** Viewport-space `top` for the panel. */\n top: number;\n /** Cap that keeps the panel inside the VISIBLE viewport — apply as `maxHeight`. */\n maxHeight: number;\n /** Whether the panel was flipped above the trigger. */\n above: boolean;\n}\n\n/** Absolute floor: below this a list is useless, so overflow the margin instead. */\nconst FLOOR = 96;\n\n/**\n * The pure half of {@link useAnchoredPanel}: where a panel goes, given the trigger's\n * rect and the region actually on screen.\n *\n * Prefers below the trigger — the conventional direction, and the one that keeps the\n * trigger's own value visible. Flips above only when below cannot show a usable list\n * AND above is roomier, so a panel never jumps sides for a few pixels' gain. The\n * returned `maxHeight` is what makes the flip sufficient rather than merely different:\n * without it a tall panel placed above just runs off the top instead of the bottom.\n *\n * Split out so the geometry is testable without a DOM and a fake keyboard — @hb/ui has\n * no test runner of its own, so this is exercised from the consuming app.\n */\nexport function anchoredPanelPlacement(\n rect: Pick<AnchorRect, \"top\" | \"bottom\">,\n viewport: ViewportBox,\n { gap = 4, margin = 8, preferredHeight = 320, minHeight = 160 }: AnchoredPanelOptions = {},\n): Omit<AnchoredPanel, \"rect\"> {\n const viewTop = viewport.top + margin;\n const viewBottom = viewport.top + viewport.height - margin;\n const spaceBelow = viewBottom - (rect.bottom + gap);\n const spaceAbove = rect.top - gap - viewTop;\n const above = spaceBelow < minHeight && spaceAbove > spaceBelow;\n const space = Math.max(FLOOR, above ? spaceAbove : spaceBelow);\n const maxHeight = Math.min(preferredHeight, space);\n return {\n top: above ? Math.max(viewTop, rect.top - gap - maxHeight) : rect.bottom + gap,\n maxHeight,\n above,\n };\n}\n\n/**\n * Where to put a panel anchored under `ref`, tracking the visible viewport.\n *\n * Callers own horizontal placement — the pickers align to the trigger's left edge, the\n * popovers to its right — and must apply `maxHeight` with an internal `overflow-y`.\n */\n/**\n * The region actually on screen, tracked while `active` (Keksdose live #328).\n *\n * The same measurement {@link useAnchoredPanel} caps a dropdown with, for the callers\n * that need the box itself rather than a placement: a FULL-SCREEN sheet cannot use\n * `inset-0`, because on Android the on-screen keyboard shrinks only the VISUAL\n * viewport — `inset-0` still spans the whole screen, so the bottom of the sheet, and\n * the end of whatever list is scrolling inside it, live behind the keyboard where no\n * gesture reaches them.\n *\n * Returns `null` where there is nothing to correct: no `visualViewport` (jsdom, SSR,\n * older browsers) or no keyboard taking a bite out of it. A caller then keeps its\n * static layout, which is right at every width where this does not apply — and is why\n * the desktop and the test suite see exactly the markup they saw before.\n */\nexport function useVisualViewport(active: boolean): ViewportBox | null {\n const [box, setBox] = useState<ViewportBox | null>(null);\n\n useLayoutEffect(() => {\n if (!active) return;\n const update = () => {\n const vv = typeof window === \"undefined\" ? undefined : window.visualViewport;\n // The 1px slack is not superstition: `visualViewport.height` is fractional on a\n // device-pixel-ratio that is not an integer (his phone reports 1.25), so a\n // strict comparison would report a \"keyboard\" of 0.4px on every phone and pin\n // a height where none was needed.\n setBox(vv && vv.height < window.innerHeight - 1 ? { top: vv.offsetTop, height: vv.height } : null);\n };\n update();\n const vv = window.visualViewport;\n vv?.addEventListener(\"resize\", update);\n vv?.addEventListener(\"scroll\", update);\n window.addEventListener(\"resize\", update);\n return () => {\n vv?.removeEventListener(\"resize\", update);\n vv?.removeEventListener(\"scroll\", update);\n window.removeEventListener(\"resize\", update);\n };\n }, [active]);\n\n return box;\n}\n\nexport function useAnchoredPanel<T extends HTMLElement>(\n ref: RefObject<T | null>,\n open: boolean,\n options: AnchoredPanelOptions = {},\n): AnchoredPanel {\n const rect = useAnchoredRect(ref, open);\n const [viewport, setViewport] = useState<ViewportBox>(readViewport);\n\n useLayoutEffect(() => {\n if (!open) return;\n const update = () => setViewport(readViewport());\n update();\n const vv = window.visualViewport;\n // The visualViewport events are the ones that fire when the keyboard opens;\n // window.resize is the fallback for browsers without the API (and for a genuine\n // window resize on desktop).\n vv?.addEventListener(\"resize\", update);\n vv?.addEventListener(\"scroll\", update);\n window.addEventListener(\"resize\", update);\n return () => {\n vv?.removeEventListener(\"resize\", update);\n vv?.removeEventListener(\"scroll\", update);\n window.removeEventListener(\"resize\", update);\n };\n }, [open]);\n\n if (!rect) {\n return { rect: null, top: 0, maxHeight: options.preferredHeight ?? 320, above: false };\n }\n return { rect, ...anchoredPanelPlacement(rect, viewport, options) };\n}\n"],"mappings":"AAAA,SAAS,iBAAiB,gBAAgB;AAE1C,SAAS,uBAAwC;AAsBjD,SAAS,eAA4B;AACnC,MAAI,OAAO,WAAW,YAAa,QAAO,EAAE,KAAK,GAAG,QAAQ,EAAE;AAC9D,QAAM,KAAK,OAAO;AAGlB,SAAO,KAAK,EAAE,KAAK,GAAG,WAAW,QAAQ,GAAG,OAAO,IAAI,EAAE,KAAK,GAAG,QAAQ,OAAO,YAAY;AAC9F;AAyBA,MAAM,QAAQ;AAeP,SAAS,uBACd,MACA,UACA,EAAE,MAAM,GAAG,SAAS,GAAG,kBAAkB,KAAK,YAAY,IAAI,IAA0B,CAAC,GAC5D;AAC7B,QAAM,UAAU,SAAS,MAAM;AAC/B,QAAM,aAAa,SAAS,MAAM,SAAS,SAAS;AACpD,QAAM,aAAa,cAAc,KAAK,SAAS;AAC/C,QAAM,aAAa,KAAK,MAAM,MAAM;AACpC,QAAM,QAAQ,aAAa,aAAa,aAAa;AACrD,QAAM,QAAQ,KAAK,IAAI,OAAO,QAAQ,aAAa,UAAU;AAC7D,QAAM,YAAY,KAAK,IAAI,iBAAiB,KAAK;AACjD,SAAO;AAAA,IACL,KAAK,QAAQ,KAAK,IAAI,SAAS,KAAK,MAAM,MAAM,SAAS,IAAI,KAAK,SAAS;AAAA,IAC3E;AAAA,IACA;AAAA,EACF;AACF;AAuBO,SAAS,kBAAkB,QAAqC;AACrE,QAAM,CAAC,KAAK,MAAM,IAAI,SAA6B,IAAI;AAEvD,kBAAgB,MAAM;AACpB,QAAI,CAAC,OAAQ;AACb,UAAM,SAAS,MAAM;AACnB,YAAMA,MAAK,OAAO,WAAW,cAAc,SAAY,OAAO;AAK9D,aAAOA,OAAMA,IAAG,SAAS,OAAO,cAAc,IAAI,EAAE,KAAKA,IAAG,WAAW,QAAQA,IAAG,OAAO,IAAI,IAAI;AAAA,IACnG;AACA,WAAO;AACP,UAAM,KAAK,OAAO;AAClB,QAAI,iBAAiB,UAAU,MAAM;AACrC,QAAI,iBAAiB,UAAU,MAAM;AACrC,WAAO,iBAAiB,UAAU,MAAM;AACxC,WAAO,MAAM;AACX,UAAI,oBAAoB,UAAU,MAAM;AACxC,UAAI,oBAAoB,UAAU,MAAM;AACxC,aAAO,oBAAoB,UAAU,MAAM;AAAA,IAC7C;AAAA,EACF,GAAG,CAAC,MAAM,CAAC;AAEX,SAAO;AACT;AAEO,SAAS,iBACd,KACA,MACA,UAAgC,CAAC,GAClB;AACf,QAAM,OAAO,gBAAgB,KAAK,IAAI;AACtC,QAAM,CAAC,UAAU,WAAW,IAAI,SAAsB,YAAY;AAElE,kBAAgB,MAAM;AACpB,QAAI,CAAC,KAAM;AACX,UAAM,SAAS,MAAM,YAAY,aAAa,CAAC;AAC/C,WAAO;AACP,UAAM,KAAK,OAAO;AAIlB,QAAI,iBAAiB,UAAU,MAAM;AACrC,QAAI,iBAAiB,UAAU,MAAM;AACrC,WAAO,iBAAiB,UAAU,MAAM;AACxC,WAAO,MAAM;AACX,UAAI,oBAAoB,UAAU,MAAM;AACxC,UAAI,oBAAoB,UAAU,MAAM;AACxC,aAAO,oBAAoB,UAAU,MAAM;AAAA,IAC7C;AAAA,EACF,GAAG,CAAC,IAAI,CAAC;AAET,MAAI,CAAC,MAAM;AACT,WAAO,EAAE,MAAM,MAAM,KAAK,GAAG,WAAW,QAAQ,mBAAmB,KAAK,OAAO,MAAM;AAAA,EACvF;AACA,SAAO,EAAE,MAAM,GAAG,uBAAuB,MAAM,UAAU,OAAO,EAAE;AACpE;","names":["vv"]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-anchored-panel.ts"],"sourcesContent":["import { useLayoutEffect, useState } from \"react\";\nimport type { RefObject } from \"react\";\nimport { useAnchoredRect, type AnchorRect } from \"./use-anchored-rect\";\n\n/**\n * Vertical placement for a portalled, `position: fixed` panel anchored to a trigger.\n *\n * The problem it solves (feedback #135): on a phone, opening a picker focuses its\n * search box, the on-screen keyboard slides up, and a panel pinned below a trigger in\n * the lower half of the screen ends up entirely behind that keyboard — with nothing\n * the user can do about it, because `position: fixed` does not scroll.\n *\n * The keyboard is invisible to the usual measurements: on Android Chrome the LAYOUT\n * viewport (`window.innerHeight`, and the origin `getBoundingClientRect` and\n * `position: fixed` are relative to) does not shrink when it opens. Only\n * `visualViewport` reports the region actually on screen, so that is what this reads.\n */\n\n/** The on-screen region, in layout-viewport coordinates. */\nexport interface ViewportBox {\n top: number;\n height: number;\n}\n\nfunction readViewport(): ViewportBox {\n if (typeof window === \"undefined\") return { top: 0, height: 0 };\n const vv = window.visualViewport;\n // offsetTop, not 0: with the keyboard up the user can also pan the visual viewport\n // within the layout viewport, which shifts where \"visible\" starts.\n return vv ? { top: vv.offsetTop, height: vv.height } : { top: 0, height: window.innerHeight };\n}\n\nexport interface AnchoredPanelOptions {\n /** Space between trigger and panel. */\n gap?: number;\n /** Space kept clear at the viewport edges. */\n margin?: number;\n /** The height the panel would like, when there is room. */\n preferredHeight?: number;\n /** Below this, the space under the trigger counts as unusable and a flip is considered. */\n minHeight?: number;\n}\n\nexport interface AnchoredPanel {\n /** The trigger's rect, or null while closed/unmeasured. */\n rect: AnchorRect | null;\n /** Viewport-space `top` for the panel. */\n top: number;\n /** Cap that keeps the panel inside the VISIBLE viewport — apply as `maxHeight`. */\n maxHeight: number;\n /** Whether the panel was flipped above the trigger. */\n above: boolean;\n}\n\n/** Absolute floor: below this a list is useless, so overflow the margin instead. */\nconst FLOOR = 96;\n\n/**\n * The pure half of {@link useAnchoredPanel}: where a panel goes, given the trigger's\n * rect and the region actually on screen.\n *\n * Prefers below the trigger — the conventional direction, and the one that keeps the\n * trigger's own value visible. Flips above only when below cannot show a usable list\n * AND above is roomier, so a panel never jumps sides for a few pixels' gain. The\n * returned `maxHeight` is what makes the flip sufficient rather than merely different:\n * without it a tall panel placed above just runs off the top instead of the bottom.\n *\n * Split out so the geometry is testable without a DOM and a fake keyboard — @hb/ui has\n * no test runner of its own, so this is exercised from the consuming app.\n */\nexport function anchoredPanelPlacement(\n rect: Pick<AnchorRect, \"top\" | \"bottom\">,\n viewport: ViewportBox,\n { gap = 4, margin = 8, preferredHeight = 320, minHeight = 160 }: AnchoredPanelOptions = {},\n): Omit<AnchoredPanel, \"rect\"> {\n const viewTop = viewport.top + margin;\n const viewBottom = viewport.top + viewport.height - margin;\n const spaceBelow = viewBottom - (rect.bottom + gap);\n const spaceAbove = rect.top - gap - viewTop;\n const above = spaceBelow < minHeight && spaceAbove > spaceBelow;\n const space = Math.max(FLOOR, above ? spaceAbove : spaceBelow);\n const maxHeight = Math.min(preferredHeight, space);\n return {\n top: above ? Math.max(viewTop, rect.top - gap - maxHeight) : rect.bottom + gap,\n maxHeight,\n above,\n };\n}\n\n/**\n * Where to put a panel anchored under `ref`, tracking the visible viewport.\n *\n * Callers own horizontal placement — the pickers align to the trigger's left edge, the\n * popovers to its right — and must apply `maxHeight` with an internal `overflow-y`.\n */\n/**\n * The region actually on screen, tracked while `active` (Keksdose live #328).\n *\n * The same measurement {@link useAnchoredPanel} caps a dropdown with, for the callers\n * that need the box itself rather than a placement: a FULL-SCREEN sheet cannot use\n * `inset-0`, because on Android the on-screen keyboard shrinks only the VISUAL\n * viewport — `inset-0` still spans the whole screen, so the bottom of the sheet, and\n * the end of whatever list is scrolling inside it, live behind the keyboard where no\n * gesture reaches them.\n *\n * Returns `null` where there is nothing to correct: no `visualViewport` (jsdom, SSR,\n * older browsers) or no keyboard taking a bite out of it. A caller then keeps its\n * static layout, which is right at every width where this does not apply — and is why\n * the desktop and the test suite see exactly the markup they saw before.\n */\nexport function useVisualViewport(active: boolean): ViewportBox | null {\n const [box, setBox] = useState<ViewportBox | null>(null);\n\n useLayoutEffect(() => {\n if (!active) return;\n const update = () => {\n const vv = typeof window === \"undefined\" ? undefined : window.visualViewport;\n // The 1px slack is not superstition: `visualViewport.height` is fractional on a\n // device-pixel-ratio that is not an integer (his phone reports 1.25), so a\n // strict comparison would report a \"keyboard\" of 0.4px on every phone and pin\n // a height where none was needed.\n setBox(vv && vv.height < window.innerHeight - 1 ? { top: vv.offsetTop, height: vv.height } : null);\n };\n update();\n const vv = window.visualViewport;\n vv?.addEventListener(\"resize\", update);\n vv?.addEventListener(\"scroll\", update);\n window.addEventListener(\"resize\", update);\n return () => {\n vv?.removeEventListener(\"resize\", update);\n vv?.removeEventListener(\"scroll\", update);\n window.removeEventListener(\"resize\", update);\n };\n }, [active]);\n\n return box;\n}\n\nexport function useAnchoredPanel<T extends HTMLElement>(\n ref: RefObject<T | null>,\n open: boolean,\n options: AnchoredPanelOptions = {},\n): AnchoredPanel {\n const rect = useAnchoredRect(ref, open);\n const [viewport, setViewport] = useState<ViewportBox>(readViewport);\n\n useLayoutEffect(() => {\n if (!open) return;\n const update = () => setViewport(readViewport());\n update();\n const vv = window.visualViewport;\n // The visualViewport events are the ones that fire when the keyboard opens;\n // window.resize is the fallback for browsers without the API (and for a genuine\n // window resize on desktop).\n vv?.addEventListener(\"resize\", update);\n vv?.addEventListener(\"scroll\", update);\n window.addEventListener(\"resize\", update);\n return () => {\n vv?.removeEventListener(\"resize\", update);\n vv?.removeEventListener(\"scroll\", update);\n window.removeEventListener(\"resize\", update);\n };\n }, [open]);\n\n if (!rect) {\n return { rect: null, top: 0, maxHeight: options.preferredHeight ?? 320, above: false };\n }\n return { rect, ...anchoredPanelPlacement(rect, viewport, options) };\n}\n"],"mappings":";AAAA,SAAS,iBAAiB,gBAAgB;AAE1C,SAAS,uBAAwC;AAsBjD,SAAS,eAA4B;AACnC,MAAI,OAAO,WAAW,YAAa,QAAO,EAAE,KAAK,GAAG,QAAQ,EAAE;AAC9D,QAAM,KAAK,OAAO;AAGlB,SAAO,KAAK,EAAE,KAAK,GAAG,WAAW,QAAQ,GAAG,OAAO,IAAI,EAAE,KAAK,GAAG,QAAQ,OAAO,YAAY;AAC9F;AAyBA,MAAM,QAAQ;AAeP,SAAS,uBACd,MACA,UACA,EAAE,MAAM,GAAG,SAAS,GAAG,kBAAkB,KAAK,YAAY,IAAI,IAA0B,CAAC,GAC5D;AAC7B,QAAM,UAAU,SAAS,MAAM;AAC/B,QAAM,aAAa,SAAS,MAAM,SAAS,SAAS;AACpD,QAAM,aAAa,cAAc,KAAK,SAAS;AAC/C,QAAM,aAAa,KAAK,MAAM,MAAM;AACpC,QAAM,QAAQ,aAAa,aAAa,aAAa;AACrD,QAAM,QAAQ,KAAK,IAAI,OAAO,QAAQ,aAAa,UAAU;AAC7D,QAAM,YAAY,KAAK,IAAI,iBAAiB,KAAK;AACjD,SAAO;AAAA,IACL,KAAK,QAAQ,KAAK,IAAI,SAAS,KAAK,MAAM,MAAM,SAAS,IAAI,KAAK,SAAS;AAAA,IAC3E;AAAA,IACA;AAAA,EACF;AACF;AAuBO,SAAS,kBAAkB,QAAqC;AACrE,QAAM,CAAC,KAAK,MAAM,IAAI,SAA6B,IAAI;AAEvD,kBAAgB,MAAM;AACpB,QAAI,CAAC,OAAQ;AACb,UAAM,SAAS,MAAM;AACnB,YAAMA,MAAK,OAAO,WAAW,cAAc,SAAY,OAAO;AAK9D,aAAOA,OAAMA,IAAG,SAAS,OAAO,cAAc,IAAI,EAAE,KAAKA,IAAG,WAAW,QAAQA,IAAG,OAAO,IAAI,IAAI;AAAA,IACnG;AACA,WAAO;AACP,UAAM,KAAK,OAAO;AAClB,QAAI,iBAAiB,UAAU,MAAM;AACrC,QAAI,iBAAiB,UAAU,MAAM;AACrC,WAAO,iBAAiB,UAAU,MAAM;AACxC,WAAO,MAAM;AACX,UAAI,oBAAoB,UAAU,MAAM;AACxC,UAAI,oBAAoB,UAAU,MAAM;AACxC,aAAO,oBAAoB,UAAU,MAAM;AAAA,IAC7C;AAAA,EACF,GAAG,CAAC,MAAM,CAAC;AAEX,SAAO;AACT;AAEO,SAAS,iBACd,KACA,MACA,UAAgC,CAAC,GAClB;AACf,QAAM,OAAO,gBAAgB,KAAK,IAAI;AACtC,QAAM,CAAC,UAAU,WAAW,IAAI,SAAsB,YAAY;AAElE,kBAAgB,MAAM;AACpB,QAAI,CAAC,KAAM;AACX,UAAM,SAAS,MAAM,YAAY,aAAa,CAAC;AAC/C,WAAO;AACP,UAAM,KAAK,OAAO;AAIlB,QAAI,iBAAiB,UAAU,MAAM;AACrC,QAAI,iBAAiB,UAAU,MAAM;AACrC,WAAO,iBAAiB,UAAU,MAAM;AACxC,WAAO,MAAM;AACX,UAAI,oBAAoB,UAAU,MAAM;AACxC,UAAI,oBAAoB,UAAU,MAAM;AACxC,aAAO,oBAAoB,UAAU,MAAM;AAAA,IAC7C;AAAA,EACF,GAAG,CAAC,IAAI,CAAC;AAET,MAAI,CAAC,MAAM;AACT,WAAO,EAAE,MAAM,MAAM,KAAK,GAAG,WAAW,QAAQ,mBAAmB,KAAK,OAAO,MAAM;AAAA,EACvF;AACA,SAAO,EAAE,MAAM,GAAG,uBAAuB,MAAM,UAAU,OAAO,EAAE;AACpE;","names":["vv"]}
|
|
@@ -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\nfunction sameRect(a: AnchorRect | null, b: AnchorRect | null): boolean {\n if (a === b) return true;\n if (!a || !b) return false;\n return (\n a.top === b.top &&\n a.left === b.left &&\n a.right === b.right &&\n a.bottom === b.bottom &&\n a.width === b.width &&\n a.height === b.height\n );\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>(\n ref: RefObject<T | null>,\n open: boolean,\n): 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;AAa1C,SAAS,SAAS,GAAsB,GAA+B;AACrE,MAAI,MAAM,EAAG,QAAO;AACpB,MAAI,CAAC,KAAK,CAAC,EAAG,QAAO;AACrB,SACE,EAAE,QAAQ,EAAE,OACZ,EAAE,SAAS,EAAE,QACb,EAAE,UAAU,EAAE,SACd,EAAE,WAAW,EAAE,UACf,EAAE,UAAU,EAAE,SACd,EAAE,WAAW,EAAE;AAEnB;AAYO,SAAS,gBACd,KACA,MACmB;AACnB,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
|
+
{"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\nfunction sameRect(a: AnchorRect | null, b: AnchorRect | null): boolean {\n if (a === b) return true;\n if (!a || !b) return false;\n return (\n a.top === b.top &&\n a.left === b.left &&\n a.right === b.right &&\n a.bottom === b.bottom &&\n a.width === b.width &&\n a.height === b.height\n );\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>(\n ref: RefObject<T | null>,\n open: boolean,\n): 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;AAa1C,SAAS,SAAS,GAAsB,GAA+B;AACrE,MAAI,MAAM,EAAG,QAAO;AACpB,MAAI,CAAC,KAAK,CAAC,EAAG,QAAO;AACrB,SACE,EAAE,QAAQ,EAAE,OACZ,EAAE,SAAS,EAAE,QACb,EAAE,UAAU,EAAE,SACd,EAAE,WAAW,EAAE,UACf,EAAE,UAAU,EAAE,SACd,EAAE,WAAW,EAAE;AAEnB;AAYO,SAAS,gBACd,KACA,MACmB;AACnB,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":[]}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Announce a state change that produces no focus change.
|
|
3
|
+
*
|
|
4
|
+
* Sorting a column, applying a filter, moving to the next page: the rows under the
|
|
5
|
+
* cursor silently become different rows, and nothing tells a screen-reader user. There
|
|
6
|
+
* is no focus move to carry the news, because the control they activated is still the
|
|
7
|
+
* control they are on. A live region is the only channel.
|
|
8
|
+
*
|
|
9
|
+
* Two details that are the whole reason this is a hook rather than a `<div aria-live>`
|
|
10
|
+
* written inline at each call site:
|
|
11
|
+
*
|
|
12
|
+
* 1. **The region must already be in the DOM before the message arrives.** Screen
|
|
13
|
+
* readers subscribe to a live region when they encounter it; a region that mounts
|
|
14
|
+
* *with* its first message is usually missed entirely. So `regionProps` is spread
|
|
15
|
+
* onto an element that renders empty from the start and never unmounts.
|
|
16
|
+
* 2. **Re-announcing the same string must still speak.** Assistive tech diffs the
|
|
17
|
+
* region's content, so setting "12 results" when it already says "12 results" is a
|
|
18
|
+
* no-op — which is exactly the case that matters when a user re-applies a filter
|
|
19
|
+
* and needs confirmation that something happened. Clearing first and setting on the
|
|
20
|
+
* next tick forces a diff.
|
|
21
|
+
*/
|
|
22
|
+
interface AnnounceRegionProps {
|
|
23
|
+
role: "status" | "alert";
|
|
24
|
+
"aria-live": "polite" | "assertive";
|
|
25
|
+
"aria-atomic": true;
|
|
26
|
+
/** `sr-only-fixed` — visually hidden without inflating the document height. */
|
|
27
|
+
className: string;
|
|
28
|
+
children: string;
|
|
29
|
+
}
|
|
30
|
+
interface UseAnnounceReturn {
|
|
31
|
+
/** Speak `message`. Passing the same string twice still speaks twice. */
|
|
32
|
+
announce: (message: string) => void;
|
|
33
|
+
/** Spread onto an element that is rendered unconditionally. */
|
|
34
|
+
regionProps: AnnounceRegionProps;
|
|
35
|
+
}
|
|
36
|
+
interface UseAnnounceOptions {
|
|
37
|
+
/**
|
|
38
|
+
* `polite` (default) waits for a pause; `assertive` interrupts. Sorting and paging
|
|
39
|
+
* are polite — interrupting someone mid-sentence to tell them a table re-sorted is
|
|
40
|
+
* worse than telling them a moment later. Reserve assertive for failures.
|
|
41
|
+
*/
|
|
42
|
+
politeness?: "polite" | "assertive";
|
|
43
|
+
}
|
|
44
|
+
declare function useAnnounce({ politeness }?: UseAnnounceOptions): UseAnnounceReturn;
|
|
45
|
+
|
|
46
|
+
export { type AnnounceRegionProps, type UseAnnounceOptions, type UseAnnounceReturn, useAnnounce };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
function useAnnounce({ politeness = "polite" } = {}) {
|
|
4
|
+
const [message, setMessage] = useState("");
|
|
5
|
+
const timerRef = useRef(null);
|
|
6
|
+
useEffect(
|
|
7
|
+
() => () => {
|
|
8
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
9
|
+
},
|
|
10
|
+
[]
|
|
11
|
+
);
|
|
12
|
+
const announce = useCallback((next) => {
|
|
13
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
14
|
+
setMessage("");
|
|
15
|
+
timerRef.current = setTimeout(() => setMessage(next), 50);
|
|
16
|
+
}, []);
|
|
17
|
+
return {
|
|
18
|
+
announce,
|
|
19
|
+
regionProps: {
|
|
20
|
+
role: politeness === "assertive" ? "alert" : "status",
|
|
21
|
+
"aria-live": politeness,
|
|
22
|
+
// Read the whole region, not just the changed words — "Page 3 of 14" is a
|
|
23
|
+
// sentence, and an atomic=false region can announce a bare "3".
|
|
24
|
+
"aria-atomic": true,
|
|
25
|
+
// `sr-only-fixed`, not `sr-only`: this region is rendered by the KIT inside a
|
|
26
|
+
// consumer's component, so there is no wrapper we can require to be `relative`.
|
|
27
|
+
// Tailwind's `sr-only` is `position: absolute` and would extend the document
|
|
28
|
+
// height from wherever it landed — see the note on the class in tokens.css.
|
|
29
|
+
className: "sr-only-fixed",
|
|
30
|
+
children: message
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export {
|
|
35
|
+
useAnnounce
|
|
36
|
+
};
|
|
37
|
+
//# sourceMappingURL=use-announce.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-announce.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\n\n/**\n * Announce a state change that produces no focus change.\n *\n * Sorting a column, applying a filter, moving to the next page: the rows under the\n * cursor silently become different rows, and nothing tells a screen-reader user. There\n * is no focus move to carry the news, because the control they activated is still the\n * control they are on. A live region is the only channel.\n *\n * Two details that are the whole reason this is a hook rather than a `<div aria-live>`\n * written inline at each call site:\n *\n * 1. **The region must already be in the DOM before the message arrives.** Screen\n * readers subscribe to a live region when they encounter it; a region that mounts\n * *with* its first message is usually missed entirely. So `regionProps` is spread\n * onto an element that renders empty from the start and never unmounts.\n * 2. **Re-announcing the same string must still speak.** Assistive tech diffs the\n * region's content, so setting \"12 results\" when it already says \"12 results\" is a\n * no-op — which is exactly the case that matters when a user re-applies a filter\n * and needs confirmation that something happened. Clearing first and setting on the\n * next tick forces a diff.\n */\n\nexport interface AnnounceRegionProps {\n role: \"status\" | \"alert\";\n \"aria-live\": \"polite\" | \"assertive\";\n \"aria-atomic\": true;\n /** `sr-only-fixed` — visually hidden without inflating the document height. */\n className: string;\n children: string;\n}\n\nexport interface UseAnnounceReturn {\n /** Speak `message`. Passing the same string twice still speaks twice. */\n announce: (message: string) => void;\n /** Spread onto an element that is rendered unconditionally. */\n regionProps: AnnounceRegionProps;\n}\n\nexport interface UseAnnounceOptions {\n /**\n * `polite` (default) waits for a pause; `assertive` interrupts. Sorting and paging\n * are polite — interrupting someone mid-sentence to tell them a table re-sorted is\n * worse than telling them a moment later. Reserve assertive for failures.\n */\n politeness?: \"polite\" | \"assertive\";\n}\n\nexport function useAnnounce({ politeness = \"polite\" }: UseAnnounceOptions = {}): UseAnnounceReturn {\n const [message, setMessage] = useState(\"\");\n const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n useEffect(\n () => () => {\n if (timerRef.current) clearTimeout(timerRef.current);\n },\n [],\n );\n\n const announce = useCallback((next: string) => {\n if (timerRef.current) clearTimeout(timerRef.current);\n // Clear, then set on a later tick. A 0ms timeout is enough: it puts the two\n // mutations in separate frames, which is what makes the second one a diff.\n setMessage(\"\");\n timerRef.current = setTimeout(() => setMessage(next), 50);\n }, []);\n\n return {\n announce,\n regionProps: {\n role: politeness === \"assertive\" ? \"alert\" : \"status\",\n \"aria-live\": politeness,\n // Read the whole region, not just the changed words — \"Page 3 of 14\" is a\n // sentence, and an atomic=false region can announce a bare \"3\".\n \"aria-atomic\": true,\n // `sr-only-fixed`, not `sr-only`: this region is rendered by the KIT inside a\n // consumer's component, so there is no wrapper we can require to be `relative`.\n // Tailwind's `sr-only` is `position: absolute` and would extend the document\n // height from wherever it landed — see the note on the class in tokens.css.\n className: \"sr-only-fixed\",\n children: message,\n },\n };\n}\n"],"mappings":";AAAA,SAAS,aAAa,WAAW,QAAQ,gBAAgB;AAiDlD,SAAS,YAAY,EAAE,aAAa,SAAS,IAAwB,CAAC,GAAsB;AACjG,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,EAAE;AACzC,QAAM,WAAW,OAA6C,IAAI;AAElE;AAAA,IACE,MAAM,MAAM;AACV,UAAI,SAAS,QAAS,cAAa,SAAS,OAAO;AAAA,IACrD;AAAA,IACA,CAAC;AAAA,EACH;AAEA,QAAM,WAAW,YAAY,CAAC,SAAiB;AAC7C,QAAI,SAAS,QAAS,cAAa,SAAS,OAAO;AAGnD,eAAW,EAAE;AACb,aAAS,UAAU,WAAW,MAAM,WAAW,IAAI,GAAG,EAAE;AAAA,EAC1D,GAAG,CAAC,CAAC;AAEL,SAAO;AAAA,IACL;AAAA,IACA,aAAa;AAAA,MACX,MAAM,eAAe,cAAc,UAAU;AAAA,MAC7C,aAAa;AAAA;AAAA;AAAA,MAGb,eAAe;AAAA;AAAA;AAAA;AAAA;AAAA,MAKf,WAAW;AAAA,MACX,UAAU;AAAA,IACZ;AAAA,EACF;AACF;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-body-scroll-lock.ts"],"sourcesContent":["import { useEffect } from \"react\";\n\n/**\n * How many locks are currently held, and what `body.style.overflow` was before the\n * first of them. Module scope on purpose: the whole point is that the lockers do not\n * know about each other, so the only place they can agree is here.\n *\n * This used to be per-locker — each one saved the previous value and restored it on\n * cleanup — and five places in this package do that (Modal, PickerSheet,\n * NumberPadSheet, and the DataTable's row dialog and settings panel). Save/restore\n * composes only if the releases are strictly nested in reverse order of the\n * acquisitions, and React runs unmount cleanups PARENT FIRST, which is the opposite\n * of what the idiom needs.\n *\n * The gesture that broke it is an ordinary one: open the transaction dialog, tap the\n * amount — the numpad sheet opens on focus, in a later commit, capturing the\n * \"hidden\" the dialog had already set — then press Save. Both unmount together, the\n * dialog restores \"\" first, the sheet then puts \"hidden\" back, and the page is left\n * unscrollable with no overlay on screen. The only way out was a reload.\n *\n * A count has neither failure: the first acquire records the real previous value, the\n * last release restores it, and the order in between does not matter.\n */\nlet lockCount = 0;\nlet previousOverflow = \"\";\nlet previousPaddingRight = \"\";\n\n/**\n * How much width the scrollbar of the DOCUMENT is currently taking.\n *\n * Zero on every platform with overlay scrollbars (macOS, most Linux, every phone) and\n * zero whenever the page does not scroll; ~15px on Windows with classic ones. Read\n * BEFORE `overflow: hidden` is applied, because applying it is what makes it vanish.\n */\nfunction scrollbarWidth(): number {\n return Math.max(0, window.innerWidth - document.documentElement.clientWidth);\n}\n\nfunction acquire(): void {\n if (lockCount === 0) {\n // ⚠️ Hiding the document's overflow REMOVES its scrollbar, and on a platform where\n // that scrollbar occupied layout space the page gets ~15px wider for as long as the\n // dialog is open — then snaps back on close. Keksdose dev #561 from a 500px-wide\n // desktop window: *\"remove the scroll bar to stop horizontal resizing when opening\n // edit tx and closing\"*.\n //\n // Replacing the width we are about to take away is the standard fix and the narrow\n // one. The alternatives both cost more than the bug: `scrollbar-gutter` on `html`\n // shrinks the initial containing block, so every `position: fixed` overlay stops\n // 15px short on each edge (measured, and documented in tokens.css), and moving the\n // scroll container to `main` below `md` stops a phone's URL bar collapsing. This\n // touches neither — only the element already being modified, only while locked.\n //\n // Note this fixes the DIALOG jump specifically. The separate page-to-page width\n // change, where one route scrolls and the next does not, is the open decision\n // recorded in tokens.css and is untouched here.\n const gap = scrollbarWidth();\n previousOverflow = document.body.style.overflow;\n previousPaddingRight = document.body.style.paddingRight;\n document.body.style.overflow = \"hidden\";\n if (gap > 0) {\n // Added to whatever the body already had rather than assigned, so a consumer\n // that sets its own padding keeps it.\n const existing = parseFloat(getComputedStyle(document.body).paddingRight) || 0;\n document.body.style.paddingRight = `${existing + gap}px`;\n }\n }\n lockCount += 1;\n}\n\nfunction release(): void {\n if (lockCount === 0) return;\n lockCount -= 1;\n if (lockCount === 0) {\n document.body.style.overflow = previousOverflow;\n document.body.style.paddingRight = previousPaddingRight;\n previousOverflow = \"\";\n previousPaddingRight = \"\";\n }\n}\n\n/**\n * Lock background scrolling while `locked` is true, restoring the prior value once\n * the LAST holder releases. Composable with every other caller of this hook — see\n * the note above for why that has to be a property of the hook rather than of each\n * caller's discipline.\n */\nexport function useBodyScrollLock(locked: boolean): void {\n useEffect(() => {\n if (!locked) return;\n acquire();\n return release;\n }, [locked]);\n}\n"],"mappings":"AAAA,SAAS,iBAAiB;AAuB1B,IAAI,YAAY;AAChB,IAAI,mBAAmB;AACvB,IAAI,uBAAuB;AAS3B,SAAS,iBAAyB;AAChC,SAAO,KAAK,IAAI,GAAG,OAAO,aAAa,SAAS,gBAAgB,WAAW;AAC7E;AAEA,SAAS,UAAgB;AACvB,MAAI,cAAc,GAAG;AAiBnB,UAAM,MAAM,eAAe;AAC3B,uBAAmB,SAAS,KAAK,MAAM;AACvC,2BAAuB,SAAS,KAAK,MAAM;AAC3C,aAAS,KAAK,MAAM,WAAW;AAC/B,QAAI,MAAM,GAAG;AAGX,YAAM,WAAW,WAAW,iBAAiB,SAAS,IAAI,EAAE,YAAY,KAAK;AAC7E,eAAS,KAAK,MAAM,eAAe,GAAG,WAAW,GAAG;AAAA,IACtD;AAAA,EACF;AACA,eAAa;AACf;AAEA,SAAS,UAAgB;AACvB,MAAI,cAAc,EAAG;AACrB,eAAa;AACb,MAAI,cAAc,GAAG;AACnB,aAAS,KAAK,MAAM,WAAW;AAC/B,aAAS,KAAK,MAAM,eAAe;AACnC,uBAAmB;AACnB,2BAAuB;AAAA,EACzB;AACF;AAQO,SAAS,kBAAkB,QAAuB;AACvD,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AACb,YAAQ;AACR,WAAO;AAAA,EACT,GAAG,CAAC,MAAM,CAAC;AACb;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-body-scroll-lock.ts"],"sourcesContent":["import { useEffect } from \"react\";\n\n/**\n * How many locks are currently held, and what `body.style.overflow` was before the\n * first of them. Module scope on purpose: the whole point is that the lockers do not\n * know about each other, so the only place they can agree is here.\n *\n * This used to be per-locker — each one saved the previous value and restored it on\n * cleanup — and five places in this package do that (Modal, PickerSheet,\n * NumberPadSheet, and the DataTable's row dialog and settings panel). Save/restore\n * composes only if the releases are strictly nested in reverse order of the\n * acquisitions, and React runs unmount cleanups PARENT FIRST, which is the opposite\n * of what the idiom needs.\n *\n * The gesture that broke it is an ordinary one: open the transaction dialog, tap the\n * amount — the numpad sheet opens on focus, in a later commit, capturing the\n * \"hidden\" the dialog had already set — then press Save. Both unmount together, the\n * dialog restores \"\" first, the sheet then puts \"hidden\" back, and the page is left\n * unscrollable with no overlay on screen. The only way out was a reload.\n *\n * A count has neither failure: the first acquire records the real previous value, the\n * last release restores it, and the order in between does not matter.\n */\nlet lockCount = 0;\nlet previousOverflow = \"\";\nlet previousPaddingRight = \"\";\n\n/**\n * How much width the scrollbar of the DOCUMENT is currently taking.\n *\n * Zero on every platform with overlay scrollbars (macOS, most Linux, every phone) and\n * zero whenever the page does not scroll; ~15px on Windows with classic ones. Read\n * BEFORE `overflow: hidden` is applied, because applying it is what makes it vanish.\n */\nfunction scrollbarWidth(): number {\n return Math.max(0, window.innerWidth - document.documentElement.clientWidth);\n}\n\nfunction acquire(): void {\n if (lockCount === 0) {\n // ⚠️ Hiding the document's overflow REMOVES its scrollbar, and on a platform where\n // that scrollbar occupied layout space the page gets ~15px wider for as long as the\n // dialog is open — then snaps back on close. Keksdose dev #561 from a 500px-wide\n // desktop window: *\"remove the scroll bar to stop horizontal resizing when opening\n // edit tx and closing\"*.\n //\n // Replacing the width we are about to take away is the standard fix and the narrow\n // one. The alternatives both cost more than the bug: `scrollbar-gutter` on `html`\n // shrinks the initial containing block, so every `position: fixed` overlay stops\n // 15px short on each edge (measured, and documented in tokens.css), and moving the\n // scroll container to `main` below `md` stops a phone's URL bar collapsing. This\n // touches neither — only the element already being modified, only while locked.\n //\n // Note this fixes the DIALOG jump specifically. The separate page-to-page width\n // change, where one route scrolls and the next does not, is the open decision\n // recorded in tokens.css and is untouched here.\n const gap = scrollbarWidth();\n previousOverflow = document.body.style.overflow;\n previousPaddingRight = document.body.style.paddingRight;\n document.body.style.overflow = \"hidden\";\n if (gap > 0) {\n // Added to whatever the body already had rather than assigned, so a consumer\n // that sets its own padding keeps it.\n const existing = parseFloat(getComputedStyle(document.body).paddingRight) || 0;\n document.body.style.paddingRight = `${existing + gap}px`;\n }\n }\n lockCount += 1;\n}\n\nfunction release(): void {\n if (lockCount === 0) return;\n lockCount -= 1;\n if (lockCount === 0) {\n document.body.style.overflow = previousOverflow;\n document.body.style.paddingRight = previousPaddingRight;\n previousOverflow = \"\";\n previousPaddingRight = \"\";\n }\n}\n\n/**\n * Lock background scrolling while `locked` is true, restoring the prior value once\n * the LAST holder releases. Composable with every other caller of this hook — see\n * the note above for why that has to be a property of the hook rather than of each\n * caller's discipline.\n */\nexport function useBodyScrollLock(locked: boolean): void {\n useEffect(() => {\n if (!locked) return;\n acquire();\n return release;\n }, [locked]);\n}\n"],"mappings":";AAAA,SAAS,iBAAiB;AAuB1B,IAAI,YAAY;AAChB,IAAI,mBAAmB;AACvB,IAAI,uBAAuB;AAS3B,SAAS,iBAAyB;AAChC,SAAO,KAAK,IAAI,GAAG,OAAO,aAAa,SAAS,gBAAgB,WAAW;AAC7E;AAEA,SAAS,UAAgB;AACvB,MAAI,cAAc,GAAG;AAiBnB,UAAM,MAAM,eAAe;AAC3B,uBAAmB,SAAS,KAAK,MAAM;AACvC,2BAAuB,SAAS,KAAK,MAAM;AAC3C,aAAS,KAAK,MAAM,WAAW;AAC/B,QAAI,MAAM,GAAG;AAGX,YAAM,WAAW,WAAW,iBAAiB,SAAS,IAAI,EAAE,YAAY,KAAK;AAC7E,eAAS,KAAK,MAAM,eAAe,GAAG,WAAW,GAAG;AAAA,IACtD;AAAA,EACF;AACA,eAAa;AACf;AAEA,SAAS,UAAgB;AACvB,MAAI,cAAc,EAAG;AACrB,eAAa;AACb,MAAI,cAAc,GAAG;AACnB,aAAS,KAAK,MAAM,WAAW;AAC/B,aAAS,KAAK,MAAM,eAAe;AACnC,uBAAmB;AACnB,2BAAuB;AAAA,EACzB;AACF;AAQO,SAAS,kBAAkB,QAAuB;AACvD,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AACb,YAAQ;AACR,WAAO;AAAA,EACT,GAAG,CAAC,MAAM,CAAC;AACb;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-close-transition.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\n\n/** How long the exit animation runs, in ms. Mirrors `.animate-sheet-out` in\n * tokens.css — the sheet is the slower of the two, so it is what the unmount waits\n * for. Exported so a test can advance a fake clock by the real number rather than\n * by a copy of it. */\nexport const OVERLAY_EXIT_MS = 220;\n\n/**\n * The state machine an exit animation needs, written once for every overlay in this\n * package (Keksdose live #320 rework: *\"Recognized the opening transition. Choose\n * transition should be the same, only backwards\"*).\n *\n * ## Why a hook and not a class on the panel\n *\n * An enter animation is free: the element mounts, the keyframes run. An exit is not —\n * by the time the state says \"closed\" the element is already gone, so something has to\n * hold it on screen for exactly as long as the animation lasts. That something cannot\n * be the caller (every caller would need the same timer) and it cannot be the panel's\n * markup. It is this: the overlay's own affordances call {@link requestClose} instead\n * of `onClose`, render the `-out` classes while `closing` is true, and the real\n * `onClose` — the one that unmounts them — fires when the animation has finished.\n *\n * ## What it deliberately does not do\n *\n * It does not intercept a close the CALLER decides on. A dialog that closes itself\n * after a successful save sets its own state and unmounts, with no exit animation, and\n * that is the right trade: making it otherwise would mean every overlay owning an\n * `open` prop and every caller waiting on a callback to learn when its own state took\n * effect. What is animated is what the user dismissed — the X, the backdrop, Escape,\n * the Back gesture — which is the motion the row was about.\n *\n * ## Reduced motion\n *\n * Closes immediately, without the delay. A user who has asked for no motion must not\n * be made to wait 220ms for an animation they are not being shown, and the `-out`\n * classes are never applied, so tokens.css needs no reduced-motion rule for them.\n */\nexport function useCloseTransition(\n onClose: () => void,\n ms: number = OVERLAY_EXIT_MS,\n): { closing: boolean; requestClose: () => void } {\n const [closing, setClosing] = useState(false);\n const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n // Latest-ref rather than a dependency: callers pass inline arrows, so a dependency\n // would hand every render a new `requestClose` — and this one is read once, by a\n // timer that has already been scheduled. Assigned in an effect, never during render\n // (`react-hooks/refs`), matching `use-long-press.ts` next door.\n const latest = useRef(onClose);\n useEffect(() => {\n latest.current = onClose;\n });\n\n // One timer, ever. A second Escape while the panel is already leaving must not\n // schedule a second unmount — two `onClose` calls is a caller's state machine being\n // run twice, which is how a \"close\" ends up also discarding the row behind it.\n useEffect(\n () => () => {\n if (timer.current) clearTimeout(timer.current);\n },\n [],\n );\n\n const requestClose = useCallback(() => {\n if (timer.current) return;\n // Read at close time, not at mount: the setting can change under a long-lived\n // page, and `matchMedia` is absent in jsdom and in SSR — where \"no animation\" is\n // also the only correct answer, since nothing is painting.\n const reduced =\n typeof window === \"undefined\" ||\n typeof window.matchMedia !== \"function\" ||\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n if (reduced || ms <= 0) {\n latest.current();\n return;\n }\n setClosing(true);\n timer.current = setTimeout(() => {\n timer.current = null;\n // ⚠️ Reset BEFORE handing control back — reported from a phone within the hour\n // of the 0.4.52 deploy that first shipped this hook, against every table at once.\n // A panel that returns `null` while closed is still MOUNTED — `FullBleedDialog`\n // early-returns after its hooks, and DataTable renders it once and flips `open`\n // — so this state outlives the panel it describes. Left true, the NEXT open\n // rendered `animate-*-out` with `animation-fill-mode: forwards` on a sheet that\n // had only just arrived: it lowered itself off the screen in 220ms and stayed\n // there, invisible but still holding the backdrop's pointer events. Every table\n // on every phone, first open fine and every one after it not, until a route\n // change remounted the component.\n //\n // Batched with the `onClose` below, so a caller that unmounts on close still\n // renders exactly once and nothing flashes.\n setClosing(false);\n latest.current();\n }, ms);\n }, [ms]);\n\n return { closing, requestClose };\n}\n"],"mappings":"AAAA,SAAS,aAAa,WAAW,QAAQ,gBAAgB;AAMlD,MAAM,kBAAkB;AAgCxB,SAAS,mBACd,SACA,KAAa,iBACmC;AAChD,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAC5C,QAAM,QAAQ,OAA6C,IAAI;AAK/D,QAAM,SAAS,OAAO,OAAO;AAC7B,YAAU,MAAM;AACd,WAAO,UAAU;AAAA,EACnB,CAAC;AAKD;AAAA,IACE,MAAM,MAAM;AACV,UAAI,MAAM,QAAS,cAAa,MAAM,OAAO;AAAA,IAC/C;AAAA,IACA,CAAC;AAAA,EACH;AAEA,QAAM,eAAe,YAAY,MAAM;AACrC,QAAI,MAAM,QAAS;AAInB,UAAM,UACJ,OAAO,WAAW,eAClB,OAAO,OAAO,eAAe,cAC7B,OAAO,WAAW,kCAAkC,EAAE;AACxD,QAAI,WAAW,MAAM,GAAG;AACtB,aAAO,QAAQ;AACf;AAAA,IACF;AACA,eAAW,IAAI;AACf,UAAM,UAAU,WAAW,MAAM;AAC/B,YAAM,UAAU;AAchB,iBAAW,KAAK;AAChB,aAAO,QAAQ;AAAA,IACjB,GAAG,EAAE;AAAA,EACP,GAAG,CAAC,EAAE,CAAC;AAEP,SAAO,EAAE,SAAS,aAAa;AACjC;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-close-transition.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\n\n/** How long the exit animation runs, in ms. Mirrors `.animate-sheet-out` in\n * tokens.css — the sheet is the slower of the two, so it is what the unmount waits\n * for. Exported so a test can advance a fake clock by the real number rather than\n * by a copy of it. */\nexport const OVERLAY_EXIT_MS = 220;\n\n/**\n * The state machine an exit animation needs, written once for every overlay in this\n * package (Keksdose live #320 rework: *\"Recognized the opening transition. Choose\n * transition should be the same, only backwards\"*).\n *\n * ## Why a hook and not a class on the panel\n *\n * An enter animation is free: the element mounts, the keyframes run. An exit is not —\n * by the time the state says \"closed\" the element is already gone, so something has to\n * hold it on screen for exactly as long as the animation lasts. That something cannot\n * be the caller (every caller would need the same timer) and it cannot be the panel's\n * markup. It is this: the overlay's own affordances call {@link requestClose} instead\n * of `onClose`, render the `-out` classes while `closing` is true, and the real\n * `onClose` — the one that unmounts them — fires when the animation has finished.\n *\n * ## What it deliberately does not do\n *\n * It does not intercept a close the CALLER decides on. A dialog that closes itself\n * after a successful save sets its own state and unmounts, with no exit animation, and\n * that is the right trade: making it otherwise would mean every overlay owning an\n * `open` prop and every caller waiting on a callback to learn when its own state took\n * effect. What is animated is what the user dismissed — the X, the backdrop, Escape,\n * the Back gesture — which is the motion the row was about.\n *\n * ## Reduced motion\n *\n * Closes immediately, without the delay. A user who has asked for no motion must not\n * be made to wait 220ms for an animation they are not being shown, and the `-out`\n * classes are never applied, so tokens.css needs no reduced-motion rule for them.\n */\nexport function useCloseTransition(\n onClose: () => void,\n ms: number = OVERLAY_EXIT_MS,\n): { closing: boolean; requestClose: () => void } {\n const [closing, setClosing] = useState(false);\n const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n // Latest-ref rather than a dependency: callers pass inline arrows, so a dependency\n // would hand every render a new `requestClose` — and this one is read once, by a\n // timer that has already been scheduled. Assigned in an effect, never during render\n // (`react-hooks/refs`), matching `use-long-press.ts` next door.\n const latest = useRef(onClose);\n useEffect(() => {\n latest.current = onClose;\n });\n\n // One timer, ever. A second Escape while the panel is already leaving must not\n // schedule a second unmount — two `onClose` calls is a caller's state machine being\n // run twice, which is how a \"close\" ends up also discarding the row behind it.\n useEffect(\n () => () => {\n if (timer.current) clearTimeout(timer.current);\n },\n [],\n );\n\n const requestClose = useCallback(() => {\n if (timer.current) return;\n // Read at close time, not at mount: the setting can change under a long-lived\n // page, and `matchMedia` is absent in jsdom and in SSR — where \"no animation\" is\n // also the only correct answer, since nothing is painting.\n const reduced =\n typeof window === \"undefined\" ||\n typeof window.matchMedia !== \"function\" ||\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n if (reduced || ms <= 0) {\n latest.current();\n return;\n }\n setClosing(true);\n timer.current = setTimeout(() => {\n timer.current = null;\n // ⚠️ Reset BEFORE handing control back — reported from a phone within the hour\n // of the 0.4.52 deploy that first shipped this hook, against every table at once.\n // A panel that returns `null` while closed is still MOUNTED — `FullBleedDialog`\n // early-returns after its hooks, and DataTable renders it once and flips `open`\n // — so this state outlives the panel it describes. Left true, the NEXT open\n // rendered `animate-*-out` with `animation-fill-mode: forwards` on a sheet that\n // had only just arrived: it lowered itself off the screen in 220ms and stayed\n // there, invisible but still holding the backdrop's pointer events. Every table\n // on every phone, first open fine and every one after it not, until a route\n // change remounted the component.\n //\n // Batched with the `onClose` below, so a caller that unmounts on close still\n // renders exactly once and nothing flashes.\n setClosing(false);\n latest.current();\n }, ms);\n }, [ms]);\n\n return { closing, requestClose };\n}\n"],"mappings":";AAAA,SAAS,aAAa,WAAW,QAAQ,gBAAgB;AAMlD,MAAM,kBAAkB;AAgCxB,SAAS,mBACd,SACA,KAAa,iBACmC;AAChD,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAC5C,QAAM,QAAQ,OAA6C,IAAI;AAK/D,QAAM,SAAS,OAAO,OAAO;AAC7B,YAAU,MAAM;AACd,WAAO,UAAU;AAAA,EACnB,CAAC;AAKD;AAAA,IACE,MAAM,MAAM;AACV,UAAI,MAAM,QAAS,cAAa,MAAM,OAAO;AAAA,IAC/C;AAAA,IACA,CAAC;AAAA,EACH;AAEA,QAAM,eAAe,YAAY,MAAM;AACrC,QAAI,MAAM,QAAS;AAInB,UAAM,UACJ,OAAO,WAAW,eAClB,OAAO,OAAO,eAAe,cAC7B,OAAO,WAAW,kCAAkC,EAAE;AACxD,QAAI,WAAW,MAAM,GAAG;AACtB,aAAO,QAAQ;AACf;AAAA,IACF;AACA,eAAW,IAAI;AACf,UAAM,UAAU,WAAW,MAAM;AAC/B,YAAM,UAAU;AAchB,iBAAW,KAAK;AAChB,aAAO,QAAQ;AAAA,IACjB,GAAG,EAAE;AAAA,EACP,GAAG,CAAC,EAAE,CAAC;AAEP,SAAO,EAAE,SAAS,aAAa;AACjC;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-dismiss.ts"],"sourcesContent":["import { useEffect, useRef } from \"react\";\nimport type { RefObject } from \"react\";\n\n/**\n * Calls `handler` on an Escape keydown while `enabled`. A single document-level\n * listener owns the \"press Escape to dismiss\" behaviour that popovers, flyouts\n * and menus otherwise each re-implement. `handler` is read live at event time,\n * so it need not be memoised and the listener only re-subscribes when `enabled`\n * flips (typically the open/closed transition).\n */\nexport function useEscapeKey(handler: () => void, enabled = true): void {\n const handlerRef = useRef(handler);\n // Written in an effect, not during render, so it satisfies react-hooks/refs: a\n // render that React starts and then discards — a transition, a Suspense retry —\n // would otherwise leave this holding a handler from a tree that never committed,\n // and the document listener below would go on to call it. Keydown events fire\n // after the commit, so the ref is always current by the time one arrives.\n useEffect(() => {\n handlerRef.current = handler;\n });\n useEffect(() => {\n if (!enabled) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") handlerRef.current();\n };\n document.addEventListener(\"keydown\", onKey);\n return () => document.removeEventListener(\"keydown\", onKey);\n }, [enabled]);\n}\n\n/**\n * Calls `handler` on a press that lands outside every supplied element, while\n * `enabled` — the outside-click-to-close half of the same dismissal story. Pass one\n * ref or several (trigger + panel); a press inside any of them is ignored. Refs and\n * `handler` are read live, so an inline `[triggerRef, panelRef]` array is fine and\n * won't churn the listener.\n *\n * **`pointerdown`, not `mousedown`** — one event covering mouse, touch and pen.\n * This listened for `mousedown` alone, which on a touch platform is a SYNTHESISED\n * event: the browser emits it for most taps, which is why nothing obviously broke,\n * but the synthesis is not guaranteed. A tap that begins a scroll, or one that lands\n * on an element which preventDefaults the touch sequence, may never produce one — and\n * on a phone-first PWA whose pickers, menus and flyouts all dismiss through this hook,\n * inheriting that is worse than choosing it.\n *\n * The one behaviour this changes deliberately: a touch-drag that starts outside an\n * open panel now dismisses it at touch-down, where before it did not dismiss at all\n * (a drag synthesises no mousedown). That matches every other platform surface, and\n * the anchored panels re-measure on scroll precisely because the page moves under\n * them.\n */\nexport function useOutsideClick(\n refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],\n handler: () => void,\n enabled = true,\n): void {\n const refsRef = useRef(refs);\n const handlerRef = useRef(handler);\n // In an effect rather than during render — see the note on useEscapeKey.\n useEffect(() => {\n refsRef.current = refs;\n handlerRef.current = handler;\n });\n useEffect(() => {\n if (!enabled) return;\n const onPointerDown = (e: Event) => {\n const target = e.target as Node;\n const list = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];\n for (const r of list) {\n if (r.current?.contains(target)) return;\n }\n handlerRef.current();\n };\n document.addEventListener(\"pointerdown\", onPointerDown);\n return () => document.removeEventListener(\"pointerdown\", onPointerDown);\n }, [enabled]);\n}\n"],"mappings":"AAAA,SAAS,WAAW,cAAc;AAU3B,SAAS,aAAa,SAAqB,UAAU,MAAY;AACtE,QAAM,aAAa,OAAO,OAAO;AAMjC,YAAU,MAAM;AACd,eAAW,UAAU;AAAA,EACvB,CAAC;AACD,YAAU,MAAM;AACd,QAAI,CAAC,QAAS;AACd,UAAM,QAAQ,CAAC,MAAqB;AAClC,UAAI,EAAE,QAAQ,SAAU,YAAW,QAAQ;AAAA,IAC7C;AACA,aAAS,iBAAiB,WAAW,KAAK;AAC1C,WAAO,MAAM,SAAS,oBAAoB,WAAW,KAAK;AAAA,EAC5D,GAAG,CAAC,OAAO,CAAC;AACd;AAuBO,SAAS,gBACd,MACA,SACA,UAAU,MACJ;AACN,QAAM,UAAU,OAAO,IAAI;AAC3B,QAAM,aAAa,OAAO,OAAO;AAEjC,YAAU,MAAM;AACd,YAAQ,UAAU;AAClB,eAAW,UAAU;AAAA,EACvB,CAAC;AACD,YAAU,MAAM;AACd,QAAI,CAAC,QAAS;AACd,UAAM,gBAAgB,CAAC,MAAa;AAClC,YAAM,SAAS,EAAE;AACjB,YAAM,OAAO,MAAM,QAAQ,QAAQ,OAAO,IAAI,QAAQ,UAAU,CAAC,QAAQ,OAAO;AAChF,iBAAW,KAAK,MAAM;AACpB,YAAI,EAAE,SAAS,SAAS,MAAM,EAAG;AAAA,MACnC;AACA,iBAAW,QAAQ;AAAA,IACrB;AACA,aAAS,iBAAiB,eAAe,aAAa;AACtD,WAAO,MAAM,SAAS,oBAAoB,eAAe,aAAa;AAAA,EACxE,GAAG,CAAC,OAAO,CAAC;AACd;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-dismiss.ts"],"sourcesContent":["import { useEffect, useRef } from \"react\";\nimport type { RefObject } from \"react\";\n\n/**\n * Calls `handler` on an Escape keydown while `enabled`. A single document-level\n * listener owns the \"press Escape to dismiss\" behaviour that popovers, flyouts\n * and menus otherwise each re-implement. `handler` is read live at event time,\n * so it need not be memoised and the listener only re-subscribes when `enabled`\n * flips (typically the open/closed transition).\n */\nexport function useEscapeKey(handler: () => void, enabled = true): void {\n const handlerRef = useRef(handler);\n // Written in an effect, not during render, so it satisfies react-hooks/refs: a\n // render that React starts and then discards — a transition, a Suspense retry —\n // would otherwise leave this holding a handler from a tree that never committed,\n // and the document listener below would go on to call it. Keydown events fire\n // after the commit, so the ref is always current by the time one arrives.\n useEffect(() => {\n handlerRef.current = handler;\n });\n useEffect(() => {\n if (!enabled) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") handlerRef.current();\n };\n document.addEventListener(\"keydown\", onKey);\n return () => document.removeEventListener(\"keydown\", onKey);\n }, [enabled]);\n}\n\n/**\n * Calls `handler` on a press that lands outside every supplied element, while\n * `enabled` — the outside-click-to-close half of the same dismissal story. Pass one\n * ref or several (trigger + panel); a press inside any of them is ignored. Refs and\n * `handler` are read live, so an inline `[triggerRef, panelRef]` array is fine and\n * won't churn the listener.\n *\n * **`pointerdown`, not `mousedown`** — one event covering mouse, touch and pen.\n * This listened for `mousedown` alone, which on a touch platform is a SYNTHESISED\n * event: the browser emits it for most taps, which is why nothing obviously broke,\n * but the synthesis is not guaranteed. A tap that begins a scroll, or one that lands\n * on an element which preventDefaults the touch sequence, may never produce one — and\n * on a phone-first PWA whose pickers, menus and flyouts all dismiss through this hook,\n * inheriting that is worse than choosing it.\n *\n * The one behaviour this changes deliberately: a touch-drag that starts outside an\n * open panel now dismisses it at touch-down, where before it did not dismiss at all\n * (a drag synthesises no mousedown). That matches every other platform surface, and\n * the anchored panels re-measure on scroll precisely because the page moves under\n * them.\n */\nexport function useOutsideClick(\n refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],\n handler: () => void,\n enabled = true,\n): void {\n const refsRef = useRef(refs);\n const handlerRef = useRef(handler);\n // In an effect rather than during render — see the note on useEscapeKey.\n useEffect(() => {\n refsRef.current = refs;\n handlerRef.current = handler;\n });\n useEffect(() => {\n if (!enabled) return;\n const onPointerDown = (e: Event) => {\n const target = e.target as Node;\n const list = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];\n for (const r of list) {\n if (r.current?.contains(target)) return;\n }\n handlerRef.current();\n };\n document.addEventListener(\"pointerdown\", onPointerDown);\n return () => document.removeEventListener(\"pointerdown\", onPointerDown);\n }, [enabled]);\n}\n"],"mappings":";AAAA,SAAS,WAAW,cAAc;AAU3B,SAAS,aAAa,SAAqB,UAAU,MAAY;AACtE,QAAM,aAAa,OAAO,OAAO;AAMjC,YAAU,MAAM;AACd,eAAW,UAAU;AAAA,EACvB,CAAC;AACD,YAAU,MAAM;AACd,QAAI,CAAC,QAAS;AACd,UAAM,QAAQ,CAAC,MAAqB;AAClC,UAAI,EAAE,QAAQ,SAAU,YAAW,QAAQ;AAAA,IAC7C;AACA,aAAS,iBAAiB,WAAW,KAAK;AAC1C,WAAO,MAAM,SAAS,oBAAoB,WAAW,KAAK;AAAA,EAC5D,GAAG,CAAC,OAAO,CAAC;AACd;AAuBO,SAAS,gBACd,MACA,SACA,UAAU,MACJ;AACN,QAAM,UAAU,OAAO,IAAI;AAC3B,QAAM,aAAa,OAAO,OAAO;AAEjC,YAAU,MAAM;AACd,YAAQ,UAAU;AAClB,eAAW,UAAU;AAAA,EACvB,CAAC;AACD,YAAU,MAAM;AACd,QAAI,CAAC,QAAS;AACd,UAAM,gBAAgB,CAAC,MAAa;AAClC,YAAM,SAAS,EAAE;AACjB,YAAM,OAAO,MAAM,QAAQ,QAAQ,OAAO,IAAI,QAAQ,UAAU,CAAC,QAAQ,OAAO;AAChF,iBAAW,KAAK,MAAM;AACpB,YAAI,EAAE,SAAS,SAAS,MAAM,EAAG;AAAA,MACnC;AACA,iBAAW,QAAQ;AAAA,IACrB;AACA,aAAS,iBAAiB,eAAe,aAAa;AACtD,WAAO,MAAM,SAAS,oBAAoB,eAAe,aAAa;AAAA,EACxE,GAAG,CAAC,OAAO,CAAC;AACd;","names":[]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface FocusTrapOptions {
|
|
2
|
+
/** Trap only while this is true. Mount-time `true` is the common case. */
|
|
3
|
+
active?: boolean;
|
|
4
|
+
/** Return focus to whatever had it before the trap engaged. Default `true`. */
|
|
5
|
+
restoreFocus?: boolean;
|
|
6
|
+
/**
|
|
7
|
+
* What to focus when the trap engages. Default is the container itself, which is
|
|
8
|
+
* what `Modal` does and why: focusing the first FIELD pops the software keyboard on
|
|
9
|
+
* a phone the moment a dialog opens, before the user has asked to type.
|
|
10
|
+
* The container needs `tabIndex={-1}` for this to work.
|
|
11
|
+
*/
|
|
12
|
+
initialFocus?: "container" | "first" | (() => HTMLElement | null);
|
|
13
|
+
}
|
|
14
|
+
declare function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>, { active, restoreFocus, initialFocus }?: FocusTrapOptions): void;
|
|
15
|
+
|
|
16
|
+
export { type FocusTrapOptions, useFocusTrap };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
const FOCUSABLE = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
4
|
+
const TRAP_ATTR = "data-focus-trap";
|
|
5
|
+
function useFocusTrap(containerRef, { active = true, restoreFocus = true, initialFocus = "container" } = {}) {
|
|
6
|
+
const optsRef = useRef({ restoreFocus, initialFocus });
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
optsRef.current = { restoreFocus, initialFocus };
|
|
9
|
+
});
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!active) return;
|
|
12
|
+
const container = containerRef.current;
|
|
13
|
+
if (!container) return;
|
|
14
|
+
container.setAttribute(TRAP_ATTR, "");
|
|
15
|
+
const previouslyFocused = document.activeElement;
|
|
16
|
+
const inNestedTrap = (node) => {
|
|
17
|
+
if (!node) return false;
|
|
18
|
+
const owner = node.closest(`[${TRAP_ATTR}]`);
|
|
19
|
+
return owner !== null && owner !== container;
|
|
20
|
+
};
|
|
21
|
+
const tabbables = () => Array.from(container.querySelectorAll(FOCUSABLE)).filter(
|
|
22
|
+
// A tabbable inside a NESTED open overlay belongs to that overlay's trap, not
|
|
23
|
+
// to this one — otherwise the outer list spans both panels and Tab from the
|
|
24
|
+
// inner panel's last control wraps to the OUTER panel's first.
|
|
25
|
+
(el) => !inNestedTrap(el)
|
|
26
|
+
);
|
|
27
|
+
const { initialFocus: how } = optsRef.current;
|
|
28
|
+
if (typeof how === "function") {
|
|
29
|
+
(how() ?? container).focus();
|
|
30
|
+
} else if (how === "first") {
|
|
31
|
+
(tabbables()[0] ?? container).focus();
|
|
32
|
+
} else {
|
|
33
|
+
container.focus();
|
|
34
|
+
}
|
|
35
|
+
const onKeyDown = (e) => {
|
|
36
|
+
if (e.key !== "Tab") return;
|
|
37
|
+
if (inNestedTrap(document.activeElement)) return;
|
|
38
|
+
const items = tabbables();
|
|
39
|
+
if (items.length === 0) {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
container.focus();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const first = items[0];
|
|
45
|
+
const last = items[items.length - 1];
|
|
46
|
+
const activeEl = document.activeElement;
|
|
47
|
+
if (e.shiftKey && (activeEl === first || activeEl === container)) {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
last.focus();
|
|
50
|
+
} else if (!e.shiftKey && activeEl === last) {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
first.focus();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
container.addEventListener("keydown", onKeyDown);
|
|
56
|
+
return () => {
|
|
57
|
+
container.removeEventListener("keydown", onKeyDown);
|
|
58
|
+
container.removeAttribute(TRAP_ATTR);
|
|
59
|
+
if (!optsRef.current.restoreFocus) return;
|
|
60
|
+
if (previouslyFocused?.isConnected) previouslyFocused.focus?.();
|
|
61
|
+
};
|
|
62
|
+
}, [active, containerRef]);
|
|
63
|
+
}
|
|
64
|
+
export {
|
|
65
|
+
useFocusTrap
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=use-focus-trap.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-focus-trap.ts"],"sourcesContent":["import { useEffect, useRef } 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 * - **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 * Tabbable descendants, in DOM order.\n *\n * Deliberately the same selector `Modal` used, rather than a more thorough one that\n * also walks `inert`, `visibility: hidden` and open `<details>`: this is the set that\n * has been in production across three apps, and widening it here would change which\n * element receives focus in overlays nobody asked me to change the behaviour of.\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 * 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 initialFocus?: \"container\" | \"first\" | (() => HTMLElement | null);\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.\n const optsRef = useRef({ restoreFocus, initialFocus });\n useEffect(() => {\n optsRef.current = { restoreFocus, initialFocus };\n });\n\n useEffect(() => {\n if (!active) return;\n const container = containerRef.current;\n if (!container) return;\n\n container.setAttribute(TRAP_ATTR, \"\");\n const previouslyFocused = document.activeElement as HTMLElement | null;\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) => !inNestedTrap(el),\n );\n\n const { initialFocus: how } = optsRef.current;\n if (typeof how === \"function\") {\n (how() ?? container).focus();\n } else if (how === \"first\") {\n (tabbables()[0] ?? container).focus();\n } else {\n 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 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 (previouslyFocused?.isConnected) previouslyFocused.focus?.();\n };\n }, [active, containerRef]);\n}\n"],"mappings":";AAAA,SAAS,WAAW,cAAc;AAyClC,MAAM,YACJ;AAcF,MAAM,YAAY;AAgBX,SAAS,aACd,cACA,EAAE,SAAS,MAAM,eAAe,MAAM,eAAe,YAAY,IAAsB,CAAC,GAClF;AAIN,QAAM,UAAU,OAAO,EAAE,cAAc,aAAa,CAAC;AACrD,YAAU,MAAM;AACd,YAAQ,UAAU,EAAE,cAAc,aAAa;AAAA,EACjD,CAAC;AAED,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AACb,UAAM,YAAY,aAAa;AAC/B,QAAI,CAAC,UAAW;AAEhB,cAAU,aAAa,WAAW,EAAE;AACpC,UAAM,oBAAoB,SAAS;AAGnC,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,CAAC,aAAa,EAAE;AAAA,IAC1B;AAEF,UAAM,EAAE,cAAc,IAAI,IAAI,QAAQ;AACtC,QAAI,OAAO,QAAQ,YAAY;AAC7B,OAAC,IAAI,KAAK,WAAW,MAAM;AAAA,IAC7B,WAAW,QAAQ,SAAS;AAC1B,OAAC,UAAU,EAAE,CAAC,KAAK,WAAW,MAAM;AAAA,IACtC,OAAO;AACL,gBAAU,MAAM;AAAA,IAClB;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;AAEnC,UAAI,CAAC,QAAQ,QAAQ,aAAc;AAKnC,UAAI,mBAAmB,YAAa,mBAAkB,QAAQ;AAAA,IAChE;AAAA,EACF,GAAG,CAAC,QAAQ,YAAY,CAAC;AAC3B;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-media-query.ts"],"sourcesContent":["import { useEffect, useState } from \"react\";\n\n/**\n * Subscribe to a CSS media query, returning whether it currently matches and\n * re-rendering when it changes. `fallback` is the value used during SSR / when\n * `window.matchMedia` is unavailable.\n */\nexport function useMediaQuery(query: string, fallback: boolean): boolean {\n const get = () =>\n typeof window !== \"undefined\" && typeof window.matchMedia === \"function\"\n ? window.matchMedia(query).matches\n : fallback;\n const [matches, setMatches] = useState<boolean>(get);\n useEffect(() => {\n if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return;\n const mql = window.matchMedia(query);\n const update = () => setMatches(mql.matches);\n update();\n mql.addEventListener(\"change\", update);\n return () => mql.removeEventListener(\"change\", update);\n }, [query]);\n return matches;\n}\n"],"mappings":"AAAA,SAAS,WAAW,gBAAgB;AAO7B,SAAS,cAAc,OAAe,UAA4B;AACvE,QAAM,MAAM,MACV,OAAO,WAAW,eAAe,OAAO,OAAO,eAAe,aAC1D,OAAO,WAAW,KAAK,EAAE,UACzB;AACN,QAAM,CAAC,SAAS,UAAU,IAAI,SAAkB,GAAG;AACnD,YAAU,MAAM;AACd,QAAI,OAAO,WAAW,eAAe,OAAO,OAAO,eAAe,WAAY;AAC9E,UAAM,MAAM,OAAO,WAAW,KAAK;AACnC,UAAM,SAAS,MAAM,WAAW,IAAI,OAAO;AAC3C,WAAO;AACP,QAAI,iBAAiB,UAAU,MAAM;AACrC,WAAO,MAAM,IAAI,oBAAoB,UAAU,MAAM;AAAA,EACvD,GAAG,CAAC,KAAK,CAAC;AACV,SAAO;AACT;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-media-query.ts"],"sourcesContent":["import { useEffect, useState } from \"react\";\n\n/**\n * Subscribe to a CSS media query, returning whether it currently matches and\n * re-rendering when it changes. `fallback` is the value used during SSR / when\n * `window.matchMedia` is unavailable.\n */\nexport function useMediaQuery(query: string, fallback: boolean): boolean {\n const get = () =>\n typeof window !== \"undefined\" && typeof window.matchMedia === \"function\"\n ? window.matchMedia(query).matches\n : fallback;\n const [matches, setMatches] = useState<boolean>(get);\n useEffect(() => {\n if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return;\n const mql = window.matchMedia(query);\n const update = () => setMatches(mql.matches);\n update();\n mql.addEventListener(\"change\", update);\n return () => mql.removeEventListener(\"change\", update);\n }, [query]);\n return matches;\n}\n"],"mappings":";AAAA,SAAS,WAAW,gBAAgB;AAO7B,SAAS,cAAc,OAAe,UAA4B;AACvE,QAAM,MAAM,MACV,OAAO,WAAW,eAAe,OAAO,OAAO,eAAe,aAC1D,OAAO,WAAW,KAAK,EAAE,UACzB;AACN,QAAM,CAAC,SAAS,UAAU,IAAI,SAAkB,GAAG;AACnD,YAAU,MAAM;AACd,QAAI,OAAO,WAAW,eAAe,OAAO,OAAO,eAAe,WAAY;AAC9E,UAAM,MAAM,OAAO,WAAW,KAAK;AACnC,UAAM,SAAS,MAAM,WAAW,IAAI,OAAO;AAC3C,WAAO;AACP,QAAI,iBAAiB,UAAU,MAAM;AACrC,WAAO,MAAM,IAAI,oBAAoB,UAAU,MAAM;AAAA,EACvD,GAAG,CAAC,KAAK,CAAC;AACV,SAAO;AACT;","names":[]}
|