react-native-keyboard-toolkit 0.0.0-stage → 1.0.1

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 +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +256 -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 +112 -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 +21 -0
  43. package/dist/utils/scrollManager.js +37 -0
  44. package/docs/android.md +31 -0
  45. package/docs/api.md +78 -0
  46. package/docs/assets/demo-preview.svg +24 -0
  47. package/docs/installation.md +22 -0
  48. package/docs/ios.md +27 -0
  49. package/docs/troubleshooting.md +44 -0
  50. package/package.json +90 -4
  51. package/src/components/KeyboardAwareScrollView.tsx +240 -0
  52. package/src/components/KeyboardAwareView.tsx +172 -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 +71 -0
@@ -0,0 +1,21 @@
1
+ export interface ScrollViewport {
2
+ y: number;
3
+ height: number;
4
+ }
5
+ export interface ScrollInput {
6
+ y: number;
7
+ height: number;
8
+ }
9
+ export interface ScrollCalculation {
10
+ offset: number;
11
+ delta: number;
12
+ }
13
+ /** Return how far a bottom edge must move to clear the keyboard and spacing. */
14
+ export declare function calculateKeyboardDisplacement(viewport: ScrollViewport, keyboardTop: number, spacing: number): number;
15
+ /** Return the part of the measured view actually covered by the keyboard. */
16
+ export declare function calculateKeyboardOverlap(viewport: ScrollViewport, keyboardHeight: number, keyboardTop?: number): number;
17
+ /**
18
+ * Calculate the smallest scroll adjustment needed to bring an input into the
19
+ * visible viewport. `keyboardTop` avoids double-counting Android adjustResize.
20
+ */
21
+ export declare function calculateScrollOffset(input: ScrollInput, viewport: ScrollViewport, keyboardHeight: number, spacing: number, currentOffset: number, keyboardTop?: number): ScrollCalculation;
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.calculateKeyboardDisplacement = calculateKeyboardDisplacement;
4
+ exports.calculateKeyboardOverlap = calculateKeyboardOverlap;
5
+ exports.calculateScrollOffset = calculateScrollOffset;
6
+ /** Return how far a bottom edge must move to clear the keyboard and spacing. */
7
+ function calculateKeyboardDisplacement(viewport, keyboardTop, spacing) {
8
+ return Math.max(0, viewport.y + viewport.height - keyboardTop + Math.max(0, spacing));
9
+ }
10
+ /** Return the part of the measured view actually covered by the keyboard. */
11
+ function calculateKeyboardOverlap(viewport, keyboardHeight, keyboardTop) {
12
+ const viewportBottom = viewport.y + viewport.height;
13
+ const obscuredTop = typeof keyboardTop === 'number'
14
+ ? Math.max(viewport.y, Math.min(viewportBottom, keyboardTop))
15
+ : Math.max(viewport.y, viewportBottom - Math.max(0, keyboardHeight));
16
+ return Math.max(0, viewportBottom - obscuredTop);
17
+ }
18
+ /**
19
+ * Calculate the smallest scroll adjustment needed to bring an input into the
20
+ * visible viewport. `keyboardTop` avoids double-counting Android adjustResize.
21
+ */
22
+ function calculateScrollOffset(input, viewport, keyboardHeight, spacing, currentOffset, keyboardTop) {
23
+ const viewportBottom = viewport.y + viewport.height;
24
+ const unobscuredBottom = viewportBottom -
25
+ calculateKeyboardOverlap(viewport, keyboardHeight, keyboardTop) -
26
+ spacing;
27
+ const unobscuredTop = viewport.y + spacing;
28
+ const inputBottom = input.y + input.height;
29
+ let delta = 0;
30
+ if (inputBottom > unobscuredBottom) {
31
+ delta = inputBottom - unobscuredBottom;
32
+ }
33
+ else if (input.y < unobscuredTop) {
34
+ delta = input.y - unobscuredTop;
35
+ }
36
+ return { delta, offset: Math.max(0, currentOffset + delta) };
37
+ }
@@ -0,0 +1,31 @@
1
+ # Android testing and setup
2
+
3
+ The library listens to `keyboardDidShow` / `keyboardDidHide`, measures native
4
+ input and scroll-view frames, and reads the reported keyboard screen coordinate
5
+ when available. It does not edit `MainActivity`, `MainApplication`, or the
6
+ manifest.
7
+
8
+ For common form behavior, configure the activity to use `adjustResize`. The
9
+ repository app already uses this setting. The components intersect the measured
10
+ viewport with the keyboard's reported top frame; if `adjustResize` has already
11
+ ended the viewport at that frame, the measured overlap is zero and no second
12
+ keyboard-height inset is added. OEM keyboards and edge-to-edge configurations
13
+ can still report coordinates relative to different window origins.
14
+
15
+ For a sticky submit action, put an absolutely positioned
16
+ `KeyboardAwareView` outside the scroll view. With `adjustResize`, Android may
17
+ already move the footer as the window changes size; the measured keyboard frame
18
+ is used to apply only any remaining displacement and configured spacing.
19
+
20
+ ## Device checklist
21
+
22
+ - Test Android 10+ devices/emulators and the app's minimum supported OS.
23
+ - Try both gesture and three-button navigation.
24
+ - Test `adjustResize` and, if the app supports it, `adjustPan`.
25
+ - Test status/navigation bars, edge-to-edge, and any translucent system bars.
26
+ - Test multiline inputs, modal screens, nested scroll views, and input focus
27
+ during navigation transitions.
28
+ - Try at least one hardware keyboard and one alternate software keyboard.
29
+
30
+ This project has not been validated on physical Android devices. Do not treat
31
+ these notes as device compatibility claims.
package/docs/api.md ADDED
@@ -0,0 +1,78 @@
1
+ # API reference
2
+
3
+ ## `KeyboardAwareScrollView`
4
+
5
+ Extends React Native `ScrollViewProps`.
6
+
7
+ - `behavior?: 'smart' | 'scroll' | 'resize' | 'padding' | 'none'` (default
8
+ `'smart'`): smart and scroll behaviors minimally scroll a covered focused
9
+ input; padding extends the scroll range without focus scrolling; resize uses
10
+ automatic keyboard insets on iOS and relies on window resizing on Android;
11
+ none disables keyboard adjustment.
12
+ - `keyboardSpacing?: number` (default `16`): spacing in points.
13
+ - `enabled?: boolean` (default `true`): disable keyboard adjustments.
14
+
15
+ The component measures its viewport and the focused native input. It adds
16
+ scrollable bottom space based on measured viewport/keyboard-frame overlap,
17
+ avoiding blindly adding the full keyboard height when the OS has resized the
18
+ window. Existing numeric bottom padding is preserved. Its ref points to the
19
+ underlying React Native `ScrollView`.
20
+
21
+ ## `KeyboardAwareView`
22
+
23
+ Extends `ViewProps`. Supports `behavior`, `keyboardSpacing`, and `enabled`.
24
+ Smart, scroll, and padding add bottom padding for the measured overlap. Resize
25
+ and none do not adjust the layout. When `style.position` is `absolute`, the
26
+ component animates the view upward by the distance from its measured bottom
27
+ edge to the keyboard frame, plus `keyboardSpacing`. It returns to its original
28
+ position when the keyboard hides. This view does not scroll to reveal a hidden
29
+ child. Its ref points to the underlying React Native `View`.
30
+
31
+ ## `KeyboardProvider`
32
+
33
+ `<KeyboardProvider debug={false}>children</KeyboardProvider>`. It is optional;
34
+ set `debug` to log state changes in development.
35
+
36
+ ## `KeyboardToolbar`
37
+
38
+ Extends `ViewProps` and accepts:
39
+
40
+ - `inputRefs?: readonly RefObject<TextInputRef | null>[]`: refs ordered for
41
+ Previous/Next navigation. The currently focused ref is detected
42
+ automatically.
43
+ - `onFocusedInputChange?: (index: number) => void`: called when a supplied ref
44
+ becomes or ceases to be focused; `-1` means no supplied ref is focused.
45
+ - `onDone?: () => void`, `doneText?: string`, and `alwaysVisible?: boolean`.
46
+ - `children?: ReactNode`: replaces the default controls.
47
+
48
+ Previous and Next skip null/unmounted refs and are disabled when no mounted
49
+ input exists in that direction. Done blurs the focused native input when
50
+ available, calls `Keyboard.dismiss()`, then invokes `onDone`. The deprecated
51
+ `focusedInputIndex` prop remains supported for compatibility; it is not needed
52
+ for automatic focus tracking.
53
+
54
+ ## `KeyboardDismissButton`
55
+
56
+ Accepts `children`, `onPress`, `dismissKeyboard` (default `true`),
57
+ `accessibilityLabel`, and React Native `ViewProps`. The optional callback runs
58
+ before keyboard dismissal.
59
+
60
+ ## Hooks
61
+
62
+ - `useKeyboardState()` and its alias `useKeyboard()` return a stable
63
+ `KeyboardState`: `{ isVisible, height, progress, animationDuration, screenY? }`.
64
+ - `useKeyboardAnimation()` returns `{ progress: Animated.Value,
65
+ animationDuration }`.
66
+ - `useFocusedInput()` returns `{ focusedInput, refresh }`; the layout is
67
+ window-relative or `null` when the focused input cannot be measured.
68
+
69
+ The package entry point also exports `calculateKeyboardOverlap`,
70
+ `calculateScrollOffset`, `focusInput`, `getFocusedInput`, `isAndroid`, `isIOS`,
71
+ `measureFocusedInput`, and the `TextInputRef` type used by toolbar refs. The
72
+ `KeyboardAwareScrollViewRef` and `KeyboardAwareViewRef` types describe the
73
+ corresponding component refs.
74
+
75
+ `calculateKeyboardOverlap(viewport, keyboardHeight, keyboardTop?)` returns the
76
+ measured overlap. `calculateScrollOffset(input, viewport, keyboardHeight,
77
+ spacing, currentOffset, keyboardTop?)` returns `{ offset, delta }`. The
78
+ coordinate values must use the same window-coordinate system.
@@ -0,0 +1,24 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-labelledby="title description">
2
+ <title id="title">Keyboard Shepherd illustrative demo preview</title>
3
+ <desc id="description">An illustration of a keyboard-aware profile form above a software keyboard.</desc>
4
+ <rect width="960" height="540" fill="#f4f6fb"/>
5
+ <text x="54" y="58" fill="#101828" font-family="Arial, sans-serif" font-size="26" font-weight="700">Keyboard Shepherd</text>
6
+ <text x="54" y="86" fill="#667085" font-family="Arial, sans-serif" font-size="15">Illustrative form layout — keyboard-aware scrolling</text>
7
+ <rect x="250" y="110" width="460" height="290" rx="18" fill="#ffffff" stroke="#d0d5dd"/>
8
+ <text x="282" y="151" fill="#101828" font-family="Arial, sans-serif" font-size="21" font-weight="700">Create your profile</text>
9
+ <text x="282" y="181" fill="#475467" font-family="Arial, sans-serif" font-size="13">Email</text>
10
+ <rect x="282" y="190" width="396" height="42" rx="8" fill="#ffffff" stroke="#98a2b3"/>
11
+ <text x="296" y="216" fill="#667085" font-family="Arial, sans-serif" font-size="14">name@example.com</text>
12
+ <text x="282" y="258" fill="#475467" font-family="Arial, sans-serif" font-size="13">Description</text>
13
+ <rect x="282" y="267" width="396" height="62" rx="8" fill="#ffffff" stroke="#98a2b3"/>
14
+ <text x="296" y="293" fill="#667085" font-family="Arial, sans-serif" font-size="14">Input remains visible while typing</text>
15
+ <rect x="282" y="345" width="396" height="38" rx="8" fill="#155eef"/>
16
+ <text x="480" y="370" fill="#ffffff" font-family="Arial, sans-serif" font-size="14" font-weight="700" text-anchor="middle">Submit form</text>
17
+ <rect x="0" y="420" width="960" height="120" fill="#e4e7ec"/>
18
+ <rect x="24" y="434" width="912" height="30" rx="7" fill="#ffffff"/>
19
+ <text x="480" y="454" fill="#475467" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">Keyboard toolbar: Previous Next Done</text>
20
+ <g fill="#ffffff">
21
+ <rect x="38" y="475" width="84" height="25" rx="5"/><rect x="132" y="475" width="84" height="25" rx="5"/><rect x="226" y="475" width="84" height="25" rx="5"/><rect x="320" y="475" width="84" height="25" rx="5"/><rect x="414" y="475" width="84" height="25" rx="5"/><rect x="508" y="475" width="84" height="25" rx="5"/><rect x="602" y="475" width="84" height="25" rx="5"/><rect x="696" y="475" width="84" height="25" rx="5"/><rect x="790" y="475" width="84" height="25" rx="5"/>
22
+ <rect x="82" y="507" width="84" height="25" rx="5"/><rect x="176" y="507" width="84" height="25" rx="5"/><rect x="270" y="507" width="84" height="25" rx="5"/><rect x="364" y="507" width="84" height="25" rx="5"/><rect x="458" y="507" width="84" height="25" rx="5"/><rect x="552" y="507" width="84" height="25" rx="5"/><rect x="646" y="507" width="84" height="25" rx="5"/><rect x="740" y="507" width="84" height="25" rx="5"/>
23
+ </g>
24
+ </svg>
@@ -0,0 +1,22 @@
1
+ # Installation
2
+
3
+ ```sh
4
+ npm install react-native-keyboard-toolkit
5
+ ```
6
+
7
+ React and React Native are peer dependencies. The package uses only React Native
8
+ JavaScript APIs, so it does not require native linking, a Pod, a config plugin,
9
+ or a custom Expo development client.
10
+
11
+ ## React Native CLI
12
+
13
+ 1. Install the package in the app.
14
+ 2. Use `KeyboardAwareScrollView` around the form content.
15
+ 3. On Android, keep the activity's `windowSoftInputMode` at `adjustResize` for
16
+ the conventional resizable-window behavior.
17
+
18
+ ## Expo
19
+
20
+ Use the package from an Expo app as you would any JavaScript-only dependency.
21
+ The keyboard APIs are included in Expo Go. The development demo lives in the
22
+ repository's React Native host app; it is not a generated Expo project.
package/docs/ios.md ADDED
@@ -0,0 +1,27 @@
1
+ # iOS testing
2
+
3
+ The library consumes iOS keyboard frame-change events and uses
4
+ `measureInWindow` for the focused field and its scroll viewport. The intersection
5
+ of those measured window coordinates with the keyboard frame determines the
6
+ temporary scroll range, so safe-area-constrained viewports do not blindly add
7
+ the full keyboard height. The library does not set safe-area, navigation, or
8
+ modal presentation configuration.
9
+
10
+ An absolutely positioned `KeyboardAwareView` uses the same keyboard frame and
11
+ event duration to animate a sticky footer above the keyboard. Place it outside
12
+ the scroll view and account for its height in the scroll view's content padding
13
+ and `keyboardSpacing`.
14
+
15
+ ## Device checklist
16
+
17
+ - Test iPhone and iPad layouts with safe areas and the home indicator.
18
+ - Verify navigation headers and bottom tab bars are outside the measured form
19
+ region or included in its layout.
20
+ - Test multiline inputs, interactive keyboard dismissal, and modal screens.
21
+ - On iPad, test floating and split keyboards where available.
22
+ - Test hardware keyboard attachment and focus transitions.
23
+ - Check input behavior during navigation animations.
24
+
25
+ Floating/split keyboards and transition-time measurements may not describe one
26
+ contiguous obscured area. Automated unit tests cannot validate native keyboard
27
+ geometry; test on actual simulators/devices before release.
@@ -0,0 +1,44 @@
1
+ # Troubleshooting
2
+
3
+ ## The focused field does not scroll
4
+
5
+ - Confirm the input is a native React Native `TextInput`, not an unmeasurable
6
+ third-party wrapper.
7
+ - Ensure the focused input belongs to the scroll view and has completed layout.
8
+ - Check that `enabled` is true and the behavior is `smart` or `scroll`.
9
+ - If a custom input wrapper delays focus, call `refresh()` from
10
+ `useFocusedInput` after its layout settles.
11
+
12
+ ## A sticky submit button is behind the keyboard
13
+
14
+ Wrap the footer in `KeyboardAwareView` and set `position: 'absolute'` on that
15
+ component's style. The view then measures its bottom edge and moves above the
16
+ keyboard by the covered distance plus `keyboardSpacing`. Keep it outside the
17
+ scroll view and give the scroll content enough bottom padding to scroll above
18
+ the footer.
19
+
20
+ ## Android content is moved twice
21
+
22
+ Use `adjustResize` as the standard window mode and avoid adding a second
23
+ keyboard-height spacer around the same content. The scroll view uses the event
24
+ screen coordinate where available, but unusual edge-to-edge or OEM keyboard
25
+ configurations may need a custom layout.
26
+
27
+ ## Keyboard height is zero or unexpected
28
+
29
+ Hardware keyboards may not open a software keyboard. Floating and split
30
+ keyboards can occupy only part of the screen and do not have a useful full-width
31
+ height. Keyboard-reported heights can differ with navigation bars and OS
32
+ versions.
33
+
34
+ ## Nested views add too much space
35
+
36
+ Use one keyboard-padding component per layout region. A nested aware view and
37
+ scroll view both respond to the same keyboard state; disable one with
38
+ `behavior="resize"` or `enabled={false}` when only one should adjust.
39
+
40
+ ## The keyboard hides a modal field
41
+
42
+ Place the scroll view inside the modal content and verify the modal's measured
43
+ window frame. Presentation styles and window insets vary; test on the target
44
+ platform rather than assuming the main screen's coordinates apply to a modal.
package/package.json CHANGED
@@ -1,6 +1,92 @@
1
1
  {
2
2
  "name": "react-native-keyboard-toolkit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Keyboard-aware scrolling, views, sticky form actions, and keyboard hooks for React Native on iOS and Android.",
5
+ "author": "Denish Dabhi <dabhidenish57@gmail.com>",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/DenishDabhi/react-native-keyboard-toolkit#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/DenishDabhi/react-native-keyboard-toolkit.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/DenishDabhi/react-native-keyboard-toolkit/issues"
14
+ },
15
+ "main": "dist/index.js",
16
+ "react-native": "src/index.ts",
17
+ "types": "dist/index.d.ts",
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "docs",
22
+ "README.md",
23
+ "CHANGELOG.md",
24
+ "LICENSE"
25
+ ],
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "react-native": "./src/index.ts",
30
+ "default": "./dist/index.js"
31
+ }
32
+ },
33
+ "sideEffects": false,
34
+ "keywords": [
35
+ "react-native",
36
+ "keyboard",
37
+ "keyboard-aware",
38
+ "keyboard-management",
39
+ "keyboard-avoiding",
40
+ "scrollview",
41
+ "textinput",
42
+ "expo",
43
+ "ios",
44
+ "android",
45
+ "typescript",
46
+ "keyboard-dismiss",
47
+ "keyboard-handling",
48
+ "sticky-button",
49
+ "sticky-footer"
50
+ ],
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "scripts": {
55
+ "android": "react-native run-android",
56
+ "ios": "react-native run-ios",
57
+ "build": "tsc -p tsconfig.build.json",
58
+ "typecheck": "tsc --noEmit -p tsconfig.json",
59
+ "lint": "eslint src tests demo App.tsx --ext .ts,.tsx",
60
+ "format:check": "prettier --check src tests demo App.tsx package.json README.md CHANGELOG.md docs",
61
+ "start": "react-native start",
62
+ "test": "jest --runInBand",
63
+ "validate": "npm run typecheck && npm run lint && npm run format:check && npm test && npm run build && npm pack --dry-run",
64
+ "prepublishOnly": "npm run validate"
65
+ },
66
+ "peerDependencies": {
67
+ "react": ">=18.2.0",
68
+ "react-native": ">=0.71.0"
69
+ },
70
+ "devDependencies": {
71
+ "@babel/core": "^7.25.2",
72
+ "@react-native-community/cli": "20.2.0",
73
+ "@react-native-community/cli-platform-android": "20.2.0",
74
+ "@react-native-community/cli-platform-ios": "20.2.0",
75
+ "@react-native/babel-preset": "0.88.1",
76
+ "@react-native/eslint-config": "0.88.1",
77
+ "@react-native/jest-preset": "0.88.1",
78
+ "@react-native/metro-config": "0.88.1",
79
+ "@react-native/typescript-config": "0.88.1",
80
+ "@types/jest": "^29.5.13",
81
+ "@types/react": "^19.2.0",
82
+ "@types/react-test-renderer": "^19.1.0",
83
+ "eslint": "^8.19.0",
84
+ "jest": "^29.6.3",
85
+ "prettier": "2.8.8",
86
+ "react": "19.3.0",
87
+ "react-native": "0.88.1",
88
+ "react-native-safe-area-context": "^5.5.2",
89
+ "react-test-renderer": "19.3.0",
90
+ "typescript": "^6.0.3"
91
+ }
92
+ }
@@ -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;