@symbiote-native/components 0.5.0 → 2.0.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 (88) hide show
  1. package/README.md +23 -10
  2. package/build/accessibility-props.d.ts +11 -0
  3. package/build/accessibility-props.js +30 -118
  4. package/build/behaviors/activity-indicator/index.android.d.ts +1 -0
  5. package/build/behaviors/activity-indicator/index.android.js +16 -0
  6. package/build/behaviors/activity-indicator/index.d.ts +3 -0
  7. package/build/behaviors/activity-indicator/index.ios.d.ts +1 -0
  8. package/build/behaviors/activity-indicator/index.ios.js +14 -0
  9. package/build/behaviors/activity-indicator/index.js +5 -0
  10. package/build/behaviors/activity-indicator/shared.d.ts +18 -0
  11. package/build/behaviors/activity-indicator/shared.js +149 -0
  12. package/build/behaviors/button.d.ts +2 -0
  13. package/build/behaviors/button.js +328 -0
  14. package/build/behaviors/image-background.d.ts +2 -0
  15. package/build/behaviors/image-background.js +139 -0
  16. package/build/behaviors/image.d.ts +3 -0
  17. package/build/behaviors/image.js +123 -0
  18. package/build/behaviors/input-accessory-view.d.ts +3 -0
  19. package/build/behaviors/input-accessory-view.js +70 -0
  20. package/build/behaviors/pressable.d.ts +60 -0
  21. package/build/behaviors/pressable.js +385 -0
  22. package/build/behaviors/refresh-control.d.ts +2 -0
  23. package/build/behaviors/refresh-control.js +83 -0
  24. package/build/behaviors/scroll-view/index.android.d.ts +1 -0
  25. package/build/behaviors/scroll-view/index.android.js +52 -0
  26. package/build/behaviors/scroll-view/index.d.ts +2 -0
  27. package/build/behaviors/scroll-view/index.ios.d.ts +1 -0
  28. package/build/behaviors/scroll-view/index.ios.js +10 -0
  29. package/build/behaviors/scroll-view/index.js +5 -0
  30. package/build/behaviors/scroll-view/shared.d.ts +11 -0
  31. package/build/behaviors/scroll-view/shared.js +291 -0
  32. package/build/behaviors/scroll-view/sticky.d.ts +17 -0
  33. package/build/behaviors/scroll-view/sticky.js +568 -0
  34. package/build/behaviors/switch.d.ts +2 -0
  35. package/build/behaviors/switch.js +186 -0
  36. package/build/behaviors/text-input.d.ts +14 -0
  37. package/build/behaviors/text-input.js +319 -0
  38. package/build/behaviors/touchable-highlight.d.ts +9 -0
  39. package/build/behaviors/touchable-highlight.js +192 -0
  40. package/build/behaviors/touchable-native-feedback.d.ts +20 -0
  41. package/build/behaviors/touchable-native-feedback.js +333 -0
  42. package/build/behaviors/touchable-opacity.d.ts +12 -0
  43. package/build/behaviors/touchable-opacity.js +227 -0
  44. package/build/behaviors/touchable-without-feedback.d.ts +2 -0
  45. package/build/behaviors/touchable-without-feedback.js +296 -0
  46. package/build/component-names/index.android.js +47 -15
  47. package/build/component-names/index.ios.js +35 -15
  48. package/build/component-names/shared.d.ts +2 -1
  49. package/build/component-names/shared.js +49 -3
  50. package/build/descriptor.js +4 -4
  51. package/build/fold-host-bag.d.ts +15 -0
  52. package/build/fold-host-bag.js +99 -0
  53. package/build/index.d.ts +23 -12
  54. package/build/index.js +50 -9
  55. package/build/register.d.ts +1 -0
  56. package/build/register.js +55 -0
  57. package/build/resolve-intrinsic.d.ts +7 -0
  58. package/build/resolve-intrinsic.js +49 -0
  59. package/build/scroll-view-commands.d.ts +4 -0
  60. package/build/scroll-view-commands.js +30 -31
  61. package/build/state/pressable.d.ts +9 -0
  62. package/build/state/pressable.js +120 -34
  63. package/build/state/text-input.d.ts +10 -2
  64. package/build/state/text-input.js +11 -0
  65. package/build/text-props.js +2 -1
  66. package/build/view/render-button.d.ts +36 -1
  67. package/build/view/render-button.js +101 -12
  68. package/build/view/render-image/index.d.ts +2 -0
  69. package/build/view/render-image/index.js +29 -2
  70. package/build/view/render-input-accessory-view.d.ts +2 -0
  71. package/build/view/render-input-accessory-view.js +37 -5
  72. package/build/view/render-modal.js +3 -3
  73. package/build/view/render-pressable/index.d.ts +2 -0
  74. package/build/view/render-pressable/index.js +24 -0
  75. package/build/view/render-scroll-view.d.ts +1 -0
  76. package/build/view/render-scroll-view.js +18 -9
  77. package/build/view/render-switch.d.ts +4 -1
  78. package/build/view/render-switch.js +8 -2
  79. package/build/view/render-text-input.js +6 -2
  80. package/build/view/render-touchable-native-feedback.d.ts +19 -0
  81. package/build/view/render-touchable-native-feedback.js +19 -0
  82. package/host-primitives.cjs +432 -0
  83. package/host-primitives.d.cts +33 -0
  84. package/package.json +33 -5
  85. package/build/view/render-activity-indicator.d.ts +0 -25
  86. package/build/view/render-activity-indicator.js +0 -62
  87. package/build/view/render-image-background.d.ts +0 -9
  88. package/build/view/render-image-background.js +0 -48
