@symbiote-native/components 1.0.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 (83) hide show
  1. package/README.md +3 -4
  2. package/build/behaviors/activity-indicator/index.android.d.ts +1 -0
  3. package/build/behaviors/activity-indicator/index.android.js +16 -0
  4. package/build/behaviors/activity-indicator/index.d.ts +3 -0
  5. package/build/behaviors/activity-indicator/index.ios.d.ts +1 -0
  6. package/build/behaviors/activity-indicator/index.ios.js +14 -0
  7. package/build/behaviors/activity-indicator/index.js +5 -0
  8. package/build/behaviors/activity-indicator/shared.d.ts +18 -0
  9. package/build/behaviors/activity-indicator/shared.js +149 -0
  10. package/build/behaviors/button.d.ts +2 -0
  11. package/build/behaviors/button.js +328 -0
  12. package/build/behaviors/image-background.d.ts +2 -0
  13. package/build/behaviors/image-background.js +139 -0
  14. package/build/behaviors/image.d.ts +1 -1
  15. package/build/behaviors/image.js +2 -2
  16. package/build/behaviors/input-accessory-view.d.ts +1 -1
  17. package/build/behaviors/input-accessory-view.js +2 -2
  18. package/build/behaviors/pressable.d.ts +59 -1
  19. package/build/behaviors/pressable.js +93 -18
  20. package/build/behaviors/refresh-control.d.ts +2 -0
  21. package/build/behaviors/refresh-control.js +83 -0
  22. package/build/behaviors/scroll-view/index.android.d.ts +1 -0
  23. package/build/behaviors/scroll-view/index.android.js +52 -0
  24. package/build/behaviors/scroll-view/index.d.ts +2 -0
  25. package/build/behaviors/scroll-view/index.ios.d.ts +1 -0
  26. package/build/behaviors/scroll-view/index.ios.js +10 -0
  27. package/build/behaviors/scroll-view/index.js +5 -0
  28. package/build/behaviors/scroll-view/shared.d.ts +11 -0
  29. package/build/behaviors/scroll-view/shared.js +291 -0
  30. package/build/behaviors/scroll-view/sticky.d.ts +17 -0
  31. package/build/behaviors/scroll-view/sticky.js +568 -0
  32. package/build/behaviors/switch.d.ts +1 -1
  33. package/build/behaviors/switch.js +9 -5
  34. package/build/behaviors/text-input.d.ts +2 -2
  35. package/build/behaviors/text-input.js +43 -15
  36. package/build/behaviors/touchable-highlight.d.ts +9 -0
  37. package/build/behaviors/touchable-highlight.js +192 -0
  38. package/build/behaviors/touchable-native-feedback.d.ts +20 -0
  39. package/build/behaviors/touchable-native-feedback.js +333 -0
  40. package/build/behaviors/touchable-opacity.d.ts +12 -0
  41. package/build/behaviors/touchable-opacity.js +227 -0
  42. package/build/behaviors/touchable-without-feedback.d.ts +2 -0
  43. package/build/behaviors/touchable-without-feedback.js +296 -0
  44. package/build/component-names/index.android.js +29 -19
  45. package/build/component-names/index.ios.js +31 -19
  46. package/build/component-names/shared.d.ts +2 -1
  47. package/build/component-names/shared.js +16 -6
  48. package/build/descriptor.js +4 -4
  49. package/build/fold-host-bag.js +2 -2
  50. package/build/index.d.ts +17 -11
  51. package/build/index.js +44 -8
  52. package/build/register.d.ts +1 -0
  53. package/build/register.js +55 -0
  54. package/build/scroll-view-commands.d.ts +4 -0
  55. package/build/scroll-view-commands.js +30 -31
  56. package/build/state/text-input.d.ts +4 -1
  57. package/build/view/render-button.d.ts +36 -1
  58. package/build/view/render-button.js +101 -12
  59. package/build/view/render-image/index.js +2 -2
  60. package/build/view/render-input-accessory-view.js +1 -1
  61. package/build/view/render-modal.js +3 -3
  62. package/build/view/render-pressable/index.d.ts +2 -0
  63. package/build/view/render-pressable/index.js +24 -0
  64. package/build/view/render-scroll-view.d.ts +1 -0
  65. package/build/view/render-scroll-view.js +18 -9
  66. package/build/view/render-switch.d.ts +4 -1
  67. package/build/view/render-switch.js +2 -2
  68. package/build/view/render-text-input.js +2 -2
  69. package/build/view/render-touchable-native-feedback.d.ts +19 -0
  70. package/build/view/render-touchable-native-feedback.js +19 -0
  71. package/host-primitives.cjs +202 -150
  72. package/host-primitives.d.cts +0 -2
  73. package/package.json +8 -17
  74. package/build/state-style.d.ts +0 -15
  75. package/build/state-style.js +0 -47
  76. package/build/view/render-activity-indicator.d.ts +0 -25
  77. package/build/view/render-activity-indicator.js +0 -88
  78. package/build/view/render-image-background.d.ts +0 -9
  79. package/build/view/render-image-background.js +0 -48
  80. package/lowering-fixtures.cjs +0 -259
  81. package/lowering-fixtures.d.cts +0 -17
  82. package/specialize-state-style.cjs +0 -219
  83. package/specialize-state-style.d.cts +0 -15
