react-native-keyboard-toolkit 0.0.0-stage → 1.0.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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +1014 -2
- package/dist/components/KeyboardAwareScrollView.d.ts +6 -0
- package/dist/components/KeyboardAwareScrollView.js +137 -0
- package/dist/components/KeyboardAwareView.d.ts +6 -0
- package/dist/components/KeyboardAwareView.js +63 -0
- package/dist/components/KeyboardDismissButton.d.ts +5 -0
- package/dist/components/KeyboardDismissButton.js +16 -0
- package/dist/components/KeyboardToolbar.d.ts +5 -0
- package/dist/components/KeyboardToolbar.js +109 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.js +11 -0
- package/dist/context/KeyboardContext.d.ts +6 -0
- package/dist/context/KeyboardContext.js +27 -0
- package/dist/context/index.d.ts +1 -0
- package/dist/context/index.js +5 -0
- package/dist/hooks/index.d.ts +4 -0
- package/dist/hooks/index.js +11 -0
- package/dist/hooks/useFocusedInput.d.ts +6 -0
- package/dist/hooks/useFocusedInput.js +42 -0
- package/dist/hooks/useKeyboard.d.ts +1 -0
- package/dist/hooks/useKeyboard.js +5 -0
- package/dist/hooks/useKeyboardAnimation.d.ts +6 -0
- package/dist/hooks/useKeyboardAnimation.js +43 -0
- package/dist/hooks/useKeyboardState.d.ts +2 -0
- package/dist/hooks/useKeyboardState.js +9 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +23 -0
- package/dist/types/index.d.ts +69 -0
- package/dist/types/index.js +2 -0
- package/dist/utils/constants.d.ts +2 -0
- package/dist/utils/constants.js +5 -0
- package/dist/utils/focusManager.d.ts +10 -0
- package/dist/utils/focusManager.js +34 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.js +13 -0
- package/dist/utils/keyboardEvents.d.ts +7 -0
- package/dist/utils/keyboardEvents.js +88 -0
- package/dist/utils/platform.d.ts +11 -0
- package/dist/utils/platform.js +19 -0
- package/dist/utils/scrollManager.d.ts +19 -0
- package/dist/utils/scrollManager.js +32 -0
- package/docs/android.md +26 -0
- package/docs/api.md +75 -0
- package/docs/assets/demo-preview.svg +24 -0
- package/docs/installation.md +22 -0
- package/docs/ios.md +22 -0
- package/docs/troubleshooting.md +36 -0
- package/package.json +90 -4
- package/src/components/KeyboardAwareScrollView.tsx +240 -0
- package/src/components/KeyboardAwareView.tsx +112 -0
- package/src/components/KeyboardDismissButton.tsx +32 -0
- package/src/components/KeyboardToolbar.tsx +200 -0
- package/src/components/index.ts +4 -0
- package/src/context/KeyboardContext.tsx +35 -0
- package/src/context/index.ts +1 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useFocusedInput.ts +52 -0
- package/src/hooks/useKeyboard.ts +1 -0
- package/src/hooks/useKeyboardAnimation.ts +50 -0
- package/src/hooks/useKeyboardState.ts +14 -0
- package/src/index.ts +35 -0
- package/src/types/index.ts +101 -0
- package/src/utils/constants.ts +2 -0
- package/src/utils/focusManager.ts +40 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/keyboardEvents.ts +110 -0
- package/src/utils/platform.ts +23 -0
- package/src/utils/scrollManager.ts +59 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { ScrollView, StyleSheet, type LayoutChangeEvent } from 'react-native';
|
|
10
|
+
import type { KeyboardAwareScrollViewProps } from '../types';
|
|
11
|
+
import type { KeyboardAwareScrollViewRef } from '../types';
|
|
12
|
+
import { getFocusedInput, measureFocusedInput } from '../utils/focusManager';
|
|
13
|
+
import {
|
|
14
|
+
calculateKeyboardOverlap,
|
|
15
|
+
calculateScrollOffset,
|
|
16
|
+
} from '../utils/scrollManager';
|
|
17
|
+
import { useKeyboardDebug } from '../context/KeyboardContext';
|
|
18
|
+
import { useKeyboardState } from '../hooks/useKeyboardState';
|
|
19
|
+
import { DEFAULT_KEYBOARD_SPACING } from '../utils/constants';
|
|
20
|
+
import { isIOS } from '../utils/platform';
|
|
21
|
+
|
|
22
|
+
/** A ScrollView that adds keyboard space and scrolls a covered focused input into view. */
|
|
23
|
+
export const KeyboardAwareScrollView: React.ForwardRefExoticComponent<
|
|
24
|
+
KeyboardAwareScrollViewProps & React.RefAttributes<KeyboardAwareScrollViewRef>
|
|
25
|
+
> = forwardRef<KeyboardAwareScrollViewRef, KeyboardAwareScrollViewProps>(
|
|
26
|
+
function KeyboardAwareScrollViewComponent(
|
|
27
|
+
{
|
|
28
|
+
behavior = 'smart',
|
|
29
|
+
keyboardSpacing = DEFAULT_KEYBOARD_SPACING,
|
|
30
|
+
enabled = true,
|
|
31
|
+
contentContainerStyle,
|
|
32
|
+
onScroll,
|
|
33
|
+
onScrollBeginDrag,
|
|
34
|
+
onContentSizeChange,
|
|
35
|
+
onLayout,
|
|
36
|
+
onFocusCapture,
|
|
37
|
+
scrollEventThrottle = 16,
|
|
38
|
+
...props
|
|
39
|
+
},
|
|
40
|
+
forwardedRef,
|
|
41
|
+
): React.JSX.Element {
|
|
42
|
+
const keyboard = useKeyboardState();
|
|
43
|
+
const debug = useKeyboardDebug();
|
|
44
|
+
const scrollRef = useRef<KeyboardAwareScrollViewRef>(null);
|
|
45
|
+
useImperativeHandle(forwardedRef, () => scrollRef.current!, []);
|
|
46
|
+
const scrollOffset = useRef(0);
|
|
47
|
+
const lastScrollTarget = useRef<number | null>(null);
|
|
48
|
+
const measurementSequence = useRef(0);
|
|
49
|
+
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
|
|
50
|
+
const spacing = Math.max(0, keyboardSpacing);
|
|
51
|
+
|
|
52
|
+
const scrollFocusedInput = useCallback(async () => {
|
|
53
|
+
const sequence = ++measurementSequence.current;
|
|
54
|
+
if (!enabled || !keyboard.isVisible || behavior === 'resize') {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const scrollView = scrollRef.current;
|
|
59
|
+
const input = getFocusedInput();
|
|
60
|
+
if (!scrollView?.measureInWindow) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const viewport = await new Promise<{ y: number; height: number }>(
|
|
65
|
+
resolve => {
|
|
66
|
+
scrollView.measureInWindow((_x, y, _width, height) =>
|
|
67
|
+
resolve({ y, height }),
|
|
68
|
+
);
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (sequence !== measurementSequence.current || !keyboard.isVisible) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const overlap = calculateKeyboardOverlap(
|
|
77
|
+
viewport,
|
|
78
|
+
keyboard.height,
|
|
79
|
+
keyboard.screenY,
|
|
80
|
+
);
|
|
81
|
+
if (Math.abs(overlap - keyboardOverlap) > 1) {
|
|
82
|
+
setKeyboardOverlap(overlap);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (
|
|
86
|
+
!input?.measureInWindow ||
|
|
87
|
+
behavior === 'none' ||
|
|
88
|
+
behavior === 'padding'
|
|
89
|
+
) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const inputLayout = await measureFocusedInput(input);
|
|
94
|
+
if (sequence !== measurementSequence.current || !inputLayout) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const calculation = calculateScrollOffset(
|
|
99
|
+
{ y: inputLayout.y, height: inputLayout.height },
|
|
100
|
+
viewport,
|
|
101
|
+
keyboard.height,
|
|
102
|
+
spacing,
|
|
103
|
+
scrollOffset.current,
|
|
104
|
+
keyboard.screenY,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (debug && __DEV__) {
|
|
108
|
+
console.debug('[keyboard-toolkit] scroll calculation', calculation);
|
|
109
|
+
}
|
|
110
|
+
if (
|
|
111
|
+
Math.abs(calculation.delta) > 1 &&
|
|
112
|
+
(lastScrollTarget.current === null ||
|
|
113
|
+
Math.abs(lastScrollTarget.current - calculation.offset) > 1)
|
|
114
|
+
) {
|
|
115
|
+
lastScrollTarget.current = calculation.offset;
|
|
116
|
+
scrollView.scrollTo({ y: calculation.offset, animated: true });
|
|
117
|
+
}
|
|
118
|
+
}, [
|
|
119
|
+
behavior,
|
|
120
|
+
debug,
|
|
121
|
+
enabled,
|
|
122
|
+
keyboard.height,
|
|
123
|
+
keyboard.isVisible,
|
|
124
|
+
keyboard.screenY,
|
|
125
|
+
keyboardOverlap,
|
|
126
|
+
spacing,
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!keyboard.isVisible) {
|
|
131
|
+
lastScrollTarget.current = null;
|
|
132
|
+
measurementSequence.current += 1;
|
|
133
|
+
setKeyboardOverlap(0);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const frame = requestAnimationFrame(() => {
|
|
137
|
+
scrollFocusedInput();
|
|
138
|
+
});
|
|
139
|
+
return () => cancelAnimationFrame(frame);
|
|
140
|
+
}, [keyboard.height, keyboard.isVisible, scrollFocusedInput]);
|
|
141
|
+
|
|
142
|
+
const handleScroll = useCallback(
|
|
143
|
+
(event: Parameters<NonNullable<typeof onScroll>>[0]) => {
|
|
144
|
+
scrollOffset.current = event.nativeEvent.contentOffset.y;
|
|
145
|
+
if (
|
|
146
|
+
lastScrollTarget.current !== null &&
|
|
147
|
+
Math.abs(scrollOffset.current - lastScrollTarget.current) <= 1
|
|
148
|
+
) {
|
|
149
|
+
lastScrollTarget.current = null;
|
|
150
|
+
}
|
|
151
|
+
onScroll?.(event);
|
|
152
|
+
},
|
|
153
|
+
[onScroll],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const handleScrollBeginDrag = useCallback(
|
|
157
|
+
(event: Parameters<NonNullable<typeof onScrollBeginDrag>>[0]) => {
|
|
158
|
+
lastScrollTarget.current = null;
|
|
159
|
+
onScrollBeginDrag?.(event);
|
|
160
|
+
},
|
|
161
|
+
[onScrollBeginDrag],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const handleContentSizeChange = useCallback(
|
|
165
|
+
(width: number, height: number) => {
|
|
166
|
+
onContentSizeChange?.(width, height);
|
|
167
|
+
if (keyboard.isVisible) {
|
|
168
|
+
requestAnimationFrame(() => scrollFocusedInput());
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
[keyboard.isVisible, onContentSizeChange, scrollFocusedInput],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const handleLayout = useCallback(
|
|
175
|
+
(event: LayoutChangeEvent) => {
|
|
176
|
+
onLayout?.(event);
|
|
177
|
+
if (keyboard.isVisible) {
|
|
178
|
+
requestAnimationFrame(() => scrollFocusedInput());
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
[keyboard.isVisible, onLayout, scrollFocusedInput],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const handleFocusCapture = useCallback(
|
|
185
|
+
(event: Parameters<NonNullable<typeof onFocusCapture>>[0]) => {
|
|
186
|
+
onFocusCapture?.(event);
|
|
187
|
+
if (keyboard.isVisible) {
|
|
188
|
+
requestAnimationFrame(() => scrollFocusedInput());
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
[keyboard.isVisible, onFocusCapture, scrollFocusedInput],
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const flattenedContentStyle = StyleSheet.flatten(contentContainerStyle);
|
|
195
|
+
const existingBottomPadding =
|
|
196
|
+
typeof flattenedContentStyle?.paddingBottom === 'number'
|
|
197
|
+
? flattenedContentStyle.paddingBottom
|
|
198
|
+
: 0;
|
|
199
|
+
const shouldPad =
|
|
200
|
+
enabled &&
|
|
201
|
+
keyboard.isVisible &&
|
|
202
|
+
behavior !== 'none' &&
|
|
203
|
+
behavior !== 'resize' &&
|
|
204
|
+
keyboardOverlap > 0;
|
|
205
|
+
const adjustedContentStyle =
|
|
206
|
+
shouldPad && typeof flattenedContentStyle?.paddingBottom !== 'string'
|
|
207
|
+
? [
|
|
208
|
+
contentContainerStyle,
|
|
209
|
+
{
|
|
210
|
+
paddingBottom: Math.max(
|
|
211
|
+
existingBottomPadding,
|
|
212
|
+
keyboardOverlap + spacing,
|
|
213
|
+
),
|
|
214
|
+
},
|
|
215
|
+
]
|
|
216
|
+
: contentContainerStyle;
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<ScrollView
|
|
220
|
+
{...props}
|
|
221
|
+
automaticallyAdjustKeyboardInsets={
|
|
222
|
+
props.automaticallyAdjustKeyboardInsets ??
|
|
223
|
+
(behavior === 'resize' && isIOS)
|
|
224
|
+
}
|
|
225
|
+
ref={scrollRef}
|
|
226
|
+
contentContainerStyle={adjustedContentStyle}
|
|
227
|
+
onContentSizeChange={handleContentSizeChange}
|
|
228
|
+
onLayout={handleLayout}
|
|
229
|
+
onFocusCapture={handleFocusCapture}
|
|
230
|
+
onScroll={handleScroll}
|
|
231
|
+
onScrollBeginDrag={handleScrollBeginDrag}
|
|
232
|
+
scrollEventThrottle={scrollEventThrottle}
|
|
233
|
+
/>
|
|
234
|
+
);
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
KeyboardAwareScrollView.displayName = 'KeyboardAwareScrollView';
|
|
239
|
+
|
|
240
|
+
export default KeyboardAwareScrollView;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { StyleSheet, View } from 'react-native';
|
|
10
|
+
import type { KeyboardAwareViewProps } from '../types';
|
|
11
|
+
import type { KeyboardAwareViewRef } from '../types';
|
|
12
|
+
import { DEFAULT_KEYBOARD_SPACING } from '../utils/constants';
|
|
13
|
+
import { useKeyboardState } from '../hooks/useKeyboardState';
|
|
14
|
+
import { calculateKeyboardOverlap } from '../utils/scrollManager';
|
|
15
|
+
|
|
16
|
+
/** A lightweight view that adds bottom space while the keyboard is visible. */
|
|
17
|
+
export const KeyboardAwareView: React.ForwardRefExoticComponent<
|
|
18
|
+
KeyboardAwareViewProps & React.RefAttributes<KeyboardAwareViewRef>
|
|
19
|
+
> = forwardRef<KeyboardAwareViewRef, KeyboardAwareViewProps>(
|
|
20
|
+
function KeyboardAwareViewComponent(
|
|
21
|
+
{
|
|
22
|
+
behavior = 'smart',
|
|
23
|
+
keyboardSpacing = DEFAULT_KEYBOARD_SPACING,
|
|
24
|
+
enabled = true,
|
|
25
|
+
style,
|
|
26
|
+
onLayout,
|
|
27
|
+
...props
|
|
28
|
+
},
|
|
29
|
+
forwardedRef,
|
|
30
|
+
): React.JSX.Element {
|
|
31
|
+
const keyboard = useKeyboardState();
|
|
32
|
+
const viewRef = useRef<KeyboardAwareViewRef>(null);
|
|
33
|
+
useImperativeHandle(forwardedRef, () => viewRef.current!, []);
|
|
34
|
+
const measurementSequence = useRef(0);
|
|
35
|
+
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
|
|
36
|
+
const flattenedStyle = StyleSheet.flatten(style);
|
|
37
|
+
const basePadding =
|
|
38
|
+
typeof flattenedStyle?.paddingBottom === 'number'
|
|
39
|
+
? flattenedStyle.paddingBottom
|
|
40
|
+
: 0;
|
|
41
|
+
const measureOverlap = useCallback(() => {
|
|
42
|
+
const view = viewRef.current;
|
|
43
|
+
if (!view?.measureInWindow || !keyboard.isVisible) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sequence = ++measurementSequence.current;
|
|
48
|
+
view.measureInWindow((_x, y, _width, height) => {
|
|
49
|
+
if (sequence !== measurementSequence.current || !keyboard.isVisible) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setKeyboardOverlap(
|
|
53
|
+
calculateKeyboardOverlap(
|
|
54
|
+
{ y, height },
|
|
55
|
+
keyboard.height,
|
|
56
|
+
keyboard.screenY,
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
}, [keyboard.height, keyboard.isVisible, keyboard.screenY]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!keyboard.isVisible) {
|
|
64
|
+
measurementSequence.current += 1;
|
|
65
|
+
setKeyboardOverlap(0);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const frame = requestAnimationFrame(measureOverlap);
|
|
69
|
+
return () => cancelAnimationFrame(frame);
|
|
70
|
+
}, [keyboard.isVisible, keyboard.height, keyboard.screenY, measureOverlap]);
|
|
71
|
+
|
|
72
|
+
const handleLayout = useCallback(
|
|
73
|
+
(event: Parameters<NonNullable<typeof onLayout>>[0]) => {
|
|
74
|
+
onLayout?.(event);
|
|
75
|
+
measureOverlap();
|
|
76
|
+
},
|
|
77
|
+
[measureOverlap, onLayout],
|
|
78
|
+
);
|
|
79
|
+
const adjust =
|
|
80
|
+
enabled &&
|
|
81
|
+
keyboard.isVisible &&
|
|
82
|
+
behavior !== 'none' &&
|
|
83
|
+
behavior !== 'resize' &&
|
|
84
|
+
keyboardOverlap > 0 &&
|
|
85
|
+
typeof flattenedStyle?.paddingBottom !== 'string';
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<View
|
|
89
|
+
{...props}
|
|
90
|
+
ref={viewRef}
|
|
91
|
+
onLayout={handleLayout}
|
|
92
|
+
style={
|
|
93
|
+
adjust
|
|
94
|
+
? [
|
|
95
|
+
style,
|
|
96
|
+
{
|
|
97
|
+
paddingBottom: Math.max(
|
|
98
|
+
basePadding,
|
|
99
|
+
keyboardOverlap + Math.max(0, keyboardSpacing),
|
|
100
|
+
),
|
|
101
|
+
},
|
|
102
|
+
]
|
|
103
|
+
: style
|
|
104
|
+
}
|
|
105
|
+
/>
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
KeyboardAwareView.displayName = 'KeyboardAwareView';
|
|
111
|
+
|
|
112
|
+
export default KeyboardAwareView;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Keyboard, Pressable } from 'react-native';
|
|
3
|
+
import type { KeyboardDismissButtonProps } from '../types';
|
|
4
|
+
|
|
5
|
+
/** A pressable that dismisses the software keyboard by default. */
|
|
6
|
+
export function KeyboardDismissButton({
|
|
7
|
+
children,
|
|
8
|
+
onPress,
|
|
9
|
+
dismissKeyboard = true,
|
|
10
|
+
accessibilityLabel = 'Dismiss keyboard',
|
|
11
|
+
...props
|
|
12
|
+
}: KeyboardDismissButtonProps): React.JSX.Element {
|
|
13
|
+
const handlePress = () => {
|
|
14
|
+
onPress?.();
|
|
15
|
+
if (dismissKeyboard) {
|
|
16
|
+
Keyboard.dismiss();
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Pressable
|
|
22
|
+
{...props}
|
|
23
|
+
accessibilityLabel={accessibilityLabel}
|
|
24
|
+
accessibilityRole="button"
|
|
25
|
+
onPress={handlePress}
|
|
26
|
+
>
|
|
27
|
+
{children}
|
|
28
|
+
</Pressable>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default KeyboardDismissButton;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Keyboard,
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
Text,
|
|
7
|
+
TextInput,
|
|
8
|
+
View,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import type { KeyboardToolbarProps } from '../types';
|
|
11
|
+
import { useKeyboardState } from '../hooks/useKeyboardState';
|
|
12
|
+
|
|
13
|
+
function findFocusedInputIndex(
|
|
14
|
+
inputRefs: NonNullable<KeyboardToolbarProps['inputRefs']>,
|
|
15
|
+
): number {
|
|
16
|
+
return inputRefs.findIndex(ref => ref.current?.isFocused() === true);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isMountedInputAt(
|
|
20
|
+
inputRefs: NonNullable<KeyboardToolbarProps['inputRefs']>,
|
|
21
|
+
index: number,
|
|
22
|
+
): boolean {
|
|
23
|
+
return (
|
|
24
|
+
index >= 0 && index < inputRefs.length && inputRefs[index]?.current != null
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hasMountedInputBefore(
|
|
29
|
+
inputRefs: NonNullable<KeyboardToolbarProps['inputRefs']>,
|
|
30
|
+
index: number,
|
|
31
|
+
): boolean {
|
|
32
|
+
return inputRefs.some(
|
|
33
|
+
(input, inputIndex) => inputIndex < index && input.current != null,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hasMountedInputAfter(
|
|
38
|
+
inputRefs: NonNullable<KeyboardToolbarProps['inputRefs']>,
|
|
39
|
+
index: number,
|
|
40
|
+
): boolean {
|
|
41
|
+
return inputRefs.some(
|
|
42
|
+
(input, inputIndex) => inputIndex > index && input.current != null,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** An optional in-layout toolbar for input navigation and keyboard dismissal. */
|
|
47
|
+
export function KeyboardToolbar({
|
|
48
|
+
inputRefs = [],
|
|
49
|
+
focusedInputIndex,
|
|
50
|
+
onFocusedInputChange,
|
|
51
|
+
onDone,
|
|
52
|
+
doneText = 'Done',
|
|
53
|
+
children,
|
|
54
|
+
alwaysVisible = false,
|
|
55
|
+
style,
|
|
56
|
+
...props
|
|
57
|
+
}: KeyboardToolbarProps): React.JSX.Element | null {
|
|
58
|
+
const keyboard = useKeyboardState();
|
|
59
|
+
const [trackedIndex, setTrackedIndex] = useState(-1);
|
|
60
|
+
const lastReportedIndex = useRef(-1);
|
|
61
|
+
const trackFocus = useCallback(() => {
|
|
62
|
+
const index = findFocusedInputIndex(inputRefs);
|
|
63
|
+
setTrackedIndex(current => (current === index ? current : index));
|
|
64
|
+
if (lastReportedIndex.current !== index) {
|
|
65
|
+
lastReportedIndex.current = index;
|
|
66
|
+
onFocusedInputChange?.(index);
|
|
67
|
+
}
|
|
68
|
+
}, [inputRefs, onFocusedInputChange]);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
trackFocus();
|
|
72
|
+
if (!keyboard.isVisible && !alwaysVisible) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const interval = setInterval(trackFocus, 100);
|
|
77
|
+
return () => clearInterval(interval);
|
|
78
|
+
}, [alwaysVisible, keyboard.isVisible, trackFocus]);
|
|
79
|
+
|
|
80
|
+
if (!alwaysVisible && !keyboard.isVisible) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const actualFocusedIndex = findFocusedInputIndex(inputRefs);
|
|
85
|
+
const currentIndex =
|
|
86
|
+
actualFocusedIndex >= 0
|
|
87
|
+
? actualFocusedIndex
|
|
88
|
+
: isMountedInputAt(inputRefs, trackedIndex)
|
|
89
|
+
? trackedIndex
|
|
90
|
+
: isMountedInputAt(inputRefs, focusedInputIndex ?? -1)
|
|
91
|
+
? focusedInputIndex!
|
|
92
|
+
: -1;
|
|
93
|
+
const canNavigateBackward =
|
|
94
|
+
currentIndex >= 0 && hasMountedInputBefore(inputRefs, currentIndex);
|
|
95
|
+
const canNavigateForward =
|
|
96
|
+
currentIndex >= 0 && hasMountedInputAfter(inputRefs, currentIndex);
|
|
97
|
+
|
|
98
|
+
const focusInDirection = (direction: -1 | 1) => {
|
|
99
|
+
const actualIndex = findFocusedInputIndex(inputRefs);
|
|
100
|
+
const origin = actualIndex >= 0 ? actualIndex : currentIndex;
|
|
101
|
+
if (origin < 0) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (
|
|
106
|
+
let index = origin + direction;
|
|
107
|
+
index >= 0 && index < inputRefs.length;
|
|
108
|
+
index += direction
|
|
109
|
+
) {
|
|
110
|
+
const target = inputRefs[index]?.current;
|
|
111
|
+
if (target) {
|
|
112
|
+
target.focus();
|
|
113
|
+
lastReportedIndex.current = index;
|
|
114
|
+
setTrackedIndex(index);
|
|
115
|
+
onFocusedInputChange?.(index);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const done = () => {
|
|
122
|
+
const focusedInput = TextInput.State.currentlyFocusedInput?.();
|
|
123
|
+
if (focusedInput) {
|
|
124
|
+
TextInput.State.blurTextInput(focusedInput);
|
|
125
|
+
}
|
|
126
|
+
Keyboard.dismiss();
|
|
127
|
+
onDone?.();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<View {...props} style={[styles.toolbar, style]}>
|
|
132
|
+
{children ?? (
|
|
133
|
+
<>
|
|
134
|
+
<Pressable
|
|
135
|
+
accessibilityRole="button"
|
|
136
|
+
accessibilityLabel="Previous input"
|
|
137
|
+
disabled={!canNavigateBackward}
|
|
138
|
+
onPress={() => focusInDirection(-1)}
|
|
139
|
+
style={styles.action}
|
|
140
|
+
>
|
|
141
|
+
<Text style={canNavigateBackward ? styles.label : styles.disabled}>
|
|
142
|
+
Previous
|
|
143
|
+
</Text>
|
|
144
|
+
</Pressable>
|
|
145
|
+
<Pressable
|
|
146
|
+
accessibilityRole="button"
|
|
147
|
+
accessibilityLabel="Next input"
|
|
148
|
+
disabled={!canNavigateForward}
|
|
149
|
+
onPress={() => focusInDirection(1)}
|
|
150
|
+
style={styles.action}
|
|
151
|
+
>
|
|
152
|
+
<Text style={canNavigateForward ? styles.label : styles.disabled}>
|
|
153
|
+
Next
|
|
154
|
+
</Text>
|
|
155
|
+
</Pressable>
|
|
156
|
+
<View style={styles.spacer} />
|
|
157
|
+
<Pressable
|
|
158
|
+
accessibilityRole="button"
|
|
159
|
+
accessibilityLabel="Done"
|
|
160
|
+
onPress={done}
|
|
161
|
+
style={styles.action}
|
|
162
|
+
>
|
|
163
|
+
<Text style={styles.label}>{doneText}</Text>
|
|
164
|
+
</Pressable>
|
|
165
|
+
</>
|
|
166
|
+
)}
|
|
167
|
+
</View>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const styles = StyleSheet.create({
|
|
172
|
+
toolbar: {
|
|
173
|
+
alignItems: 'center',
|
|
174
|
+
backgroundColor: '#f6f7f9',
|
|
175
|
+
borderTopColor: '#d8dce2',
|
|
176
|
+
borderTopWidth: StyleSheet.hairlineWidth,
|
|
177
|
+
flexDirection: 'row',
|
|
178
|
+
minHeight: 48,
|
|
179
|
+
paddingHorizontal: 12,
|
|
180
|
+
},
|
|
181
|
+
action: {
|
|
182
|
+
minHeight: 44,
|
|
183
|
+
justifyContent: 'center',
|
|
184
|
+
paddingHorizontal: 10,
|
|
185
|
+
},
|
|
186
|
+
label: {
|
|
187
|
+
color: '#155eef',
|
|
188
|
+
fontSize: 15,
|
|
189
|
+
fontWeight: '600',
|
|
190
|
+
},
|
|
191
|
+
disabled: {
|
|
192
|
+
color: '#98a2b3',
|
|
193
|
+
fontSize: 15,
|
|
194
|
+
},
|
|
195
|
+
spacer: {
|
|
196
|
+
flex: 1,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
export default KeyboardToolbar;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useRef } from 'react';
|
|
2
|
+
import { useKeyboardState } from '../hooks/useKeyboardState';
|
|
3
|
+
import type { KeyboardProviderProps } from '../types';
|
|
4
|
+
|
|
5
|
+
const DebugContext = createContext(false);
|
|
6
|
+
|
|
7
|
+
/** Optional provider for development diagnostics; keyboard subscriptions are shared globally. */
|
|
8
|
+
export function KeyboardProvider({
|
|
9
|
+
children,
|
|
10
|
+
debug = false,
|
|
11
|
+
}: KeyboardProviderProps): React.JSX.Element {
|
|
12
|
+
const keyboard = useKeyboardState();
|
|
13
|
+
const previous = useRef(keyboard);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (
|
|
17
|
+
debug &&
|
|
18
|
+
__DEV__ &&
|
|
19
|
+
(previous.current.isVisible !== keyboard.isVisible ||
|
|
20
|
+
previous.current.height !== keyboard.height)
|
|
21
|
+
) {
|
|
22
|
+
console.debug('[keyboard-toolkit]', keyboard);
|
|
23
|
+
}
|
|
24
|
+
previous.current = keyboard;
|
|
25
|
+
}, [debug, keyboard]);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<DebugContext.Provider value={debug}>{children}</DebugContext.Provider>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read whether development diagnostics are enabled on the nearest provider. */
|
|
33
|
+
export function useKeyboardDebug(): boolean {
|
|
34
|
+
return useContext(DebugContext);
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { KeyboardProvider } from './KeyboardContext';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { FocusedInputLayout } from '../types';
|
|
3
|
+
import { getFocusedInput, measureFocusedInput } from '../utils/focusManager';
|
|
4
|
+
import { useKeyboardState } from './useKeyboardState';
|
|
5
|
+
|
|
6
|
+
/** Measure the focused TextInput when the keyboard opens; call `refresh` after layout changes. */
|
|
7
|
+
export function useFocusedInput(): {
|
|
8
|
+
focusedInput: FocusedInputLayout | null;
|
|
9
|
+
refresh: () => Promise<FocusedInputLayout | null>;
|
|
10
|
+
} {
|
|
11
|
+
const keyboard = useKeyboardState();
|
|
12
|
+
const [focusedInput, setFocusedInput] = useState<FocusedInputLayout | null>(
|
|
13
|
+
null,
|
|
14
|
+
);
|
|
15
|
+
const measureSequence = useRef(0);
|
|
16
|
+
|
|
17
|
+
const refresh = useCallback(async () => {
|
|
18
|
+
const sequence = ++measureSequence.current;
|
|
19
|
+
const layout = await measureFocusedInput(getFocusedInput());
|
|
20
|
+
if (sequence === measureSequence.current) {
|
|
21
|
+
setFocusedInput(current => {
|
|
22
|
+
if (
|
|
23
|
+
current?.x === layout?.x &&
|
|
24
|
+
current?.y === layout?.y &&
|
|
25
|
+
current?.width === layout?.width &&
|
|
26
|
+
current?.height === layout?.height
|
|
27
|
+
) {
|
|
28
|
+
return current;
|
|
29
|
+
}
|
|
30
|
+
return layout;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return layout;
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!keyboard.isVisible) {
|
|
38
|
+
measureSequence.current += 1;
|
|
39
|
+
setFocusedInput(null);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
refresh();
|
|
44
|
+
const interval = setInterval(refresh, 120);
|
|
45
|
+
return () => {
|
|
46
|
+
measureSequence.current += 1;
|
|
47
|
+
clearInterval(interval);
|
|
48
|
+
};
|
|
49
|
+
}, [keyboard.isVisible, keyboard.height, refresh]);
|
|
50
|
+
|
|
51
|
+
return { focusedInput, refresh };
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useKeyboardState as useKeyboard } from './useKeyboardState';
|