@astryxdesign/core 0.4.3 → 0.4.4-canary.0f4d45f

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 (200) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +79 -45
  3. package/dist/Banner/Banner.d.ts +3 -0
  4. package/dist/Banner/Banner.d.ts.map +1 -1
  5. package/dist/Banner/Banner.js +13 -3
  6. package/dist/BottomSheet/BottomSheet.d.ts +66 -0
  7. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  8. package/dist/BottomSheet/BottomSheet.js +392 -0
  9. package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
  10. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  11. package/dist/BottomSheet/BottomSheetPanel.js +398 -0
  12. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  13. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  14. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  15. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  16. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  17. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  18. package/dist/BottomSheet/index.d.ts +11 -0
  19. package/dist/BottomSheet/index.d.ts.map +1 -0
  20. package/dist/BottomSheet/index.js +12 -0
  21. package/dist/BottomSheet/snapOffsets.d.ts +72 -0
  22. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  23. package/dist/BottomSheet/snapOffsets.js +130 -0
  24. package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
  25. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  26. package/dist/BottomSheet/useMobileKeyboard.js +373 -0
  27. package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
  28. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  29. package/dist/BottomSheet/useSheetGestures.js +989 -0
  30. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  31. package/dist/Chat/ChatComposerInput.js +3 -4
  32. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  33. package/dist/ComplexSelector/ComplexSelector.js +12 -6
  34. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  35. package/dist/ContextMenu/ContextMenu.js +4 -2
  36. package/dist/DateInput/DateInput.d.ts.map +1 -1
  37. package/dist/DateInput/DateInput.js +9 -1
  38. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  39. package/dist/DateTimeInput/DateTimeInput.js +31 -1
  40. package/dist/Dialog/Dialog.d.ts.map +1 -1
  41. package/dist/Dialog/Dialog.js +2 -1
  42. package/dist/Field/InputClearButton.d.ts +2 -1
  43. package/dist/Field/InputClearButton.d.ts.map +1 -1
  44. package/dist/Field/InputClearButton.js +6 -1
  45. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  46. package/dist/Lightbox/Lightbox.js +9 -3
  47. package/dist/MobileNav/MobileNav.d.ts +12 -0
  48. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  49. package/dist/MobileNav/MobileNav.js +102 -30
  50. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  51. package/dist/MultiSelector/MultiSelector.js +21 -7
  52. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  53. package/dist/NumberInput/NumberInput.js +9 -1
  54. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  55. package/dist/Selector/Selector.d.ts.map +1 -1
  56. package/dist/Selector/Selector.js +14 -3
  57. package/dist/StatusDot/StatusDot.d.ts +43 -3
  58. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  59. package/dist/StatusDot/StatusDot.js +42 -5
  60. package/dist/Switch/Switch.d.ts.map +1 -1
  61. package/dist/Switch/Switch.js +5 -1
  62. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  63. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  64. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  65. package/dist/TimeInput/TimeInput.js +8 -1
  66. package/dist/Tokenizer/Tokenizer.js +11 -5
  67. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  68. package/dist/Tooltip/useTooltip.js +4 -1
  69. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  70. package/dist/Typeahead/BaseTypeahead.js +5 -3
  71. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  72. package/dist/Typeahead/Typeahead.js +10 -1
  73. package/dist/astryx.css +21 -0
  74. package/dist/hooks/index.d.ts +8 -1
  75. package/dist/hooks/index.d.ts.map +1 -1
  76. package/dist/hooks/index.js +8 -1
  77. package/dist/hooks/useFocusTrap.d.ts +0 -11
  78. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  79. package/dist/hooks/useFocusTrap.js +1 -11
  80. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  81. package/dist/hooks/useInputStatusIcon.js +1 -0
  82. package/dist/index.d.ts +1 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +1 -0
  85. package/dist/theme/defineTheme.d.ts +22 -4
  86. package/dist/theme/defineTheme.d.ts.map +1 -1
  87. package/dist/theme/expandColorScale.d.ts +17 -2
  88. package/dist/theme/expandColorScale.d.ts.map +1 -1
  89. package/dist/theme/expandColorScale.js +63 -33
  90. package/dist/utils/ime.d.ts +5 -0
  91. package/dist/utils/ime.d.ts.map +1 -0
  92. package/dist/utils/ime.js +63 -0
  93. package/dist/utils/index.d.ts +1 -0
  94. package/dist/utils/index.d.ts.map +1 -1
  95. package/dist/utils/index.js +2 -1
  96. package/locales/af-ZA.json +958 -0
  97. package/locales/ar-SA.json +998 -0
  98. package/locales/ca-ES.json +974 -0
  99. package/locales/cs-CZ.json +990 -0
  100. package/locales/da-DK.json +966 -0
  101. package/locales/de-DE.json +966 -0
  102. package/locales/el-GR.json +990 -0
  103. package/locales/en.json +48 -0
  104. package/locales/es-ES.json +978 -0
  105. package/locales/fi-FI.json +990 -0
  106. package/locales/fr-FR.json +928 -0
  107. package/locales/he-IL.json +990 -0
  108. package/locales/hu-HU.json +990 -0
  109. package/locales/it-IT.json +978 -0
  110. package/locales/ja-JP.json +998 -0
  111. package/locales/ko-KR.json +990 -0
  112. package/locales/nl-NL.json +954 -0
  113. package/locales/no-NO.json +978 -0
  114. package/locales/pl-PL.json +982 -0
  115. package/locales/pseudo.json +36 -0
  116. package/locales/pt-BR.json +982 -0
  117. package/locales/pt-PT.json +982 -0
  118. package/locales/ro-RO.json +982 -0
  119. package/locales/ru-RU.json +990 -0
  120. package/locales/sr-SP.json +990 -0
  121. package/locales/sv-SE.json +986 -0
  122. package/locales/tr-TR.json +990 -0
  123. package/locales/uk-UA.json +990 -0
  124. package/locales/vi-VN.json +986 -0
  125. package/locales/zh-CN.json +998 -0
  126. package/locales/zh-TW.json +998 -0
  127. package/package.json +9 -8
  128. package/src/Banner/Banner.doc.mjs +3 -3
  129. package/src/Banner/Banner.test.tsx +40 -0
  130. package/src/Banner/Banner.tsx +27 -2
  131. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  132. package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
  133. package/src/BottomSheet/BottomSheet.tsx +545 -0
  134. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  135. package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
  136. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  137. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  138. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  139. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  140. package/src/BottomSheet/index.ts +15 -0
  141. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  142. package/src/BottomSheet/snapOffsets.ts +159 -0
  143. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  144. package/src/BottomSheet/useSheetGestures.test.ts +1059 -0
  145. package/src/BottomSheet/useSheetGestures.ts +1459 -0
  146. package/src/Chat/ChatComposerInput.tsx +3 -4
  147. package/src/ComplexSelector/ComplexSelector.tsx +12 -5
  148. package/src/ContextMenu/ContextMenu.tsx +4 -2
  149. package/src/DateInput/DateInput.test.tsx +21 -0
  150. package/src/DateInput/DateInput.tsx +9 -1
  151. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  152. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  153. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  154. package/src/Dialog/Dialog.tsx +2 -1
  155. package/src/Field/Field.doc.mjs +1 -0
  156. package/src/Field/InputClearButton.test.tsx +25 -0
  157. package/src/Field/InputClearButton.tsx +4 -1
  158. package/src/FileInput/FileInput.test.tsx +53 -9
  159. package/src/Lightbox/Lightbox.test.tsx +49 -28
  160. package/src/Lightbox/Lightbox.tsx +7 -3
  161. package/src/MobileNav/MobileNav.tsx +128 -37
  162. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  163. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  164. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  165. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  166. package/src/MultiSelector/MultiSelector.tsx +22 -8
  167. package/src/NumberInput/NumberInput.test.tsx +18 -0
  168. package/src/NumberInput/NumberInput.tsx +9 -1
  169. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  170. package/src/Selector/Selector.test.tsx +85 -26
  171. package/src/Selector/Selector.tsx +13 -4
  172. package/src/StatusDot/StatusDot.doc.mjs +13 -0
  173. package/src/StatusDot/StatusDot.test.tsx +115 -8
  174. package/src/StatusDot/StatusDot.tsx +74 -9
  175. package/src/Switch/Switch.test.tsx +16 -0
  176. package/src/Switch/Switch.tsx +7 -0
  177. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
  178. package/src/TimeInput/TimeInput.test.tsx +23 -0
  179. package/src/TimeInput/TimeInput.tsx +8 -0
  180. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  181. package/src/Tokenizer/Tokenizer.tsx +5 -5
  182. package/src/Tooltip/useTooltip.tsx +4 -1
  183. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  184. package/src/Typeahead/Typeahead.test.tsx +116 -7
  185. package/src/Typeahead/Typeahead.tsx +10 -1
  186. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  187. package/src/__tests__/stubMatchMedia.ts +95 -0
  188. package/src/hooks/index.ts +9 -5
  189. package/src/hooks/useFocusTrap.ts +1 -14
  190. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  191. package/src/hooks/useInputStatusIcon.tsx +7 -0
  192. package/src/index.ts +1 -0
  193. package/src/theme/defineTheme.ts +22 -4
  194. package/src/theme/expandColorScale.test.ts +138 -0
  195. package/src/theme/expandColorScale.ts +89 -44
  196. package/src/utils/ime.test.ts +32 -0
  197. package/src/utils/ime.ts +69 -0
  198. package/src/utils/index.ts +2 -0
  199. package/dist/astryx.umd.js +0 -147
  200. package/dist/astryx.umd.js.map +0 -7
