@sigx/lynx-keyboard 0.4.6

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Andreas Ekdahl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # @sigx/lynx-keyboard
2
+
3
+ Soft-keyboard handling for [SignalX](https://github.com/signalxjs) on Lynx, with an API mirroring React Native's (`KeyboardAvoidingView`, `KeyboardStickyView`/`InputAccessoryView`, `useKeyboard`). Keeps a composer input — and an accessory toolbar above it — pinned to the top edge of the on-screen keyboard.
4
+
5
+ Keyboard height reaches JS through the safe-area bridge ([`@sigx/lynx-safe-area`](../lynx-safe-area)): the native publisher reports the IME height as the `keyboard` inset on every `safeAreaChanged` event. This package turns that inset into ready-made layout primitives — no extra native module needed. Keyboard handling stays a separate concern from safe-area, mirroring the RN ecosystem split (`react-native` core / `react-native-keyboard-controller` vs `react-native-safe-area-context`).
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm add @sigx/lynx-keyboard
11
+ ```
12
+
13
+ Requires `<SafeAreaProvider>` (from `@sigx/lynx-safe-area`) at the app root — the same provider every safe-area hook already needs.
14
+
15
+ ## Quick start
16
+
17
+ The proven chat-screen shape: the content area shrinks (`KeyboardAvoidingView`), the composer bar rides the keyboard (`KeyboardStickyView`). The bar's translate and the area's padding are both `max(0, keyboard - bottomInset)`, so the list bottom always ends exactly where the bar lands.
18
+
19
+ ```tsx
20
+ import { component } from '@sigx/lynx';
21
+ import { KeyboardAvoidingView, KeyboardStickyView } from '@sigx/lynx-keyboard';
22
+
23
+ const ChatScreen = component(() => () => (
24
+ <view style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, flexShrink: 1, flexBasis: 0 }}>
25
+ <KeyboardAvoidingView behavior="padding">
26
+ <scroll-view style={{ flexGrow: 1, flexShrink: 1, flexBasis: 0 }}>
27
+ {/* messages */}
28
+ </scroll-view>
29
+ </KeyboardAvoidingView>
30
+ <KeyboardStickyView>
31
+ {/* toolbar row (formatting buttons, attachments, …) */}
32
+ {/* input row */}
33
+ </KeyboardStickyView>
34
+ </view>
35
+ ));
36
+ ```
37
+
38
+ Use **one** primitive per subtree: a bar inside both a padding `KeyboardAvoidingView` *and* a `KeyboardStickyView` lifts twice.
39
+
40
+ ## API
41
+
42
+ ### `<KeyboardStickyView>`
43
+
44
+ Pins its children to the keyboard's top edge with an MT-animated `translateY` (smooth 60fps, no per-frame thread crossing). When the keyboard is closed the bar rests in its natural flex position. Aliases: `KeyboardAccessoryView`, `KeyboardToolbar`.
45
+
46
+ | Prop | Type | Default | Description |
47
+ | --- | --- | --- | --- |
48
+ | `offset` | `number` | `0` | Extra gap (dp) above the keyboard. |
49
+ | `animated` | `boolean` | `true` | `false` = discrete BG re-render (debug fallback). |
50
+ | `discountBottomInset` | `boolean` | `true` | Subtract the bottom safe-area inset from the lift. Keep `true` when an ancestor `<SafeAreaView edges={['bottom']}>` already pads the home indicator. |
51
+
52
+ Note: the bar's `transform` is controlled internally (the MT binding writes `translateY` via `setStyleProperties`; the non-animated path writes an inline transform). A `transform` passed through `style` will be overridden — wrap children in their own view if you need an additional transform.
53
+
54
+ ### `<KeyboardAvoidingView>`
55
+
56
+ Wraps content and keeps it above the keyboard. Layout-affecting, so it applies inline BG styles (the same pattern as `<SafeAreaView>` — MT-driven layout writes don't reflow `<scroll-view>`).
57
+
58
+ | Prop | Type | Default | Description |
59
+ | --- | --- | --- | --- |
60
+ | `behavior` | `'padding' \| 'translate' \| 'height'` | `'padding'` | `padding` shrinks the column; `translate` shifts it; `height` appends a spacer. |
61
+ | `keyboardVerticalOffset` | `number` | `0` | Added to the computed lift (RN parity). |
62
+ | `discountBottomInset` | `boolean` | `true` | Same as on `KeyboardStickyView` — set `false` to lift by the full keyboard height when no ancestor pads the bottom inset. |
63
+
64
+ ### Hooks
65
+
66
+ - `useKeyboard(): Computed<{ height, visible }>` — BG-reactive keyboard state.
67
+ - `useKeyboardLift(discountBottomInset?, offset?): Computed<number>` — the raw lift value.
68
+ - `useKeyboardLiftSV(discountBottomInset?, offset?, duration?): SharedValue<number>` — smoothly animated MT SharedValue tracking the lift; bind with `useAnimatedStyle(ref, sv, 'translateY', { factor: -1 })`.
69
+
70
+ ## How it works
71
+
72
+ - **Height source** — `useSafeAreaInsets().value.keyboard`. There is no separate keyboard event API in Lynx; the safe-area publisher is canonical.
73
+ - **The lift** — `max(0, keyboard - bottomInset)`: the keyboard covers the home-indicator region, so a bar that already sits above the bottom inset only needs to rise by the difference. Never add both.
74
+ - **BG→MT bridge** — the keyboard inset is a BG-only signal (deliberately not a SharedValue in lynx-safe-area). `useKeyboardLiftSV` watches it from a BG effect and dispatches an MT `withTiming` (from [`@sigx/lynx-motion`](../lynx-motion)) toward each new target; the tween then runs entirely on the main thread.
75
+ - **Transform vs layout** — only `translateY` is MT-animated. Padding/height go through inline BG styles because MT layout writes land after the first layout pass and `<scroll-view>` won't reflow.
76
+
77
+ ## Demo
78
+
79
+ See the **Keyboard lab** screen in [`examples/showcase`](../../examples/showcase) (Settings tab → Keyboard lab).
@@ -0,0 +1,5 @@
1
+ export { KeyboardAvoidingView } from './keyboard-avoiding-view.js';
2
+ export { KeyboardStickyView, KeyboardStickyView as KeyboardAccessoryView, KeyboardStickyView as KeyboardToolbar, } from './keyboard-sticky-view.js';
3
+ export { useKeyboard, useKeyboardLift, useKeyboardLiftSV } from './use-keyboard.js';
4
+ export type { KeyboardAvoidingBehavior, KeyboardAvoidingViewProps, KeyboardState, KeyboardStickyViewProps, } from './types.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AACnE,OAAO,EACL,kBAAkB,EAGlB,kBAAkB,IAAI,qBAAqB,EAC3C,kBAAkB,IAAI,eAAe,GACtC,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACpF,YAAY,EACV,wBAAwB,EACxB,yBAAyB,EACzB,aAAa,EACb,uBAAuB,GACxB,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ // Public API for @sigx/lynx-keyboard.
2
+ //
3
+ // Soft-keyboard handling with an RN-mirroring API. Keyboard height reaches
4
+ // JS through the safe-area bridge (`@sigx/lynx-safe-area`) — this package
5
+ // turns that inset into ready-made layout primitives. Keyboard handling is
6
+ // its own concern, separate from safe-area, mirroring the RN ecosystem
7
+ // (react-native core / react-native-keyboard-controller vs
8
+ // react-native-safe-area-context).
9
+ export { KeyboardAvoidingView } from './keyboard-avoiding-view.js';
10
+ export { KeyboardStickyView,
11
+ // RN aliases: core's InputAccessoryView role is covered by the sticky
12
+ // view; react-native-keyboard-controller names for the same shape.
13
+ KeyboardStickyView as KeyboardAccessoryView, KeyboardStickyView as KeyboardToolbar, } from './keyboard-sticky-view.js';
14
+ export { useKeyboard, useKeyboardLift, useKeyboardLiftSV } from './use-keyboard.js';
15
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,EAAE;AACF,2EAA2E;AAC3E,0EAA0E;AAC1E,2EAA2E;AAC3E,uEAAuE;AACvE,2DAA2D;AAC3D,mCAAmC;AAEnC,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AACnE,OAAO,EACL,kBAAkB;AAClB,sEAAsE;AACtE,mEAAmE;AACnE,kBAAkB,IAAI,qBAAqB,EAC3C,kBAAkB,IAAI,eAAe,GACtC,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1,24 @@
1
+ import type { KeyboardAvoidingViewProps } from './types.js';
2
+ /**
3
+ * RN-mirroring `KeyboardAvoidingView`: wraps screen content and keeps it
4
+ * above the soft keyboard. Default `behavior="padding"` squeezes the flex
5
+ * column so nothing hides behind the keyboard.
6
+ *
7
+ * Implementation: BG signal + inline style, the same pattern as
8
+ * lynx-safe-area's `<SafeAreaView>` — layout-affecting properties must NOT
9
+ * be driven from the main thread (`setStyleProperties` layout writes fire
10
+ * after the first layout pass and `<scroll-view>` won't reflow), so the
11
+ * padding snaps to the final value in one re-render. The native keyboard
12
+ * slide masks the snap. For a smoothly *animated* bar, use
13
+ * `<KeyboardStickyView>` (transform-based, MT-animated) instead — and use
14
+ * one or the other on a given subtree, not both, or it double-lifts.
15
+ *
16
+ * The bottom safe-area inset is discounted from the lift
17
+ * (`max(0, keyboard - bottom + keyboardVerticalOffset)`) because an
18
+ * ancestor `<SafeAreaView edges={['bottom']}>` typically already pads the
19
+ * home indicator, which the keyboard covers when open.
20
+ */
21
+ export declare const KeyboardAvoidingView: import("@sigx/runtime-core").ComponentFactory<KeyboardAvoidingViewProps, void, {
22
+ default: () => import("@sigx/runtime-core").JSXElement | import("@sigx/runtime-core").JSXElement[] | null;
23
+ }>;
24
+ //# sourceMappingURL=keyboard-avoiding-view.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keyboard-avoiding-view.d.ts","sourceRoot":"","sources":["../src/keyboard-avoiding-view.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,YAAY,CAAC;AAE5D;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,oBAAoB;;EAmC/B,CAAC"}
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@sigx/lynx/jsx-runtime";
2
+ import { component } from '@sigx/lynx';
3
+ import { useSafeAreaInsets } from '@sigx/lynx-safe-area';
4
+ /**
5
+ * RN-mirroring `KeyboardAvoidingView`: wraps screen content and keeps it
6
+ * above the soft keyboard. Default `behavior="padding"` squeezes the flex
7
+ * column so nothing hides behind the keyboard.
8
+ *
9
+ * Implementation: BG signal + inline style, the same pattern as
10
+ * lynx-safe-area's `<SafeAreaView>` — layout-affecting properties must NOT
11
+ * be driven from the main thread (`setStyleProperties` layout writes fire
12
+ * after the first layout pass and `<scroll-view>` won't reflow), so the
13
+ * padding snaps to the final value in one re-render. The native keyboard
14
+ * slide masks the snap. For a smoothly *animated* bar, use
15
+ * `<KeyboardStickyView>` (transform-based, MT-animated) instead — and use
16
+ * one or the other on a given subtree, not both, or it double-lifts.
17
+ *
18
+ * The bottom safe-area inset is discounted from the lift
19
+ * (`max(0, keyboard - bottom + keyboardVerticalOffset)`) because an
20
+ * ancestor `<SafeAreaView edges={['bottom']}>` typically already pads the
21
+ * home indicator, which the keyboard covers when open.
22
+ */
23
+ export const KeyboardAvoidingView = component(({ props, slots }) => {
24
+ const insets = useSafeAreaInsets();
25
+ const behavior = props.behavior ?? 'padding';
26
+ const kvo = props.keyboardVerticalOffset ?? 0;
27
+ const discountBottomInset = props.discountBottomInset ?? true;
28
+ return () => {
29
+ const i = insets.value;
30
+ const lift = i.keyboard > 0
31
+ ? Math.max(0, i.keyboard - (discountBottomInset ? i.bottom : 0) + kvo)
32
+ : 0;
33
+ // Fill-parent defaults, mirroring SafeAreaView: Lynx resolves the
34
+ // `flex: 1` shorthand with `flexBasis: 'auto'`, which sizes to content
35
+ // and collapses the chain — long-form `flexBasis: 0` is the only
36
+ // reliable "fill remaining space".
37
+ const base = {
38
+ flexGrow: 1,
39
+ flexShrink: 1,
40
+ flexBasis: 0,
41
+ minHeight: 0,
42
+ display: 'flex',
43
+ flexDirection: 'column',
44
+ };
45
+ if (behavior === 'padding') {
46
+ base['paddingBottom'] = `${lift}px`;
47
+ }
48
+ else if (behavior === 'translate') {
49
+ base['transform'] = `translateY(-${lift}px)`;
50
+ }
51
+ return (_jsxs("view", { class: props.class, style: props.style ? { ...base, ...props.style } : base, children: [slots.default?.(), behavior === 'height' ? _jsx("view", { style: { height: `${lift}px`, flexShrink: 0 } }) : null] }));
52
+ };
53
+ });
54
+ //# sourceMappingURL=keyboard-avoiding-view.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keyboard-avoiding-view.js","sourceRoot":"","sources":["../src/keyboard-avoiding-view.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAGzD;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,SAAS,CAA4B,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE;IAC5F,MAAM,MAAM,GAAG,iBAAiB,EAAE,CAAC;IACnC,MAAM,QAAQ,GAAG,KAAK,CAAC,QAAQ,IAAI,SAAS,CAAC;IAC7C,MAAM,GAAG,GAAG,KAAK,CAAC,sBAAsB,IAAI,CAAC,CAAC;IAC9C,MAAM,mBAAmB,GAAG,KAAK,CAAC,mBAAmB,IAAI,IAAI,CAAC;IAE9D,OAAO,GAAG,EAAE;QACV,MAAM,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC;QACvB,MAAM,IAAI,GAAG,CAAC,CAAC,QAAQ,GAAG,CAAC;YACzB,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,QAAQ,GAAG,CAAC,mBAAmB,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC;YACtE,CAAC,CAAC,CAAC,CAAC;QACN,kEAAkE;QAClE,uEAAuE;QACvE,iEAAiE;QACjE,mCAAmC;QACnC,MAAM,IAAI,GAAoC;YAC5C,QAAQ,EAAE,CAAC;YACX,UAAU,EAAE,CAAC;YACb,SAAS,EAAE,CAAC;YACZ,SAAS,EAAE,CAAC;YACZ,OAAO,EAAE,MAAM;YACf,aAAa,EAAE,QAAQ;SACxB,CAAC;QACF,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;YAC3B,IAAI,CAAC,eAAe,CAAC,GAAG,GAAG,IAAI,IAAI,CAAC;QACtC,CAAC;aAAM,IAAI,QAAQ,KAAK,WAAW,EAAE,CAAC;YACpC,IAAI,CAAC,WAAW,CAAC,GAAG,eAAe,IAAI,KAAK,CAAC;QAC/C,CAAC;QACD,OAAO,CACL,gBAAM,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,GAAG,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,aAC9E,KAAK,CAAC,OAAO,EAAE,EAAE,EACjB,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,eAAM,KAAK,EAAE,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,EAAE,UAAU,EAAE,CAAC,EAAE,GAAI,CAAC,CAAC,CAAC,IAAI,IAClF,CACR,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC,CAAC,CAAC"}
@@ -0,0 +1,36 @@
1
+ import type { KeyboardStickyViewProps } from './types.js';
2
+ /**
3
+ * Pins its children to the top edge of the soft keyboard — the home for a
4
+ * chat composer / input-accessory toolbar. (RN names: `KeyboardStickyView`
5
+ * in react-native-keyboard-controller, `InputAccessoryView` in core.)
6
+ *
7
+ * The bar flows as a normal bottom flex sibling; when the keyboard opens it
8
+ * is lifted with `transform: translateY(-lift)` where
9
+ * `lift = max(0, keyboard - bottomInset + offset)`. Transform doesn't
10
+ * reflow layout, so (unlike padding/height) it is safe to drive from the
11
+ * main thread via `useAnimatedStyle` — see lynx-safe-area's
12
+ * `safe-area-view.tsx` for why MT-driven *layout* writes are a trap.
13
+ *
14
+ * Note: because the bar is translated rather than re-laid-out, content
15
+ * behind it (e.g. the bottom of a message list) does not shrink — it can
16
+ * sit behind the keyboard. Pair with `<KeyboardAvoidingView
17
+ * behavior="padding">` around the *content area only* (never around the bar
18
+ * itself, or it double-lifts) when the content must stay fully visible.
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * <Col class="flex-fill">
23
+ * <KeyboardAvoidingView behavior="padding">
24
+ * <ScrollView class="flex-1">{messages}</ScrollView>
25
+ * </KeyboardAvoidingView>
26
+ * <KeyboardStickyView>
27
+ * <Toolbar />
28
+ * <Composer />
29
+ * </KeyboardStickyView>
30
+ * </Col>
31
+ * ```
32
+ */
33
+ export declare const KeyboardStickyView: import("@sigx/runtime-core").ComponentFactory<KeyboardStickyViewProps, void, {
34
+ default: () => import("@sigx/runtime-core").JSXElement | import("@sigx/runtime-core").JSXElement[] | null;
35
+ }>;
36
+ //# sourceMappingURL=keyboard-sticky-view.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keyboard-sticky-view.d.ts","sourceRoot":"","sources":["../src/keyboard-sticky-view.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,eAAO,MAAM,kBAAkB;;EAmC7B,CAAC"}
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx } from "@sigx/lynx/jsx-runtime";
2
+ import { component, useAnimatedStyle, useMainThreadRef, } from '@sigx/lynx';
3
+ import { useKeyboardLift, useKeyboardLiftSV } from './use-keyboard.js';
4
+ /**
5
+ * Pins its children to the top edge of the soft keyboard — the home for a
6
+ * chat composer / input-accessory toolbar. (RN names: `KeyboardStickyView`
7
+ * in react-native-keyboard-controller, `InputAccessoryView` in core.)
8
+ *
9
+ * The bar flows as a normal bottom flex sibling; when the keyboard opens it
10
+ * is lifted with `transform: translateY(-lift)` where
11
+ * `lift = max(0, keyboard - bottomInset + offset)`. Transform doesn't
12
+ * reflow layout, so (unlike padding/height) it is safe to drive from the
13
+ * main thread via `useAnimatedStyle` — see lynx-safe-area's
14
+ * `safe-area-view.tsx` for why MT-driven *layout* writes are a trap.
15
+ *
16
+ * Note: because the bar is translated rather than re-laid-out, content
17
+ * behind it (e.g. the bottom of a message list) does not shrink — it can
18
+ * sit behind the keyboard. Pair with `<KeyboardAvoidingView
19
+ * behavior="padding">` around the *content area only* (never around the bar
20
+ * itself, or it double-lifts) when the content must stay fully visible.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * <Col class="flex-fill">
25
+ * <KeyboardAvoidingView behavior="padding">
26
+ * <ScrollView class="flex-1">{messages}</ScrollView>
27
+ * </KeyboardAvoidingView>
28
+ * <KeyboardStickyView>
29
+ * <Toolbar />
30
+ * <Composer />
31
+ * </KeyboardStickyView>
32
+ * </Col>
33
+ * ```
34
+ */
35
+ export const KeyboardStickyView = component(({ props, slots }) => {
36
+ const discountBottomInset = props.discountBottomInset ?? true;
37
+ const offset = props.offset ?? 0;
38
+ // Hooks register unconditionally (same rule as NavDrawer's backdrop
39
+ // binding): a runtime `animated` toggle must keep working in both
40
+ // directions, and a binding created inside `if (animated)` would be
41
+ // missing after a false→true flip. The reactive accessor form binds /
42
+ // unbinds the MT transform as the prop changes.
43
+ const barRef = useMainThreadRef(null);
44
+ const liftSV = useKeyboardLiftSV(discountBottomInset, offset);
45
+ const liftBG = useKeyboardLift(discountBottomInset, offset);
46
+ useAnimatedStyle(barRef, () => (props.animated ?? true)
47
+ // factor -1: the SV stays a positive height; the mapper negates it so
48
+ // the bar moves UP.
49
+ ? { sv: liftSV, mapperName: 'translateY', params: { factor: -1 } }
50
+ : null);
51
+ return () => {
52
+ const animated = props.animated ?? true;
53
+ return (_jsx("view", { "main-thread:ref": barRef, class: props.class,
54
+ // Debug / fallback path (`animated={false}`): discrete BG re-render,
55
+ // no tween — the MT binding above is unregistered then.
56
+ style: animated
57
+ ? props.style
58
+ : { ...props.style, transform: `translateY(-${liftBG.value}px)` }, children: slots.default?.() }));
59
+ };
60
+ });
61
+ //# sourceMappingURL=keyboard-sticky-view.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keyboard-sticky-view.js","sourceRoot":"","sources":["../src/keyboard-sticky-view.tsx"],"names":[],"mappings":";AAAA,OAAO,EACL,SAAS,EACT,gBAAgB,EAChB,gBAAgB,GAEjB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAGvE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,SAAS,CAA0B,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE;IACxF,MAAM,mBAAmB,GAAG,KAAK,CAAC,mBAAmB,IAAI,IAAI,CAAC;IAC9D,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,IAAI,CAAC,CAAC;IAEjC,oEAAoE;IACpE,kEAAkE;IAClE,oEAAoE;IACpE,sEAAsE;IACtE,gDAAgD;IAChD,MAAM,MAAM,GAAG,gBAAgB,CAA4B,IAAI,CAAC,CAAC;IACjE,MAAM,MAAM,GAAG,iBAAiB,CAAC,mBAAmB,EAAE,MAAM,CAAC,CAAC;IAC9D,MAAM,MAAM,GAAG,eAAe,CAAC,mBAAmB,EAAE,MAAM,CAAC,CAAC;IAC5D,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE,CAC5B,CAAC,KAAK,CAAC,QAAQ,IAAI,IAAI,CAAC;QACtB,sEAAsE;QACtE,oBAAoB;QACpB,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC,EAAE,EAAE;QAClE,CAAC,CAAC,IAAI,CAAC,CAAC;IAEZ,OAAO,GAAG,EAAE;QACV,MAAM,QAAQ,GAAG,KAAK,CAAC,QAAQ,IAAI,IAAI,CAAC;QACxC,OAAO,CACL,kCACmB,MAAM,EACvB,KAAK,EAAE,KAAK,CAAC,KAAK;YAClB,qEAAqE;YACrE,wDAAwD;YACxD,KAAK,EAAE,QAAQ;gBACb,CAAC,CAAC,KAAK,CAAC,KAAK;gBACb,CAAC,CAAC,EAAE,GAAG,KAAK,CAAC,KAAK,EAAE,SAAS,EAAE,eAAe,MAAM,CAAC,KAAK,KAAK,EAAE,YAElE,KAAK,CAAC,OAAO,EAAE,EAAE,GACb,CACR,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC,CAAC,CAAC"}
@@ -0,0 +1,44 @@
1
+ import type { Define } from '@sigx/lynx';
2
+ /**
3
+ * RN-mirroring behavior modes for `<KeyboardAvoidingView>`:
4
+ * - `'padding'` — add `paddingBottom` equal to the keyboard overlap, squeezing
5
+ * the flex column so ALL content stays above the keyboard.
6
+ * - `'translate'` — shift the whole container up by the overlap (content at the
7
+ * top moves off-screen; layout does not reflow).
8
+ * - `'height'` — append a trailing spacer view of the overlap height, so
9
+ * the content above is squeezed without touching the container's padding.
10
+ * (Closest analogue of RN's height-resizing behavior; the implementation
11
+ * differs — RN shrinks the container's own height, this inserts a spacer.)
12
+ */
13
+ export type KeyboardAvoidingBehavior = 'padding' | 'translate' | 'height';
14
+ /** BG-reactive keyboard state returned by `useKeyboard()`. */
15
+ export interface KeyboardState {
16
+ /** Soft-keyboard height in dp; 0 when hidden. */
17
+ height: number;
18
+ visible: boolean;
19
+ }
20
+ export type KeyboardAvoidingViewProps = Define.Prop<'behavior', KeyboardAvoidingBehavior, false> & Define.Prop<'keyboardVerticalOffset', number, false>
21
+ /**
22
+ * Subtract the bottom safe-area inset from the lift (default `true`).
23
+ * Keep `true` when an ancestor `<SafeAreaView edges={['bottom']}>` already
24
+ * pads the home-indicator inset; set `false` to lift by the full keyboard
25
+ * height when no ancestor applies the bottom inset.
26
+ */
27
+ & Define.Prop<'discountBottomInset', boolean, false> & Define.Prop<'class', string, false> & Define.Prop<'style', Record<string, string | number>, false> & Define.Slot<'default'>;
28
+ export type KeyboardStickyViewProps =
29
+ /** Extra gap (dp) between the bar and the keyboard's top edge. */
30
+ Define.Prop<'offset', number, false>
31
+ /**
32
+ * `true` (default): smooth MT-driven translateY via SharedValue + timing.
33
+ * `false`: plain BG re-render with an inline transform (debug fallback).
34
+ */
35
+ & Define.Prop<'animated', boolean, false>
36
+ /**
37
+ * Subtract the bottom safe-area inset from the lift (default `true`).
38
+ * Keep `true` when an ancestor `<SafeAreaView edges={['bottom']}>` already
39
+ * pads the home-indicator inset — the keyboard covers that region, so the
40
+ * bar only needs to rise by the difference. Set `false` if no ancestor
41
+ * applies the bottom inset.
42
+ */
43
+ & Define.Prop<'discountBottomInset', boolean, false> & Define.Prop<'class', string, false> & Define.Prop<'style', Record<string, string | number>, false> & Define.Slot<'default'>;
44
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAEzC;;;;;;;;;;GAUG;AACH,MAAM,MAAM,wBAAwB,GAAG,SAAS,GAAG,WAAW,GAAG,QAAQ,CAAC;AAE1E,8DAA8D;AAC9D,MAAM,WAAW,aAAa;IAC5B,iDAAiD;IACjD,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,MAAM,yBAAyB,GACjC,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,wBAAwB,EAAE,KAAK,CAAC,GACxD,MAAM,CAAC,IAAI,CAAC,wBAAwB,EAAE,MAAM,EAAE,KAAK,CAAC;AACtD;;;;;GAKG;GACD,MAAM,CAAC,IAAI,CAAC,qBAAqB,EAAE,OAAO,EAAE,KAAK,CAAC,GAClD,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,GACnC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,EAAE,KAAK,CAAC,GAC5D,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;AAE3B,MAAM,MAAM,uBAAuB;AACjC,kEAAkE;AAChE,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC;AACtC;;;GAGG;GACD,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,OAAO,EAAE,KAAK,CAAC;AACzC;;;;;;GAMG;GACD,MAAM,CAAC,IAAI,CAAC,qBAAqB,EAAE,OAAO,EAAE,KAAK,CAAC,GAClD,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,GACnC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,EAAE,KAAK,CAAC,GAC5D,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,38 @@
1
+ import { type Computed, type SharedValue } from '@sigx/lynx';
2
+ import type { KeyboardState } from './types.js';
3
+ /**
4
+ * BG-reactive soft-keyboard state. The height comes from the safe-area
5
+ * bridge (`useSafeAreaInsets().value.keyboard`, fed by the native
6
+ * `safeAreaChanged` event) — Lynx has no separate keyboard event API.
7
+ * Consumers re-render on keyboard show/hide.
8
+ *
9
+ * Must be used under `<SafeAreaProvider>` (same requirement as every
10
+ * safe-area hook); without one it reads zero insets and warns in dev.
11
+ */
12
+ export declare function useKeyboard(): Computed<KeyboardState>;
13
+ /**
14
+ * BG-reactive "lift" — how far a bottom-anchored bar must rise to clear the
15
+ * keyboard. `max(0, keyboard - bottomInset + offset)` while the keyboard is
16
+ * visible, 0 otherwise. The bottom inset is discounted because an ancestor
17
+ * `<SafeAreaView edges={['bottom']}>` typically already keeps the bar above
18
+ * the home indicator, and the keyboard covers that region when open.
19
+ */
20
+ export declare function useKeyboardLift(discountBottomInset?: boolean, offset?: number): Computed<number>;
21
+ /**
22
+ * Smoothly animated MT SharedValue tracking the keyboard lift, for
23
+ * `useAnimatedStyle(ref, sv, 'translateY', { factor: -1 })` bindings.
24
+ *
25
+ * The keyboard inset is BG-only (deliberately not a SharedValue in
26
+ * lynx-safe-area's provider), so this hook is the BG→MT bridge: a BG effect
27
+ * watches the inset signal and dispatches an MT `withTiming` toward the new
28
+ * target each time it changes. `withTiming` is itself a `'main thread'`
29
+ * worklet, so referencing it from the dispatched worklet survives `_c`
30
+ * capture; the timing loop then runs entirely on MT (no per-frame thread
31
+ * crossing). The watcher effect is stopped on unmount (`effect` comes from
32
+ * `@sigx/reactivity` and is NOT lifecycle-scoped — same manual-stop pattern
33
+ * as lynx-safe-area's provider).
34
+ */
35
+ export declare function useKeyboardLiftSV(discountBottomInset?: boolean, offset?: number,
36
+ /** Tween duration in seconds (lynx-motion convention). */
37
+ duration?: number): SharedValue<number>;
38
+ //# sourceMappingURL=use-keyboard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-keyboard.d.ts","sourceRoot":"","sources":["../src/use-keyboard.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,QAAQ,EACb,KAAK,WAAW,EACjB,MAAM,YAAY,CAAC;AAGpB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD;;;;;;;;GAQG;AACH,wBAAgB,WAAW,IAAI,QAAQ,CAAC,aAAa,CAAC,CAMrD;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAC7B,mBAAmB,UAAO,EAC1B,MAAM,SAAI,GACT,QAAQ,CAAC,MAAM,CAAC,CAOlB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAC/B,mBAAmB,UAAO,EAC1B,MAAM,SAAI;AACV,0DAA0D;AAC1D,QAAQ,SAAO,GACd,WAAW,CAAC,MAAM,CAAC,CAqCrB"}
@@ -0,0 +1,85 @@
1
+ import { computed, effect, onUnmounted, runOnMainThread, useSharedValue, } from '@sigx/lynx';
2
+ import { useSafeAreaInsets } from '@sigx/lynx-safe-area';
3
+ import { withTiming } from '@sigx/lynx-motion';
4
+ /**
5
+ * BG-reactive soft-keyboard state. The height comes from the safe-area
6
+ * bridge (`useSafeAreaInsets().value.keyboard`, fed by the native
7
+ * `safeAreaChanged` event) — Lynx has no separate keyboard event API.
8
+ * Consumers re-render on keyboard show/hide.
9
+ *
10
+ * Must be used under `<SafeAreaProvider>` (same requirement as every
11
+ * safe-area hook); without one it reads zero insets and warns in dev.
12
+ */
13
+ export function useKeyboard() {
14
+ const insets = useSafeAreaInsets();
15
+ return computed(() => {
16
+ const height = insets.value.keyboard;
17
+ return { height, visible: height > 0 };
18
+ });
19
+ }
20
+ /**
21
+ * BG-reactive "lift" — how far a bottom-anchored bar must rise to clear the
22
+ * keyboard. `max(0, keyboard - bottomInset + offset)` while the keyboard is
23
+ * visible, 0 otherwise. The bottom inset is discounted because an ancestor
24
+ * `<SafeAreaView edges={['bottom']}>` typically already keeps the bar above
25
+ * the home indicator, and the keyboard covers that region when open.
26
+ */
27
+ export function useKeyboardLift(discountBottomInset = true, offset = 0) {
28
+ const insets = useSafeAreaInsets();
29
+ return computed(() => {
30
+ const i = insets.value;
31
+ if (i.keyboard <= 0)
32
+ return 0;
33
+ return Math.max(0, i.keyboard - (discountBottomInset ? i.bottom : 0) + offset);
34
+ });
35
+ }
36
+ /**
37
+ * Smoothly animated MT SharedValue tracking the keyboard lift, for
38
+ * `useAnimatedStyle(ref, sv, 'translateY', { factor: -1 })` bindings.
39
+ *
40
+ * The keyboard inset is BG-only (deliberately not a SharedValue in
41
+ * lynx-safe-area's provider), so this hook is the BG→MT bridge: a BG effect
42
+ * watches the inset signal and dispatches an MT `withTiming` toward the new
43
+ * target each time it changes. `withTiming` is itself a `'main thread'`
44
+ * worklet, so referencing it from the dispatched worklet survives `_c`
45
+ * capture; the timing loop then runs entirely on MT (no per-frame thread
46
+ * crossing). The watcher effect is stopped on unmount (`effect` comes from
47
+ * `@sigx/reactivity` and is NOT lifecycle-scoped — same manual-stop pattern
48
+ * as lynx-safe-area's provider).
49
+ */
50
+ export function useKeyboardLiftSV(discountBottomInset = true, offset = 0,
51
+ /** Tween duration in seconds (lynx-motion convention). */
52
+ duration = 0.25) {
53
+ const insets = useSafeAreaInsets();
54
+ const computeLift = (i) => i.keyboard > 0
55
+ ? Math.max(0, i.keyboard - (discountBottomInset ? i.bottom : 0) + offset)
56
+ : 0;
57
+ // Seed from the CURRENT insets — a screen that mounts while the keyboard
58
+ // is already open (modal presented over a focused composer, back-nav to a
59
+ // focused field) must paint at the lifted position on the first frame,
60
+ // not animate up from 0. Same "correct on first paint" rule as the
61
+ // safe-area provider.
62
+ const initialLift = computeLift(insets.value);
63
+ const lift = useSharedValue(initialLift);
64
+ const animateTo = runOnMainThread((target, seconds) => {
65
+ 'main thread';
66
+ void withTiming(lift, target, { duration: seconds });
67
+ });
68
+ // Dedupe by last dispatched target: the inset signal also fires for
69
+ // changes that don't affect the lift (rotation, status-bar changes, the
70
+ // mount-time run), and animate() doesn't short-circuit zero-delta tweens
71
+ // — it would still schedule rAF ticks for the full duration and
72
+ // cancel/restart any in-flight animation. Seeded with the SharedValue's
73
+ // initial value so the mount-time run dispatches nothing.
74
+ let lastTarget = initialLift;
75
+ const watcher = effect(() => {
76
+ const target = computeLift(insets.value);
77
+ if (target === lastTarget)
78
+ return;
79
+ lastTarget = target;
80
+ void animateTo(target, duration);
81
+ });
82
+ onUnmounted(() => watcher.stop());
83
+ return lift;
84
+ }
85
+ //# sourceMappingURL=use-keyboard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-keyboard.js","sourceRoot":"","sources":["../src/use-keyboard.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,QAAQ,EACR,MAAM,EACN,WAAW,EACX,eAAe,EACf,cAAc,GAGf,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAG/C;;;;;;;;GAQG;AACH,MAAM,UAAU,WAAW;IACzB,MAAM,MAAM,GAAG,iBAAiB,EAAE,CAAC;IACnC,OAAO,QAAQ,CAAC,GAAG,EAAE;QACnB,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC;QACrC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,CAAC,EAAE,CAAC;IACzC,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAC7B,mBAAmB,GAAG,IAAI,EAC1B,MAAM,GAAG,CAAC;IAEV,MAAM,MAAM,GAAG,iBAAiB,EAAE,CAAC;IACnC,OAAO,QAAQ,CAAC,GAAG,EAAE;QACnB,MAAM,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC;QACvB,IAAI,CAAC,CAAC,QAAQ,IAAI,CAAC;YAAE,OAAO,CAAC,CAAC;QAC9B,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,QAAQ,GAAG,CAAC,mBAAmB,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC;IACjF,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,iBAAiB,CAC/B,mBAAmB,GAAG,IAAI,EAC1B,MAAM,GAAG,CAAC;AACV,0DAA0D;AAC1D,QAAQ,GAAG,IAAI;IAEf,MAAM,MAAM,GAAG,iBAAiB,EAAE,CAAC;IAEnC,MAAM,WAAW,GAAG,CAAC,CAAuC,EAAU,EAAE,CACtE,CAAC,CAAC,QAAQ,GAAG,CAAC;QACZ,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,QAAQ,GAAG,CAAC,mBAAmB,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC;QACzE,CAAC,CAAC,CAAC,CAAC;IAER,yEAAyE;IACzE,0EAA0E;IAC1E,uEAAuE;IACvE,mEAAmE;IACnE,sBAAsB;IACtB,MAAM,WAAW,GAAG,WAAW,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC9C,MAAM,IAAI,GAAG,cAAc,CAAC,WAAW,CAAC,CAAC;IAEzC,MAAM,SAAS,GAAG,eAAe,CAAC,CAAC,MAAc,EAAE,OAAe,EAAE,EAAE;QACpE,aAAa,CAAC;QACd,KAAK,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,CAAC;IACvD,CAAC,CAAC,CAAC;IAEH,oEAAoE;IACpE,wEAAwE;IACxE,yEAAyE;IACzE,gEAAgE;IAChE,wEAAwE;IACxE,0DAA0D;IAC1D,IAAI,UAAU,GAAG,WAAW,CAAC;IAC7B,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,EAAE;QAC1B,MAAM,MAAM,GAAG,WAAW,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACzC,IAAI,MAAM,KAAK,UAAU;YAAE,OAAO;QAClC,UAAU,GAAG,MAAM,CAAC;QACpB,KAAK,SAAS,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IACnC,CAAC,CAAC,CAAC;IACH,WAAW,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;IAElC,OAAO,IAAI,CAAC;AACd,CAAC"}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@sigx/lynx-keyboard",
3
+ "version": "0.4.6",
4
+ "description": "Soft-keyboard handling for sigx-lynx — KeyboardStickyView, KeyboardAvoidingView and keyboard hooks",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "src",
16
+ "dist"
17
+ ],
18
+ "keywords": [
19
+ "sigx",
20
+ "lynx",
21
+ "keyboard",
22
+ "keyboard-avoiding",
23
+ "input-accessory",
24
+ "ime"
25
+ ],
26
+ "author": "Andreas Ekdahl",
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "@sigx/lynx": "^0.4.6",
30
+ "@sigx/lynx-motion": "^0.4.6",
31
+ "@sigx/lynx-safe-area": "^0.4.6"
32
+ },
33
+ "devDependencies": {
34
+ "@typescript/native-preview": "7.0.0-dev.20260521.1",
35
+ "typescript": "^6.0.3",
36
+ "@sigx/lynx-plugin": "^0.4.6",
37
+ "@sigx/lynx-testing": "^0.4.6",
38
+ "@sigx/lynx-runtime-main": "^0.4.6"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/signalxjs/lynx.git",
43
+ "directory": "packages/lynx-keyboard"
44
+ },
45
+ "homepage": "https://github.com/signalxjs/lynx/tree/main/packages/lynx-keyboard",
46
+ "bugs": {
47
+ "url": "https://github.com/signalxjs/lynx/issues"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "scripts": {
53
+ "build": "node ../../scripts/clean.mjs dist && tsgo",
54
+ "dev": "tsgo --watch",
55
+ "clean": "node ../../scripts/clean.mjs dist .turbo"
56
+ }
57
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ // Public API for @sigx/lynx-keyboard.
2
+ //
3
+ // Soft-keyboard handling with an RN-mirroring API. Keyboard height reaches
4
+ // JS through the safe-area bridge (`@sigx/lynx-safe-area`) — this package
5
+ // turns that inset into ready-made layout primitives. Keyboard handling is
6
+ // its own concern, separate from safe-area, mirroring the RN ecosystem
7
+ // (react-native core / react-native-keyboard-controller vs
8
+ // react-native-safe-area-context).
9
+
10
+ export { KeyboardAvoidingView } from './keyboard-avoiding-view.js';
11
+ export {
12
+ KeyboardStickyView,
13
+ // RN aliases: core's InputAccessoryView role is covered by the sticky
14
+ // view; react-native-keyboard-controller names for the same shape.
15
+ KeyboardStickyView as KeyboardAccessoryView,
16
+ KeyboardStickyView as KeyboardToolbar,
17
+ } from './keyboard-sticky-view.js';
18
+ export { useKeyboard, useKeyboardLift, useKeyboardLiftSV } from './use-keyboard.js';
19
+ export type {
20
+ KeyboardAvoidingBehavior,
21
+ KeyboardAvoidingViewProps,
22
+ KeyboardState,
23
+ KeyboardStickyViewProps,
24
+ } from './types.js';
@@ -0,0 +1,59 @@
1
+ import { component } from '@sigx/lynx';
2
+ import { useSafeAreaInsets } from '@sigx/lynx-safe-area';
3
+ import type { KeyboardAvoidingViewProps } from './types.js';
4
+
5
+ /**
6
+ * RN-mirroring `KeyboardAvoidingView`: wraps screen content and keeps it
7
+ * above the soft keyboard. Default `behavior="padding"` squeezes the flex
8
+ * column so nothing hides behind the keyboard.
9
+ *
10
+ * Implementation: BG signal + inline style, the same pattern as
11
+ * lynx-safe-area's `<SafeAreaView>` — layout-affecting properties must NOT
12
+ * be driven from the main thread (`setStyleProperties` layout writes fire
13
+ * after the first layout pass and `<scroll-view>` won't reflow), so the
14
+ * padding snaps to the final value in one re-render. The native keyboard
15
+ * slide masks the snap. For a smoothly *animated* bar, use
16
+ * `<KeyboardStickyView>` (transform-based, MT-animated) instead — and use
17
+ * one or the other on a given subtree, not both, or it double-lifts.
18
+ *
19
+ * The bottom safe-area inset is discounted from the lift
20
+ * (`max(0, keyboard - bottom + keyboardVerticalOffset)`) because an
21
+ * ancestor `<SafeAreaView edges={['bottom']}>` typically already pads the
22
+ * home indicator, which the keyboard covers when open.
23
+ */
24
+ export const KeyboardAvoidingView = component<KeyboardAvoidingViewProps>(({ props, slots }) => {
25
+ const insets = useSafeAreaInsets();
26
+ const behavior = props.behavior ?? 'padding';
27
+ const kvo = props.keyboardVerticalOffset ?? 0;
28
+ const discountBottomInset = props.discountBottomInset ?? true;
29
+
30
+ return () => {
31
+ const i = insets.value;
32
+ const lift = i.keyboard > 0
33
+ ? Math.max(0, i.keyboard - (discountBottomInset ? i.bottom : 0) + kvo)
34
+ : 0;
35
+ // Fill-parent defaults, mirroring SafeAreaView: Lynx resolves the
36
+ // `flex: 1` shorthand with `flexBasis: 'auto'`, which sizes to content
37
+ // and collapses the chain — long-form `flexBasis: 0` is the only
38
+ // reliable "fill remaining space".
39
+ const base: Record<string, string | number> = {
40
+ flexGrow: 1,
41
+ flexShrink: 1,
42
+ flexBasis: 0,
43
+ minHeight: 0,
44
+ display: 'flex',
45
+ flexDirection: 'column',
46
+ };
47
+ if (behavior === 'padding') {
48
+ base['paddingBottom'] = `${lift}px`;
49
+ } else if (behavior === 'translate') {
50
+ base['transform'] = `translateY(-${lift}px)`;
51
+ }
52
+ return (
53
+ <view class={props.class} style={props.style ? { ...base, ...props.style } : base}>
54
+ {slots.default?.()}
55
+ {behavior === 'height' ? <view style={{ height: `${lift}px`, flexShrink: 0 }} /> : null}
56
+ </view>
57
+ );
58
+ };
59
+ });
@@ -0,0 +1,76 @@
1
+ import {
2
+ component,
3
+ useAnimatedStyle,
4
+ useMainThreadRef,
5
+ type MainThread,
6
+ } from '@sigx/lynx';
7
+ import { useKeyboardLift, useKeyboardLiftSV } from './use-keyboard.js';
8
+ import type { KeyboardStickyViewProps } from './types.js';
9
+
10
+ /**
11
+ * Pins its children to the top edge of the soft keyboard — the home for a
12
+ * chat composer / input-accessory toolbar. (RN names: `KeyboardStickyView`
13
+ * in react-native-keyboard-controller, `InputAccessoryView` in core.)
14
+ *
15
+ * The bar flows as a normal bottom flex sibling; when the keyboard opens it
16
+ * is lifted with `transform: translateY(-lift)` where
17
+ * `lift = max(0, keyboard - bottomInset + offset)`. Transform doesn't
18
+ * reflow layout, so (unlike padding/height) it is safe to drive from the
19
+ * main thread via `useAnimatedStyle` — see lynx-safe-area's
20
+ * `safe-area-view.tsx` for why MT-driven *layout* writes are a trap.
21
+ *
22
+ * Note: because the bar is translated rather than re-laid-out, content
23
+ * behind it (e.g. the bottom of a message list) does not shrink — it can
24
+ * sit behind the keyboard. Pair with `<KeyboardAvoidingView
25
+ * behavior="padding">` around the *content area only* (never around the bar
26
+ * itself, or it double-lifts) when the content must stay fully visible.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * <Col class="flex-fill">
31
+ * <KeyboardAvoidingView behavior="padding">
32
+ * <ScrollView class="flex-1">{messages}</ScrollView>
33
+ * </KeyboardAvoidingView>
34
+ * <KeyboardStickyView>
35
+ * <Toolbar />
36
+ * <Composer />
37
+ * </KeyboardStickyView>
38
+ * </Col>
39
+ * ```
40
+ */
41
+ export const KeyboardStickyView = component<KeyboardStickyViewProps>(({ props, slots }) => {
42
+ const discountBottomInset = props.discountBottomInset ?? true;
43
+ const offset = props.offset ?? 0;
44
+
45
+ // Hooks register unconditionally (same rule as NavDrawer's backdrop
46
+ // binding): a runtime `animated` toggle must keep working in both
47
+ // directions, and a binding created inside `if (animated)` would be
48
+ // missing after a false→true flip. The reactive accessor form binds /
49
+ // unbinds the MT transform as the prop changes.
50
+ const barRef = useMainThreadRef<MainThread.Element | null>(null);
51
+ const liftSV = useKeyboardLiftSV(discountBottomInset, offset);
52
+ const liftBG = useKeyboardLift(discountBottomInset, offset);
53
+ useAnimatedStyle(barRef, () =>
54
+ (props.animated ?? true)
55
+ // factor -1: the SV stays a positive height; the mapper negates it so
56
+ // the bar moves UP.
57
+ ? { sv: liftSV, mapperName: 'translateY', params: { factor: -1 } }
58
+ : null);
59
+
60
+ return () => {
61
+ const animated = props.animated ?? true;
62
+ return (
63
+ <view
64
+ main-thread:ref={barRef}
65
+ class={props.class}
66
+ // Debug / fallback path (`animated={false}`): discrete BG re-render,
67
+ // no tween — the MT binding above is unregistered then.
68
+ style={animated
69
+ ? props.style
70
+ : { ...props.style, transform: `translateY(-${liftBG.value}px)` }}
71
+ >
72
+ {slots.default?.()}
73
+ </view>
74
+ );
75
+ };
76
+ });
package/src/types.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { Define } from '@sigx/lynx';
2
+
3
+ /**
4
+ * RN-mirroring behavior modes for `<KeyboardAvoidingView>`:
5
+ * - `'padding'` — add `paddingBottom` equal to the keyboard overlap, squeezing
6
+ * the flex column so ALL content stays above the keyboard.
7
+ * - `'translate'` — shift the whole container up by the overlap (content at the
8
+ * top moves off-screen; layout does not reflow).
9
+ * - `'height'` — append a trailing spacer view of the overlap height, so
10
+ * the content above is squeezed without touching the container's padding.
11
+ * (Closest analogue of RN's height-resizing behavior; the implementation
12
+ * differs — RN shrinks the container's own height, this inserts a spacer.)
13
+ */
14
+ export type KeyboardAvoidingBehavior = 'padding' | 'translate' | 'height';
15
+
16
+ /** BG-reactive keyboard state returned by `useKeyboard()`. */
17
+ export interface KeyboardState {
18
+ /** Soft-keyboard height in dp; 0 when hidden. */
19
+ height: number;
20
+ visible: boolean;
21
+ }
22
+
23
+ export type KeyboardAvoidingViewProps =
24
+ & Define.Prop<'behavior', KeyboardAvoidingBehavior, false>
25
+ & Define.Prop<'keyboardVerticalOffset', number, false>
26
+ /**
27
+ * Subtract the bottom safe-area inset from the lift (default `true`).
28
+ * Keep `true` when an ancestor `<SafeAreaView edges={['bottom']}>` already
29
+ * pads the home-indicator inset; set `false` to lift by the full keyboard
30
+ * height when no ancestor applies the bottom inset.
31
+ */
32
+ & Define.Prop<'discountBottomInset', boolean, false>
33
+ & Define.Prop<'class', string, false>
34
+ & Define.Prop<'style', Record<string, string | number>, false>
35
+ & Define.Slot<'default'>;
36
+
37
+ export type KeyboardStickyViewProps =
38
+ /** Extra gap (dp) between the bar and the keyboard's top edge. */
39
+ & Define.Prop<'offset', number, false>
40
+ /**
41
+ * `true` (default): smooth MT-driven translateY via SharedValue + timing.
42
+ * `false`: plain BG re-render with an inline transform (debug fallback).
43
+ */
44
+ & Define.Prop<'animated', boolean, false>
45
+ /**
46
+ * Subtract the bottom safe-area inset from the lift (default `true`).
47
+ * Keep `true` when an ancestor `<SafeAreaView edges={['bottom']}>` already
48
+ * pads the home-indicator inset — the keyboard covers that region, so the
49
+ * bar only needs to rise by the difference. Set `false` if no ancestor
50
+ * applies the bottom inset.
51
+ */
52
+ & Define.Prop<'discountBottomInset', boolean, false>
53
+ & Define.Prop<'class', string, false>
54
+ & Define.Prop<'style', Record<string, string | number>, false>
55
+ & Define.Slot<'default'>;
@@ -0,0 +1,106 @@
1
+ import {
2
+ computed,
3
+ effect,
4
+ onUnmounted,
5
+ runOnMainThread,
6
+ useSharedValue,
7
+ type Computed,
8
+ type SharedValue,
9
+ } from '@sigx/lynx';
10
+ import { useSafeAreaInsets } from '@sigx/lynx-safe-area';
11
+ import { withTiming } from '@sigx/lynx-motion';
12
+ import type { KeyboardState } from './types.js';
13
+
14
+ /**
15
+ * BG-reactive soft-keyboard state. The height comes from the safe-area
16
+ * bridge (`useSafeAreaInsets().value.keyboard`, fed by the native
17
+ * `safeAreaChanged` event) — Lynx has no separate keyboard event API.
18
+ * Consumers re-render on keyboard show/hide.
19
+ *
20
+ * Must be used under `<SafeAreaProvider>` (same requirement as every
21
+ * safe-area hook); without one it reads zero insets and warns in dev.
22
+ */
23
+ export function useKeyboard(): Computed<KeyboardState> {
24
+ const insets = useSafeAreaInsets();
25
+ return computed(() => {
26
+ const height = insets.value.keyboard;
27
+ return { height, visible: height > 0 };
28
+ });
29
+ }
30
+
31
+ /**
32
+ * BG-reactive "lift" — how far a bottom-anchored bar must rise to clear the
33
+ * keyboard. `max(0, keyboard - bottomInset + offset)` while the keyboard is
34
+ * visible, 0 otherwise. The bottom inset is discounted because an ancestor
35
+ * `<SafeAreaView edges={['bottom']}>` typically already keeps the bar above
36
+ * the home indicator, and the keyboard covers that region when open.
37
+ */
38
+ export function useKeyboardLift(
39
+ discountBottomInset = true,
40
+ offset = 0,
41
+ ): Computed<number> {
42
+ const insets = useSafeAreaInsets();
43
+ return computed(() => {
44
+ const i = insets.value;
45
+ if (i.keyboard <= 0) return 0;
46
+ return Math.max(0, i.keyboard - (discountBottomInset ? i.bottom : 0) + offset);
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Smoothly animated MT SharedValue tracking the keyboard lift, for
52
+ * `useAnimatedStyle(ref, sv, 'translateY', { factor: -1 })` bindings.
53
+ *
54
+ * The keyboard inset is BG-only (deliberately not a SharedValue in
55
+ * lynx-safe-area's provider), so this hook is the BG→MT bridge: a BG effect
56
+ * watches the inset signal and dispatches an MT `withTiming` toward the new
57
+ * target each time it changes. `withTiming` is itself a `'main thread'`
58
+ * worklet, so referencing it from the dispatched worklet survives `_c`
59
+ * capture; the timing loop then runs entirely on MT (no per-frame thread
60
+ * crossing). The watcher effect is stopped on unmount (`effect` comes from
61
+ * `@sigx/reactivity` and is NOT lifecycle-scoped — same manual-stop pattern
62
+ * as lynx-safe-area's provider).
63
+ */
64
+ export function useKeyboardLiftSV(
65
+ discountBottomInset = true,
66
+ offset = 0,
67
+ /** Tween duration in seconds (lynx-motion convention). */
68
+ duration = 0.25,
69
+ ): SharedValue<number> {
70
+ const insets = useSafeAreaInsets();
71
+
72
+ const computeLift = (i: { keyboard: number; bottom: number }): number =>
73
+ i.keyboard > 0
74
+ ? Math.max(0, i.keyboard - (discountBottomInset ? i.bottom : 0) + offset)
75
+ : 0;
76
+
77
+ // Seed from the CURRENT insets — a screen that mounts while the keyboard
78
+ // is already open (modal presented over a focused composer, back-nav to a
79
+ // focused field) must paint at the lifted position on the first frame,
80
+ // not animate up from 0. Same "correct on first paint" rule as the
81
+ // safe-area provider.
82
+ const initialLift = computeLift(insets.value);
83
+ const lift = useSharedValue(initialLift);
84
+
85
+ const animateTo = runOnMainThread((target: number, seconds: number) => {
86
+ 'main thread';
87
+ void withTiming(lift, target, { duration: seconds });
88
+ });
89
+
90
+ // Dedupe by last dispatched target: the inset signal also fires for
91
+ // changes that don't affect the lift (rotation, status-bar changes, the
92
+ // mount-time run), and animate() doesn't short-circuit zero-delta tweens
93
+ // — it would still schedule rAF ticks for the full duration and
94
+ // cancel/restart any in-flight animation. Seeded with the SharedValue's
95
+ // initial value so the mount-time run dispatches nothing.
96
+ let lastTarget = initialLift;
97
+ const watcher = effect(() => {
98
+ const target = computeLift(insets.value);
99
+ if (target === lastTarget) return;
100
+ lastTarget = target;
101
+ void animateTo(target, duration);
102
+ });
103
+ onUnmounted(() => watcher.stop());
104
+
105
+ return lift;
106
+ }