@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,566 @@
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
+
17
+ import {useEffect, useRef, type RefObject} from 'react';
18
+
19
+ const MOBILE_KEYBOARD_INSET_VAR = '--_sheet-keyboard-inset';
20
+ const TOUCH_FOCUS_MOVE_THRESHOLD = 10;
21
+ const NON_TEXT_INPUT_TYPES = new Set([
22
+ 'button',
23
+ 'checkbox',
24
+ 'color',
25
+ 'file',
26
+ 'hidden',
27
+ 'image',
28
+ 'radio',
29
+ 'range',
30
+ 'reset',
31
+ 'submit',
32
+ ]);
33
+
34
+ interface UseMobileKeyboardOptions {
35
+ bodyRef: RefObject<HTMLElement | null>;
36
+ bottomClearance: number;
37
+ isEnabled: boolean;
38
+ isFullyExpanded: boolean;
39
+ isSheetTraveling: boolean;
40
+ isOpen: boolean;
41
+ isPresented: boolean;
42
+ sheetRef: RefObject<HTMLDivElement | null>;
43
+ }
44
+
45
+ interface KeyboardGeometry {
46
+ bodyBottom: number;
47
+ }
48
+
49
+ interface FocusScrollSnapshot {
50
+ target: HTMLElement;
51
+ elements: {
52
+ element: HTMLElement;
53
+ scrollLeft: number;
54
+ scrollTop: number;
55
+ }[];
56
+ windowX: number;
57
+ windowY: number;
58
+ }
59
+
60
+ interface PendingTouchFocus {
61
+ clientX: number;
62
+ clientY: number;
63
+ control: HTMLElement;
64
+ pointerDown: PointerEvent;
65
+ pointerId: number;
66
+ }
67
+
68
+ function captureFocusScroll(target: HTMLElement): FocusScrollSnapshot {
69
+ const elements: FocusScrollSnapshot['elements'] = [];
70
+ for (
71
+ let element = target.parentElement;
72
+ element;
73
+ element = element.parentElement
74
+ ) {
75
+ elements.push({
76
+ element,
77
+ scrollLeft: element.scrollLeft,
78
+ scrollTop: element.scrollTop,
79
+ });
80
+ }
81
+ return {
82
+ target,
83
+ elements,
84
+ windowX: window.scrollX,
85
+ windowY: window.scrollY,
86
+ };
87
+ }
88
+
89
+ function getVisualViewportBounds(): {top: number; bottom: number} {
90
+ const viewport = window.visualViewport;
91
+ const top = viewport?.offsetTop ?? 0;
92
+ return {
93
+ top,
94
+ bottom: top + (viewport?.height ?? window.innerHeight),
95
+ };
96
+ }
97
+
98
+ function isIOSWebKit(): boolean {
99
+ const userAgent = window.navigator.userAgent;
100
+ return (
101
+ /iPad|iPhone|iPod/.test(userAgent) ||
102
+ (userAgent.includes('Macintosh') && window.navigator.maxTouchPoints > 1)
103
+ );
104
+ }
105
+
106
+ function isTextEntryControl(element: Element | null): element is HTMLElement {
107
+ if (element instanceof HTMLTextAreaElement) {
108
+ return !element.disabled && !element.readOnly;
109
+ }
110
+ if (element instanceof HTMLInputElement) {
111
+ return (
112
+ !element.disabled &&
113
+ !element.readOnly &&
114
+ !NON_TEXT_INPUT_TYPES.has(
115
+ (element.getAttribute('type') ?? 'text').toLowerCase(),
116
+ )
117
+ );
118
+ }
119
+ return (
120
+ element instanceof HTMLElement &&
121
+ element.matches('[contenteditable]:not([contenteditable="false"])')
122
+ );
123
+ }
124
+
125
+ function findTextEntryControl(
126
+ target: EventTarget | null,
127
+ body: HTMLElement,
128
+ ): HTMLElement | null {
129
+ if (!(target instanceof Element)) {
130
+ return null;
131
+ }
132
+
133
+ const direct = target.closest(
134
+ 'input, textarea, [contenteditable]:not([contenteditable="false"])',
135
+ );
136
+ if (body.contains(direct) && isTextEntryControl(direct)) {
137
+ return direct;
138
+ }
139
+
140
+ const label = target.closest('label');
141
+ const control = label instanceof HTMLLabelElement ? label.control : null;
142
+ return body.contains(control) && isTextEntryControl(control) ? control : null;
143
+ }
144
+
145
+ export function useMobileKeyboard({
146
+ bodyRef,
147
+ bottomClearance,
148
+ isEnabled,
149
+ isFullyExpanded,
150
+ isSheetTraveling,
151
+ isOpen,
152
+ isPresented,
153
+ sheetRef,
154
+ }: UseMobileKeyboardOptions): void {
155
+ const hasKeyboardLayoutRef = useRef(false);
156
+ const retainKeyboardLayoutRef = useRef(false);
157
+ const isActiveRef = useRef(isOpen);
158
+ const isFullyExpandedRef = useRef(isFullyExpanded);
159
+ isActiveRef.current = isOpen;
160
+ isFullyExpandedRef.current = isFullyExpanded;
161
+
162
+ useEffect(() => {
163
+ if (!isEnabled || !isSheetTraveling) {
164
+ return;
165
+ }
166
+ const sheet = sheetRef.current;
167
+ const activeElement = document.activeElement;
168
+ if (
169
+ sheet &&
170
+ activeElement instanceof HTMLElement &&
171
+ activeElement !== sheet &&
172
+ sheet.contains(activeElement)
173
+ ) {
174
+ // Keep the current keyboard scroll range while focusing the sheet
175
+ // dismisses the keyboard. Viewport resize events unwind that layout as
176
+ // the visual viewport recovers, avoiding a content jump on the first
177
+ // drag frame.
178
+ retainKeyboardLayoutRef.current = hasKeyboardLayoutRef.current;
179
+ sheet.focus({preventScroll: true});
180
+ }
181
+ }, [isEnabled, isSheetTraveling, sheetRef]);
182
+
183
+ useEffect(() => {
184
+ if (!isEnabled || !isPresented || isOpen) {
185
+ return;
186
+ }
187
+ const sheet = sheetRef.current;
188
+ const activeElement = document.activeElement;
189
+ if (
190
+ sheet &&
191
+ activeElement instanceof HTMLElement &&
192
+ activeElement !== sheet &&
193
+ sheet.contains(activeElement)
194
+ ) {
195
+ retainKeyboardLayoutRef.current = hasKeyboardLayoutRef.current;
196
+ activeElement.blur();
197
+ }
198
+ }, [isEnabled, isOpen, isPresented, sheetRef]);
199
+
200
+ useEffect(() => {
201
+ const body = bodyRef.current;
202
+ const sheet = sheetRef.current;
203
+ if (!isEnabled || !isPresented || !body) {
204
+ return;
205
+ }
206
+
207
+ let keyboardGeometry: KeyboardGeometry | null = null;
208
+ let pendingFocusScroll: FocusScrollSnapshot | null = null;
209
+ let pendingPointerFocusScroll: FocusScrollSnapshot | null = null;
210
+ let pendingTouchFocus: PendingTouchFocus | null = null;
211
+ const preventFocusScroll = isIOSWebKit();
212
+
213
+ const clearKeyboardLayout = () => {
214
+ body.style.setProperty(MOBILE_KEYBOARD_INSET_VAR, '0px');
215
+ keyboardGeometry = null;
216
+ hasKeyboardLayoutRef.current = false;
217
+ retainKeyboardLayoutRef.current = false;
218
+ };
219
+
220
+ const restoreScrollSnapshot = (snapshot: FocusScrollSnapshot) => {
221
+ for (const {element, scrollLeft, scrollTop} of snapshot.elements) {
222
+ if (element.scrollLeft !== scrollLeft) {
223
+ element.scrollLeft = scrollLeft;
224
+ }
225
+ if (element.scrollTop !== scrollTop) {
226
+ element.scrollTop = scrollTop;
227
+ }
228
+ }
229
+ if (
230
+ window.scrollX !== snapshot.windowX ||
231
+ window.scrollY !== snapshot.windowY
232
+ ) {
233
+ window.scrollTo(snapshot.windowX, snapshot.windowY);
234
+ }
235
+ };
236
+
237
+ const scrollBodyBy = (distance: number, smoothly: boolean) => {
238
+ if (typeof body.scrollBy !== 'function') {
239
+ body.scrollTop += distance;
240
+ return;
241
+ }
242
+ const reduceMotion =
243
+ typeof window.matchMedia === 'function' &&
244
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
245
+ body.scrollBy({
246
+ top: distance,
247
+ behavior: smoothly && !reduceMotion ? 'smooth' : 'auto',
248
+ });
249
+ };
250
+
251
+ const applyKeyboardGeometry = (geometry: KeyboardGeometry) => {
252
+ const viewport = getVisualViewportBounds();
253
+ // A collapsed detent can extend the body below the layout viewport even
254
+ // after the keyboard closes, so body overlap alone cannot identify
255
+ // recovery. Once the visual viewport reaches the layout viewport bottom,
256
+ // release the retained keyboard layout unconditionally.
257
+ if (viewport.bottom >= window.innerHeight - 0.5) {
258
+ clearKeyboardLayout();
259
+ return;
260
+ }
261
+ const overlap = Math.max(0, geometry.bodyBottom - viewport.bottom);
262
+ if (overlap === 0) {
263
+ clearKeyboardLayout();
264
+ return;
265
+ }
266
+
267
+ const inset = Math.max(
268
+ 0,
269
+ geometry.bodyBottom - (viewport.bottom - bottomClearance),
270
+ );
271
+ body.style.setProperty(MOBILE_KEYBOARD_INSET_VAR, `${inset}px`);
272
+ };
273
+
274
+ // Wait until pointerup confirms a touch was a tap, then focus before the
275
+ // native click so preventScroll suppresses page panning while the click
276
+ // still owns caret placement. Holding the original pointerdown event lets
277
+ // consumer preventDefault calls remain authoritative after capture.
278
+ const rememberPointerFocusScroll = (event: PointerEvent) => {
279
+ const control = findTextEntryControl(event.target, body);
280
+ pendingPointerFocusScroll =
281
+ isFullyExpandedRef.current &&
282
+ control &&
283
+ control !== document.activeElement
284
+ ? captureFocusScroll(control)
285
+ : null;
286
+ pendingTouchFocus =
287
+ isActiveRef.current &&
288
+ isFullyExpandedRef.current &&
289
+ event.pointerType === 'touch' &&
290
+ event.isPrimary !== false &&
291
+ control &&
292
+ control !== document.activeElement
293
+ ? {
294
+ clientX: event.clientX,
295
+ clientY: event.clientY,
296
+ control,
297
+ pointerDown: event,
298
+ pointerId: event.pointerId,
299
+ }
300
+ : null;
301
+ };
302
+ const clearPendingTouchFocus = () => {
303
+ if (pendingPointerFocusScroll?.target === pendingTouchFocus?.control) {
304
+ pendingPointerFocusScroll = null;
305
+ }
306
+ pendingTouchFocus = null;
307
+ };
308
+ const handlePointerMove = (event: PointerEvent) => {
309
+ if (
310
+ pendingTouchFocus?.pointerId === event.pointerId &&
311
+ Math.hypot(
312
+ event.clientX - pendingTouchFocus.clientX,
313
+ event.clientY - pendingTouchFocus.clientY,
314
+ ) > TOUCH_FOCUS_MOVE_THRESHOLD
315
+ ) {
316
+ clearPendingTouchFocus();
317
+ }
318
+ };
319
+ const handlePointerCancel = (event: PointerEvent) => {
320
+ if (pendingTouchFocus?.pointerId === event.pointerId) {
321
+ clearPendingTouchFocus();
322
+ }
323
+ };
324
+ const preventTouchFocusScroll = (event: PointerEvent) => {
325
+ const pending = pendingTouchFocus;
326
+ pendingTouchFocus = null;
327
+ if (
328
+ !pending ||
329
+ !isFullyExpandedRef.current ||
330
+ pending.pointerId !== event.pointerId ||
331
+ pending.pointerDown.defaultPrevented ||
332
+ event.defaultPrevented ||
333
+ findTextEntryControl(event.target, body) !== pending.control ||
334
+ document.activeElement === pending.control
335
+ ) {
336
+ if (pendingPointerFocusScroll?.target === pending?.control) {
337
+ pendingPointerFocusScroll = null;
338
+ }
339
+ return;
340
+ }
341
+
342
+ pending.control.focus({preventScroll: true});
343
+ };
344
+
345
+ // Keyboard and programmatic focus transitions cannot supply preventScroll.
346
+ // Restore their pre-focus scroll positions without re-entering the focus
347
+ // lifecycle, so consumer focus and blur callbacks remain single events.
348
+ const rememberFocusScroll = (event: FocusEvent) => {
349
+ const control = findTextEntryControl(event.relatedTarget, body);
350
+ if (!isFullyExpandedRef.current || !control) {
351
+ pendingFocusScroll = null;
352
+ return;
353
+ }
354
+ pendingFocusScroll = captureFocusScroll(control);
355
+ };
356
+ const restoreFocusScroll = (event: FocusEvent) => {
357
+ const control = findTextEntryControl(event.target, body);
358
+ const snapshot =
359
+ pendingFocusScroll?.target === control
360
+ ? pendingFocusScroll
361
+ : pendingPointerFocusScroll?.target === control
362
+ ? pendingPointerFocusScroll
363
+ : null;
364
+ pendingFocusScroll = null;
365
+ pendingPointerFocusScroll = null;
366
+ if (snapshot?.target === control) {
367
+ restoreScrollSnapshot(snapshot);
368
+ }
369
+ };
370
+
371
+ const revealFocusedControl = () => {
372
+ const activeElement = document.activeElement;
373
+ if (
374
+ !isFullyExpandedRef.current ||
375
+ !(activeElement instanceof HTMLElement) ||
376
+ !body.contains(activeElement) ||
377
+ !isTextEntryControl(activeElement)
378
+ ) {
379
+ if (retainKeyboardLayoutRef.current && keyboardGeometry) {
380
+ applyKeyboardGeometry(keyboardGeometry);
381
+ return;
382
+ }
383
+ clearKeyboardLayout();
384
+ return;
385
+ }
386
+
387
+ retainKeyboardLayoutRef.current = false;
388
+
389
+ const measuredBodyRect = body.getBoundingClientRect();
390
+ const measuredFocusedRect = activeElement.getBoundingClientRect();
391
+ const viewport = getVisualViewportBounds();
392
+ const overlap = Math.max(0, measuredBodyRect.bottom - viewport.bottom);
393
+ // The extra clearance leaves room for mobile suggestion UI, but only
394
+ // while the visual viewport actually overlaps the sheet. With no
395
+ // obstruction, ordinary desktop and hardware-keyboard focus must not
396
+ // shift an already visible control.
397
+ const clearance = overlap > 0 ? bottomClearance : 0;
398
+ keyboardGeometry =
399
+ overlap > 0
400
+ ? {
401
+ bodyBottom: measuredBodyRect.bottom,
402
+ }
403
+ : null;
404
+ hasKeyboardLayoutRef.current = overlap > 0;
405
+
406
+ const inset =
407
+ overlap > 0
408
+ ? Math.max(0, measuredBodyRect.bottom - (viewport.bottom - clearance))
409
+ : 0;
410
+ body.style.setProperty(MOBILE_KEYBOARD_INSET_VAR, `${inset}px`);
411
+
412
+ const safeTop = Math.max(measuredBodyRect.top, viewport.top);
413
+ const safeBottom = Math.min(
414
+ measuredBodyRect.bottom,
415
+ viewport.bottom - clearance,
416
+ );
417
+ if (safeBottom <= safeTop) {
418
+ return;
419
+ }
420
+
421
+ if (measuredFocusedRect.bottom > safeBottom) {
422
+ scrollBodyBy(measuredFocusedRect.bottom - safeBottom, overlap > 0);
423
+ } else if (measuredFocusedRect.top < safeTop) {
424
+ scrollBodyBy(measuredFocusedRect.top - safeTop, overlap > 0);
425
+ }
426
+ };
427
+
428
+ let animationFrame = 0;
429
+ const scheduleReveal = () => {
430
+ cancelAnimationFrame(animationFrame);
431
+ animationFrame = requestAnimationFrame(revealFocusedControl);
432
+ };
433
+
434
+ const resizeObserver =
435
+ typeof ResizeObserver === 'undefined'
436
+ ? null
437
+ : new ResizeObserver(scheduleReveal);
438
+ const refreshObservedLayout = () => {
439
+ if (!resizeObserver) {
440
+ return;
441
+ }
442
+ resizeObserver.disconnect();
443
+
444
+ // Body/sheet box changes include public height and xstyle updates. The
445
+ // generated inset pseudo-element changes only scroll overflow, so these
446
+ // observations do not feed the keyboard inset back into sheet geometry.
447
+ const targets = new Set<Element>([body]);
448
+ if (sheet) {
449
+ targets.add(sheet);
450
+ }
451
+ for (const child of body.children) {
452
+ targets.add(child);
453
+ }
454
+ const activeElement = document.activeElement;
455
+ if (
456
+ activeElement instanceof HTMLElement &&
457
+ body.contains(activeElement)
458
+ ) {
459
+ for (
460
+ let element: HTMLElement | null = activeElement;
461
+ element && element !== body;
462
+ element = element.parentElement
463
+ ) {
464
+ targets.add(element);
465
+ }
466
+ }
467
+ for (const target of targets) {
468
+ resizeObserver.observe(target);
469
+ }
470
+ };
471
+ const mutationObserver =
472
+ typeof MutationObserver === 'undefined'
473
+ ? null
474
+ : new MutationObserver(records => {
475
+ // Ignore the internal inset written on the body. Consumer DOM,
476
+ // text, class, style, or visibility changes can all move the
477
+ // focused control without a viewport event.
478
+ if (
479
+ records.every(
480
+ record =>
481
+ record.type === 'attributes' && record.target === body,
482
+ )
483
+ ) {
484
+ return;
485
+ }
486
+ refreshObservedLayout();
487
+ scheduleReveal();
488
+ });
489
+ const handleFocusIn = () => {
490
+ refreshObservedLayout();
491
+ scheduleReveal();
492
+ };
493
+ const handleFocusOut = () => {
494
+ // Keep the added scroll range while the keyboard animates away. A focus
495
+ // transition to another text-entry control cancels this in focusin.
496
+ retainKeyboardLayoutRef.current = hasKeyboardLayoutRef.current;
497
+ scheduleReveal();
498
+ };
499
+ const handleSheetTransitionEnd = (event: TransitionEvent) => {
500
+ if (event.target === sheet && event.propertyName === 'transform') {
501
+ scheduleReveal();
502
+ }
503
+ };
504
+
505
+ const viewport = window.visualViewport;
506
+ if (preventFocusScroll) {
507
+ document.addEventListener(
508
+ 'pointerdown',
509
+ rememberPointerFocusScroll,
510
+ true,
511
+ );
512
+ document.addEventListener('pointermove', handlePointerMove, true);
513
+ document.addEventListener('pointercancel', handlePointerCancel, true);
514
+ document.addEventListener('pointerup', preventTouchFocusScroll);
515
+ document.addEventListener('focusout', rememberFocusScroll);
516
+ body.addEventListener('focus', restoreFocusScroll, true);
517
+ }
518
+ body.addEventListener('focusin', handleFocusIn);
519
+ body.addEventListener('focusout', handleFocusOut);
520
+ sheet?.addEventListener('transitionend', handleSheetTransitionEnd);
521
+ viewport?.addEventListener('resize', scheduleReveal);
522
+ viewport?.addEventListener('scroll', scheduleReveal);
523
+ window.addEventListener('resize', scheduleReveal);
524
+ mutationObserver?.observe(body, {
525
+ attributes: true,
526
+ characterData: true,
527
+ childList: true,
528
+ subtree: true,
529
+ });
530
+ refreshObservedLayout();
531
+ // The dialog enters the top layer in a later effect. Wait until the next
532
+ // frame so flex geometry is final before measuring the body.
533
+ scheduleReveal();
534
+
535
+ return () => {
536
+ cancelAnimationFrame(animationFrame);
537
+ if (preventFocusScroll) {
538
+ document.removeEventListener(
539
+ 'pointerdown',
540
+ rememberPointerFocusScroll,
541
+ true,
542
+ );
543
+ document.removeEventListener('pointermove', handlePointerMove, true);
544
+ document.removeEventListener(
545
+ 'pointercancel',
546
+ handlePointerCancel,
547
+ true,
548
+ );
549
+ document.removeEventListener('pointerup', preventTouchFocusScroll);
550
+ document.removeEventListener('focusout', rememberFocusScroll);
551
+ body.removeEventListener('focus', restoreFocusScroll, true);
552
+ }
553
+ body.removeEventListener('focusin', handleFocusIn);
554
+ body.removeEventListener('focusout', handleFocusOut);
555
+ sheet?.removeEventListener('transitionend', handleSheetTransitionEnd);
556
+ viewport?.removeEventListener('resize', scheduleReveal);
557
+ viewport?.removeEventListener('scroll', scheduleReveal);
558
+ window.removeEventListener('resize', scheduleReveal);
559
+ resizeObserver?.disconnect();
560
+ mutationObserver?.disconnect();
561
+ body.style.removeProperty(MOBILE_KEYBOARD_INSET_VAR);
562
+ hasKeyboardLayoutRef.current = false;
563
+ retainKeyboardLayoutRef.current = false;
564
+ };
565
+ }, [bodyRef, bottomClearance, isEnabled, isPresented, sheetRef]);
566
+ }