@astryxdesign/core 0.7.8-canary.ffd29c5 → 0.7.8

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 (189) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/dist/BottomSheet/BottomSheetPanel.d.ts +1 -1
  3. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetPanel.js +30 -14
  5. package/dist/BottomSheet/bottomSheetStack.js +1 -1
  6. package/dist/BottomSheet/useSheetGestures.d.ts +3 -1
  7. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  8. package/dist/BottomSheet/useSheetGestures.js +4 -0
  9. package/dist/CheckboxList/CheckboxList.d.ts +13 -2
  10. package/dist/CheckboxList/CheckboxList.d.ts.map +1 -1
  11. package/dist/CheckboxList/CheckboxList.js +10 -4
  12. package/dist/CheckboxList/CheckboxListContext.d.ts +7 -0
  13. package/dist/CheckboxList/CheckboxListContext.d.ts.map +1 -1
  14. package/dist/CheckboxList/CheckboxListContext.js +2 -1
  15. package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
  16. package/dist/CheckboxList/CheckboxListItem.js +14 -2
  17. package/dist/CheckboxList/requiredGroup.d.ts +22 -0
  18. package/dist/CheckboxList/requiredGroup.d.ts.map +1 -0
  19. package/dist/CheckboxList/requiredGroup.js +52 -0
  20. package/dist/DateInput/PickerWheel.d.ts +1 -1
  21. package/dist/DateInput/PickerWheel.d.ts.map +1 -1
  22. package/dist/DateInput/PickerWheel.js +27 -6
  23. package/dist/DateInput/Wheel.d.ts +2 -2
  24. package/dist/DateInput/Wheel.d.ts.map +1 -1
  25. package/dist/DateInput/Wheel.js +20 -6
  26. package/dist/DateInput/datePickerTouch.d.ts +13 -0
  27. package/dist/DateInput/datePickerTouch.d.ts.map +1 -0
  28. package/dist/DateInput/datePickerTouch.js +14 -0
  29. package/dist/DateInput/wheelOptions.d.ts +22 -2
  30. package/dist/DateInput/wheelOptions.d.ts.map +1 -1
  31. package/dist/DateInput/wheelOptions.js +14 -2
  32. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  33. package/dist/DateTimeInput/TouchDateTimeField.js +62 -119
  34. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  35. package/dist/HoverCard/useHoverCard.js +6 -1
  36. package/dist/Overlay/Overlay.d.ts.map +1 -1
  37. package/dist/Overlay/Overlay.js +7 -2
  38. package/dist/PowerSearch/PowerSearch.d.ts +4 -2
  39. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  40. package/dist/PowerSearch/PowerSearchBottomSheet.d.ts +24 -1
  41. package/dist/PowerSearch/PowerSearchBottomSheet.d.ts.map +1 -1
  42. package/dist/PowerSearch/PowerSearchBottomSheet.js +172 -50
  43. package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
  44. package/dist/Resizable/ResizeHandle.js +15 -15
  45. package/dist/Resizable/createResizable.d.ts +70 -16
  46. package/dist/Resizable/createResizable.d.ts.map +1 -1
  47. package/dist/Resizable/createResizable.js +180 -22
  48. package/dist/Resizable/separatorAttributes.d.ts +45 -0
  49. package/dist/Resizable/separatorAttributes.d.ts.map +1 -0
  50. package/dist/Resizable/separatorAttributes.js +35 -0
  51. package/dist/Resizable/useResizable.js +2 -2
  52. package/dist/TimeInput/TimeWheels.d.ts +32 -0
  53. package/dist/TimeInput/TimeWheels.d.ts.map +1 -0
  54. package/dist/TimeInput/TimeWheels.js +144 -0
  55. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  56. package/dist/TimeInput/TouchTimeField.js +63 -118
  57. package/dist/TimeInput/timeWheelRules.d.ts +124 -0
  58. package/dist/TimeInput/timeWheelRules.d.ts.map +1 -0
  59. package/dist/TimeInput/timeWheelRules.js +277 -0
  60. package/dist/Tokenizer/TokenizerBottomSheet.js +8 -12
  61. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  62. package/dist/Tooltip/useTooltip.js +6 -0
  63. package/dist/astryx.css +3 -1
  64. package/dist/gestures/GestureHost.d.ts.map +1 -1
  65. package/dist/gestures/GestureHost.js +3 -0
  66. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.d.ts.map +1 -1
  67. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.js +5 -2
  68. package/dist/i18n/generated-english/Resizable/createResizable.generated.d.ts +8 -0
  69. package/dist/i18n/generated-english/Resizable/createResizable.generated.d.ts.map +1 -0
  70. package/dist/i18n/generated-english/Resizable/createResizable.generated.js +13 -0
  71. package/dist/i18n/generated-english/TimeInput/TimeWheels.generated.d.ts +8 -0
  72. package/dist/i18n/generated-english/TimeInput/TimeWheels.generated.d.ts.map +1 -0
  73. package/dist/i18n/generated-english/TimeInput/TimeWheels.generated.js +17 -0
  74. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  75. package/dist/i18n/generated-locales/en.generated.js +5 -2
  76. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  77. package/dist/i18n/generated-locales/pseudo.generated.js +5 -2
  78. package/dist/keyboard/index.d.ts +17 -0
  79. package/dist/keyboard/index.d.ts.map +1 -0
  80. package/dist/keyboard/index.js +19 -0
  81. package/dist/keyboard/keyboardHeight.d.ts +149 -0
  82. package/dist/keyboard/keyboardHeight.d.ts.map +1 -0
  83. package/dist/keyboard/keyboardHeight.js +432 -0
  84. package/dist/keyboard/keyboardLayoutGuide.d.ts +59 -0
  85. package/dist/keyboard/keyboardLayoutGuide.d.ts.map +1 -0
  86. package/dist/keyboard/keyboardLayoutGuide.js +207 -0
  87. package/dist/keyboard/keyboardModel.d.ts +93 -0
  88. package/dist/keyboard/keyboardModel.d.ts.map +1 -0
  89. package/dist/keyboard/keyboardModel.js +220 -0
  90. package/dist/keyboard/rideKeyboard.d.ts +42 -0
  91. package/dist/keyboard/rideKeyboard.d.ts.map +1 -0
  92. package/dist/keyboard/rideKeyboard.js +77 -0
  93. package/dist/keyboard/useMobileKeyboard.d.ts +61 -0
  94. package/dist/keyboard/useMobileKeyboard.d.ts.map +1 -0
  95. package/dist/keyboard/useMobileKeyboard.js +699 -0
  96. package/dist/motion/android/index.d.ts +16 -0
  97. package/dist/motion/android/index.d.ts.map +1 -0
  98. package/dist/motion/android/index.js +18 -0
  99. package/dist/motion/android/keyboard.d.ts +83 -0
  100. package/dist/motion/android/keyboard.d.ts.map +1 -0
  101. package/dist/motion/android/keyboard.js +96 -0
  102. package/dist/motion/uikit/datePicker.d.ts +58 -0
  103. package/dist/motion/uikit/datePicker.d.ts.map +1 -0
  104. package/dist/motion/uikit/datePicker.js +60 -0
  105. package/dist/motion/uikit/index.d.ts +4 -3
  106. package/dist/motion/uikit/index.d.ts.map +1 -1
  107. package/dist/motion/uikit/index.js +4 -3
  108. package/dist/utils/interactionOverlay.stylex.d.ts +3 -2
  109. package/dist/utils/interactionOverlay.stylex.d.ts.map +1 -1
  110. package/dist/utils/interactionOverlay.stylex.js +3 -2
  111. package/dist/utils/layoutViewport.d.ts +25 -0
  112. package/dist/utils/layoutViewport.d.ts.map +1 -0
  113. package/dist/utils/layoutViewport.js +102 -0
  114. package/dist/utils/pressFeedback.d.ts.map +1 -1
  115. package/dist/utils/pressFeedback.js +74 -9
  116. package/dist/utils/pressGesture.d.ts +4 -2
  117. package/dist/utils/pressGesture.d.ts.map +1 -1
  118. package/dist/utils/pressGesture.js +3 -1
  119. package/dist/utils/selectionLock.d.ts +10 -1
  120. package/dist/utils/selectionLock.d.ts.map +1 -1
  121. package/dist/utils/selectionLock.js +31 -5
  122. package/dist/utils/sharedMutationObserver.d.ts +2 -1
  123. package/dist/utils/sharedMutationObserver.d.ts.map +1 -1
  124. package/dist/utils/sharedMutationObserver.js +2 -1
  125. package/dist/utils/timeParser.d.ts +2 -1
  126. package/dist/utils/timeParser.d.ts.map +1 -1
  127. package/dist/utils/timeParser.js +29 -3
  128. package/locales/en.json +21 -9
  129. package/locales/pseudo.json +15 -6
  130. package/package.json +5 -5
  131. package/src/BottomSheet/BottomSheet.doc.mjs +1 -1
  132. package/src/BottomSheet/BottomSheetPanel.tsx +41 -13
  133. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +5 -0
  134. package/src/BottomSheet/bottomSheetStack.ts +1 -1
  135. package/src/BottomSheet/useSheetGestures.ts +6 -0
  136. package/src/CheckboxList/CheckboxList.doc.mjs +7 -0
  137. package/src/CheckboxList/CheckboxList.tsx +34 -2
  138. package/src/CheckboxList/CheckboxListContext.tsx +9 -1
  139. package/src/CheckboxList/CheckboxListItem.tsx +23 -2
  140. package/src/CheckboxList/requiredGroup.ts +77 -0
  141. package/src/DateInput/PickerWheel.tsx +37 -4
  142. package/src/DateInput/Wheel.tsx +30 -3
  143. package/src/DateInput/datePickerTouch.ts +15 -0
  144. package/src/DateInput/wheelOptions.ts +31 -2
  145. package/src/DateTimeInput/TouchDateTimeField.tsx +94 -178
  146. package/src/HoverCard/useHoverCard.tsx +6 -1
  147. package/src/Overlay/Overlay.tsx +7 -2
  148. package/src/PowerSearch/PowerSearch.doc.mjs +2 -2
  149. package/src/PowerSearch/PowerSearch.tsx +4 -2
  150. package/src/PowerSearch/PowerSearchBottomSheet.tsx +237 -62
  151. package/src/Resizable/ResizeHandle.tsx +18 -26
  152. package/src/Resizable/createResizable.ts +242 -27
  153. package/src/Resizable/separatorAttributes.ts +73 -0
  154. package/src/Resizable/useResizable.ts +2 -2
  155. package/src/TimeInput/TimeWheels.tsx +218 -0
  156. package/src/TimeInput/TouchTimeField.tsx +93 -157
  157. package/src/TimeInput/timeWheelRules.ts +352 -0
  158. package/src/Tokenizer/TokenizerBottomSheet.tsx +8 -12
  159. package/src/Tooltip/useTooltip.tsx +6 -0
  160. package/src/gestures/GestureHost.ts +5 -4
  161. package/src/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.ts +5 -2
  162. package/src/i18n/generated-english/Resizable/createResizable.generated.ts +14 -0
  163. package/src/i18n/generated-english/TimeInput/TimeWheels.generated.ts +18 -0
  164. package/src/i18n/generated-locales/en.generated.ts +6 -4
  165. package/src/i18n/generated-locales/pseudo.generated.ts +6 -4
  166. package/src/keyboard/README.md +197 -0
  167. package/src/keyboard/index.ts +48 -0
  168. package/src/keyboard/keyboardHeight.ts +543 -0
  169. package/src/keyboard/keyboardLayoutGuide.ts +266 -0
  170. package/src/keyboard/keyboardModel.ts +301 -0
  171. package/src/keyboard/rideKeyboard.ts +100 -0
  172. package/src/keyboard/useMobileKeyboard.ts +866 -0
  173. package/src/motion/MEASUREMENTS.md +10 -0
  174. package/src/motion/README.md +2 -0
  175. package/src/motion/android/index.ts +18 -0
  176. package/src/motion/android/keyboard.ts +96 -0
  177. package/src/motion/uikit/datePicker.ts +60 -0
  178. package/src/motion/uikit/index.ts +4 -3
  179. package/src/utils/interactionOverlay.stylex.ts +3 -2
  180. package/src/utils/layoutViewport.ts +104 -0
  181. package/src/utils/pressFeedback.ts +99 -21
  182. package/src/utils/pressGesture.ts +4 -2
  183. package/src/utils/selectionLock.ts +40 -6
  184. package/src/utils/sharedMutationObserver.ts +2 -1
  185. package/src/utils/timeParser.ts +40 -3
  186. package/dist/BottomSheet/useMobileKeyboard.d.ts +0 -21
  187. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +0 -1
  188. package/dist/BottomSheet/useMobileKeyboard.js +0 -413
  189. package/src/BottomSheet/useMobileKeyboard.ts +0 -542
