@eifi1/ui-kit 0.4.1 → 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 +89 -19
- 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 +74 -19
- package/dist/components/dropdown.js +72 -22
- 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 +21 -7
- package/dist/components/file-dropzone.js +28 -7
- 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 +21 -2
- package/dist/components/number-input.js +2 -1
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +25 -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 +5 -3
- package/dist/components/user-avatar.js +4 -2
- 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 +14 -1
- package/dist/theme/palette-presets.js +231 -3
- 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 +66 -13
- 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
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keep keyboard focus inside an open overlay, and give it back when it closes.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from `Modal`, which was the ONLY component in this package that did any of
|
|
7
|
+
* this. Every other overlay — the full-bleed dialog a phone's row editor opens, the
|
|
8
|
+
* picker sheet, the numpad, the tour card — declared `role="dialog" aria-modal="true"`
|
|
9
|
+
* and then left focus on the page behind it. That is the worst of the two possible
|
|
10
|
+
* bugs: assistive technology hides the page because of `aria-modal`, while the user's
|
|
11
|
+
* focus is still down there in the part that is now hidden, so Tab walks through
|
|
12
|
+
* controls the screen reader refuses to describe.
|
|
13
|
+
*
|
|
14
|
+
* What this does beyond Modal's original copy:
|
|
15
|
+
*
|
|
16
|
+
* - **Recomputes the tabbable list on every Tab**, not once when the overlay opens.
|
|
17
|
+
* These panels have conditionally-rendered contents — a search box that appears
|
|
18
|
+
* once options load, a "clear" button that exists only while a filter is set — and
|
|
19
|
+
* a list captured at open time traps focus against elements that have since gone.
|
|
20
|
+
* - **Guards the restore target.** The element focused before opening may have left
|
|
21
|
+
* the DOM by the time the overlay closes (the row that owned the button was
|
|
22
|
+
* filtered away). Calling `.focus()` on a detached node silently drops focus to
|
|
23
|
+
* `<body>`, which for a keyboard user means losing their place in the page
|
|
24
|
+
* entirely. When the target is gone, focus falls back to the nearest thing that
|
|
25
|
+
* still exists rather than to nothing.
|
|
26
|
+
* - **Nests.** An overlay can open another — a picker inside a dialog. Only the
|
|
27
|
+
* innermost trap handles a keystroke, and closing it hands control back to the one
|
|
28
|
+
* underneath. Innermost is decided by DOM containment rather than by mount order;
|
|
29
|
+
* see the note on `TRAP_ATTR` for why the obvious stack is wrong.
|
|
30
|
+
* `use-overlay-history.ts` reasons about the same nesting problem for the back
|
|
31
|
+
* button; this is the focus half of it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Tabbable descendants, in DOM order.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately the same selector `Modal` used, rather than a more thorough one that
|
|
38
|
+
* also walks `inert`, `visibility: hidden` and open `<details>`: this is the set that
|
|
39
|
+
* has been in production across three apps, and widening it here would change which
|
|
40
|
+
* element receives focus in overlays nobody asked me to change the behaviour of.
|
|
41
|
+
*/
|
|
42
|
+
const FOCUSABLE =
|
|
43
|
+
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Marks a container whose trap is currently active.
|
|
47
|
+
*
|
|
48
|
+
* "Which trap is innermost?" is answered by DOM CONTAINMENT, not by mount order. The
|
|
49
|
+
* obvious implementation — a module-level stack pushed in the effect — is wrong, and
|
|
50
|
+
* wrong in a way that passes a casual reading: React runs CHILD effects before PARENT
|
|
51
|
+
* effects, so a nested overlay pushes first and the outer one ends up on top of the
|
|
52
|
+
* stack, claiming to be innermost. The outer trap then handles the keystroke and wraps
|
|
53
|
+
* focus to its own first element, jumping the user out of the picker they had open.
|
|
54
|
+
*
|
|
55
|
+
* An attribute on the live DOM cannot disagree with the DOM.
|
|
56
|
+
*/
|
|
57
|
+
const TRAP_ATTR = "data-focus-trap";
|
|
58
|
+
|
|
59
|
+
export interface FocusTrapOptions {
|
|
60
|
+
/** Trap only while this is true. Mount-time `true` is the common case. */
|
|
61
|
+
active?: boolean;
|
|
62
|
+
/** Return focus to whatever had it before the trap engaged. Default `true`. */
|
|
63
|
+
restoreFocus?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* What to focus when the trap engages. Default is the container itself, which is
|
|
66
|
+
* what `Modal` does and why: focusing the first FIELD pops the software keyboard on
|
|
67
|
+
* a phone the moment a dialog opens, before the user has asked to type.
|
|
68
|
+
* The container needs `tabIndex={-1}` for this to work.
|
|
69
|
+
*/
|
|
70
|
+
initialFocus?: "container" | "first" | (() => HTMLElement | null);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useFocusTrap(
|
|
74
|
+
containerRef: React.RefObject<HTMLElement | null>,
|
|
75
|
+
{ active = true, restoreFocus = true, initialFocus = "container" }: FocusTrapOptions = {},
|
|
76
|
+
): void {
|
|
77
|
+
// Latest-ref so a caller passing an inline `initialFocus` thunk — which every caller
|
|
78
|
+
// will — does not tear the trap down and re-engage it on each render, stealing focus
|
|
79
|
+
// back to the top of the panel mid-interaction.
|
|
80
|
+
const optsRef = useRef({ restoreFocus, initialFocus });
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
optsRef.current = { restoreFocus, initialFocus };
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!active) return;
|
|
87
|
+
const container = containerRef.current;
|
|
88
|
+
if (!container) return;
|
|
89
|
+
|
|
90
|
+
container.setAttribute(TRAP_ATTR, "");
|
|
91
|
+
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
92
|
+
|
|
93
|
+
/** Is `node` inside a trap nested within this one? */
|
|
94
|
+
const inNestedTrap = (node: Element | null) => {
|
|
95
|
+
if (!node) return false;
|
|
96
|
+
const owner = node.closest(`[${TRAP_ATTR}]`);
|
|
97
|
+
return owner !== null && owner !== container;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const tabbables = () =>
|
|
101
|
+
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
|
102
|
+
// A tabbable inside a NESTED open overlay belongs to that overlay's trap, not
|
|
103
|
+
// to this one — otherwise the outer list spans both panels and Tab from the
|
|
104
|
+
// inner panel's last control wraps to the OUTER panel's first.
|
|
105
|
+
(el) => !inNestedTrap(el),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const { initialFocus: how } = optsRef.current;
|
|
109
|
+
if (typeof how === "function") {
|
|
110
|
+
(how() ?? container).focus();
|
|
111
|
+
} else if (how === "first") {
|
|
112
|
+
(tabbables()[0] ?? container).focus();
|
|
113
|
+
} else {
|
|
114
|
+
container.focus();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
118
|
+
if (e.key !== "Tab") return;
|
|
119
|
+
// Only the innermost trap acts. The event bubbles through every ancestor
|
|
120
|
+
// container, so without this an outer dialog and an inner picker both handle one
|
|
121
|
+
// Tab and fight over where focus lands.
|
|
122
|
+
if (inNestedTrap(document.activeElement)) return;
|
|
123
|
+
|
|
124
|
+
// Recomputed per keystroke — see the note at the top of this file.
|
|
125
|
+
const items = tabbables();
|
|
126
|
+
if (items.length === 0) {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
container.focus();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const first = items[0];
|
|
132
|
+
const last = items[items.length - 1];
|
|
133
|
+
const activeEl = document.activeElement;
|
|
134
|
+
if (e.shiftKey && (activeEl === first || activeEl === container)) {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
last.focus();
|
|
137
|
+
} else if (!e.shiftKey && activeEl === last) {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
first.focus();
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// On the container, not the document: a portalled child panel (an open dropdown
|
|
144
|
+
// rendered to <body>) is outside this subtree and manages its own focus, which is
|
|
145
|
+
// the behaviour Modal's comment describes and consumers already depend on.
|
|
146
|
+
container.addEventListener("keydown", onKeyDown);
|
|
147
|
+
|
|
148
|
+
return () => {
|
|
149
|
+
container.removeEventListener("keydown", onKeyDown);
|
|
150
|
+
container.removeAttribute(TRAP_ATTR);
|
|
151
|
+
|
|
152
|
+
if (!optsRef.current.restoreFocus) return;
|
|
153
|
+
// `isConnected` is the whole point: restoring to a node that has been removed
|
|
154
|
+
// sends focus to <body> and the user loses their place entirely. When the
|
|
155
|
+
// original is gone, leave focus where the browser put it rather than moving it
|
|
156
|
+
// somewhere arbitrary — a wrong guess is worse than no move.
|
|
157
|
+
if (previouslyFocused?.isConnected) previouslyFocused.focus?.();
|
|
158
|
+
};
|
|
159
|
+
}, [active, containerRef]);
|
|
160
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Subscribe to a CSS media query, returning whether it currently matches and
|
|
5
|
+
* re-rendering when it changes. `fallback` is the value used during SSR / when
|
|
6
|
+
* `window.matchMedia` is unavailable.
|
|
7
|
+
*/
|
|
8
|
+
export function useMediaQuery(query: string, fallback: boolean): boolean {
|
|
9
|
+
const get = () =>
|
|
10
|
+
typeof window !== "undefined" && typeof window.matchMedia === "function"
|
|
11
|
+
? window.matchMedia(query).matches
|
|
12
|
+
: fallback;
|
|
13
|
+
const [matches, setMatches] = useState<boolean>(get);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
|
16
|
+
const mql = window.matchMedia(query);
|
|
17
|
+
const update = () => setMatches(mql.matches);
|
|
18
|
+
update();
|
|
19
|
+
mql.addEventListener("change", update);
|
|
20
|
+
return () => mql.removeEventListener("change", update);
|
|
21
|
+
}, [query]);
|
|
22
|
+
return matches;
|
|
23
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Back closes the overlay, not the page (Keksdose feedback #172).
|
|
5
|
+
*
|
|
6
|
+
* On a phone the Back gesture is the universal "dismiss this" — so a dialog or a
|
|
7
|
+
* full-screen search surface that ignores it doesn't merely fail to close: the
|
|
8
|
+
* navigation lands somewhere else entirely, and on a route with nothing beneath it
|
|
9
|
+
* that means leaving the app with the overlay still notionally open.
|
|
10
|
+
*
|
|
11
|
+
* The mechanism is one throwaway, SAME-URL history entry pushed while the overlay is
|
|
12
|
+
* up. Back then pops a same-document entry — no route change, no re-render of the
|
|
13
|
+
* page under it — and this hook turns that pop into `onClose()`. Same trick the
|
|
14
|
+
* transactions page already uses for its unsaved-edit trap
|
|
15
|
+
* (`use-soft-leave-guard.ts`); the two coexist because they use different marker
|
|
16
|
+
* keys AND because `handlePop` only claims a pop that actually consumed OUR entry
|
|
17
|
+
* — a foreign sentinel stacked on top of ours pops back ONTO us, and an overlay
|
|
18
|
+
* that closed on that would vanish mid-edit (feedback #424/#426).
|
|
19
|
+
*
|
|
20
|
+
* Not a URL parameter, deliberately. Encoding "a dialog is open" in the address bar
|
|
21
|
+
* makes it shareable and restorable, which is wrong for the overlays this covers —
|
|
22
|
+
* nobody wants to send someone a link that opens a confirm dialog — and it would
|
|
23
|
+
* fight the page-owned `?row=` / `f.*` params that ARE meaningful state.
|
|
24
|
+
*
|
|
25
|
+
* ## Ordering
|
|
26
|
+
*
|
|
27
|
+
* The stack is module-level because correctness here is about order ACROSS
|
|
28
|
+
* instances: every mounted overlay hears the same `popstate`, but only the one whose
|
|
29
|
+
* entry was actually popped may close. Overlays nest and unwind last-in-first-out —
|
|
30
|
+
* a dialog opened from a search surface closes before the surface — so the top of
|
|
31
|
+
* this stack is by construction the entry the browser just popped.
|
|
32
|
+
*
|
|
33
|
+
* ## Not undoing a real navigation
|
|
34
|
+
*
|
|
35
|
+
* The cleanup pops our entry when the overlay closes some other way (a button,
|
|
36
|
+
* Escape, a backdrop click) so history doesn't silently accumulate dead entries. It
|
|
37
|
+
* first checks that our marker is still the CURRENT entry: if the user followed a
|
|
38
|
+
* link while the overlay was open, the router has pushed on top of us, and a blind
|
|
39
|
+
* `history.back()` there would undo their navigation.
|
|
40
|
+
*
|
|
41
|
+
* ## The traversal is deferred
|
|
42
|
+
*
|
|
43
|
+
* And it is *scheduled* rather than issued, because `history.go()` lands a task
|
|
44
|
+
* later while everything this module knows is written synchronously. See
|
|
45
|
+
* {@link Unwind}: an overlay opening inside that window adopts the entry instead
|
|
46
|
+
* of stacking a second one on it, which is what makes the hook survive React
|
|
47
|
+
* StrictMode's mount → clean up → mount (steering-design feedback #49).
|
|
48
|
+
*/
|
|
49
|
+
const SENTINEL_KEY = "__hbUiOverlayHistory";
|
|
50
|
+
|
|
51
|
+
interface OverlayEntry {
|
|
52
|
+
id: string;
|
|
53
|
+
close: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const stack: OverlayEntry[] = [];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The sentinels we have PUSHED and not yet unwound, oldest first.
|
|
60
|
+
*
|
|
61
|
+
* The stack above tracks live overlays; this tracks live history entries, and the
|
|
62
|
+
* two come apart in exactly one case — which is the bug this list exists for.
|
|
63
|
+
* Nested overlays that close in the same commit unwind parent-first, so the dialog's
|
|
64
|
+
* cleanup runs while the sheet's entry is still on top of its own. It cannot call
|
|
65
|
+
* `history.back()` there (that would consume the SHEET's entry, and the sheet is
|
|
66
|
+
* about to consume it itself), so its entry was simply abandoned: a husk with no
|
|
67
|
+
* live owner sitting between the user and the page.
|
|
68
|
+
*
|
|
69
|
+
* The user paid for it on the next Back press. It popped the husk, `handlePop` found
|
|
70
|
+
* an empty stack and returned, and nothing visible happened — Back had to be pressed
|
|
71
|
+
* twice to leave the page.
|
|
72
|
+
*
|
|
73
|
+
* So an abandoned entry is recorded as `dead` instead, and whichever overlay DOES
|
|
74
|
+
* get to unwind pays the debt with a single `history.go(-n)`. `href` is what keeps
|
|
75
|
+
* that safe: only dead entries pushed at the address we are still sitting at are
|
|
76
|
+
* unwound, so a router navigation that landed between two sentinels is never
|
|
77
|
+
* reversed on the user's behalf.
|
|
78
|
+
*/
|
|
79
|
+
interface PushedEntry {
|
|
80
|
+
id: string;
|
|
81
|
+
href: string;
|
|
82
|
+
/** Its overlay is gone but the entry is not — someone above owes this pop. */
|
|
83
|
+
dead: boolean;
|
|
84
|
+
}
|
|
85
|
+
const pushed: PushedEntry[] = [];
|
|
86
|
+
|
|
87
|
+
/** Entries a real Back press already consumed. Their overlay's cleanup still has to
|
|
88
|
+
* run, and it must not mistake "my entry is not current" for "my entry is buried"
|
|
89
|
+
* — it has no entry left at all. */
|
|
90
|
+
const consumed = new Set<string>();
|
|
91
|
+
|
|
92
|
+
/** Pops WE caused (a cleanup unwinding its own entries) — their `popstate` is
|
|
93
|
+
* bookkeeping, not a user gesture, and must not close a second overlay. A
|
|
94
|
+
* `history.go(-n)` traversal fires exactly ONE popstate however far it travels. */
|
|
95
|
+
let pendingProgrammatic = 0;
|
|
96
|
+
let listening = false;
|
|
97
|
+
let nextId = 0;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The unwind a cleanup has asked for and not yet issued.
|
|
101
|
+
*
|
|
102
|
+
* **`history.go()` is asynchronous and everything above is written
|
|
103
|
+
* synchronously**, which is the seam the whole of this block exists to close.
|
|
104
|
+
* The traversal lands a task later, so an overlay that opens in the *same* tick
|
|
105
|
+
* as one that closed used to push its sentinel on top of the entry that was
|
|
106
|
+
* about to be popped — and the pop then took the NEW entry. The overlay left on
|
|
107
|
+
* screen had no entry of its own (so Back no longer closed it) and the old one
|
|
108
|
+
* stayed behind as a husk between the user and their page.
|
|
109
|
+
*
|
|
110
|
+
* That is not an exotic race. **React StrictMode remounts every effect** — mount,
|
|
111
|
+
* clean up, mount again, all in one commit — so in development *every* dialog
|
|
112
|
+
* anyone opened left a husk behind, the position drifted one entry further from
|
|
113
|
+
* the page on each open, and the Back press that was meant to leave the page was
|
|
114
|
+
* spent on something invisible instead (steering-design feedback #49).
|
|
115
|
+
*
|
|
116
|
+
* So the traversal is deferred by a task, and an overlay mounting inside that
|
|
117
|
+
* window **adopts** the entry instead of pushing a second one (see the effect).
|
|
118
|
+
* Adoption is `replaceState` on an entry that is already there: no traversal, no
|
|
119
|
+
* window in which the count can be wrong, and the StrictMode remount costs
|
|
120
|
+
* exactly nothing.
|
|
121
|
+
*/
|
|
122
|
+
interface Unwind {
|
|
123
|
+
timer: ReturnType<typeof setTimeout>;
|
|
124
|
+
/** The sentinel on the entry to leave — the top of `entries`. */
|
|
125
|
+
id: string;
|
|
126
|
+
href: string;
|
|
127
|
+
/** That entry plus the abandoned ones directly beneath it, oldest first. */
|
|
128
|
+
entries: PushedEntry[];
|
|
129
|
+
}
|
|
130
|
+
let unwind: Unwind | null = null;
|
|
131
|
+
|
|
132
|
+
/** Issue the deferred traversal, if the ground has not moved under it.
|
|
133
|
+
*
|
|
134
|
+
* A real Back press or a router navigation during the deferred task leaves us
|
|
135
|
+
* somewhere that is not the entry we meant to leave, and going back from there
|
|
136
|
+
* would take something that is not ours. The entries are then simply forgotten
|
|
137
|
+
* rather than handed back as `dead`: a forgotten entry costs one dead Back
|
|
138
|
+
* press, while an over-counted one costs the user a navigation they did make. */
|
|
139
|
+
function runUnwind(): void {
|
|
140
|
+
const job = unwind;
|
|
141
|
+
unwind = null;
|
|
142
|
+
if (!job) return;
|
|
143
|
+
if (currentSentinel() !== job.id || currentHref() !== job.href) return;
|
|
144
|
+
pendingProgrammatic += 1;
|
|
145
|
+
window.history.go(-job.entries.length);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function currentHref(): string {
|
|
149
|
+
return typeof window === "undefined" ? "" : window.location.href;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function forgetPushed(id: string): void {
|
|
153
|
+
const at = pushed.findIndex((p) => p.id === id);
|
|
154
|
+
if (at >= 0) pushed.splice(at, 1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The sentinel this module believes is on the entry we are standing on.
|
|
159
|
+
*
|
|
160
|
+
* `history.state` is where the tag physically sits, because it is the only
|
|
161
|
+
* per-entry storage a browser has — but it is not this module's storage, so
|
|
162
|
+
* since the 2026-09-22 audit it is no longer read as the last word.
|
|
163
|
+
* The ROUTER owns that object and writes it WHOLE: every
|
|
164
|
+
* `setSearchParams(…, { replace: true })` stamps `{usr, key, idx}` over
|
|
165
|
+
* whatever was there. A `DataTable` with `urlSync` does exactly that, on mount
|
|
166
|
+
* and on every filter change — so an overlay opened from such a table had its
|
|
167
|
+
* tag wiped off the entry it was standing on, and then:
|
|
168
|
+
*
|
|
169
|
+
* * its cleanup read "not my entry" and abandoned a live entry as a husk;
|
|
170
|
+
* * the push path below could not find itself in `pushed`, so it discarded
|
|
171
|
+
* every record it had (`splice(0)`) and nothing was ever unwound again;
|
|
172
|
+
* * the StrictMode adoption never matched, so in development every dialog
|
|
173
|
+
* pushed a second entry and the deferred `go(-1)` ate the live one — which
|
|
174
|
+
* is Back no longer closing the overlay at all.
|
|
175
|
+
*
|
|
176
|
+
* So `standing` answers when the tag is gone: a MISSING tag on an entry we have
|
|
177
|
+
* not left is a wipe to repair rather than a verdict. The tag is still read
|
|
178
|
+
* FIRST wherever it is there, because it is the one thing that can see a FOREIGN
|
|
179
|
+
* same-URL entry stacked above ours (feedback #424/#426, below) — module-level
|
|
180
|
+
* bookkeeping cannot, since nobody tells us about someone else's `pushState`.
|
|
181
|
+
*/
|
|
182
|
+
let standing: string | null = null;
|
|
183
|
+
|
|
184
|
+
/** The tag actually on the current entry, with no repair and no belief. */
|
|
185
|
+
function taggedSentinel(): string | null {
|
|
186
|
+
const state = typeof window !== "undefined" ? window.history.state : null;
|
|
187
|
+
if (!state || typeof state !== "object") return null;
|
|
188
|
+
const id = (state as Record<string, unknown>)[SENTINEL_KEY];
|
|
189
|
+
return typeof id === "string" ? id : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Put our tag back on the current entry, keeping whatever the router has since
|
|
193
|
+
* written there — we are a passenger in that object, not its owner. */
|
|
194
|
+
function retag(id: string): void {
|
|
195
|
+
const prev = window.history.state;
|
|
196
|
+
window.history.replaceState(
|
|
197
|
+
{ ...(prev && typeof prev === "object" ? prev : {}), [SENTINEL_KEY]: id },
|
|
198
|
+
"",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function currentSentinel(): string | null {
|
|
203
|
+
const tag = taggedSentinel();
|
|
204
|
+
if (tag !== null) {
|
|
205
|
+
standing = tag;
|
|
206
|
+
return tag;
|
|
207
|
+
}
|
|
208
|
+
if (standing === null || typeof window === "undefined") return null;
|
|
209
|
+
// Only ever re-stamp an entry we still hold a record for. Without that guard a
|
|
210
|
+
// belief left over from an overlay that is long gone — a tab that has since
|
|
211
|
+
// navigated, a test file that ran another case — would be stamped onto a
|
|
212
|
+
// stranger's entry, and the next cleanup would traverse off it.
|
|
213
|
+
if (!pushed.some((p) => p.id === standing)) {
|
|
214
|
+
standing = null;
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
retag(standing);
|
|
218
|
+
return standing;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function handlePop() {
|
|
222
|
+
// A pop MOVES us, so the belief is stale by definition: re-read it from the
|
|
223
|
+
// entry we have landed on before anything below consults it, or the repair in
|
|
224
|
+
// `currentSentinel` would stamp the entry we just left onto the one we are on.
|
|
225
|
+
standing = taggedSentinel();
|
|
226
|
+
if (pendingProgrammatic > 0) {
|
|
227
|
+
pendingProgrammatic -= 1;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
// Nothing open → an ordinary navigation, which is none of our business.
|
|
231
|
+
const top = stack[stack.length - 1];
|
|
232
|
+
if (!top) return;
|
|
233
|
+
// We landed back ON the top overlay's own entry, so that entry was NOT what the
|
|
234
|
+
// pop consumed — something stacked ABOVE it was (Keksdose feedback #424/#426).
|
|
235
|
+
// The app layers a second same-URL sentinel of its own on top of ours for the
|
|
236
|
+
// unsaved-edit trap (`use-soft-leave-guard.ts`), and it pops that sentinel every
|
|
237
|
+
// time the form goes back to clean — twice-toggling one field was enough to
|
|
238
|
+
// close the whole dialog. A pop is only ours when the entry we came to rest on
|
|
239
|
+
// is no longer ours: either the layer below (a different overlay's marker) or
|
|
240
|
+
// the plain page entry (none at all).
|
|
241
|
+
if (currentSentinel() === top.id) return;
|
|
242
|
+
stack.pop();
|
|
243
|
+
// The browser consumed this entry, so the overlay's own cleanup has nothing left
|
|
244
|
+
// to unwind and nothing to record as owed.
|
|
245
|
+
consumed.add(top.id);
|
|
246
|
+
forgetPushed(top.id);
|
|
247
|
+
top.close();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function ensureListening() {
|
|
251
|
+
if (listening || typeof window === "undefined") return;
|
|
252
|
+
window.addEventListener("popstate", handlePop);
|
|
253
|
+
listening = true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Make the platform Back gesture dismiss an overlay.
|
|
258
|
+
*
|
|
259
|
+
* @param open whether the overlay is currently showing. Components that mount
|
|
260
|
+
* only while open (the common case) pass `true`.
|
|
261
|
+
* @param onClose the overlay's own close handler — the same one Escape calls.
|
|
262
|
+
*/
|
|
263
|
+
export function useOverlayHistory(open: boolean, onClose: () => void): void {
|
|
264
|
+
// The listener fires outside React's render, so it reads the latest handler
|
|
265
|
+
// through a ref rather than through the closure the effect captured.
|
|
266
|
+
const closeRef = useRef(onClose);
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
closeRef.current = onClose;
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (!open || typeof window === "undefined") return;
|
|
273
|
+
ensureListening();
|
|
274
|
+
const id = `overlay-${(nextId += 1)}`;
|
|
275
|
+
const entry: OverlayEntry = { id, close: () => closeRef.current() };
|
|
276
|
+
stack.push(entry);
|
|
277
|
+
const prev = window.history.state;
|
|
278
|
+
const marked = { ...(prev && typeof prev === "object" ? prev : {}), [SENTINEL_KEY]: id };
|
|
279
|
+
// An entry that is on its way out and that we are still standing on is ours
|
|
280
|
+
// to take over: same URL, same slot, only the marker changes. Pushing a
|
|
281
|
+
// second one on top of it is what used to leave a husk on every StrictMode
|
|
282
|
+
// remount — see {@link Unwind}.
|
|
283
|
+
const adopting = unwind && unwind.id === currentSentinel() && unwind.href === currentHref();
|
|
284
|
+
if (adopting && unwind) {
|
|
285
|
+
clearTimeout(unwind.timer);
|
|
286
|
+
const inherited = unwind.entries;
|
|
287
|
+
unwind = null;
|
|
288
|
+
// Preserve react-router's own `{usr,key,idx}` — we only swap the marker,
|
|
289
|
+
// and the entry we are swapping it on is the one the router is already on.
|
|
290
|
+
window.history.replaceState(marked, "");
|
|
291
|
+
// The abandoned ones beneath come back owed; ours takes the top slot.
|
|
292
|
+
for (const owed of inherited.slice(0, -1)) pushed.push({ ...owed, dead: true });
|
|
293
|
+
pushed.push({ id, href: currentHref(), dead: false });
|
|
294
|
+
standing = id;
|
|
295
|
+
} else {
|
|
296
|
+
// A push DESTROYS every entry ahead of the one we are on, so anything we
|
|
297
|
+
// still had recorded above our own position is gone. Kept, those would be
|
|
298
|
+
// counted into a later `history.go(-n)` and the overshoot would come out
|
|
299
|
+
// of a navigation the user actually made. Where we cannot tell which of
|
|
300
|
+
// ours are beneath us — we are standing on a plain page entry, or on a
|
|
301
|
+
// husk nobody tracks — none of them can be proven to be, so none survive.
|
|
302
|
+
const under = pushed.findIndex((p) => p.id === currentSentinel());
|
|
303
|
+
pushed.splice(under + 1);
|
|
304
|
+
// Preserve react-router's own `{usr,key,idx}` — we only tack a marker on, and
|
|
305
|
+
// the URL is unchanged, so the router treats popping this as a no-op re-render.
|
|
306
|
+
window.history.pushState(marked, "");
|
|
307
|
+
pushed.push({ id, href: currentHref(), dead: false });
|
|
308
|
+
standing = id;
|
|
309
|
+
}
|
|
310
|
+
return () => {
|
|
311
|
+
const at = stack.indexOf(entry);
|
|
312
|
+
if (at >= 0) stack.splice(at, 1);
|
|
313
|
+
|
|
314
|
+
// A Back press already took this entry; there is nothing to unwind or to owe.
|
|
315
|
+
if (consumed.delete(id)) return;
|
|
316
|
+
|
|
317
|
+
const mine = pushed.findIndex((p) => p.id === id);
|
|
318
|
+
// Only unwind an entry that is still THE current one. If a router push landed
|
|
319
|
+
// on top of it, going back would undo the user's navigation instead — and if
|
|
320
|
+
// a SIBLING overlay's sentinel landed on top, that overlay is about to unwind
|
|
321
|
+
// and is the one that can take ours with it.
|
|
322
|
+
if (currentSentinel() !== id) {
|
|
323
|
+
if (mine >= 0) pushed[mine].dead = true;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Ours plus every abandoned entry lying directly beneath it at this address.
|
|
328
|
+
// One traversal, so one popstate, so one programmatic pop to absorb.
|
|
329
|
+
const here = currentHref();
|
|
330
|
+
let steps = 1;
|
|
331
|
+
while (steps <= mine && pushed[mine - steps].dead && pushed[mine - steps].href === here) {
|
|
332
|
+
steps += 1;
|
|
333
|
+
}
|
|
334
|
+
const leaving = pushed.splice(mine - (steps - 1), steps);
|
|
335
|
+
// Scheduled, not issued: an overlay opening in this same tick takes the
|
|
336
|
+
// entry over instead, which is what makes the hook survive a StrictMode
|
|
337
|
+
// remount. An unwind already waiting is dropped rather than merged — it
|
|
338
|
+
// was the entry ABOVE ours and something has since moved off it, so its
|
|
339
|
+
// count can no longer be proven, and an over-counted traversal costs a
|
|
340
|
+
// navigation the user made while an under-counted one costs one dead Back.
|
|
341
|
+
if (unwind) clearTimeout(unwind.timer);
|
|
342
|
+
unwind = { timer: setTimeout(runUnwind, 0), id, href: here, entries: leaving };
|
|
343
|
+
};
|
|
344
|
+
}, [open]);
|
|
345
|
+
}
|