Skip to content

Commit 1e6190f

Browse files
sclebaltomhicks
authored andcommitted
Extract utility isTextInput. Extract useSnapOnFocus for clarity and improves it for smoother experience.
1 parent 8bc85e9 commit 1e6190f

File tree

6 files changed

+109
-31
lines changed

6 files changed

+109
-31
lines changed

src/hooks/isTextInput.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Checks if an element is a text input
3+
*/
4+
5+
export function isTextInput(el: Element | null): el is HTMLElement {
6+
return (
7+
el instanceof HTMLElement &&
8+
(el.tagName === 'INPUT' ||
9+
el.tagName === 'TEXTAREA' ||
10+
el.isContentEditable)
11+
);
12+
}

src/hooks/use-prevent-scroll.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect';
44
import { isIOS } from '../utils';
5+
import { isTextInput } from './isTextInput';
56

67
const KEYBOARD_BUFFER = 24;
78

@@ -164,7 +165,7 @@ function preventScrollMobileSafari() {
164165
// Reset scroll tracking for new gesture
165166
didScroll = false;
166167

167-
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
168+
if (isTextInput(target)) return;
168169

169170
// Store the nearest scrollable parent element from the element that the user touched.
170171
scrollable = getScrollParent(target, true);

src/hooks/use-scroll-to-focused-input.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { type RefObject, useEffect, useRef } from 'react';
2+
import { isTextInput } from './isTextInput';
23

34
type UseScrollToFocusedInputOptions = {
45
/**
@@ -70,18 +71,6 @@ function findAssociatedLabel(element: HTMLElement): HTMLElement | null {
7071
return null;
7172
}
7273

73-
/**
74-
* Checks if an element is a text input
75-
*/
76-
function isTextInput(el: Element | null): el is HTMLElement {
77-
return (
78-
el instanceof HTMLElement &&
79-
(el.tagName === 'INPUT' ||
80-
el.tagName === 'TEXTAREA' ||
81-
el.isContentEditable)
82-
);
83-
}
84-
8574
/**
8675
* Scrolls a focused input (and its label) into view, centering it in the
8776
* visible area while respecting scroll bounds.

src/hooks/use-snap-on-focus.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { useEffect, type RefObject } from 'react';
2+
import { isTextInput } from './isTextInput';
3+
4+
type UseSnapOnFocusOptions = {
5+
/** Ref to the container element to listen for focus events */
6+
containerRef: RefObject<HTMLElement | null>;
7+
/** Whether the sheet is open */
8+
isOpen: boolean;
9+
/** Current snap point index */
10+
currentSnap: number | undefined;
11+
/** The last (full height) snap point index */
12+
lastSnapPointIndex: number;
13+
/** Whether the hook is enabled */
14+
isEnabled: boolean;
15+
/** Callback to snap to full height, returns a cleanup function to restore */
16+
onSnapToFull: (() => VoidFunction) | (() => void);
17+
};
18+
19+
/**
20+
* Snaps the sheet to full height when an input/textarea inside receives focus.
21+
* This happens before the keyboard opens, providing a smoother experience.
22+
*/
23+
export function useSnapOnFocus({
24+
containerRef,
25+
isOpen,
26+
currentSnap,
27+
lastSnapPointIndex,
28+
isEnabled,
29+
onSnapToFull,
30+
}: UseSnapOnFocusOptions) {
31+
useEffect(() => {
32+
if (!isOpen) return;
33+
if (!isEnabled) return;
34+
35+
const container = containerRef.current;
36+
if (!container) return;
37+
38+
let cleanup: (() => void) | undefined;
39+
40+
const handleFocusIn = (event: FocusEvent) => {
41+
const target = event.target as HTMLElement | null;
42+
if (!target) return;
43+
44+
if (!isTextInput(target)) return;
45+
46+
// Already at full height, nothing to do
47+
if (currentSnap === lastSnapPointIndex) return;
48+
49+
cleanup = onSnapToFull() ?? undefined;
50+
};
51+
52+
const handleFocusOut = (event: FocusEvent) => {
53+
const relatedTarget = event.relatedTarget as HTMLElement | null;
54+
55+
// If focus is moving to another input inside the container, don't restore
56+
if (relatedTarget && container.contains(relatedTarget)) {
57+
if (!isTextInput(relatedTarget)) return;
58+
}
59+
60+
// Focus left the container or moved to a non-input element, restore snap
61+
cleanup?.();
62+
cleanup = undefined;
63+
};
64+
65+
container.addEventListener('focusin', handleFocusIn);
66+
container.addEventListener('focusout', handleFocusOut);
67+
68+
return () => {
69+
container.removeEventListener('focusin', handleFocusIn);
70+
container.removeEventListener('focusout', handleFocusOut);
71+
cleanup?.();
72+
};
73+
}, [
74+
isOpen,
75+
isEnabled,
76+
containerRef,
77+
currentSnap,
78+
lastSnapPointIndex,
79+
onSnapToFull,
80+
]);
81+
}

src/hooks/use-virtual-keyboard.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type RefObject, useEffect, useRef, useState } from 'react';
2-
import { useStableCallback } from './use-stable-callback';
32
import { isIOSSafari26 } from '../utils';
3+
import { isTextInput } from './isTextInput';
44

55
type VirtualKeyboardState = {
66
isVisible: boolean;
@@ -45,16 +45,6 @@ export function useVirtualKeyboard({
4545
const focusedElementRef = useRef<HTMLElement | null>(null);
4646
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
4747

48-
const isTextInput = useStableCallback((el: Element | null) => {
49-
return (
50-
el?.tagName === 'INPUT' ||
51-
el?.tagName === 'TEXTAREA' ||
52-
(includeContentEditable &&
53-
el instanceof HTMLElement &&
54-
el.isContentEditable)
55-
);
56-
});
57-
5848
useEffect(() => {
5949
if (!isEnabled) return;
6050

src/sheet.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { usePreventScroll } from './hooks/use-prevent-scroll';
3434
import { useSheetState } from './hooks/use-sheet-state';
3535
import { useStableCallback } from './hooks/use-stable-callback';
3636
import { useScrollToFocusedInput } from './hooks/use-scroll-to-focused-input';
37+
import { useSnapOnFocus } from './hooks/use-snap-on-focus';
3738
import { useVirtualKeyboard } from './hooks/use-virtual-keyboard';
3839
import {
3940
computeSnapPoints,
@@ -417,12 +418,14 @@ export const Sheet = forwardRef<any, SheetProps>(
417418
return onKeyboardOpen();
418419
});
419420

420-
useEffect(() => {
421-
if (openStateRef.current !== 'open') return;
422-
if (detent !== 'default') return;
423-
if (!keyboard.isKeyboardOpen) return;
424-
return handleKeyboardOpen();
425-
}, [keyboard.isKeyboardOpen]);
421+
useSnapOnFocus({
422+
containerRef: positionerRef,
423+
isOpen: openStateRef.current === 'open',
424+
currentSnap,
425+
lastSnapPointIndex,
426+
isEnabled: detent === 'default',
427+
onSnapToFull: handleKeyboardOpen,
428+
});
426429

427430
useScrollToFocusedInput({
428431
containerRef,
@@ -491,8 +494,10 @@ export const Sheet = forwardRef<any, SheetProps>(
491494
updateSnap(initialSnap);
492495
}
493496

494-
onOpenEnd?.();
495497
openStateRef.current = 'open';
498+
requestAnimationFrame(() => {
499+
onOpenEnd?.();
500+
});
496501
};
497502

498503
const doWhenSheetReady = () => {

0 commit comments

Comments
 (0)