@octane-xplat/keyboard 0.0.1 → 0.17.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 (56) hide show
  1. package/README.md +153 -1
  2. package/UPSTREAM.md +12 -0
  3. package/package.json +77 -9
  4. package/src/KeyboardAwareScrollView.mobile.tsrx +54 -0
  5. package/src/KeyboardAwareScrollView.tsrx +53 -0
  6. package/src/KeyboardStickyView.mobile.tsrx +66 -0
  7. package/src/KeyboardStickyView.tsrx +9 -0
  8. package/src/Provider.tsrx +11 -0
  9. package/src/binding-types.ts +16 -0
  10. package/src/controller.ts +243 -0
  11. package/src/focus-clock.macos.ts +13 -0
  12. package/src/focus-geometry.ts +34 -0
  13. package/src/focus-publish.ts +14 -0
  14. package/src/focus-runtime-types.ts +9 -0
  15. package/src/focus-runtime.macos.ts +62 -0
  16. package/src/focus-runtime.mobile.ts +57 -0
  17. package/src/focus-runtime.ts +14 -0
  18. package/src/focus-runtime.web.ts +139 -0
  19. package/src/focus-source.android.ts +173 -0
  20. package/src/focus-source.ios.ts +202 -0
  21. package/src/focus-source.macos.ts +113 -0
  22. package/src/focus-source.ts +12 -0
  23. package/src/focus-source.web.ts +157 -0
  24. package/src/focus-types.ts +19 -0
  25. package/src/focused-input.ts +43 -0
  26. package/src/index.ts +12 -0
  27. package/src/motion.ts +82 -0
  28. package/src/native-scope.ts +28 -0
  29. package/src/prediction.ts +32 -0
  30. package/src/props.ts +37 -0
  31. package/src/runtime-types.ts +10 -0
  32. package/src/runtime.mobile.ts +62 -0
  33. package/src/runtime.ts +16 -0
  34. package/src/scroll-controller.ts +136 -0
  35. package/src/scroll-geometry.ts +72 -0
  36. package/src/scroll-runtime-types.ts +6 -0
  37. package/src/scroll-runtime.macos.ts +41 -0
  38. package/src/scroll-runtime.mobile.ts +46 -0
  39. package/src/scroll-runtime.ts +7 -0
  40. package/src/scroll-runtime.web.ts +25 -0
  41. package/src/scroll-types.ts +24 -0
  42. package/src/source.android.ts +179 -0
  43. package/src/source.ios.ts +283 -0
  44. package/src/source.ts +5 -0
  45. package/src/tsconfig.json +1 -0
  46. package/src/types.ts +45 -0
  47. package/src/viewport-types.ts +7 -0
  48. package/src/viewport.android.ts +238 -0
  49. package/src/viewport.ios.ts +6 -0
  50. package/src/viewport.ts +6 -0
  51. package/types/focus-types.d.ts +17 -0
  52. package/types/index.d.ts +20 -0
  53. package/types/props.d.ts +35 -0
  54. package/types/scroll-types.d.ts +22 -0
  55. package/types/types.d.ts +40 -0
  56. package/index.js +0 -1
package/README.md CHANGED
@@ -1,3 +1,155 @@
1
1
  # @octane-xplat/keyboard
2
2
 
