@sigx/lynx-safe-area 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ // Public API for @sigx/lynx-safe-area.
2
+
3
+ export { SafeAreaProvider, SAFE_AREA_EVENT } from './provider.js';
4
+ export type { SafeAreaProviderProps } from './provider.js';
5
+
6
+ export { SafeAreaView } from './safe-area-view.js';
7
+ export type { SafeAreaViewProps } from './safe-area-view.js';
8
+
9
+ export {
10
+ useSafeAreaInsets,
11
+ useSafeAreaSharedValues,
12
+ useSafeAreaFrame,
13
+ useSafeAreaInsetsMT,
14
+ } from './hooks.js';
15
+
16
+ export { useSafeAreaContext } from './injectable.js';
17
+
18
+ export {
19
+ readGlobalSafeArea,
20
+ GLOBAL_PROPS_KEY,
21
+ } from './globals.js';
22
+ export type { RawSafeAreaProps } from './globals.js';
23
+
24
+ export { ZERO_INSETS } from './types.js';
25
+ export type {
26
+ EdgeInsets,
27
+ Edge,
28
+ SafeAreaMode,
29
+ SafeAreaContextValue,
30
+ } from './types.js';
@@ -0,0 +1,17 @@
1
+ import { defineInjectable } from '@sigx/lynx';
2
+ import type { SafeAreaContextValue } from './types.js';
3
+
4
+ /**
5
+ * The DI handle for the safe-area context.
6
+ *
7
+ * - Inside `<SafeAreaProvider>`'s setup we call `defineProvide(useSafeAreaContext, factory)`
8
+ * to install a per-app instance.
9
+ * - Anywhere downstream `useSafeAreaContext()` returns that instance, or
10
+ * `null` if no provider is in scope. Hooks defined in `./hooks.ts` wrap
11
+ * this with the null-check + signal subscription.
12
+ *
13
+ * The factory returns `null` at the global-singleton level so consumers
14
+ * outside a `<SafeAreaProvider>` get a clear signal (vs. a phantom zero-
15
+ * insets context that silently does nothing).
16
+ */
17
+ export const useSafeAreaContext = defineInjectable<SafeAreaContextValue | null>(() => null);
@@ -0,0 +1,209 @@
1
+ import {
2
+ component,
3
+ defineProvide,
4
+ computed,
5
+ signal,
6
+ onMounted,
7
+ onUnmounted,
8
+ useSharedValue,
9
+ useMainThreadRef,
10
+ runOnMainThread,
11
+ type Define,
12
+ type MainThread,
13
+ type SharedValue,
14
+ } from '@sigx/lynx';
15
+ import { useSafeAreaContext } from './injectable.js';
16
+ import { readGlobalSafeArea } from './globals.js';
17
+ import type { EdgeInsets, SafeAreaContextValue } from './types.js';
18
+
19
+ /**
20
+ * The native publisher (iOS `SafeAreaPublisher.swift`, Android
21
+ * `SafeAreaPublisher.kt`) emits this event via `GlobalEventEmitter` every
22
+ * time it republishes insets. Payload mirrors the same `RawSafeAreaProps`
23
+ * shape stored under `lynx.__globalProps[GLOBAL_PROPS_KEY]`.
24
+ *
25
+ * We use a custom event rather than upstream's `onGlobalPropsChanged` so
26
+ * the contract stays in our hands (upstream's event-name conventions have
27
+ * churned across Lynx releases).
28
+ */
29
+ export const SAFE_AREA_EVENT = 'safeAreaChanged';
30
+
31
+ interface GlobalEventEmitterLike {
32
+ addListener: (name: string, fn: (...a: unknown[]) => void) => void;
33
+ removeListener: (name: string, fn: (...a: unknown[]) => void) => void;
34
+ }
35
+
36
+ interface LynxLike {
37
+ getJSModule?: (name: string) => GlobalEventEmitterLike | undefined;
38
+ }
39
+
40
+ // Closure-injected identifier provided by
41
+ // `@lynx-js/runtime-wrapper-webpack-plugin`. Same pattern as
42
+ // `runtime-lynx/src/shims.d.ts`. Declared locally so this package doesn't
43
+ // have to depend on runtime-lynx-internal just for the ambient.
44
+ declare const lynx: unknown | undefined;
45
+
46
+ export type SafeAreaProviderProps =
47
+ & Define.Prop<'class', string, false>
48
+ & Define.Prop<'style', Record<string, string | number>, false>
49
+ & Define.Slot<'default'>;
50
+
51
+ /**
52
+ * Mount once at the root of an app. Responsibilities:
53
+ *
54
+ * 1. **Seed insets synchronously** from `lynx.__globalProps[safeArea]`. The
55
+ * native side populates this *before* the MT bundle evaluates, so the
56
+ * seed is correct on first render — no flash of unsafe content.
57
+ *
58
+ * 2. **Provide a DI context** (`useSafeAreaContext`) holding:
59
+ * - four per-edge `SharedValue<number>`s — the single source of truth,
60
+ * writable on MT, observable from both threads.
61
+ * - a derived BG `computed<EdgeInsets>` for re-render-driven consumers
62
+ * (`useSafeAreaInsets()`).
63
+ *
64
+ * 3. **Subscribe to live updates** via `GlobalEventEmitter`. The native
65
+ * publisher emits `'safeAreaChanged'` after each `updateGlobalProps`,
66
+ * carrying the new inset map. We dispatch a `runOnMainThread` worklet
67
+ * that writes the per-edge SVs on MT — the SharedValue diff/publish
68
+ * bridge then propagates the new values back to the BG signal mirror,
69
+ * which re-fires the `computed` and re-renders consumers.
70
+ *
71
+ * 4. **Apply CSS variables** (`--sat`, `--sar`, `--sab`, `--sal`,
72
+ * `--safe-area-keyboard`) on the root `<view>` so utility-class
73
+ * consumers can write `class="pt-[var(--sat)]"` and have it work
74
+ * uniformly across iOS and Android (upstream's
75
+ * `env(safe-area-inset-*)` is iOS-only).
76
+ */
77
+ export const SafeAreaProvider = component<SafeAreaProviderProps>(({ props, slots }) => {
78
+ const initial = readGlobalSafeArea();
79
+
80
+ const svTop = useSharedValue(initial.top);
81
+ const svRight = useSharedValue(initial.right);
82
+ const svBottom = useSharedValue(initial.bottom);
83
+ const svLeft = useSharedValue(initial.left);
84
+
85
+ // Reactive object signal for the non-SV extras (BG-only — keyboard,
86
+ // statusBar, navigationBar don't drive MT-bound layout, so SV plumbing
87
+ // isn't worth the cost). `signal({...})` returns a deeply reactive proxy;
88
+ // access via `extras.keyboard` etc., replace via `extras.$set({...})`.
89
+ const extras = signal<Extras>({
90
+ keyboard: initial.keyboard,
91
+ statusBar: initial.statusBar,
92
+ navigationBar: initial.navigationBar,
93
+ });
94
+
95
+ // Single source of truth for BG consumers — derived reactively from the
96
+ // four edge SVs (which live on MT) and the extras signal (which lives on
97
+ // BG). Re-runs when MT publishes new SV values via the AvBridge OR when
98
+ // the safeAreaChanged listener writes to `extras`.
99
+ const insets = computed<EdgeInsets>(() => ({
100
+ top: svTop.value,
101
+ right: svRight.value,
102
+ bottom: svBottom.value,
103
+ left: svLeft.value,
104
+ keyboard: extras.keyboard,
105
+ statusBar: extras.statusBar,
106
+ navigationBar: extras.navigationBar,
107
+ }));
108
+
109
+ const ctx: SafeAreaContextValue = {
110
+ insets,
111
+ sv: { top: svTop, right: svRight, bottom: svBottom, left: svLeft },
112
+ };
113
+ defineProvide(useSafeAreaContext, () => ctx);
114
+
115
+ // Worklet that writes the four per-edge SVs on MT. Captured by `_c` at
116
+ // build time — runOnMainThread ships the SV refs as `{_wvid, _initValue}`
117
+ // placeholders that the MT runtime resolves to the live envelope.
118
+ const writeOnMT = runOnMainThread((t: number, r: number, b: number, l: number) => {
119
+ 'main thread';
120
+ svTop.current.value = t;
121
+ svRight.current.value = r;
122
+ svBottom.current.value = b;
123
+ svLeft.current.value = l;
124
+ });
125
+
126
+ // Hold the elRef purely so consumers can extend the provider's host view
127
+ // via the published CSS variables. Not used internally for any MT writes.
128
+ const elRef = useMainThreadRef<MainThread.Element | null>(null);
129
+
130
+ let listener: ((...a: unknown[]) => void) | undefined;
131
+ let emitter: GlobalEventEmitterLike | undefined;
132
+
133
+ onMounted(() => {
134
+ // `lynx` is a closure-injected identifier (provided by
135
+ // `@lynx-js/runtime-wrapper-webpack-plugin`'s `__init_card_bundle__`
136
+ // wrapper), NOT a property of `globalThis`. Access as a bare identifier
137
+ // with `typeof` guard — same pattern as `runtime-lynx/src/bg-bridge.ts`.
138
+ const lynxObj: LynxLike | undefined = typeof lynx !== 'undefined'
139
+ ? (lynx as unknown as LynxLike)
140
+ : undefined;
141
+ emitter = lynxObj?.getJSModule?.('GlobalEventEmitter');
142
+ if (!emitter) return;
143
+ listener = (raw: unknown) => {
144
+ const next = normaliseInsets(raw, insets.value);
145
+ extras.$set({
146
+ keyboard: next.keyboard,
147
+ statusBar: next.statusBar,
148
+ navigationBar: next.navigationBar,
149
+ });
150
+ void writeOnMT(next.top, next.right, next.bottom, next.left);
151
+ };
152
+ emitter.addListener(SAFE_AREA_EVENT, listener);
153
+ });
154
+
155
+ onUnmounted(() => {
156
+ if (emitter && listener) emitter.removeListener(SAFE_AREA_EVENT, listener);
157
+ });
158
+
159
+ return () => (
160
+ <view
161
+ class={props.class}
162
+ main-thread:ref={elRef}
163
+ style={cssVarStyle(insets.value, props.style)}
164
+ >
165
+ {slots.default?.()}
166
+ </view>
167
+ );
168
+ });
169
+
170
+ interface Extras {
171
+ keyboard: number;
172
+ statusBar: number;
173
+ navigationBar: number;
174
+ }
175
+
176
+ function normaliseInsets(raw: unknown, fallback: EdgeInsets): EdgeInsets {
177
+ if (!raw || typeof raw !== 'object') return fallback;
178
+ const o = raw as Record<string, unknown>;
179
+ return {
180
+ top: numOr(o['top'], fallback.top),
181
+ right: numOr(o['right'], fallback.right),
182
+ bottom: numOr(o['bottom'], fallback.bottom),
183
+ left: numOr(o['left'], fallback.left),
184
+ keyboard: numOr(o['keyboard'], fallback.keyboard),
185
+ statusBar: numOr(o['statusBar'], fallback.statusBar),
186
+ navigationBar: numOr(o['navigationBar'], fallback.navigationBar),
187
+ };
188
+ }
189
+
190
+ function numOr(v: unknown, fallback: number): number {
191
+ return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
192
+ }
193
+
194
+ function cssVarStyle(
195
+ i: EdgeInsets,
196
+ user: Record<string, string | number> | undefined,
197
+ ): Record<string, string | number> {
198
+ const base: Record<string, string | number> = {
199
+ '--sat': `${i.top}px`,
200
+ '--sar': `${i.right}px`,
201
+ '--sab': `${i.bottom}px`,
202
+ '--sal': `${i.left}px`,
203
+ '--safe-area-keyboard': `${i.keyboard}px`,
204
+ };
205
+ return user ? { ...base, ...user } : base;
206
+ }
207
+
208
+ // re-export so users only need `@sigx/lynx-safe-area`
209
+ export type { SharedValue };
@@ -0,0 +1,73 @@
1
+ import { component, type Define } from '@sigx/lynx';
2
+ import { useSafeAreaInsets } from './hooks.js';
3
+ import type { Edge, SafeAreaMode } from './types.js';
4
+
5
+ export type SafeAreaViewProps =
6
+ & Define.Prop<'edges', Edge[], false>
7
+ & Define.Prop<'mode', SafeAreaMode, false>
8
+ & Define.Prop<'class', string, false>
9
+ & Define.Prop<'style', Record<string, string | number>, false>
10
+ & Define.Slot<'default'>;
11
+
12
+ const ALL_EDGES: Edge[] = ['top', 'right', 'bottom', 'left'];
13
+
14
+ /**
15
+ * Drop-in container that applies the current safe-area insets as padding
16
+ * (default) or margin on the configured edges.
17
+ *
18
+ * Implementation: BG signal + inline style. Sigx auto-tracks `insets.value`
19
+ * access in the render function, so the inset values land in the FIRST
20
+ * layout pass and re-apply reactively on every `safeAreaChanged` event.
21
+ *
22
+ * The previous implementation used `useAnimatedStyle` to drive padding via
23
+ * the MT bridge — but `setStyleProperties` writes that affect layout fire
24
+ * AFTER the first layout pass, and child elements that have already laid
25
+ * out (notably `<scroll-view>`, which captures its frame eagerly) don't
26
+ * reflow. Inline style avoids that timing trap entirely.
27
+ *
28
+ * `edges` defaults to all four sides. Pass a subset (e.g. `['top']`) to
29
+ * leave the unspecified sides unaffected.
30
+ *
31
+ * Must be a descendant of `<SafeAreaProvider>`. If no provider is in scope
32
+ * (test/storybook), `useSafeAreaInsets()` returns `ZERO_INSETS` with a
33
+ * dev-mode warning and SafeAreaView passes through unchanged.
34
+ *
35
+ * @example
36
+ * ```tsx
37
+ * <SafeAreaProvider>
38
+ * <SafeAreaView edges={['top', 'bottom']} class="bg-base-100 flex-1">
39
+ * <PageContent />
40
+ * </SafeAreaView>
41
+ * </SafeAreaProvider>
42
+ * ```
43
+ */
44
+ export const SafeAreaView = component<SafeAreaViewProps>(({ props, slots }) => {
45
+ const insets = useSafeAreaInsets();
46
+ const edges = props.edges ?? ALL_EDGES;
47
+ const mode = props.mode ?? 'padding';
48
+
49
+ return () => {
50
+ const i = insets.value;
51
+ const insetStyle: Record<string, string | number> = {};
52
+ if (edges.includes('top')) {
53
+ insetStyle[mode === 'padding' ? 'paddingTop' : 'marginTop'] = `${i.top}px`;
54
+ }
55
+ if (edges.includes('right')) {
56
+ insetStyle[mode === 'padding' ? 'paddingRight' : 'marginRight'] = `${i.right}px`;
57
+ }
58
+ if (edges.includes('bottom')) {
59
+ insetStyle[mode === 'padding' ? 'paddingBottom' : 'marginBottom'] = `${i.bottom}px`;
60
+ }
61
+ if (edges.includes('left')) {
62
+ insetStyle[mode === 'padding' ? 'paddingLeft' : 'marginLeft'] = `${i.left}px`;
63
+ }
64
+ return (
65
+ <view
66
+ class={props.class}
67
+ style={props.style ? { ...props.style, ...insetStyle } : insetStyle}
68
+ >
69
+ {slots.default?.()}
70
+ </view>
71
+ );
72
+ };
73
+ });
package/src/types.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Per-edge inset values, in dp/pt (logical pixels). Top/right/bottom/left
3
+ * follow CSS shorthand order. Keyboard, statusBar, navigationBar are
4
+ * informational extras populated when the host platform exposes them — they
5
+ * may be 0 if unknown.
6
+ */
7
+ export interface EdgeInsets {
8
+ top: number;
9
+ right: number;
10
+ bottom: number;
11
+ left: number;
12
+ /** IME (soft keyboard) height when visible, 0 when hidden. */
13
+ keyboard: number;
14
+ /** Status-bar height (top system bar). Often equal to `top`, but on
15
+ * notched devices the safe-area top includes the notch and the status
16
+ * bar is the smaller status-only inset. */
17
+ statusBar: number;
18
+ /** Navigation-bar height (Android gesture/3-button nav at bottom). */
19
+ navigationBar: number;
20
+ }
21
+
22
+ /**
23
+ * The four standard CSS edges. Subset to control which sides
24
+ * `<SafeAreaView>` applies inset padding/margin to.
25
+ */
26
+ export type Edge = 'top' | 'right' | 'bottom' | 'left';
27
+
28
+ /** Whether `<SafeAreaView>` applies its insets as `padding` or `margin`. */
29
+ export type SafeAreaMode = 'padding' | 'margin';
30
+
31
+ /**
32
+ * The injectable shape exposed by `<SafeAreaProvider>`. Components that need
33
+ * insets reactively read `insets.value` (BG signal) or, for MT-driven
34
+ * layouts, subscribe to per-edge `SharedValue`s.
35
+ */
36
+ export interface SafeAreaContextValue {
37
+ /** BG-side reactive insets. Re-renders the consumer on change. */
38
+ readonly insets: import('@sigx/reactivity').PrimitiveSignal<EdgeInsets>;
39
+ /** Per-edge SharedValues for MT-driven `useAnimatedStyle` bindings. */
40
+ readonly sv: {
41
+ top: import('@sigx/lynx').SharedValue<number>;
42
+ right: import('@sigx/lynx').SharedValue<number>;
43
+ bottom: import('@sigx/lynx').SharedValue<number>;
44
+ left: import('@sigx/lynx').SharedValue<number>;
45
+ };
46
+ }
47
+
48
+ export const ZERO_INSETS: EdgeInsets = {
49
+ top: 0,
50
+ right: 0,
51
+ bottom: 0,
52
+ left: 0,
53
+ keyboard: 0,
54
+ statusBar: 0,
55
+ navigationBar: 0,
56
+ };