@symbiote-native/solid 0.0.1 → 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.
Files changed (105) hide show
  1. package/build/bootstrap.d.ts +7 -0
  2. package/build/bootstrap.js +18 -0
  3. package/build/components/activity-indicator/index.android.d.ts +2 -0
  4. package/build/components/activity-indicator/index.android.js +11 -0
  5. package/build/components/activity-indicator/index.d.ts +1 -0
  6. package/build/components/activity-indicator/index.ios.d.ts +2 -0
  7. package/build/components/activity-indicator/index.ios.js +10 -0
  8. package/build/components/activity-indicator/index.js +4 -0
  9. package/build/components/activity-indicator/shared.d.ts +8 -0
  10. package/build/components/activity-indicator/shared.js +51 -0
  11. package/build/components/button.d.ts +7 -0
  12. package/build/components/button.jsx +45 -0
  13. package/build/components/flat-list/index.d.ts +10 -0
  14. package/build/components/flat-list/index.jsx +118 -0
  15. package/build/components/image-background.d.ts +10 -0
  16. package/build/components/image-background.jsx +74 -0
  17. package/build/components/image.d.ts +9 -0
  18. package/build/components/image.js +75 -0
  19. package/build/components/input-accessory-view.d.ts +11 -0
  20. package/build/components/input-accessory-view.jsx +39 -0
  21. package/build/components/keyboard-avoiding-view.d.ts +15 -0
  22. package/build/components/keyboard-avoiding-view.jsx +152 -0
  23. package/build/components/modal.d.ts +24 -0
  24. package/build/components/modal.jsx +125 -0
  25. package/build/components/pressable.d.ts +30 -0
  26. package/build/components/pressable.jsx +185 -0
  27. package/build/components/refresh-control.d.ts +18 -0
  28. package/build/components/refresh-control.jsx +36 -0
  29. package/build/components/safe-area-view.d.ts +10 -0
  30. package/build/components/safe-area-view.jsx +24 -0
  31. package/build/components/scroll-view/index.android.d.ts +2 -0
  32. package/build/components/scroll-view/index.android.js +11 -0
  33. package/build/components/scroll-view/index.d.ts +2 -0
  34. package/build/components/scroll-view/index.ios.d.ts +2 -0
  35. package/build/components/scroll-view/index.ios.js +8 -0
  36. package/build/components/scroll-view/index.js +9 -0
  37. package/build/components/scroll-view/shared.d.ts +85 -0
  38. package/build/components/scroll-view/shared.jsx +334 -0
  39. package/build/components/scroll-view/sticky-header.d.ts +18 -0
  40. package/build/components/scroll-view/sticky-header.jsx +182 -0
  41. package/build/components/section-list/index.d.ts +6 -0
  42. package/build/components/section-list/index.jsx +8 -0
  43. package/build/components/switch/index.android.d.ts +2 -0
  44. package/build/components/switch/index.android.js +12 -0
  45. package/build/components/switch/index.d.ts +1 -0
  46. package/build/components/switch/index.ios.d.ts +2 -0
  47. package/build/components/switch/index.ios.js +11 -0
  48. package/build/components/switch/index.js +4 -0
  49. package/build/components/switch/shared.d.ts +11 -0
  50. package/build/components/switch/shared.js +87 -0
  51. package/build/components/text-input.d.ts +10 -0
  52. package/build/components/text-input.js +268 -0
  53. package/build/components/text.d.ts +26 -0
  54. package/build/components/text.jsx +27 -0
  55. package/build/components/touchable/index.d.ts +22 -0
  56. package/build/components/touchable/index.jsx +247 -0
  57. package/build/components/touchable-native-feedback/index.d.ts +16 -0
  58. package/build/components/touchable-native-feedback/index.jsx +68 -0
  59. package/build/components/view.d.ts +32 -0
  60. package/build/components/view.jsx +49 -0
  61. package/build/components/virtualized-list/index.android.d.ts +2 -0
  62. package/build/components/virtualized-list/index.android.js +9 -0
  63. package/build/components/virtualized-list/index.d.ts +1 -0
  64. package/build/components/virtualized-list/index.ios.d.ts +2 -0
  65. package/build/components/virtualized-list/index.ios.js +9 -0
  66. package/build/components/virtualized-list/index.js +4 -0
  67. package/build/components/virtualized-list/shared.d.ts +75 -0
  68. package/build/components/virtualized-list/shared.jsx +782 -0
  69. package/build/components/virtualized-section-list/index.d.ts +62 -0
  70. package/build/components/virtualized-section-list/index.jsx +145 -0
  71. package/build/components.d.ts +43 -0
  72. package/build/components.js +54 -0
  73. package/build/create-portal/index.d.ts +11 -0
  74. package/build/create-portal/index.jsx +88 -0
  75. package/build/create-tunnel/index.d.ts +13 -0
  76. package/build/create-tunnel/index.jsx +53 -0
  77. package/build/descriptor-to-solid.d.ts +3 -0
  78. package/build/descriptor-to-solid.js +91 -0
  79. package/build/host-instance.d.ts +2 -0
  80. package/build/host-instance.js +36 -0
  81. package/build/index.d.ts +30 -0
  82. package/build/index.js +96 -0
  83. package/build/jsx-runtime.d.ts +26 -0
  84. package/build/jsx-runtime.js +46 -0
  85. package/build/modules/animated/create-animated-component.d.ts +10 -0
  86. package/build/modules/animated/create-animated-component.jsx +105 -0
  87. package/build/modules/animated/index.d.ts +54 -0
  88. package/build/modules/animated/index.js +78 -0
  89. package/build/modules/app-registry/index.d.ts +7 -0
  90. package/build/modules/app-registry/index.js +37 -0
  91. package/build/modules/status-bar.d.ts +7 -0
  92. package/build/modules/status-bar.js +45 -0
  93. package/build/primitives/create-color-scheme.d.ts +3 -0
  94. package/build/primitives/create-color-scheme.js +33 -0
  95. package/build/primitives/create-window-dimensions.d.ts +3 -0
  96. package/build/primitives/create-window-dimensions.js +34 -0
  97. package/build/render.d.ts +7 -0
  98. package/build/render.js +91 -0
  99. package/build/renderer.d.ts +7 -0
  100. package/build/renderer.js +195 -0
  101. package/build/utils/host-ref.d.ts +3 -0
  102. package/build/utils/host-ref.js +22 -0
  103. package/build/utils/stable-keys.d.ts +1 -0
  104. package/build/utils/stable-keys.js +31 -0
  105. package/package.json +5 -5