@@ -0,0 +1,499 @@
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
+ * Every route into a field also has to reach it without the browser revealing
17
+ * it for us. On iOS that reveal scrolls the DOCUMENT, and a fixed sheet travels
18
+ * with it, so the whole page lurches. The reveal is attached to the focus
19
+ * operation, which is where it can be refused: take the focus transition over
20
+ * on the capture-phase blur, deliver it with preventScroll, and bring the
21
+ * control into view with the sheet's own scroller afterwards. Every route in —
22
+ * a tap, the keyboard's Next, Tab, a programmatic focus() — passes through that
23
+ * one transition.
24
+ */
25
+
26
+ import {useEffect, useRef, type RefObject} from 'react';
27
+
28
+ const MOBILE_KEYBOARD_INSET_VAR = '--_sheet-keyboard-inset';
29
+ const NON_TEXT_INPUT_TYPES = new Set([
30
+ 'button',
31
+ 'checkbox',
32
+ 'color',
33
+ 'file',
34
+ 'hidden',
35
+ 'image',
36
+ 'radio',
37
+ 'range',
38
+ 'reset',
39
+ 'submit',
40
+ ]);
41
+
42
+ interface UseMobileKeyboardOptions {
43
+ bodyRef: RefObject<HTMLElement | null>;
44
+ bottomClearance: number;
45
+ isEnabled: boolean;
46
+ isFullyExpanded: boolean;
47
+ isPageScrollLocked: boolean;
48
+ isSheetTraveling: boolean;
49
+ isOpen: boolean;
50
+ isPresented: boolean;
51
+ sheetRef: RefObject<HTMLDivElement | null>;
52
+ }
53
+
54
+ interface KeyboardGeometry {
55
+ bodyBottom: number;
56
+ }
57
+
58
+ function getObstructionTop(): number {
59
+ return window.visualViewport?.height ?? window.innerHeight;
60
+ }
61
+
62
+ // The keyboard is gone once the visible band reaches the layout viewport bottom.
63
+ function isVisualViewportRecovered(): boolean {
64
+ return getObstructionTop() >= window.innerHeight - 0.5;
65
+ }
66
+
67
+ function isIOSWebKit(): boolean {
68
+ const userAgent = window.navigator.userAgent;
69
+ return (
70
+ /iPad|iPhone|iPod/.test(userAgent) ||
71
+ (userAgent.includes('Macintosh') && window.navigator.maxTouchPoints > 1)
72
+ );
73
+ }
74
+
75
+ function isTextEntryControl(element: Element | null): element is HTMLElement {
76
+ if (element instanceof HTMLTextAreaElement) {
77
+ return !element.disabled && !element.readOnly;
78
+ }
79
+ if (element instanceof HTMLInputElement) {
80
+ return (
81
+ !element.disabled &&
82
+ !element.readOnly &&
83
+ !NON_TEXT_INPUT_TYPES.has(
84
+ (element.getAttribute('type') ?? 'text').toLowerCase(),
85
+ )
86
+ );
87
+ }
88
+ return (
89
+ element instanceof HTMLElement &&
90
+ element.matches('[contenteditable]:not([contenteditable="false"])')
91
+ );
92
+ }
93
+
94
+ function findTextEntryControl(
95
+ target: EventTarget | null,
96
+ body: HTMLElement,
97
+ ): HTMLElement | null {
98
+ if (!(target instanceof Element)) {
99
+ return null;
100
+ }
101
+
102
+ const direct = target.closest(
103
+ 'input, textarea, [contenteditable]:not([contenteditable="false"])',
104
+ );
105
+ if (body.contains(direct) && isTextEntryControl(direct)) {
106
+ return direct;
107
+ }
108
+
109
+ const label = target.closest('label');
110
+ const control = label instanceof HTMLLabelElement ? label.control : null;
111
+ return body.contains(control) && isTextEntryControl(control) ? control : null;
112
+ }
113
+
114
+ export function useMobileKeyboard({
115
+ bodyRef,
116
+ bottomClearance,
117
+ isEnabled,
118
+ isFullyExpanded,
119
+ isPageScrollLocked,
120
+ isSheetTraveling,
121
+ isOpen,
122
+ isPresented,
123
+ sheetRef,
124
+ }: UseMobileKeyboardOptions): void {
125
+ const hasKeyboardLayoutRef = useRef(false);
126
+ const retainKeyboardLayoutRef = useRef(false);
127
+ const isFullyExpandedRef = useRef(isFullyExpanded);
128
+ const isOpenRef = useRef(isOpen);
129
+ isFullyExpandedRef.current = isFullyExpanded;
130
+ isOpenRef.current = isOpen;
131
+
132
+ useEffect(() => {
133
+ if (!isEnabled || !isSheetTraveling) {
134
+ return;
135
+ }
136
+ const sheet = sheetRef.current;
137
+ const activeElement = document.activeElement;
138
+ if (
139
+ sheet &&
140
+ activeElement instanceof HTMLElement &&
141
+ activeElement !== sheet &&
142
+ sheet.contains(activeElement)
143
+ ) {
144
+ // Keep the current keyboard scroll range while focusing the sheet
145
+ // dismisses the keyboard. Viewport resize events unwind that layout as
146
+ // the visual viewport recovers, avoiding a content jump on the first
147
+ // drag frame.
148
+ retainKeyboardLayoutRef.current = hasKeyboardLayoutRef.current;
149
+ sheet.focus({preventScroll: true});
150
+ }
151
+ }, [isEnabled, isSheetTraveling, sheetRef]);
152
+
153
+ useEffect(() => {
154
+ if (!isEnabled || !isPresented || isOpen) {
155
+ return;
156
+ }
157
+ const sheet = sheetRef.current;
158
+ const activeElement = document.activeElement;
159
+ if (
160
+ sheet &&
161
+ activeElement instanceof HTMLElement &&
162
+ activeElement !== sheet &&
163
+ sheet.contains(activeElement)
164
+ ) {
165
+ retainKeyboardLayoutRef.current = hasKeyboardLayoutRef.current;
166
+ activeElement.blur();
167
+ }
168
+ }, [isEnabled, isOpen, isPresented, sheetRef]);
169
+
170
+ useEffect(() => {
171
+ const body = bodyRef.current;
172
+ const sheet = sheetRef.current;
173
+ if (!isEnabled || !isPresented || !body) {
174
+ return;
175
+ }
176
+
177
+ let keyboardGeometry: KeyboardGeometry | null = null;
178
+ let documentScrollAtKeyboard: {x: number; y: number} | null = null;
179
+ const ownsFocusTransitions = isIOSWebKit();
180
+
181
+ const clearKeyboardLayout = () => {
182
+ body.style.setProperty(MOBILE_KEYBOARD_INSET_VAR, '0px');
183
+ keyboardGeometry = null;
184
+ documentScrollAtKeyboard = null;
185
+ hasKeyboardLayoutRef.current = false;
186
+ retainKeyboardLayoutRef.current = false;
187
+ };
188
+
189
+ const scrollBodyBy = (distance: number, smoothly: boolean) => {
190
+ if (typeof body.scrollBy !== 'function') {
191
+ body.scrollTop += distance;
192
+ return;
193
+ }
194
+ const reduceMotion =
195
+ typeof window.matchMedia === 'function' &&
196
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
197
+ body.scrollBy({
198
+ top: distance,
199
+ // Not 'auto': that defers to the element's computed `scroll-behavior`,
200
+ // so a consumer's `scroll-behavior: smooth` would animate a scroll this
201
+ // hook needs to land in the same frame.
202
+ behavior: smoothly && !reduceMotion ? 'smooth' : 'instant',
203
+ });
204
+ };
205
+
206
+ // Scroll `control` into the part of the body the keyboard leaves visible,
207
+ // reading live geometry each time.
208
+ const scrollControlIntoSafeArea = (
209
+ control: HTMLElement,
210
+ smoothly: boolean,
211
+ ) => {
212
+ const measuredBodyRect = body.getBoundingClientRect();
213
+ const measuredControlRect = control.getBoundingClientRect();
214
+ const obstructionTop = getObstructionTop();
215
+ const overlap = Math.max(0, measuredBodyRect.bottom - obstructionTop);
216
+ const clearance = overlap > 0 ? bottomClearance : 0;
217
+ const safeTop = measuredBodyRect.top;
218
+ const safeBottom = Math.min(
219
+ measuredBodyRect.bottom,
220
+ obstructionTop - clearance,
221
+ );
222
+ if (safeBottom <= safeTop) {
223
+ return;
224
+ }
225
+
226
+ if (measuredControlRect.bottom > safeBottom) {
227
+ scrollBodyBy(measuredControlRect.bottom - safeBottom, smoothly);
228
+ } else if (measuredControlRect.top < safeTop) {
229
+ scrollBodyBy(measuredControlRect.top - safeTop, smoothly);
230
+ }
231
+ };
232
+
233
+ const applyKeyboardGeometry = (geometry: KeyboardGeometry) => {
234
+ const obstructionTop = getObstructionTop();
235
+ // A collapsed detent can extend the body below the layout viewport even
236
+ // after the keyboard closes, so body overlap alone cannot identify
237
+ // recovery. Once the visual viewport is full height again, release the
238
+ // retained keyboard layout unconditionally.
239
+ if (isVisualViewportRecovered()) {
240
+ clearKeyboardLayout();
241
+ return;
242
+ }
243
+ const overlap = Math.max(0, geometry.bodyBottom - obstructionTop);
244
+ if (overlap === 0) {
245
+ clearKeyboardLayout();
246
+ return;
247
+ }
248
+
249
+ const inset = Math.max(
250
+ 0,
251
+ geometry.bodyBottom - (obstructionTop - bottomClearance),
252
+ );
253
+ body.style.setProperty(MOBILE_KEYBOARD_INSET_VAR, `${inset}px`);
254
+ };
255
+
256
+ // Take the focus transition over before the browser performs it.
257
+ //
258
+ // `blur` is dispatched in the capture phase ahead of the browser's own
259
+ // focus step, and it names the destination in `relatedTarget`. Focusing it
260
+ // here with preventScroll settles the transition: the browser's step finds
261
+ // the element already active, so it dispatches nothing further and has
262
+ // nothing to reveal.
263
+ const claimFocusTransition = (event: FocusEvent) => {
264
+ if (!isFullyExpandedRef.current) {
265
+ return;
266
+ }
267
+ const destination = findTextEntryControl(event.relatedTarget, body);
268
+ if (destination) {
269
+ if (destination !== document.activeElement) {
270
+ // Revealing the field is not this handler's job: focusing it raises
271
+ // focusin, and the viewport resize that follows the keyboard raises
272
+ // another — both already schedule the reveal below, which knows the
273
+ // safe area and scrolls only the sheet's own body.
274
+ destination.focus({preventScroll: true});
275
+ }
276
+ return;
277
+ }
278
+
279
+ // Focus left for nothing — the keyboard's Done button parks it on the
280
+ // body. Park it on the sheet instead, so re-tapping the same field is
281
+ // still a transition this handler sees. Left on the body, the field is
282
+ // already `document.activeElement` on the next tap, no blur fires, and
283
+ // the browser reveals it its own way.
284
+ //
285
+ // Only while the sheet is open: closing blurs the field too, and there
286
+ // is no next tap to keep claimable — the host restores focus to whatever
287
+ // opened the sheet.
288
+ const origin = findTextEntryControl(event.target, body);
289
+ if (isOpenRef.current && origin && !event.relatedTarget) {
290
+ sheet?.focus({preventScroll: true});
291
+ }
292
+ };
293
+
294
+ const revealFocusedControl = () => {
295
+ const activeElement = document.activeElement;
296
+ if (
297
+ !isFullyExpandedRef.current ||
298
+ !(activeElement instanceof HTMLElement) ||
299
+ !body.contains(activeElement) ||
300
+ !isTextEntryControl(activeElement)
301
+ ) {
302
+ if (retainKeyboardLayoutRef.current && keyboardGeometry) {
303
+ applyKeyboardGeometry(keyboardGeometry);
304
+ return;
305
+ }
306
+ clearKeyboardLayout();
307
+ return;
308
+ }
309
+
310
+ retainKeyboardLayoutRef.current = false;
311
+
312
+ const measuredBodyRect = body.getBoundingClientRect();
313
+ const obstructionTop = getObstructionTop();
314
+ const overlap = Math.max(0, measuredBodyRect.bottom - obstructionTop);
315
+ // The extra clearance leaves room for mobile suggestion UI, but only
316
+ // while the visual viewport actually overlaps the sheet. With no
317
+ // obstruction, ordinary desktop and hardware-keyboard focus must not
318
+ // shift an already visible control.
319
+ const clearance = overlap > 0 ? bottomClearance : 0;
320
+ keyboardGeometry =
321
+ overlap > 0
322
+ ? {
323
+ bodyBottom: measuredBodyRect.bottom,
324
+ }
325
+ : null;
326
+ // Where the document sits with the keyboard up and nothing yet shifted:
327
+ // the position handleDocumentScroll returns to. Captured on the
328
+ // transition only — a reveal that runs after the browser has already
329
+ // scrolled would otherwise record the shifted position as correct.
330
+ if (isPageScrollLocked && overlap > 0 && !hasKeyboardLayoutRef.current) {
331
+ documentScrollAtKeyboard = {x: window.scrollX, y: window.scrollY};
332
+ }
333
+ hasKeyboardLayoutRef.current = overlap > 0;
334
+
335
+ const inset =
336
+ overlap > 0
337
+ ? Math.max(0, measuredBodyRect.bottom - (obstructionTop - clearance))
338
+ : 0;
339
+ body.style.setProperty(MOBILE_KEYBOARD_INSET_VAR, `${inset}px`);
340
+
341
+ scrollControlIntoSafeArea(activeElement, overlap > 0);
342
+ };
343
+
344
+ let animationFrame = 0;
345
+ const scheduleReveal = () => {
346
+ cancelAnimationFrame(animationFrame);
347
+ animationFrame = requestAnimationFrame(revealFocusedControl);
348
+ };
349
+
350
+ // Resuming the app re-reveals the focused field with no focus event to
351
+ // claim, so the reveal above never runs and the document scrolls — taking
352
+ // the fixed sheet with it. Put that scroll back on the event that reports
353
+ // it and re-reveal inside the sheet.
354
+ //
355
+ // Only while the page is locked, which is the only state in which a
356
+ // document scroll cannot be the user's own: behind a non-modal sheet the
357
+ // page stays scrollable, and reverting there would fight them.
358
+ const handleDocumentScroll = () => {
359
+ const expected = documentScrollAtKeyboard;
360
+ if (
361
+ expected == null ||
362
+ (window.scrollX === expected.x && window.scrollY === expected.y)
363
+ ) {
364
+ return;
365
+ }
366
+ window.scrollTo(expected.x, expected.y);
367
+ const activeElement = document.activeElement;
368
+ if (
369
+ activeElement instanceof HTMLElement &&
370
+ body.contains(activeElement) &&
371
+ isTextEntryControl(activeElement)
372
+ ) {
373
+ scrollControlIntoSafeArea(activeElement, false);
374
+ }
375
+ };
376
+
377
+ const resizeObserver =
378
+ typeof ResizeObserver === 'undefined'
379
+ ? null
380
+ : new ResizeObserver(scheduleReveal);
381
+ const refreshObservedLayout = () => {
382
+ if (!resizeObserver) {
383
+ return;
384
+ }
385
+ resizeObserver.disconnect();
386
+
387
+ // Body/sheet box changes include public height and xstyle updates. The
388
+ // generated inset pseudo-element changes only scroll overflow, so these
389
+ // observations do not feed the keyboard inset back into sheet geometry.
390
+ const targets = new Set<Element>([body]);
391
+ if (sheet) {
392
+ targets.add(sheet);
393
+ }
394
+ for (const child of body.children) {
395
+ targets.add(child);
396
+ }
397
+ const activeElement = document.activeElement;
398
+ if (
399
+ activeElement instanceof HTMLElement &&
400
+ body.contains(activeElement)
401
+ ) {
402
+ for (
403
+ let element: HTMLElement | null = activeElement;
404
+ element && element !== body;
405
+ element = element.parentElement
406
+ ) {
407
+ targets.add(element);
408
+ }
409
+ }
410
+ for (const target of targets) {
411
+ resizeObserver.observe(target);
412
+ }
413
+ };
414
+ const mutationObserver =
415
+ typeof MutationObserver === 'undefined'
416
+ ? null
417
+ : new MutationObserver(records => {
418
+ // Ignore the internal inset written on the body. Consumer DOM,
419
+ // text, class, style, or visibility changes can all move the
420
+ // focused control without a viewport event.
421
+ if (
422
+ records.every(
423
+ record =>
424
+ record.type === 'attributes' && record.target === body,
425
+ )
426
+ ) {
427
+ return;
428
+ }
429
+ refreshObservedLayout();
430
+ scheduleReveal();
431
+ });
432
+ const handleFocusIn = () => {
433
+ refreshObservedLayout();
434
+ scheduleReveal();
435
+ };
436
+ const handleFocusOut = () => {
437
+ // Keep the added scroll range while the keyboard animates away. A focus
438
+ // transition to another text-entry control cancels this in focusin.
439
+ retainKeyboardLayoutRef.current = hasKeyboardLayoutRef.current;
440
+ scheduleReveal();
441
+ };
442
+ const handleSheetTransitionEnd = (event: TransitionEvent) => {
443
+ if (event.target === sheet && event.propertyName === 'transform') {
444
+ scheduleReveal();
445
+ }
446
+ };
447
+
448
+ const viewport = window.visualViewport;
449
+ if (ownsFocusTransitions) {
450
+ // `blur` does not bubble, so the capture phase is the only place to hear
451
+ // every one of them — and it runs before the browser's own focus step,
452
+ // which is the only window in which the transition can still be claimed.
453
+ document.addEventListener('blur', claimFocusTransition, true);
454
+ }
455
+ body.addEventListener('focusin', handleFocusIn);
456
+ body.addEventListener('focusout', handleFocusOut);
457
+ sheet?.addEventListener('transitionend', handleSheetTransitionEnd);
458
+ viewport?.addEventListener('resize', scheduleReveal);
459
+ viewport?.addEventListener('scroll', scheduleReveal);
460
+ window.addEventListener('scroll', handleDocumentScroll);
461
+ window.addEventListener('resize', scheduleReveal);
462
+ mutationObserver?.observe(body, {
463
+ attributes: true,
464
+ characterData: true,
465
+ childList: true,
466
+ subtree: true,
467
+ });
468
+ refreshObservedLayout();
469
+ // The dialog enters the top layer in a later effect. Wait until the next
470
+ // frame so flex geometry is final before measuring the body.
471
+ scheduleReveal();
472
+
473
+ return () => {
474
+ cancelAnimationFrame(animationFrame);
475
+ if (ownsFocusTransitions) {
476
+ document.removeEventListener('blur', claimFocusTransition, true);
477
+ }
478
+ body.removeEventListener('focusin', handleFocusIn);
479
+ body.removeEventListener('focusout', handleFocusOut);
480
+ sheet?.removeEventListener('transitionend', handleSheetTransitionEnd);
481
+ viewport?.removeEventListener('resize', scheduleReveal);
482
+ viewport?.removeEventListener('scroll', scheduleReveal);
483
+ window.removeEventListener('scroll', handleDocumentScroll);
484
+ window.removeEventListener('resize', scheduleReveal);
485
+ resizeObserver?.disconnect();
486
+ mutationObserver?.disconnect();
487
+ body.style.removeProperty(MOBILE_KEYBOARD_INSET_VAR);
488
+ hasKeyboardLayoutRef.current = false;
489
+ retainKeyboardLayoutRef.current = false;
490
+ };
491
+ }, [
492
+ bodyRef,
493
+ bottomClearance,
494
+ isEnabled,
495
+ isPageScrollLocked,
496
+ isPresented,
497
+ sheetRef,
498
+ ]);
499
+ }