@astryxdesign/core 0.4.2 → 0.4.3-canary.0a9618e

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 (199) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +79 -45
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +3 -19
  5. package/dist/Banner/Banner.d.ts +9 -2
  6. package/dist/Banner/Banner.d.ts.map +1 -1
  7. package/dist/Banner/Banner.js +84 -33
  8. package/dist/Banner/index.d.ts +14 -12
  9. package/dist/Banner/index.d.ts.map +1 -1
  10. package/dist/Banner/index.js +10 -8
  11. package/dist/BottomSheet/BottomSheet.d.ts +66 -0
  12. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  13. package/dist/BottomSheet/BottomSheet.js +381 -0
  14. package/dist/BottomSheet/BottomSheetPanel.d.ts +57 -0
  15. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  16. package/dist/BottomSheet/BottomSheetPanel.js +395 -0
  17. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  18. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  19. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  20. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  21. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  22. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  23. package/dist/BottomSheet/index.d.ts +11 -0
  24. package/dist/BottomSheet/index.d.ts.map +1 -0
  25. package/dist/BottomSheet/index.js +12 -0
  26. package/dist/BottomSheet/snapOffsets.d.ts +72 -0
  27. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  28. package/dist/BottomSheet/snapOffsets.js +130 -0
  29. package/dist/BottomSheet/useMobileKeyboard.d.ts +25 -0
  30. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  31. package/dist/BottomSheet/useMobileKeyboard.js +384 -0
  32. package/dist/BottomSheet/useSheetGestures.d.ts +170 -0
  33. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  34. package/dist/BottomSheet/useSheetGestures.js +881 -0
  35. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  36. package/dist/Chat/ChatComposerInput.js +3 -4
  37. package/dist/Chat/ChatTokenizedText.js +1 -1
  38. package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
  39. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  40. package/dist/ComplexSelector/ComplexSelector.js +98 -31
  41. package/dist/ComplexSelector/index.d.ts +2 -2
  42. package/dist/ComplexSelector/index.d.ts.map +1 -1
  43. package/dist/ComplexSelector/index.js +1 -1
  44. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  45. package/dist/ContextMenu/ContextMenu.js +4 -2
  46. package/dist/DateInput/DateInput.d.ts.map +1 -1
  47. package/dist/DateInput/DateInput.js +9 -1
  48. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  49. package/dist/DateTimeInput/DateTimeInput.js +31 -1
  50. package/dist/Dialog/Dialog.d.ts.map +1 -1
  51. package/dist/Dialog/Dialog.js +2 -1
  52. package/dist/Markdown/parser.d.ts.map +1 -1
  53. package/dist/Markdown/parser.js +55 -12
  54. package/dist/MobileNav/MobileNav.d.ts +12 -0
  55. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  56. package/dist/MobileNav/MobileNav.js +102 -30
  57. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  58. package/dist/MultiSelector/MultiSelector.js +10 -1
  59. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  60. package/dist/NumberInput/NumberInput.js +9 -1
  61. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  62. package/dist/PowerSearch/PowerSearch.js +4 -1
  63. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  64. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  65. package/dist/PowerSearch/formatFilterValue.js +2 -4
  66. package/dist/Selector/Selector.d.ts.map +1 -1
  67. package/dist/Selector/Selector.js +10 -1
  68. package/dist/Switch/Switch.d.ts.map +1 -1
  69. package/dist/Switch/Switch.js +5 -1
  70. package/dist/Table/columnUtils.d.ts.map +1 -1
  71. package/dist/Table/columnUtils.js +4 -1
  72. package/dist/TextArea/TextArea.d.ts +6 -3
  73. package/dist/TextArea/TextArea.d.ts.map +1 -1
  74. package/dist/TextArea/TextArea.js +17 -6
  75. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  76. package/dist/TimeInput/TimeInput.js +8 -1
  77. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  78. package/dist/Tooltip/useTooltip.js +4 -1
  79. package/dist/TreeList/TreeList.js +2 -1
  80. package/dist/Typeahead/BaseTypeahead.js +1 -1
  81. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  82. package/dist/Typeahead/Typeahead.js +10 -1
  83. package/dist/astryx.css +25 -2
  84. package/dist/hooks/index.d.ts +8 -1
  85. package/dist/hooks/index.d.ts.map +1 -1
  86. package/dist/hooks/index.js +8 -1
  87. package/dist/hooks/useFocusTrap.d.ts +0 -11
  88. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  89. package/dist/hooks/useFocusTrap.js +1 -11
  90. package/dist/index.d.ts +1 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +1 -0
  93. package/dist/theme/defineTheme.d.ts +30 -7
  94. package/dist/theme/defineTheme.d.ts.map +1 -1
  95. package/dist/theme/defineTheme.js +36 -47
  96. package/dist/theme/expandColorScale.d.ts +17 -2
  97. package/dist/theme/expandColorScale.d.ts.map +1 -1
  98. package/dist/theme/expandColorScale.js +63 -33
  99. package/dist/theme/mergeComponents.d.ts +20 -0
  100. package/dist/theme/mergeComponents.d.ts.map +1 -0
  101. package/dist/theme/mergeComponents.js +56 -0
  102. package/dist/theme/onMediaTokens.d.ts +6 -1
  103. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  104. package/dist/theme/onMediaTokens.js +11 -3
  105. package/dist/utils/characters.d.ts +27 -0
  106. package/dist/utils/characters.d.ts.map +1 -0
  107. package/dist/utils/characters.js +83 -0
  108. package/dist/utils/ime.d.ts +5 -0
  109. package/dist/utils/ime.d.ts.map +1 -0
  110. package/dist/utils/ime.js +63 -0
  111. package/dist/utils/index.d.ts +2 -0
  112. package/dist/utils/index.d.ts.map +1 -1
  113. package/dist/utils/index.js +3 -1
  114. package/package.json +9 -8
  115. package/src/Avatar/Avatar.test.tsx +6 -1
  116. package/src/Avatar/Avatar.tsx +3 -21
  117. package/src/Banner/Banner.doc.mjs +12 -10
  118. package/src/Banner/Banner.test.tsx +108 -0
  119. package/src/Banner/Banner.tsx +124 -38
  120. package/src/Banner/index.ts +15 -13
  121. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  122. package/src/BottomSheet/BottomSheet.test.tsx +1865 -0
  123. package/src/BottomSheet/BottomSheet.tsx +539 -0
  124. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  125. package/src/BottomSheet/BottomSheetPanel.tsx +542 -0
  126. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  127. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  128. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  129. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  130. package/src/BottomSheet/index.ts +15 -0
  131. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  132. package/src/BottomSheet/snapOffsets.ts +159 -0
  133. package/src/BottomSheet/useMobileKeyboard.ts +566 -0
  134. package/src/BottomSheet/useSheetGestures.test.ts +833 -0
  135. package/src/BottomSheet/useSheetGestures.ts +1333 -0
  136. package/src/Chat/ChatComposerInput.tsx +3 -4
  137. package/src/Chat/ChatTokenizedText.tsx +1 -1
  138. package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
  139. package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
  140. package/src/ComplexSelector/ComplexSelector.tsx +156 -28
  141. package/src/ComplexSelector/index.ts +3 -1
  142. package/src/ContextMenu/ContextMenu.tsx +4 -2
  143. package/src/DateInput/DateInput.test.tsx +21 -0
  144. package/src/DateInput/DateInput.tsx +9 -1
  145. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  146. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  147. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  148. package/src/Dialog/Dialog.tsx +2 -1
  149. package/src/Markdown/parser.test.ts +53 -0
  150. package/src/Markdown/parser.ts +53 -12
  151. package/src/MobileNav/MobileNav.tsx +128 -37
  152. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  153. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  154. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  155. package/src/MultiSelector/MultiSelector.test.tsx +31 -0
  156. package/src/MultiSelector/MultiSelector.tsx +10 -1
  157. package/src/NumberInput/NumberInput.test.tsx +18 -0
  158. package/src/NumberInput/NumberInput.tsx +9 -1
  159. package/src/PowerSearch/PowerSearch.test.tsx +48 -3
  160. package/src/PowerSearch/PowerSearch.tsx +4 -1
  161. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  162. package/src/PowerSearch/formatFilterValue.test.ts +22 -0
  163. package/src/PowerSearch/formatFilterValue.ts +2 -4
  164. package/src/Selector/Selector.test.tsx +33 -0
  165. package/src/Selector/Selector.tsx +10 -1
  166. package/src/Switch/Switch.test.tsx +16 -0
  167. package/src/Switch/Switch.tsx +7 -0
  168. package/src/Table/Table.test.tsx +6 -0
  169. package/src/Table/columnUtils.ts +3 -1
  170. package/src/TextArea/TextArea.doc.mjs +1 -1
  171. package/src/TextArea/TextArea.test.tsx +72 -0
  172. package/src/TextArea/TextArea.tsx +26 -8
  173. package/src/TimeInput/TimeInput.test.tsx +23 -0
  174. package/src/TimeInput/TimeInput.tsx +8 -0
  175. package/src/Tooltip/useTooltip.tsx +4 -1
  176. package/src/TreeList/TreeList.doc.mjs +2 -2
  177. package/src/TreeList/TreeList.tsx +1 -1
  178. package/src/Typeahead/BaseTypeahead.tsx +1 -1
  179. package/src/Typeahead/Typeahead.test.tsx +35 -0
  180. package/src/Typeahead/Typeahead.tsx +10 -1
  181. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  182. package/src/__tests__/stubMatchMedia.ts +95 -0
  183. package/src/hooks/index.ts +9 -5
  184. package/src/hooks/useFocusTrap.ts +1 -14
  185. package/src/index.ts +1 -0
  186. package/src/theme/defineTheme.test.ts +127 -0
  187. package/src/theme/defineTheme.ts +70 -55
  188. package/src/theme/expandColorScale.test.ts +138 -0
  189. package/src/theme/expandColorScale.ts +89 -44
  190. package/src/theme/extensibleAxes.test.ts +365 -0
  191. package/src/theme/mergeComponents.ts +59 -0
  192. package/src/theme/onMediaTokens.ts +9 -2
  193. package/src/utils/characters.test.ts +141 -0
  194. package/src/utils/characters.ts +88 -0
  195. package/src/utils/ime.test.ts +32 -0
  196. package/src/utils/ime.ts +69 -0
  197. package/src/utils/index.ts +4 -0
  198. package/dist/astryx.umd.js +0 -147
  199. package/dist/astryx.umd.js.map +0 -7
