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.
Files changed (70) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1014 -2
  4. package/dist/components/KeyboardAwareScrollView.d.ts +6 -0
  5. package/dist/components/KeyboardAwareScrollView.js +137 -0
  6. package/dist/components/KeyboardAwareView.d.ts +6 -0
  7. package/dist/components/KeyboardAwareView.js +63 -0
  8. package/dist/components/KeyboardDismissButton.d.ts +5 -0
  9. package/dist/components/KeyboardDismissButton.js +16 -0
  10. package/dist/components/KeyboardToolbar.d.ts +5 -0
  11. package/dist/components/KeyboardToolbar.js +109 -0
  12. package/dist/components/index.d.ts +4 -0
  13. package/dist/components/index.js +11 -0
  14. package/dist/context/KeyboardContext.d.ts +6 -0
  15. package/dist/context/KeyboardContext.js +27 -0
  16. package/dist/context/index.d.ts +1 -0
  17. package/dist/context/index.js +5 -0
  18. package/dist/hooks/index.d.ts +4 -0
  19. package/dist/hooks/index.js +11 -0
  20. package/dist/hooks/useFocusedInput.d.ts +6 -0
  21. package/dist/hooks/useFocusedInput.js +42 -0
  22. package/dist/hooks/useKeyboard.d.ts +1 -0
  23. package/dist/hooks/useKeyboard.js +5 -0
  24. package/dist/hooks/useKeyboardAnimation.d.ts +6 -0
  25. package/dist/hooks/useKeyboardAnimation.js +43 -0
  26. package/dist/hooks/useKeyboardState.d.ts +2 -0
  27. package/dist/hooks/useKeyboardState.js +9 -0
  28. package/dist/index.d.ts +5 -0
  29. package/dist/index.js +23 -0
  30. package/dist/types/index.d.ts +69 -0
  31. package/dist/types/index.js +2 -0
  32. package/dist/utils/constants.d.ts +2 -0
  33. package/dist/utils/constants.js +5 -0
  34. package/dist/utils/focusManager.d.ts +10 -0
  35. package/dist/utils/focusManager.js +34 -0
  36. package/dist/utils/index.d.ts +3 -0
  37. package/dist/utils/index.js +13 -0
  38. package/dist/utils/keyboardEvents.d.ts +7 -0
  39. package/dist/utils/keyboardEvents.js +88 -0
  40. package/dist/utils/platform.d.ts +11 -0
  41. package/dist/utils/platform.js +19 -0
  42. package/dist/utils/scrollManager.d.ts +19 -0
  43. package/dist/utils/scrollManager.js +32 -0
  44. package/docs/android.md +26 -0
  45. package/docs/api.md +75 -0
  46. package/docs/assets/demo-preview.svg +24 -0
  47. package/docs/installation.md +22 -0
  48. package/docs/ios.md +22 -0
  49. package/docs/troubleshooting.md +36 -0
  50. package/package.json +90 -4
  51. package/src/components/KeyboardAwareScrollView.tsx +240 -0
  52. package/src/components/KeyboardAwareView.tsx +112 -0
  53. package/src/components/KeyboardDismissButton.tsx +32 -0
  54. package/src/components/KeyboardToolbar.tsx +200 -0
  55. package/src/components/index.ts +4 -0
  56. package/src/context/KeyboardContext.tsx +35 -0
  57. package/src/context/index.ts +1 -0
  58. package/src/hooks/index.ts +4 -0
  59. package/src/hooks/useFocusedInput.ts +52 -0
  60. package/src/hooks/useKeyboard.ts +1 -0
  61. package/src/hooks/useKeyboardAnimation.ts +50 -0
  62. package/src/hooks/useKeyboardState.ts +14 -0
  63. package/src/index.ts +35 -0
  64. package/src/types/index.ts +101 -0
  65. package/src/utils/constants.ts +2 -0
  66. package/src/utils/focusManager.ts +40 -0
  67. package/src/utils/index.ts +10 -0
  68. package/src/utils/keyboardEvents.ts +110 -0
  69. package/src/utils/platform.ts +23 -0
  70. 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,4 @@
1
+ export { KeyboardAwareScrollView } from './KeyboardAwareScrollView';
2
+ export { KeyboardAwareView } from './KeyboardAwareView';
3
+ export { KeyboardToolbar } from './KeyboardToolbar';
4
+ export { KeyboardDismissButton } from './KeyboardDismissButton';
@@ -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,4 @@
1
+ export { useKeyboard } from './useKeyboard';
2
+ export { useKeyboardState } from './useKeyboardState';
3
+ export { useKeyboardAnimation } from './useKeyboardAnimation';
4
+ export { useFocusedInput } from './useFocusedInput';
@@ -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';