@eifi1/ui-kit 0.4.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.
Files changed (229) hide show
  1. package/README.md +219 -0
  2. package/dist/components/account-settings.d.ts +71 -0
  3. package/dist/components/account-settings.js +159 -0
  4. package/dist/components/account-settings.js.map +1 -0
  5. package/dist/components/alert-banner.d.ts +20 -0
  6. package/dist/components/alert-banner.js +39 -0
  7. package/dist/components/alert-banner.js.map +1 -0
  8. package/dist/components/amount-input.d.ts +117 -0
  9. package/dist/components/amount-input.js +243 -0
  10. package/dist/components/amount-input.js.map +1 -0
  11. package/dist/components/calculator.d.ts +42 -0
  12. package/dist/components/calculator.js +161 -0
  13. package/dist/components/calculator.js.map +1 -0
  14. package/dist/components/chart.d.ts +58 -0
  15. package/dist/components/chart.js +158 -0
  16. package/dist/components/chart.js.map +1 -0
  17. package/dist/components/combobox-core.d.ts +77 -0
  18. package/dist/components/combobox-core.js +263 -0
  19. package/dist/components/combobox-core.js.map +1 -0
  20. package/dist/components/combobox.d.ts +141 -0
  21. package/dist/components/combobox.js +550 -0
  22. package/dist/components/combobox.js.map +1 -0
  23. package/dist/components/currency-select.d.ts +37 -0
  24. package/dist/components/currency-select.js +131 -0
  25. package/dist/components/currency-select.js.map +1 -0
  26. package/dist/components/data-table-filter-popover.d.ts +28 -0
  27. package/dist/components/data-table-filter-popover.js +201 -0
  28. package/dist/components/data-table-filter-popover.js.map +1 -0
  29. package/dist/components/data-table-filters.d.ts +5 -0
  30. package/dist/components/data-table-filters.js +118 -0
  31. package/dist/components/data-table-filters.js.map +1 -0
  32. package/dist/components/data-table-labels.d.ts +33 -0
  33. package/dist/components/data-table-labels.js +53 -0
  34. package/dist/components/data-table-labels.js.map +1 -0
  35. package/dist/components/data-table-pagination.d.ts +18 -0
  36. package/dist/components/data-table-pagination.js +92 -0
  37. package/dist/components/data-table-pagination.js.map +1 -0
  38. package/dist/components/data-table-sort.d.ts +23 -0
  39. package/dist/components/data-table-sort.js +55 -0
  40. package/dist/components/data-table-sort.js.map +1 -0
  41. package/dist/components/data-table.d.ts +5 -0
  42. package/dist/components/data-table.js +926 -0
  43. package/dist/components/data-table.js.map +1 -0
  44. package/dist/components/date-picker.d.ts +85 -0
  45. package/dist/components/date-picker.js +253 -0
  46. package/dist/components/date-picker.js.map +1 -0
  47. package/dist/components/dropdown.d.ts +98 -0
  48. package/dist/components/dropdown.js +122 -0
  49. package/dist/components/dropdown.js.map +1 -0
  50. package/dist/components/entity-combobox.d.ts +49 -0
  51. package/dist/components/entity-combobox.js +115 -0
  52. package/dist/components/entity-combobox.js.map +1 -0
  53. package/dist/components/file-dropzone.d.ts +18 -0
  54. package/dist/components/file-dropzone.js +101 -0
  55. package/dist/components/file-dropzone.js.map +1 -0
  56. package/dist/components/full-bleed-dialog.d.ts +57 -0
  57. package/dist/components/full-bleed-dialog.js +68 -0
  58. package/dist/components/full-bleed-dialog.js.map +1 -0
  59. package/dist/components/grouped-picker.d.ts +37 -0
  60. package/dist/components/grouped-picker.js +87 -0
  61. package/dist/components/grouped-picker.js.map +1 -0
  62. package/dist/components/hover-menu.d.ts +16 -0
  63. package/dist/components/hover-menu.js +113 -0
  64. package/dist/components/hover-menu.js.map +1 -0
  65. package/dist/components/mini-calendar.d.ts +33 -0
  66. package/dist/components/mini-calendar.js +133 -0
  67. package/dist/components/mini-calendar.js.map +1 -0
  68. package/dist/components/modal.d.ts +76 -0
  69. package/dist/components/modal.js +156 -0
  70. package/dist/components/modal.js.map +1 -0
  71. package/dist/components/multi-entity-combobox.d.ts +47 -0
  72. package/dist/components/multi-entity-combobox.js +116 -0
  73. package/dist/components/multi-entity-combobox.js.map +1 -0
  74. package/dist/components/multi-select.d.ts +39 -0
  75. package/dist/components/multi-select.js +132 -0
  76. package/dist/components/multi-select.js.map +1 -0
  77. package/dist/components/number-input.d.ts +74 -0
  78. package/dist/components/number-input.js +114 -0
  79. package/dist/components/number-input.js.map +1 -0
  80. package/dist/components/numpad-sheet.d.ts +45 -0
  81. package/dist/components/numpad-sheet.js +102 -0
  82. package/dist/components/numpad-sheet.js.map +1 -0
  83. package/dist/components/picker-sheet.d.ts +44 -0
  84. package/dist/components/picker-sheet.js +78 -0
  85. package/dist/components/picker-sheet.js.map +1 -0
  86. package/dist/components/popover.d.ts +20 -0
  87. package/dist/components/popover.js +46 -0
  88. package/dist/components/popover.js.map +1 -0
  89. package/dist/components/search-field.d.ts +52 -0
  90. package/dist/components/search-field.js +64 -0
  91. package/dist/components/search-field.js.map +1 -0
  92. package/dist/components/settings-fields.d.ts +39 -0
  93. package/dist/components/settings-fields.js +17 -0
  94. package/dist/components/settings-fields.js.map +1 -0
  95. package/dist/components/swipeable-row.d.ts +60 -0
  96. package/dist/components/swipeable-row.js +115 -0
  97. package/dist/components/swipeable-row.js.map +1 -0
  98. package/dist/components/toggle-group.d.ts +34 -0
  99. package/dist/components/toggle-group.js +90 -0
  100. package/dist/components/toggle-group.js.map +1 -0
  101. package/dist/components/tooltip.d.ts +86 -0
  102. package/dist/components/tooltip.js +171 -0
  103. package/dist/components/tooltip.js.map +1 -0
  104. package/dist/components/ui.d.ts +239 -0
  105. package/dist/components/ui.js +512 -0
  106. package/dist/components/ui.js.map +1 -0
  107. package/dist/components/use-mobile-reveal.d.ts +31 -0
  108. package/dist/components/use-mobile-reveal.js +33 -0
  109. package/dist/components/use-mobile-reveal.js.map +1 -0
  110. package/dist/components/use-table-state.d.ts +54 -0
  111. package/dist/components/use-table-state.js +153 -0
  112. package/dist/components/use-table-state.js.map +1 -0
  113. package/dist/components/user-avatar.d.ts +23 -0
  114. package/dist/components/user-avatar.js +33 -0
  115. package/dist/components/user-avatar.js.map +1 -0
  116. package/dist/components/wizard-stepper.d.ts +14 -0
  117. package/dist/components/wizard-stepper.js +27 -0
  118. package/dist/components/wizard-stepper.js.map +1 -0
  119. package/dist/data-table-filters-aXyf0Xub.d.ts +261 -0
  120. package/dist/feedback/feedback-attachment.d.ts +63 -0
  121. package/dist/feedback/feedback-attachment.js +160 -0
  122. package/dist/feedback/feedback-attachment.js.map +1 -0
  123. package/dist/feedback/feedback-dialog.d.ts +72 -0
  124. package/dist/feedback/feedback-dialog.js +109 -0
  125. package/dist/feedback/feedback-dialog.js.map +1 -0
  126. package/dist/feedback/feedback-inbox.d.ts +226 -0
  127. package/dist/feedback/feedback-inbox.js +315 -0
  128. package/dist/feedback/feedback-inbox.js.map +1 -0
  129. package/dist/hooks/use-anchored-panel.d.ts +80 -0
  130. package/dist/hooks/use-anchored-panel.js +71 -0
  131. package/dist/hooks/use-anchored-panel.js.map +1 -0
  132. package/dist/hooks/use-anchored-rect.d.ts +24 -0
  133. package/dist/hooks/use-anchored-rect.js +40 -0
  134. package/dist/hooks/use-anchored-rect.js.map +1 -0
  135. package/dist/hooks/use-body-scroll-lock.d.ts +9 -0
  136. package/dist/hooks/use-body-scroll-lock.js +41 -0
  137. package/dist/hooks/use-body-scroll-lock.js.map +1 -0
  138. package/dist/hooks/use-close-transition.d.ts +41 -0
  139. package/dist/hooks/use-close-transition.js +36 -0
  140. package/dist/hooks/use-close-transition.js.map +1 -0
  141. package/dist/hooks/use-dismiss.d.ts +34 -0
  142. package/dist/hooks/use-dismiss.js +41 -0
  143. package/dist/hooks/use-dismiss.js.map +1 -0
  144. package/dist/hooks/use-media-query.d.ts +8 -0
  145. package/dist/hooks/use-media-query.js +18 -0
  146. package/dist/hooks/use-media-query.js.map +1 -0
  147. package/dist/hooks/use-overlay-history.d.ts +10 -0
  148. package/dist/hooks/use-overlay-history.js +99 -0
  149. package/dist/hooks/use-overlay-history.js.map +1 -0
  150. package/dist/hooks/use-row-swipe.d.ts +30 -0
  151. package/dist/hooks/use-row-swipe.js +128 -0
  152. package/dist/hooks/use-row-swipe.js.map +1 -0
  153. package/dist/index.d.ts +75 -0
  154. package/dist/index.js +90 -0
  155. package/dist/index.js.map +1 -0
  156. package/dist/lib/calc.d.ts +89 -0
  157. package/dist/lib/calc.js +180 -0
  158. package/dist/lib/calc.js.map +1 -0
  159. package/dist/lib/cn.d.ts +6 -0
  160. package/dist/lib/cn.js +7 -0
  161. package/dist/lib/cn.js.map +1 -0
  162. package/dist/lib/dates.d.ts +84 -0
  163. package/dist/lib/dates.js +108 -0
  164. package/dist/lib/dates.js.map +1 -0
  165. package/dist/lib/logger.d.ts +17 -0
  166. package/dist/lib/logger.js +30 -0
  167. package/dist/lib/logger.js.map +1 -0
  168. package/dist/lib/safe-storage.d.ts +23 -0
  169. package/dist/lib/safe-storage.js +20 -0
  170. package/dist/lib/safe-storage.js.map +1 -0
  171. package/dist/search/command-palette.d.ts +71 -0
  172. package/dist/search/command-palette.js +198 -0
  173. package/dist/search/command-palette.js.map +1 -0
  174. package/dist/shell/app-shell.d.ts +64 -0
  175. package/dist/shell/app-shell.js +237 -0
  176. package/dist/shell/app-shell.js.map +1 -0
  177. package/dist/shell/option-switcher-menu.d.ts +24 -0
  178. package/dist/shell/option-switcher-menu.js +44 -0
  179. package/dist/shell/option-switcher-menu.js.map +1 -0
  180. package/dist/shell/role-switcher.d.ts +19 -0
  181. package/dist/shell/role-switcher.js +28 -0
  182. package/dist/shell/role-switcher.js.map +1 -0
  183. package/dist/shell/top-bar.d.ts +15 -0
  184. package/dist/shell/top-bar.js +29 -0
  185. package/dist/shell/top-bar.js.map +1 -0
  186. package/dist/shell/topbar-action-menu.d.ts +44 -0
  187. package/dist/shell/topbar-action-menu.js +49 -0
  188. package/dist/shell/topbar-action-menu.js.map +1 -0
  189. package/dist/shell/topbar-controls.d.ts +54 -0
  190. package/dist/shell/topbar-controls.js +152 -0
  191. package/dist/shell/topbar-controls.js.map +1 -0
  192. package/dist/theme/chart-palette.d.ts +60 -0
  193. package/dist/theme/chart-palette.js +79 -0
  194. package/dist/theme/chart-palette.js.map +1 -0
  195. package/dist/theme/palette-presets.d.ts +39 -0
  196. package/dist/theme/palette-presets.js +440 -0
  197. package/dist/theme/palette-presets.js.map +1 -0
  198. package/dist/theme/palette-store.d.ts +45 -0
  199. package/dist/theme/palette-store.js +52 -0
  200. package/dist/theme/palette-store.js.map +1 -0
  201. package/dist/theme/theme-store.d.ts +57 -0
  202. package/dist/theme/theme-store.js +96 -0
  203. package/dist/theme/theme-store.js.map +1 -0
  204. package/dist/tour/tour.d.ts +104 -0
  205. package/dist/tour/tour.js +351 -0
  206. package/dist/tour/tour.js.map +1 -0
  207. package/dist/wizard/stepper-nav.d.ts +30 -0
  208. package/dist/wizard/stepper-nav.js +146 -0
  209. package/dist/wizard/stepper-nav.js.map +1 -0
  210. package/dist/wizard/types.d.ts +124 -0
  211. package/dist/wizard/types.js +24 -0
  212. package/dist/wizard/types.js.map +1 -0
  213. package/dist/wizard/use-wizard.d.ts +6 -0
  214. package/dist/wizard/use-wizard.js +222 -0
  215. package/dist/wizard/use-wizard.js.map +1 -0
  216. package/dist/wizard/validation.d.ts +23 -0
  217. package/dist/wizard/validation.js +13 -0
  218. package/dist/wizard/validation.js.map +1 -0
  219. package/dist/wizard/wizard-context.d.ts +19 -0
  220. package/dist/wizard/wizard-context.js +15 -0
  221. package/dist/wizard/wizard-context.js.map +1 -0
  222. package/dist/wizard/wizard-step.d.ts +11 -0
  223. package/dist/wizard/wizard-step.js +12 -0
  224. package/dist/wizard/wizard-step.js.map +1 -0
  225. package/dist/wizard/wizard-summary.d.ts +18 -0
  226. package/dist/wizard/wizard-summary.js +41 -0
  227. package/dist/wizard/wizard-summary.js.map +1 -0
  228. package/package.json +87 -0
  229. package/tokens.css +254 -0