@@ -0,0 +1,12 @@
1
+ import { type IHostBehavior } from '@symbiote-native/engine';
2
+ import { type IDisabledResolver } from './pressable';
3
+ export declare const TOUCHABLE_OPACITY_TAG = "touchable-opacity";
4
+ /**
5
+ * The behavior as PARTS, so a tag that is a TouchableOpacity plus something — `button`, which RN
6
+ * builds as exactly that (Button.js:283) — composes the fade instead of re-implementing it.
7
+ *
8
+ * Safe to hand to two tags: every piece of runtime is keyed by NODE (`states`), and `press` is
9
+ * itself already shared that way.
10
+ */
11
+ export declare function createTouchableOpacityBehavior(disabledOf?: IDisabledResolver): IHostBehavior;
12
+ export declare function registerTouchableOpacityBehavior(): void;
@@ -0,0 +1,227 @@
1
+ // TouchableOpacity as an ENGINE-NODE behavior, so it can be an intrinsic tag instead of a
2
+ // framework component (`.claude/rules/host-primitive-tier.md`, tier 2).
3
+ //
4
+ // ONE NODE, and that is a correction rather than a shortcut. Every adapter's wrapper builds two —
5
+ // a `pressable` holding a faded `view` — and RN builds one: `TouchableOpacity.js:302` renders a
6
+ // single `<Animated.View>` carrying the responder handlers AND
7
+ // `style={[props.style, {opacity: anim}]}`. The two-node shape is copied from
8
+ // TouchableNativeFeedback, which genuinely does clone onto a child; the same misreading is on
9
+ // record for `android_ripple` (`.claude/rules/adapter-parity-audit.md`). So the tag collapses the
10
+ // pair and lands on RN's own tree.
11
+ //
12
+ // WHAT IS SHARED AND WHAT IS NEW. The press machine is `./pressable`'s, composed through
13
+ // `createPressBehavior` — one node may hold exactly one press machine, so this tag must not also
14
+ // register the plain `pressable` behavior. The press-scheduling half (delayPressIn defer,
15
+ // flush-on-early-release, the minPressDuration hold) is `../state/touchable`, unchanged and still
16
+ // shared with the wrappers. New here is only WHERE the fade lives: on the engine node, through
17
+ // `setAnimatedBehaviorStyle`, instead of in a component's own Animated wiring.
18
+ //
19
+ // REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter's entry
20
+ // does a bare `import './register';` that the barrel does not re-export.
21
+ import { AnimatedMock, AnimatedValue, Easing, Platform, appListenerFor, dlog, getExplicitStyle, markPropsDirty, registerHostBehavior, requestCommitFor, setAnimatedBehaviorStyle, timing, } from '@symbiote-native/engine';
22
+ import { resolveTouchableFocusable } from '../view/render-pressable/index.js';
23
+ import { accessibleUnlessOptedOut, booleanOr, createPressBehavior, } from './pressable.js';
24
+ import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_GRANT_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, restingOpacityFromStyle, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '../state/touchable.js';
25
+ export const TOUCHABLE_OPACITY_TAG = 'touchable-opacity';
26
+ const states = new WeakMap();
27
+ // `modules/animated`'s barrel swaps the whole driver namespace for the mock when the host reports
28
+ // reduced motion; the one driver used here is swapped on the same flag.
29
+ const startTiming = Platform.isDisableAnimations ? AnimatedMock.timing : timing;
30
+ function numberOr(value, fallback) {
31
+ return typeof value === 'number' ? value : fallback;
32
+ }
33
+ // From `parts.explicitStyle`, never `node.props.style`. A fade frame lands as `setNativeProps`,
34
+ // which merges its partial onto `node.props.style` in place — so reading the published slot would
35
+ // hand the resting-opacity math the fade's own output and `afterCommit` would chase itself.
36
+ //
37
+ // It therefore sees the AUTHOR's style and not a class-derived `opacity`, which is exactly RN's
38
+ // scope: `_getChildStyleOpacityWithDefault` reads `this.props.style` and nothing else.
39
+ function restingOpacityOf(node) {
40
+ return restingOpacityFromStyle(getExplicitStyle(node));
41
+ }
42
+ function fadeTo(state, toValue, ms) {
43
+ dlog(`touchable-opacity opacity -> ${toValue} over ${ms}ms`);
44
+ // useNativeDriver is RN's own (TouchableOpacity.js:242): opacity is natively drivable, so the
45
+ // fade survives a busy JS thread, which is the whole point of press feedback.
46
+ startTiming(state.opacity, {
47
+ toValue,
48
+ duration: ms,
49
+ easing: Easing.inOut(Easing.quad),
50
+ useNativeDriver: true,
51
+ }).start();
52
+ }
53
+ // Runs once per GESTURE (see IPressConfigRefinement), so the config it reads is whatever the props
54
+ // hold when a finger lands, and the handlers it builds are discarded with the gesture. The runtime
55
+ // that must outlive one — an in-flight delayPressIn timer, the activation clock — is on the node.
56
+ const refine = (node, config) => {
57
+ const state = states.get(node);
58
+ if (state === undefined)
59
+ return config;
60
+ const resting = restingOpacityOf(node);
61
+ const activeOpacity = numberOr(node.props.activeOpacity, DEFAULT_ACTIVE_OPACITY);
62
+ const handlers = createTouchableFeedbackHandlers({
63
+ delayPressIn: numberOr(node.props.delayPressIn, 0),
64
+ delayPressOut: numberOr(node.props.delayPressOut, 0),
65
+ // Read raw rather than off `config`, which has already defaulted the absent case to the press
66
+ // machine's own 130 ms — a floor RN's Touchables never see (TOUCHABLE_MIN_PRESS_DURATION_MS).
67
+ minPressDuration: numberOr(node.props.minPressDuration, TOUCHABLE_MIN_PRESS_DURATION_MS),
68
+ schedule: (callback, ms) => {
69
+ const id = setTimeout(() => {
70
+ state.timers.delete(id);
71
+ callback();
72
+ }, ms);
73
+ state.timers.add(id);
74
+ return () => {
75
+ clearTimeout(id);
76
+ state.timers.delete(id);
77
+ };
78
+ },
79
+ now: Date.now,
80
+ }, state.runtime, {
81
+ activate(event) {
82
+ // 0, not 150 (TouchableOpacity.js:215-220): the duration is chosen by where the press-in
83
+ // came from, and every pressIn our engine produces is the grant-equivalent.
84
+ fadeTo(state, activeOpacity, OPACITY_ACTIVE_GRANT_DURATION_MS);
85
+ config.onPressIn?.(event);
86
+ },
87
+ deactivate(event) {
88
+ fadeTo(state, resting, OPACITY_INACTIVE_DURATION_MS);
89
+ config.onPressOut?.(event);
90
+ },
91
+ });
92
+ return {
93
+ ...config,
94
+ // RN hands Pressability 0 (TouchableOpacity.js:195): the deactivation floor belongs to the
95
+ // feedback machine above, and a second one holds the fade for the press machine's default.
96
+ minPressDuration: 0,
97
+ onPressIn: handlers.handlePressIn,
98
+ onPressOut: handlers.handlePressOut,
99
+ };
100
+ };
101
+ const press = createPressBehavior(refine);
102
+ // The `id -> nativeID` alias every primitive's spec entry declares, applied here because
103
+ // `HOST_PRIMITIVES` deliberately withholds this primitive's entry until the other adapters'
104
+ // wrappers collapse to one node (the note at its Pressable neighbour says why). A raw `id` is a key
105
+ // no ViewConfig declares, so Fabric drops it and the nativeID is lost on device with nothing red.
106
+ //
107
+ // Unconditional priority when both are set, matching RN (`View.js:77-79`) and `foldHostBag`.
108
+ const foldPayload = props => {
109
+ const folded = press.foldPayload === undefined ? props : press.foldPayload(props);
110
+ const next = { ...folded };
111
+ // TouchableOpacity.js:303. The tag is this primitive's only path, so unlike `pressable` there is
112
+ // no wrapper for it to disagree with.
113
+ next.accessible = accessibleUnlessOptedOut(props);
114
+ if (Object.hasOwn(next, 'id')) {
115
+ next.nativeID = next.id;
116
+ delete next.id;
117
+ }
118
+ return next;
119
+ };
120
+ /**
121
+ * The behavior as PARTS, so a tag that is a TouchableOpacity plus something — `button`, which RN
122
+ * builds as exactly that (Button.js:283) — composes the fade instead of re-implementing it.
123
+ *
124
+ * Safe to hand to two tags: every piece of runtime is keyed by NODE (`states`), and `press` is
125
+ * itself already shared that way.
126
+ */
127
+ export function createTouchableOpacityBehavior(disabledOf) {
128
+ // Its own machine rather than the module-level `press`, so a composing tag can say what
129
+ // `disabled` MEANS to it. `foldPayload` below is the same function either way.
130
+ const machine = createPressBehavior(refine, disabledOf);
131
+ return {
132
+ ...machine,
133
+ foldPayload,
134
+ attach(node) {
135
+ const state = {
136
+ opacity: new AnimatedValue(RESTING_OPACITY),
137
+ runtime: createTouchableFeedbackRuntime(),
138
+ timers: new Set(),
139
+ settled: undefined,
140
+ };
141
+ states.set(node, state);
142
+ // Bound at ATTACH rather than at the first press, matching RN: the binding is what forces
143
+ // `collapsable: false`, and a view Fabric un-flattens mid-gesture would lose the responder.
144
+ // Nothing is published until a fade runs — the leaf writes only when its value moves.
145
+ setAnimatedBehaviorStyle(node, { opacity: state.opacity });
146
+ machine.attach(node);
147
+ },
148
+ // RN's componentDidUpdate: a changed `disabled` or a changed style opacity re-settles the view,
149
+ // so a Touchable disabled mid-press does not stay stuck at its active opacity.
150
+ afterCommit(node) {
151
+ const state = states.get(node);
152
+ if (state === undefined)
153
+ return;
154
+ const resting = restingOpacityOf(node);
155
+ // Through the SAME resolver the press machine uses, not the raw prop: on Button `disabled` is
156
+ // the three-way answer of `props.disabled ?? aria-disabled ?? accessibilityState.disabled`
157
+ // (Button.js:331,337), so reading the prop here left an aria-only flip un-settled — the view
158
+ // stayed at its active opacity while the press was already suppressed. RN's Button has no
159
+ // such gap because it passes the resolved value DOWN as its touchable's prop.
160
+ const disabled = disabledOf?.(node.props) ?? node.props.disabled;
161
+ const previous = state.settled;
162
+ state.settled = { disabled, resting };
163
+ if (previous === undefined) {
164
+ // RN's `Animated.View` carries `{opacity: anim}` in its style from the FIRST render, so a
165
+ // resting Touchable commits the key too — a tag that published nothing until the first
166
+ // press would differ from every wrapper on mount. Set rather than animate: this is the
167
+ // mount, and RN re-settles on componentDidUpdate only.
168
+ state.opacity.setValue(resting);
169
+ return;
170
+ }
171
+ if (previous.disabled === disabled && previous.resting === resting)
172
+ return;
173
+ fadeTo(state, resting, OPACITY_INACTIVE_DURATION_MS);
174
+ },
175
+ detach(node) {
176
+ machine.detach(node);
177
+ const state = states.get(node);
178
+ if (state === undefined)
179
+ return;
180
+ for (const id of state.timers)
181
+ clearTimeout(id);
182
+ state.timers.clear();
183
+ // RN's componentWillUnmount: stop the animation so a teardown mid-fade leaves no driver
184
+ // running against a node that is gone.
185
+ state.opacity.resetAnimation();
186
+ setAnimatedBehaviorStyle(node, undefined);
187
+ states.delete(node);
188
+ dlog('touchable-opacity behavior detached');
189
+ },
190
+ };
191
+ }
192
+ // `focusable` is NOT in `foldPayload` above, because its middle leg — `onPress !== undefined`
193
+ // (TouchableOpacity.js:338) — is an OWNED name and therefore lives in the stash, which a props-only
194
+ // fold cannot reach. `./button` composes this behavior and resolves its own; the tag resolves it
195
+ // here, over the node.
196
+ //
197
+ // `props.disabled`, not `next.disabled`: the press fold strips it (MACHINE_ONLY_KEYS).
198
+ function tagFold(node) {
199
+ return props => {
200
+ const next = foldPayload(props);
201
+ next.focusable = resolveTouchableFocusable(booleanOr(props.focusable), appListenerFor(node, 'press') !== undefined, booleanOr(props.disabled));
202
+ return next;
203
+ };
204
+ }
205
+ // A listener flip changes no payload by itself, so the commit after it is a no-op and no fold
206
+ // re-runs (`IHostBehavior.onOwnedListenerChange`). `./button` carries the twin of this.
207
+ function onOwnedListenerChange(node, name) {
208
+ if (name !== 'press')
209
+ return;
210
+ markPropsDirty(node);
211
+ requestCommitFor(node);
212
+ }
213
+ // Idempotent: an adapter entry may be imported more than once in a bundle.
214
+ export function registerTouchableOpacityBehavior() {
215
+ const touchable = createTouchableOpacityBehavior();
216
+ registerHostBehavior(TOUCHABLE_OPACITY_TAG, {
217
+ ...touchable,
218
+ // `attachHostBehavior` writes `behavior.foldPayload` into the field one line BEFORE it calls
219
+ // `attach`, so binding here is what stands. Only the TAG binds it — a composing behavior owns
220
+ // its own node's fold (`./button` sets one in `buildStructure`, which runs later still).
221
+ attach(node) {
222
+ touchable.attach(node);
223
+ node.payloadFold = tagFold(node);
224
+ },
225
+ onOwnedListenerChange,
226
+ });
227
+ }
@@ -0,0 +1,2 @@
1
+ export declare const TOUCHABLE_WITHOUT_FEEDBACK_TAG = "touchable-without-feedback";
2
+ export declare function registerTouchableWithoutFeedbackBehavior(): void;
@@ -0,0 +1,296 @@
1
+ // TouchableWithoutFeedback as an ENGINE-NODE behavior — the second primitive that commits NO NODE
2
+ // OF ITS OWN (`.claude/rules/host-primitive-tier.md`, tier 2), after `./touchable-native-feedback`.
3
+ //
4
+ // RN'S TWF RENDERS NOTHING, exactly like TNF: `React.Children.only(props.children)`
5
+ // (TouchableWithoutFeedback.js:229) then `cloneElement(element, elementProps, ...children)` (:286).
6
+ // All five of our adapters wrapped a `Pressable` around the children instead, so every TWF in every
7
+ // app committed a node RN does not.
8
+ //
9
+ // The SHAPE — anchor tag, `onChildInserted` adoption, a `cloneElement`-shaped `payloadFold` on the
10
+ // child, the press machine on the CHILD reading the OWNER — is `./touchable-native-feedback`'s and
11
+ // is documented there. Its three KNOWN LIMITS (one child only, a child removed from a live owner
12
+ // keeps its machine, `style` is not cloned) hold here verbatim: RN's TWF declares a `style` prop
13
+ // (:122, tagged "FIXME: not in doc") and then never puts it in `elementProps`, so it is dead
14
+ // upstream too.
15
+ //
16
+ // WHAT DIFFERS FROM TNF, since the two look like one primitive with a different name:
17
+ //
18
+ // 1. THE CLONE IS CONDITIONAL. TNF assigns its whole list unconditionally, so an absent prop CLEARS
19
+ // the child's (`cloneElement` copies undefined keys). TWF splits: eight names are computed and
20
+ // assigned unconditionally, and `PASSTHROUGH_PROPS` (:130-153) is copied only
21
+ // `if (props[prop] !== undefined)` (:281). So a TWF with no `testID` LEAVES the child's alone.
22
+ // 2. NO ANDROID RIPPLE. `background` / `useForeground` / `getBackgroundProp` and the
23
+ // `setPressed`/`hotspotUpdate` view commands are TNF's alone — they exist to drive the drawable
24
+ // TNF installs, and TWF installs none. `nativeFeedbackRefinement` is therefore NOT reused.
25
+ // 3. `onBlur` / `onFocus` ARE cloned (:152-153, in the passthrough list), where TNF drops them.
26
+ // Both are `BASE_EVENTS`, so they route to the stash and trampoline like the other two.
27
+ // 4. `accessibilityIgnoresInvertColors` is cloned; `hasTVPreferredFocus` and the five `nextFocus*`
28
+ // are NOT — the reverse of TNF on both counts. Read RN's two lists side by side rather than
29
+ // inheriting either.
30
+ // 5. TWF FORWARDS `delayPressIn` / `delayPressOut` to Pressability (:186-188), which the press
31
+ // machine does not own — so the tag layers `../state/touchable`'s scheduler on top, the same
32
+ // machine `./touchable-opacity` uses with the fade replaced by nothing. That is what "without
33
+ // feedback" means: no VISUAL, not no TIMING.
34
+ //
35
+ // REGISTRATION IS THE HAZARD, not the machine — see `./pressable`. Registered in the same commit
36
+ // that deletes the five wrappers, because the registry is keyed by TAG and a wrapper still emitting
37
+ // its own `pressable` would put two press machines on one tree.
38
+ import { ARIA_ALIAS_KEYS, appListenerFor, foldAriaProps, markPropsDirty, registerHostBehavior, requestCommitFor, setBehaviorListener, } from '@symbiote-native/engine';
39
+ import { resolveDisabledAccessibilityState, resolveTouchableFocusable, } from '../view/render-pressable/index.js';
40
+ import { accessibleUnlessOptedOut, asAccessibilityState, attachPressMachine, booleanOr, detachPressMachine, } from './pressable.js';
41
+ import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '../state/touchable.js';
42
+ export const TOUCHABLE_WITHOUT_FEEDBACK_TAG = 'touchable-without-feedback';
43
+ // TouchableWithoutFeedback.js:130-153, RN's own order, minus two groups that are NOT props here:
44
+ // the four `on*` names (forwarded as LISTENERS by `FORWARDED_LISTENERS`) and the five raw `aria-*`
45
+ // entries. RN passes those raw because its TWF folds no aria itself and leaves the child View to do
46
+ // it; we fold the owner's bag before the clone runs, so `aria-valuemax` has already become
47
+ // `accessibilityValue` and `aria-modal` `accessibilityViewIsModal` by this point.
48
+ //
49
+ // COPIED ONLY WHEN SET (:281), which is the split from TNF's unconditional clone.
50
+ const CLONED_WHEN_SET = [
51
+ 'accessibilityActions',
52
+ 'accessibilityHint',
53
+ 'accessibilityLanguage',
54
+ 'accessibilityIgnoresInvertColors',
55
+ 'accessibilityLabel',
56
+ 'accessibilityRole',
57
+ 'accessibilityValue',
58
+ 'accessibilityViewIsModal',
59
+ 'hitSlop',
60
+ 'testID',
61
+ ];
62
+ // :253-276, assigned whatever their value — including `undefined`, which clears the child's, the
63
+ // same `cloneElement` semantics TNF relies on. Three of them (`accessibilityElementsHidden`,
64
+ // `accessibilityLiveRegion`, `importantForAccessibility`) are in RN's passthrough list TOO, and the
65
+ // later conditional copy can only re-assign the same value the aria fold already resolved — so they
66
+ // belong here, not above.
67
+ const CLONED_ALWAYS = [
68
+ 'accessibilityElementsHidden',
69
+ 'accessibilityLiveRegion',
70
+ 'importantForAccessibility',
71
+ ];
72
+ // Owner props the four COMPUTED clones read, on top of the two lists above. `focusable` also
73
+ // derives from the `press` LISTENER, which is not a prop — see `onOwnedListenerChange`.
74
+ const DERIVED_FROM = [
75
+ 'accessible',
76
+ 'accessibilityState',
77
+ 'disabled',
78
+ 'focusable',
79
+ 'id',
80
+ 'nativeID',
81
+ // Not cloned at all: they configure the scheduler, which reads them live at gesture start. Listed
82
+ // so a mid-mount change still dirties the child and the next commit re-reads them.
83
+ 'delayPressIn',
84
+ 'delayPressOut',
85
+ 'minPressDuration',
86
+ ];
87
+ // Every owner name the child's payload reads. Without it the clone is correct at mount and frozen
88
+ // forever after: `markPropsDirty` bubbles UP, so an owner write never reaches the child on its own.
89
+ const SLOT_DERIVED = [
90
+ ...CLONED_WHEN_SET,
91
+ ...CLONED_ALWAYS,
92
+ ...DERIVED_FROM,
93
+ ...ARIA_ALIAS_KEYS,
94
+ ];
95
+ // :148-153. Owned, so the app's callback stashes on the owner and a trampoline on the child reads it
96
+ // at dispatch time. `layout` and `accessibilityAction` are Fabric BOOLEAN-GATED events
97
+ // (`.claude/rules/fabric-boolean-event-gates.md`), so the trampoline goes in only while the app has
98
+ // one wired; `blur` and `focus` are not gated but ride the same wiring, since the question — is
99
+ // there a callback to forward — is identical.
100
+ const FORWARDED_LISTENERS = [
101
+ 'layout',
102
+ 'accessibilityAction',
103
+ 'blur',
104
+ 'focus',
105
+ ];
106
+ const PRESS_LISTENERS = [
107
+ 'press',
108
+ 'pressIn',
109
+ 'pressOut',
110
+ 'pressMove',
111
+ 'longPress',
112
+ 'startShouldSetResponder',
113
+ 'responderMove',
114
+ 'responderTerminationRequest',
115
+ ];
116
+ const states = new WeakMap();
117
+ function numberOr(value, fallback) {
118
+ return typeof value === 'number' ? value : fallback;
119
+ }
120
+ function stringOr(value) {
121
+ return typeof value === 'string' ? value : undefined;
122
+ }
123
+ /**
124
+ * `cloneElement(element, elementProps)` as a payload fold: the child's own props first, the owner's
125
+ * two lists over them. Pure, as `IPayloadFold` requires — the owner is read, never written.
126
+ */
127
+ function cloneFold(owner, inner) {
128
+ return (props) => {
129
+ const next = {
130
+ ...(inner === undefined ? props : inner(props)),
131
+ };
132
+ // The owner's aria props are on the OWNER, so the engine's own fold at `fabricProps` — which
133
+ // reads the node being committed — never sees them. Run it here over the source bag; the fold
134
+ // returns its input by identity when there is nothing to do.
135
+ const source = owner.hasAriaAlias
136
+ ? foldAriaProps(owner.props)
137
+ : owner.props;
138
+ for (const key of CLONED_ALWAYS)
139
+ next[key] = source[key];
140
+ for (const key of CLONED_WHEN_SET) {
141
+ if (source[key] !== undefined)
142
+ next[key] = source[key];
143
+ }
144
+ // :255-266. `onPress` is an OWNED name, so it lives in the stash and never in `props`.
145
+ const disabled = booleanOr(source.disabled);
146
+ next.accessible = accessibleUnlessOptedOut(source);
147
+ next.focusable = resolveTouchableFocusable(booleanOr(source.focusable), appListenerFor(owner, 'press') !== undefined, disabled);
148
+ // :275 gives `id` priority; the passthrough loop then re-assigns a set `nativeID` over it
149
+ // (:280-284), so upstream an explicit `nativeID` wins where TNF's `id` does. NOT reproduced,
150
+ // and deliberately: the spec entry declares `ID_ALIAS`, so on the three adapters that fold in
151
+ // the renderer `id` has already become `nativeID` before this runs and the quirk is
152
+ // unobservable — reproducing it would make the answer depend on WHICH adapter is driving,
153
+ // which is the divergence `.claude/rules/adapter-parity-audit.md` exists to prevent.
154
+ next.nativeID = stringOr(source.id) ?? stringOr(source.nativeID);
155
+ // :257-262 folded by the engine above, then :258-263: an explicit `disabled` overrides the
156
+ // aria/accessibilityState answer.
157
+ next.accessibilityState = resolveDisabledAccessibilityState(asAccessibilityState(source.accessibilityState), disabled);
158
+ return next;
159
+ };
160
+ }
161
+ /**
162
+ * `delayPressIn` / `delayPressOut` (:186-188) plus RN's unconditional `minPressDuration: 0` (:190).
163
+ *
164
+ * The scheduler is `../state/touchable`'s — the one `./touchable-opacity` drives its fade with,
165
+ * here with `activate` / `deactivate` forwarding and painting nothing. It reads the OWNER, so the
166
+ * owner is closed over rather than taken from the refinement's argument, which is the CHILD.
167
+ */
168
+ function refinementFor(owner, state) {
169
+ return (_node, config) => {
170
+ const handlers = createTouchableFeedbackHandlers({
171
+ delayPressIn: numberOr(owner.props.delayPressIn, 0),
172
+ delayPressOut: numberOr(owner.props.delayPressOut, 0),
173
+ // Read raw rather than off `config`, which has already defaulted the absent case to the
174
+ // press machine's own 130 ms — a floor RN's Touchables never see.
175
+ minPressDuration: numberOr(owner.props.minPressDuration, TOUCHABLE_MIN_PRESS_DURATION_MS),
176
+ schedule: (callback, ms) => {
177
+ const id = setTimeout(() => {
178
+ state.timers.delete(id);
179
+ callback();
180
+ }, ms);
181
+ state.timers.add(id);
182
+ return () => {
183
+ clearTimeout(id);
184
+ state.timers.delete(id);
185
+ };
186
+ },
187
+ now: Date.now,
188
+ }, state.runtime, {
189
+ activate(event) {
190
+ config.onPressIn?.(event);
191
+ },
192
+ deactivate(event) {
193
+ config.onPressOut?.(event);
194
+ },
195
+ });
196
+ return {
197
+ ...config,
198
+ // :190, unconditional. The deactivation floor belongs to the scheduler above, and a second
199
+ // one would defer `onPressOut` by the press machine's 130 ms default on top of it.
200
+ minPressDuration: 0,
201
+ onPressIn: handlers.handlePressIn,
202
+ onPressOut: handlers.handlePressOut,
203
+ };
204
+ };
205
+ }
206
+ // The app's callback, read at dispatch time so a fresh closure per render costs nothing.
207
+ function trampolineFor(owner, name) {
208
+ return (event) => {
209
+ const listener = appListenerFor(owner, name);
210
+ if (typeof listener === 'function')
211
+ listener(event);
212
+ };
213
+ }
214
+ function forwardListener(owner, child, name, wired) {
215
+ setBehaviorListener(child, name, wired ? trampolineFor(owner, name) : undefined);
216
+ }
217
+ // The machine plus the forwarded listeners. Split from adoption because a parked subtree comes back
218
+ // with its child and its fold intact but its machine torn down — `attach` re-runs this, and must
219
+ // NOT re-chain the fold.
220
+ function arm(owner, child) {
221
+ let state = states.get(owner);
222
+ if (state === undefined) {
223
+ state = { runtime: createTouchableFeedbackRuntime(), timers: new Set() };
224
+ states.set(owner, state);
225
+ }
226
+ attachPressMachine(child, {
227
+ source: owner,
228
+ refine: refinementFor(owner, state),
229
+ });
230
+ for (const name of FORWARDED_LISTENERS) {
231
+ forwardListener(owner, child, name, appListenerFor(owner, name) !== undefined);
232
+ }
233
+ }
234
+ function attach(node) {
235
+ // First attach has no child yet; a re-attach after the sweep does. See `arm`, which is also what
236
+ // seeds the scheduling cell — a TWF that never adopts a child never needs one.
237
+ if (node.childHost !== undefined)
238
+ arm(node, node.childHost);
239
+ }
240
+ function detach(node) {
241
+ const child = node.childHost;
242
+ if (child !== undefined)
243
+ detachPressMachine(child);
244
+ const state = states.get(node);
245
+ if (state === undefined)
246
+ return;
247
+ for (const id of state.timers)
248
+ clearTimeout(id);
249
+ state.timers.clear();
250
+ states.delete(node);
251
+ }
252
+ function onChildInserted(node, child) {
253
+ const previous = node.childHost;
254
+ if (previous === child)
255
+ return;
256
+ // A replaced child: release the machine still armed on the one that left. `removeChild` clears
257
+ // `childHost` (node.ts), so this only runs when a framework inserts without removing first.
258
+ if (previous !== undefined)
259
+ detachPressMachine(previous);
260
+ node.childHost = child;
261
+ child.payloadFold = cloneFold(node, child.payloadFold);
262
+ // The owner's props were very likely written BEFORE this child existed (React and Solid set props
263
+ // at createInstance), so the fold owes a run even though nothing was written since. Witnessed by
264
+ // the MOVED-child case, not by a fresh one — `appendChild` already dirties a node it created.
265
+ markPropsDirty(child);
266
+ arm(node, child);
267
+ }
268
+ // `focusable` is a function of a LISTENER, and a listener flip changes no payload by itself — so
269
+ // the commit after it is a no-op and no fold re-runs (`IHostBehavior.onOwnedListenerChange`).
270
+ function onOwnedListenerChange(node, name, wired) {
271
+ const child = node.childHost;
272
+ if (child === undefined)
273
+ return;
274
+ if (FORWARDED_LISTENERS.includes(name)) {
275
+ forwardListener(node, child, name, wired);
276
+ return;
277
+ }
278
+ if (name !== 'press')
279
+ return;
280
+ markPropsDirty(child);
281
+ requestCommitFor(child);
282
+ }
283
+ // Idempotent: an adapter entry may be imported more than once in a bundle.
284
+ export function registerTouchableWithoutFeedbackBehavior() {
285
+ const behavior = {
286
+ attach,
287
+ detach,
288
+ onChildInserted,
289
+ onOwnedListenerChange,
290
+ // Owned on the OWNER, which is where the app writes them, and read from there by the machine on
291
+ // the child (`attachPressMachine`'s `source`) and by the trampolines above.
292
+ ownedListeners: [...PRESS_LISTENERS, ...FORWARDED_LISTENERS],
293
+ slotDerived: SLOT_DERIVED,
294
+ };
295
+ registerHostBehavior(TOUCHABLE_WITHOUT_FEEDBACK_TAG, behavior);
296
+ }
@@ -2,33 +2,42 @@
2
2
  // Each name is the ViewManager's REACT_CLASS in react-native/ReactAndroid/.../views/**.
