@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.
- package/CHANGELOG.md +132 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +1 -1
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.js +30 -14
- package/dist/BottomSheet/bottomSheetStack.js +1 -1
- package/dist/BottomSheet/useSheetGestures.d.ts +3 -1
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
- package/dist/BottomSheet/useSheetGestures.js +4 -0
- package/dist/CheckboxList/CheckboxList.d.ts +13 -2
- package/dist/CheckboxList/CheckboxList.d.ts.map +1 -1
- package/dist/CheckboxList/CheckboxList.js +10 -4
- package/dist/CheckboxList/CheckboxListContext.d.ts +7 -0
- package/dist/CheckboxList/CheckboxListContext.d.ts.map +1 -1
- package/dist/CheckboxList/CheckboxListContext.js +2 -1
- package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
- package/dist/CheckboxList/CheckboxListItem.js +14 -2
- package/dist/CheckboxList/requiredGroup.d.ts +22 -0
- package/dist/CheckboxList/requiredGroup.d.ts.map +1 -0
- package/dist/CheckboxList/requiredGroup.js +52 -0
- package/dist/DateInput/PickerWheel.d.ts +1 -1
- package/dist/DateInput/PickerWheel.d.ts.map +1 -1
- package/dist/DateInput/PickerWheel.js +27 -6
- package/dist/DateInput/Wheel.d.ts +2 -2
- package/dist/DateInput/Wheel.d.ts.map +1 -1
- package/dist/DateInput/Wheel.js +20 -6
- package/dist/DateInput/datePickerTouch.d.ts +13 -0
- package/dist/DateInput/datePickerTouch.d.ts.map +1 -0
- package/dist/DateInput/datePickerTouch.js +14 -0
- package/dist/DateInput/wheelOptions.d.ts +22 -2
- package/dist/DateInput/wheelOptions.d.ts.map +1 -1
- package/dist/DateInput/wheelOptions.js +14 -2
- package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
- package/dist/DateTimeInput/TouchDateTimeField.js +62 -119
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +6 -1
- package/dist/Overlay/Overlay.d.ts.map +1 -1
- package/dist/Overlay/Overlay.js +7 -2
- package/dist/PowerSearch/PowerSearch.d.ts +4 -2
- package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearchBottomSheet.d.ts +24 -1
- package/dist/PowerSearch/PowerSearchBottomSheet.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearchBottomSheet.js +172 -50
- package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
- package/dist/Resizable/ResizeHandle.js +15 -15
- package/dist/Resizable/createResizable.d.ts +70 -16
- package/dist/Resizable/createResizable.d.ts.map +1 -1
- package/dist/Resizable/createResizable.js +180 -22
- package/dist/Resizable/separatorAttributes.d.ts +45 -0
- package/dist/Resizable/separatorAttributes.d.ts.map +1 -0
- package/dist/Resizable/separatorAttributes.js +35 -0
- package/dist/Resizable/useResizable.js +2 -2
- package/dist/TimeInput/TimeWheels.d.ts +32 -0
- package/dist/TimeInput/TimeWheels.d.ts.map +1 -0
- package/dist/TimeInput/TimeWheels.js +144 -0
- package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
- package/dist/TimeInput/TouchTimeField.js +63 -118
- package/dist/TimeInput/timeWheelRules.d.ts +124 -0
- package/dist/TimeInput/timeWheelRules.d.ts.map +1 -0
- package/dist/TimeInput/timeWheelRules.js +277 -0
- package/dist/Tokenizer/TokenizerBottomSheet.js +8 -12
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +6 -0
- package/dist/astryx.css +3 -1
- package/dist/gestures/GestureHost.d.ts.map +1 -1
- package/dist/gestures/GestureHost.js +3 -0
- package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.d.ts.map +1 -1
- package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.js +5 -2
- package/dist/i18n/generated-english/Resizable/createResizable.generated.d.ts +8 -0
- package/dist/i18n/generated-english/Resizable/createResizable.generated.d.ts.map +1 -0
- package/dist/i18n/generated-english/Resizable/createResizable.generated.js +13 -0
- package/dist/i18n/generated-english/TimeInput/TimeWheels.generated.d.ts +8 -0
- package/dist/i18n/generated-english/TimeInput/TimeWheels.generated.d.ts.map +1 -0
- package/dist/i18n/generated-english/TimeInput/TimeWheels.generated.js +17 -0
- package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/en.generated.js +5 -2
- package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/pseudo.generated.js +5 -2
- package/dist/keyboard/index.d.ts +17 -0
- package/dist/keyboard/index.d.ts.map +1 -0
- package/dist/keyboard/index.js +19 -0
- package/dist/keyboard/keyboardHeight.d.ts +149 -0
- package/dist/keyboard/keyboardHeight.d.ts.map +1 -0
- package/dist/keyboard/keyboardHeight.js +432 -0
- package/dist/keyboard/keyboardLayoutGuide.d.ts +59 -0
- package/dist/keyboard/keyboardLayoutGuide.d.ts.map +1 -0
- package/dist/keyboard/keyboardLayoutGuide.js +207 -0
- package/dist/keyboard/keyboardModel.d.ts +93 -0
- package/dist/keyboard/keyboardModel.d.ts.map +1 -0
- package/dist/keyboard/keyboardModel.js +220 -0
- package/dist/keyboard/rideKeyboard.d.ts +42 -0
- package/dist/keyboard/rideKeyboard.d.ts.map +1 -0
- package/dist/keyboard/rideKeyboard.js +77 -0
- package/dist/keyboard/useMobileKeyboard.d.ts +61 -0
- package/dist/keyboard/useMobileKeyboard.d.ts.map +1 -0
- package/dist/keyboard/useMobileKeyboard.js +699 -0
- package/dist/motion/android/index.d.ts +16 -0
- package/dist/motion/android/index.d.ts.map +1 -0
- package/dist/motion/android/index.js +18 -0
- package/dist/motion/android/keyboard.d.ts +83 -0
- package/dist/motion/android/keyboard.d.ts.map +1 -0
- package/dist/motion/android/keyboard.js +96 -0
- package/dist/motion/uikit/datePicker.d.ts +58 -0
- package/dist/motion/uikit/datePicker.d.ts.map +1 -0
- package/dist/motion/uikit/datePicker.js +60 -0
- package/dist/motion/uikit/index.d.ts +4 -3
- package/dist/motion/uikit/index.d.ts.map +1 -1
- package/dist/motion/uikit/index.js +4 -3
- package/dist/utils/interactionOverlay.stylex.d.ts +3 -2
- package/dist/utils/interactionOverlay.stylex.d.ts.map +1 -1
- package/dist/utils/interactionOverlay.stylex.js +3 -2
- package/dist/utils/layoutViewport.d.ts +25 -0
- package/dist/utils/layoutViewport.d.ts.map +1 -0
- package/dist/utils/layoutViewport.js +102 -0
- package/dist/utils/pressFeedback.d.ts.map +1 -1
- package/dist/utils/pressFeedback.js +74 -9
- package/dist/utils/pressGesture.d.ts +4 -2
- package/dist/utils/pressGesture.d.ts.map +1 -1
- package/dist/utils/pressGesture.js +3 -1
- package/dist/utils/selectionLock.d.ts +10 -1
- package/dist/utils/selectionLock.d.ts.map +1 -1
- package/dist/utils/selectionLock.js +31 -5
- package/dist/utils/sharedMutationObserver.d.ts +2 -1
- package/dist/utils/sharedMutationObserver.d.ts.map +1 -1
- package/dist/utils/sharedMutationObserver.js +2 -1
- package/dist/utils/timeParser.d.ts +2 -1
- package/dist/utils/timeParser.d.ts.map +1 -1
- package/dist/utils/timeParser.js +29 -3
- package/locales/en.json +21 -9
- package/locales/pseudo.json +15 -6
- package/package.json +5 -5
- package/src/BottomSheet/BottomSheet.doc.mjs +1 -1
- package/src/BottomSheet/BottomSheetPanel.tsx +41 -13
- package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +5 -0
- package/src/BottomSheet/bottomSheetStack.ts +1 -1
- package/src/BottomSheet/useSheetGestures.ts +6 -0
- package/src/CheckboxList/CheckboxList.doc.mjs +7 -0
- package/src/CheckboxList/CheckboxList.tsx +34 -2
- package/src/CheckboxList/CheckboxListContext.tsx +9 -1
- package/src/CheckboxList/CheckboxListItem.tsx +23 -2
- package/src/CheckboxList/requiredGroup.ts +77 -0
- package/src/DateInput/PickerWheel.tsx +37 -4
- package/src/DateInput/Wheel.tsx +30 -3
- package/src/DateInput/datePickerTouch.ts +15 -0
- package/src/DateInput/wheelOptions.ts +31 -2
- package/src/DateTimeInput/TouchDateTimeField.tsx +94 -178
- package/src/HoverCard/useHoverCard.tsx +6 -1
- package/src/Overlay/Overlay.tsx +7 -2
- package/src/PowerSearch/PowerSearch.doc.mjs +2 -2
- package/src/PowerSearch/PowerSearch.tsx +4 -2
- package/src/PowerSearch/PowerSearchBottomSheet.tsx +237 -62
- package/src/Resizable/ResizeHandle.tsx +18 -26
- package/src/Resizable/createResizable.ts +242 -27
- package/src/Resizable/separatorAttributes.ts +73 -0
- package/src/Resizable/useResizable.ts +2 -2
- package/src/TimeInput/TimeWheels.tsx +218 -0
- package/src/TimeInput/TouchTimeField.tsx +93 -157
- package/src/TimeInput/timeWheelRules.ts +352 -0
- package/src/Tokenizer/TokenizerBottomSheet.tsx +8 -12
- package/src/Tooltip/useTooltip.tsx +6 -0
- package/src/gestures/GestureHost.ts +5 -4
- package/src/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.ts +5 -2
- package/src/i18n/generated-english/Resizable/createResizable.generated.ts +14 -0
- package/src/i18n/generated-english/TimeInput/TimeWheels.generated.ts +18 -0
- package/src/i18n/generated-locales/en.generated.ts +6 -4
- package/src/i18n/generated-locales/pseudo.generated.ts +6 -4
- package/src/keyboard/README.md +197 -0
- package/src/keyboard/index.ts +48 -0
- package/src/keyboard/keyboardHeight.ts +543 -0
- package/src/keyboard/keyboardLayoutGuide.ts +266 -0
- package/src/keyboard/keyboardModel.ts +301 -0
- package/src/keyboard/rideKeyboard.ts +100 -0
- package/src/keyboard/useMobileKeyboard.ts +866 -0
- package/src/motion/MEASUREMENTS.md +10 -0
- package/src/motion/README.md +2 -0
- package/src/motion/android/index.ts +18 -0
- package/src/motion/android/keyboard.ts +96 -0
- package/src/motion/uikit/datePicker.ts +60 -0
- package/src/motion/uikit/index.ts +4 -3
- package/src/utils/interactionOverlay.stylex.ts +3 -2
- package/src/utils/layoutViewport.ts +104 -0
- package/src/utils/pressFeedback.ts +99 -21
- package/src/utils/pressGesture.ts +4 -2
- package/src/utils/selectionLock.ts +40 -6
- package/src/utils/sharedMutationObserver.ts +2 -1
- package/src/utils/timeParser.ts +40 -3
- package/dist/BottomSheet/useMobileKeyboard.d.ts +0 -21
- package/dist/BottomSheet/useMobileKeyboard.d.ts.map +0 -1
- package/dist/BottomSheet/useMobileKeyboard.js +0 -413
- package/src/BottomSheet/useMobileKeyboard.ts +0 -542
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file keyboardHeight.ts
|
|
7
|
+
* @input The visual viewport, the window's input stream (focus, blur,
|
|
8
|
+
* resize, scroll and touch), and the layout viewport's height
|
|
9
|
+
* @output The on-screen keyboard module: measureKeyboardHeight,
|
|
10
|
+
* isKeyboardMeasurable, subscribeKeyboardHeight, getKeyboardHeight,
|
|
11
|
+
* isTextEntryControl, findTextEntryControl, refuseFocusReveal and
|
|
12
|
+
* watchRevealPan
|
|
13
|
+
* @position Core's one source for the on-screen keyboard. It is internal to
|
|
14
|
+
* core and never exported from the package.
|
|
15
|
+
*
|
|
16
|
+
* SYNC: When modified, update /packages/core/src/keyboard/README.md
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {subscribeInput} from '../utils/inputStream';
|
|
20
|
+
import {layoutViewportHeight} from '../utils/layoutViewport';
|
|
21
|
+
|
|
22
|
+
// A shortfall smaller than this is rounding, not a keyboard.
|
|
23
|
+
export const KEYBOARD_EPSILON = 1;
|
|
24
|
+
const NON_TEXT_INPUT_TYPES = new Set([
|
|
25
|
+
'button',
|
|
26
|
+
'checkbox',
|
|
27
|
+
'color',
|
|
28
|
+
'file',
|
|
29
|
+
'hidden',
|
|
30
|
+
'image',
|
|
31
|
+
'radio',
|
|
32
|
+
'range',
|
|
33
|
+
'reset',
|
|
34
|
+
'submit',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether this window measures the keyboard: a top-level document with a
|
|
39
|
+
* visual viewport. Capabilities decide it, never the user agent or the
|
|
40
|
+
* pointer. The module measures a browser with its own keyboard geometry API,
|
|
41
|
+
* such as Chrome on Android with the VirtualKeyboard API, the same way.
|
|
42
|
+
* Its visual viewport shrinks for the keyboard while the layout viewport keeps
|
|
43
|
+
* its height, as everywhere else. A desktop browser's visual viewport never
|
|
44
|
+
* shrinks for a keyboard, so it reads 0. Where the app sets
|
|
45
|
+
* `interactive-widget=resizes-content`, Chrome shrinks the layout viewport
|
|
46
|
+
* with the keyboard. The height is then 0 too, and nothing rides, because the
|
|
47
|
+
* browser has made the room itself.
|
|
48
|
+
*/
|
|
49
|
+
export function isKeyboardMeasurable(win: Window): boolean {
|
|
50
|
+
return win.visualViewport != null && win.self === win.top;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The on-screen keyboard's height over the layout viewport, CSS px. It is 0
|
|
55
|
+
* when none is shown, also while pinch-zoomed, and wherever
|
|
56
|
+
* `isKeyboardMeasurable` is false.
|
|
57
|
+
*/
|
|
58
|
+
export function measureKeyboardHeight(win: Window): number {
|
|
59
|
+
const viewport = win.visualViewport;
|
|
60
|
+
if (viewport == null || !isKeyboardMeasurable(win)) {
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
const height =
|
|
64
|
+
layoutViewportHeight(win) - viewport.height * (viewport.scale ?? 1);
|
|
65
|
+
return height > KEYBOARD_EPSILON ? Math.round(height * 100) / 100 : 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A report from the keyboard module, in the order the window raised them. */
|
|
69
|
+
export type KeyboardHeightEvent =
|
|
70
|
+
| {
|
|
71
|
+
/** The visual viewport or the window changed size or position. */
|
|
72
|
+
type: 'viewport';
|
|
73
|
+
/** The keyboard height after this report. */
|
|
74
|
+
height: number;
|
|
75
|
+
/** The report's event time (the document timeline). */
|
|
76
|
+
timeStamp: number;
|
|
77
|
+
}
|
|
78
|
+
| {
|
|
79
|
+
/** Focus moved into (`focusin`) or out of (`focusout`) `target`. */
|
|
80
|
+
type: 'focusin' | 'focusout';
|
|
81
|
+
height: number;
|
|
82
|
+
timeStamp: number;
|
|
83
|
+
target: EventTarget | null;
|
|
84
|
+
relatedTarget: EventTarget | null;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
interface KeyboardStore {
|
|
88
|
+
height: number;
|
|
89
|
+
listeners: Set<(event: KeyboardHeightEvent) => void>;
|
|
90
|
+
/** The subscribers that asked for focus moves too (the layout guide). */
|
|
91
|
+
focusListeners: Set<(event: KeyboardHeightEvent) => void>;
|
|
92
|
+
detach: (() => void) | null;
|
|
93
|
+
detachFocus: (() => void) | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const stores = new WeakMap<Window, KeyboardStore>();
|
|
97
|
+
|
|
98
|
+
// A window's capture phase also hears its document's events. A window's own
|
|
99
|
+
// event has no node as its target.
|
|
100
|
+
export function isNodeTarget(target: EventTarget | null): boolean {
|
|
101
|
+
return target != null && typeof (target as Node).nodeType === 'number';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function storeOf(win: Window): KeyboardStore {
|
|
105
|
+
let store = stores.get(win);
|
|
106
|
+
if (store == null) {
|
|
107
|
+
store = {
|
|
108
|
+
height: 0,
|
|
109
|
+
listeners: new Set(),
|
|
110
|
+
focusListeners: new Set(),
|
|
111
|
+
detach: null,
|
|
112
|
+
detachFocus: null,
|
|
113
|
+
};
|
|
114
|
+
stores.set(win, store);
|
|
115
|
+
}
|
|
116
|
+
return store;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function attach(win: Window, store: KeyboardStore): () => void {
|
|
120
|
+
const viewport = win.visualViewport;
|
|
121
|
+
store.height = measureKeyboardHeight(win);
|
|
122
|
+
const onViewport = (event: Event) => {
|
|
123
|
+
store.height = measureKeyboardHeight(win);
|
|
124
|
+
const report: KeyboardHeightEvent = {
|
|
125
|
+
type: 'viewport',
|
|
126
|
+
height: store.height,
|
|
127
|
+
timeStamp: event.timeStamp,
|
|
128
|
+
};
|
|
129
|
+
for (const listener of [...store.listeners]) {
|
|
130
|
+
listener(report);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
viewport?.addEventListener('resize', onViewport);
|
|
134
|
+
viewport?.addEventListener('scroll', onViewport);
|
|
135
|
+
// Only the window's own resize reports.
|
|
136
|
+
const stopResize = subscribeInput(win, 'resize', event => {
|
|
137
|
+
if (!isNodeTarget(event.target)) {
|
|
138
|
+
onViewport(event);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
return () => {
|
|
142
|
+
viewport?.removeEventListener('resize', onViewport);
|
|
143
|
+
viewport?.removeEventListener('scroll', onViewport);
|
|
144
|
+
stopResize();
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Focus moves are heard only while a subscriber asks for them. A viewport
|
|
149
|
+
// reader, such as a Tall BottomSheet's hook under any pointer, adds no focus
|
|
150
|
+
// listener.
|
|
151
|
+
function attachFocus(win: Window, store: KeyboardStore): () => void {
|
|
152
|
+
const onFocus = (event: FocusEvent) => {
|
|
153
|
+
const report: KeyboardHeightEvent = {
|
|
154
|
+
type: event.type === 'focusin' ? 'focusin' : 'focusout',
|
|
155
|
+
height: store.height,
|
|
156
|
+
timeStamp: event.timeStamp,
|
|
157
|
+
target: event.target,
|
|
158
|
+
relatedTarget: event.relatedTarget,
|
|
159
|
+
};
|
|
160
|
+
for (const listener of [...store.focusListeners]) {
|
|
161
|
+
listener(report);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const stopFocusIn = subscribeInput(win, 'focusin', onFocus);
|
|
165
|
+
const stopFocusOut = subscribeInput(win, 'focusout', onFocus);
|
|
166
|
+
return () => {
|
|
167
|
+
stopFocusIn();
|
|
168
|
+
stopFocusOut();
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Calls `listener` with every report of `win`'s keyboard until the caller
|
|
174
|
+
* runs the returned function. It reports the viewport's changes, and focus
|
|
175
|
+
* moves too when the caller sets `focus`. All subscribers of a window share
|
|
176
|
+
* one set of listeners, and its focus listeners exist only while a subscriber
|
|
177
|
+
* asks for focus moves.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```
|
|
181
|
+
* const stop = subscribeKeyboardHeight(window, event => {
|
|
182
|
+
* if (event.type === 'viewport') layout(event.height);
|
|
183
|
+
* });
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
export function subscribeKeyboardHeight(
|
|
187
|
+
win: Window,
|
|
188
|
+
listener: (event: KeyboardHeightEvent) => void,
|
|
189
|
+
{focus = false}: {focus?: boolean} = {},
|
|
190
|
+
): () => void {
|
|
191
|
+
const store = storeOf(win);
|
|
192
|
+
store.listeners.add(listener);
|
|
193
|
+
if (store.detach == null) {
|
|
194
|
+
store.detach = attach(win, store);
|
|
195
|
+
}
|
|
196
|
+
if (focus) {
|
|
197
|
+
store.focusListeners.add(listener);
|
|
198
|
+
if (store.detachFocus == null) {
|
|
199
|
+
store.detachFocus = attachFocus(win, store);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return () => {
|
|
203
|
+
store.listeners.delete(listener);
|
|
204
|
+
if (focus) {
|
|
205
|
+
store.focusListeners.delete(listener);
|
|
206
|
+
if (store.focusListeners.size === 0 && store.detachFocus != null) {
|
|
207
|
+
store.detachFocus();
|
|
208
|
+
store.detachFocus = null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (store.listeners.size === 0 && store.detach != null) {
|
|
212
|
+
store.detach();
|
|
213
|
+
store.detach = null;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `win`'s keyboard height. While the module listens, it is the height
|
|
220
|
+
* measured at the last report. Every reader of a keyboard change, such as
|
|
221
|
+
* each component's hook, shares that one layout read. Every change of the
|
|
222
|
+
* visual viewport or the window is a report, so a reader between two reports
|
|
223
|
+
* sees the current height. With nothing listening yet, the call measures the
|
|
224
|
+
* height now.
|
|
225
|
+
*/
|
|
226
|
+
export function getKeyboardHeight(win: Window): number {
|
|
227
|
+
const store = stores.get(win);
|
|
228
|
+
return store?.detach != null ? store.height : measureKeyboardHeight(win);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Whether focusing `element` brings up a keyboard for typing text. A control
|
|
233
|
+
* with `inputmode="none"` brings up none.
|
|
234
|
+
*/
|
|
235
|
+
export function isTextEntryControl(
|
|
236
|
+
element: Element | null,
|
|
237
|
+
): element is HTMLElement {
|
|
238
|
+
if (
|
|
239
|
+
element instanceof HTMLElement &&
|
|
240
|
+
element.getAttribute('inputmode') === 'none'
|
|
241
|
+
) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
if (element instanceof HTMLTextAreaElement) {
|
|
245
|
+
return !element.disabled && !element.readOnly;
|
|
246
|
+
}
|
|
247
|
+
if (element instanceof HTMLInputElement) {
|
|
248
|
+
return (
|
|
249
|
+
!element.disabled &&
|
|
250
|
+
!element.readOnly &&
|
|
251
|
+
!NON_TEXT_INPUT_TYPES.has(
|
|
252
|
+
(element.getAttribute('type') ?? 'text').toLowerCase(),
|
|
253
|
+
)
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return (
|
|
257
|
+
element instanceof HTMLElement &&
|
|
258
|
+
element.matches('[contenteditable]:not([contenteditable="false"])')
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The text-entry control inside `container` that `target` is or labels. */
|
|
263
|
+
export function findTextEntryControl(
|
|
264
|
+
target: EventTarget | null,
|
|
265
|
+
container: HTMLElement,
|
|
266
|
+
): HTMLElement | null {
|
|
267
|
+
if (!(target instanceof Element)) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const direct = target.closest(
|
|
272
|
+
'input, textarea, [contenteditable]:not([contenteditable="false"])',
|
|
273
|
+
);
|
|
274
|
+
if (container.contains(direct) && isTextEntryControl(direct)) {
|
|
275
|
+
return direct;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const label = target.closest('label');
|
|
279
|
+
const control = label instanceof HTMLLabelElement ? label.control : null;
|
|
280
|
+
return container.contains(control) && isTextEntryControl(control)
|
|
281
|
+
? control
|
|
282
|
+
: null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The caller's rules for refuseFocusReveal. */
|
|
286
|
+
export interface RefuseFocusRevealOptions {
|
|
287
|
+
/** Whether to claim at all just now, checked on every blur. */
|
|
288
|
+
isActive?: () => boolean;
|
|
289
|
+
/** The control a focus move is heading to, when this caller claims it. */
|
|
290
|
+
claim: (destination: EventTarget | null) => HTMLElement | null;
|
|
291
|
+
/** Focus left `origin` for nothing (the keyboard's Done): park it, if at all. */
|
|
292
|
+
park?: (origin: EventTarget | null) => void;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// One shared `blur` subscription per document serves every caller of
|
|
296
|
+
// refuseFocusReveal in it, so listeners do not grow with the number of
|
|
297
|
+
// instances. The first caller adds it, and the last removes it.
|
|
298
|
+
const focusTakeovers = new WeakMap<
|
|
299
|
+
Document,
|
|
300
|
+
{
|
|
301
|
+
stop: () => void;
|
|
302
|
+
callers: Set<(event: FocusEvent) => void>;
|
|
303
|
+
}
|
|
304
|
+
>();
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Delivers the focus moves `claim` accepts with `preventScroll`, so the
|
|
308
|
+
* browser never reveals their destination itself. It stops when the caller
|
|
309
|
+
* runs the returned function.
|
|
310
|
+
*
|
|
311
|
+
* The browser dispatches `blur` in the capture phase ahead of its own focus
|
|
312
|
+
* step, and `blur` names the destination in `relatedTarget`. Focusing it there
|
|
313
|
+
* with preventScroll settles the change. The browser's step then finds the
|
|
314
|
+
* element already active, so it dispatches nothing further and reveals
|
|
315
|
+
* nothing.
|
|
316
|
+
*
|
|
317
|
+
* @example
|
|
318
|
+
* ```
|
|
319
|
+
* const stop = refuseFocusReveal(document, {
|
|
320
|
+
* claim: target => findTextEntryControl(target, body),
|
|
321
|
+
* park: () => sheet.focus({preventScroll: true}),
|
|
322
|
+
* });
|
|
323
|
+
* ```
|
|
324
|
+
*/
|
|
325
|
+
export function refuseFocusReveal(
|
|
326
|
+
doc: Document,
|
|
327
|
+
{isActive, claim, park}: RefuseFocusRevealOptions,
|
|
328
|
+
): () => void {
|
|
329
|
+
const onBlur = (event: FocusEvent) => {
|
|
330
|
+
if (isActive != null && !isActive()) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const destination = claim(event.relatedTarget);
|
|
334
|
+
if (destination != null) {
|
|
335
|
+
if (destination !== doc.activeElement) {
|
|
336
|
+
destination.focus({preventScroll: true});
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (event.relatedTarget == null) {
|
|
341
|
+
park?.(event.target);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
let shared = focusTakeovers.get(doc);
|
|
345
|
+
if (shared == null) {
|
|
346
|
+
const view = doc.defaultView;
|
|
347
|
+
if (view == null) {
|
|
348
|
+
return () => {};
|
|
349
|
+
}
|
|
350
|
+
const callers = new Set<(event: FocusEvent) => void>();
|
|
351
|
+
// Callers hear each blur in the order they came, as separate listeners
|
|
352
|
+
// would; one that stopped during this blur no longer hears it.
|
|
353
|
+
const listener = (event: FocusEvent) => {
|
|
354
|
+
for (const caller of [...callers]) {
|
|
355
|
+
if (callers.has(caller)) {
|
|
356
|
+
caller(event);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
// `blur` does not bubble: only the capture phase hears every one, and it
|
|
361
|
+
// runs before the browser's own focus step. The window's own blur moves
|
|
362
|
+
// no focus inside the document.
|
|
363
|
+
const stop = subscribeInput(view, 'blur', event => {
|
|
364
|
+
if (isNodeTarget(event.target)) {
|
|
365
|
+
listener(event);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
shared = {stop, callers};
|
|
369
|
+
focusTakeovers.set(doc, shared);
|
|
370
|
+
}
|
|
371
|
+
shared.callers.add(onBlur);
|
|
372
|
+
return () => {
|
|
373
|
+
const current = focusTakeovers.get(doc);
|
|
374
|
+
if (current == null || !current.callers.delete(onBlur)) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (current.callers.size === 0) {
|
|
378
|
+
current.stop();
|
|
379
|
+
focusTakeovers.delete(doc);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Test-only: how many callers refuse the focus reveal in `doc`, through its
|
|
386
|
+
* one shared subscription. Not part of the runtime API.
|
|
387
|
+
*
|
|
388
|
+
* @internal
|
|
389
|
+
*/
|
|
390
|
+
export function __focusTakeoverCallersForTest(doc: Document): number {
|
|
391
|
+
return focusTakeovers.get(doc)?.callers.size ?? 0;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The caller's rules for watchRevealPan. */
|
|
395
|
+
export interface WatchRevealPanOptions {
|
|
396
|
+
/** Whether the focused field is the caller's to keep in view. */
|
|
397
|
+
owns: (focused: HTMLElement) => boolean;
|
|
398
|
+
/** Called on every touch start, which ends the watch for the report. */
|
|
399
|
+
onTouch?: () => void;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** A watch for the browser's own reveal pan (see watchRevealPan). */
|
|
403
|
+
export interface RevealPanWatch {
|
|
404
|
+
/** A report changed the keyboard's height: remember where everything sat. */
|
|
405
|
+
report: () => void;
|
|
406
|
+
/** Whether the visual viewport has since moved down as a reveal pan. */
|
|
407
|
+
isRevealPan: (height: number) => boolean;
|
|
408
|
+
/**
|
|
409
|
+
* Pans the visual viewport back to where it sat at the report, without
|
|
410
|
+
* scrolling the document. It scrolls `anchor` into view, an element fixed at
|
|
411
|
+
* the layout viewport's top, or else a probe fixed where the viewport sat.
|
|
412
|
+
*/
|
|
413
|
+
putBack: (anchor?: Element | null) => void;
|
|
414
|
+
/** Ends the watch; the shared touch subscription goes with the last watch. */
|
|
415
|
+
stop: () => void;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// One shared touch subscription per document serves every reveal-pan watch
|
|
419
|
+
// in it, so no instance adds a listener. The first watch adds it, and the
|
|
420
|
+
// last removes it.
|
|
421
|
+
const touchStartWatches = new WeakMap<
|
|
422
|
+
Document,
|
|
423
|
+
{stop: () => void; watches: Set<() => void>}
|
|
424
|
+
>();
|
|
425
|
+
|
|
426
|
+
function watchTouchStarts(doc: Document, onTouchStart: () => void): () => void {
|
|
427
|
+
let shared = touchStartWatches.get(doc);
|
|
428
|
+
if (shared == null) {
|
|
429
|
+
const view = doc.defaultView;
|
|
430
|
+
if (view == null) {
|
|
431
|
+
return () => {};
|
|
432
|
+
}
|
|
433
|
+
const watches = new Set<() => void>();
|
|
434
|
+
const stop = subscribeInput(view, 'touchstart', () => {
|
|
435
|
+
for (const watch of [...watches]) {
|
|
436
|
+
watch();
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
shared = {stop, watches};
|
|
440
|
+
touchStartWatches.set(doc, shared);
|
|
441
|
+
}
|
|
442
|
+
shared.watches.add(onTouchStart);
|
|
443
|
+
return () => {
|
|
444
|
+
const current = touchStartWatches.get(doc);
|
|
445
|
+
if (current == null || !current.watches.delete(onTouchStart)) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (current.watches.size === 0) {
|
|
449
|
+
current.stop();
|
|
450
|
+
touchStartWatches.delete(doc);
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Some browsers reveal a focused field themselves, after the keyboard's
|
|
457
|
+
* report, by panning the visual viewport to it while the keyboard still
|
|
458
|
+
* covers it. Firefox for Android pans 54 to 143 ms after the report. Chrome
|
|
459
|
+
* on Android animates a pan of the keyboard's whole height from about 100 to
|
|
460
|
+
* 170 ms after it. The page gets no frame until that pan ends. Such a pan
|
|
461
|
+
* moves the visual viewport down within the
|
|
462
|
+
* layout viewport. Since the report there is no touch, document scroll or
|
|
463
|
+
* zoom, and a text field the caller owns has focus. The watch reads the
|
|
464
|
+
* behavior, never the user agent. The caller lands its field itself and
|
|
465
|
+
* puts the pan back. Every keyboard-aware surface can use it.
|
|
466
|
+
*
|
|
467
|
+
* @example
|
|
468
|
+
* ```
|
|
469
|
+
* const watch = watchRevealPan(window, {owns: field => region.contains(field)});
|
|
470
|
+
* // On a report that changes the height: watch.report();
|
|
471
|
+
* // On any later report: if (watch.isRevealPan(height)) watch.putBack();
|
|
472
|
+
* ```
|
|
473
|
+
*/
|
|
474
|
+
export function watchRevealPan(
|
|
475
|
+
win: Window,
|
|
476
|
+
{owns, onTouch}: WatchRevealPanOptions,
|
|
477
|
+
): RevealPanWatch {
|
|
478
|
+
const doc = win.document;
|
|
479
|
+
let atReport: {x: number; y: number; top: number; scale: number} | null =
|
|
480
|
+
null;
|
|
481
|
+
let touchedSinceReport = false;
|
|
482
|
+
const stopTouchStarts = watchTouchStarts(doc, () => {
|
|
483
|
+
touchedSinceReport = true;
|
|
484
|
+
onTouch?.();
|
|
485
|
+
});
|
|
486
|
+
return {
|
|
487
|
+
report: () => {
|
|
488
|
+
const viewport = win.visualViewport;
|
|
489
|
+
atReport =
|
|
490
|
+
viewport == null
|
|
491
|
+
? null
|
|
492
|
+
: {
|
|
493
|
+
x: win.scrollX,
|
|
494
|
+
y: win.scrollY,
|
|
495
|
+
top: viewport.offsetTop,
|
|
496
|
+
scale: viewport.scale,
|
|
497
|
+
};
|
|
498
|
+
touchedSinceReport = false;
|
|
499
|
+
},
|
|
500
|
+
isRevealPan: height => {
|
|
501
|
+
const viewport = win.visualViewport;
|
|
502
|
+
const focused = doc.activeElement;
|
|
503
|
+
return (
|
|
504
|
+
viewport != null &&
|
|
505
|
+
atReport != null &&
|
|
506
|
+
!touchedSinceReport &&
|
|
507
|
+
height > 0 &&
|
|
508
|
+
viewport.offsetTop > atReport.top &&
|
|
509
|
+
viewport.scale === atReport.scale &&
|
|
510
|
+
win.scrollX === atReport.x &&
|
|
511
|
+
win.scrollY === atReport.y &&
|
|
512
|
+
focused instanceof HTMLElement &&
|
|
513
|
+
isTextEntryControl(focused) &&
|
|
514
|
+
owns(focused)
|
|
515
|
+
);
|
|
516
|
+
},
|
|
517
|
+
putBack: anchor => {
|
|
518
|
+
const options: ScrollIntoViewOptions = {
|
|
519
|
+
block: 'start',
|
|
520
|
+
inline: 'nearest',
|
|
521
|
+
behavior: 'instant',
|
|
522
|
+
};
|
|
523
|
+
if (anchor != null) {
|
|
524
|
+
anchor.scrollIntoView(options);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
// A fixed element sits in the layout viewport, which the pan does not
|
|
528
|
+
// move: scrolling its top into view pans the visual viewport back.
|
|
529
|
+
const probe = doc.createElement('div');
|
|
530
|
+
probe.style.position = 'fixed';
|
|
531
|
+
probe.style.left = '0';
|
|
532
|
+
probe.style.top = `${atReport?.top ?? 0}px`;
|
|
533
|
+
probe.style.width = '1px';
|
|
534
|
+
probe.style.height = '1px';
|
|
535
|
+
probe.style.visibility = 'hidden';
|
|
536
|
+
probe.style.pointerEvents = 'none';
|
|
537
|
+
(doc.body ?? doc.documentElement).append(probe);
|
|
538
|
+
probe.scrollIntoView(options);
|
|
539
|
+
probe.remove();
|
|
540
|
+
},
|
|
541
|
+
stop: stopTouchStarts,
|
|
542
|
+
};
|
|
543
|
+
}
|