3
- Bootstrap stub for trusted publishing. This version has no supported API. Real releases ship through https://github.com/octane-xplat/octane-xplat.
3
+ Keep a form above the phone keyboard while it moves. Add the optional keyboard
4
+ package around the existing UI wrapper:
5
+
6
+ ```sh
7
+ pnpm add @octane-xplat/keyboard
8
+ ```
9
+
10
+ ```tsx
11
+ import { KeyboardProvider } from '@octane-xplat/keyboard'
12
+ import { Field, KeyboardAvoiding, TextInput } from '@octane-xplat/ui'
13
+
14
+ export function MessageForm() {
15
+ return <KeyboardProvider>
16
+ <KeyboardAvoiding keyboardVerticalOffset={8}>
17
+ <Field label="Message">
18
+ <TextInput placeholder="Write a message" />
19
+ </Field>
20
+ </KeyboardAvoiding>
21
+ </KeyboardProvider>
22
+ }
23
+ ```
24
+
25
+ On supported native hosts, the padding follows measured keyboard frames without
26
+ rendering the component on each animation frame. Native tracking uses the public
27
+ UIKit keyboard guide on iOS 17+ and AndroidX animation callbacks on Android 11+.
28
+ Runtime qualification currently covers iOS 27 and Android API 35 with NativeScript
29
+ 9.1.3. Older or incompatible native contexts retain destination-only avoidance;
30
+ web and desktop retain neutral software-keyboard behavior.
31
+
32
+ ```tsx
33
+ // Pausing tracking retains the form's children and the UI wrapper's fallback.
34
+ <KeyboardProvider enabled={false}>
35
+ <KeyboardAvoiding>
36
+ <TextInput placeholder="Write a message" />
37
+ </KeyboardAvoiding>
38
+ </KeyboardProvider>
39
+ ```
40
+
41
+ On iOS, a fresh observer needs one native keyboard completion before it can
42
+ advertise measured movement. Until then, avoidance uses the destination, the
43
+ current frame is unavailable, and the native-frame capability is false. This
44
+ also applies when starting observation over an already-moving keyboard. Readers
45
+ that share a running window source reuse its available measurements.
46
+
47
+ ```ts
48
+ import { KeyboardController } from '@octane-xplat/keyboard'
49
+
50
+ export function canFollowMeasuredFrames(host: object) {
51
+ return KeyboardController.getSnapshot(host).capabilities.nativeFrames
52
+ }
53
+ ```
54
+
55
+ For native integrations, `KeyboardController` separates lifecycle subscriptions
56
+ from measured movement. Supply a mounted native host from your platform leaf,
57
+ and release subscriptions when that host detaches. These operations activate
58
+ tracking for their own lifetime; a Provider is optional for these readers.
59
+
60
+ ```ts
61
+ import { KeyboardController } from '@octane-xplat/keyboard'
62
+
63
+ export function observeHost(host: object) {
64
+ const stop = KeyboardController.subscribe(host, state => {
65
+ console.log(state.phase, state.capabilities.nativeFrames)
66
+ })
67
+ const stopMoving = KeyboardController.onMove(host, state => {
68
+ // Use measured geometry for native work; avoid rendering on every frame.
69
+ const top = state.currentFrame?.y
70
+ if (top !== undefined) updateNativeMeasurement(top)
71
+ })
72
+ return () => { stopMoving(); stop() }
73
+ }
74
+
75
+ function updateNativeMeasurement(_top: number) {}
76
+ ```
77
+
78
+ Frames use device-independent coordinates in the owning window. The destination
79
+ can change before the measured current frame reaches it. Read capability flags
80
+ before depending on native tracking; floating-keyboard tracking and Android interactive dismissal are not yet
81
+ qualified. The supported iOS guide path tracks interactive dismissal when its
82
+ scroll host enables that gesture. No text contents are collected.
83
+
84
+ ```ts
85
+ import { KeyboardController } from '@octane-xplat/keyboard'
86
+
87
+ export function readHost(host: object) {
88
+ const state = KeyboardController.getSnapshot(host)
89
+ return {
90
+ current: state.currentFrame,
91
+ destination: state.targetFrame,
92
+ canTrackMovement: state.capabilities.nativeFrames,
93
+ }
94
+ }
95
+ ```
96
+
97
+ The [maintained example](./examples/KeyboardMotionDemo.tsrx) uses the shared UI API.
98
+ Native layout and presentation limits are recorded in the framework's
99
+ [software keyboard notes](../../.agents/docs/keyboard.md).
100
+
101
+ `FocusedInputController` observes input identity, selection, input/caret bounds,
102
+ and scroll ancestors independently of keyboard motion. Coordinates use the
103
+ owning window. Caret precision is explicit; browser text fields expose box
104
+ bounds without a caret rectangle. Release subscriptions when the host is removed.
105
+
106
+ ```ts
107
+ import { FocusedInputController } from '@octane-xplat/keyboard'
108
+
109
+ export function observeEditing(host: object) {
110
+ return FocusedInputController.subscribe(host, state => {
111
+ console.log(state.inputRect, state.capabilities.caretBounds)
112
+ })
113
+ }
114
+ ```
115
+
116
+ See the [focused-input guide](../../docs/app/software-keyboard.md#read-focused-input-geometry)
117
+ for capability limits, selection units, and immediate rechecks.
118
+
119
+ Use `KeyboardAwareScrollView` for long forms. It exposes the focused field or
120
+ caret with minimal scrolling, pauses after manual scrolling by default, and
121
+ provides a binding ref for explicit visibility rechecks.
122
+
123
+ ```tsx
124
+ import { KeyboardAwareScrollView } from '@octane-xplat/keyboard'
125
+ import { Field, TextInput } from '@octane-xplat/ui'
126
+
127
+ export function LongForm() {
128
+ return <KeyboardAwareScrollView clearance={12} extraScrollSpace={40}>
129
+ <Field label="Message"><TextInput placeholder="Write a message" /></Field>
130
+ </KeyboardAwareScrollView>
131
+ }
132
+ ```
133
+
134
+ The [form guide](../../docs/app/software-keyboard.md#keep-the-editing-field-visible)
135
+ explains scroll policies and the [maintained form example](./examples/KeyboardFormDemo.tsrx)
136
+ shows an explicit recheck.
137
+
138
+ `KeyboardStickyView` translates a bottom control independently of surrounding
139
+ layout on supported native hosts. Existing transforms compose with its owned
140
+ vertical channel; disabling releases that channel. Browser and desktop keep a
141
+ neutral View.
142
+
143
+ ```tsx
144
+ import { KeyboardStickyView } from '@octane-xplat/keyboard'
145
+ import { TextInput } from '@octane-xplat/ui'
146
+
147
+ export function Composer() {
148
+ return <KeyboardStickyView openOffset={8}>
149
+ <TextInput placeholder="Write a message" />
150
+ </KeyboardStickyView>
151
+ }
152
+ ```
153
+
154
+ Place it at the screen's bottom. See the [sticky guide](../../docs/app/software-keyboard.md#keep-a-composer-beside-the-keyboard)
155
+ and [maintained composer](./examples/KeyboardComposerDemo.tsrx).
package/UPSTREAM.md ADDED
@@ -0,0 +1,12 @@
1
+ # Keyboard implementation references
2
+
3
+ The separation of measured keyboard frames, native movement bindings, focused
4
+ input geometry, and scroll policy is informed by
5
+ [react-native-keyboard-controller 1.22.0](https://github.com/kirillzyusko/react-native-keyboard-controller/tree/b80086f1207e36d634f93afabb65eb886fe1a2fe)
6
+ (MIT; copyright 2021 Kiryl Ziusko). This package does not copy its React
7
+ Native/Reanimated implementation or private keyboard-view discovery.
8
+
9
+ Native adapters use the public UIKit keyboard layout guide and CADisplayLink,
10
+ and AndroidX WindowInsetsAnimationCompat on an owned helper view. NativeScript
11
+ Page inset distribution is leased through its public androidOverflowInset event.
12
+ The package owns these helpers and releases them with its final window reader.
package/package.json CHANGED
@@ -1,20 +1,88 @@
1
1
  {
2
2
  "name": "@octane-xplat/keyboard",
3
- "version": "0.0.1",
4
- "description": "Bootstrap stub for trusted publishing; no supported API.",
5
- "main": "index.js",
3
+ "version": "0.17.0",
4
+ "description": "Window-scoped software keyboard tracking and native movement for Octane Xplat",
5
+ "keywords": [
6
+ "android",
7
+ "cross-platform",
8
+ "ios",
9
+ "keyboard",
10
+ "nativescript",
11
+ "octane",
12
+ "xplat"
13
+ ],
6
14
  "license": "MIT",
15
+ "homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/keyboard",
7
16
  "repository": {
8
17
  "type": "git",
9
18
  "url": "https://github.com/octane-xplat/octane-xplat",
10
19
  "directory": "packages/keyboard"
11
20
  },
21
+ "type": "module",
22
+ "files": [
23
+ "src",
24
+ "types",
25
+ "UPSTREAM.md",
26
+ "!src/**/*.test.*",
27
+ "!src/**/*.spec.*"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "types": "./types/index.d.ts",
32
+ "default": "./src/index.ts"
33
+ }
34
+ },
12
35
  "publishConfig": {
13
36
  "access": "public"
14
37
  },
15
- "files": [
16
- "index.js",
17
- "README.md",
18
- "LICENSE"
19
- ]
20
- }
38
+ "dependencies": {
39
+ "@octane-xplat/platform": "0.17.0",
40
+ "@octane-xplat/ui": "0.17.0"
41
+ },
42
+ "devDependencies": {
43
+ "@nativescript-community/octane": "0.2.5",
44
+ "@nativescript/core": "9.1.3",
45
+ "@octanejs/vite-plugin": "0.1.61",
46
+ "@octane-xplat/jsx": "0.17.0",
47
+ "octane": "0.14.0",
48
+ "tsrx-typegen": "0.4.0",
49
+ "typescript": "5.9.3",
50
+ "vite": "8.3.0",
51
+ "vitest": "4.1.11"
52
+ },
53
+ "peerDependencies": {
54
+ "@nativescript-community/octane": ">=0.2.1 <1",
55
+ "@nativescript/core": ">=9.1.0 <10",
56
+ "octane": ">=0.11.0 <1"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "@nativescript-community/octane": {
60
+ "optional": true
61
+ },
62
+ "@nativescript/core": {
63
+ "optional": true
64
+ }
65
+ },
66
+ "scripts": {
67
+ "typegen": "tsc -p tsconfig.types.json",
68
+ "typecheck": "node ../../scripts/typecheck.ts",
69
+ "test": "vitest run",
70
+ "test:motion:ios": "node ../../scripts/probe.ts run tests/fixtures/motion.ios.tsrx --target ios",
71
+ "test:motion:android": "node ../../scripts/probe.ts run tests/fixtures/motion.android.tsrx --target android",
72
+ "test:interactive:ios": "node ../../scripts/probe.ts run tests/fixtures/interactive.ios.tsrx --target ios",
73
+ "pack:check": "tsrx-typegen --pack-check",
74
+ "test:focus:web": "node ../../scripts/probe.ts run tests/fixtures/focus.web.tsrx --target web",
75
+ "test:focus:ios": "node ../../scripts/probe.ts run tests/fixtures/focus.ios.tsrx --target ios",
76
+ "test:focus:android": "node ../../scripts/probe.ts run tests/fixtures/focus.android.tsrx --target android",
77
+ "test:focus:macos": "node ../../scripts/probe.ts run tests/fixtures/focus.macos.tsrx --target macos",
78
+ "test:scroll:web": "node ../../scripts/probe.ts run tests/fixtures/scroll.web.tsrx --target web",
79
+ "test:scroll:ios": "node ../../scripts/probe.ts run tests/fixtures/scroll.ios.tsrx --target ios",
80
+ "test:scroll:android": "node ../../scripts/probe.ts run tests/fixtures/scroll.android.tsrx --target android",
81
+ "test:scroll:macos": "node ../../scripts/probe.ts run tests/fixtures/scroll.macos.tsrx --target macos",
82
+ "test:sticky:web": "node ../../scripts/probe.ts run tests/fixtures/sticky.web.tsrx --target web",
83
+ "test:sticky:ios": "node ../../scripts/probe.ts run tests/fixtures/sticky.ios.tsrx --target ios",
84
+ "test:sticky:android": "node ../../scripts/probe.ts run tests/fixtures/sticky.android.tsrx --target android",
85
+ "test:packed": "node tests/packed-consumer.mjs",
86
+ "test:os-input": "node tests/os-input.mjs"
87
+ }
88
+ }
@@ -0,0 +1,54 @@
1
+ /** @jsxImportSource @octane-xplat/jsx */
2
+ import { useCallback, useEffect, useRef, useState } from 'octane'
3
+ import { KeyboardAvoiding } from '@octane-xplat/ui'
4
+ import { KeyboardScrollController } from './scroll-controller'
5
+ import type { KeyboardAwareScrollViewProps } from './props'
6
+ import type { KeyboardScrollBinding } from './scroll-types'
7
+
8
+ export function KeyboardAwareScrollView(props: KeyboardAwareScrollViewProps) @{
9
+ const [host, setHost] = useState<any>(null)
10
+ const attach = useCallback((next: any) => setHost(next), [])
11
+ const binding = useRef<KeyboardScrollBinding | null>(null)
12
+ const latest = useRef(props)
13
+ latest.current = props
14
+ const enabled = props.enabled ?? true
15
+ if (!Number.isFinite(props.extraScrollSpace ?? 0) || (props.extraScrollSpace ?? 0) < 0) {
16
+ throw new RangeError('Extra keyboard scroll space must be finite and nonnegative')
17
+ }
18
+
19
+ useEffect(() => {
20
+ if (!host || !enabled) {
21
+ return
22
+ }
23
+
24
+ const own = KeyboardScrollController.bind(host, latest.current)
25
+ binding.current = own
26
+ return () => {
27
+ own.release()
28
+ binding.current = null
29
+ }
30
+ }, [host, enabled])
31
+
32
+ useEffect(() => {
33
+ props.ref?.(binding.current)
34
+ return () => props.ref?.(null)
35
+ }, [host, enabled, props.ref])
36
+
37
+ useEffect(() => {
38
+ binding.current?.update(props)
39
+ }, [props.clearance, props.manualScroll, props.keyboardClose])
40
+
41
+ <KeyboardAvoiding
42
+ id={props.id}
43
+ className={props.className}
44
+ style={props.style}
45
+ ref={attach}
46
+ enabled={enabled}
47
+ keyboardVerticalOffset={(props.keyboardVerticalOffset ?? 0) + (props.extraScrollSpace ?? 0)}
48
+ ios={props.ios}
49
+ android={props.android}
50
+ web={props.web}
51
+ >
52
+ {props.children}
53
+ </KeyboardAvoiding>
54
+ }
@@ -0,0 +1,53 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'octane'
2
+ import { ScrollableArea } from '@octane-xplat/ui'
3
+ import { KeyboardScrollController } from './scroll-controller'
4
+ import type { KeyboardAwareScrollViewProps } from './props'
5
+ import type { KeyboardScrollBinding } from './scroll-types'
6
+
7
+ /** Desktop/browser focus scrolling; software-keyboard movement remains neutral. */
8
+ export function KeyboardAwareScrollView(props: KeyboardAwareScrollViewProps) @{
9
+ const [host, setHost] = useState<any>(null)
10
+ const attach = useCallback((next: any) => setHost(next?.native ?? null), [])
11
+ const binding = useRef<KeyboardScrollBinding | null>(null)
12
+ const latest = useRef(props)
13
+ latest.current = props
14
+ const enabled = props.enabled ?? true
15
+ if (!Number.isFinite(props.extraScrollSpace ?? 0) || (props.extraScrollSpace ?? 0) < 0) {
16
+ throw new RangeError('Extra keyboard scroll space must be finite and nonnegative')
17
+ }
18
+
19
+ useEffect(() => {
20
+ if (!host || !enabled) {
21
+ return
22
+ }
23
+
24
+ const own = KeyboardScrollController.bind(host, latest.current)
25
+ binding.current = own
26
+ return () => {
27
+ own.release()
28
+ binding.current = null
29
+ }
30
+ }, [host, enabled])
31
+
32
+ useEffect(() => {
33
+ props.ref?.(binding.current)
34
+ return () => props.ref?.(null)
35
+ }, [host, enabled, props.ref])
36
+
37
+ useEffect(() => {
38
+ binding.current?.update(props)
39
+ }, [props.clearance, props.manualScroll, props.keyboardClose])
40
+
41
+ <ScrollableArea
42
+ id={props.id}
43
+ className={props.className}
44
+ style={props.style}
45
+ ref={attach}
46
+ axis="block"
47
+ ios={props.ios}
48
+ android={props.android}
49
+ web={props.web}
50
+ >
51
+ {props.children}
52
+ </ScrollableArea>
53
+ }
@@ -0,0 +1,66 @@
1
+ /** @jsxImportSource @octane-xplat/jsx */
2
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'octane'
3
+ import { View } from '@octane-xplat/ui'
4
+ import {
5
+ ConsumedInsetsContext,
6
+ KeyboardAvoidanceContext,
7
+ keyboardWindowForHost,
8
+ } from '@octane-xplat/ui/nativescript/keyboard'
9
+
10
+ import { bindKeyboardHost } from './controller'
11
+ import type { KeyboardHostBinding } from './binding-types'
12
+ import type { KeyboardStickyViewProps } from './props'
13
+
14
+ export function KeyboardStickyView(props: KeyboardStickyViewProps) @{
15
+ const [host, setHost] = useState<any>(null)
16
+ const attach = useCallback((next: any) => setHost(next), [])
17
+ const forwarded = useMemo(() => [attach, props.ref], [attach, props.ref])
18
+ const safe = useContext(ConsumedInsetsContext)
19
+ const ancestor = useContext(KeyboardAvoidanceContext)
20
+ const latest = useRef({ props, safe, ancestor })
21
+ latest.current = { props, safe, ancestor }
22
+ const binding = useRef<KeyboardHostBinding | null>(null)
23
+ const enabled = props.enabled ?? true
24
+ useEffect(() => {
25
+ if (!host || !enabled) {
26
+ return
27
+ }
28
+
29
+ const own = bindKeyboardHost(host, () => {
30
+ const current = latest.current
31
+ return {
32
+ openOffset: current.props.openOffset,
33
+ closedOffset: current.props.closedOffset,
34
+ consumedBottom:
35
+ Math.max(0, current.safe.bottom) +
36
+ (current.ancestor.window === keyboardWindowForHost(host) ? current.ancestor.bottom : 0),
37
+ }
38
+ })
39
+
40
+ binding.current = own
41
+ return () => {
42
+ binding.current = null
43
+ own()
44
+ }
45
+ }, [host, enabled])
46
+
47
+ useEffect(() => {
48
+ binding.current?.update()
49
+ }, [
50
+ props.openOffset,
51
+ props.closedOffset,
52
+ props.style,
53
+ props.className,
54
+ props.ios,
55
+ props.android,
56
+ safe.bottom,
57
+ ancestor.window,
58
+ ancestor.bottom,
59
+ ])
60
+
61
+ const { enabled: _enabled, openOffset: _openOffset, closedOffset: _closedOffset, ...view } = props
62
+
63
+ <View {...view} ref={forwarded}>
64
+ {props.children}
65
+ </View>
66
+ }
@@ -0,0 +1,9 @@
1
+ import { View } from '@octane-xplat/ui'
2
+ import type { KeyboardStickyViewProps } from './props'
3
+
4
+ /** Neutral layout on hosts without measured software-keyboard movement. */
5
+ export function KeyboardStickyView(props: KeyboardStickyViewProps) @{
6
+ const { enabled: _enabled, openOffset: _openOffset, closedOffset: _closedOffset, ...view } = props
7
+
8
+ <View {...view}>{props.children}</View>
9
+ }
@@ -0,0 +1,11 @@
1
+ import { useEffect } from 'octane'
2
+ import { KeyboardController } from './controller'
3
+ import type { KeyboardProviderProps } from './props'
4
+
5
+ /** Activate shared tracking without adding a layout host. */
6
+ export function KeyboardProvider(props: KeyboardProviderProps) @{
7
+ const enabled = props.enabled ?? true
8
+ useEffect(() => (enabled ? KeyboardController.activate() : undefined), [enabled])
9
+
10
+ <>{props.children}</>
11
+ }
@@ -0,0 +1,16 @@
1
+ /** @internal Native host policy used by framework-owned movement bindings. */
2
+ /** Low-level native translation policy. Offsets are device-independent pixels. */
3
+ export interface KeyboardBindingOptions {
4
+ /** Do not observe or move the host while disabled. @default true */
5
+ enabled?: boolean
6
+ /** Additional upward clearance while the keyboard is open. @default 0 */
7
+ openOffset?: number
8
+ /** Upward displacement while closed. @default 0 */
9
+ closedOffset?: number
10
+ /** Space supplied by a safe-area/avoidance ancestor in the same window.
11
+ * Actual native resizing is measured separately. @default 0 */
12
+ consumedBottom?: number
13
+ }
14
+
15
+ /** @internal Options can change without replacing the shared window source. */
16
+ export type KeyboardHostBinding = (() => void) & { update(): void }