@@ -0,0 +1,80 @@
1
+ import { RefObject } from 'react';
2
+ import { AnchorRect } from './use-anchored-rect.js';
3
+
4
+ /**
5
+ * Vertical placement for a portalled, `position: fixed` panel anchored to a trigger.
6
+ *
7
+ * The problem it solves (feedback #135): on a phone, opening a picker focuses its
8
+ * search box, the on-screen keyboard slides up, and a panel pinned below a trigger in
9
+ * the lower half of the screen ends up entirely behind that keyboard — with nothing
10
+ * the user can do about it, because `position: fixed` does not scroll.
11
+ *
12
+ * The keyboard is invisible to the usual measurements: on Android Chrome the LAYOUT
13
+ * viewport (`window.innerHeight`, and the origin `getBoundingClientRect` and
14
+ * `position: fixed` are relative to) does not shrink when it opens. Only
15
+ * `visualViewport` reports the region actually on screen, so that is what this reads.
16
+ */
17
+ /** The on-screen region, in layout-viewport coordinates. */
18
+ interface ViewportBox {
19
+ top: number;
20
+ height: number;
21
+ }
22
+ interface AnchoredPanelOptions {
23
+ /** Space between trigger and panel. */
24
+ gap?: number;
25
+ /** Space kept clear at the viewport edges. */
26
+ margin?: number;
27
+ /** The height the panel would like, when there is room. */
28
+ preferredHeight?: number;
29
+ /** Below this, the space under the trigger counts as unusable and a flip is considered. */
30
+ minHeight?: number;
31
+ }
32
+ interface AnchoredPanel {
33
+ /** The trigger's rect, or null while closed/unmeasured. */
34
+ rect: AnchorRect | null;
35
+ /** Viewport-space `top` for the panel. */
36
+ top: number;
37
+ /** Cap that keeps the panel inside the VISIBLE viewport — apply as `maxHeight`. */
38
+ maxHeight: number;
39
+ /** Whether the panel was flipped above the trigger. */
40
+ above: boolean;
41
+ }
42
+ /**
43
+ * The pure half of {@link useAnchoredPanel}: where a panel goes, given the trigger's
44
+ * rect and the region actually on screen.
45
+ *
46
+ * Prefers below the trigger — the conventional direction, and the one that keeps the
47
+ * trigger's own value visible. Flips above only when below cannot show a usable list
48
+ * AND above is roomier, so a panel never jumps sides for a few pixels' gain. The
49
+ * returned `maxHeight` is what makes the flip sufficient rather than merely different:
50
+ * without it a tall panel placed above just runs off the top instead of the bottom.
51
+ *
52
+ * Split out so the geometry is testable without a DOM and a fake keyboard — @hb/ui has
53
+ * no test runner of its own, so this is exercised from the consuming app.
54
+ */
55
+ declare function anchoredPanelPlacement(rect: Pick<AnchorRect, "top" | "bottom">, viewport: ViewportBox, { gap, margin, preferredHeight, minHeight }?: AnchoredPanelOptions): Omit<AnchoredPanel, "rect">;
56
+ /**
57
+ * Where to put a panel anchored under `ref`, tracking the visible viewport.
58
+ *
59
+ * Callers own horizontal placement — the pickers align to the trigger's left edge, the
60
+ * popovers to its right — and must apply `maxHeight` with an internal `overflow-y`.
61
+ */
62
+ /**
63
+ * The region actually on screen, tracked while `active` (Keksdose live #328).
64
+ *
65
+ * The same measurement {@link useAnchoredPanel} caps a dropdown with, for the callers
66
+ * that need the box itself rather than a placement: a FULL-SCREEN sheet cannot use
67
+ * `inset-0`, because on Android the on-screen keyboard shrinks only the VISUAL
68
+ * viewport — `inset-0` still spans the whole screen, so the bottom of the sheet, and
69
+ * the end of whatever list is scrolling inside it, live behind the keyboard where no
70
+ * gesture reaches them.
71
+ *
72
+ * Returns `null` where there is nothing to correct: no `visualViewport` (jsdom, SSR,
73
+ * older browsers) or no keyboard taking a bite out of it. A caller then keeps its
74
+ * static layout, which is right at every width where this does not apply — and is why
75
+ * the desktop and the test suite see exactly the markup they saw before.
76
+ */
77
+ declare function useVisualViewport(active: boolean): ViewportBox | null;
78
+ declare function useAnchoredPanel<T extends HTMLElement>(ref: RefObject<T | null>, open: boolean, options?: AnchoredPanelOptions): AnchoredPanel;
79
+
80
+ export { type AnchoredPanel, type AnchoredPanelOptions, type ViewportBox, anchoredPanelPlacement, useAnchoredPanel, useVisualViewport };
@@ -0,0 +1,71 @@
1
+ import { useLayoutEffect, useState } from "react";
2
+ import { useAnchoredRect } from "./use-anchored-rect.js";
3
+ function readViewport() {
4
+ if (typeof window === "undefined") return { top: 0, height: 0 };
5
+ const vv = window.visualViewport;
6
+ return vv ? { top: vv.offsetTop, height: vv.height } : { top: 0, height: window.innerHeight };
7
+ }
8
+ const FLOOR = 96;
9
+ function anchoredPanelPlacement(rect, viewport, { gap = 4, margin = 8, preferredHeight = 320, minHeight = 160 } = {}) {
10
+ const viewTop = viewport.top + margin;
11
+ const viewBottom = viewport.top + viewport.height - margin;
12
+ const spaceBelow = viewBottom - (rect.bottom + gap);
13
+ const spaceAbove = rect.top - gap - viewTop;
14
+ const above = spaceBelow < minHeight && spaceAbove > spaceBelow;
15
+ const space = Math.max(FLOOR, above ? spaceAbove : spaceBelow);
16
+ const maxHeight = Math.min(preferredHeight, space);
17
+ return {
18
+ top: above ? Math.max(viewTop, rect.top - gap - maxHeight) : rect.bottom + gap,
19
+ maxHeight,
20
+ above
21
+ };
22
+ }
23
+ function useVisualViewport(active) {
24
+ const [box, setBox] = useState(null);
25
+ useLayoutEffect(() => {
26
+ if (!active) return;
27
+ const update = () => {
28
+ const vv2 = typeof window === "undefined" ? void 0 : window.visualViewport;
29
+ setBox(vv2 && vv2.height < window.innerHeight - 1 ? { top: vv2.offsetTop, height: vv2.height } : null);
30
+ };
31
+ update();
32
+ const vv = window.visualViewport;
33
+ vv?.addEventListener("resize", update);
34
+ vv?.addEventListener("scroll", update);
35
+ window.addEventListener("resize", update);
36
+ return () => {
37
+ vv?.removeEventListener("resize", update);
38
+ vv?.removeEventListener("scroll", update);
39
+ window.removeEventListener("resize", update);
40
+ };
41
+ }, [active]);
42
+ return box;
43
+ }
44
+ function useAnchoredPanel(ref, open, options = {}) {
45
+ const rect = useAnchoredRect(ref, open);
46
+ const [viewport, setViewport] = useState(readViewport);
47
+ useLayoutEffect(() => {
48
+ if (!open) return;
49
+ const update = () => setViewport(readViewport());
50
+ update();
51
+ const vv = window.visualViewport;
52
+ vv?.addEventListener("resize", update);
53
+ vv?.addEventListener("scroll", update);
54
+ window.addEventListener("resize", update);
55
+ return () => {
56
+ vv?.removeEventListener("resize", update);
57
+ vv?.removeEventListener("scroll", update);
58
+ window.removeEventListener("resize", update);
59
+ };
60
+ }, [open]);
61
+ if (!rect) {
62
+ return { rect: null, top: 0, maxHeight: options.preferredHeight ?? 320, above: false };
63
+ }
64
+ return { rect, ...anchoredPanelPlacement(rect, viewport, options) };
65
+ }
66
+ export {
67
+ anchoredPanelPlacement,
68
+ useAnchoredPanel,
69
+ useVisualViewport
70
+ };
71
+ //# sourceMappingURL=use-anchored-panel.js.map
@@ -0,0 +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"]}
@@ -0,0 +1,24 @@
1
+ import { RefObject } from 'react';
2
+
3
+ /** The subset of an element's viewport rect used for anchored positioning. */
4
+ interface AnchorRect {
5
+ top: number;
6
+ left: number;
7
+ right: number;
8
+ bottom: number;
9
+ width: number;
10
+ height: number;
11
+ }
12
+ /**
13
+ * Tracks an anchor element's viewport rect while `open`, re-measuring on scroll
14
+ * (capture phase, so nested scroll containers are caught too) and resize, and
15
+ * returning null when closed or unmeasured. Callers derive their own panel
16
+ * position from the rect — this hook owns only the measure + listener lifecycle
17
+ * that every portalled, position:fixed popover/flyout otherwise re-implements.
18
+ *
19
+ * Measured in a layout effect so the position is ready before paint (no flicker
20
+ * on open).
21
+ */
22
+ declare function useAnchoredRect<T extends HTMLElement>(ref: RefObject<T | null>, open: boolean): AnchorRect | null;
23
+
24
+ export { type AnchorRect, useAnchoredRect };
@@ -0,0 +1,40 @@
1
+ import { useLayoutEffect, useState } from "react";
2
+ function sameRect(a, b) {
3
+ if (a === b) return true;
4
+ if (!a || !b) return false;
5
+ return a.top === b.top && a.left === b.left && a.right === b.right && a.bottom === b.bottom && a.width === b.width && a.height === b.height;
6
+ }
7
+ function useAnchoredRect(ref, open) {
8
+ const [rect, setRect] = useState(null);
9
+ useLayoutEffect(() => {
10
+ if (!open) {
11
+ setRect(null);
12
+ return;
13
+ }
14
+ const measure = () => {
15
+ const r = ref.current?.getBoundingClientRect();
16
+ if (!r) return;
17
+ const next = {
18
+ top: r.top,
19
+ left: r.left,
20
+ right: r.right,
21
+ bottom: r.bottom,
22
+ width: r.width,
23
+ height: r.height
24
+ };
25
+ setRect((prev) => sameRect(prev, next) ? prev : next);
26
+ };
27
+ measure();
28
+ window.addEventListener("resize", measure);
29
+ window.addEventListener("scroll", measure, true);
30
+ return () => {
31
+ window.removeEventListener("resize", measure);
32
+ window.removeEventListener("scroll", measure, true);
33
+ };
34
+ }, [open, ref]);
35
+ return rect;
36
+ }
37
+ export {
38
+ useAnchoredRect
39
+ };
40
+ //# sourceMappingURL=use-anchored-rect.js.map
@@ -0,0 +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":[]}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Lock background scrolling while `locked` is true, restoring the prior value once
3
+ * the LAST holder releases. Composable with every other caller of this hook — see
4
+ * the note above for why that has to be a property of the hook rather than of each
5
+ * caller's discipline.
6
+ */
7
+ declare function useBodyScrollLock(locked: boolean): void;
8
+
9
+ export { useBodyScrollLock };
@@ -0,0 +1,41 @@
1
+ import { useEffect } from "react";
2
+ let lockCount = 0;
3
+ let previousOverflow = "";
4
+ let previousPaddingRight = "";
5
+ function scrollbarWidth() {
6
+ return Math.max(0, window.innerWidth - document.documentElement.clientWidth);
7
+ }
8
+ function acquire() {
9
+ if (lockCount === 0) {
10
+ const gap = scrollbarWidth();
11
+ previousOverflow = document.body.style.overflow;
12
+ previousPaddingRight = document.body.style.paddingRight;
13
+ document.body.style.overflow = "hidden";
14
+ if (gap > 0) {
15
+ const existing = parseFloat(getComputedStyle(document.body).paddingRight) || 0;
16
+ document.body.style.paddingRight = `${existing + gap}px`;
17
+ }
18
+ }
19
+ lockCount += 1;
20
+ }
21
+ function release() {
22
+ if (lockCount === 0) return;
23
+ lockCount -= 1;
24
+ if (lockCount === 0) {
25
+ document.body.style.overflow = previousOverflow;
26
+ document.body.style.paddingRight = previousPaddingRight;
27
+ previousOverflow = "";
28
+ previousPaddingRight = "";
29
+ }
30
+ }
31
+ function useBodyScrollLock(locked) {
32
+ useEffect(() => {
33
+ if (!locked) return;
34
+ acquire();
35
+ return release;
36
+ }, [locked]);
37
+ }
38
+ export {
39
+ useBodyScrollLock
40
+ };
41
+ //# sourceMappingURL=use-body-scroll-lock.js.map
@@ -0,0 +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":[]}
@@ -0,0 +1,41 @@
1
+ /** How long the exit animation runs, in ms. Mirrors `.animate-sheet-out` in
2
+ * tokens.css — the sheet is the slower of the two, so it is what the unmount waits
3
+ * for. Exported so a test can advance a fake clock by the real number rather than
4
+ * by a copy of it. */
5
+ declare const OVERLAY_EXIT_MS = 220;
6
+ /**
7
+ * The state machine an exit animation needs, written once for every overlay in this
8
+ * package (Keksdose live #320 rework: *"Recognized the opening transition. Choose
9
+ * transition should be the same, only backwards"*).
10
+ *
11
+ * ## Why a hook and not a class on the panel
12
+ *
13
+ * An enter animation is free: the element mounts, the keyframes run. An exit is not —
14
+ * by the time the state says "closed" the element is already gone, so something has to
15
+ * hold it on screen for exactly as long as the animation lasts. That something cannot
16
+ * be the caller (every caller would need the same timer) and it cannot be the panel's
17
+ * markup. It is this: the overlay's own affordances call {@link requestClose} instead
18
+ * of `onClose`, render the `-out` classes while `closing` is true, and the real
19
+ * `onClose` — the one that unmounts them — fires when the animation has finished.
20
+ *
21
+ * ## What it deliberately does not do
22
+ *
23
+ * It does not intercept a close the CALLER decides on. A dialog that closes itself
24
+ * after a successful save sets its own state and unmounts, with no exit animation, and
25
+ * that is the right trade: making it otherwise would mean every overlay owning an
26
+ * `open` prop and every caller waiting on a callback to learn when its own state took
27
+ * effect. What is animated is what the user dismissed — the X, the backdrop, Escape,
28
+ * the Back gesture — which is the motion the row was about.
29
+ *
30
+ * ## Reduced motion
31
+ *
32
+ * Closes immediately, without the delay. A user who has asked for no motion must not
33
+ * be made to wait 220ms for an animation they are not being shown, and the `-out`
34
+ * classes are never applied, so tokens.css needs no reduced-motion rule for them.
35
+ */
36
+ declare function useCloseTransition(onClose: () => void, ms?: number): {
37
+ closing: boolean;
38
+ requestClose: () => void;
39
+ };
40
+
41
+ export { OVERLAY_EXIT_MS, useCloseTransition };
@@ -0,0 +1,36 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ const OVERLAY_EXIT_MS = 220;
3
+ function useCloseTransition(onClose, ms = OVERLAY_EXIT_MS) {
4
+ const [closing, setClosing] = useState(false);
5
+ const timer = useRef(null);
6
+ const latest = useRef(onClose);
7
+ useEffect(() => {
8
+ latest.current = onClose;
9
+ });
10
+ useEffect(
11
+ () => () => {
12
+ if (timer.current) clearTimeout(timer.current);
13
+ },
14
+ []
15
+ );
16
+ const requestClose = useCallback(() => {
17
+ if (timer.current) return;
18
+ const reduced = typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
19
+ if (reduced || ms <= 0) {
20
+ latest.current();
21
+ return;
22
+ }
23
+ setClosing(true);
24
+ timer.current = setTimeout(() => {
25
+ timer.current = null;
26
+ setClosing(false);
27
+ latest.current();
28
+ }, ms);
29
+ }, [ms]);
30
+ return { closing, requestClose };
31
+ }
32
+ export {
33
+ OVERLAY_EXIT_MS,
34
+ useCloseTransition
35
+ };
36
+ //# sourceMappingURL=use-close-transition.js.map
@@ -0,0 +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":[]}
@@ -0,0 +1,34 @@
1
+ import { RefObject } from 'react';
2
+
3
+ /**
4
+ * Calls `handler` on an Escape keydown while `enabled`. A single document-level
5
+ * listener owns the "press Escape to dismiss" behaviour that popovers, flyouts
6
+ * and menus otherwise each re-implement. `handler` is read live at event time,
7
+ * so it need not be memoised and the listener only re-subscribes when `enabled`
8
+ * flips (typically the open/closed transition).
9
+ */
10
+ declare function useEscapeKey(handler: () => void, enabled?: boolean): void;
11
+ /**
12
+ * Calls `handler` on a press that lands outside every supplied element, while
13
+ * `enabled` — the outside-click-to-close half of the same dismissal story. Pass one
14
+ * ref or several (trigger + panel); a press inside any of them is ignored. Refs and
15
+ * `handler` are read live, so an inline `[triggerRef, panelRef]` array is fine and
16
+ * won't churn the listener.
17
+ *
18
+ * **`pointerdown`, not `mousedown`** — one event covering mouse, touch and pen.
19
+ * This listened for `mousedown` alone, which on a touch platform is a SYNTHESISED
20
+ * event: the browser emits it for most taps, which is why nothing obviously broke,
21
+ * but the synthesis is not guaranteed. A tap that begins a scroll, or one that lands
22
+ * on an element which preventDefaults the touch sequence, may never produce one — and
23
+ * on a phone-first PWA whose pickers, menus and flyouts all dismiss through this hook,
24
+ * inheriting that is worse than choosing it.
25
+ *
26
+ * The one behaviour this changes deliberately: a touch-drag that starts outside an
27
+ * open panel now dismisses it at touch-down, where before it did not dismiss at all
28
+ * (a drag synthesises no mousedown). That matches every other platform surface, and
29
+ * the anchored panels re-measure on scroll precisely because the page moves under
30
+ * them.
31
+ */
32
+ declare function useOutsideClick(refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[], handler: () => void, enabled?: boolean): void;
33
+
34
+ export { useEscapeKey, useOutsideClick };
@@ -0,0 +1,41 @@
1
+ import { useEffect, useRef } from "react";
2
+ function useEscapeKey(handler, enabled = true) {
3
+ const handlerRef = useRef(handler);
4
+ useEffect(() => {
5
+ handlerRef.current = handler;
6
+ });
7
+ useEffect(() => {
8
+ if (!enabled) return;
9
+ const onKey = (e) => {
10
+ if (e.key === "Escape") handlerRef.current();
11
+ };
12
+ document.addEventListener("keydown", onKey);
13
+ return () => document.removeEventListener("keydown", onKey);
14
+ }, [enabled]);
15
+ }
16
+ function useOutsideClick(refs, handler, enabled = true) {
17
+ const refsRef = useRef(refs);
18
+ const handlerRef = useRef(handler);
19
+ useEffect(() => {
20
+ refsRef.current = refs;
21
+ handlerRef.current = handler;
22
+ });
23
+ useEffect(() => {
24
+ if (!enabled) return;
25
+ const onPointerDown = (e) => {
26
+ const target = e.target;
27
+ const list = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];
28
+ for (const r of list) {
29
+ if (r.current?.contains(target)) return;
30
+ }
31
+ handlerRef.current();
32
+ };
33
+ document.addEventListener("pointerdown", onPointerDown);
34
+ return () => document.removeEventListener("pointerdown", onPointerDown);
35
+ }, [enabled]);
36
+ }
37
+ export {
38
+ useEscapeKey,
39
+ useOutsideClick
40
+ };
41
+ //# sourceMappingURL=use-dismiss.js.map
@@ -0,0 +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":[]}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Subscribe to a CSS media query, returning whether it currently matches and
3
+ * re-rendering when it changes. `fallback` is the value used during SSR / when
4
+ * `window.matchMedia` is unavailable.
5
+ */
6
+ declare function useMediaQuery(query: string, fallback: boolean): boolean;
7
+
8
+ export { useMediaQuery };
@@ -0,0 +1,18 @@
1
+ import { useEffect, useState } from "react";
2
+ function useMediaQuery(query, fallback) {
3
+ const get = () => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(query).matches : fallback;
4
+ const [matches, setMatches] = useState(get);
5
+ useEffect(() => {
6
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
7
+ const mql = window.matchMedia(query);
8
+ const update = () => setMatches(mql.matches);
9
+ update();
10
+ mql.addEventListener("change", update);
11
+ return () => mql.removeEventListener("change", update);
12
+ }, [query]);
13
+ return matches;
14
+ }
15
+ export {
16
+ useMediaQuery
17
+ };
18
+ //# sourceMappingURL=use-media-query.js.map
@@ -0,0 +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":[]}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Make the platform Back gesture dismiss an overlay.
3
+ *
4
+ * @param open whether the overlay is currently showing. Components that mount
5
+ * only while open (the common case) pass `true`.
6
+ * @param onClose the overlay's own close handler — the same one Escape calls.
7
+ */
8
+ declare function useOverlayHistory(open: boolean, onClose: () => void): void;
9
+
10
+ export { useOverlayHistory };
@@ -0,0 +1,99 @@
1
+ import { useEffect, useRef } from "react";
2
+ const SENTINEL_KEY = "__hbUiOverlayHistory";
3
+ const stack = [];
4
+ const pushed = [];
5
+ const consumed = /* @__PURE__ */ new Set();
6
+ let pendingProgrammatic = 0;
7
+ let listening = false;
8
+ let nextId = 0;
9
+ let unwind = null;
10
+ function runUnwind() {
11
+ const job = unwind;
12
+ unwind = null;
13
+ if (!job) return;
14
+ if (currentSentinel() !== job.id || currentHref() !== job.href) return;
15
+ pendingProgrammatic += 1;
16
+ window.history.go(-job.entries.length);
17
+ }
18
+ function currentHref() {
19
+ return typeof window === "undefined" ? "" : window.location.href;
20
+ }
21
+ function forgetPushed(id) {
22
+ const at = pushed.findIndex((p) => p.id === id);
23
+ if (at >= 0) pushed.splice(at, 1);
24
+ }
25
+ function currentSentinel() {
26
+ const state = typeof window !== "undefined" ? window.history.state : null;
27
+ if (!state || typeof state !== "object") return null;
28
+ const id = state[SENTINEL_KEY];
29
+ return typeof id === "string" ? id : null;
30
+ }
31
+ function handlePop() {
32
+ if (pendingProgrammatic > 0) {
33
+ pendingProgrammatic -= 1;
34
+ return;
35
+ }
36
+ const top = stack[stack.length - 1];
37
+ if (!top) return;
38
+ if (currentSentinel() === top.id) return;
39
+ stack.pop();
40
+ consumed.add(top.id);
41
+ forgetPushed(top.id);
42
+ top.close();
43
+ }
44
+ function ensureListening() {
45
+ if (listening || typeof window === "undefined") return;
46
+ window.addEventListener("popstate", handlePop);
47
+ listening = true;
48
+ }
49
+ function useOverlayHistory(open, onClose) {
50
+ const closeRef = useRef(onClose);
51
+ useEffect(() => {
52
+ closeRef.current = onClose;
53
+ });
54
+ useEffect(() => {
55
+ if (!open || typeof window === "undefined") return;
56
+ ensureListening();
57
+ const id = `overlay-${nextId += 1}`;
58
+ const entry = { id, close: () => closeRef.current() };
59
+ stack.push(entry);
60
+ const prev = window.history.state;
61
+ const marked = { ...prev && typeof prev === "object" ? prev : {}, [SENTINEL_KEY]: id };
62
+ const adopting = unwind && unwind.id === currentSentinel() && unwind.href === currentHref();
63
+ if (adopting && unwind) {
64
+ clearTimeout(unwind.timer);
65
+ const inherited = unwind.entries;
66
+ unwind = null;
67
+ window.history.replaceState(marked, "");
68
+ for (const owed of inherited.slice(0, -1)) pushed.push({ ...owed, dead: true });
69
+ pushed.push({ id, href: currentHref(), dead: false });
70
+ } else {
71
+ const under = pushed.findIndex((p) => p.id === currentSentinel());
72
+ pushed.splice(under + 1);
73
+ window.history.pushState(marked, "");
74
+ pushed.push({ id, href: currentHref(), dead: false });
75
+ }
76
+ return () => {
77
+ const at = stack.indexOf(entry);
78
+ if (at >= 0) stack.splice(at, 1);
79
+ if (consumed.delete(id)) return;
80
+ const mine = pushed.findIndex((p) => p.id === id);
81
+ if (currentSentinel() !== id) {
82
+ if (mine >= 0) pushed[mine].dead = true;
83
+ return;
84
+ }
85
+ const here = currentHref();
86
+ let steps = 1;
87
+ while (steps <= mine && pushed[mine - steps].dead && pushed[mine - steps].href === here) {
88
+ steps += 1;
89
+ }
90
+ const leaving = pushed.splice(mine - (steps - 1), steps);
91
+ if (unwind) clearTimeout(unwind.timer);
92
+ unwind = { timer: setTimeout(runUnwind, 0), id, href: here, entries: leaving };
93
+ };
94
+ }, [open]);
95
+ }
96
+ export {
97
+ useOverlayHistory
98
+ };
99
+ //# sourceMappingURL=use-overlay-history.js.map