@@ -0,0 +1,130 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file snapOffsets.ts
5
+ * @input Pure geometry helpers — no React, no DOM.
6
+ * @output Converts snap points to translateY offsets for the sheet surface.
7
+ * @position Internal to BottomSheet; consumed by useSheetGestures, tested by
8
+ * snapOffsets.test.ts.
9
+ *
10
+ * A detent is represented as an offset in px from the fully-open position
11
+ * (0 = fully open, larger = more collapsed). The panel may render that offset
12
+ * as a translate or as a reduction in layout height. These
13
+ * helpers turn snap points into the shared offset list; they are pure so the
14
+ * geometry can be unit-tested without a DOM.
15
+ */
16
+
17
+ /**
18
+ * Detents whose resting offsets land within this many px of each other are
19
+ * treated as the same stop (the taller one wins). Stops the sheet from having
20
+ * two near-identical rest positions — e.g. when a content-hugging height sits
21
+ * a hair away from a fractional snap point.
22
+ */
23
+ export const DETENT_DEDUP_PX = 48;
24
+
25
+ /** Resolve snap points given as viewport fractions (0, 1] to px heights. */
26
+ export function snapFractionsToHeights(fractions, viewportPx) {
27
+ return fractions.filter(f => f > 0 && f <= 1).map(f => f * viewportPx);
28
+ }
29
+
30
+ /**
31
+ * Given the sheet's full height (px, as rendered fully open) and a set of
32
+ * candidate detent *visible heights* (px), return the resting offsets from
33
+ * fully-open, ascending and de-duplicated.
34
+ *
35
+ * - `0` (fully open) is always the first detent.
36
+ * - Only heights strictly shorter than the sheet become collapsed detents; a
37
+ * height >= the sheet is already covered by the fully-open stop.
38
+ * - Offsets closer than `dedupPx` collapse to the smaller (taller) offset, so
39
+ * near-identical detents — e.g. a hug height ≈ a snap point — become one stop.
40
+ */
41
+ export function computeDetentOffsets(sheetHeight, detentHeights, dedupPx = DETENT_DEDUP_PX) {
42
+ const collapsed = detentHeights.filter(h => h > 0 && h < sheetHeight).map(h => sheetHeight - h);
43
+ const ascending = [0, ...collapsed].sort((a, b) => a - b);
44
+ const deduped = [];
45
+ for (const offset of ascending) {
46
+ const last = deduped[deduped.length - 1];
47
+ if (last === undefined || offset - last >= dedupPx) {
48
+ deduped.push(offset);
49
+ }
50
+ }
51
+ return deduped;
52
+ }
53
+
54
+ /** Nearest value in `offsets` to `value` (offsets must be non-empty). */
55
+ export function nearestOffset(value, offsets) {
56
+ return offsets.reduce((best, o) => Math.abs(o - value) < Math.abs(best - value) ? o : best, offsets[0]);
57
+ }
58
+
59
+ /**
60
+ * Minimum scrim opacity at the peek detent. The scrim thins to a glance state
61
+ * but never fully vanishes, because a modal sheet keeps the background inert —
62
+ * a fully clear backdrop would read as "interactive" when it isn't. (For a
63
+ * genuinely interactive, undimmed peek, use a non-modal sheet, `hasScrim=false`.)
64
+ */
65
+ export const MIN_PEEK_SCRIM_OPACITY = 0.3;
66
+
67
+ /**
68
+ * Whether `offset` is the peek detent: the shortest stop, where the sheet is a
69
+ * glance rather than a working surface. A sheet with no collapsed stop at all
70
+ * has no peek — the same rule `scrimOpacityForOffset` thins the scrim by.
71
+ *
72
+ * The peek is the one detent the sheet does NOT express as layout height: at a
73
+ * sliver of viewport there is nothing useful to lay out, and reflowing the
74
+ * content into it (then back out on the way up) is churn the user sees. It
75
+ * keeps the full layout height and slides below the viewport instead.
76
+ */
77
+ export function isPeekOffset(offset, offsets, tolerancePx = 0.5) {
78
+ if (offsets.length < 2) {
79
+ return false;
80
+ }
81
+ return Math.abs(offset - offsets[offsets.length - 1]) <= tolerancePx;
82
+ }
83
+
84
+ /**
85
+ * Scrim opacity (1 = fully visible) for a drag/settle `offset`.
86
+ * The scrim stays full while the sheet is at or above its second-shortest
87
+ * detent, then fades as it collapses onto the shortest ("peek") detent — a
88
+ * glance state that thins the backdrop to `MIN_PEEK_SCRIM_OPACITY` (not fully
89
+ * gone, since the sheet is still modal) and holds there below it.
90
+ * A single-detent sheet has no peek, so it instead fades all the way to 0
91
+ * across the dismiss overshoot toward `dismissOffset` (the sheet is leaving).
92
+ */
93
+ export function scrimOpacityForOffset(offset, offsets, dismissOffset) {
94
+ const shortest = offsets[offsets.length - 1];
95
+ const hasPeek = offsets.length >= 2;
96
+ const fadeStart = hasPeek ? offsets[offsets.length - 2] : 0;
97
+ const fadeEnd = hasPeek ? shortest : dismissOffset;
98
+ // Peek is a resting state on a still-modal sheet, so keep a floor; the
99
+ // dismiss overshoot is a sheet on its way out, so let it clear completely.
100
+ const floor = hasPeek ? MIN_PEEK_SCRIM_OPACITY : 0;
101
+ if (offset <= fadeStart) {
102
+ return 1;
103
+ }
104
+ if (offset >= fadeEnd) {
105
+ return floor;
106
+ }
107
+ return 1 - (1 - floor) * ((offset - fadeStart) / (fadeEnd - fadeStart));
108
+ }
109
+
110
+ /**
111
+ * Settle target for a released drag. Restricts candidates to the drag
112
+ * direction so a committed drag never snaps *back past* where it started
113
+ * (a down-drag settles at/below the start, an up-drag at/above), then picks
114
+ * the nearest remaining detent. `dir`: 1 = dragged down, -1 = up, 0 = neither.
115
+ */
116
+ export function resolveSettleOffset(value, offsets, dir, baseOffset) {
117
+ let candidates = offsets;
118
+ if (dir > 0) {
119
+ const downward = offsets.filter(o => o >= baseOffset);
120
+ if (downward.length > 0) {
121
+ candidates = downward;
122
+ }
123
+ } else if (dir < 0) {
124
+ const upward = offsets.filter(o => o <= baseOffset);
125
+ if (upward.length > 0) {
126
+ candidates = upward;
127
+ }
128
+ }
129
+ return nearestOffset(value, candidates);
130
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @file useMobileKeyboard.ts
3
+ * @input Uses React effects and refs supplied by BottomSheet
4
+ * @output Exports internal useMobileKeyboard hook
5
+ * @position Internal to BottomSheet; not exported from the core entry point
6
+ *
7
+ * Gives a fully expanded, explicitly Tall sheet a keyboard-aware internal
8
+ * scroll range while leaving the sheet itself stationary. Shorter detents and
9
+ * other heights opt out entirely. Starting Tall-sheet travel or closing the
10
+ * sheet blurs the field and dismisses the keyboard.
11
+ */
12
+ import { type RefObject } from 'react';
13
+ interface UseMobileKeyboardOptions {
14
+ bodyRef: RefObject<HTMLElement | null>;
15
+ bottomClearance: number;
16
+ isEnabled: boolean;
17
+ isFullyExpanded: boolean;
18
+ isSheetTraveling: boolean;
19
+ isOpen: boolean;
20
+ isPresented: boolean;
21
+ sheetRef: RefObject<HTMLDivElement | null>;
22
+ }
23
+ export declare function useMobileKeyboard({ bodyRef, bottomClearance, isEnabled, isFullyExpanded, isSheetTraveling, isOpen, isPresented, sheetRef, }: UseMobileKeyboardOptions): void;
24
+ export {};
25
+ //# sourceMappingURL=useMobileKeyboard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useMobileKeyboard.d.ts","sourceRoot":"","sources":["../../src/BottomSheet/useMobileKeyboard.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAAoB,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAiBxD,UAAU,wBAAwB;IAChC,OAAO,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IACvC,eAAe,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,OAAO,CAAC;IACnB,eAAe,EAAE,OAAO,CAAC;IACzB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,MAAM,EAAE,OAAO,CAAC;IAChB,WAAW,EAAE,OAAO,CAAC;IACrB,QAAQ,EAAE,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;CAC5C;AAsGD,wBAAgB,iBAAiB,CAAC,EAChC,OAAO,EACP,eAAe,EACf,SAAS,EACT,eAAe,EACf,gBAAgB,EAChB,MAAM,EACN,WAAW,EACX,QAAQ,GACT,EAAE,wBAAwB,GAAG,IAAI,CA4ZjC"}
@@ -0,0 +1,384 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useMobileKeyboard.ts
7
+ * @input Uses React effects and refs supplied by BottomSheet
8
+ * @output Exports internal useMobileKeyboard hook
9
+ * @position Internal to BottomSheet; not exported from the core entry point
10
+ *
11
+ * Gives a fully expanded, explicitly Tall sheet a keyboard-aware internal
12
+ * scroll range while leaving the sheet itself stationary. Shorter detents and
13
+ * other heights opt out entirely. Starting Tall-sheet travel or closing the
14
+ * sheet blurs the field and dismisses the keyboard.
15
+ */
16
+ import { useEffect, useRef } from 'react';
17
+ const MOBILE_KEYBOARD_INSET_VAR = '--_sheet-keyboard-inset';
18
+ const TOUCH_FOCUS_MOVE_THRESHOLD = 10;
19
+ const NON_TEXT_INPUT_TYPES = new Set(['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit']);
20
+ function captureFocusScroll(target) {
21
+ const elements = [];
22
+ for (let element = target.parentElement; element; element = element.parentElement) {
23
+ elements.push({
24
+ element,
25
+ scrollLeft: element.scrollLeft,
26
+ scrollTop: element.scrollTop
27
+ });
28
+ }
29
+ return {
30
+ target,
31
+ elements,
32
+ windowX: window.scrollX,
33
+ windowY: window.scrollY
34
+ };
35
+ }
36
+ function getVisualViewportBounds() {
37
+ const viewport = window.visualViewport;
38
+ const top = viewport?.offsetTop ?? 0;
39
+ return {
40
+ top,
41
+ bottom: top + (viewport?.height ?? window.innerHeight)
42
+ };
43
+ }
44
+ function isIOSWebKit() {
45
+ const userAgent = window.navigator.userAgent;
46
+ return /iPad|iPhone|iPod/.test(userAgent) || userAgent.includes('Macintosh') && window.navigator.maxTouchPoints > 1;
47
+ }
48
+ function isTextEntryControl(element) {
49
+ if (element instanceof HTMLTextAreaElement) {
50
+ return !element.disabled && !element.readOnly;
51
+ }
52
+ if (element instanceof HTMLInputElement) {
53
+ return !element.disabled && !element.readOnly && !NON_TEXT_INPUT_TYPES.has((element.getAttribute('type') ?? 'text').toLowerCase());
54
+ }
55
+ return element instanceof HTMLElement && element.matches('[contenteditable]:not([contenteditable="false"])');
56
+ }
57
+ function findTextEntryControl(target, body) {
58
+ if (!(target instanceof Element)) {
59
+ return null;
60
+ }
61
+ const direct = target.closest('input, textarea, [contenteditable]:not([contenteditable="false"])');
62
+ if (body.contains(direct) && isTextEntryControl(direct)) {
63
+ return direct;
64
+ }
65
+ const label = target.closest('label');
66
+ const control = label instanceof HTMLLabelElement ? label.control : null;
67
+ return body.contains(control) && isTextEntryControl(control) ? control : null;
68
+ }
69
+ export function useMobileKeyboard({
70
+ bodyRef,
71
+ bottomClearance,
72
+ isEnabled,
73
+ isFullyExpanded,
74
+ isSheetTraveling,
75
+ isOpen,
76
+ isPresented,
77
+ sheetRef
78
+ }) {
79
+ const hasKeyboardLayoutRef = useRef(false);
80
+ const retainKeyboardLayoutRef = useRef(false);
81
+ const isActiveRef = useRef(isOpen);
82
+ const isFullyExpandedRef = useRef(isFullyExpanded);
83
+ isActiveRef.current = isOpen;
84
+ isFullyExpandedRef.current = isFullyExpanded;
85
+ useEffect(() => {
86
+ if (!isEnabled || !isSheetTraveling) {
87
+ return;
88
+ }
89
+ const sheet = sheetRef.current;
90
+ const activeElement = document.activeElement;
91
+ if (sheet && activeElement instanceof HTMLElement && activeElement !== sheet && sheet.contains(activeElement)) {
92
+ // Keep the current keyboard scroll range while focusing the sheet
93
+ // dismisses the keyboard. Viewport resize events unwind that layout as
94
+ // the visual viewport recovers, avoiding a content jump on the first
95
+ // drag frame.
96
+ retainKeyboardLayoutRef.current = hasKeyboardLayoutRef.current;
97
+ sheet.focus({
98
+ preventScroll: true
99
+ });
100
+ }
101
+ }, [isEnabled, isSheetTraveling, sheetRef]);
102
+ useEffect(() => {
103
+ if (!isEnabled || !isPresented || isOpen) {
104
+ return;
105
+ }
106
+ const sheet = sheetRef.current;
107
+ const activeElement = document.activeElement;
108
+ if (sheet && activeElement instanceof HTMLElement && activeElement !== sheet && sheet.contains(activeElement)) {
109
+ retainKeyboardLayoutRef.current = hasKeyboardLayoutRef.current;
110
+ activeElement.blur();
111
+ }
112
+ }, [isEnabled, isOpen, isPresented, sheetRef]);
113
+ useEffect(() => {
114
+ const body = bodyRef.current;
115
+ const sheet = sheetRef.current;
116
+ if (!isEnabled || !isPresented || !body) {
117
+ return;
118
+ }
119
+ let keyboardGeometry = null;
120
+ let pendingFocusScroll = null;
121
+ let pendingPointerFocusScroll = null;
122
+ let pendingTouchFocus = null;
123
+ const preventFocusScroll = isIOSWebKit();
124
+ const clearKeyboardLayout = () => {
125
+ body.style.setProperty(MOBILE_KEYBOARD_INSET_VAR, '0px');
126
+ keyboardGeometry = null;
127
+ hasKeyboardLayoutRef.current = false;
128
+ retainKeyboardLayoutRef.current = false;
129
+ };
130
+ const restoreScrollSnapshot = snapshot => {
131
+ for (const {
132
+ element,
133
+ scrollLeft,
134
+ scrollTop
135
+ } of snapshot.elements) {
136
+ if (element.scrollLeft !== scrollLeft) {
137
+ element.scrollLeft = scrollLeft;
138
+ }
139
+ if (element.scrollTop !== scrollTop) {
140
+ element.scrollTop = scrollTop;
141
+ }
142
+ }
143
+ if (window.scrollX !== snapshot.windowX || window.scrollY !== snapshot.windowY) {
144
+ window.scrollTo(snapshot.windowX, snapshot.windowY);
145
+ }
146
+ };
147
+ const scrollBodyBy = (distance, smoothly) => {
148
+ if (typeof body.scrollBy !== 'function') {
149
+ body.scrollTop += distance;
150
+ return;
151
+ }
152
+ const reduceMotion = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
153
+ body.scrollBy({
154
+ top: distance,
155
+ behavior: smoothly && !reduceMotion ? 'smooth' : 'auto'
156
+ });
157
+ };
158
+ const applyKeyboardGeometry = geometry => {
159
+ const viewport = getVisualViewportBounds();
160
+ // A collapsed detent can extend the body below the layout viewport even
161
+ // after the keyboard closes, so body overlap alone cannot identify
162
+ // recovery. Once the visual viewport reaches the layout viewport bottom,
163
+ // release the retained keyboard layout unconditionally.
164
+ if (viewport.bottom >= window.innerHeight - 0.5) {
165
+ clearKeyboardLayout();
166
+ return;
167
+ }
168
+ const overlap = Math.max(0, geometry.bodyBottom - viewport.bottom);
169
+ if (overlap === 0) {
170
+ clearKeyboardLayout();
171
+ return;
172
+ }
173
+ const inset = Math.max(0, geometry.bodyBottom - (viewport.bottom - bottomClearance));
174
+ body.style.setProperty(MOBILE_KEYBOARD_INSET_VAR, `${inset}px`);
175
+ };
176
+
177
+ // Wait until pointerup confirms a touch was a tap, then focus before the
178
+ // native click so preventScroll suppresses page panning while the click
179
+ // still owns caret placement. Holding the original pointerdown event lets
180
+ // consumer preventDefault calls remain authoritative after capture.
181
+ const rememberPointerFocusScroll = event => {
182
+ const control = findTextEntryControl(event.target, body);
183
+ pendingPointerFocusScroll = isFullyExpandedRef.current && control && control !== document.activeElement ? captureFocusScroll(control) : null;
184
+ pendingTouchFocus = isActiveRef.current && isFullyExpandedRef.current && event.pointerType === 'touch' && event.isPrimary !== false && control && control !== document.activeElement ? {
185
+ clientX: event.clientX,
186
+ clientY: event.clientY,
187
+ control,
188
+ pointerDown: event,
189
+ pointerId: event.pointerId
190
+ } : null;
191
+ };
192
+ const clearPendingTouchFocus = () => {
193
+ if (pendingPointerFocusScroll?.target === pendingTouchFocus?.control) {
194
+ pendingPointerFocusScroll = null;
195
+ }
196
+ pendingTouchFocus = null;
197
+ };
198
+ const handlePointerMove = event => {
199
+ if (pendingTouchFocus?.pointerId === event.pointerId && Math.hypot(event.clientX - pendingTouchFocus.clientX, event.clientY - pendingTouchFocus.clientY) > TOUCH_FOCUS_MOVE_THRESHOLD) {
200
+ clearPendingTouchFocus();
201
+ }
202
+ };
203
+ const handlePointerCancel = event => {
204
+ if (pendingTouchFocus?.pointerId === event.pointerId) {
205
+ clearPendingTouchFocus();
206
+ }
207
+ };
208
+ const preventTouchFocusScroll = event => {
209
+ const pending = pendingTouchFocus;
210
+ pendingTouchFocus = null;
211
+ if (!pending || !isFullyExpandedRef.current || pending.pointerId !== event.pointerId || pending.pointerDown.defaultPrevented || event.defaultPrevented || findTextEntryControl(event.target, body) !== pending.control || document.activeElement === pending.control) {
212
+ if (pendingPointerFocusScroll?.target === pending?.control) {
213
+ pendingPointerFocusScroll = null;
214
+ }
215
+ return;
216
+ }
217
+ pending.control.focus({
218
+ preventScroll: true
219
+ });
220
+ };
221
+
222
+ // Keyboard and programmatic focus transitions cannot supply preventScroll.
223
+ // Restore their pre-focus scroll positions without re-entering the focus
224
+ // lifecycle, so consumer focus and blur callbacks remain single events.
225
+ const rememberFocusScroll = event => {
226
+ const control = findTextEntryControl(event.relatedTarget, body);
227
+ if (!isFullyExpandedRef.current || !control) {
228
+ pendingFocusScroll = null;
229
+ return;
230
+ }
231
+ pendingFocusScroll = captureFocusScroll(control);
232
+ };
233
+ const restoreFocusScroll = event => {
234
+ const control = findTextEntryControl(event.target, body);
235
+ const snapshot = pendingFocusScroll?.target === control ? pendingFocusScroll : pendingPointerFocusScroll?.target === control ? pendingPointerFocusScroll : null;
236
+ pendingFocusScroll = null;
237
+ pendingPointerFocusScroll = null;
238
+ if (snapshot?.target === control) {
239
+ restoreScrollSnapshot(snapshot);
240
+ }
241
+ };
242
+ const revealFocusedControl = () => {
243
+ const activeElement = document.activeElement;
244
+ if (!isFullyExpandedRef.current || !(activeElement instanceof HTMLElement) || !body.contains(activeElement) || !isTextEntryControl(activeElement)) {
245
+ if (retainKeyboardLayoutRef.current && keyboardGeometry) {
246
+ applyKeyboardGeometry(keyboardGeometry);
247
+ return;
248
+ }
249
+ clearKeyboardLayout();
250
+ return;
251
+ }
252
+ retainKeyboardLayoutRef.current = false;
253
+ const measuredBodyRect = body.getBoundingClientRect();
254
+ const measuredFocusedRect = activeElement.getBoundingClientRect();
255
+ const viewport = getVisualViewportBounds();
256
+ const overlap = Math.max(0, measuredBodyRect.bottom - viewport.bottom);
257
+ // The extra clearance leaves room for mobile suggestion UI, but only
258
+ // while the visual viewport actually overlaps the sheet. With no
259
+ // obstruction, ordinary desktop and hardware-keyboard focus must not
260
+ // shift an already visible control.
261
+ const clearance = overlap > 0 ? bottomClearance : 0;
262
+ keyboardGeometry = overlap > 0 ? {
263
+ bodyBottom: measuredBodyRect.bottom
264
+ } : null;
265
+ hasKeyboardLayoutRef.current = overlap > 0;
266
+ const inset = overlap > 0 ? Math.max(0, measuredBodyRect.bottom - (viewport.bottom - clearance)) : 0;
267
+ body.style.setProperty(MOBILE_KEYBOARD_INSET_VAR, `${inset}px`);
268
+ const safeTop = Math.max(measuredBodyRect.top, viewport.top);
269
+ const safeBottom = Math.min(measuredBodyRect.bottom, viewport.bottom - clearance);
270
+ if (safeBottom <= safeTop) {
271
+ return;
272
+ }
273
+ if (measuredFocusedRect.bottom > safeBottom) {
274
+ scrollBodyBy(measuredFocusedRect.bottom - safeBottom, overlap > 0);
275
+ } else if (measuredFocusedRect.top < safeTop) {
276
+ scrollBodyBy(measuredFocusedRect.top - safeTop, overlap > 0);
277
+ }
278
+ };
279
+ let animationFrame = 0;
280
+ const scheduleReveal = () => {
281
+ cancelAnimationFrame(animationFrame);
282
+ animationFrame = requestAnimationFrame(revealFocusedControl);
283
+ };
284
+ const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(scheduleReveal);
285
+ const refreshObservedLayout = () => {
286
+ if (!resizeObserver) {
287
+ return;
288
+ }
289
+ resizeObserver.disconnect();
290
+
291
+ // Body/sheet box changes include public height and xstyle updates. The
292
+ // generated inset pseudo-element changes only scroll overflow, so these
293
+ // observations do not feed the keyboard inset back into sheet geometry.
294
+ const targets = new Set([body]);
295
+ if (sheet) {
296
+ targets.add(sheet);
297
+ }
298
+ for (const child of body.children) {
299
+ targets.add(child);
300
+ }
301
+ const activeElement = document.activeElement;
302
+ if (activeElement instanceof HTMLElement && body.contains(activeElement)) {
303
+ for (let element = activeElement; element && element !== body; element = element.parentElement) {
304
+ targets.add(element);
305
+ }
306
+ }
307
+ for (const target of targets) {
308
+ resizeObserver.observe(target);
309
+ }
310
+ };
311
+ const mutationObserver = typeof MutationObserver === 'undefined' ? null : new MutationObserver(records => {
312
+ // Ignore the internal inset written on the body. Consumer DOM,
313
+ // text, class, style, or visibility changes can all move the
314
+ // focused control without a viewport event.
315
+ if (records.every(record => record.type === 'attributes' && record.target === body)) {
316
+ return;
317
+ }
318
+ refreshObservedLayout();
319
+ scheduleReveal();
320
+ });
321
+ const handleFocusIn = () => {
322
+ refreshObservedLayout();
323
+ scheduleReveal();
324
+ };
325
+ const handleFocusOut = () => {
326
+ // Keep the added scroll range while the keyboard animates away. A focus
327
+ // transition to another text-entry control cancels this in focusin.
328
+ retainKeyboardLayoutRef.current = hasKeyboardLayoutRef.current;
329
+ scheduleReveal();
330
+ };
331
+ const handleSheetTransitionEnd = event => {
332
+ if (event.target === sheet && event.propertyName === 'transform') {
333
+ scheduleReveal();
334
+ }
335
+ };
336
+ const viewport = window.visualViewport;
337
+ if (preventFocusScroll) {
338
+ document.addEventListener('pointerdown', rememberPointerFocusScroll, true);
339
+ document.addEventListener('pointermove', handlePointerMove, true);
340
+ document.addEventListener('pointercancel', handlePointerCancel, true);
341
+ document.addEventListener('pointerup', preventTouchFocusScroll);
342
+ document.addEventListener('focusout', rememberFocusScroll);
343
+ body.addEventListener('focus', restoreFocusScroll, true);
344
+ }
345
+ body.addEventListener('focusin', handleFocusIn);
346
+ body.addEventListener('focusout', handleFocusOut);
347
+ sheet?.addEventListener('transitionend', handleSheetTransitionEnd);
348
+ viewport?.addEventListener('resize', scheduleReveal);
349
+ viewport?.addEventListener('scroll', scheduleReveal);
350
+ window.addEventListener('resize', scheduleReveal);
351
+ mutationObserver?.observe(body, {
352
+ attributes: true,
353
+ characterData: true,
354
+ childList: true,
355
+ subtree: true
356
+ });
357
+ refreshObservedLayout();
358
+ // The dialog enters the top layer in a later effect. Wait until the next
359
+ // frame so flex geometry is final before measuring the body.
360
+ scheduleReveal();
361
+ return () => {
362
+ cancelAnimationFrame(animationFrame);
363
+ if (preventFocusScroll) {
364
+ document.removeEventListener('pointerdown', rememberPointerFocusScroll, true);
365
+ document.removeEventListener('pointermove', handlePointerMove, true);
366
+ document.removeEventListener('pointercancel', handlePointerCancel, true);
367
+ document.removeEventListener('pointerup', preventTouchFocusScroll);
368
+ document.removeEventListener('focusout', rememberFocusScroll);
369
+ body.removeEventListener('focus', restoreFocusScroll, true);
370
+ }
371
+ body.removeEventListener('focusin', handleFocusIn);
372
+ body.removeEventListener('focusout', handleFocusOut);
373
+ sheet?.removeEventListener('transitionend', handleSheetTransitionEnd);
374
+ viewport?.removeEventListener('resize', scheduleReveal);
375
+ viewport?.removeEventListener('scroll', scheduleReveal);
376
+ window.removeEventListener('resize', scheduleReveal);
377
+ resizeObserver?.disconnect();
378
+ mutationObserver?.disconnect();
379
+ body.style.removeProperty(MOBILE_KEYBOARD_INSET_VAR);
380
+ hasKeyboardLayoutRef.current = false;
381
+ retainKeyboardLayoutRef.current = false;
382
+ };
383
+ }, [bodyRef, bottomClearance, isEnabled, isPresented, sheetRef]);
384
+ }