@@ -0,0 +1,70 @@
1
+ // InputAccessoryView's host behavior — a prop fold and nothing else. No listeners, no timers, no
2
+ // commit hook: the wrapper's whole body was prop mapping, so its lowered form owes exactly that.
3
+ //
4
+ // The fold is NOT written here. `mapInputAccessoryViewProps` in
5
+ // `../view/render-input-accessory-view` is the one implementation and this file only narrows a flat
6
+ // bag into the typed view it takes, the `stringOf` / `styleOf` guard idiom `behaviors/image.ts`
7
+ // uses. Counted before starting, which is step one of the fold-only recipe: unlike Image, which had
8
+ // THREE implementations, all five adapters already call the shared render fn and Svelte's
9
+ // `input-accessory-view-props.ts` is a type declaration rather than a second mapping. So there was
10
+ // one implementation to extract, not three to collapse.
11
+ //
12
+ // SHARES THE WRAPPER'S TAG, no `-managed` twin. The two questions the recipe separates both answer
13
+ // no: the fold is idempotent (there is no aliasing at all — every consumed name leaves under the
14
+ // same name, so a second pass rewrites the same values), and the behavior carries no machine, so
15
+ // nothing can end up with two owners on one node. Idempotence is asserted rather than reasoned.
16
+ //
17
+ // PLATFORM. This is the only primitive in its group that is not platform-invariant in what it
18
+ // COMMITS TO: `input-accessory-view` resolves to `RCTInputAccessoryView` on iOS and to a
19
+ // plain `RCTView` on Android. The fold itself is platform-invariant on purpose — it reproduces the
20
+ // wrapper's mapping exactly, on both platforms, so the lowered and wrapped paths cannot diverge
21
+ // per-platform. What it does NOT do is fix the pre-existing Android divergence underneath it:
22
+ // upstream RN renders NOTHING there (`InputAccessoryView.js` — `console.warn('<InputAccessoryView>
23
+ // is only supported on iOS.'); return null`), while we commit an RCTView, and `backgroundColor` is
24
+ // a declared prop of the iOS view but a style key on RCTView. Both predate this behavior and are
25
+ // identical on both paths; neither is in scope here, and a fold that "fixed" one silently would
26
+ // make the two paths disagree.
27
+ import { registerHostBehavior, } from '@symbiote-native/engine';
28
+ import { INPUT_ACCESSORY_VIEW_PROP_NAMES, mapInputAccessoryViewProps, } from '../view/render-input-accessory-view.js';
29
+ export const INPUT_ACCESSORY_VIEW_TAG = 'input-accessory-view';
30
+ const CONSUMED = new Set(INPUT_ACCESSORY_VIEW_PROP_NAMES);
31
+ function stringOf(value) {
32
+ return typeof value === 'string' ? value : undefined;
33
+ }
34
+ // A StyleProp is an object, an array of them, or a registered class array — all shapes
35
+ // `flattenStyle` already handles. Only a scalar has to be excluded.
36
+ function styleOf(value) {
37
+ if (typeof value !== 'object' || value === null)
38
+ return undefined;
39
+ if (Array.isArray(value))
40
+ return value;
41
+ return { ...value };
42
+ }
43
+ export function foldInputAccessoryViewPayload(props) {
44
+ const passthrough = {};
45
+ for (const key of Object.keys(props)) {
46
+ if (!CONSUMED.has(key))
47
+ passthrough[key] = props[key];
48
+ }
49
+ return mapInputAccessoryViewProps({
50
+ nativeID: stringOf(props.nativeID),
51
+ backgroundColor: stringOf(props.backgroundColor),
52
+ style: styleOf(props.style),
53
+ passthrough,
54
+ });
55
+ }
56
+ // Required by IHostBehavior and deliberately empty: this primitive owns no per-node runtime.
57
+ // Written out rather than shared with a `noop` so the emptiness reads as a decision.
58
+ function attach(_node) {
59
+ // nothing to set up
60
+ }
61
+ function detach(_node) {
62
+ // nothing to release
63
+ }
64
+ export function registerInputAccessoryViewBehavior() {
65
+ registerHostBehavior(INPUT_ACCESSORY_VIEW_TAG, {
66
+ attach,
67
+ detach,
68
+ foldPayload: foldInputAccessoryViewPayload,
69
+ });
70
+ }
@@ -0,0 +1,60 @@
1
+ import { type IHostBehavior, type ISymbioteNode } from '@symbiote-native/engine';
2
+ import { type IPressMachineConfig } from '../state/pressable';
3
+ import type { IAccessibilityStateValue } from '../accessibility-props';
4
+ export declare const PRESSABLE_TAG = "pressable";
5
+ /**
6
+ * A last look at the machine's config before its handlers are built, for a tag that IS a pressable
7
+ * plus something — TouchableOpacity, whose fade has to run between the machine and the app's own
8
+ * `onPressIn`.
9
+ *
10
+ * Called from `rebuild`, so once per gesture rather than once per mount: it sees the config the
11
+ * props actually hold by the time a finger lands, and anything it captures is discarded with the
12
+ * gesture. Per-node state that must OUTLIVE a gesture belongs on the caller's own WeakMap.
13
+ */
14
+ export type IPressConfigRefinement = (node: ISymbioteNode, config: IPressMachineConfig) => IPressMachineConfig;
15
+ /**
16
+ * What the machine reads as `disabled`, for a tag whose spelling of it is not the raw prop.
17
+ *
18
+ * There is no resolver by default because RN's Pressable hands Pressability the RAW prop
19
+ * (`Pressable.js:266`) — `aria-disabled` there changes only what is ANNOUNCED. Button is the one
20
+ * primitive that differs: it resolves `disabled ?? aria-disabled ?? accessibilityState.disabled` in
21
+ * the component and passes the ANSWER down as the touchable's own prop (`Button.js:337` -> `:386`),
22
+ * which a single tag has no second node to pass to.
23
+ *
24
+ * Reads the bag and returns the answer; it must never write one back. `resolveButtonDisabled`
25
+ * short-circuits on an authored `disabled`, so a resolved value stored in `node.props.disabled`
26
+ * would answer the NEXT resolution as if the app had written it and the tag could never re-enable.
27
+ */
28
+ export type IDisabledResolver = (props: Readonly<Record<string, unknown>>) => boolean | undefined;
29
+ export declare function booleanOr(value: unknown): boolean | undefined;
30
+ export declare function asAccessibilityState(value: unknown): IAccessibilityStateValue | undefined;
31
+ export declare function accessibleUnlessOptedOut(props: Readonly<Record<string, unknown>>): boolean;
32
+ /**
33
+ * The machine on `node`, reading its props and the app's callbacks off `options.source` when that
34
+ * is a different node.
35
+ *
36
+ * Exported for a behavior whose responder is not its own node — `./touchable-native-feedback`,
37
+ * whose tag commits nothing and adopts the app's single child as the responder. Every other caller
38
+ * goes through `createPressBehavior`, where source and node are the same.
39
+ *
40
+ * Re-callable on the same node: a second call replaces the state and the dispatchers, which is what
41
+ * a re-arm after `detachPressMachine` needs.
42
+ */
43
+ export declare function attachPressMachine(node: ISymbioteNode, options?: {
44
+ readonly refine?: IPressConfigRefinement;
45
+ readonly disabledOf?: IDisabledResolver;
46
+ readonly source?: ISymbioteNode;
47
+ }): void;
48
+ /** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
49
+ export declare function detachPressMachine(node: ISymbioteNode): void;
50
+ /**
51
+ * The press machine as behavior parts, so a tag that is a pressable PLUS something can compose it
52
+ * instead of re-implementing it.
53
+ *
54
+ * Spread into the caller's own behavior and wrap `attach`/`detach` around these — the touchable
55
+ * family needs a per-node Animated value opened before the machine and closed after it. The
56
+ * WeakMap holding the machine's own state is keyed by node, so one node may hold exactly one of
57
+ * these; a tag composing it therefore must not also register the plain `pressable` behavior.
58
+ */
59
+ export declare function createPressBehavior(refine?: IPressConfigRefinement, disabledOf?: IDisabledResolver): Pick<IHostBehavior, 'attach' | 'detach' | 'foldPayload' | 'ownedListeners'>;
60
+ export declare function registerPressableBehavior(): void;
@@ -0,0 +1,385 @@
1
+ // The press machine as an ENGINE-NODE behavior, so a pressable can be an intrinsic tag instead of
2
+ // a framework component (`.claude/rules/host-primitive-tier.md`, tier 2). Written once; every
3
+ // adapter inherits it by registering, and none re-implements it.
4
+ //
5
+ // The machine itself is unchanged and still shared with the component path — `createPressRuntime`
6
+ // / `createPressHandlers` in `../state/pressable`. What is new is only WHERE its lifecycle lives:
7
+ // on the engine node rather than in a component instance.
8
+ //
9
+ // REGISTRATION IS THE HAZARD, not the machine. Metro enables `inlineRequires` in production only,
10
+ // moving a `require` to the first place its binding is used as a VALUE, and a barrel's
11
+ // `export { X } from './x'` compiles to a lazy getter. A module whose only job is a side effect is
12
+ // never named as a value, so re-exporting it means it NEVER RUNS in a release build — dev perfect,
13
+ // release silently pressless. Each adapter therefore keeps its own `src/register.ts` calling
14
+ // `registerPressableBehavior()`, and its entry does a bare `import './register';` that the barrel
15
+ // does NOT re-export. A bare `import './register';` sitting NEXT TO such a re-export does not work
16
+ // either: Babel merges the two imports of one specifier and the merged dependency stays lazy.
17
+ import { appListenerFor, dlog, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, } from '@symbiote-native/engine';
18
+ import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_MIN_PRESS_DURATION_MS, rippleProps, } from '../state/pressable.js';
19
+ import { buildPressableListeners, resolveDisabledAccessibilityState, resolvePressableFocusable, } from '../view/render-pressable/index.js';
20
+ export const PRESSABLE_TAG = 'pressable';
21
+ const states = new WeakMap();
22
+ function isRecord(value) {
23
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
24
+ }
25
+ function numberOr(value, fallback) {
26
+ return typeof value === 'number' ? value : fallback;
27
+ }
28
+ // The bag arrives as `unknown` off `node.props`; every RN default below is written against
29
+ // `boolean | undefined`. Exported to the sibling behaviors folding the same bag, and deliberately
30
+ // NOT to the shared barrel — same reasoning as `asAccessibilityState`.
31
+ export function booleanOr(value) {
32
+ return typeof value === 'boolean' ? value : undefined;
33
+ }
34
+ // A scalar offset or the per-edge object; anything else reads as "no offset", which the machine
35
+ // turns into RN's defaults. Same narrowing the component path does — kept here rather than shared
36
+ // because the component's version narrows Vue attrs, and this one narrows engine props.
37
+ function asRectOffset(value) {
38
+ if (typeof value === 'number')
39
+ return value;
40
+ if (!isRecord(value))
41
+ return undefined;
42
+ const rect = {};
43
+ if (typeof value.top === 'number')
44
+ rect.top = value.top;
45
+ if (typeof value.left === 'number')
46
+ rect.left = value.left;
47
+ if (typeof value.bottom === 'number')
48
+ rect.bottom = value.bottom;
49
+ if (typeof value.right === 'number')
50
+ rect.right = value.right;
51
+ return rect;
52
+ }
53
+ // A predicate rather than a bare `typeof`: `typeof x === 'function'` narrows to `Function`, which
54
+ // carries no call signature and cannot satisfy IPressHandler. Guard, never cast. Also used for the
55
+ // listener bag, whose values are `unknown` for the same reason.
56
+ function isPressHandler(value) {
57
+ return typeof value === 'function';
58
+ }
59
+ // The props the MACHINE consumes and the host must never see. The wrapper drops them by
60
+ // destructuring — they go into `createPressHandlers` / `buildPressableListeners` and are simply
61
+ // absent from the object it spreads onto its View. A lowered element has no destructure, so every
62
+ // one of them rode into the payload as a key no ViewConfig declares.
63
+ const MACHINE_ONLY_KEYS = [
64
+ // Consumed below and replaced by the resolved `nativeBackgroundAndroid` /
65
+ // `nativeForegroundAndroid`; the raw config is not a native prop.
66
+ 'android_ripple',
67
+ 'disabled',
68
+ 'cancelable',
69
+ 'delayLongPress',
70
+ 'minPressDuration',
71
+ 'unstable_pressDelay',
72
+ 'pressRetentionOffset',
73
+ 'delayHoverIn',
74
+ 'delayHoverOut',
75
+ ];
76
+ // Narrowed field by field rather than cast: the bag arrives as `unknown` off `node.props`. A local
77
+ // twin of the guard each adapter keeps for its own attrs (Vue's `asAccessibilityState`) — exported
78
+ // to the sibling behaviors that fold the same bag, and deliberately NOT to the shared barrel, which
79
+ // every adapter re-exports wholesale: a narrowing helper is not API anyone should be able to import.
80
+ export function asAccessibilityState(value) {
81
+ if (!isRecord(value))
82
+ return undefined;
83
+ const state = {};
84
+ if (typeof value.disabled === 'boolean')
85
+ state.disabled = value.disabled;
86
+ if (typeof value.selected === 'boolean')
87
+ state.selected = value.selected;
88
+ if (value.checked === 'mixed' || typeof value.checked === 'boolean')
89
+ state.checked = value.checked;
90
+ if (typeof value.busy === 'boolean')
91
+ state.busy = value.busy;
92
+ if (typeof value.expanded === 'boolean')
93
+ state.expanded = value.expanded;
94
+ return state;
95
+ }
96
+ // Narrowed field by field, same reason as the accessibility guard above: the config arrives as
97
+ // `unknown` off `node.props`.
98
+ function asRippleConfig(value) {
99
+ if (!isRecord(value))
100
+ return undefined;
101
+ const config = {};
102
+ if (typeof value.color === 'string')
103
+ config.color = value.color;
104
+ if (typeof value.borderless === 'boolean')
105
+ config.borderless = value.borderless;
106
+ if (typeof value.radius === 'number')
107
+ config.radius = value.radius;
108
+ if (typeof value.foreground === 'boolean')
109
+ config.foreground = value.foreground;
110
+ return config;
111
+ }
112
+ // `disabled` reaches a screen reader ONLY as `accessibilityState.disabled` — it is not a native
113
+ // View prop, so the wrapper folds it (`resolveDisabledAccessibilityState`, called by all five) and
114
+ // forwards the composite. Lowering dropped that fold: press suppression still worked, because the
115
+ // machine reads `node.props.disabled` directly, so the button behaved correctly and announced
116
+ // itself as enabled. An accessibility regression with no visual tell and no failing test.
117
+ //
118
+ // Found by the wrapper-vs-behavior import audit (`.claude/rules/adapter-parity-audit.md`).
119
+ function foldPayload(props) {
120
+ const resolved = resolveDisabledAccessibilityState(asAccessibilityState(props.accessibilityState), typeof props.disabled === 'boolean' ? props.disabled : undefined);
121
+ // The Android ripple. Our WRAPPER paints it through a dedicated inner View, mirroring
122
+ // TouchableNativeFeedback — and that reading is what made this look unfixable for a lowered
123
+ // element, which has no child to put it on. RN's own `Pressable` does NOT do that: it spreads
124
+ // `useAndroidRippleForView`'s `viewProps` onto its own View (`Pressable.js:251`), so the ripple
125
+ // background is an ordinary prop of the responder itself and a single node carries it fine.
126
+ //
127
+ // `rippleProps` returns undefined off Android, so this whole branch is inert on iOS.
128
+ //
129
+ // STILL MISSING ON BOTH PATHS, and lowering did not cause it: RN also dispatches
130
+ // `Commands.hotspotUpdate(x, y)` on pressIn/pressMove and `Commands.setPressed` on
131
+ // pressIn/pressOut, which is what makes the ripple originate at the touch point. Neither our
132
+ // wrapper nor this behavior sends them — grep for `hotspotUpdate` returns nothing in the tree.
133
+ const rippleConfig = asRippleConfig(props.android_ripple);
134
+ const ripple = rippleConfig !== undefined ? rippleProps(rippleConfig) : undefined;
135
+ const out = { ...props };
136
+ for (const key of MACHINE_ONLY_KEYS)
137
+ delete out[key];
138
+ if (ripple !== undefined)
139
+ Object.assign(out, ripple);
140
+ // Written only when the fold produced something: an unconditional assignment would put an
141
+ // `accessibilityState: undefined` key on every lowered Pressable in the tree, and `fabricProps`
142
+ // skipping undefined is a coincidence to lean on, not a contract to rely on here.
143
+ if (resolved !== undefined)
144
+ out.accessibilityState = resolved;
145
+ out.accessible = accessibleUnlessOptedOut(props);
146
+ // Pressable.js:258 — the plain form, with no press-handler or disabled leg. A Touchable composing
147
+ // this tag has already resolved its own three-leg formula and passed the answer in as `focusable`,
148
+ // which `!== false` leaves alone.
149
+ out.focusable = resolvePressableFocusable(booleanOr(props.focusable));
150
+ return out;
151
+ }
152
+ // RN makes every pressable accessible unless the app opts OUT — `Pressable.js:252`
153
+ // (`accessible: accessible !== false`), and the whole Touchable family repeats it verbatim
154
+ // (`TouchableOpacity.js:303`, `TouchableHighlight.js:337`). `!== false` rather than `?? true`, so
155
+ // only a literal `false` opts out and an explicit `undefined` still reads as accessible.
156
+ //
157
+ // Nothing in this repo did it until 2026-09-09, on either path, so a Pressable reached a screen
158
+ // reader as a plain view unless the app wrote the prop. Landing it in the fold above alone reddens
159
+ // four equivalence arms — correctly, since those compare the wrapper against the lowered path — so
160
+ // the behavior and every adapter's wrapper have to move in ONE change. Exported for the wrappers
161
+ // that need to say it themselves, and for the tags whose behavior is their only path.
162
+ export function accessibleUnlessOptedOut(props) {
163
+ return props.accessible !== false;
164
+ }
165
+ // From the STASH, not from `node.props`. Every name below is in `ownedListeners`, so `routeProp`
166
+ // diverts the app's `onPress` away from `node.listeners` (where it would evict the behavior's own
167
+ // dispatcher) and into the stash — which makes the stash the only place it exists. Reading
168
+ // `node.props` here returns undefined for every callback and every press silently does nothing:
169
+ // the behavior runs, the machine runs, and it calls nobody.
170
+ function callbackAt(node, event) {
171
+ const value = appListenerFor(node, event);
172
+ return isPressHandler(value) ? value : undefined;
173
+ }
174
+ // Callbacks come from `callbackAt` (the stash), scalars from `node.props`. The split is not
175
+ // cosmetic: `delayLongPress` and `hitSlop` are ordinary props that `fabricProps` drops as unknown
176
+ // keys, while `onPress` and friends are OWNED event names that never reach `node.props` at all.
177
+ function configFor(node) {
178
+ return {
179
+ onPress: callbackAt(node, 'press'),
180
+ onPressIn: callbackAt(node, 'pressIn'),
181
+ onPressOut: callbackAt(node, 'pressOut'),
182
+ onPressMove: callbackAt(node, 'pressMove'),
183
+ onLongPress: callbackAt(node, 'longPress'),
184
+ delayLongPress: numberOr(node.props.delayLongPress, DEFAULT_DELAY_LONG_PRESS_MS),
185
+ unstable_pressDelay: numberOr(node.props.unstable_pressDelay, 0),
186
+ // RN's Touchables own the deactivation floor in their OWN machine and hand Pressability
187
+ // `minPressDuration: 0` (TouchableOpacity.js:195). While they were wrappers they passed it as
188
+ // an internal input; on the tag there is nowhere else to say it, so the floor has to be a
189
+ // readable prop or every Touchable holds its fade for the machine's 130 ms default.
190
+ minPressDuration: numberOr(node.props.minPressDuration, DEFAULT_MIN_PRESS_DURATION_MS),
191
+ hitSlop: asRectOffset(node.props.hitSlop),
192
+ pressRetentionOffset: asRectOffset(node.props.pressRetentionOffset),
193
+ };
194
+ }
195
+ // Rebuilding at GESTURE START is the whole reason for the dispatcher indirection, and skipping it
196
+ // is a bug that looks like working code. `attach` runs inside `createElement`, before a single
197
+ // prop has been routed — `node.props` is literally `{}` there — so a machine built at attach would
198
+ // capture no `onPress` at all and every press would silently do nothing. `createPressHandlers`
199
+ // destructures its config eagerly, so it cannot be handed a live view either; it has to be re-made
200
+ // once the props exist. A gesture is one interaction, so a handful of closures per press is
201
+ // invisible — unlike doing it per prop write, which is the cost this whole tier exists to remove.
202
+ function rebuild(node, state) {
203
+ // `state.source` for everything READ, `node` for the refinement, which acts on the responder
204
+ // (dispatching a view command needs the committed node, not the one holding the props).
205
+ const source = state.source;
206
+ const base = configFor(source);
207
+ const handlers = createPressHandlers(state.refine === undefined ? base : state.refine(node, base), state.runtime, state.host);
208
+ state.isBuilt = true;
209
+ // Re-read every gesture, so a tag whose resolver looks past `disabled` — `./button`, at
210
+ // `aria-disabled` — re-enables on the next touch instead of latching at its first answer.
211
+ const disabled = state.disabledOf === undefined
212
+ ? source.props.disabled
213
+ : state.disabledOf(source.props);
214
+ state.listeners = buildPressableListeners(handlers, {
215
+ disabled: disabled === true ? true : undefined,
216
+ cancelable: typeof source.props.cancelable === 'boolean'
217
+ ? source.props.cancelable
218
+ : undefined,
219
+ });
220
+ }
221
+ // WHICHEVER EVENT OPENS THE GESTURE REBUILDS, and pinning that to one name was a real bug.
222
+ // `onStartShouldSetResponder` looks like the opener and is not: `core/engine/src/events/index.ts`
223
+ // bubbles PRESS_IN and only THEN calls `negotiateResponder`, so `pressIn` arrives FIRST on every
224
+ // gesture. Rebuilding only on the responder claim therefore handed the first `pressIn` an empty
225
+ // listener bag — the press-in half of every press was dropped, the pressed style never reached
226
+ // Fabric, and `onPress` still fired because by then the machine existed. That is exactly the
227
+ // device report: the callback works, the button does not light up.
228
+ //
229
+ // So the trigger is a FLAG, not a name: build if this gesture has not built yet, and clear it when
230
+ // the gesture ends. Order-independent, and it survives the engine reordering its own events.
231
+ //
232
+ // A key `buildPressableListeners` omitted — every one of them when `disabled` is true — resolves to
233
+ // undefined here and the dispatcher returns undefined, which is what an absent listener would do.
234
+ const GESTURE_END_KEYS = new Set([
235
+ 'onPressOut',
236
+ 'onResponderTerminationRequest',
237
+ ]);
238
+ function dispatch(node, state, key, args) {
239
+ if (!state.isBuilt)
240
+ rebuild(node, state);
241
+ const listener = state.listeners[key];
242
+ const result = isPressHandler(listener)
243
+ ? Reflect.apply(listener, undefined, args)
244
+ : undefined;
245
+ // AFTER the handler, not before: `handlePressOut` is what settles the machine, and clearing the
246
+ // flag first would let a re-entrant dispatch rebuild mid-gesture.
247
+ if (GESTURE_END_KEYS.has(key))
248
+ state.isBuilt = false;
249
+ return result;
250
+ }
251
+ // Installed straight into the listener slot rather than through `routeProp`: the behavior OWNS
252
+ // these names, and `setEventListener` diverts an owned name into the app stash — routing its own
253
+ // dispatcher through there would stash it and leave the slot empty.
254
+ //
255
+ // The engine event names, not the `onX` prop spellings. `buildPressableListeners` speaks the prop
256
+ // spelling, so the two are mapped here rather than guessed at either end.
257
+ const KEY_BY_EVENT = new Map([
258
+ ['press', 'onPress'],
259
+ ['pressIn', 'onPressIn'],
260
+ ['pressOut', 'onPressOut'],
261
+ ['startShouldSetResponder', 'onStartShouldSetResponder'],
262
+ ['responderMove', 'onResponderMove'],
263
+ ['responderTerminationRequest', 'onResponderTerminationRequest'],
264
+ ]);
265
+ function installListeners(node, state) {
266
+ for (const [event, key] of KEY_BY_EVENT) {
267
+ setBehaviorListener(node, event, symbioteEvent => dispatch(node, state, key, [symbioteEvent]));
268
+ }
269
+ }
270
+ function attachWith(refine, disabledOf) {
271
+ return node => attach(node, { refine, disabledOf });
272
+ }
273
+ /**
274
+ * The machine on `node`, reading its props and the app's callbacks off `options.source` when that
275
+ * is a different node.
276
+ *
277
+ * Exported for a behavior whose responder is not its own node — `./touchable-native-feedback`,
278
+ * whose tag commits nothing and adopts the app's single child as the responder. Every other caller
279
+ * goes through `createPressBehavior`, where source and node are the same.
280
+ *
281
+ * Re-callable on the same node: a second call replaces the state and the dispatchers, which is what
282
+ * a re-arm after `detachPressMachine` needs.
283
+ */
284
+ export function attachPressMachine(node, options = {}) {
285
+ attach(node, options);
286
+ }
287
+ function attach(node, options) {
288
+ const timers = new Set();
289
+ const runtime = createPressRuntime();
290
+ const host = {
291
+ // The whole reason this tier is possible: the pressed state resolves BELOW the framework,
292
+ // through the style registry's `:active` variant, and never crosses into it.
293
+ setPressed: pressed => {
294
+ setNodePressed(node, pressed);
295
+ // Dirtying is not publishing. A press arrives from a native event, outside every renderer
296
+ // mutation path, so nothing schedules a commit — `native-events.ts` requests none, and no
297
+ // adapter does either. `setNodeHidden`'s React twin never hit this because the reconciler is
298
+ // already in its commit phase when it calls.
299
+ requestCommitFor(node);
300
+ },
301
+ // `measure` needs a committed Fabric tag, which a node has by the time a human can touch it.
302
+ // Returning undefined before then makes the machine fall back to its radius test rather than
303
+ // throwing, which is the behaviour the component path already relies on.
304
+ getMeasureFn: () => callback => node.measure(callback),
305
+ // Tracked so `detach` can cancel them. An un-cancelled long-press timer outlives the tree that
306
+ // owned it, and `removeChild` visits only a subtree ROOT — a pressable is normally nested.
307
+ schedule: (callback, ms) => {
308
+ const id = setTimeout(() => {
309
+ timers.delete(id);
310
+ callback();
311
+ }, ms);
312
+ timers.add(id);
313
+ return () => {
314
+ clearTimeout(id);
315
+ timers.delete(id);
316
+ };
317
+ },
318
+ now: Date.now,
319
+ };
320
+ const state = {
321
+ runtime,
322
+ host,
323
+ refine: options.refine,
324
+ disabledOf: options.disabledOf,
325
+ source: options.source ?? node,
326
+ timers,
327
+ listeners: {},
328
+ isBuilt: false,
329
+ };
330
+ states.set(node, state);
331
+ installListeners(node, state);
332
+ }
333
+ /** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
334
+ export function detachPressMachine(node) {
335
+ detach(node);
336
+ }
337
+ function detach(node) {
338
+ const state = states.get(node);
339
+ if (state === undefined)
340
+ return;
341
+ // The machine's own teardown, which every wrapper calls from its destroy hook. Not load-bearing
342
+ // here and no test can make it so: `host.schedule` puts every timer the machine arms into
343
+ // `state.timers` — the 130ms floor's deferred `pressOut` included — so the loop below already
344
+ // cancels them. Kept as the contract, and for a timer armed by some future route.
345
+ disposePressRuntime(state.runtime);
346
+ for (const id of state.timers)
347
+ clearTimeout(id);
348
+ state.timers.clear();
349
+ states.delete(node);
350
+ dlog('pressable behavior detached');
351
+ }
352
+ /**
353
+ * The press machine as behavior parts, so a tag that is a pressable PLUS something can compose it
354
+ * instead of re-implementing it.
355
+ *
356
+ * Spread into the caller's own behavior and wrap `attach`/`detach` around these — the touchable
357
+ * family needs a per-node Animated value opened before the machine and closed after it. The
358
+ * WeakMap holding the machine's own state is keyed by node, so one node may hold exactly one of
359
+ * these; a tag composing it therefore must not also register the plain `pressable` behavior.
360
+ */
361
+ export function createPressBehavior(refine, disabledOf) {
362
+ return {
363
+ attach: attachWith(refine, disabledOf),
364
+ detach,
365
+ foldPayload,
366
+ // Every name the machine needs as an INPUT. The responder pair is not optional — it is how a
367
+ // gesture starts at all, and `RESPONDER_EVENTS` makes those listeners on any node regardless
368
+ // of ViewConfig, so they collide exactly like `press` does.
369
+ ownedListeners: [
370
+ 'press',
371
+ 'pressIn',
372
+ 'pressOut',
373
+ 'pressMove',
374
+ 'longPress',
375
+ 'startShouldSetResponder',
376
+ 'responderMove',
377
+ 'responderTerminationRequest',
378
+ ],
379
+ };
380
+ }
381
+ // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
382
+ // same tag with an equivalent behavior must not double-install anything.
383
+ export function registerPressableBehavior() {
384
+ registerHostBehavior(PRESSABLE_TAG, createPressBehavior());
385
+ }
@@ -0,0 +1,2 @@
1
+ export declare const REFRESH_CONTROL_TAG = "refresh-control";
2
+ export declare function registerRefreshControlBehavior(): void;
@@ -0,0 +1,83 @@
1
+ // RefreshControl's machine, on the engine node instead of inside a framework component.
2
+ //
3
+ // WHAT THE FIVE WRAPPERS ACTUALLY DO, counted before writing this — the audit rule's instruction to
4
+ // grep the fold's OUTPUT rather than trust one wrapper. Four of the five (react, vue, solid,
5
+ // svelte) fold exactly `resolveAccessibilityProps` and forward, and that fold ALREADY runs in the
6
+ // engine at `fabricProps` on every path (the aria fold `SafeAreaView`'s spec entry cites). So there
7
+ // is nothing left for a `foldPayload` here to do, and this behavior deliberately declares none.
8
+ //
9
+ // The fifth is Angular, and it is the whole reason this file exists: it alone reproduces RN's
10
+ // CONTROLLED handshake (`RefreshControl.js:145-166`) — mirror what native last reported, and when
11
+ // the app's `refreshing` disagrees, command native back with `setNativeRefreshing`. React, Vue,
12
+ // Solid and Svelte have never had it, so a pull whose handler leaves `refreshing` false spins
13
+ // forever on four of five adapters. Moving it here closes that as a P0 parity gap rather than
14
+ // porting it four more times.
15
+ //
16
+ // ONE COMMAND NAME ON BOTH PLATFORMS — no `Platform.OS` branch, unlike Switch's snap-back
17
+ // (`setValue` / `setNativeValue`). RN sends `setNativeRefreshing` through both
18
+ // `PullToRefreshCommands` and `AndroidSwipeRefreshLayoutCommands` (`RefreshControl.js:152,157`).
19
+ //
20
+ // PLACEMENT IS NOT THIS FILE'S PROBLEM, though the tier audit once filed the primitive as
21
+ // impossible over it. iOS puts the control BESIDE the scroll view's content and Android makes it
22
+ // the scroll view's PARENT, and that decision belongs to the ScrollView, which now states it as
23
+ // data — `claimedChildren: { [REFRESH_CONTROL]: platform.claimMode }` in
24
+ // `behaviors/scroll-view/shared.ts`, honoured by the engine's `appendChild`. A claim is keyed on
25
+ // the child's FABRIC name and needs nothing from the child's own behavior, so the two are
26
+ // independent; `refresh-control-placement.test.ts` pins that both ways round.
27
+ //
28
+ // WHY THE DIVERGENCE CHECK IS DEFERRED A MICROTASK, and why `afterCommit` alone is not enough:
29
+ // `behaviors/switch.ts`'s module header, verbatim. Same shape, same two triggers, same reasons —
30
+ // an ACCEPTING app's state reaches `node.props` only after its own reconciliation, and a REJECTING
31
+ // app writes no prop at all, so the commit that `afterCommit` waits for never comes.
32
+ import { appListenerFor, dispatchViewCommand, dlog, registerHostBehavior, setBehaviorListener, } from '@symbiote-native/engine';
33
+ export const REFRESH_CONTROL_TAG = 'refresh-control';
34
+ // RN's own name for the command, sent to whichever of the two native views the platform resolved.
35
+ const SET_NATIVE_REFRESHING = 'setNativeRefreshing';
36
+ // What native LAST reported, absent until it has reported at all. Absent is not `false`: native is
37
+ // optimistic — it spins on the gesture before JS approves — so only a report can make the mirror
38
+ // authoritative, and an app that drives `refreshing` on its own initiative must never be corrected
39
+ // against a value native never claimed.
40
+ const reported = new WeakMap();
41
+ // Shared by both triggers — see the module header for why there are two.
42
+ function evaluateSnapBack(node) {
43
+ const lastNativeReport = reported.get(node);
44
+ if (lastNativeReport === undefined)
45
+ return; // no report yet, nothing to disagree with
46
+ const refreshing = node.props.refreshing === true;
47
+ if (lastNativeReport === refreshing) {
48
+ dlog(`RefreshControl behavior snap-back no-op refreshing=${refreshing}`);
49
+ return;
50
+ }
51
+ dlog(`RefreshControl behavior ${SET_NATIVE_REFRESHING} reported=${lastNativeReport} refreshing=${refreshing}`);
52
+ dispatchViewCommand(node, SET_NATIVE_REFRESHING, [refreshing]);
53
+ reported.set(node, refreshing);
54
+ }
55
+ function onRefresh(node, event) {
56
+ // Native has already started spinning by the time this arrives (RefreshControl.js:180), so the
57
+ // mirror moves BEFORE the app's handler runs — a handler that flips `refreshing` to true then
58
+ // agrees with it, and one that does nothing is what the deferred check corrects.
59
+ reported.set(node, true);
60
+ const listener = appListenerFor(node, 'refresh');
61
+ if (typeof listener === 'function')
62
+ listener(event);
63
+ queueMicrotask(() => evaluateSnapBack(node));
64
+ }
65
+ function attach(node) {
66
+ setBehaviorListener(node, 'refresh', event => onRefresh(node, event));
67
+ }
68
+ function detach(node) {
69
+ reported.delete(node);
70
+ }
71
+ // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
72
+ // same tag with an equivalent behavior must not double-install anything.
73
+ export function registerRefreshControlBehavior() {
74
+ registerHostBehavior(REFRESH_CONTROL_TAG, {
75
+ attach,
76
+ // Closes the case a microtask scheduled from `onRefresh` cannot: the app moves `refreshing` on
77
+ // its own while a past report is still unresolved. Costs nothing extra — it fires only on a
78
+ // commit that already changed something.
79
+ afterCommit: evaluateSnapBack,
80
+ detach,
81
+ ownedListeners: ['refresh'],
82
+ });
83
+ }
@@ -0,0 +1 @@
1
+ export declare function registerScrollViewBehavior(): void;
@@ -0,0 +1,52 @@
1
+ // ScrollView's behavior on Android, where a RefreshControl is not a child at all.
2
+ //
3
+ // An Android ScrollView holds exactly ONE child, so a sibling refresh control is an `addViewAt`
4
+ // crash rather than a layout mistake. RN inverts the tree instead: `AndroidSwipeRefreshLayout`
5
+ // WRAPS the scroll view, and the scroll view's style is split across the two boxes — layout on the
6
+ // wrapper's frame, visual on the scroller (`ScrollView.js:1856`). `nestedScrollEnabled` goes on the
7
+ // inner view so it consumes the gesture before the refresh parent sees it.
8
+ //
9
+ // The two folds below are what neither node can work out alone: the wrapper is the APP's node, so
10
+ // it carries whatever the app wrote on `<RefreshControl>` and knows nothing about the scroll view's
11
+ // style. RN reaches the same place through `cloneElement`, which likewise OVERRIDES the refresh
12
+ // control's own `style` — so replacing it here is parity, not a liberty.
13
+ import {} from '@symbiote-native/engine';
14
+ import { splitScrollViewStyle } from '../../scroll-view-commands.js';
15
+ import { ownerFold, registerScrollViewBehaviors, } from './shared.js';
16
+ // The owner under a wrap: the ordinary fold with the VISUAL half of its own style in place of the
17
+ // composed one. Delegating rather than restating is what keeps `decelerationRate`, `horizontal` and
18
+ // `nestedScrollEnabled` in ONE place — none of the three has anything to do with the wrap, and the
19
+ // hand-written copy this replaced had already lost the first of them.
20
+ function wrappedOwnerFold(base, horizontal) {
21
+ const plain = ownerFold(base, horizontal);
22
+ return props => ({
23
+ ...plain(props),
24
+ style: splitScrollViewStyle(base, props.style).inner,
25
+ });
26
+ }
27
+ // The wrapper: the LAYOUT half of the OWNER's style, read off the owner because that is where the
28
+ // app wrote it. Kept in step by `slotDerived` naming `style`, which marks the wrapper dirty on an
29
+ // owner style write.
30
+ function wrapperFold(owner, base) {
31
+ return props => ({
32
+ ...props,
33
+ style: splitScrollViewStyle(base, owner.props.style).outer,
34
+ });
35
+ }
36
+ const android = {
37
+ claimMode: 'wrap',
38
+ slotDerived: ['style'],
39
+ onWrapChange: (base, horizontal) => (owner, wrapper) => {
40
+ // Back to the ordinary composition, not to `undefined` — the plain fold carries the axis and
41
+ // the gesture props, which have nothing to do with the wrap.
42
+ owner.payloadFold =
43
+ wrapper === undefined
44
+ ? ownerFold(base, horizontal)
45
+ : wrappedOwnerFold(base, horizontal);
46
+ if (wrapper !== undefined)
47
+ wrapper.payloadFold = wrapperFold(owner, base);
48
+ },
49
+ };
50
+ export function registerScrollViewBehavior() {
51
+ registerScrollViewBehaviors(android);
52
+ }