@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,192 @@
1
+ // TouchableHighlight 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, THE SAME SIMPLIFICATION EVERY WRAPPER ALREADY SHIPS. RN's own TouchableHighlight
5
+ // renders a container View (the responder, the underlay backgroundColor, the whole accessibility
6
+ // fold) and CLONES an extra opacity style onto its single child (TouchableHighlight.js:281-320,
7
+ // `_createExtraStyles` + `cloneElement`) — closer to TouchableNativeFeedback's clone-onto-child
8
+ // shape than to TouchableOpacity's true single node. Every wrapper (Svelte's own comment: "ITEM 7
9
+ // IS DELIBERATELY NOT FIXED HERE... exactly as Solid and Angular decided") folds BOTH the underlay
10
+ // and the child opacity onto the ONE node instead, because splitting them needs a child to target
11
+ // and a framework component holding an opaque children snippet/slot cannot reach one safely. This
12
+ // port keeps that already-shipped, already-cross-adapter simplification rather than reopening it —
13
+ // `render-touchable-highlight.ts`'s own header says the shared layer "takes no position on where
14
+ // they land", so this is a legitimate placement choice, not a new shortcut.
15
+ //
16
+ // WHAT IS SHARED AND WHAT IS NEW. The underlay show/hide state machine
17
+ // (createHighlightUnderlayHandlers/createHighlightUnderlayRuntime, `../state/touchable`) is already
18
+ // framework-agnostic — every wrapper already calls it. New here is only WHERE `shown` lives (a
19
+ // WeakMap keyed by node, the same shape `./touchable-opacity`'s `states` map uses) and how a flip
20
+ // reaches Fabric: `markPropsDirty` + `requestCommitFor`, the same primitives `./pressable`'s
21
+ // `setPressed` composer uses to re-fold after an internal state change — NOT
22
+ // `setAnimatedBehaviorStyle`, which exists for a continuously-driven `AnimatedValue` and would be
23
+ // the wrong tool for a discrete boolean swap with no easing.
24
+ //
25
+ // The press machine is `./pressable`'s, composed through `createPressBehavior` — one node may hold
26
+ // exactly one press machine, so this tag must not also register the plain `pressable` behavior.
27
+ //
28
+ // REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter's entry does
29
+ // a bare `import './register';` that the barrel does not re-export.
30
+ import { appListenerFor, markPropsDirty, registerHostBehavior, requestCommitFor, } from '@symbiote-native/engine';
31
+ import { resolveTouchableFocusable } from '../view/render-pressable/index.js';
32
+ import { accessibleUnlessOptedOut, booleanOr, createPressBehavior, } from './pressable.js';
33
+ import { createHighlightUnderlayHandlers, createHighlightUnderlayRuntime, hasTouchablePressHandler, } from '../state/touchable.js';
34
+ import { resolveHighlightExtraStyles } from '../view/render-touchable-highlight.js';
35
+ export const TOUCHABLE_HIGHLIGHT_TAG = 'touchable-highlight';
36
+ const states = new WeakMap();
37
+ function numberOr(value, fallback) {
38
+ return typeof value === 'number' ? value : fallback;
39
+ }
40
+ function stringOr(value, fallback) {
41
+ return typeof value === 'string' ? value : fallback;
42
+ }
43
+ // Runs once per GESTURE, matching `./touchable-opacity`'s `refine` — the config it reads is
44
+ // whatever the props hold when a finger lands, and the underlay handlers it builds read `shown`
45
+ // through the closure below rather than owning it, so a re-arm reads the current props.
46
+ const refine = (node, config) => {
47
+ const state = states.get(node);
48
+ if (state === undefined)
49
+ return config;
50
+ const hasPressHandler = hasTouchablePressHandler({
51
+ onPress: config.onPress,
52
+ onPressIn: config.onPressIn,
53
+ onPressOut: config.onPressOut,
54
+ onLongPress: config.onLongPress,
55
+ });
56
+ const underlay = createHighlightUnderlayHandlers({
57
+ delayPressOut: numberOr(node.props.delayPressOut, 0),
58
+ hasPressHandler,
59
+ schedule: (callback, ms) => {
60
+ const id = setTimeout(() => {
61
+ state.timers.delete(id);
62
+ callback();
63
+ }, ms);
64
+ state.timers.add(id);
65
+ return () => {
66
+ clearTimeout(id);
67
+ state.timers.delete(id);
68
+ };
69
+ },
70
+ }, state.runtime, {
71
+ setShown: (shown) => {
72
+ if (state.shown === shown)
73
+ return;
74
+ state.shown = shown;
75
+ markPropsDirty(node);
76
+ requestCommitFor(node);
77
+ },
78
+ onShowUnderlay: () => {
79
+ const onShowUnderlay = node.props.onShowUnderlay;
80
+ if (typeof onShowUnderlay === 'function')
81
+ onShowUnderlay();
82
+ },
83
+ onHideUnderlay: () => {
84
+ const onHideUnderlay = node.props.onHideUnderlay;
85
+ if (typeof onHideUnderlay === 'function')
86
+ onHideUnderlay();
87
+ },
88
+ });
89
+ return {
90
+ ...config,
91
+ // TouchableHighlight.js:203, verbatim: RN hands Pressability a 0 floor here too — what holds
92
+ // the underlay visible is the hold TIMER above, not a press-duration floor.
93
+ minPressDuration: 0,
94
+ onPress: (event) => {
95
+ underlay.handlePress(event);
96
+ config.onPress?.(event);
97
+ },
98
+ onPressIn: (event) => {
99
+ underlay.handlePressIn(event);
100
+ config.onPressIn?.(event);
101
+ },
102
+ onPressOut: (event) => {
103
+ underlay.handlePressOut(event);
104
+ config.onPressOut?.(event);
105
+ },
106
+ };
107
+ };
108
+ // `id -> nativeID`, the fold every un-lowered wrapper's `foldHostBag` already applies. Not read off
109
+ // `HOST_PRIMITIVES`, matching `./touchable-opacity`'s own inline check — that spec entry is
110
+ // deliberately withheld until this primitive's wrapper collapses to one node everywhere.
111
+ const foldPayload = props => {
112
+ const next = { ...props };
113
+ if (Object.hasOwn(next, 'id')) {
114
+ next.nativeID = next.id;
115
+ delete next.id;
116
+ }
117
+ next.accessible = accessibleUnlessOptedOut(props);
118
+ return next;
119
+ };
120
+ function tagFold(node) {
121
+ return props => {
122
+ const next = foldPayload(props);
123
+ const state = states.get(node);
124
+ const hasPressHandler = appListenerFor(node, 'press') !== undefined ||
125
+ appListenerFor(node, 'pressIn') !== undefined ||
126
+ appListenerFor(node, 'pressOut') !== undefined ||
127
+ appListenerFor(node, 'longPress') !== undefined;
128
+ const extra = state === undefined
129
+ ? undefined
130
+ : resolveHighlightExtraStyles({
131
+ shown: state.shown,
132
+ hasPressHandler,
133
+ underlayColor: stringOr(props.underlayColor, undefined),
134
+ activeOpacity: numberOr(props.activeOpacity, NaN) || undefined,
135
+ });
136
+ if (extra !== undefined) {
137
+ next.style = [props.style, extra.underlay, extra.child];
138
+ }
139
+ next.focusable = resolveTouchableFocusable(booleanOr(props.focusable), appListenerFor(node, 'press') !== undefined, booleanOr(props.disabled));
140
+ return next;
141
+ };
142
+ }
143
+ // A listener flip changes no payload by itself, so the commit after it is a no-op and no fold
144
+ // re-runs (`IHostBehavior.onOwnedListenerChange`) — same reason `./touchable-opacity` carries this.
145
+ function onOwnedListenerChange(node, name) {
146
+ if (name !== 'press')
147
+ return;
148
+ markPropsDirty(node);
149
+ requestCommitFor(node);
150
+ }
151
+ /**
152
+ * The behavior as PARTS, mirroring `./touchable-opacity`'s shape — a tag that is a
153
+ * TouchableHighlight plus something composes this instead of re-implementing the underlay.
154
+ */
155
+ export function createTouchableHighlightBehavior(disabledOf) {
156
+ const machine = createPressBehavior(refine, disabledOf);
157
+ return {
158
+ ...machine,
159
+ foldPayload,
160
+ attach(node) {
161
+ states.set(node, {
162
+ shown: false,
163
+ runtime: createHighlightUnderlayRuntime(),
164
+ timers: new Set(),
165
+ });
166
+ machine.attach(node);
167
+ },
168
+ detach(node) {
169
+ machine.detach(node);
170
+ const state = states.get(node);
171
+ if (state === undefined)
172
+ return;
173
+ for (const id of state.timers)
174
+ clearTimeout(id);
175
+ state.timers.clear();
176
+ states.delete(node);
177
+ },
178
+ };
179
+ }
180
+ // Idempotent: an adapter entry may be imported more than once in a bundle.
181
+ export function registerTouchableHighlightBehavior() {
182
+ const touchable = createTouchableHighlightBehavior();
183
+ registerHostBehavior(TOUCHABLE_HIGHLIGHT_TAG, {
184
+ ...touchable,
185
+ // Only the TAG binds a per-node fold — see `./touchable-opacity`'s identical comment.
186
+ attach(node) {
187
+ touchable.attach(node);
188
+ node.payloadFold = tagFold(node);
189
+ },
190
+ onOwnedListenerChange,
191
+ });
192
+ }
@@ -0,0 +1,20 @@
1
+ import { type IPressConfigRefinement } from './pressable';
2
+ export declare const TOUCHABLE_NATIVE_FEEDBACK_TAG = "touchable-native-feedback";
3
+ /**
4
+ * TNF's own Pressability config, applied to whatever node carries the responder.
5
+ *
6
+ * SHARED WITH `./button`, which is what this file's arrival makes possible. Button's Android arm
7
+ * held a private copy with a comment saying a future `touchable-native-feedback` tag could not
8
+ * reuse it "while the wrappers wrap" — the tag is here now and the responder node is a parameter
9
+ * either way, so the copy is gone.
10
+ *
11
+ * `minPressDuration: 0` is UNCONDITIONAL in RN (:226), not Android-gated: TNF has no feedback
12
+ * machine of its own to hold, and the press machine's 130 ms deactivation floor would only defer
13
+ * `onPressOut` — which on Android leaves `setPressed(false)` ~130 ms late and the ripple lit after
14
+ * the finger is gone. The two view commands ARE Android-gated (:257,:276): the ripple is a drawable
15
+ * Android animates off the view's pressed state, and the JS responder consumes the touch before
16
+ * Android's own pressed handling ever runs, so without them the background is installed and never
17
+ * moves.
18
+ */
19
+ export declare const nativeFeedbackRefinement: IPressConfigRefinement;
20
+ export declare function registerTouchableNativeFeedbackBehavior(): void;
@@ -0,0 +1,333 @@
1
+ // TouchableNativeFeedback as an ENGINE-NODE behavior — the one primitive that must commit NO NODE
2
+ // OF ITS OWN (`.claude/rules/host-primitive-tier.md`, tier 2).
3
+ //
4
+ // RN'S TNF RENDERS NOTHING. `render()` takes `React.Children.only(this.props.children)`
5
+ // (TouchableNativeFeedback.js:289) and returns `cloneElement(element, {…}, ...children)` (:339) —
6
+ // the child IS the responder, carries the ripple background, and takes the whole accessibility
7
+ // fold. All five of our adapters render a `Pressable` around a feedback `View` instead, so every
8
+ // TNF in every app commits a node RN does not, and the mistake has already spread: our Pressable
9
+ // wrapper and our TouchableOpacity wrapper both copied that inner view from here
10
+ // (`.claude/rules/adapter-parity-audit.md`, "the OTHER half turned up a gap on BOTH paths").
11
+ //
12
+ // WHY THE FIX CANNOT BE PER ADAPTER. At the FRAMEWORK level React, Vue and Solid can clone a child
13
+ // element; Svelte's children arrive as a snippet and Angular's through `<ng-content>`, and neither
14
+ // can have props written onto them there. At the ENGINE level all five can reach the child node.
15
+ // So the clone belongs below the frameworks, once, or it is five copies of one fold.
16
+ //
17
+ // ---------------------------------------------------------------------------------------------
18
+ // THE SHAPE, and which existing engine seam does each half:
19
+ //
20
+ // commits nothing the tag resolves to ANCHOR_COMPONENT (component-names), so the node is
21
+ // BORN skipped: `renderableChildren` (commit.ts) drops it and flattens its
22
+ // child into the grandparent's renderable list. Not a new concept — that is
23
+ // precisely what an anchor is, and TNF is its third instance after Vue's
24
+ // fragment placeholders and Angular's non-painting component hosts.
25
+ // finds its child `onChildInserted`, the one hook this needed. `buildStructure` cannot do it:
26
+ // it runs at `attach`, inside `createElement`, and the child is the
27
+ // FRAMEWORK'S and does not exist yet.
28
+ // clones the props a `payloadFold` on the CHILD reading the owner's bag, re-run because
29
+ // `slotDerived` marks the child's props dirty on every owner write. The
30
+ // alternative — redirecting each prop onto the child through `slotPropsExcept`
31
+ // — was rejected: adapters disagree about ORDER (React writes props before
32
+ // children, Vue mounts children before props), so a redirect silently drops
33
+ // every prop written before the child arrives, on some adapters only.
34
+ // presses the press machine on the CHILD, reading the OWNER (`attachPressMachine`'s
35
+ // `source`). It cannot live on the owner: `bubble` (events/index.ts) skips
36
+ // anchors for listener lookup, and `handOverNativeResponder` has no Fabric
37
+ // handle to hand native for an uncommitted node. So a responder on this tag
38
+ // could never fire — checked before the design, not after.
39
+ // ---------------------------------------------------------------------------------------------
40
+ // KNOWN LIMITS, stated rather than left to be found:
41
+ //
42
+ // 1. EXACTLY ONE CHILD, as RN enforces with `React.Children.only`. A second child lands INSIDE the
43
+ // first (the ordinary `childHost` redirect) rather than throwing. Nothing in RN's surface
44
+ // produces that shape.
45
+ // 2. A child REMOVED from a still-mounted TNF leaves its press machine armed until the TNF itself
46
+ // unmounts (`detach` releases whatever `childHost` then holds). Unreachable through the
47
+ // frameworks we drive: Solid's remove-then-reinsert move and Svelte's parking both move the TNF,
48
+ // never its single child, and there is no sibling to reorder it against.
49
+ // 3. `style` is NOT cloned, matching RN — its clone list (:342-390) is closed, and a `style` on a
50
+ // TNF stays on a node that never commits. RN's TNF declares no style prop either.
51
+ //
52
+ // REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter entry does a
53
+ // bare `import './register';` that the barrel does not re-export. Registered by ALL FIVE adapters
54
+ // since 2026-09-09, in the same commit that deleted the five wrappers, which is what makes it safe:
55
+ // while a wrapper still built its own Pressable + feedback View, registering would have left every
56
+ // TouchableNativeFeedback with two responders. There is no `-managed` twin and none is possible —
57
+ // this tag commits no node, so a second spelling would have nothing to name.
58
+ import { ARIA_ALIAS_KEYS, appListenerFor, dispatchViewCommand, foldAriaProps, markPropsDirty, Platform, registerHostBehavior, requestCommitFor, setBehaviorListener, } from '@symbiote-native/engine';
59
+ import { resolveDisabledAccessibilityState, resolveTouchableFocusable, } from '../view/render-pressable/index.js';
60
+ import { backgroundProps, selectableBackground, } from '../view/render-touchable-native-feedback.js';
61
+ import { accessibleUnlessOptedOut, asAccessibilityState, attachPressMachine, booleanOr, detachPressMachine, } from './pressable.js';
62
+ export const TOUCHABLE_NATIVE_FEEDBACK_TAG = 'touchable-native-feedback';
63
+ // Read once, like `./button`'s: the platform cannot change under a running app.
64
+ const IS_ANDROID = Platform.OS === 'android';
65
+ // TouchableNativeFeedback.js:349-390, verbatim and in RN's own order. A CLOSED list, not a
66
+ // passthrough: RN clones exactly these and nothing else, so a prop it does not name stays behind.
67
+ // `accessibilityState`, `accessible`, `focusable` and `nativeID` are cloned too and are computed
68
+ // below; `onLayout` and `onAccessibilityAction` are cloned as LISTENERS and forwarded by
69
+ // `FORWARDED_LISTENERS`.
70
+ const CLONED_PROPS = [
71
+ 'accessibilityHint',
72
+ 'accessibilityLanguage',
73
+ 'accessibilityLabel',
74
+ 'accessibilityRole',
75
+ 'accessibilityActions',
76
+ 'accessibilityValue',
77
+ 'importantForAccessibility',
78
+ 'accessibilityViewIsModal',
79
+ 'accessibilityLiveRegion',
80
+ 'accessibilityElementsHidden',
81
+ 'hasTVPreferredFocus',
82
+ 'hitSlop',
83
+ 'nextFocusDown',
84
+ 'nextFocusForward',
85
+ 'nextFocusLeft',
86
+ 'nextFocusRight',
87
+ 'nextFocusUp',
88
+ 'testID',
89
+ ];
90
+ // Owner props the cloned payload is COMPUTED from, on top of the verbatim list above. `focusable`
91
+ // also derives from the `press` LISTENER, which is not a prop and is handled by
92
+ // `onOwnedListenerChange` — a listener flip dirties no payload by itself.
93
+ const DERIVED_FROM = [
94
+ 'background',
95
+ 'useForeground',
96
+ 'accessible',
97
+ 'accessibilityState',
98
+ 'disabled',
99
+ 'focusable',
100
+ 'id',
101
+ 'nativeID',
102
+ ];
103
+ // Every owner name the child's payload reads. Without it the clone is correct at mount and frozen
104
+ // forever after: `markPropsDirty` bubbles UP, so an owner write never reaches the child on its own.
105
+ // The aria half comes off the engine's own list rather than a second copy of it.
106
+ const SLOT_DERIVED = [
107
+ ...CLONED_PROPS,
108
+ ...DERIVED_FROM,
109
+ ...ARIA_ALIAS_KEYS,
110
+ ];
111
+ // The two RN clones as EVENTS rather than props (:386-387). Owned, so the app's callback stashes on
112
+ // the owner and a trampoline installed on the child reads it at dispatch time — which keeps a fresh
113
+ // closure per render free, exactly as `ownedListeners` intends.
114
+ //
115
+ // Both are Fabric BOOLEAN-GATED events (`.claude/rules/fabric-boolean-event-gates.md`), so the
116
+ // trampoline goes in only while the app has one wired: `setBehaviorListener` writes and clears the
117
+ // flag with it, and installing eagerly would light the gate on every TNF child in the tree.
118
+ const FORWARDED_LISTENERS = [
119
+ 'layout',
120
+ 'accessibilityAction',
121
+ ];
122
+ const PRESS_LISTENERS = [
123
+ 'press',
124
+ 'pressIn',
125
+ 'pressOut',
126
+ 'pressMove',
127
+ 'longPress',
128
+ 'startShouldSetResponder',
129
+ 'responderMove',
130
+ 'responderTerminationRequest',
131
+ ];
132
+ function stringOr(value) {
133
+ return typeof value === 'string' ? value : undefined;
134
+ }
135
+ // The background dicts our own factories produce, narrowed on the discriminant rather than cast.
136
+ // Anything else reads as "not supplied", which is RN's `background === undefined` branch.
137
+ function asFeedbackBackground(value) {
138
+ if (typeof value !== 'object' || value === null)
139
+ return undefined;
140
+ const type = Reflect.get(value, 'type');
141
+ if (type !== 'ThemeAttrAndroid' && type !== 'RippleAndroid')
142
+ return undefined;
143
+ const attribute = Reflect.get(value, 'attribute');
144
+ const rippleRadius = Reflect.get(value, 'rippleRadius');
145
+ const radius = typeof rippleRadius === 'number' ? rippleRadius : undefined;
146
+ if (type === 'ThemeAttrAndroid') {
147
+ return attribute === 'selectableItemBackgroundBorderless'
148
+ ? { type, attribute, rippleRadius: radius }
149
+ : { type, attribute: 'selectableItemBackground', rippleRadius: radius };
150
+ }
151
+ const color = Reflect.get(value, 'color');
152
+ return {
153
+ type,
154
+ color: typeof color === 'string' ? color : null,
155
+ borderless: Reflect.get(value, 'borderless') === true,
156
+ rippleRadius: radius,
157
+ };
158
+ }
159
+ // TouchableNativeFeedback.js:280 — `locationX ?? 0`. The bag is raw Fabric payload, so guard.
160
+ function hotspotAt(nativeEvent, key) {
161
+ const value = nativeEvent[key];
162
+ return typeof value === 'number' ? value : 0;
163
+ }
164
+ /**
165
+ * TNF's own Pressability config, applied to whatever node carries the responder.
166
+ *
167
+ * SHARED WITH `./button`, which is what this file's arrival makes possible. Button's Android arm
168
+ * held a private copy with a comment saying a future `touchable-native-feedback` tag could not
169
+ * reuse it "while the wrappers wrap" — the tag is here now and the responder node is a parameter
170
+ * either way, so the copy is gone.
171
+ *
172
+ * `minPressDuration: 0` is UNCONDITIONAL in RN (:226), not Android-gated: TNF has no feedback
173
+ * machine of its own to hold, and the press machine's 130 ms deactivation floor would only defer
174
+ * `onPressOut` — which on Android leaves `setPressed(false)` ~130 ms late and the ripple lit after
175
+ * the finger is gone. The two view commands ARE Android-gated (:257,:276): the ripple is a drawable
176
+ * Android animates off the view's pressed state, and the JS responder consumes the touch before
177
+ * Android's own pressed handling ever runs, so without them the background is installed and never
178
+ * moves.
179
+ */
180
+ export const nativeFeedbackRefinement = (node, config) => {
181
+ if (!IS_ANDROID)
182
+ return { ...config, minPressDuration: 0 };
183
+ const hotspot = (event) => {
184
+ dispatchViewCommand(node, 'hotspotUpdate', [
185
+ hotspotAt(event.nativeEvent, 'locationX'),
186
+ hotspotAt(event.nativeEvent, 'locationY'),
187
+ ]);
188
+ };
189
+ return {
190
+ ...config,
191
+ minPressDuration: 0,
192
+ onPressIn(event) {
193
+ // RN's order: hotspot first, so the ripple starts where the finger is rather than at centre.
194
+ hotspot(event);
195
+ dispatchViewCommand(node, 'setPressed', [true]);
196
+ config.onPressIn?.(event);
197
+ },
198
+ onPressMove(event) {
199
+ hotspot(event);
200
+ // RN drops the app's own onPressMove here; ours forwards it, because on a tag that callback
201
+ // is the app's and iOS already delivers it.
202
+ config.onPressMove?.(event);
203
+ },
204
+ onPressOut(event) {
205
+ dispatchViewCommand(node, 'setPressed', [false]);
206
+ config.onPressOut?.(event);
207
+ },
208
+ };
209
+ };
210
+ /**
211
+ * `cloneElement(element, {…})` as a payload fold: the child's own props first, the owner's clone
212
+ * list over them.
213
+ *
214
+ * OVERWRITING WITH `undefined` IS DELIBERATE and is RN's behaviour, not an accident of the spread.
215
+ * React's `cloneElement` assigns every key of its config, so a TNF with no `accessibilityLabel`
216
+ * clears the child's; `fabricProps` skips undefined values, which reproduces that exactly.
217
+ *
218
+ * Pure, as `IPayloadFold` requires — the owner is read, never written.
219
+ */
220
+ function cloneFold(owner, inner) {
221
+ return (props) => {
222
+ const next = {
223
+ ...(inner === undefined ? props : inner(props)),
224
+ };
225
+ // The owner's aria props are on the OWNER, so the engine's own fold at `fabricProps` — which
226
+ // reads the node being committed — never sees them. Run it here over the source bag instead of
227
+ // restating any of `:349-390`'s aria half; `hasAriaAlias` is the same sticky gate `fabricProps`
228
+ // uses, and the fold returns its input by identity when there is nothing to do.
229
+ const source = owner.hasAriaAlias
230
+ ? foldAriaProps(owner.props)
231
+ : owner.props;
232
+ for (const key of CLONED_PROPS)
233
+ next[key] = source[key];
234
+ // :369-372. `onPress` is an OWNED name, so it lives in the stash and never in `props`.
235
+ const disabled = booleanOr(source.disabled);
236
+ next.accessible = accessibleUnlessOptedOut(source);
237
+ next.focusable = resolveTouchableFocusable(booleanOr(source.focusable), appListenerFor(owner, 'press') !== undefined, disabled);
238
+ // :373. RN gives `id` unconditional priority over `nativeID`.
239
+ next.nativeID = stringOr(source.id) ?? stringOr(source.nativeID);
240
+ // :318-323 folded by the engine above, then :324-330: an explicit `disabled` overrides the
241
+ // aria/accessibilityState answer. Same helper every wrapper calls, so both paths agree.
242
+ next.accessibilityState = resolveDisabledAccessibilityState(asAccessibilityState(source.accessibilityState), disabled);
243
+ // :343-348 + :402. `getBackgroundProp` returns null off Android, so nothing is spread there.
244
+ if (IS_ANDROID) {
245
+ Object.assign(next, backgroundProps(asFeedbackBackground(source.background) ?? selectableBackground(), source.useForeground === true));
246
+ }
247
+ return next;
248
+ };
249
+ }
250
+ // Not RN's own list: RN drops these by never cloning them, and a lowered tag has no clone to omit
251
+ // them from — they would ride into the child's payload as keys no ViewConfig declares. Same strip
252
+ // `./pressable`'s fold does for the machine-only half, applied to the owner's bag instead.
253
+ //
254
+ // (`background` / `useForeground` / `disabled` / `hitSlop` never reach the child's props at all —
255
+ // the fold only ever ADDS to the child's own bag — so nothing needs stripping here. Kept as a
256
+ // comment rather than an empty constant so the next reader does not go looking for the strip.)
257
+ // The app's callback, read at dispatch time so a fresh closure per render costs nothing.
258
+ function trampolineFor(owner, name) {
259
+ return (event) => {
260
+ const listener = appListenerFor(owner, name);
261
+ if (typeof listener === 'function')
262
+ listener(event);
263
+ };
264
+ }
265
+ function forwardListener(owner, child, name, wired) {
266
+ setBehaviorListener(child, name, wired ? trampolineFor(owner, name) : undefined);
267
+ }
268
+ // The machine plus the forwarded listeners. Split from adoption because a parked subtree comes back
269
+ // with its child and its fold intact but its machine torn down — `attach` re-runs this, and must
270
+ // NOT re-chain the fold.
271
+ function arm(owner, child) {
272
+ attachPressMachine(child, {
273
+ source: owner,
274
+ refine: nativeFeedbackRefinement,
275
+ });
276
+ for (const name of FORWARDED_LISTENERS) {
277
+ forwardListener(owner, child, name, appListenerFor(owner, name) !== undefined);
278
+ }
279
+ }
280
+ function attach(node) {
281
+ // First attach has no child yet; a re-attach after the sweep does. See `arm`.
282
+ if (node.childHost !== undefined)
283
+ arm(node, node.childHost);
284
+ }
285
+ function detach(node) {
286
+ const child = node.childHost;
287
+ if (child !== undefined)
288
+ detachPressMachine(child);
289
+ }
290
+ function onChildInserted(node, child) {
291
+ const previous = node.childHost;
292
+ if (previous === child)
293
+ return;
294
+ // A replaced child: release the machine still armed on the one that left. `removeChild` clears
295
+ // `childHost` (node.ts), so this only runs when a framework inserts without removing first.
296
+ if (previous !== undefined)
297
+ detachPressMachine(previous);
298
+ node.childHost = child;
299
+ child.payloadFold = cloneFold(node, child.payloadFold);
300
+ // The owner's props were very likely written BEFORE this child existed (React and Solid set props
301
+ // at createInstance), so the fold owes a run even though nothing was written since.
302
+ markPropsDirty(child);
303
+ arm(node, child);
304
+ }
305
+ // `focusable` is a function of a LISTENER, and a listener flip changes no payload by itself — so
306
+ // the commit after it is a no-op and no fold re-runs (`IHostBehavior.onOwnedListenerChange`).
307
+ function onOwnedListenerChange(node, name, wired) {
308
+ const child = node.childHost;
309
+ if (child === undefined)
310
+ return;
311
+ if (FORWARDED_LISTENERS.includes(name)) {
312
+ forwardListener(node, child, name, wired);
313
+ return;
314
+ }
315
+ if (name !== 'press')
316
+ return;
317
+ markPropsDirty(child);
318
+ requestCommitFor(child);
319
+ }
320
+ // Idempotent: an adapter entry may be imported more than once in a bundle.
321
+ export function registerTouchableNativeFeedbackBehavior() {
322
+ const behavior = {
323
+ attach,
324
+ detach,
325
+ onChildInserted,
326
+ onOwnedListenerChange,
327
+ // Owned on the OWNER, which is where the app writes them, and read from there by the machine
328
+ // on the child (`attachPressMachine`'s `source`) and by the trampolines above.
329
+ ownedListeners: [...PRESS_LISTENERS, ...FORWARDED_LISTENERS],
330
+ slotDerived: SLOT_DERIVED,
331
+ };
332
+ registerHostBehavior(TOUCHABLE_NATIVE_FEEDBACK_TAG, behavior);
333
+ }
@@ -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;