diff --git a/FabricExample/__tests__/__snapshots__/components-rendering.spec.tsx.snap b/FabricExample/__tests__/__snapshots__/components-rendering.spec.tsx.snap
index 6897ce4170..9fca9ee417 100644
--- a/FabricExample/__tests__/__snapshots__/components-rendering.spec.tsx.snap
+++ b/FabricExample/__tests__/__snapshots__/components-rendering.spec.tsx.snap
@@ -133,3 +133,38 @@ exports[`components rendering should render \`OverKeyboardView\` 1`] = `
/>
`;
+
+exports[`components rendering should render compound \`KeyboardToolbar\` 1`] = `
+[
+
+
+
+
+
+
+
+
+
+
+ ,
+
+
+ ,
+]
+`;
diff --git a/FabricExample/__tests__/components-rendering.spec.tsx b/FabricExample/__tests__/components-rendering.spec.tsx
index f154e642a9..429542e4c0 100644
--- a/FabricExample/__tests__/components-rendering.spec.tsx
+++ b/FabricExample/__tests__/components-rendering.spec.tsx
@@ -1,6 +1,6 @@
import { render } from "@testing-library/react-native";
import React from "react";
-import { View } from "react-native";
+import { TextInput, View } from "react-native";
import {
KeyboardAvoidingView,
KeyboardAwareScrollView,
@@ -68,6 +68,27 @@ function KeyboardToolbarTest() {
return ;
}
+function KeyboardToolbarCompoundTest() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
function OverKeyboardViewTest() {
return (
@@ -109,6 +130,10 @@ describe("components rendering", () => {
expect(render()).toMatchSnapshot();
});
+ it("should render compound `KeyboardToolbar`", () => {
+ expect(render()).toMatchSnapshot();
+ });
+
it("should render `OverKeyboardView`", () => {
expect(render()).toMatchSnapshot();
});
diff --git a/FabricExample/src/screens/Examples/Toolbar/index.tsx b/FabricExample/src/screens/Examples/Toolbar/index.tsx
index 353bc13baf..51c96a4200 100644
--- a/FabricExample/src/screens/Examples/Toolbar/index.tsx
+++ b/FabricExample/src/screens/Examples/Toolbar/index.tsx
@@ -1,3 +1,4 @@
+import BottomSheet, { BottomSheetView } from "@gorhom/bottom-sheet";
import React, { useCallback, useEffect, useState } from "react";
import { Modal, Platform, StyleSheet, Text, View } from "react-native";
import {
@@ -214,6 +215,24 @@ export default function ToolbarExample({ navigation }: Props) {
+
+
+
+
+
+
+
+
>
);
}
@@ -245,4 +264,7 @@ const styles = StyleSheet.create({
modal: {
marginTop: 32,
},
+ bottomSheetContent: {
+ flex: 1,
+ },
});
diff --git a/android/src/base/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt b/android/src/base/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt
index 22fced3ac3..d3018eac42 100644
--- a/android/src/base/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt
+++ b/android/src/base/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt
@@ -60,5 +60,6 @@ class KeyboardControllerPackage : BaseReactPackage() {
OverKeyboardViewManager(),
KeyboardBackgroundViewManager(),
ClippingScrollViewDecoratorViewManager(),
+ KeyboardToolbarGroupViewManager(),
)
}
diff --git a/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardToolbarGroupViewManager.kt b/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardToolbarGroupViewManager.kt
new file mode 100644
index 0000000000..b8399e90a2
--- /dev/null
+++ b/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardToolbarGroupViewManager.kt
@@ -0,0 +1,23 @@
+package com.reactnativekeyboardcontroller
+
+import com.facebook.react.uimanager.ThemedReactContext
+import com.facebook.react.uimanager.ViewGroupManager
+import com.facebook.react.uimanager.ViewManagerDelegate
+import com.facebook.react.viewmanagers.KeyboardToolbarGroupViewManagerDelegate
+import com.facebook.react.viewmanagers.KeyboardToolbarGroupViewManagerInterface
+import com.reactnativekeyboardcontroller.managers.KeyboardToolbarGroupViewManagerImpl
+import com.reactnativekeyboardcontroller.views.KeyboardToolbarGroupReactViewGroup
+
+class KeyboardToolbarGroupViewManager :
+ ViewGroupManager(),
+ KeyboardToolbarGroupViewManagerInterface {
+ private val manager = KeyboardToolbarGroupViewManagerImpl()
+ private val mDelegate = KeyboardToolbarGroupViewManagerDelegate(this)
+
+ override fun getDelegate(): ViewManagerDelegate = mDelegate
+
+ override fun getName(): String = KeyboardToolbarGroupViewManagerImpl.NAME
+
+ override fun createViewInstance(context: ThemedReactContext): KeyboardToolbarGroupReactViewGroup =
+ manager.createViewInstance(context)
+}
diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputObserver.kt b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputObserver.kt
index 1e7be518b0..4de762dae0 100644
--- a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputObserver.kt
+++ b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputObserver.kt
@@ -120,7 +120,8 @@ class FocusedInputObserver(
selectionSubscription = newFocus.addOnSelectionChangedListener(selectionListener)
FocusedInputHolder.set(newFocus)
- val allInputFields = ViewHierarchyNavigator.getAllInputFields(context?.rootView)
+ val groupAncestor = ViewHierarchyNavigator.findGroupAncestor(newFocus)
+ val allInputFields = ViewHierarchyNavigator.getAllInputFields(groupAncestor ?: context?.rootView)
val currentIndex = allInputFields.indexOf(newFocus)
context.emitEvent(
diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardToolbarGroupViewManagerImpl.kt b/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardToolbarGroupViewManagerImpl.kt
new file mode 100644
index 0000000000..1dd6b5ea8e
--- /dev/null
+++ b/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardToolbarGroupViewManagerImpl.kt
@@ -0,0 +1,13 @@
+package com.reactnativekeyboardcontroller.managers
+
+import com.facebook.react.uimanager.ThemedReactContext
+import com.reactnativekeyboardcontroller.views.KeyboardToolbarGroupReactViewGroup
+
+class KeyboardToolbarGroupViewManagerImpl {
+ fun createViewInstance(reactContext: ThemedReactContext): KeyboardToolbarGroupReactViewGroup =
+ KeyboardToolbarGroupReactViewGroup(reactContext)
+
+ companion object {
+ const val NAME = "KeyboardToolbarGroupView"
+ }
+}
diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/traversal/ViewHierarchyNavigator.kt b/android/src/main/java/com/reactnativekeyboardcontroller/traversal/ViewHierarchyNavigator.kt
index 8a0d6ec4ee..13d43717dd 100644
--- a/android/src/main/java/com/reactnativekeyboardcontroller/traversal/ViewHierarchyNavigator.kt
+++ b/android/src/main/java/com/reactnativekeyboardcontroller/traversal/ViewHierarchyNavigator.kt
@@ -5,6 +5,7 @@ import android.view.ViewGroup
import android.widget.EditText
import com.facebook.react.bridge.UiThreadUtil
import com.reactnativekeyboardcontroller.extensions.focus
+import com.reactnativekeyboardcontroller.views.KeyboardToolbarGroupReactViewGroup
object ViewHierarchyNavigator {
fun setFocusTo(
@@ -25,19 +26,40 @@ object ViewHierarchyNavigator {
fun findEditTexts(view: View?) {
if (isValidTextInput(view)) {
editTexts.add(view as EditText)
- } else if (view is ViewGroup) {
+ } else if (view is ViewGroup && view !is KeyboardToolbarGroupReactViewGroup) {
for (i in 0 until view.childCount) {
findEditTexts(view.getChildAt(i))
}
}
}
- // Start the search with the provided viewGroup
- findEditTexts(viewGroup)
+ // If the root is a group itself, search within it (for group-scoped queries)
+ if (viewGroup is KeyboardToolbarGroupReactViewGroup) {
+ for (i in 0 until viewGroup.childCount) {
+ findEditTexts(viewGroup.getChildAt(i))
+ }
+ } else {
+ findEditTexts(viewGroup)
+ }
return editTexts
}
+ /**
+ * Finds the closest [KeyboardToolbarGroupReactViewGroup] ancestor of the given view.
+ * Returns null if the view is not inside any group.
+ */
+ fun findGroupAncestor(view: View?): KeyboardToolbarGroupReactViewGroup? {
+ var current = view?.parent
+ while (current != null) {
+ if (current is KeyboardToolbarGroupReactViewGroup) {
+ return current
+ }
+ current = current.parent
+ }
+ return null
+ }
+
private fun findNextEditText(currentFocus: View): EditText? = findEditTextInDirection(currentFocus, 1)
private fun findPreviousEditText(currentFocus: View): EditText? = findEditTextInDirection(currentFocus, -1)
@@ -64,6 +86,11 @@ object ViewHierarchyNavigator {
i += direction
}
+ // Don't navigate outside the group boundary
+ if (parentViewGroup is KeyboardToolbarGroupReactViewGroup) {
+ return null
+ }
+
// Recurse to the parent's parent if no sibling EditText is found
return findEditTextInDirection(parentViewGroup, direction)
}
@@ -91,7 +118,7 @@ object ViewHierarchyNavigator {
if (isValidTextInput(child)) {
result = child as EditText
- } else if (child is ViewGroup) {
+ } else if (child is ViewGroup && child !is KeyboardToolbarGroupReactViewGroup) {
// If the child is a ViewGroup, check its children recursively
result = findEditTextInHierarchy(child, direction)
}
diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/views/KeyboardToolbarGroupReactViewGroup.kt b/android/src/main/java/com/reactnativekeyboardcontroller/views/KeyboardToolbarGroupReactViewGroup.kt
new file mode 100644
index 0000000000..09b1b23ef5
--- /dev/null
+++ b/android/src/main/java/com/reactnativekeyboardcontroller/views/KeyboardToolbarGroupReactViewGroup.kt
@@ -0,0 +1,13 @@
+package com.reactnativekeyboardcontroller.views
+
+import android.annotation.SuppressLint
+import android.content.Context
+import com.facebook.react.uimanager.ThemedReactContext
+import com.facebook.react.views.view.ReactViewGroup
+
+@SuppressLint("ViewConstructor")
+class KeyboardToolbarGroupReactViewGroup : ReactViewGroup {
+ constructor(reactContext: ThemedReactContext) : super(reactContext)
+ internal constructor(context: Context) : super(context)
+ // semantic view used in KeyboardToolbar traverse algorithm
+}
diff --git a/android/src/main/jni/RNKC.h b/android/src/main/jni/RNKC.h
index 9916c77dc1..c099b0ab8f 100644
--- a/android/src/main/jni/RNKC.h
+++ b/android/src/main/jni/RNKC.h
@@ -15,6 +15,7 @@
#include
#include
#include
+#include
#include
#include
diff --git a/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardToolbarGroupViewManager.kt b/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardToolbarGroupViewManager.kt
new file mode 100644
index 0000000000..074f8c1791
--- /dev/null
+++ b/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardToolbarGroupViewManager.kt
@@ -0,0 +1,15 @@
+package com.reactnativekeyboardcontroller
+
+import com.facebook.react.uimanager.ThemedReactContext
+import com.facebook.react.views.view.ReactViewManager
+import com.reactnativekeyboardcontroller.managers.KeyboardToolbarGroupViewManagerImpl
+import com.reactnativekeyboardcontroller.views.KeyboardToolbarGroupReactViewGroup
+
+class KeyboardToolbarGroupViewManager : ReactViewManager() {
+ private val manager = KeyboardToolbarGroupViewManagerImpl()
+
+ override fun getName(): String = KeyboardToolbarGroupViewManagerImpl.NAME
+
+ override fun createViewInstance(reactContext: ThemedReactContext): KeyboardToolbarGroupReactViewGroup =
+ manager.createViewInstance(reactContext)
+}
diff --git a/android/src/test/java/com/reactnativekeyboardcontroller/traversal/ViewHierarchyNavigatorGroupTest.kt b/android/src/test/java/com/reactnativekeyboardcontroller/traversal/ViewHierarchyNavigatorGroupTest.kt
new file mode 100644
index 0000000000..939af4c7e5
--- /dev/null
+++ b/android/src/test/java/com/reactnativekeyboardcontroller/traversal/ViewHierarchyNavigatorGroupTest.kt
@@ -0,0 +1,157 @@
+package com.reactnativekeyboardcontroller.traversal
+
+import android.content.Context
+import android.widget.EditText
+import android.widget.LinearLayout
+import androidx.test.core.app.ApplicationProvider
+import com.reactnativekeyboardcontroller.extensions.focus
+import com.reactnativekeyboardcontroller.views.KeyboardToolbarGroupReactViewGroup
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.shadows.ShadowLooper
+
+@RunWith(RobolectricTestRunner::class)
+class ViewHierarchyNavigatorGroupTest {
+ private lateinit var layout: LinearLayout
+ private lateinit var editText1: EditText
+ private lateinit var editText2: EditText
+ private lateinit var groupEditText1: EditText
+ private lateinit var groupEditText2: EditText
+ private lateinit var groupEditText3: EditText
+ private lateinit var editText3: EditText
+ private lateinit var editText4: EditText
+ private lateinit var group: KeyboardToolbarGroupReactViewGroup
+
+ @Before
+ fun setUp() {
+ val context = ApplicationProvider.getApplicationContext()
+
+ editText1 = EditText(context).apply { id = 1 }
+ editText2 = EditText(context).apply { id = 2 }
+ groupEditText1 = EditText(context).apply { id = 3 }
+ groupEditText2 = EditText(context).apply { id = 4 }
+ groupEditText3 = EditText(context).apply { id = 5 }
+ editText3 = EditText(context).apply { id = 6 }
+ editText4 = EditText(context).apply { id = 7 }
+
+ group =
+ KeyboardToolbarGroupReactViewGroup(context).apply {
+ addView(groupEditText1)
+ addView(groupEditText2)
+ addView(groupEditText3)
+ }
+
+ // Layout: editText1, editText2, [group: gET1, gET2, gET3], editText3, editText4
+ layout =
+ LinearLayout(context).apply {
+ addView(editText1)
+ addView(editText2)
+ addView(group)
+ addView(editText3)
+ addView(editText4)
+ }
+ }
+
+ @Test
+ fun `getAllInputFields should not include inputs inside a group`() {
+ val editTexts = ViewHierarchyNavigator.getAllInputFields(layout)
+
+ // Only editText1, editText2, editText3, editText4 (group inputs excluded)
+ assertTrue(editTexts.size == 4)
+ }
+
+ @Test
+ fun `getAllInputFields with group as root should return only group inputs`() {
+ val editTexts = ViewHierarchyNavigator.getAllInputFields(group)
+
+ assertTrue(editTexts.size == 3)
+ }
+
+ @Test
+ fun `setFocusTo 'next' inside group should stay within group`() {
+ groupEditText1.focus()
+
+ ViewHierarchyNavigator.setFocusTo("next", groupEditText1)
+
+ ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
+
+ assertTrue(groupEditText2.hasFocus())
+ }
+
+ @Test
+ fun `setFocusTo 'prev' inside group should stay within group`() {
+ groupEditText3.focus()
+
+ ViewHierarchyNavigator.setFocusTo("prev", groupEditText3)
+
+ ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
+
+ assertTrue(groupEditText2.hasFocus())
+ }
+
+ @Test
+ fun `setFocusTo 'next' at last group input should not leave group`() {
+ groupEditText3.focus()
+
+ ViewHierarchyNavigator.setFocusTo("next", groupEditText3)
+
+ ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
+
+ // Should stay on last group input, not move to editText3
+ assertTrue(groupEditText3.hasFocus())
+ }
+
+ @Test
+ fun `setFocusTo 'prev' at first group input should not leave group`() {
+ groupEditText1.focus()
+
+ ViewHierarchyNavigator.setFocusTo("prev", groupEditText1)
+
+ ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
+
+ // Should stay on first group input, not move to editText2
+ assertTrue(groupEditText1.hasFocus())
+ }
+
+ @Test
+ fun `setFocusTo 'next' outside group should skip group inputs`() {
+ editText2.focus()
+
+ ViewHierarchyNavigator.setFocusTo("next", editText2)
+
+ ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
+
+ // Should skip group and go to editText3
+ assertTrue(editText3.hasFocus())
+ }
+
+ @Test
+ fun `setFocusTo 'prev' outside group should skip group inputs`() {
+ editText3.focus()
+
+ ViewHierarchyNavigator.setFocusTo("prev", editText3)
+
+ ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
+
+ // Should skip group and go to editText2
+ assertTrue(editText2.hasFocus())
+ }
+
+ @Test
+ fun `findGroupAncestor should return group for inputs inside group`() {
+ val ancestor = ViewHierarchyNavigator.findGroupAncestor(groupEditText1)
+
+ assertTrue(ancestor === group)
+ }
+
+ @Test
+ fun `findGroupAncestor should return null for inputs outside group`() {
+ val ancestor = ViewHierarchyNavigator.findGroupAncestor(editText1)
+
+ assertNull(ancestor)
+ }
+}
diff --git a/android/src/turbo/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt b/android/src/turbo/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt
index cd576d51ea..4e61039c9b 100644
--- a/android/src/turbo/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt
+++ b/android/src/turbo/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt
@@ -60,5 +60,7 @@ class KeyboardControllerPackage : TurboReactPackage() {
KeyboardGestureAreaViewManager(),
OverKeyboardViewManager(),
KeyboardBackgroundViewManager(),
+ ClippingScrollViewDecoratorViewManager(),
+ KeyboardToolbarGroupViewManager(),
)
}
diff --git a/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewComponentDescriptor.h b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewComponentDescriptor.h
new file mode 100644
index 0000000000..36605c4b32
--- /dev/null
+++ b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewComponentDescriptor.h
@@ -0,0 +1,20 @@
+#pragma once
+
+#include "RNKCKeyboardToolbarGroupViewShadowNode.h"
+
+#include
+#include
+#include
+
+namespace facebook::react {
+class KeyboardToolbarGroupViewComponentDescriptor final
+ : public ConcreteComponentDescriptor {
+ public:
+ using ConcreteComponentDescriptor::ConcreteComponentDescriptor;
+ void adopt(ShadowNode &shadowNode) const override {
+ react_native_assert(dynamic_cast(&shadowNode));
+ ConcreteComponentDescriptor::adopt(shadowNode);
+ }
+};
+
+} // namespace facebook::react
diff --git a/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewShadowNode.cpp b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewShadowNode.cpp
new file mode 100644
index 0000000000..876bff3cc7
--- /dev/null
+++ b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewShadowNode.cpp
@@ -0,0 +1,7 @@
+#include "RNKCKeyboardToolbarGroupViewShadowNode.h"
+
+namespace facebook::react {
+
+extern const char KeyboardToolbarGroupViewComponentName[] = "KeyboardToolbarGroupView";
+
+} // namespace facebook::react
diff --git a/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewShadowNode.h b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewShadowNode.h
new file mode 100644
index 0000000000..2db75c9f83
--- /dev/null
+++ b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewShadowNode.h
@@ -0,0 +1,23 @@
+#pragma once
+
+#include "RNKCKeyboardToolbarGroupViewState.h"
+
+#include
+#include
+#include
+#include
+
+namespace facebook::react {
+
+JSI_EXPORT extern const char KeyboardToolbarGroupViewComponentName[];
+
+/*
+ * `ShadowNode` for component.
+ */
+using KeyboardToolbarGroupViewShadowNode = ConcreteViewShadowNode<
+ KeyboardToolbarGroupViewComponentName,
+ KeyboardToolbarGroupViewProps,
+ KeyboardToolbarGroupViewEventEmitter,
+ KeyboardToolbarGroupViewState>;
+
+} // namespace facebook::react
diff --git a/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewState.h b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewState.h
new file mode 100644
index 0000000000..a69d6a6208
--- /dev/null
+++ b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewState.h
@@ -0,0 +1,21 @@
+#pragma once
+
+#ifdef ANDROID
+#include
+#endif
+
+namespace facebook::react {
+
+class KeyboardToolbarGroupViewState {
+ public:
+ KeyboardToolbarGroupViewState() = default;
+
+#ifdef ANDROID
+ KeyboardToolbarGroupViewState(KeyboardToolbarGroupViewState const &previousState, folly::dynamic data) {}
+ folly::dynamic getDynamic() const {
+ return {};
+ }
+#endif
+};
+
+} // namespace facebook::react
diff --git a/docs/docs/api/components/keyboard-toolbar/index.mdx b/docs/docs/api/components/keyboard-toolbar/index.mdx
index bf0d023949..8d9f2cb3cc 100644
--- a/docs/docs/api/components/keyboard-toolbar/index.mdx
+++ b/docs/docs/api/components/keyboard-toolbar/index.mdx
@@ -332,6 +332,26 @@ The property that allows to specify custom text for `Done` button.
```
+### ``
+
+This component defines a group of inputs that form an isolated navigation region. When the user presses the prev/next arrows on `KeyboardToolbar`, the focus will only cycle through inputs **within the same group**. Inputs outside the group are not reachable from within, and inputs inside the group are not reachable from outside.
+
+This is useful for scenarios like bottom sheets, tab views, or any UI where a subset of inputs should have independent navigation.
+
+```tsx
+
+
+
+
+
+
+
+```
+
+:::info Toolbar button state
+When a grouped input is focused, the toolbar's prev/next buttons reflect the position within that group (not the global list of inputs). For example, the "prev" button will be disabled when focused on the first input of a group.
+:::
+
## Props
### [`View Props`](https://reactnative.dev/docs/view#props)
diff --git a/example/__tests__/__snapshots__/components-rendering.spec.tsx.snap b/example/__tests__/__snapshots__/components-rendering.spec.tsx.snap
index 13e0011b0c..542d694b5f 100644
--- a/example/__tests__/__snapshots__/components-rendering.spec.tsx.snap
+++ b/example/__tests__/__snapshots__/components-rendering.spec.tsx.snap
@@ -142,3 +142,38 @@ exports[`components rendering should render \`OverKeyboardView\` 1`] = `
/>
`;
+
+exports[`components rendering should render compound \`KeyboardToolbar\` 1`] = `
+[
+
+
+
+
+
+
+
+
+
+
+ ,
+
+
+ ,
+]
+`;
diff --git a/example/__tests__/components-rendering.spec.tsx b/example/__tests__/components-rendering.spec.tsx
index f154e642a9..429542e4c0 100644
--- a/example/__tests__/components-rendering.spec.tsx
+++ b/example/__tests__/components-rendering.spec.tsx
@@ -1,6 +1,6 @@
import { render } from "@testing-library/react-native";
import React from "react";
-import { View } from "react-native";
+import { TextInput, View } from "react-native";
import {
KeyboardAvoidingView,
KeyboardAwareScrollView,
@@ -68,6 +68,27 @@ function KeyboardToolbarTest() {
return ;
}
+function KeyboardToolbarCompoundTest() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
function OverKeyboardViewTest() {
return (
@@ -109,6 +130,10 @@ describe("components rendering", () => {
expect(render()).toMatchSnapshot();
});
+ it("should render compound `KeyboardToolbar`", () => {
+ expect(render()).toMatchSnapshot();
+ });
+
it("should render `OverKeyboardView`", () => {
expect(render()).toMatchSnapshot();
});
diff --git a/example/src/screens/Examples/Toolbar/index.tsx b/example/src/screens/Examples/Toolbar/index.tsx
index 353bc13baf..51c96a4200 100644
--- a/example/src/screens/Examples/Toolbar/index.tsx
+++ b/example/src/screens/Examples/Toolbar/index.tsx
@@ -1,3 +1,4 @@
+import BottomSheet, { BottomSheetView } from "@gorhom/bottom-sheet";
import React, { useCallback, useEffect, useState } from "react";
import { Modal, Platform, StyleSheet, Text, View } from "react-native";
import {
@@ -214,6 +215,24 @@ export default function ToolbarExample({ navigation }: Props) {
+
+
+
+
+
+
+
+
>
);
}
@@ -245,4 +264,7 @@ const styles = StyleSheet.create({
modal: {
marginTop: 32,
},
+ bottomSheetContent: {
+ flex: 1,
+ },
});
diff --git a/ios/KeyboardControllerNative/KeyboardControllerNative.xcodeproj/project.pbxproj b/ios/KeyboardControllerNative/KeyboardControllerNative.xcodeproj/project.pbxproj
index 3c9bae28b7..c80a0bd66d 100644
--- a/ios/KeyboardControllerNative/KeyboardControllerNative.xcodeproj/project.pbxproj
+++ b/ios/KeyboardControllerNative/KeyboardControllerNative.xcodeproj/project.pbxproj
@@ -19,6 +19,7 @@
0837001E2CE8CA4F00D67BBF /* TextInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0837001D2CE8CA4F00D67BBF /* TextInput.swift */; };
0837001F2CE8CA4F00D67BBF /* TextInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0837001D2CE8CA4F00D67BBF /* TextInput.swift */; };
083700202CE8CA4F00D67BBF /* TextInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0837001D2CE8CA4F00D67BBF /* TextInput.swift */; };
+ 083C81EB2F6035A5005EDD4C /* KeyboardToolbarGroupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 083C81EA2F6035A5005EDD4C /* KeyboardToolbarGroupTests.swift */; };
0850CCBA2CB49ECC000C0F8D /* SpringAnimationPerformanceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0850CCB92CB49ECC000C0F8D /* SpringAnimationPerformanceTest.swift */; };
0850CCC22CB49F74000C0F8D /* SpringAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0850CCBF2CB49F74000C0F8D /* SpringAnimation.swift */; };
0850CCC32CB49F74000C0F8D /* SpringAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0850CCBF2CB49F74000C0F8D /* SpringAnimation.swift */; };
@@ -76,6 +77,7 @@
081006AB2C36906800578E07 /* UIScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = UIScrollView.swift; path = ../../extensions/UIScrollView.swift; sourceTree = ""; };
0828F07A2D4BFFDC005D4701 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UIView.swift; path = ../extensions/UIView.swift; sourceTree = SOURCE_ROOT; };
0837001D2CE8CA4F00D67BBF /* TextInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TextInput.swift; path = ../protocols/TextInput.swift; sourceTree = SOURCE_ROOT; };
+ 083C81EA2F6035A5005EDD4C /* KeyboardToolbarGroupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardToolbarGroupTests.swift; sourceTree = ""; };
0850CCB92CB49ECC000C0F8D /* SpringAnimationPerformanceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpringAnimationPerformanceTest.swift; sourceTree = ""; };
0850CCBF2CB49F74000C0F8D /* SpringAnimation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SpringAnimation.swift; path = ../../animations/SpringAnimation.swift; sourceTree = ""; };
0850CCC02CB49F74000C0F8D /* KeyboardAnimation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = KeyboardAnimation.swift; path = ../../animations/KeyboardAnimation.swift; sourceTree = ""; };
@@ -177,6 +179,7 @@
0873ED652BB6B7390004F3A4 /* KeyboardControllerNativeTests */ = {
isa = PBXGroup;
children = (
+ 083C81EA2F6035A5005EDD4C /* KeyboardToolbarGroupTests.swift */,
0873ED662BB6B7390004F3A4 /* KeyboardControllerNativeTests.swift */,
0850CCB92CB49ECC000C0F8D /* SpringAnimationPerformanceTest.swift */,
08833D512CB56DB9007D4380 /* TimingAnimationPerformanceTest.swift */,
@@ -358,6 +361,7 @@
081006AD2C36906900578E07 /* UIScrollView.swift in Sources */,
0850CCCD2CB4A096000C0F8D /* UIUtils.swift in Sources */,
08833D522CB56DB9007D4380 /* TimingAnimationPerformanceTest.swift in Sources */,
+ 083C81EB2F6035A5005EDD4C /* KeyboardToolbarGroupTests.swift in Sources */,
083700202CE8CA4F00D67BBF /* TextInput.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
diff --git a/ios/KeyboardControllerNative/KeyboardControllerNativeTests/KeyboardToolbarGroupTests.swift b/ios/KeyboardControllerNative/KeyboardControllerNativeTests/KeyboardToolbarGroupTests.swift
new file mode 100644
index 0000000000..7b629a693f
--- /dev/null
+++ b/ios/KeyboardControllerNative/KeyboardControllerNativeTests/KeyboardToolbarGroupTests.swift
@@ -0,0 +1,168 @@
+//
+// KeyboardToolbarGroupTests.swift
+// KeyboardControllerNativeTests
+//
+// Created by Kiryl Ziusko on 10/03/2026.
+//
+
+@testable import KeyboardControllerNative
+import XCTest
+
+/// A mock group view whose type name matches what ViewHierarchyNavigator checks.
+class KeyboardToolbarGroupView: UIView {}
+
+final class KeyboardToolbarGroupTests: XCTestCase {
+ var rootView: UIView!
+ var groupView: KeyboardToolbarGroupView!
+ var editText1: TestableInput!
+ var editText2: TestableInput!
+ var groupEditText1: TestableInput!
+ var groupEditText2: TestableInput!
+ var groupEditText3: TestableInput!
+ var editText3: TestableInput!
+ var editText4: TestableInput!
+
+ override func setUpWithError() throws {
+ super.setUp()
+
+ rootView = UIView()
+ groupView = KeyboardToolbarGroupView()
+
+ editText1 = TestableTextField()
+ editText1.tag = 1
+ editText2 = TestableTextView()
+ editText2.tag = 2
+ groupEditText1 = TestableTextField()
+ groupEditText1.tag = 3
+ groupEditText2 = TestableTextView()
+ groupEditText2.tag = 4
+ groupEditText3 = TestableTextField()
+ groupEditText3.tag = 5
+ editText3 = TestableTextView()
+ editText3.tag = 6
+ editText4 = TestableTextField()
+ editText4.tag = 7
+
+ groupView.addSubview(groupEditText1)
+ groupView.addSubview(groupEditText2)
+ groupView.addSubview(groupEditText3)
+
+ // Layout: editText1, editText2, [group: gET1, gET2, gET3], editText3, editText4
+ rootView.addSubview(editText1)
+ rootView.addSubview(editText2)
+ rootView.addSubview(groupView)
+ rootView.addSubview(editText3)
+ rootView.addSubview(editText4)
+ }
+
+ // MARK: - getAllInputFields
+
+ func testGetAllInputFieldsExcludesGroupInputs() {
+ let allFields = ViewHierarchyNavigator.getAllInputFields(root: rootView)
+
+ // Only editText1, editText2, editText3, editText4 (group inputs excluded)
+ XCTAssertEqual(allFields.count, 4)
+ }
+
+ func testGetAllInputFieldsWithGroupRootReturnsOnlyGroupInputs() {
+ let groupFields = ViewHierarchyNavigator.getAllInputFields(root: groupView)
+
+ XCTAssertEqual(groupFields.count, 3)
+ }
+
+ // MARK: - Navigation within group
+
+ func testNextInsideGroupStaysWithinGroup() {
+ FocusedInputHolder.shared.set(groupEditText1 as? TextInput)
+
+ ViewHierarchyNavigator.setFocusTo(direction: "next")
+
+ waitForFocusChange(to: groupEditText2)
+ }
+
+ func testPrevInsideGroupStaysWithinGroup() {
+ FocusedInputHolder.shared.set(groupEditText3 as? TextInput)
+
+ ViewHierarchyNavigator.setFocusTo(direction: "prev")
+
+ waitForFocusChange(to: groupEditText2)
+ }
+
+ // MARK: - Group boundary: cannot leave
+
+ func testNextAtLastGroupInputDoesNotLeaveGroup() {
+ FocusedInputHolder.shared.set(groupEditText3 as? TextInput)
+
+ ViewHierarchyNavigator.setFocusTo(direction: "next")
+
+ // Should NOT move to editText3 — should stay at groupEditText3
+ let expectation = XCTestExpectation(description: "Wait for main queue")
+ DispatchQueue.main.async {
+ if let input = self.editText3 as? TestableInput {
+ XCTAssertFalse(
+ input.becomeFirstResponderCalled,
+ "Should not have moved focus to editText3 outside group"
+ )
+ } else {
+ XCTFail("editText3 is not a TestableInput")
+ }
+ expectation.fulfill()
+ }
+ wait(for: [expectation], timeout: 10.0)
+ }
+
+ func testPrevAtFirstGroupInputDoesNotLeaveGroup() {
+ FocusedInputHolder.shared.set(groupEditText1 as? TextInput)
+
+ ViewHierarchyNavigator.setFocusTo(direction: "prev")
+
+ // Should NOT move to editText2 — should stay at groupEditText1
+ let expectation = XCTestExpectation(description: "Wait for main queue")
+ DispatchQueue.main.async {
+ if let input = self.editText2 as? TestableInput {
+ XCTAssertFalse(
+ input.becomeFirstResponderCalled,
+ "Should not have moved focus to editText2 outside group"
+ )
+ } else {
+ XCTFail("editText2 is not a TestableInput")
+ }
+ expectation.fulfill()
+ }
+ wait(for: [expectation], timeout: 10.0)
+ }
+
+ // MARK: - Navigation outside group skips group inputs
+
+ func testNextOutsideGroupSkipsGroupInputs() {
+ FocusedInputHolder.shared.set(editText2 as? TextInput)
+
+ ViewHierarchyNavigator.setFocusTo(direction: "next")
+
+ // Should skip group and go to editText3
+ waitForFocusChange(to: editText3)
+ }
+
+ func testPrevOutsideGroupSkipsGroupInputs() {
+ FocusedInputHolder.shared.set(editText3 as? TextInput)
+
+ ViewHierarchyNavigator.setFocusTo(direction: "prev")
+
+ // Should skip group and go to editText2
+ waitForFocusChange(to: editText2)
+ }
+
+ // MARK: - findGroupAncestor
+
+ func testFindGroupAncestorReturnsGroupForInputsInsideGroup() {
+ let ancestor = ViewHierarchyNavigator.findGroupAncestor(groupEditText1)
+
+ XCTAssertTrue(ancestor === groupView)
+ }
+
+ func testFindGroupAncestorReturnsNilForInputsOutsideGroup() {
+ let ancestor = ViewHierarchyNavigator.findGroupAncestor(editText1)
+
+ XCTAssertNil(ancestor)
+ }
+}
diff --git a/ios/observers/FocusedInputObserver.swift b/ios/observers/FocusedInputObserver.swift
index 1f01ebd7ef..74ede28b97 100644
--- a/ios/observers/FocusedInputObserver.swift
+++ b/ios/observers/FocusedInputObserver.swift
@@ -151,7 +151,8 @@ public class FocusedInputObserver: NSObject {
FocusedInputHolder.shared.set(currentResponder as? TextInput)
- let allInputFields = ViewHierarchyNavigator.getAllInputFields()
+ let groupAncestor = ViewHierarchyNavigator.findGroupAncestor(currentResponder as? UIView)
+ let allInputFields = ViewHierarchyNavigator.getAllInputFields(root: groupAncestor)
let currentIndex = allInputFields.firstIndex(where: { $0 == currentResponder }) ?? -1
onFocusDidSet([
diff --git a/ios/traversal/ViewHierarchyNavigator.swift b/ios/traversal/ViewHierarchyNavigator.swift
index 8af9f74af9..29164a8576 100644
--- a/ios/traversal/ViewHierarchyNavigator.swift
+++ b/ios/traversal/ViewHierarchyNavigator.swift
@@ -11,6 +11,8 @@ import UIKit
@objc(ViewHierarchyNavigator)
public class ViewHierarchyNavigator: NSObject {
+ private static let groupViewTypeName = "KeyboardToolbarGroupView"
+
@objc public static func setFocusTo(direction: String) {
DispatchQueue.main.async {
if direction == "current" {
@@ -30,30 +32,67 @@ public class ViewHierarchyNavigator: NSObject {
}
public static func getAllInputFields() -> [TextInput] {
+ return getAllInputFields(root: nil)
+ }
+
+ public static func getAllInputFields(root: UIView?) -> [TextInput] {
var textInputs = [TextInput]()
- guard let rootView = UIApplication.topViewController()?.view else {
+ let rootView: UIView?
+ if let root = root {
+ rootView = root
+ } else {
+ rootView = UIApplication.topViewController()?.view
+ }
+
+ guard let rootView = rootView else {
return []
}
+ let isGroupRoot = isGroupView(rootView)
+
/// Helper function to recursively search for TextInput views
func findTextInputs(in view: UIView?) {
guard let view = view else { return }
if let textInput = isValidTextInput(view) {
textInputs.append(textInput)
- } else {
+ } else if !isGroupView(view) {
for subview in view.subviews {
findTextInputs(in: subview)
}
}
}
- findTextInputs(in: rootView)
+ if isGroupRoot {
+ // When root is a group, search its children directly
+ for subview in rootView.subviews {
+ findTextInputs(in: subview)
+ }
+ } else {
+ findTextInputs(in: rootView)
+ }
return textInputs
}
+ /// Finds the closest KeyboardToolbarGroupView ancestor of the given view.
+ /// Returns nil if the view is not inside any group.
+ public static func findGroupAncestor(_ view: UIView?) -> UIView? {
+ var current = view?.superview
+ while let parent = current {
+ if isGroupView(parent) {
+ return parent
+ }
+ current = parent.superview
+ }
+ return nil
+ }
+
+ private static func isGroupView(_ view: UIView) -> Bool {
+ return String(describing: type(of: view)) == groupViewTypeName
+ }
+
private static func findTextInputInDirection(currentFocus: UIView, direction: String) -> TextInput? {
// Find the parent view group
guard let parentViewGroup = currentFocus.superview else {
@@ -81,6 +120,11 @@ public class ViewHierarchyNavigator: NSObject {
}
}
+ // Don't navigate outside the group boundary
+ if isGroupView(parentViewGroup) {
+ return nil
+ }
+
// If no next or previous sibling was found in the parent, recurse to the parent's parent
return findTextInputInDirection(currentFocus: parentViewGroup, direction: direction)
}
@@ -91,6 +135,8 @@ public class ViewHierarchyNavigator: NSObject {
return validTextInput
}
+ guard !isGroupView(view) else { return nil }
+
// Determine the iteration order based on the direction
let subviews = direction == "next" ? view.subviews : view.subviews.reversed()
diff --git a/ios/views/KeyboardToolbarGroupViewManager.h b/ios/views/KeyboardToolbarGroupViewManager.h
new file mode 100644
index 0000000000..a2d478936d
--- /dev/null
+++ b/ios/views/KeyboardToolbarGroupViewManager.h
@@ -0,0 +1,28 @@
+//
+// KeyboardToolbarGroupViewManager.h
+// KeyboardController
+//
+// Created by Kiryl Ziusko on 26/12/2024.
+//
+
+#ifdef RCT_NEW_ARCH_ENABLED
+#import
+#else
+#import
+#endif
+#import
+#import
+
+@interface KeyboardToolbarGroupViewManager : RCTViewManager
+@end
+
+@interface KeyboardToolbarGroupView :
+#ifdef RCT_NEW_ARCH_ENABLED
+ RCTViewComponentView
+#else
+ UIView
+
+- (instancetype)initWithBridge:(RCTBridge *)bridge;
+
+#endif
+@end
diff --git a/ios/views/KeyboardToolbarGroupViewManager.mm b/ios/views/KeyboardToolbarGroupViewManager.mm
new file mode 100644
index 0000000000..083a2fcb0c
--- /dev/null
+++ b/ios/views/KeyboardToolbarGroupViewManager.mm
@@ -0,0 +1,88 @@
+//
+// KeyboardToolbarGroupViewManager.mm
+// react-native-keyboard-controller
+//
+// Created by Kiryl Ziusko on 26/12/2024.
+//
+
+#import "KeyboardToolbarGroupViewManager.h"
+
+#ifdef RCT_NEW_ARCH_ENABLED
+#import
+#import
+#import
+#import
+
+#import "RCTFabricComponentsPlugins.h"
+#endif
+
+#import
+
+#ifdef RCT_NEW_ARCH_ENABLED
+using namespace facebook::react;
+#endif
+
+// MARK: Manager
+@implementation KeyboardToolbarGroupViewManager
+
+RCT_EXPORT_MODULE(KeyboardToolbarGroupViewManager)
+
++ (BOOL)requiresMainQueueSetup
+{
+ return NO;
+}
+
+#ifndef RCT_NEW_ARCH_ENABLED
+- (UIView *)view
+{
+ return [[KeyboardToolbarGroupView alloc] initWithBridge:self.bridge];
+}
+#endif
+
+@end
+
+// MARK: View
+#ifdef RCT_NEW_ARCH_ENABLED
+@interface KeyboardToolbarGroupView ()
+@end
+#endif
+
+@implementation KeyboardToolbarGroupView {
+}
+
+#ifdef RCT_NEW_ARCH_ENABLED
++ (ComponentDescriptorProvider)componentDescriptorProvider
+{
+ return concreteComponentDescriptorProvider();
+}
+#endif
+
+// Needed because of this: https://github.com/facebook/react-native/pull/37274
++ (void)load
+{
+ [super load];
+}
+
+// MARK: Constructor
+#ifdef RCT_NEW_ARCH_ENABLED
+- (instancetype)init
+{
+ self = [super init];
+ return self;
+}
+#else
+- (instancetype)initWithBridge:(RCTBridge *)bridge
+{
+ self = [super init];
+ return self;
+}
+#endif
+
+#ifdef RCT_NEW_ARCH_ENABLED
+Class KeyboardToolbarGroupViewCls(void)
+{
+ return KeyboardToolbarGroupView.class;
+}
+#endif
+
+@end
diff --git a/jest/index.js b/jest/index.js
index 7ca5a79610..17e7a2c6df 100644
--- a/jest/index.js
+++ b/jest/index.js
@@ -122,7 +122,14 @@ const mock = {
KeyboardStickyView: View,
KeyboardAvoidingView: View,
KeyboardAwareScrollView: ScrollView,
- KeyboardToolbar: View,
+ KeyboardToolbar: Object.assign(View, {
+ Background: "KeyboardToolbar.Background",
+ Content: "KeyboardToolbar.Content",
+ Prev: "KeyboardToolbar.Prev",
+ Next: "KeyboardToolbar.Next",
+ Done: "KeyboardToolbar.Done",
+ Group: "KeyboardToolbar.Group",
+ }),
KeyboardChatScrollView: ScrollView,
// themes
DefaultKeyboardToolbarTheme,
diff --git a/package.json b/package.json
index 97a4f2c626..2648f520fe 100644
--- a/package.json
+++ b/package.json
@@ -194,7 +194,8 @@
"OverKeyboardView": "OverKeyboardView",
"KeyboardBackgroundView": "KeyboardBackgroundView",
"KeyboardExtender": "KeyboardExtender",
- "ClippingScrollViewDecoratorView": "ClippingScrollViewDecoratorView"
+ "ClippingScrollViewDecoratorView": "ClippingScrollViewDecoratorView",
+ "KeyboardToolbarGroupView": "KeyboardToolbarGroupView"
}
}
},
diff --git a/react-native.config.js b/react-native.config.js
index fb3c79b6e3..33228da090 100644
--- a/react-native.config.js
+++ b/react-native.config.js
@@ -8,6 +8,7 @@ module.exports = {
"OverKeyboardViewComponentDescriptor",
"KeyboardBackgroundViewComponentDescriptor",
"ClippingScrollViewDecoratorViewComponentDescriptor",
+ "KeyboardToolbarGroupViewComponentDescriptor",
],
cmakeListsPath: "../android/src/main/jni/CMakeLists.txt",
},
diff --git a/src/bindings.native.ts b/src/bindings.native.ts
index 9489aa9834..8f29cc10d2 100644
--- a/src/bindings.native.ts
+++ b/src/bindings.native.ts
@@ -8,6 +8,7 @@ import type {
KeyboardEventsModule,
KeyboardExtenderProps,
KeyboardGestureAreaProps,
+ KeyboardToolbarGroupViewProps,
OverKeyboardViewProps,
WindowDimensionsEventsModule,
} from "./types";
@@ -73,3 +74,5 @@ export const RCTKeyboardExtender: React.FC =
: ({ children }: KeyboardExtenderProps) => children;
export const ClippingScrollView: React.FC =
require("./specs/ClippingScrollViewDecoratorViewNativeComponent").default;
+export const RCTKeyboardToolbarGroupView: React.FC =
+ require("./specs/KeyboardToolbarGroupViewNativeComponent").default;
diff --git a/src/bindings.ts b/src/bindings.ts
index 65f8c56f2d..96e40ac91b 100644
--- a/src/bindings.ts
+++ b/src/bindings.ts
@@ -9,6 +9,7 @@ import type {
KeyboardEventsModule,
KeyboardExtenderProps,
KeyboardGestureAreaProps,
+ KeyboardToolbarGroupViewProps,
OverKeyboardViewProps,
WindowDimensionsEventsModule,
} from "./types";
@@ -86,9 +87,20 @@ export const KeyboardBackgroundView =
export const RCTKeyboardExtender =
View as unknown as React.FC;
/**
- * A decorator that will clip the content of the `ScrollView`. It helps to simulate `contentInset` behavior on Android.
+ * A decorator that will clip the content of the `ScrollView`. It helps to simulate `contentInset` behavior on Android
* Supports only `bottom` property (`paddingBottom` is not supported property of `ScrollView.style`).
* Using this component we can modify bottom inset without having a fake view.
+ *
+ * On iOS we use swizzling to apply runtime patches to fix some broken internal methods.
+ * Ideally this component shouldn't exist and all its fixes/polyfills must be added directly to react-native and
+ * we will port features/fixes back to upstream, but at the moment we use this view to
+ * deliver desired functionality regardless of react-native version used.
*/
export const ClippingScrollView =
View as unknown as React.FC;
+/**
+ * A View that defines a group of `TextInput`s.
+ * Used in toolbar navigation to assure that you can navigate only between inputs withing the same group.
+ */
+export const RCTKeyboardToolbarGroupView =
+ View as unknown as React.FC;
diff --git a/src/components/KeyboardToolbar/index.tsx b/src/components/KeyboardToolbar/index.tsx
index bc2a807fe7..8ec18c31c7 100644
--- a/src/components/KeyboardToolbar/index.tsx
+++ b/src/components/KeyboardToolbar/index.tsx
@@ -1,7 +1,10 @@
import React, { useEffect, useMemo, useState } from "react";
import { StyleSheet, View } from "react-native";
-import { FocusedInputEvents } from "../../bindings";
+import {
+ FocusedInputEvents,
+ RCTKeyboardToolbarGroupView,
+} from "../../bindings";
import { useKeyboardState } from "../../hooks";
import KeyboardStickyView from "../KeyboardStickyView";
@@ -41,6 +44,7 @@ const KeyboardToolbar: React.FC & {
Prev: typeof Prev;
Next: typeof Next;
Done: typeof Done;
+ Group: typeof RCTKeyboardToolbarGroupView;
} = (props) => {
const {
children,
@@ -227,6 +231,7 @@ KeyboardToolbar.Content = Content;
KeyboardToolbar.Prev = Prev;
KeyboardToolbar.Next = Next;
KeyboardToolbar.Done = Done;
+KeyboardToolbar.Group = RCTKeyboardToolbarGroupView;
export { colors as DefaultKeyboardToolbarTheme, KeyboardToolbarProps };
export default KeyboardToolbar;
diff --git a/src/specs/KeyboardToolbarGroupViewNativeComponent.ts b/src/specs/KeyboardToolbarGroupViewNativeComponent.ts
new file mode 100644
index 0000000000..b1116c1ae9
--- /dev/null
+++ b/src/specs/KeyboardToolbarGroupViewNativeComponent.ts
@@ -0,0 +1,10 @@
+import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent";
+
+import type { HostComponent } from "react-native";
+import type { ViewProps } from "react-native/Libraries/Components/View/ViewPropTypes";
+
+export interface NativeProps extends ViewProps {}
+
+export default codegenNativeComponent("KeyboardToolbarGroupView", {
+ interfaceOnly: true,
+}) as HostComponent;
diff --git a/src/types/views.ts b/src/types/views.ts
index c13446587a..e1da3bd28f 100644
--- a/src/types/views.ts
+++ b/src/types/views.ts
@@ -49,6 +49,7 @@ export type KeyboardExtenderProps = PropsWithChildren<{
/** Controls whether this `KeyboardExtender` instance should take an effect. Default is `true`. */
enabled?: boolean;
}>;
+export type KeyboardToolbarGroupViewProps = PropsWithChildren;
export type ClippingScrollViewProps = PropsWithChildren<
ViewProps & {
/** An additional space that gets applied to the bottom of the `ScrollView` (inside a scrollable content). Default is `0`. */