react-modal-sheet 5.4.0 → 5.5.0

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/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.4.0",
2
+ "version": "5.5.0",
3
3
  "license": "MIT",
4
4
  "name": "react-modal-sheet",
5
5
  "description": "Flexible bottom sheet component for your React apps",
@@ -6,7 +6,7 @@ import { useDragConstraints } from './hooks/use-drag-constraints';
6
6
  import { useScrollPosition } from './hooks/use-scroll-position';
7
7
  import { styles } from './styles';
8
8
  import { type SheetContentProps } from './types';
9
- import { applyStyles, mergeRefs } from './utils';
9
+ import { applyStyles, isHTTPS, mergeRefs } from './utils';
10
10
 
11
11
  export const SheetContent = forwardRef<any, SheetContentProps>(
12
12
  (
@@ -73,8 +73,14 @@ export const SheetContent = forwardRef<any, SheetContentProps>(
73
73
  const scrollStyle: MotionStyle = applyStyles(styles.scroller, isUnstyled);
74
74
 
75
75
  if (sheetContext.avoidKeyboard) {
76
- scrollStyle.paddingBottom =
77
- 'env(keyboard-inset-height, var(--keyboard-inset-height, 0px))';
76
+ /**
77
+ * Virtual Keyboard API is only available in secure context (https)
78
+ * and it will have 0px height in insecure context,
79
+ * which causes issues when developing and testing on localhost (http).
80
+ */
81
+ scrollStyle.paddingBottom = isHTTPS()
82
+ ? 'env(keyboard-inset-height, var(--keyboard-inset-height, 0px))'
83
+ : 'var(--keyboard-inset-height, 0px)';
78
84
  }
79
85
 
80
86
  if (disableScroll) {
package/src/constants.ts CHANGED
@@ -16,4 +16,4 @@ export const REDUCED_MOTION_TWEEN_CONFIG: SheetTweenConfig = {
16
16
 
17
17
  export const DEFAULT_DRAG_CLOSE_THRESHOLD = 0.6;
18
18
 
19
- export const DEFAULT_DRAG_VELOCITY_THRESHOLD = 500;
19
+ export const DEFAULT_DRAG_VELOCITY_THRESHOLD = 1200;
@@ -0,0 +1,75 @@
1
+ import { type RefObject, useEffect, useState } from 'react';
2
+
3
+ import { willOpenKeyboard } from '../utils';
4
+ import { useStableCallback } from './use-stable-callback';
5
+ import { useVirtualKeyboard } from './use-virtual-keyboard';
6
+
7
+ export function useKeyboardAvoidance({
8
+ isEnabled,
9
+ containerRef,
10
+ onWillOpenKeyboard,
11
+ onDidOpenKeyboard,
12
+ }: {
13
+ isEnabled: boolean;
14
+ containerRef: RefObject<HTMLDivElement | null>;
15
+ onWillOpenKeyboard?: (event: FocusEvent) => Promise<void>;
16
+ onDidOpenKeyboard?: (focusedElement: HTMLElement) => void;
17
+ }) {
18
+ const [focusedElement, setFocusedElement] = useState<HTMLElement | null>(
19
+ null
20
+ );
21
+
22
+ const keyboard = useVirtualKeyboard({
23
+ isEnabled,
24
+ containerRef,
25
+ });
26
+
27
+ const handleFocusIn = useStableCallback(async (event: FocusEvent) => {
28
+ const element = event.target as HTMLElement;
29
+
30
+ if (willOpenKeyboard(element) && containerRef.current?.contains(element)) {
31
+ await onWillOpenKeyboard?.(event);
32
+ setFocusedElement(element);
33
+ }
34
+ });
35
+
36
+ const handleFocusOut = useStableCallback((event: FocusEvent) => {
37
+ const element = event.target as HTMLElement;
38
+
39
+ if (focusedElement === element) {
40
+ setFocusedElement(null);
41
+ }
42
+ });
43
+
44
+ // Keep track of the currently focused input within the container
45
+ useEffect(() => {
46
+ if (!isEnabled) return;
47
+
48
+ document.addEventListener('focusin', handleFocusIn);
49
+ document.addEventListener('focusout', handleFocusOut);
50
+
51
+ return () => {
52
+ document.removeEventListener('focusin', handleFocusIn);
53
+ document.removeEventListener('focusout', handleFocusOut);
54
+ };
55
+ }, [isEnabled]);
56
+
57
+ useEffect(() => {
58
+ const containerElement = containerRef.current;
59
+
60
+ if (
61
+ !isEnabled ||
62
+ !focusedElement ||
63
+ !containerElement ||
64
+ !keyboard.isKeyboardOpen
65
+ ) {
66
+ return;
67
+ }
68
+
69
+ requestAnimationFrame(() => {
70
+ onDidOpenKeyboard?.(focusedElement);
71
+ });
72
+ }, [isEnabled, keyboard.isKeyboardOpen, focusedElement]);
73
+
74
+ return keyboard;
75
+ }
@@ -1,7 +1,7 @@
1
1
  // This code originates from https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/overlays/src/usePreventScroll.ts
2
2
 
3
+ import { isIOS, willOpenKeyboard } from '../utils';
3
4
  import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect';
4
- import { isIOS } from '../utils';
5
5
 
6
6
  const KEYBOARD_BUFFER = 24;
7
7
 
@@ -32,17 +32,17 @@ export function isScrollable(
32
32
 
33
33
  const style = window.getComputedStyle(node);
34
34
 
35
- let isScrollable = /(auto|scroll)/.test(
35
+ let scrollable = /(auto|scroll)/.test(
36
36
  style.overflow + style.overflowX + style.overflowY
37
37
  );
38
38
 
39
- if (isScrollable && checkForOverflow) {
40
- isScrollable =
39
+ if (scrollable && checkForOverflow) {
40
+ scrollable =
41
41
  node.scrollHeight !== node.clientHeight ||
42
42
  node.scrollWidth !== node.clientWidth;
43
43
  }
44
44
 
45
- return isScrollable;
45
+ return scrollable;
46
46
  }
47
47
 
48
48
  export function getScrollParent(
@@ -64,19 +64,6 @@ export function getScrollParent(
64
64
  );
65
65
  }
66
66
 
67
- // HTML input types that do not cause the software keyboard to appear.
68
- const nonTextInputTypes = new Set([
69
- 'checkbox',
70
- 'radio',
71
- 'range',
72
- 'color',
73
- 'file',
74
- 'image',
75
- 'button',
76
- 'submit',
77
- 'reset',
78
- ]);
79
-
80
67
  // The number of active usePreventScroll calls. Used to determine whether to revert back to the original page style/scroll position
81
68
  let preventScrollCount = 0;
82
69
  let restore: () => void;
@@ -367,12 +354,3 @@ function scrollIntoView(target: Element) {
367
354
  target = scrollable.parentElement;
368
355
  }
369
356
  }
370
-
371
- function willOpenKeyboard(target: Element) {
372
- return (
373
- (target instanceof HTMLInputElement &&
374
- !nonTextInputTypes.has(target.type)) ||
375
- target instanceof HTMLTextAreaElement ||
376
- (target instanceof HTMLElement && target.isContentEditable)
377
- );
378
- }
@@ -1,5 +1,4 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react';
2
- import { debug } from '../debug';
3
2
  import { useStableCallback } from './use-stable-callback';
4
3
 
5
4
  type UseScrollPositionOptions = {
@@ -1,11 +1,36 @@
1
1
  import { type RefObject, useEffect, useRef, useState } from 'react';
2
- import { useStableCallback } from './use-stable-callback';
2
+ import { isHTTPS, willOpenKeyboard } from '../utils';
3
+
4
+ type VirtualKeyboardApi = {
5
+ overlaysContent: boolean;
6
+ boundingRect: {
7
+ x: number;
8
+ y: number;
9
+ height: number;
10
+ width: number;
11
+ };
12
+ addEventListener: (
13
+ type: 'geometrychange',
14
+ listener: EventListenerOrEventListenerObject
15
+ ) => void;
16
+ removeEventListener: (
17
+ type: 'geometrychange',
18
+ listener: EventListenerOrEventListenerObject
19
+ ) => void;
20
+ };
3
21
 
4
22
  type VirtualKeyboardState = {
5
23
  isVisible: boolean;
6
24
  height: number;
7
25
  };
8
26
 
27
+ /**
28
+ * Keep track of how many components are using the virtual keyboard API
29
+ * to avoid conflicts when toggling `overlaysContent` which is a global setting.
30
+ */
31
+ let virtualKeyboardOverlayUsers = 0;
32
+ let initialVirtualKeyboardOverlaysContent: boolean | null = null;
33
+
9
34
  type UseVirtualKeyboardOptions = {
10
35
  /**
11
36
  * Ref to the container element to apply `keyboard-inset-height` CSS variable updates.
@@ -22,11 +47,6 @@ type UseVirtualKeyboardOptions = {
22
47
  * @default 100
23
48
  */
24
49
  visualViewportThreshold?: number;
25
- /**
26
- * Whether to treat contenteditable elements as text inputs.
27
- * @default true
28
- */
29
- includeContentEditable?: boolean;
30
50
  /**
31
51
  * Delay in ms for debouncing viewport changes.
32
52
  * @default 100
@@ -66,7 +86,6 @@ export function useVirtualKeyboard(options: UseVirtualKeyboardOptions = {}) {
66
86
  containerRef,
67
87
  isEnabled = true,
68
88
  debounceDelay = 100,
69
- includeContentEditable = true,
70
89
  visualViewportThreshold = 100,
71
90
  } = options;
72
91
 
@@ -78,27 +97,14 @@ export function useVirtualKeyboard(options: UseVirtualKeyboardOptions = {}) {
78
97
  const focusedElementRef = useRef<HTMLElement | null>(null);
79
98
  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
80
99
 
81
- const isTextInput = useStableCallback((el: Element | null) => {
82
- return (
83
- el?.tagName === 'INPUT' ||
84
- el?.tagName === 'TEXTAREA' ||
85
- (includeContentEditable &&
86
- el instanceof HTMLElement &&
87
- el.isContentEditable)
88
- );
89
- });
90
-
91
100
  useEffect(() => {
92
- if (!isEnabled) return;
93
-
94
101
  const vv = window.visualViewport;
95
- const vk = (navigator as any).virtualKeyboard;
102
+ const vk = getVirtualKeyboardApi();
96
103
 
97
104
  function setKeyboardInsetHeightEnv(height: number) {
98
105
  const element = containerRef?.current || document.documentElement;
99
106
 
100
- // Virtual Keyboard API is only available in secure context
101
- if (window.isSecureContext) {
107
+ if (vk) {
102
108
  element.style.setProperty(
103
109
  '--keyboard-inset-height',
104
110
  `env(keyboard-inset-height, ${height}px)`
@@ -108,29 +114,46 @@ export function useVirtualKeyboard(options: UseVirtualKeyboardOptions = {}) {
108
114
  }
109
115
  }
110
116
 
111
- function handleFocusIn(e: FocusEvent) {
112
- if (e.target instanceof HTMLElement && isTextInput(e.target)) {
113
- focusedElementRef.current = e.target;
114
- updateKeyboardState();
115
- }
117
+ function setKeyboardState(nextState: VirtualKeyboardState) {
118
+ setState((prevState) =>
119
+ prevState.isVisible === nextState.isVisible &&
120
+ prevState.height === nextState.height
121
+ ? prevState
122
+ : nextState
123
+ );
116
124
  }
117
125
 
118
- function handleFocusOut() {
126
+ function resetKeyboardState() {
119
127
  focusedElementRef.current = null;
120
- updateKeyboardState();
128
+ setKeyboardInsetHeightEnv(0);
129
+ setKeyboardState({ isVisible: false, height: 0 });
130
+ }
131
+
132
+ if (!isEnabled) {
133
+ resetKeyboardState();
134
+ return;
121
135
  }
122
136
 
123
137
  function updateKeyboardState() {
124
138
  if (debounceTimer.current) {
125
139
  clearTimeout(debounceTimer.current);
126
140
  }
141
+
127
142
  debounceTimer.current = setTimeout(() => {
128
- const active = focusedElementRef.current;
129
- const inputIsFocused = isTextInput(active);
143
+ const active = getActiveElement() ?? focusedElementRef.current;
144
+ const inputIsFocused = active ? willOpenKeyboard(active) : false;
130
145
 
131
146
  if (!inputIsFocused) {
132
- setKeyboardInsetHeightEnv(0);
133
- setState({ isVisible: false, height: 0 });
147
+ resetKeyboardState();
148
+ return;
149
+ }
150
+
151
+ focusedElementRef.current = active as HTMLElement;
152
+
153
+ if (vk?.overlaysContent) {
154
+ const keyboardHeight = vk.boundingRect.height;
155
+ setKeyboardInsetHeightEnv(keyboardHeight);
156
+ setKeyboardState({ isVisible: true, height: keyboardHeight });
134
157
  return;
135
158
  }
136
159
 
@@ -139,33 +162,53 @@ export function useVirtualKeyboard(options: UseVirtualKeyboardOptions = {}) {
139
162
 
140
163
  if (heightDiff > visualViewportThreshold) {
141
164
  setKeyboardInsetHeightEnv(heightDiff);
142
- setState({ isVisible: true, height: heightDiff });
143
- } else {
144
- setKeyboardInsetHeightEnv(0);
145
- setState({ isVisible: false, height: 0 });
165
+ setKeyboardState({ isVisible: true, height: heightDiff });
166
+ return;
146
167
  }
147
168
  }
169
+
170
+ resetKeyboardState();
148
171
  }, debounceDelay);
149
172
  }
150
173
 
151
- window.addEventListener('focusin', handleFocusIn);
152
- window.addEventListener('focusout', handleFocusOut);
174
+ function handleFocusIn(e: FocusEvent) {
175
+ if (e.target instanceof HTMLElement && willOpenKeyboard(e.target)) {
176
+ focusedElementRef.current = e.target;
177
+ updateKeyboardState();
178
+ }
179
+ }
180
+
181
+ function handleFocusOut() {
182
+ requestAnimationFrame(() => {
183
+ focusedElementRef.current = getActiveElement();
184
+ updateKeyboardState();
185
+ });
186
+ }
187
+
188
+ document.addEventListener('focusin', handleFocusIn);
189
+ document.addEventListener('focusout', handleFocusOut);
153
190
 
154
191
  if (vv) {
155
192
  vv.addEventListener('resize', updateKeyboardState);
156
193
  vv.addEventListener('scroll', updateKeyboardState);
157
194
  }
158
195
 
159
- let currentOverlaysContent = false;
160
-
161
196
  if (vk) {
162
- currentOverlaysContent = vk.overlaysContent;
163
- vk.overlaysContent = true;
197
+ if (virtualKeyboardOverlayUsers === 0) {
198
+ initialVirtualKeyboardOverlaysContent = vk.overlaysContent;
199
+ vk.overlaysContent = true;
200
+ }
201
+
202
+ virtualKeyboardOverlayUsers++;
203
+ vk.addEventListener('geometrychange', updateKeyboardState);
164
204
  }
165
205
 
206
+ focusedElementRef.current = getActiveElement();
207
+ updateKeyboardState();
208
+
166
209
  return () => {
167
- window.removeEventListener('focusin', handleFocusIn);
168
- window.removeEventListener('focusout', handleFocusOut);
210
+ document.removeEventListener('focusin', handleFocusIn);
211
+ document.removeEventListener('focusout', handleFocusOut);
169
212
 
170
213
  if (vv) {
171
214
  vv.removeEventListener('resize', updateKeyboardState);
@@ -173,22 +216,54 @@ export function useVirtualKeyboard(options: UseVirtualKeyboardOptions = {}) {
173
216
  }
174
217
 
175
218
  if (vk) {
176
- vk.overlaysContent = currentOverlaysContent;
219
+ vk.removeEventListener('geometrychange', updateKeyboardState);
220
+
221
+ virtualKeyboardOverlayUsers = Math.max(
222
+ 0,
223
+ virtualKeyboardOverlayUsers - 1
224
+ );
225
+
226
+ if (virtualKeyboardOverlayUsers === 0) {
227
+ vk.overlaysContent = initialVirtualKeyboardOverlaysContent ?? false;
228
+ initialVirtualKeyboardOverlaysContent = null;
229
+ }
177
230
  }
178
231
 
179
232
  if (debounceTimer.current) {
180
233
  clearTimeout(debounceTimer.current);
181
234
  }
235
+
236
+ resetKeyboardState();
182
237
  };
183
- }, [
184
- debounceDelay,
185
- includeContentEditable,
186
- isEnabled,
187
- visualViewportThreshold,
188
- ]);
238
+ }, [debounceDelay, isEnabled, visualViewportThreshold]);
189
239
 
190
240
  return {
191
241
  keyboardHeight: state.height,
192
242
  isKeyboardOpen: state.isVisible,
193
243
  };
194
244
  }
245
+
246
+ /**
247
+ * Virtual Keyboard API is only available in secure contexts (HTTPS)
248
+ * and may not be supported in all browsers.
249
+ */
250
+ function getVirtualKeyboardApi() {
251
+ return isHTTPS() && 'virtualKeyboard' in navigator
252
+ ? (navigator.virtualKeyboard as VirtualKeyboardApi)
253
+ : null;
254
+ }
255
+
256
+ function getActiveElement() {
257
+ let activeElement: Element | null = document.activeElement;
258
+
259
+ while (
260
+ activeElement instanceof HTMLElement &&
261
+ activeElement.shadowRoot?.activeElement
262
+ ) {
263
+ activeElement = activeElement.shadowRoot.activeElement;
264
+ }
265
+
266
+ return activeElement && willOpenKeyboard(activeElement)
267
+ ? (activeElement as HTMLElement)
268
+ : null;
269
+ }