@@ -0,0 +1,152 @@
1
+ // KeyboardAvoidingView — the Solid lifecycle half. It composes a wrapper View and shifts it out of
2
+ // the keyboard's way as the keyboard shows/hides. The inset math (readKeyboardFrame /
3
+ // readLayoutFrame / computeInset) and the behavior → style/structure decision
4
+ // (resolveKeyboardAvoidingLayout) are framework-agnostic and live in @symbiote-native/components,
5
+ // shared verbatim with React, Vue, Svelte and Angular. Solid supplies only the reactivity: one
6
+ // signal for the inset, three plain variables for the measured frame and the cross-fade setting, a
7
+ // subscribe-at-setup / onCleanup pair over the Keyboard module, and the element assembly around its
8
+ // own children.
9
+ //
10
+ // render-keyboard-avoiding-view.ts deliberately returns a layout DESCRIPTION rather than a
11
+ // Descriptor tree — KAV wraps a live user subtree, which is the case the render-fn boundary rules
12
+ // OUT of core (.claude/rules/component-render-fn-boundary.md). So descriptorToSolid has nothing to
13
+ // do here, exactly as in View and Pressable; the two structural shapes ('nested' vs 'wrapper') are
14
+ // written directly as the two branches at the bottom.
15
+ //
16
+ // NO whenCommitted anywhere: KAV issues no imperative native call. It only reads `onLayout`, and the
17
+ // engine raises the native layout flag itself the moment a listener rides the prop bag — an event
18
+ // cannot arrive before the view exists, so there is no pre-commit window to retry across.
19
+ //
20
+ // NOTHING here destructures `props`. Solid props are getters and a component body runs ONCE; every
21
+ // read below sits inside an accessor, a memo, or an event handler — including
22
+ // `keyboardVerticalOffset`, which is read at EVENT time so a changed offset applies to the next
23
+ // keyboard event without re-subscribing (React re-runs its useEffect for that; here the closure
24
+ // already sees the live value).
25
+ import { createMemo, createSignal, onCleanup, splitProps } from 'solid-js';
26
+ import { computeInset, keyboardAvoidingEventNamesFor, readKeyboardFrame, readLayoutFrame, readPrefersCrossFadeTransitions, resolveKeyboardAvoidingLayout, DEFAULT_VERTICAL_OFFSET, } from '@symbiote-native/components';
27
+ import { Keyboard, Platform, dlog, } from '@symbiote-native/engine';
28
+ import { View } from './view.jsx';
29
+ // Read by KeyboardAvoidingView itself; everything else (the aria/role aliases, accessibility*,
30
+ // testID, class) forwards onto the wrapper View, which folds them once through
31
+ // resolveAccessibilityProps and widens the vanished-key case with withStableKeys.
32
+ const HANDLED_PROPS = [
33
+ 'behavior',
34
+ 'enabled',
35
+ 'keyboardVerticalOffset',
36
+ 'contentContainerStyle',
37
+ 'style',
38
+ 'onLayout',
39
+ 'children',
40
+ ];
41
+ export function KeyboardAvoidingView(props) {
42
+ const [local, rest] = splitProps(props, HANDLED_PROPS);
43
+ const [inset, setInset] = createSignal(0);
44
+ // Plain variables, not signals: a re-measure alone must not repaint anything. They only feed the
45
+ // NEXT keyboard event's math, which is what re-runs the layout memo. Same shape as React's
46
+ // frameRef/initialHeightRef, Vue's `let frame` and Svelte's non-$state pair.
47
+ let frame;
48
+ let initialHeight;
49
+ // A device accessibility setting, not state: it cannot change mid-session, and nothing should
50
+ // repaint when the promise lands. So a plain `let` rather than a signal — the same reason `frame`
51
+ // above is one. It is read at EVENT time, by which point the microtask has long resolved; a
52
+ // keyboard event that somehow beat it just sees the `false` default, which is the ordinary math.
53
+ //
54
+ // Through the shared reader, not AccessibilityInfo directly: the engine's getter REJECTS on a
55
+ // native error (RN parity), and nothing awaits this call, so a rejection here would surface as an
56
+ // unhandled promise. readPrefersCrossFadeTransitions owns that fallback once for all five adapters
57
+ // and answers false, which is also what a missing module and an Android host answer.
58
+ let prefersCrossFadeTransitions = false;
59
+ void readPrefersCrossFadeTransitions().then(enabled => {
60
+ prefersCrossFadeTransitions = enabled;
61
+ });
62
+ const onShow = (payload) => {
63
+ const keyboard = readKeyboardFrame(payload);
64
+ const offset = local.keyboardVerticalOffset ?? DEFAULT_VERTICAL_OFFSET;
65
+ const next = computeInset(frame, keyboard, offset, {
66
+ behavior: local.behavior,
67
+ // The inset CURRENTLY applied (RN's this.state.bottom). Read from the signal at event time so
68
+ // it is live: in 'height' mode the wrapper is SHRUNK by it, so the next onLayout reports a
69
+ // frame shorter by exactly that much and core adds it back to cancel the shrink. Without it
70
+ // each further keyboard event computes a smaller overlap and the view walks back down under
71
+ // the keyboard. Core gates the correction on 'height'; the other modes ignore the value.
72
+ previousInset: inset(),
73
+ prefersCrossFadeTransitions,
74
+ });
75
+ dlog(`KeyboardAvoidingView show -> inset ${next}`);
76
+ setInset(next);
77
+ };
78
+ const onHide = () => {
79
+ dlog('KeyboardAvoidingView hide -> inset 0');
80
+ setInset(0);
81
+ };
82
+ // TWO listeners, never three, and their names come from the host (RN KeyboardAvoidingView.js):
83
+ // iOS takes the WILL pair so the view rides up with the keyboard animation instead of snapping
84
+ // into place after it; Android has no will-notifications and keeps the DID pair. The change-frame
85
+ // notification is deliberately absent — RN's own comment says an undocked, split or floating iOS
86
+ // keyboard emits it BEFORE the hide, so listening to it applies a frame captured mid-dismissal.
87
+ //
88
+ // Subscribed in the setup body, which runs once per mount — Solid's counterpart to React's
89
+ // mount-effect and Vue's onMounted. onCleanup runs on dispose (render.ts's teardown disposes the
90
+ // whole root), so an unmount removes exactly the listeners this mount added; leaking them would
91
+ // leave a dead closure calling setInset on a disposed signal on every keystroke.
92
+ const events = keyboardAvoidingEventNamesFor(Platform.OS);
93
+ const subscriptions = [
94
+ Keyboard.addListener(events.show, onShow),
95
+ Keyboard.addListener(events.hide, onHide),
96
+ ];
97
+ onCleanup(() => {
98
+ for (const subscription of subscriptions)
99
+ subscription.remove();
100
+ });
101
+ const handleLayout = (event) => {
102
+ const measured = readLayoutFrame(event.nativeEvent.layout);
103
+ if (measured !== undefined) {
104
+ frame = measured;
105
+ if (initialHeight === undefined)
106
+ initialHeight = measured.height;
107
+ }
108
+ local.onLayout?.(event);
109
+ };
110
+ const layout = createMemo(() => resolveKeyboardAvoidingLayout({
111
+ behavior: local.behavior,
112
+ // When disabled the inset is forced to 0, so every behavior mode renders untouched. `inset()`
113
+ // is not read at all on that branch, so a disabled KAV does not even subscribe to the signal;
114
+ // re-enabling re-runs this memo through the `local.enabled` getter and picks the value up.
115
+ effectiveInset: local.enabled === false ? 0 : inset(),
116
+ initialHeight,
117
+ style: local.style,
118
+ contentContainerStyle: local.contentContainerStyle,
119
+ }));
120
+ // What keeps the children alive across a keystroke. Solid has no reconciler between this file and
121
+ // the host nodes — `insert` REPLACES a subtree (.claude/rules/solid-descriptor-bridge.md §4) — and
122
+ // the branch below runs inside the wrapper's `insert` render effect. `layout()` returns a fresh
123
+ // object on every keyboard event, so anything that lets it reach that effect tears the wrapped
124
+ // subtree down and rebuilds it on every show/hide: typically the form the user is typing in, which
125
+ // loses focus mid-keystroke. createMemo's default `===` equality stops the churn at the BOOLEAN —
126
+ // it only notifies when the structure actually flips, i.e. when `behavior` moves in or out of
127
+ // 'position'.
128
+ //
129
+ // babel-preset-solid's `wrapConditionals` (on by default) would memoize the condition of the
130
+ // ternary below on its own, so the inline form happens to be safe too. Naming the memo makes that
131
+ // guarantee local instead of a compiler flag nobody reads, and it survives the refactor that
132
+ // actually breaks it — measured: moving the same branch into a plain `renderContent()` helper (the
133
+ // shape Pressable uses) defeats wrapConditionals and rebuilds the subtree on every keyboard event.
134
+ const isNested = createMemo(() => layout().kind === 'nested');
135
+ // The inset reaches the host as a PROP instead: `spread` runs a per-key diff on the SAME element,
136
+ // so nothing is rebuilt.
137
+ const innerStyle = () => {
138
+ const resolved = layout();
139
+ return resolved.kind === 'nested' ? resolved.innerStyle : undefined;
140
+ };
141
+ const wrapperProps = createMemo(() => ({
142
+ ...rest,
143
+ style: layout().wrapperStyle,
144
+ // Last, so KAV's own measure always runs; the caller's handler is invoked from inside it.
145
+ onLayout: handleLayout,
146
+ }));
147
+ // 'nested' ('position') pushes the children in an inner View by `bottom: inset`; the wrapper modes
148
+ // ('padding' / 'height' / no behavior) adjust the single wrapper directly.
149
+ return (<View {...wrapperProps()}>
150
+ {isNested() ? (<View style={innerStyle()}>{local.children}</View>) : (local.children)}
151
+ </View>);
152
+ }
@@ -0,0 +1,24 @@
1
+ import type { JSX } from '../jsx-runtime';
2
+ import { type IAccessibilityProps, type IAriaProps, type IModalAnimationType, type IModalOrientation, type IModalPresentationStyle } from '@symbiote-native/components';
3
+ import { type IClassNameValue, type IStyleProp, type ISymbioteEvent, type IViewStyle } from '@symbiote-native/engine';
4
+ export type { IModalAnimationType, IModalPresentationStyle, IModalOrientation, IModalOrientationChangeEvent, } from '@symbiote-native/components';
5
+ export interface IModalProps extends IAccessibilityProps, IAriaProps {
6
+ visible?: boolean;
7
+ transparent?: boolean;
8
+ backdropColor?: string;
9
+ animationType?: IModalAnimationType;
10
+ presentationStyle?: IModalPresentationStyle;
11
+ supportedOrientations?: ReadonlyArray<IModalOrientation>;
12
+ hardwareAccelerated?: boolean;
13
+ statusBarTranslucent?: boolean;
14
+ navigationBarTranslucent?: boolean;
15
+ allowSwipeDismissal?: boolean;
16
+ onShow?: () => void;
17
+ onDismiss?: () => void;
18
+ onRequestClose?: () => void;
19
+ onOrientationChange?: (event: ISymbioteEvent) => void;
20
+ style?: IStyleProp<IViewStyle>;
21
+ class?: IClassNameValue;
22
+ children?: JSX.Element;
23
+ }
24
+ export declare function Modal(props: IModalProps): JSX.Element;
@@ -0,0 +1,125 @@
1
+ // Modal — the Solid lifecycle half. RCTModalHostView is an ordinary Fabric host node committing
2
+ // through the SAME childSet as the rest of the tree (no second JS surface, no portal). The style
3
+ // math (the backdrop override, the container/host styles, the presentationStyle default), the
4
+ // visible gate, and the keep-alive reducer all live framework-agnostic in
5
+ // @symbiote-native/components (state/modal.ts + view/render-modal.ts) and are shared verbatim with
6
+ // React, Vue and Svelte. Solid supplies only the reactivity: a signal over the keep-alive state, a
7
+ // post-render effect driving the visible→hidden transition, and the two host tags renderModal's
8
+ // fixed shape resolves to.
9
+ //
10
+ // A FLAT FILE, not a folder: Modal has no platform variant to select by filename. Every platform
11
+ // difference it carries is a PROP (supportedOrientations/allowSwipeDismissal are iOS-read,
12
+ // hardwareAccelerated/statusBarTranslucent/navigationBarTranslucent Android-read) forwarded by name
13
+ // to the one host, exactly as React's, Vue's and Svelte's single-file Modals do — unlike Switch,
14
+ // whose native prop NAMES and snap-back command genuinely differ per host (symbiote-file-layout:
15
+ // only a real platform/shared group gets a folder).
16
+ //
17
+ // WHY HAND-AUTHORED JSX AND NOT descriptorToSolid. Modal takes `children` — a live subtree of the
18
+ // user's components, which is the case the render-fn boundary rules OUT of core
19
+ // (.claude/rules/component-render-fn-boundary.md), so the bridge would have nothing to do with the
20
+ // half that matters and would hand back a root whose container child we would then have to reach
21
+ // into to `insert` into. renderModal always paints the SAME two-tag shape (one symbiote-modal host
22
+ // wrapping one collapsable symbiote-view container; only prop VALUES vary), so the tags are written
23
+ // out and the computed props read off the fixed positions — the same call React's createElement
24
+ // chain, Vue's h() chain and Svelte's markup all make. `children` then reaches the container
25
+ // through the compiler's own `insert`, which is the point of view.tsx's header.
26
+ //
27
+ // `Show` IS IMPORTED FROM solid-js ON PURPOSE. babel-preset-solid resolves an un-imported control-
28
+ // flow name against the renderer module instead, and renderer.ts exports no `Show` — the bundle
29
+ // still builds and `createComponent(undefined, …)` throws at RUNTIME, far from the JSX that caused
30
+ // it (.claude/rules/solid-descriptor-bridge.md §3).
31
+ //
32
+ // NOTHING here destructures `props`. Solid props are getters and a component body runs ONCE; every
33
+ // read below sits inside an accessor or an effect. The single deliberate exception is the reducer
34
+ // SEED, which is a mount-time value by definition (React's useReducer lazy init and Svelte's
35
+ // $state(...) initializer read it exactly once too).
36
+ import { createEffect, createSignal, Show, splitProps } from 'solid-js';
37
+ import { createDescriptorShapeGuard, createInitialModalState, modalReducer, renderModal, resolveAccessibilityProps, shouldRenderModal, } from '@symbiote-native/components';
38
+ import { dlog, } from '@symbiote-native/engine';
39
+ import { withStableKeys } from '../utils/stable-keys.js';
40
+ // Read by Modal itself; everything else (the four DirectEvents, testID/nativeID, every
41
+ // accessibility* and aria-* field) forwards onto the host node through `passthrough`, exactly as
42
+ // React's `...passthrough` rest does.
43
+ const HANDLED_PROPS = [
44
+ 'visible',
45
+ 'transparent',
46
+ 'backdropColor',
47
+ 'animationType',
48
+ 'presentationStyle',
49
+ 'supportedOrientations',
50
+ 'hardwareAccelerated',
51
+ 'statusBarTranslucent',
52
+ 'navigationBarTranslucent',
53
+ 'allowSwipeDismissal',
54
+ 'style',
55
+ 'class',
56
+ 'children',
57
+ ];
58
+ const shape = createDescriptorShapeGuard('Modal');
59
+ export function Modal(props) {
60
+ const [local, rest] = splitProps(props, HANDLED_PROPS);
61
+ const isVisible = () => local.visible === true;
62
+ // The keep-alive seed is a mount-time value by contract (createInitialModalState: a modal that
63
+ // starts visible is rendered, one that starts hidden contributes no node), so this one read
64
+ // outside an accessor is correct rather than the frozen-props bug.
65
+ const [state, setState] = createSignal(createInitialModalState(isVisible()));
66
+ // createEffect, not createRenderEffect: it runs AFTER the render that used the OLD state, which
67
+ // is the position React's useEffect, Vue's flush:'post' watch and Svelte's $effect all occupy —
68
+ // so a visible→hidden transition renders once more with isRendered still true (the keep-alive
69
+ // frame) before this drops it. A render effect would unmount immediately and kill the keep-alive.
70
+ // The reducer is identity-stable, so the mount run and any no-op transition write the same object
71
+ // back and nothing downstream recomputes.
72
+ //
73
+ // As on Svelte, and for the same reason: under this adapter's microtask-coalesced requestCommit()
74
+ // the whole cascade settles inside one flush, so the keep-alive frame is real but never lands as
75
+ // its own Fabric commit the way React's synchronous per-render commit makes it.
76
+ createEffect(() => {
77
+ setState(current => modalReducer(current, isVisible() ? { type: 'show' } : { type: 'hide' }));
78
+ });
79
+ const shouldRender = () => shouldRenderModal(isVisible(), state());
80
+ createEffect(() => {
81
+ if (!shouldRender())
82
+ dlog('Modal hidden -> no node committed');
83
+ });
84
+ // A plain accessor rather than a createMemo, unlike descriptor-to-solid.ts. A memo runs EAGERLY
85
+ // at creation, which would paint a descriptor (and emit renderModal's "committing" dlog) for a
86
+ // modal that is hidden and commits nothing. Read from inside the two `spread` render effects
87
+ // instead, it never runs at all while `Show` is closed; the cost is that a change recomputes this
88
+ // pure object literal twice, once per bag.
89
+ const descriptor = () => renderModal({
90
+ visible: local.visible,
91
+ transparent: local.transparent,
92
+ backdropColor: local.backdropColor,
93
+ animationType: local.animationType,
94
+ presentationStyle: local.presentationStyle,
95
+ supportedOrientations: local.supportedOrientations,
96
+ hardwareAccelerated: local.hardwareAccelerated,
97
+ statusBarTranslucent: local.statusBarTranslucent,
98
+ navigationBarTranslucent: local.navigationBarTranslucent,
99
+ allowSwipeDismissal: local.allowSwipeDismissal,
100
+ style: local.style,
101
+ passthrough: resolveAccessibilityProps(rest),
102
+ });
103
+ // root = symbiote-modal > [container]; the user children nest UNDER the container View, never as
104
+ // a direct sibling of the host (RN's modal content layout, render-modal.ts).
105
+ const container = () => {
106
+ const [first] = descriptor().children;
107
+ if (first === undefined)
108
+ throw shape.error('the container child disappeared');
109
+ return shape.asElement(first);
110
+ };
111
+ // withStableKeys on both bags because `passthrough` goes through resolveAccessibilityProps, whose
112
+ // two branches emit DIFFERENT key sets — and Solid's `spread` walks only the CURRENT keys with no
113
+ // removal pass, so a vanished key would keep its last value on the native view forever
114
+ // (.claude/rules/solid-descriptor-bridge.md §1).
115
+ const hostBag = withStableKeys(() => descriptor().props);
116
+ const containerBag = withStableKeys(() => ({
117
+ ...container().props,
118
+ class: local.class,
119
+ }));
120
+ return (<Show when={shouldRender()}>
121
+ <symbiote-modal {...hostBag()}>
122
+ <symbiote-view {...containerBag()}>{local.children}</symbiote-view>
123
+ </symbiote-modal>
124
+ </Show>);
125
+ }
@@ -0,0 +1,30 @@
1
+ import { type Accessor } from 'solid-js';
2
+ import type { JSX } from '../jsx-runtime';
3
+ import { type IAccessibilityProps, type IAriaProps, type IPressHandler, type IPressState, type IPressableAndroidRippleConfig, type IRectOffset } from '@symbiote-native/components';
4
+ import { type IClassNameValue, type IStyleProp, type IViewStyle } from '@symbiote-native/engine';
5
+ export type { IPressState, IPressableAndroidRippleConfig, } from '@symbiote-native/components';
6
+ type IPressableStyle = IStyleProp<IViewStyle> | ((state: IPressState) => IStyleProp<IViewStyle>);
7
+ type IPressableChildren = JSX.Element | ((state: Accessor<IPressState>) => JSX.Element);
8
+ export interface IPressableProps extends IAccessibilityProps, IAriaProps {
9
+ onPress?: IPressHandler;
10
+ onPressIn?: IPressHandler;
11
+ onPressOut?: IPressHandler;
12
+ onPressMove?: IPressHandler;
13
+ onLongPress?: IPressHandler;
14
+ delayLongPress?: number;
15
+ disabled?: boolean;
16
+ cancelable?: boolean;
17
+ hitSlop?: IRectOffset;
18
+ pressRetentionOffset?: IRectOffset;
19
+ unstable_pressDelay?: number;
20
+ android_ripple?: IPressableAndroidRippleConfig;
21
+ android_disableSound?: boolean;
22
+ onHoverIn?: IPressHandler;
23
+ onHoverOut?: IPressHandler;
24
+ delayHoverIn?: number;
25
+ delayHoverOut?: number;
26
+ style?: IPressableStyle;
27
+ class?: IClassNameValue;
28
+ children?: IPressableChildren;
29
+ }
30
+ export declare function Pressable(props: IPressableProps): JSX.Element;
@@ -0,0 +1,185 @@
1
+ // Pressable — the Solid lifecycle half. The press lifecycle (the long-press timer, the
2
+ // unstable_pressDelay deferral, the pressRetentionOffset drift test, the suppression flags) lives
3
+ // in @symbiote-native/components/state as a pure machine over a runtime + host; the render
4
+ // decisions (which responder listeners the View carries, the disabled→accessibilityState fold, the
5
+ // ripple prop) in @symbiote-native/components/view. Both are shared verbatim with React, Vue and
6
+ // Svelte. Solid supplies only the reactivity: a signal for `pressed`, a setup-scope object for the
7
+ // press runtime, a plain variable for the responder node, and one memo that rebuilds the handler
8
+ // bag when the caller's config changes.
9
+ //
10
+ // THE RESPONDER EVENTS ARE NEVER NAMED AS EVENTS HERE. buildPressableListeners returns a flat bag
11
+ // (`onPress`, `onStartShouldSetResponder`, `onResponderMove`, …) which rides into View's prop bag
12
+ // and reaches the engine through routeProp, which decides prop-vs-event from the node's ViewConfig
13
+ // and attaches the responder protocol itself (symbiote-engine-core §2). An adapter-side `onX` check
14
+ // would both duplicate that and get the edge cases wrong.
15
+ //
16
+ // Pressable composes View rather than painting its own host tag, exactly as React's and Vue's do —
17
+ // so the aria/role fold, the class+style merge and the vanished-key widening (utils/stable-keys)
18
+ // all happen once, in View. That also means there is no renderPressable() Descriptor to bridge:
19
+ // `children` is a live Solid subtree, which is the case the render-fn boundary rules OUT of core
20
+ // (.claude/rules/component-render-fn-boundary.md).
21
+ //
22
+ // NOTHING here destructures `props`. Solid props are getters and a component body runs ONCE, so a
23
+ // destructure would freeze the Pressable at its mount-time config; every read below sits inside an
24
+ // accessor, a memo or an event handler.
25
+ import { createEffect, createMemo, createSignal, splitProps, untrack, } from 'solid-js';
26
+ import { buildPressableListeners, createPressHandlers, createPressRuntime, noteHoverNoop, resolveDisabledAccessibilityState, rippleProps, DEFAULT_DELAY_LONG_PRESS_MS, } from '@symbiote-native/components';
27
+ import { dlog, } from '@symbiote-native/engine';
28
+ import { View } from './view.jsx';
29
+ // Read by Pressable itself; everything else forwards onto View. The user's press callbacks MUST be
30
+ // split off — they are pure JS, and it is the machine's SYNTHESIZED handlers that go on the host.
31
+ const HANDLED_PROPS = [
32
+ 'onPress',
33
+ 'onPressIn',
34
+ 'onPressOut',
35
+ 'onPressMove',
36
+ 'onLongPress',
37
+ 'delayLongPress',
38
+ 'disabled',
39
+ 'cancelable',
40
+ 'pressRetentionOffset',
41
+ 'unstable_pressDelay',
42
+ 'android_ripple',
43
+ 'android_disableSound',
44
+ 'onHoverIn',
45
+ 'onHoverOut',
46
+ 'delayHoverIn',
47
+ 'delayHoverOut',
48
+ 'accessibilityState',
49
+ 'style',
50
+ 'children',
51
+ ];
52
+ // `hitSlop` is deliberately NOT in that list: the machine reads it for the retention test AND the
53
+ // host needs it to enlarge the touch target, so it forwards through `rest` untouched.
54
+ export function Pressable(props) {
55
+ const [local, rest] = splitProps(props, HANDLED_PROPS);
56
+ const [pressed, setPressed] = createSignal(false);
57
+ // The mutable press runtime (in-flight timers, suppression flags, measured region). A plain
58
+ // setup-scope object, never a signal: the machine mutates it on every event and nothing reads it
59
+ // reactively — same shape as Vue's setup-scope runtime and Svelte's plain `const`.
60
+ const runtime = createPressRuntime();
61
+ // The responder node, held by IDENTITY in a plain variable. A store or a deep proxy would become
62
+ // a different key than the one the engine's commit mirror holds, and measure() would silently
63
+ // no-op (symbiote-engine-core §3).
64
+ let responder = null;
65
+ const attachRef = (node) => {
66
+ responder = node;
67
+ };
68
+ // The lifecycle seam the machine fills: flip the reactive `pressed` cell, and expose the
69
+ // responder's raw frame-measure. Before the first commit the node has no Fabric tag, so measure()
70
+ // no-ops and the machine falls back to its radius test — the same degradation every adapter has,
71
+ // and not a whenCommitted case: the measure is triggered by a touch, which cannot arrive before
72
+ // the view exists.
73
+ const host = {
74
+ setPressed,
75
+ getMeasureFn: () => {
76
+ const node = responder;
77
+ if (node === null)
78
+ return undefined;
79
+ return callback => {
80
+ node.measure(callback);
81
+ };
82
+ },
83
+ schedule: (callback, ms) => {
84
+ const id = setTimeout(callback, ms);
85
+ return () => {
86
+ clearTimeout(id);
87
+ };
88
+ },
89
+ };
90
+ // Rebuilt when the caller's config changes (the closures capture live values); the runtime
91
+ // persists across rebuilds, so an in-flight timer or drift flag survives one. Deliberately does
92
+ // NOT read `pressed` — a press must not churn the handler identities the host node holds.
93
+ const handlers = createMemo(() => createPressHandlers({
94
+ onPress: local.onPress,
95
+ onPressIn: local.onPressIn,
96
+ onPressOut: local.onPressOut,
97
+ onPressMove: local.onPressMove,
98
+ onLongPress: local.onLongPress,
99
+ delayLongPress: local.delayLongPress ?? DEFAULT_DELAY_LONG_PRESS_MS,
100
+ unstable_pressDelay: local.unstable_pressDelay ?? 0,
101
+ hitSlop: rest.hitSlop,
102
+ pressRetentionOffset: local.pressRetentionOffset,
103
+ }, runtime, host));
104
+ createEffect(() => {
105
+ noteHoverNoop(local.onHoverIn, local.onHoverOut);
106
+ });
107
+ // Reading `pressed` sits INSIDE the function branch on purpose: a static style must not make the
108
+ // prop bag depend on the press signal, or every touch would re-diff and re-commit the whole bag
109
+ // for nothing. Unlike the children case below, reading it here is SAFE even in the function form
110
+ // — the value feeds viewProps, a prop bag, and a prop bag reaches the host through `spread`,
111
+ // which is a per-key diff on the SAME element. Nothing is rebuilt, so nothing can be rebuilt
112
+ // mid-gesture.
113
+ const pressStyle = () => {
114
+ const style = local.style;
115
+ if (typeof style !== 'function')
116
+ return style;
117
+ return style({ pressed: pressed() });
118
+ };
119
+ const viewProps = createMemo(() => {
120
+ const disabled = local.disabled;
121
+ const bag = {
122
+ ...rest,
123
+ style: pressStyle(),
124
+ accessibilityState: resolveDisabledAccessibilityState(local.accessibilityState, disabled),
125
+ };
126
+ // Forwarded under RN's own key; inert on iOS.
127
+ if (local.android_disableSound !== undefined) {
128
+ bag.android_disableSound = local.android_disableSound;
129
+ }
130
+ // Last, so the synthesized responder listeners win over anything of the same name in `rest`.
131
+ // When disabled the bag is EMPTY — every listener key vanishes, and View's withStableKeys
132
+ // widens it back to `undefined`, which routeProp treats as a delete. Without that widening a
133
+ // Pressable that became disabled would keep its old listeners on the native view forever
134
+ // (.claude/rules/solid-descriptor-bridge.md §1).
135
+ Object.assign(bag, buildPressableListeners(handlers(), {
136
+ disabled,
137
+ cancelable: local.cancelable,
138
+ }));
139
+ return bag;
140
+ });
141
+ // A memo, not a bare `() => ({ pressed: pressed() })`: two leaves reading `state()` in the same
142
+ // press must see the same object, and an unchanged press must not hand out a fresh one.
143
+ const pressState = createMemo(() => ({ pressed: pressed() }));
144
+ // WHY AN ACCESSOR AND NOT `{ pressed: pressed() }`. Solid has no reconciler between what this
145
+ // file returns and the host nodes — `insert` REPLACES a subtree, it never diffs one (the same
146
+ // fact descriptor-to-solid.ts opens with). resolveChildren runs inside View's `insert` render
147
+ // effect, so reading `pressed()` there put the press signal in that effect's dependency set:
148
+ // every touch tore the child subtree down and built a fresh one. On device that rebuild landed
149
+ // between `pressIn` and the native responder grant, the grant was lost, the gesture died, and
150
+ // `pressed` stayed true — the "fires on every other tap, label stuck on its pressed text"
151
+ // report. Vue diffs the slot's vnodes, Svelte patches a Snippet in place, Angular updates a
152
+ // template context; all three reuse the nodes underneath. Solid has no such layer, so the
153
+ // reactivity must cross the boundary as an accessor and only the leaf that reads it re-runs.
154
+ //
155
+ // The call is UNTRACKED and happens once, which is exactly how Solid core treats its own render
156
+ // props: `<For>` runs the map fn under createRoot, and createRoot nulls the Listener. A signal
157
+ // read at the top level of the child function therefore does not re-run it — nest it in the JSX
158
+ // (or a <Show>) like anywhere else in Solid.
159
+ const resolveChildren = () => {
160
+ const children = local.children;
161
+ if (typeof children !== 'function')
162
+ return children;
163
+ // A zero-argument accessor is an ordinary Solid JSX child (JSX.Element covers `() => Element`),
164
+ // not a render prop, and `typeof` cannot tell the two apart — arity can. Handing it back
165
+ // unread lets `insert` wrap it in its own render effect, which keeps it reactive; calling it
166
+ // here, under untrack, would freeze it at its first value.
167
+ if (children.length === 0)
168
+ return () => children(pressState);
169
+ return untrack(() => children(pressState));
170
+ };
171
+ // android_ripple rides a dedicated inner View — Android's ReactViewManager reads the ripple
172
+ // background off a child, not off the responder itself. rippleProps() returns undefined off
173
+ // Android, so the child renders unwrapped, no extra node (RN Pressable.js: "inert on iOS").
174
+ const renderContent = () => {
175
+ const config = local.android_ripple;
176
+ const ripple = config !== undefined ? rippleProps(config) : undefined;
177
+ if (ripple === undefined)
178
+ return resolveChildren();
179
+ dlog('Pressable wrapping children in an android_ripple View');
180
+ return <symbiote-view {...ripple}>{resolveChildren()}</symbiote-view>;
181
+ };
182
+ return (<View ref={attachRef} {...viewProps()}>
183
+ {renderContent()}
184
+ </View>);
185
+ }
@@ -0,0 +1,18 @@
1
+ import type { JSX } from '../jsx-runtime';
2
+ import { type IAccessibilityProps, type IAriaProps } from '@symbiote-native/components';
3
+ import { type IClassNameValue } from '@symbiote-native/engine';
4
+ export interface IRefreshControlProps extends IAccessibilityProps, IAriaProps {
5
+ refreshing: boolean;
6
+ onRefresh?: () => void | Promise<void>;
7
+ tintColor?: string;
8
+ title?: string;
9
+ titleColor?: string;
10
+ progressViewOffset?: number;
11
+ colors?: readonly string[];
12
+ progressBackgroundColor?: string;
13
+ size?: 'default' | 'large';
14
+ enabled?: boolean;
15
+ class?: IClassNameValue;
16
+ children?: JSX.Element;
17
+ }
18
+ export declare function RefreshControl(props: IRefreshControlProps): JSX.Element;
@@ -0,0 +1,36 @@
1
+ // RefreshControl — the Solid lifecycle half, and there is barely any: the component owns one host
2
+ // tag and forwards its props onto it. On iOS `symbiote-refresh-control` resolves to Fabric's
3
+ // PullToRefreshView, which lives INSIDE a ScrollView as a childless sibling before the content
4
+ // container; on Android it is AndroidSwipeRefreshLayout and WRAPS the scroll view instead (an
5
+ // Android ScrollView hosts exactly one child). Which of the two shapes is built is ScrollView's
6
+ // job, not this file's — see scroll-view/shared.tsx's `refreshControlMode`.
7
+ //
8
+ // `refreshing` is a CONTROLLED prop: the parent owns it and pushes it down, and native reports the
9
+ // gesture back through the direct `topRefresh` event. `onRefresh` therefore rides in the forwarded
10
+ // bag untouched — routeProp asks the node's ViewConfig whether `onRefresh` is an event and attaches
11
+ // it as a listener, which is why nothing here inspects the `on` prefix itself
12
+ // (symbiote-engine-core §2).
13
+ //
14
+ // FLAT, not a folder: the file-layout rule gives a module its own folder only when it has real
15
+ // platform (`.ios`/`.android`) or `-shared` variants. There is no JS-side platform branch here —
16
+ // every prop forwards to the native node, which reads what it understands and ignores the rest, so
17
+ // the Android-only spinner props below are harmless on iOS and vice versa.
18
+ import { splitProps } from 'solid-js';
19
+ import { resolveAccessibilityProps, } from '@symbiote-native/components';
20
+ import { dlog } from '@symbiote-native/engine';
21
+ import { withStableKeys } from '../utils/stable-keys.js';
22
+ export function RefreshControl(props) {
23
+ // `children` is pulled out rather than left in the bag: spread skips it anyway, but leaving it in
24
+ // would make the bag accessor re-run whenever the child subtree changed. Same split View makes.
25
+ const [local, rest] = splitProps(props, ['children']);
26
+ // withStableKeys because resolveAccessibilityProps has two branches with DIFFERENT key sets — it
27
+ // returns its input untouched while no aria-* alias holds a value, and a fully-blanked bag once
28
+ // one does. Solid's `spread` walks only the current keys and has no removal pass, so a key that
29
+ // vanishes would keep its last value on the native view forever
30
+ // (.claude/rules/solid-descriptor-bridge.md §1).
31
+ const bag = withStableKeys(() => ({ ...resolveAccessibilityProps(rest) }));
32
+ dlog('RefreshControl -> PullToRefreshView');
33
+ return (<symbiote-refresh-control {...bag()}>
34
+ {local.children}
35
+ </symbiote-refresh-control>);
36
+ }
@@ -0,0 +1,10 @@
1
+ import type { JSX } from '../jsx-runtime';
2
+ import { type IAccessibilityProps, type IAriaProps } from '@symbiote-native/components';
3
+ import { type IClassNameValue, type IStyleProp, type ISymbioteEvent, type IViewStyle } from '@symbiote-native/engine';
4
+ export interface ISafeAreaViewProps extends IAccessibilityProps, IAriaProps {
5
+ style?: IStyleProp<IViewStyle>;
6
+ class?: IClassNameValue;
7
+ onLayout?: (event: ISymbioteEvent) => void;
8
+ children?: JSX.Element;
9
+ }
10
+ export declare function SafeAreaView(props: ISafeAreaViewProps): JSX.Element;
@@ -0,0 +1,24 @@
1
+ // SafeAreaView — the native view that insets its children past the notch, the rounded corners and
2
+ // the system bars. There is no JS-side inset math anywhere in this project: the host does it, and
3
+ // every adapter's job is only to put style + children on the `symbiote-safe-area-view` intrinsic.
4
+ //
5
+ // So this is a View twin, and ./view.tsx carries the reasoning they share (why the file is real
6
+ // JSX, why nothing is destructured at setup, why children and ref are pulled out of the bag).
7
+ // The one thing worth repeating: there is no renderSafeAreaView() in @symbiote-native/components
8
+ // and there must not be (.claude/rules/component-render-fn-boundary.md) — `children` is a live
9
+ // Solid subtree that flows through the reconciler and is never reduced back to a Descriptor.
10
+ //
11
+ // The bag goes through withStableKeys because resolveAccessibilityProps has two branches with
12
+ // DIFFERENT key sets, and Solid's `spread` has no removal pass — see utils/stable-keys.ts.
13
+ import { splitProps } from 'solid-js';
14
+ import { resolveAccessibilityProps, } from '@symbiote-native/components';
15
+ import { dlog, } from '@symbiote-native/engine';
16
+ import { withStableKeys } from '../utils/stable-keys.js';
17
+ export function SafeAreaView(props) {
18
+ const [local, rest] = splitProps(props, ['children']);
19
+ dlog('SafeAreaView -> SafeAreaView');
20
+ const bag = withStableKeys(() => ({ ...resolveAccessibilityProps(rest) }));
21
+ return (<symbiote-safe-area-view {...bag()}>
22
+ {local.children}
23
+ </symbiote-safe-area-view>);
24
+ }
@@ -0,0 +1,2 @@
1
+ export type { IScrollViewProps, IScrollViewHandle, IScrollViewHostPlatform, IStickyHeaderComponentType, IStickyHeaderComponentProps, } from './shared';
2
+ export declare const ScrollView: (props: import("./shared").IScrollViewProps) => import("@symbiote-native/solid/jsx-runtime").JSX.Element;
@@ -0,0 +1,11 @@
1
+ // ScrollView on Android. An Android ScrollView accepts only ONE child, so a RefreshControl cannot
2
+ // sit beside the content the way iOS allows ("addViewAt: failed to insert view … at index 1").
3
+ // Instead the RefreshControl (AndroidSwipeRefreshLayout) WRAPS the scroll view, which nests inside
4
+ // it with nestedScrollEnabled so the inner scroll takes the gesture before the refresh parent —
5
+ // RN's own ScrollView.js android branch, and the same fact adapters/react's, adapters/vue's and
6
+ // adapters/svelte's Android bindings encode. Metro picks this file on an Android host; no
7
+ // Platform.OS read.
8
+ import { createScrollView } from './shared.jsx';
9
+ export const ScrollView = createScrollView({
10
+ refreshControlMode: 'wrap',
11
+ });
@@ -0,0 +1,2 @@
1
+ export * from './index.ios';
2
+ export { ScrollViewStickyHeader } from './sticky-header';
@@ -0,0 +1,2 @@
1
+ export type { IScrollViewProps, IScrollViewHandle, IScrollViewHostPlatform, IStickyHeaderComponentType, IStickyHeaderComponentProps, } from './shared';
2
+ export declare const ScrollView: (props: import("./shared").IScrollViewProps) => import("@symbiote-native/solid/jsx-runtime").JSX.Element;