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,50 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { AccessibilityInfo, Animated } from 'react-native';
3
+ import { useKeyboardState } from './useKeyboardState';
4
+
5
+ /** Provides an animated 0-to-1 visibility value and the native event duration. */
6
+ export function useKeyboardAnimation(): {
7
+ progress: Animated.Value;
8
+ animationDuration: number;
9
+ } {
10
+ const keyboard = useKeyboardState();
11
+ const progressRef = useRef<Animated.Value | null>(null);
12
+ if (!progressRef.current) {
13
+ progressRef.current = new Animated.Value(keyboard.progress);
14
+ }
15
+ const progress = progressRef.current;
16
+ const [reduceMotion, setReduceMotion] = useState(false);
17
+
18
+ useEffect(() => {
19
+ let active = true;
20
+ AccessibilityInfo.isReduceMotionEnabled()
21
+ .then(enabled => {
22
+ if (active) {
23
+ setReduceMotion(enabled);
24
+ }
25
+ })
26
+ .catch(() => {
27
+ // Keep animations enabled when the platform preference cannot be read.
28
+ });
29
+ const subscription = AccessibilityInfo.addEventListener(
30
+ 'reduceMotionChanged',
31
+ setReduceMotion,
32
+ );
33
+ return () => {
34
+ active = false;
35
+ subscription.remove();
36
+ };
37
+ }, []);
38
+
39
+ useEffect(() => {
40
+ const animation = Animated.timing(progress, {
41
+ toValue: keyboard.progress,
42
+ duration: reduceMotion ? 0 : keyboard.animationDuration,
43
+ useNativeDriver: true,
44
+ });
45
+ animation.start();
46
+ return () => animation.stop();
47
+ }, [keyboard.animationDuration, keyboard.progress, progress, reduceMotion]);
48
+
49
+ return { progress, animationDuration: keyboard.animationDuration };
50
+ }
@@ -0,0 +1,14 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import {
3
+ getKeyboardState,
4
+ subscribeKeyboardState,
5
+ } from '../utils/keyboardEvents';
6
+
7
+ /** Subscribe to the shared, normalized keyboard state. A provider is optional. */
8
+ export function useKeyboardState() {
9
+ return useSyncExternalStore(
10
+ subscribeKeyboardState,
11
+ getKeyboardState,
12
+ getKeyboardState,
13
+ );
14
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ export {
2
+ KeyboardAwareScrollView,
3
+ KeyboardAwareView,
4
+ KeyboardDismissButton,
5
+ KeyboardToolbar,
6
+ } from './components';
7
+ export { KeyboardProvider } from './context';
8
+ export {
9
+ useFocusedInput,
10
+ useKeyboard,
11
+ useKeyboardAnimation,
12
+ useKeyboardState,
13
+ } from './hooks';
14
+ export {
15
+ calculateScrollOffset,
16
+ calculateKeyboardOverlap,
17
+ focusInput,
18
+ getFocusedInput,
19
+ isAndroid,
20
+ isIOS,
21
+ measureFocusedInput,
22
+ } from './utils';
23
+ export type {
24
+ FocusedInputLayout,
25
+ KeyboardAwareScrollViewProps,
26
+ KeyboardAwareScrollViewRef,
27
+ KeyboardAwareViewProps,
28
+ KeyboardAwareViewRef,
29
+ KeyboardBehavior,
30
+ KeyboardDismissButtonProps,
31
+ KeyboardProviderProps,
32
+ KeyboardState,
33
+ KeyboardToolbarProps,
34
+ TextInputRef,
35
+ } from './types';
@@ -0,0 +1,101 @@
1
+ import type {
2
+ ScrollView,
3
+ ScrollViewProps,
4
+ StyleProp,
5
+ TextInput,
6
+ View,
7
+ ViewProps,
8
+ ViewStyle,
9
+ } from 'react-native';
10
+ import type {
11
+ ComponentPropsWithRef,
12
+ ElementRef,
13
+ ReactNode,
14
+ RefObject,
15
+ } from 'react';
16
+
17
+ type RefTarget<T> = T extends RefObject<infer Value>
18
+ ? NonNullable<Value>
19
+ : never;
20
+
21
+ export type TextInputRef = RefTarget<
22
+ NonNullable<ComponentPropsWithRef<typeof TextInput>['ref']>
23
+ >;
24
+
25
+ export type KeyboardAwareScrollViewRef = ElementRef<typeof ScrollView>;
26
+ export type KeyboardAwareViewRef = ElementRef<typeof View>;
27
+
28
+ /** Layout strategies supported by the keyboard-aware components. */
29
+ export type KeyboardBehavior =
30
+ | 'smart'
31
+ | 'scroll'
32
+ | 'resize'
33
+ | 'padding'
34
+ | 'none';
35
+
36
+ /** A normalized snapshot of the most recent on-screen keyboard event. */
37
+ export interface KeyboardState {
38
+ isVisible: boolean;
39
+ height: number;
40
+ progress: 0 | 1;
41
+ animationDuration: number;
42
+ screenY?: number;
43
+ }
44
+
45
+ export interface KeyboardAwareScrollViewProps extends ScrollViewProps {
46
+ /** Defaults to `smart`, which scrolls only when the focused field is covered. */
47
+ behavior?: KeyboardBehavior;
48
+ /** Space to leave between the focused field and keyboard, in points. */
49
+ keyboardSpacing?: number;
50
+ /** Disable keyboard-driven padding and focus scrolling. */
51
+ enabled?: boolean;
52
+ }
53
+
54
+ export interface KeyboardAwareViewProps extends ViewProps {
55
+ /** Defaults to `smart`. `resize` relies on the platform window resize behavior. */
56
+ behavior?: KeyboardBehavior;
57
+ /** Extra space between the view content and the keyboard, in points. */
58
+ keyboardSpacing?: number;
59
+ /** Disable keyboard-driven layout changes. */
60
+ enabled?: boolean;
61
+ }
62
+
63
+ export interface KeyboardProviderProps {
64
+ children: ReactNode;
65
+ /** Log keyboard state changes in development builds. */
66
+ debug?: boolean;
67
+ }
68
+
69
+ export interface KeyboardToolbarProps extends ViewProps {
70
+ /** Refs to the form inputs the toolbar should navigate between. */
71
+ inputRefs?: readonly RefObject<TextInputRef | null>[];
72
+ /** @deprecated Focus is detected automatically from the provided input refs. */
73
+ focusedInputIndex?: number;
74
+ /** Called when the focused input in `inputRefs` changes. */
75
+ onFocusedInputChange?: (index: number) => void;
76
+ /** Called after the Done action dismisses the keyboard. */
77
+ onDone?: () => void;
78
+ /** Label shown by the built-in Done button. */
79
+ doneText?: string;
80
+ /** Replaces the built-in previous/next/done controls. */
81
+ children?: ReactNode;
82
+ /** Render the toolbar while the keyboard is hidden. Defaults to false. */
83
+ alwaysVisible?: boolean;
84
+ }
85
+
86
+ export interface KeyboardDismissButtonProps extends ViewProps {
87
+ children: ReactNode;
88
+ /** Custom press handler. The keyboard is also dismissed unless disabled. */
89
+ onPress?: () => void;
90
+ /** Defaults to true. */
91
+ dismissKeyboard?: boolean;
92
+ accessibilityLabel?: string;
93
+ style?: StyleProp<ViewStyle>;
94
+ }
95
+
96
+ export interface FocusedInputLayout {
97
+ x: number;
98
+ y: number;
99
+ width: number;
100
+ height: number;
101
+ }
@@ -0,0 +1,2 @@
1
+ export const DEFAULT_KEYBOARD_SPACING = 16;
2
+ export const DEFAULT_ANIMATION_DURATION = 250;
@@ -0,0 +1,40 @@
1
+ import { TextInput } from 'react-native';
2
+ import type { RefObject } from 'react';
3
+ import type { FocusedInputLayout, TextInputRef } from '../types';
4
+
5
+ type Measurable = Pick<TextInputRef, 'measureInWindow'>;
6
+
7
+ /** Returns the currently focused native TextInput when React Native exposes it. */
8
+ export function getFocusedInput(): Measurable | null {
9
+ const input = TextInput.State.currentlyFocusedInput?.();
10
+ if (input) {
11
+ return input;
12
+ }
13
+
14
+ return null;
15
+ }
16
+
17
+ /** Measure the focused TextInput in window coordinates. */
18
+ export function measureFocusedInput(
19
+ input: Measurable | null = getFocusedInput(),
20
+ ): Promise<FocusedInputLayout | null> {
21
+ if (!input?.measureInWindow) {
22
+ return Promise.resolve(null);
23
+ }
24
+
25
+ return new Promise(resolve => {
26
+ input.measureInWindow?.((x, y, width, height) => {
27
+ resolve({ x, y, width, height });
28
+ });
29
+ });
30
+ }
31
+
32
+ /** Request focus on a TextInput ref and return whether a native input was available. */
33
+ export function focusInput(input: RefObject<TextInputRef | null>): boolean {
34
+ const node = input.current;
35
+ if (!node) {
36
+ return false;
37
+ }
38
+ node.focus();
39
+ return true;
40
+ }
@@ -0,0 +1,10 @@
1
+ export {
2
+ calculateKeyboardOverlap,
3
+ calculateScrollOffset,
4
+ } from './scrollManager';
5
+ export {
6
+ focusInput,
7
+ getFocusedInput,
8
+ measureFocusedInput,
9
+ } from './focusManager';
10
+ export { isAndroid, isIOS } from './platform';
@@ -0,0 +1,110 @@
1
+ import {
2
+ Keyboard,
3
+ type KeyboardEvent,
4
+ type EmitterSubscription,
5
+ } from 'react-native';
6
+ import { DEFAULT_ANIMATION_DURATION } from './constants';
7
+ import { getKeyboardEventNames, isIOS } from './platform';
8
+ import type { KeyboardState } from '../types';
9
+
10
+ const initialState: KeyboardState = {
11
+ isVisible: false,
12
+ height: 0,
13
+ progress: 0,
14
+ animationDuration: 0,
15
+ };
16
+
17
+ let state = initialState;
18
+ let listeners: EmitterSubscription[] = [];
19
+ const subscribers = new Set<() => void>();
20
+
21
+ function update(event: KeyboardEvent, visible: boolean): void {
22
+ const coordinates = event.endCoordinates;
23
+ state = {
24
+ isVisible: visible,
25
+ height: visible ? Math.max(0, coordinates?.height ?? 0) : 0,
26
+ progress: visible ? 1 : 0,
27
+ animationDuration: Math.max(
28
+ 0,
29
+ event.duration ?? DEFAULT_ANIMATION_DURATION,
30
+ ),
31
+ ...(visible && typeof coordinates?.screenY === 'number'
32
+ ? { screenY: coordinates.screenY }
33
+ : {}),
34
+ };
35
+ subscribers.forEach(subscriber => subscriber());
36
+ }
37
+
38
+ function syncCurrentKeyboardState(): void {
39
+ const metrics =
40
+ typeof Keyboard.metrics === 'function' ? Keyboard.metrics() : undefined;
41
+ const isVisible =
42
+ typeof Keyboard.isVisible === 'function'
43
+ ? Keyboard.isVisible()
44
+ : metrics != null;
45
+ if (!isVisible || !metrics) {
46
+ return;
47
+ }
48
+
49
+ state = {
50
+ isVisible: true,
51
+ height: metrics.height,
52
+ progress: 1,
53
+ animationDuration: 0,
54
+ screenY: metrics.screenY,
55
+ };
56
+ subscribers.forEach(subscriber => subscriber());
57
+ }
58
+
59
+ function startListening(): void {
60
+ const names = getKeyboardEventNames();
61
+ if (isIOS && names.frameChange) {
62
+ listeners = [
63
+ Keyboard.addListener(names.frameChange, event =>
64
+ update(event, event.endCoordinates.height > 0),
65
+ ),
66
+ ];
67
+ syncCurrentKeyboardState();
68
+ return;
69
+ }
70
+
71
+ const showSubscription = Keyboard.addListener(names.show, event =>
72
+ update(event, true),
73
+ );
74
+ const hideSubscription = Keyboard.addListener(names.hide, event =>
75
+ update(event, false),
76
+ );
77
+ listeners = [showSubscription, hideSubscription];
78
+ syncCurrentKeyboardState();
79
+ }
80
+
81
+ function stopListening(): void {
82
+ listeners.forEach(subscription => subscription.remove());
83
+ listeners = [];
84
+ }
85
+
86
+ /** Subscribe to the shared keyboard event stream. */
87
+ export function subscribeKeyboardState(subscriber: () => void): () => void {
88
+ subscribers.add(subscriber);
89
+ if (subscribers.size === 1) {
90
+ startListening();
91
+ }
92
+ return () => {
93
+ subscribers.delete(subscriber);
94
+ if (subscribers.size === 0) {
95
+ stopListening();
96
+ }
97
+ };
98
+ }
99
+
100
+ /** Return the stable current snapshot used by `useSyncExternalStore`. */
101
+ export function getKeyboardState(): KeyboardState {
102
+ return state;
103
+ }
104
+
105
+ /** Reset the shared state and event listeners; intended for tests. */
106
+ export function resetKeyboardStateForTests(): void {
107
+ stopListening();
108
+ subscribers.clear();
109
+ state = initialState;
110
+ }
@@ -0,0 +1,23 @@
1
+ import { Platform } from 'react-native';
2
+ import type { KeyboardEventName } from 'react-native';
3
+
4
+ /** True when running on iOS. */
5
+ export const isIOS = Platform.OS === 'ios';
6
+
7
+ /** True when running on Android. */
8
+ export const isAndroid = Platform.OS === 'android';
9
+
10
+ /** Selects the native keyboard event names for the current platform. */
11
+ export function getKeyboardEventNames(): {
12
+ show: KeyboardEventName;
13
+ hide: KeyboardEventName;
14
+ frameChange?: KeyboardEventName;
15
+ } {
16
+ return isIOS
17
+ ? {
18
+ show: 'keyboardWillShow',
19
+ hide: 'keyboardWillHide',
20
+ frameChange: 'keyboardWillChangeFrame',
21
+ }
22
+ : { show: 'keyboardDidShow', hide: 'keyboardDidHide' };
23
+ }
@@ -0,0 +1,59 @@
1
+ export interface ScrollViewport {
2
+ y: number;
3
+ height: number;
4
+ }
5
+
6
+ export interface ScrollInput {
7
+ y: number;
8
+ height: number;
9
+ }
10
+
11
+ export interface ScrollCalculation {
12
+ offset: number;
13
+ delta: number;
14
+ }
15
+
16
+ /** Return the part of the measured view actually covered by the keyboard. */
17
+ export function calculateKeyboardOverlap(
18
+ viewport: ScrollViewport,
19
+ keyboardHeight: number,
20
+ keyboardTop?: number,
21
+ ): number {
22
+ const viewportBottom = viewport.y + viewport.height;
23
+ const obscuredTop =
24
+ typeof keyboardTop === 'number'
25
+ ? Math.max(viewport.y, Math.min(viewportBottom, keyboardTop))
26
+ : Math.max(viewport.y, viewportBottom - Math.max(0, keyboardHeight));
27
+
28
+ return Math.max(0, viewportBottom - obscuredTop);
29
+ }
30
+
31
+ /**
32
+ * Calculate the smallest scroll adjustment needed to bring an input into the
33
+ * visible viewport. `keyboardTop` avoids double-counting Android adjustResize.
34
+ */
35
+ export function calculateScrollOffset(
36
+ input: ScrollInput,
37
+ viewport: ScrollViewport,
38
+ keyboardHeight: number,
39
+ spacing: number,
40
+ currentOffset: number,
41
+ keyboardTop?: number,
42
+ ): ScrollCalculation {
43
+ const viewportBottom = viewport.y + viewport.height;
44
+ const unobscuredBottom =
45
+ viewportBottom -
46
+ calculateKeyboardOverlap(viewport, keyboardHeight, keyboardTop) -
47
+ spacing;
48
+ const unobscuredTop = viewport.y + spacing;
49
+ const inputBottom = input.y + input.height;
50
+
51
+ let delta = 0;
52
+ if (inputBottom > unobscuredBottom) {
53
+ delta = inputBottom - unobscuredBottom;
54
+ } else if (input.y < unobscuredTop) {
55
+ delta = input.y - unobscuredTop;
56
+ }
57
+
58
+ return { delta, offset: Math.max(0, currentOffset + delta) };
59
+ }