@@ -0,0 +1,699 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useMobileKeyboard.ts
7
+ * @input A target element and its scroll area, the keyboard module's reports
8
+ * and focus claim, and the layout guide
9
+ * @output useMobileKeyboard, which keeps a focused field in the target above
10
+ * the on-screen keyboard and returns the inset and a dismiss
11
+ * @position Keyboard module, internal to core. BottomSheet passes its panel.
12
+ *
13
+ * SYNC: When modified, update /packages/core/src/keyboard/README.md
14
+ */
15
+ import { useCallback, useEffect, useRef, useState } from 'react';
16
+ import { flushSync } from 'react-dom';
17
+ import { useAttachment } from "../hooks/useAttachment.js";
18
+ import { useMediaQuery } from "../hooks/useMediaQuery.js";
19
+ import { animatorFor } from "../motion/animator.js";
20
+ import { subscribeInput } from "../utils/inputStream.js";
21
+ import { layoutViewportHeight } from "../utils/layoutViewport.js";
22
+ import { observeResize } from "../utils/sharedResizeObserver.js";
23
+ import { TOUCH_QUERY } from "../utils/touchPresentation.js";
24
+ import { KEYBOARD_EPSILON, findTextEntryControl, getKeyboardHeight, isKeyboardMeasurable, isNodeTarget, isTextEntryControl, refuseFocusReveal, subscribeKeyboardHeight, watchRevealPan } from "./keyboardHeight.js";
25
+ import { getKeyboardLayoutGuide, keyboardProfileOf } from "./keyboardLayoutGuide.js";
26
+ import { rideKeyboard } from "./rideKeyboard.js";
27
+
28
+ // Chrome on Android pans the page itself if the keyboard still covers the
29
+ // focused field about 100 to 170 ms after the report. A field a fraction of a
30
+ // pixel under the keyboard's top still counts as covered. So the reveal sets
31
+ // the field 4 px above that top in the report's own commit, and only the rest
32
+ // rides.
33
+ const FIELD_CLEARANCE_AT_REPORT = 4;
34
+
35
+ /** How a target keeps a focused field above the on-screen keyboard. */
36
+
37
+ /** What useMobileKeyboard returns. */
38
+
39
+ function getObstructionTop() {
40
+ return layoutViewportHeight(window) - getKeyboardHeight(window);
41
+ }
42
+
43
+ // Windows whose browser revealed a focused field itself right after the
44
+ // keyboard's report, by panning the visual viewport to it. Firefox for
45
+ // Android does so 54 to 143 ms after the report while the field is under the
46
+ // keyboard. The module learns this from the behavior, never from the user
47
+ // agent.
48
+ // From then on, a sheet there reveals its field at once, in the report's own
49
+ // frame.
50
+ let revealsAtReport = new WeakSet();
51
+
52
+ /** Forgets every window's recorded reveal, for tests. */
53
+ export function __resetRevealsAtReportForTest() {
54
+ revealsAtReport = new WeakSet();
55
+ }
56
+
57
+ // How far the body runs under the keyboard's top, CSS px. The keyboard's top
58
+ // comes from `innerHeight`, a whole number of CSS px, but the visual viewport
59
+ // and element rects are fractional. On the Android emulator, Chrome's visual
60
+ // viewport is 754.3 px over a 754 px layout viewport. A sheet sized to it
61
+ // then ends 0.3 px below `innerHeight` with no keyboard up. So an overlap
62
+ // under one CSS px is `innerHeight`'s precision, not an obstruction.
63
+ function overlapOf(bodyBottom, obstructionTop) {
64
+ const overlap = bodyBottom - obstructionTop;
65
+ return overlap > KEYBOARD_EPSILON ? overlap : 0;
66
+ }
67
+
68
+ // Under half a pixel is rounding, not a keyboard.
69
+ function isVisualViewportRecovered() {
70
+ return getKeyboardHeight(window) <= 0.5;
71
+ }
72
+
73
+ /**
74
+ * Keeps a focused field in `targetRef`'s element above the on-screen
75
+ * keyboard (see README.md). A sheet passes its panel, and the hook reserves
76
+ * the inset in the panel's scroll area. Returns the inset the keyboard takes
77
+ * at the end of that area's content, CSS px, or 0 without a keyboard, and a
78
+ * dismiss.
79
+ *
80
+ * @example
81
+ * ```
82
+ * const keyboard = useMobileKeyboard(panelRef, {move: 'ride', inset: true, offset: 48, isEnabled, isExpanded, isOpen, isPageScrollLocked});
83
+ * ```
84
+ */
85
+ export function useMobileKeyboard(targetRef, {
86
+ inset: reservesInset = false,
87
+ offset,
88
+ isEnabled,
89
+ isExpanded,
90
+ isOpen,
91
+ isPageScrollLocked
92
+ }) {
93
+ // The inset the keyboard takes at the end of the body's content, CSS px.
94
+ // The target lays it out after its content box's children, through a
95
+ // functional style.
96
+ const [keyboardInset, setKeyboardInset] = useState(0);
97
+ const hasKeyboardLayoutRef = useRef(false);
98
+ const retainKeyboardLayoutRef = useRef(false);
99
+ const isEnabledRef = useRef(isEnabled);
100
+ const isExpandedRef = useRef(isExpanded);
101
+ const isOpenRef = useRef(isOpen);
102
+ isEnabledRef.current = isEnabled;
103
+ isExpandedRef.current = isExpanded;
104
+ isOpenRef.current = isOpen;
105
+ // Touch-only work, the ride and the reveal-pan watch, attaches only under a
106
+ // coarse primary pointer (the touch query). Under a fine one, the hook
107
+ // registers no touch listener.
108
+ const isTouch = useMediaQuery(TOUCH_QUERY, false);
109
+ const dismiss = useCallback(focusTarget => {
110
+ const target = targetRef.current;
111
+ const activeElement = document.activeElement;
112
+ if (!isEnabledRef.current || target == null || !(activeElement instanceof HTMLElement) || activeElement === target || !target.contains(activeElement)) {
113
+ return;
114
+ }
115
+ // Keep the current keyboard scroll range while the keyboard goes.
116
+ // Viewport resize events unwind that layout as the visual viewport
117
+ // recovers, avoiding a content jump on the first drag frame.
118
+ retainKeyboardLayoutRef.current = hasKeyboardLayoutRef.current;
119
+ if (focusTarget == null) {
120
+ activeElement.blur();
121
+ } else {
122
+ focusTarget.focus({
123
+ preventScroll: true
124
+ });
125
+ }
126
+ }, [targetRef]);
127
+
128
+ // Syncs the on-screen keyboard with closing: a target that closes blurs its
129
+ // focused field, which dismisses the keyboard.
130
+ useEffect(() => {
131
+ if (isEnabled && !isOpen) {
132
+ dismiss();
133
+ }
134
+ }, [dismiss, isEnabled, isOpen]);
135
+ useAttachment(targetRef, sheet => {
136
+ const body = sheet.querySelector('[data-scroll-axis]');
137
+ if (!isEnabled || !reservesInset || body == null) {
138
+ return () => {};
139
+ }
140
+ let keyboardGeometry = null;
141
+ // Where a smooth reveal under way is taking the body's scrollTop.
142
+ let smoothTarget = null;
143
+ // The inset laid out at the end of the body's content, CSS px, and the
144
+ // scroll range it adds there. The inset's drop takes that range back. The
145
+ // two can differ: Chrome 124 added 344 px of range for a 360 px inset.
146
+ let laidOutInset = 0;
147
+ let insetRange = 0;
148
+ // Where the keyboard layout guide publishes the keyboard's own slide, as
149
+ // Gboard's in Chrome on Android, the sheet's content rides it. This needs
150
+ // a coarse primary pointer and Chrome's schedule. Under a fine pointer
151
+ // nothing of the ride attaches. In a browser without the VirtualKeyboard
152
+ // API, such as iOS Safari or Firefox for Android, the guide never starts.
153
+ // There the inset follows the reports. The hook subscribes to the guide
154
+ // before the keyboard module below, so the guide publishes a report's
155
+ // transition before the reveal reads it.
156
+ const guide = isTouch && keyboardProfileOf(window) === 'chromium' ? getKeyboardLayoutGuide(window) : null;
157
+ const contentAnimator = () => {
158
+ const content = body.firstElementChild;
159
+ return guide != null && content instanceof HTMLElement ? animatorFor(content) : null;
160
+ };
161
+ // How far a ride has moved the content from where its layout puts it, as
162
+ // the page draws it now. The reveal reads a control's rect in the same
163
+ // frame.
164
+ const rideOffset = () => {
165
+ const content = body.firstElementChild;
166
+ if (!(content instanceof HTMLElement)) {
167
+ return 0;
168
+ }
169
+ const translate = getComputedStyle(content).translate;
170
+ return parseFloat(translate.split(' ')[1] ?? '') || 0;
171
+ };
172
+ // The keyboard's show the guide plays on its slide right now, if any. At
173
+ // a report, a show whose slide has ended counts too. A busy page can read
174
+ // the report that late, and the ride then lands at once.
175
+ const showOnSlide = atReport => {
176
+ const transition = guide?.transition;
177
+ return transition != null && transition.kind === 'show' && transition.animated && transition.curve != null && (atReport || performance.now() < transition.start + transition.curve.durationMs) ? transition : null;
178
+ };
179
+ // A hide the hook holds the inset through (see holdInsetThrough).
180
+ let heldHide = null;
181
+ let documentScrollAtKeyboard = null;
182
+ // The keyboard's top and the body's scroll when the keyboard came up. A
183
+ // keyboard that closes puts the body back at that scroll.
184
+ let keyboardObstructionTop = null;
185
+ let bodyScrollTopAtKeyboard = null;
186
+ // The hook takes focus moves over where this window measures the
187
+ // keyboard, never by user agent, and only under a coarse primary pointer.
188
+ // Under a fine one, it registers no touch-only listener.
189
+ // Desktop Safari and Firefox measure too, but show no on-screen keyboard
190
+ // and keep the browser's own focus. The effect runs again when the
191
+ // pointer changes, so a tablet that docks a mouse drops the claim.
192
+ const ownsFocusTransitions = isTouch && isKeyboardMeasurable(window);
193
+
194
+ // Lays the inset out before anything measures or scrolls the body: the
195
+ // reveal scrolls into the inset it adds in the same frame.
196
+ const writeInset = inset => {
197
+ const changes = inset !== laidOutInset;
198
+ const rangeBefore = changes ? body.scrollHeight : 0;
199
+ laidOutInset = inset;
200
+ // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- the reveal scrolls into this inset in the same frame, so it must be laid out before the reveal measures
201
+ flushSync(() => setKeyboardInset(inset));
202
+ if (changes) {
203
+ insetRange = inset === 0 ? 0 : Math.max(0, insetRange + body.scrollHeight - rangeBefore);
204
+ }
205
+ };
206
+ const clearKeyboardLayout = () => {
207
+ writeInset(0);
208
+ if (bodyScrollTopAtKeyboard != null) {
209
+ smoothTarget = null;
210
+ body.scrollTop = bodyScrollTopAtKeyboard;
211
+ }
212
+ keyboardGeometry = null;
213
+ keyboardObstructionTop = null;
214
+ bodyScrollTopAtKeyboard = null;
215
+ documentScrollAtKeyboard = null;
216
+ hasKeyboardLayoutRef.current = false;
217
+ retainKeyboardLayoutRef.current = false;
218
+ };
219
+
220
+ // `within` is how far below its resting place the control can sit and
221
+ // still clear the keyboard.
222
+ const scrollBodyBy = (distance, smoothly, ride = null, within = null) => {
223
+ smoothTarget = null;
224
+ const animator = ride == null ? null : contentAnimator();
225
+ if (ride != null && animator != null) {
226
+ // The body lands at once, and its content rides the keyboard's slide
227
+ // up from the slide's start. The content starts no lower than
228
+ // `within`, so the control clears the keyboard in this commit. Content
229
+ // that joins the slide late is where the keyboard is (rideKeyboard). A
230
+ // further scroll during the slide adds to the same ride.
231
+ const before = body.scrollTop;
232
+ body.scrollTop = before + distance;
233
+ const moved = body.scrollTop - before;
234
+ // Only what the body actually scrolled rides. A remainder under the
235
+ // scroll position's precision moves nothing, so the ride under way
236
+ // stays on its curve.
237
+ if (moved !== 0) {
238
+ const from = animator.get('y', ride.start) + moved;
239
+ animator.set({
240
+ y: within == null ? from : Math.min(from, within)
241
+ });
242
+ rideKeyboard(animator, {
243
+ y: 0
244
+ }, ride, {
245
+ entrance: within != null
246
+ });
247
+ }
248
+ return;
249
+ }
250
+ if (typeof body.scrollBy !== 'function') {
251
+ body.scrollTop += distance;
252
+ return;
253
+ }
254
+ // Reduce Motion applies on any pointer.
255
+ const reduceMotion = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
256
+ const behavior = smoothly && !reduceMotion ? 'smooth' : 'instant';
257
+ if (behavior === 'smooth') {
258
+ smoothTarget = body.scrollTop + distance;
259
+ }
260
+ body.scrollBy({
261
+ top: distance,
262
+ // Not 'auto', which defers to the element's computed
263
+ // `scroll-behavior`. A consumer's `scroll-behavior: smooth` would then
264
+ // animate a scroll this hook needs to land in the same frame.
265
+ behavior
266
+ });
267
+ };
268
+
269
+ // The distance a smooth reveal under way has still to scroll, as far as
270
+ // the body can scroll. It is 0 once it has landed, or with none under way.
271
+ const smoothDistanceLeft = () => {
272
+ if (smoothTarget == null) {
273
+ return 0;
274
+ }
275
+ const maxScroll = Math.max(body.scrollTop, body.scrollHeight - body.clientHeight);
276
+ const target = Math.min(smoothTarget, maxScroll);
277
+ const left = target - body.scrollTop;
278
+ return Math.abs(left) < 1 ? 0 : left;
279
+ };
280
+
281
+ // Scroll `control` into the part of the body the keyboard leaves visible,
282
+ // reading live geometry each time.
283
+ const scrollControlIntoSafeArea = (control, smoothly, ride = null) => {
284
+ const measuredBodyRect = body.getBoundingClientRect();
285
+ const measuredControlRect = control.getBoundingClientRect();
286
+ const obstructionTop = getObstructionTop();
287
+ // Under a pixel of overhang is `innerHeight`'s precision, not a
288
+ // keyboard: no suggestion clearance then (overlapOf).
289
+ const overlap = overlapOf(measuredBodyRect.bottom, obstructionTop);
290
+ const clearance = overlap > 0 ? offset : 0;
291
+ const safeTop = measuredBodyRect.top;
292
+ const safeBottom = Math.min(measuredBodyRect.bottom, obstructionTop - clearance);
293
+ if (safeBottom <= safeTop) {
294
+ return;
295
+ }
296
+
297
+ // Where the control will be once a smooth reveal under way lands. If
298
+ // that lands the control, a second scroll would overshoot in Firefox.
299
+ // Firefox keeps adding a smooth scrollBy's distance after another
300
+ // scroll. A ride's translate is not where the layout puts the control
301
+ // either.
302
+ const pending = smoothDistanceLeft() + rideOffset();
303
+ const controlBottom = measuredControlRect.bottom - pending;
304
+ const controlTop = measuredControlRect.top - pending;
305
+ if (controlBottom > safeBottom) {
306
+ scrollBodyBy(controlBottom - safeBottom, smoothly, ride, Math.max(0, obstructionTop - safeBottom - FIELD_CLEARANCE_AT_REPORT));
307
+ } else if (controlTop < safeTop) {
308
+ scrollBodyBy(controlTop - safeTop, smoothly, ride);
309
+ }
310
+ };
311
+ const applyKeyboardGeometry = geometry => {
312
+ const obstructionTop = getObstructionTop();
313
+ // A collapsed detent can extend the body below the layout viewport even
314
+ // after the keyboard closes, so body overlap alone cannot identify
315
+ // recovery. Once the visual viewport is full height again, release the
316
+ // retained keyboard layout unconditionally.
317
+ if (isVisualViewportRecovered()) {
318
+ clearKeyboardLayout();
319
+ return;
320
+ }
321
+ const overlap = overlapOf(geometry.bodyBottom, obstructionTop);
322
+ if (overlap === 0) {
323
+ clearKeyboardLayout();
324
+ return;
325
+ }
326
+ const inset = Math.max(0, geometry.bodyBottom - (obstructionTop - offset));
327
+ writeInset(inset);
328
+ };
329
+
330
+ // Take every focus move into a field over before the browser performs it
331
+ // (refuseFocusReveal). That handler does not reveal the field. Focusing
332
+ // it raises focusin, and the viewport report that follows the keyboard
333
+ // raises another. Both schedule the reveal below, which knows the safe
334
+ // area and scrolls only the sheet's own body.
335
+ //
336
+ // The keyboard's Done button parks focus on the body. The handler parks
337
+ // focus that leaves for nothing on the sheet instead. Then a second tap on
338
+ // the same field is still a move the handler sees. Left on the body, the field
339
+ // is already `document.activeElement` on the next tap. Then no blur fires,
340
+ // and the browser reveals the field its own way. This applies only while
341
+ // the sheet is open. Closing blurs the field too, and no next tap needs a
342
+ // claim. The host restores focus to whatever opened the sheet.
343
+ const claimFocusTransitions = () => refuseFocusReveal(document, {
344
+ isActive: () => isExpandedRef.current,
345
+ claim: destination => findTextEntryControl(destination, body),
346
+ park: origin => {
347
+ if (isOpenRef.current && findTextEntryControl(origin, body)) {
348
+ sheet.focus({
349
+ preventScroll: true
350
+ });
351
+ }
352
+ }
353
+ });
354
+ const revealFocusedControl = (atReport = false) => {
355
+ // Through a hide the sheet rides, its layout holds until the end.
356
+ if (heldHide != null) {
357
+ return;
358
+ }
359
+ const obstructionTop = getObstructionTop();
360
+ const activeElement = document.activeElement;
361
+ const focusedControl = activeElement instanceof HTMLElement && body.contains(activeElement) && isTextEntryControl(activeElement) ? activeElement : null;
362
+ // The keyboard can close while its field keeps focus, as a downward
363
+ // dismissal does on iOS. The full clear puts back the inset and the
364
+ // body's scroll. The hook then reveals the field still focused in the
365
+ // restored body, because it may not be the field that opened the
366
+ // keyboard.
367
+ if (hasKeyboardLayoutRef.current && isVisualViewportRecovered()) {
368
+ clearKeyboardLayout();
369
+ if (isExpandedRef.current && focusedControl != null) {
370
+ scrollControlIntoSafeArea(focusedControl, false);
371
+ }
372
+ return;
373
+ }
374
+ if (!isExpandedRef.current || focusedControl == null) {
375
+ if (retainKeyboardLayoutRef.current && keyboardGeometry) {
376
+ applyKeyboardGeometry(keyboardGeometry);
377
+ return;
378
+ }
379
+ clearKeyboardLayout();
380
+ return;
381
+ }
382
+
383
+ // An iOS keyboard dismissed by a drag can leave its accessory bar, so
384
+ // the visual viewport grows short of full height. The body goes back
385
+ // to its scroll from before the keyboard as soon as that starts. The
386
+ // reveal below still clears what covers a low field. The new top is
387
+ // kept, so a later content change does not repeat this.
388
+ if (hasKeyboardLayoutRef.current && keyboardObstructionTop != null && obstructionTop > keyboardObstructionTop + 0.5 && bodyScrollTopAtKeyboard != null) {
389
+ smoothTarget = null;
390
+ body.scrollTop = bodyScrollTopAtKeyboard;
391
+ keyboardObstructionTop = obstructionTop;
392
+ }
393
+ retainKeyboardLayoutRef.current = false;
394
+ const measuredBodyRect = body.getBoundingClientRect();
395
+ const overlap = overlapOf(measuredBodyRect.bottom, obstructionTop);
396
+ // The extra clearance leaves space for mobile suggestion UI, but only
397
+ // while the visual viewport actually overlaps the sheet. With no
398
+ // obstruction, ordinary desktop and hardware-keyboard focus must not
399
+ // shift an already visible control.
400
+ const clearance = overlap > 0 ? offset : 0;
401
+ keyboardGeometry = overlap > 0 ? {
402
+ bodyBottom: measuredBodyRect.bottom
403
+ } : null;
404
+ // Where the document sits with the keyboard up and nothing yet shifted:
405
+ // the position handleDocumentScroll returns to. Captured on the
406
+ // transition only — a reveal that runs after the browser has already
407
+ // scrolled would otherwise record the shifted position as correct.
408
+ if (overlap > 0 && !hasKeyboardLayoutRef.current) {
409
+ keyboardObstructionTop = obstructionTop;
410
+ bodyScrollTopAtKeyboard = body.scrollTop;
411
+ if (isPageScrollLocked) {
412
+ documentScrollAtKeyboard = {
413
+ x: window.scrollX,
414
+ y: window.scrollY
415
+ };
416
+ }
417
+ }
418
+ hasKeyboardLayoutRef.current = overlap > 0;
419
+ const inset = overlap > 0 ? Math.max(0, measuredBodyRect.bottom - (obstructionTop - clearance)) : 0;
420
+ writeInset(inset);
421
+ scrollControlIntoSafeArea(focusedControl, overlap > 0 && !revealsAtReport.has(window), overlap > 0 ? showOnSlide(atReport) : null);
422
+ };
423
+
424
+ // Where the body's scroll rests once the keyboard is gone, as
425
+ // revealFocusedControl leaves it. The clear puts back the scroll from
426
+ // before the keyboard, within the range left once the inset drops. The
427
+ // reveal then brings a field that keeps focus into the body's view.
428
+ const restingScrollTop = () => {
429
+ const scrollTop = body.scrollTop;
430
+ const maxAtRest = Math.max(0, body.scrollHeight - insetRange - body.clientHeight);
431
+ let resting = Math.min(maxAtRest, bodyScrollTopAtKeyboard ?? scrollTop);
432
+ const active = document.activeElement;
433
+ if (isExpandedRef.current && active instanceof HTMLElement && body.contains(active) && isTextEntryControl(active)) {
434
+ const bodyRect = body.getBoundingClientRect();
435
+ const rect = active.getBoundingClientRect();
436
+ // Where the layout puts the control at the resting scroll.
437
+ const shift = scrollTop - resting - rideOffset();
438
+ if (rect.bottom + shift > bodyRect.bottom) {
439
+ resting += rect.bottom + shift - bodyRect.bottom;
440
+ } else if (rect.top + shift < bodyRect.top) {
441
+ resting += rect.top + shift - bodyRect.top;
442
+ }
443
+ resting = Math.min(maxAtRest, Math.max(0, resting));
444
+ }
445
+ return resting;
446
+ };
447
+
448
+ // Through a hide on the keyboard's slide, the inset stays, so nothing
449
+ // drops while the keyboard still covers the sheet's bottom. The content
450
+ // rides down on the hide's curve by the scroll the body drops at rest. At
451
+ // rest, the inset drops, the scroll goes back, and the translate clears in
452
+ // one commit.
453
+ const holdInsetThrough = transition => {
454
+ heldHide?.release();
455
+ const animator = contentAnimator();
456
+ const drop = body.scrollTop - restingScrollTop();
457
+ const handle = animator != null && drop !== 0 ? rideKeyboard(animator, {
458
+ y: drop
459
+ }, transition) : null;
460
+ let timer;
461
+ const hold = {
462
+ release: () => {
463
+ clearTimeout(timer);
464
+ if (heldHide === hold) {
465
+ heldHide = null;
466
+ }
467
+ }
468
+ };
469
+ heldHide = hold;
470
+ const commit = () => {
471
+ if (heldHide !== hold) {
472
+ return;
473
+ }
474
+ heldHide = null;
475
+ revealFocusedControl();
476
+ animator?.set({
477
+ y: 0
478
+ });
479
+ };
480
+ if (handle == null) {
481
+ const end = transition.start + (transition.curve?.durationMs ?? 0);
482
+ timer = setTimeout(commit, Math.max(0, end - performance.now()));
483
+ } else {
484
+ void handle.finished.then(commit);
485
+ }
486
+ };
487
+ const unsubscribeGuide = guide?.subscribe(transition => {
488
+ // A jump lands with the reports: only a slide rides.
489
+ if (transition.curve == null) {
490
+ return;
491
+ }
492
+ if (transition.kind === 'hide') {
493
+ if (hasKeyboardLayoutRef.current && transition.animated) {
494
+ holdInsetThrough(transition);
495
+ }
496
+ return;
497
+ }
498
+ if (heldHide != null) {
499
+ // The keyboard comes back before it is down (focus returns): the
500
+ // inset never left, so the content rides back to its place with it.
501
+ heldHide.release();
502
+ const animator = contentAnimator();
503
+ if (animator != null) {
504
+ rideKeyboard(animator, {
505
+ y: 0
506
+ }, transition);
507
+ }
508
+ }
509
+ });
510
+ let animationFrame = 0;
511
+ const scheduleReveal = () => {
512
+ cancelAnimationFrame(animationFrame);
513
+ animationFrame = requestAnimationFrame(() => revealFocusedControl());
514
+ };
515
+
516
+ // Resuming the app re-reveals the focused field with no focus event to
517
+ // claim. The reveal above never runs, and the document scrolls with the
518
+ // fixed sheet. Put that scroll back on the event that reports it, and
519
+ // reveal again inside the sheet.
520
+ //
521
+ // This applies only while the page is locked. Only then can a document
522
+ // scroll not be the user's own. Behind a non-modal sheet the page stays
523
+ // scrollable, and a revert there would fight the user.
524
+ const handleDocumentScroll = () => {
525
+ const expected = documentScrollAtKeyboard;
526
+ if (expected == null || window.scrollX === expected.x && window.scrollY === expected.y) {
527
+ return;
528
+ }
529
+ window.scrollTo(expected.x, expected.y);
530
+ const activeElement = document.activeElement;
531
+ if (activeElement instanceof HTMLElement && body.contains(activeElement) && isTextEntryControl(activeElement)) {
532
+ scrollControlIntoSafeArea(activeElement, false);
533
+ }
534
+ };
535
+ let stopObserving = [];
536
+ const refreshObservedLayout = () => {
537
+ if (typeof ResizeObserver === 'undefined') {
538
+ return;
539
+ }
540
+ for (const stop of stopObserving) {
541
+ stop();
542
+ }
543
+ stopObserving = [];
544
+
545
+ // Body and sheet box changes include public height and xstyle updates.
546
+ // The inset pseudo-element is the content box's last box. Where the
547
+ // content grows with it, the observation runs the reveal again. The
548
+ // reveal computes the same inset from the body's unchanged box, so
549
+ // nothing feeds back into the sheet's geometry.
550
+ const targets = new Set([body, sheet]);
551
+ for (const child of body.children) {
552
+ targets.add(child);
553
+ }
554
+ const activeElement = document.activeElement;
555
+ if (activeElement instanceof HTMLElement && body.contains(activeElement)) {
556
+ for (let element = activeElement; element && element !== body; element = element.parentElement) {
557
+ targets.add(element);
558
+ }
559
+ }
560
+ for (const target of targets) {
561
+ stopObserving.push(observeResize(target, scheduleReveal));
562
+ }
563
+ };
564
+ const mutationObserver = typeof MutationObserver === 'undefined' ? null : new MutationObserver(records => {
565
+ // Skip the sheet's own attribute writes on the body. Consumer
566
+ // DOM, text, class, style, or visibility changes can all move the
567
+ // focused control without a viewport event. The inset's write on
568
+ // the content box schedules one more reveal, which finds the same
569
+ // inset and settles.
570
+ if (records.every(record => record.type === 'attributes' && record.target === body)) {
571
+ return;
572
+ }
573
+ refreshObservedLayout();
574
+ scheduleReveal();
575
+ });
576
+ const handleFocusIn = () => {
577
+ refreshObservedLayout();
578
+ scheduleReveal();
579
+ };
580
+ const handleFocusOut = () => {
581
+ // Keep the added scroll range while the keyboard animates away. A focus
582
+ // transition to another text-entry control cancels this in focusin.
583
+ retainKeyboardLayoutRef.current = hasKeyboardLayoutRef.current;
584
+ scheduleReveal();
585
+ };
586
+ const handleSheetTransitionEnd = event => {
587
+ if (event.target === sheet && event.propertyName === 'transform') {
588
+ scheduleReveal();
589
+ }
590
+ };
591
+ const stopClaiming = ownsFocusTransitions ? claimFocusTransitions() : null;
592
+ // The hook reveals a keyboard report in its own frame, not the next. A browser
593
+ // that reveals a focused field itself pans the visual viewport to it a few
594
+ // frames after the report. It does so if the keyboard still covers the
595
+ // field. Such a pan moves the visual viewport down within the layout
596
+ // viewport, with no touch, document scroll or zoom since the report. Once
597
+ // the sheet lands the field itself, the hook puts the pan back: it scrolls
598
+ // the full-viewport dialog's top into view. On every later report, the field
599
+ // lands at once.
600
+ let lastHeight = getKeyboardHeight(window);
601
+ // Under a coarse primary pointer only: the watch reads touches, and an
602
+ // on-screen keyboard's pan comes with a touch screen.
603
+ const revealPan = isTouch ? watchRevealPan(window, {
604
+ owns: focused => hasKeyboardLayoutRef.current && body.contains(focused),
605
+ // The reader's own scroll may stop a smooth reveal short: forget
606
+ // where it was going.
607
+ onTouch: () => {
608
+ smoothTarget = null;
609
+ }
610
+ }) : null;
611
+ // Every keyboard report reaches the reveal through the keyboard module's
612
+ // one set of listeners.
613
+ const unsubscribe = subscribeKeyboardHeight(window, event => {
614
+ if (event.type !== 'viewport') {
615
+ return;
616
+ }
617
+ if (event.height !== lastHeight) {
618
+ lastHeight = event.height;
619
+ revealPan?.report();
620
+ cancelAnimationFrame(animationFrame);
621
+ revealFocusedControl(true);
622
+ return;
623
+ }
624
+ if (revealPan?.isRevealPan(event.height) === true) {
625
+ revealsAtReport.add(window);
626
+ cancelAnimationFrame(animationFrame);
627
+ // A smooth reveal still under way lands the field where it is going.
628
+ // Another scroll on top of it would overshoot in Firefox, which keeps
629
+ // adding a smooth scrollBy's distance after an instant scroll. The
630
+ // field ended 244 px too high there. With none under way, the field
631
+ // lands at once.
632
+ const distanceLeft = smoothDistanceLeft();
633
+ if (distanceLeft === 0) {
634
+ revealFocusedControl();
635
+ }
636
+ // The hook puts the pan back only if the sheet holds the field above
637
+ // the keyboard with its own scroller. Rects are in layout-viewport
638
+ // coordinates, which the pan does not move. A ride still draws the
639
+ // field lower than its layout, so its offset does not count. A field
640
+ // the sheet cannot bring up keeps the browser's reveal.
641
+ const activeElement = document.activeElement;
642
+ const dialog = sheet.closest('dialog');
643
+ if (dialog != null && activeElement instanceof HTMLElement && activeElement.getBoundingClientRect().bottom - rideOffset() - distanceLeft <= getObstructionTop()) {
644
+ // The full-viewport dialog is fixed at the layout viewport's top.
645
+ revealPan?.putBack(dialog);
646
+ }
647
+ return;
648
+ }
649
+ scheduleReveal();
650
+ });
651
+ body.addEventListener('focusin', handleFocusIn);
652
+ body.addEventListener('focusout', handleFocusOut);
653
+ sheet.addEventListener('transitionend', handleSheetTransitionEnd);
654
+ // The window's capture phase hears every element's scroll too, and only
655
+ // the page's own scroll moves the document.
656
+ const stopDocumentScroll = subscribeInput(window, 'scroll', event => {
657
+ const target = event.target;
658
+ if (!isNodeTarget(target) || target.nodeType === 9) {
659
+ handleDocumentScroll();
660
+ }
661
+ });
662
+ mutationObserver?.observe(body, {
663
+ attributes: true,
664
+ characterData: true,
665
+ childList: true,
666
+ subtree: true
667
+ });
668
+ refreshObservedLayout();
669
+ // The dialog enters the top layer in a later effect. Wait until the next
670
+ // frame so flex geometry is final before measuring the body.
671
+ scheduleReveal();
672
+ return () => {
673
+ cancelAnimationFrame(animationFrame);
674
+ stopClaiming?.();
675
+ unsubscribe();
676
+ unsubscribeGuide?.();
677
+ heldHide?.release();
678
+ contentAnimator()?.set({
679
+ y: 0
680
+ });
681
+ revealPan?.stop();
682
+ body.removeEventListener('focusin', handleFocusIn);
683
+ body.removeEventListener('focusout', handleFocusOut);
684
+ sheet.removeEventListener('transitionend', handleSheetTransitionEnd);
685
+ stopDocumentScroll();
686
+ for (const stop of stopObserving) {
687
+ stop();
688
+ }
689
+ mutationObserver?.disconnect();
690
+ setKeyboardInset(0);
691
+ hasKeyboardLayoutRef.current = false;
692
+ retainKeyboardLayoutRef.current = false;
693
+ };
694
+ }, `${isEnabled}|${reservesInset}|${isPageScrollLocked}|${isTouch}|${offset}`);
695
+ return {
696
+ inset: keyboardInset,
697
+ dismiss
698
+ };
699
+ }