@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 +21 -0
- package/README.md +79 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/keyboard-avoiding-view.d.ts +24 -0
- package/dist/keyboard-avoiding-view.d.ts.map +1 -0
- package/dist/keyboard-avoiding-view.js +54 -0
- package/dist/keyboard-avoiding-view.js.map +1 -0
- package/dist/keyboard-sticky-view.d.ts +36 -0
- package/dist/keyboard-sticky-view.d.ts.map +1 -0
- package/dist/keyboard-sticky-view.js +61 -0
- package/dist/keyboard-sticky-view.js.map +1 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/use-keyboard.d.ts +38 -0
- package/dist/use-keyboard.d.ts.map +1 -0
- package/dist/use-keyboard.js +85 -0
- package/dist/use-keyboard.js.map +1 -0
- package/package.json +57 -0
- package/src/index.ts +24 -0
- package/src/keyboard-avoiding-view.tsx +59 -0
- package/src/keyboard-sticky-view.tsx +76 -0
- package/src/types.ts +55 -0
- package/src/use-keyboard.ts +106 -0
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).
|
package/dist/index.d.ts
ADDED
|
@@ -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"}
|
package/dist/types.d.ts
ADDED
|
@@ -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 @@
|
|
|
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
|
+
}
|