3
3
  // device-verify-pending: source-confirmed from RN's Android ViewManagers, proven on a
4
4
  // real host by the absence of a "Can't find ViewManager '<name>'" red box.
5
+ import { ANCHOR_COMPONENT } from '@symbiote-native/engine';
5
6
  import { buildDescriptors, makeDescriptorFor, } from './shared.js';
6
7
  const ANDROID_NAMES = {
7
- 'symbiote-view': 'RCTView',
8
- 'symbiote-pressable': 'RCTView',
9
- 'symbiote-text': 'RCTText',
10
- 'symbiote-image': 'RCTImageView',
11
- 'symbiote-scroll-view': 'RCTScrollView',
8
+ view: 'RCTView',
9
+ pressable: 'RCTView',
10
+ 'touchable-opacity': 'RCTView',
11
+ // NOT a view name — TNF commits nothing at all, on either platform. See `shared.ts`.
12
+ 'touchable-native-feedback': ANCHOR_COMPONENT,
13
+ 'touchable-without-feedback': ANCHOR_COMPONENT,
14
+ 'touchable-highlight': 'RCTView',
15
+ button: 'RCTView',
16
+ text: 'RCTText',
17
+ image: 'RCTImageView',
18
+ 'image-background': 'RCTView',
19
+ 'scroll-view': 'RCTScrollView',
12
20
  // RN's VScrollContentViewNativeComponent is `Platform.OS === 'android' ? View : …`,
13
21
  // so a vertical scroll's content is a plain RCTView on Android, not RCTScrollContentView.
14
- 'symbiote-scroll-content': 'RCTView',
22
+ 'scroll-content': 'RCTView',
15
23
  // Horizontal scroll on Android is its own ViewManager; RCTScrollView is vertical-only and
16
24
  // ignores `horizontal`. RN routes it to AndroidHorizontalScrollView with a dedicated content
17
25
  // view (HScrollViewNativeComponents.js: `Platform.OS === 'android' ? AndroidHorizontal… : …`).
18
- 'symbiote-horizontal-scroll-view': 'AndroidHorizontalScrollView',
19
- 'symbiote-horizontal-scroll-content': 'AndroidHorizontalScrollContentView',
26
+ 'horizontal-scroll-view': 'AndroidHorizontalScrollView',
27
+ 'horizontal-scroll-content': 'AndroidHorizontalScrollContentView',
20
28
  // Android has one text-input ViewManager for both single- and multiline.
21
- 'symbiote-text-input': 'AndroidTextInput',
22
- 'symbiote-text-input-multiline': 'AndroidTextInput',
29
+ 'text-input': 'AndroidTextInput',
30
+ 'text-input-multiline': 'AndroidTextInput',
23
31
  // The component path's pair — same native views, a tag the behavior registry does not
24
32
  // carry. See `shared.ts` for why the wrapper may not share the lowered tag.
25
- 'symbiote-text-input-managed': 'AndroidTextInput',
26
- 'symbiote-text-input-multiline-managed': 'AndroidTextInput',
27
- 'symbiote-switch': 'AndroidSwitch',
33
+ 'text-input-managed': 'AndroidTextInput',
34
+ 'text-input-multiline-managed': 'AndroidTextInput',
35
+ switch: 'AndroidSwitch',
28
36
  // The wrapper's tag — same native view, a tag the behavior registry does not carry. See
29
37
  // `shared.ts` for why the wrapper may not share the lowered tag.
30
- 'symbiote-switch-managed': 'AndroidSwitch',
31
- 'symbiote-activity-indicator': 'AndroidProgressBar',
38
+ 'switch-managed': 'AndroidSwitch',
39
+ 'activity-indicator': 'RCTView',
40
+ 'activity-indicator-spinner': 'AndroidProgressBar',
32
41
  // KNOWN DIVERGENCE FROM REACT NATIVE, and it is in our favour — recorded 2026-09-01 because it
33
42
  // was arrived at by accident, not decided. Upstream `SafeAreaView.js` is
34
43
  // `Platform.select({ ios: RCTSafeAreaViewNativeComponent, default: View })`, so RN's own JS
@@ -43,12 +52,13 @@ const ANDROID_NAMES = {
43
52
  // react-native-safe-area-context. Invisible to every audit we have, because both of our paths
44
53
  // agree with each other and only disagree with RN — the boundary
45
54
  // `.claude/rules/adapter-parity-audit.md` states about itself.
46
- 'symbiote-safe-area-view': 'RCTSafeAreaView',
47
- 'symbiote-modal': 'RCTModalHostView',
48
- 'symbiote-refresh-control': 'AndroidSwipeRefreshLayout',
55
+ 'safe-area-view': 'RCTSafeAreaView',
56
+ modal: 'RCTModalHostView',
57
+ 'refresh-control': 'AndroidSwipeRefreshLayout',
58
+ 'sticky-header': 'RCTView',
49
59
  // iOS-only primitive; RN ships no Android InputAccessoryView. Degrade to a plain
50
60
  // container so an iOS-targeted usage doesn't red-box on Android.
51
- 'symbiote-input-accessory-view': 'RCTView',
61
+ 'input-accessory-view': 'RCTView',
52
62
  };
53
63
  export const COMPONENT_DESCRIPTORS = buildDescriptors(ANDROID_NAMES);
54
64
  export const descriptorFor = makeDescriptorFor(COMPONENT_DESCRIPTORS);