@symbiote-native/components 1.0.0 → 3.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.
- package/README.md +11 -13
- package/build/accessibility-props.d.ts +1 -1
- package/build/accessibility-props.js +2 -2
- package/build/behaviors/activity-indicator/index.android.d.ts +1 -0
- package/build/behaviors/activity-indicator/index.android.js +16 -0
- package/build/behaviors/activity-indicator/index.d.ts +3 -0
- package/build/behaviors/activity-indicator/index.ios.d.ts +1 -0
- package/build/behaviors/activity-indicator/index.ios.js +14 -0
- package/build/behaviors/activity-indicator/index.js +5 -0
- package/build/behaviors/activity-indicator/shared.d.ts +18 -0
- package/build/behaviors/activity-indicator/shared.js +120 -0
- package/build/behaviors/button.d.ts +13 -0
- package/build/behaviors/button.js +340 -0
- package/build/behaviors/image-background.d.ts +3 -0
- package/build/behaviors/image-background.js +155 -0
- package/build/behaviors/image.d.ts +1 -2
- package/build/behaviors/image.js +25 -106
- package/build/behaviors/input-accessory-view.d.ts +1 -2
- package/build/behaviors/input-accessory-view.js +49 -55
- package/build/behaviors/pressable.d.ts +59 -1
- package/build/behaviors/pressable.js +142 -96
- package/build/behaviors/refresh-control.d.ts +2 -0
- package/build/behaviors/refresh-control.js +96 -0
- package/build/behaviors/scroll-view/index.android.d.ts +1 -0
- package/build/behaviors/scroll-view/index.android.js +40 -0
- package/build/behaviors/scroll-view/index.d.ts +3 -0
- package/build/behaviors/scroll-view/index.ios.d.ts +1 -0
- package/build/behaviors/scroll-view/index.ios.js +10 -0
- package/build/behaviors/scroll-view/index.js +8 -0
- package/build/behaviors/scroll-view/responder.d.ts +4 -0
- package/build/behaviors/scroll-view/responder.js +202 -0
- package/build/behaviors/scroll-view/shared.d.ts +9 -0
- package/build/behaviors/scroll-view/shared.js +291 -0
- package/build/behaviors/scroll-view/sticky.d.ts +18 -0
- package/build/behaviors/scroll-view/sticky.js +581 -0
- package/build/behaviors/switch.d.ts +1 -1
- package/build/behaviors/switch.js +49 -88
- package/build/behaviors/text-input.d.ts +2 -2
- package/build/behaviors/text-input.js +244 -104
- package/build/behaviors/touchable-highlight.d.ts +9 -0
- package/build/behaviors/touchable-highlight.js +205 -0
- package/build/behaviors/touchable-native-feedback.d.ts +20 -0
- package/build/behaviors/touchable-native-feedback.js +254 -0
- package/build/behaviors/touchable-opacity.d.ts +12 -0
- package/build/behaviors/touchable-opacity.js +239 -0
- package/build/behaviors/touchable-without-feedback.d.ts +2 -0
- package/build/behaviors/touchable-without-feedback.js +231 -0
- package/build/component-names/index.android.js +31 -25
- package/build/component-names/index.ios.js +28 -23
- package/build/component-names/shared.d.ts +2 -1
- package/build/component-names/shared.js +16 -6
- package/build/descriptor.js +4 -4
- package/build/index.d.ts +26 -26
- package/build/index.js +56 -27
- package/build/register.d.ts +1 -0
- package/build/register.js +55 -0
- package/build/resolve-intrinsic.js +3 -9
- package/build/scroll-view-commands.d.ts +1 -5
- package/build/scroll-view-commands.js +23 -85
- package/build/state/flat-list.d.ts +2 -2
- package/build/state/flat-list.js +10 -2
- package/build/state/pressable.d.ts +6 -1
- package/build/state/pressable.js +63 -28
- package/build/state/section-list.d.ts +2 -0
- package/build/state/section-list.js +14 -7
- package/build/state/text-input.d.ts +10 -40
- package/build/state/text-input.js +17 -186
- package/build/state/touchable.d.ts +1 -0
- package/build/state/touchable.js +11 -8
- package/build/state/virtualized-list-reducer.d.ts +2 -2
- package/build/state/virtualized-list.d.ts +6 -6
- package/build/state/virtualized-list.js +71 -37
- package/build/text-props.d.ts +0 -8
- package/build/text-props.js +14 -25
- package/build/view/render-button.d.ts +11 -4
- package/build/view/render-button.js +74 -22
- package/build/view/render-image/index.d.ts +14 -1
- package/build/view/render-image/index.js +22 -147
- package/build/view/render-input-accessory-view.d.ts +1 -5
- package/build/view/render-input-accessory-view.js +26 -48
- package/build/view/render-keyboard-avoiding-view.d.ts +7 -1
- package/build/view/render-keyboard-avoiding-view.js +40 -1
- package/build/view/render-modal.d.ts +1 -1
- package/build/view/render-modal.js +18 -8
- package/build/view/render-pressable/index.d.ts +3 -0
- package/build/view/render-pressable/index.js +28 -0
- package/build/view/render-scroll-view.d.ts +1 -4
- package/build/view/render-scroll-view.js +17 -54
- package/build/view/render-switch.d.ts +4 -15
- package/build/view/render-switch.js +4 -42
- package/build/view/render-touchable-highlight.d.ts +1 -0
- package/build/view/render-touchable-native-feedback.d.ts +19 -1
- package/build/view/render-touchable-native-feedback.js +34 -9
- package/host-primitives.cjs +178 -280
- package/host-primitives.d.cts +0 -3
- package/package.json +8 -21
- package/build/fold-host-bag.d.ts +0 -15
- package/build/fold-host-bag.js +0 -99
- package/build/state-style.d.ts +0 -15
- package/build/state-style.js +0 -47
- package/build/view/render-activity-indicator.d.ts +0 -25
- package/build/view/render-activity-indicator.js +0 -88
- package/build/view/render-image-background.d.ts +0 -9
- package/build/view/render-image-background.js +0 -48
- package/build/view/render-text-input.d.ts +0 -11
- package/build/view/render-text-input.js +0 -39
- package/lowering-fixtures.cjs +0 -259
- package/lowering-fixtures.d.cts +0 -17
- package/specialize-state-style.cjs +0 -219
- package/specialize-state-style.d.cts +0 -15
|
@@ -0,0 +1,205 @@
|
|
|
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 SHIPPED. 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`). This tag folds both onto the ONE node instead, in
|
|
8
|
+
// `foldTouchableHighlightUnderlay` (`SymbioteFabricProps.cpp`) now — see that rule's own header.
|
|
9
|
+
//
|
|
10
|
+
// KNOWN GAP, and it predates both this port and the fix it reverts. Composing `opacity` onto the
|
|
11
|
+
// SAME node as the underlay's `backgroundColor` fades the underlay itself, so `underlayColor:
|
|
12
|
+
// 'black'` paints grey rather than black — every adapter's wrapper shipped this, and the rule's own
|
|
13
|
+
// header above says so on purpose ("The port keeps that, it does not reopen it"). A 2026-09-15 fix
|
|
14
|
+
// (fd39750b) closed it pointwise, in JS, on this one tag; this revert returns to the shared
|
|
15
|
+
// (buggy) behavior every adapter already had, which is correct for THIS merge — a merge that also
|
|
16
|
+
// changes behavior is unattributable.
|
|
17
|
+
//
|
|
18
|
+
// The reason the old header gave for not splitting it — "needs a child to target and a framework
|
|
19
|
+
// component holding an opaque children slot cannot reach one safely" — no longer holds: that was
|
|
20
|
+
// true of JS wrappers, not of the engine. The DESCENDANT seam this needs already exists: a rule
|
|
21
|
+
// keyed on `IOwner.tagName`, the same shape `foldCloneOntoChild` uses to reach a
|
|
22
|
+
// TouchableNativeFeedback child's own `fabricProps()` call and write onto ITS payload — the write
|
|
23
|
+
// `IFirstChild` cannot do, since that seam only reads a child, from the PARENT's own call.
|
|
24
|
+
//
|
|
25
|
+
// TODO: what actually blocks it is one missing field. `IOwner` carries `{ props, tagName,
|
|
26
|
+
// hasPressListener }`; the child would need the owner's `underlayShown` too (currently only on
|
|
27
|
+
// `ISelf`, the node's own state) to decide whether to paint at all. Closing this is a new
|
|
28
|
+
// descendant-keyed rule plus that one field on `IOwner`, not a `foldTouchableHighlightUnderlay`
|
|
29
|
+
// rewrite.
|
|
30
|
+
//
|
|
31
|
+
// WHAT IS SHARED AND WHAT IS NEW. The underlay show/hide state machine
|
|
32
|
+
// (createHighlightUnderlayHandlers/createHighlightUnderlayRuntime, `../state/touchable`) is already
|
|
33
|
+
// framework-agnostic — every wrapper already calls it. New here is only WHERE `shown` lives (a
|
|
34
|
+
// WeakMap keyed by node, the same shape `./touchable-opacity`'s `states` map uses) and how a flip
|
|
35
|
+
// reaches Fabric: `markPropsDirty` + `requestCommitFor`, the same primitives `./pressable`'s
|
|
36
|
+
// `setPressed` composer uses to re-fold after an internal state change — NOT
|
|
37
|
+
// `setAnimatedBehaviorStyle`, which exists for a continuously-driven `AnimatedValue` and would be
|
|
38
|
+
// the wrong tool for a discrete boolean swap with no easing.
|
|
39
|
+
//
|
|
40
|
+
// The press machine is `./pressable`'s, composed through `createPressBehavior` — one node may hold
|
|
41
|
+
// exactly one press machine, so this tag must not also register the plain `pressable` behavior.
|
|
42
|
+
//
|
|
43
|
+
// REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter's entry does
|
|
44
|
+
// a bare `import './register';` that the barrel does not re-export.
|
|
45
|
+
//
|
|
46
|
+
// TODO(rn-parity, low priority): `TouchableHighlight.js:206-232` also gates the underlay show/hide
|
|
47
|
+
// on `onFocus`/`onBlur` (TV remote) and skips its own press-triggered show/hide-after-delay
|
|
48
|
+
// entirely when `Platform.isTV`. Neither is wired here. Deliberately not implemented — dead on
|
|
49
|
+
// every device this project targets (no tvOS build). Audit skill, "Found, NOT fixed: TV
|
|
50
|
+
// (Platform.isTV) focus/blur feedback on TouchableOpacity/Highlight".
|
|
51
|
+
import { markPropsDirty, registerHostBehavior, requestCommitFor, setNodeUnderlayShown, propOf, } from '@symbiote-native/engine';
|
|
52
|
+
import { resolveButtonDisabled } from '../view/render-button.js';
|
|
53
|
+
import { asAccessibilityState, booleanOr, createPressBehavior, } from './pressable.js';
|
|
54
|
+
import { createHighlightUnderlayHandlers, createHighlightUnderlayRuntime, hasTouchablePressHandler, } from '../state/touchable.js';
|
|
55
|
+
export const TOUCHABLE_HIGHLIGHT_TAG = 'touchable-highlight';
|
|
56
|
+
// TouchableHighlight.js:194-197 — `disabled ?? accessibilityState.disabled` (RN omits aria-disabled
|
|
57
|
+
// here, unlike Opacity/Button/NativeFeedback — an upstream inconsistency this matches rather than
|
|
58
|
+
// "fixes"). Without it, `accessibilityState={{disabled: true}}` alone greys the label but a press
|
|
59
|
+
// still fires here.
|
|
60
|
+
const touchableHighlightDisabled = props => resolveButtonDisabled(booleanOr(props.disabled), undefined, asAccessibilityState(props.accessibilityState));
|
|
61
|
+
const states = new WeakMap();
|
|
62
|
+
function numberOr(value, fallback) {
|
|
63
|
+
return typeof value === 'number' ? value : fallback;
|
|
64
|
+
}
|
|
65
|
+
// Runs once per GESTURE, matching `./touchable-opacity`'s `refine` — the config it reads is
|
|
66
|
+
// whatever the props hold when a finger lands, and the underlay handlers it builds read `shown`
|
|
67
|
+
// through the closure below rather than owning it, so a re-arm reads the current props.
|
|
68
|
+
const refine = (node, config) => {
|
|
69
|
+
const state = states.get(node);
|
|
70
|
+
if (state === undefined)
|
|
71
|
+
return config;
|
|
72
|
+
const hasPressHandler = hasTouchablePressHandler({
|
|
73
|
+
onPress: config.onPress,
|
|
74
|
+
onPressIn: config.onPressIn,
|
|
75
|
+
onPressOut: config.onPressOut,
|
|
76
|
+
onLongPress: config.onLongPress,
|
|
77
|
+
});
|
|
78
|
+
const underlay = createHighlightUnderlayHandlers({
|
|
79
|
+
delayPressOut: numberOr(propOf(node, 'delayPressOut'), 0),
|
|
80
|
+
hasPressHandler,
|
|
81
|
+
testOnlyPressed: propOf(node, 'testOnly_pressed') === true,
|
|
82
|
+
schedule: (callback, ms) => {
|
|
83
|
+
const id = setTimeout(() => {
|
|
84
|
+
state.timers.delete(id);
|
|
85
|
+
callback();
|
|
86
|
+
}, ms);
|
|
87
|
+
state.timers.add(id);
|
|
88
|
+
return () => {
|
|
89
|
+
clearTimeout(id);
|
|
90
|
+
state.timers.delete(id);
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
}, state.runtime, {
|
|
94
|
+
setShown: (shown) => {
|
|
95
|
+
if (state.shown === shown)
|
|
96
|
+
return;
|
|
97
|
+
state.shown = shown;
|
|
98
|
+
// THE BIT, and nothing else. No style is resolved here: what a showing underlay looks like
|
|
99
|
+
// is `foldTouchableHighlightUnderlay` in the engine, which reads the same two props it
|
|
100
|
+
// already strips from the payload. `markPropsDirty` is still owed — the op marks the host's
|
|
101
|
+
// node, and this marks the JS side's so the commit walk visits it.
|
|
102
|
+
setNodeUnderlayShown(node, shown);
|
|
103
|
+
markPropsDirty(node);
|
|
104
|
+
requestCommitFor(node);
|
|
105
|
+
},
|
|
106
|
+
onShowUnderlay: () => {
|
|
107
|
+
const onShowUnderlay = propOf(node, 'onShowUnderlay');
|
|
108
|
+
if (typeof onShowUnderlay === 'function')
|
|
109
|
+
onShowUnderlay();
|
|
110
|
+
},
|
|
111
|
+
onHideUnderlay: () => {
|
|
112
|
+
const onHideUnderlay = propOf(node, 'onHideUnderlay');
|
|
113
|
+
if (typeof onHideUnderlay === 'function')
|
|
114
|
+
onHideUnderlay();
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
...config,
|
|
119
|
+
// TouchableHighlight.js:203, verbatim: RN hands Pressability a 0 floor here too — what holds
|
|
120
|
+
// the underlay visible is the hold TIMER above, not a press-duration floor.
|
|
121
|
+
minPressDuration: 0,
|
|
122
|
+
onPress: (event) => {
|
|
123
|
+
underlay.handlePress(event);
|
|
124
|
+
config.onPress?.(event);
|
|
125
|
+
},
|
|
126
|
+
onPressIn: (event) => {
|
|
127
|
+
underlay.handlePressIn(event);
|
|
128
|
+
config.onPressIn?.(event);
|
|
129
|
+
},
|
|
130
|
+
onPressOut: (event) => {
|
|
131
|
+
underlay.handlePressOut(event);
|
|
132
|
+
config.onPressOut?.(event);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
// THE UNDERLAY FOLD LEFT THIS FILE ON 2026-09-18, and with it the last `payloadFold` on this tag —
|
|
137
|
+
// this behavior now costs ZERO trips into JS per commit, down from one on every commit it was dirty
|
|
138
|
+
// in (which for a touchable is FIVE at mount alone, because the opacity settle re-commits it).
|
|
139
|
+
//
|
|
140
|
+
// THE NOTE IT REPLACES SAID THE UNDERLAY WAS "the genuine unportable article" BECAUSE IT IS BUILT
|
|
141
|
+
// FROM LIVE PRESS STATE. Half right, and the half it got wrong is the reusable part: `shown` really
|
|
142
|
+
// does flip mid-gesture and really is JS's, but the RULE was never made of it. Of four inputs, three
|
|
143
|
+
// were already portable — `underlayColor` and `activeOpacity` are ordinary props (ones the engine
|
|
144
|
+
// ALREADY strips), and `_hasPressHandler` is listener EXISTENCE, which has crossed since
|
|
145
|
+
// `OP_SET_OWNED_LISTENER`. The fourth is one bit. "JS holds it" was never the same claim as "only JS
|
|
146
|
+
// can compute it", and this is the third time that distinction has moved a rule.
|
|
147
|
+
//
|
|
148
|
+
// WHAT CROSSES AND WHAT DOES NOT. `setNodeUnderlayShown` sends the bit on a flip — twice a tap. The
|
|
149
|
+
// hold timer, the `press`-then-`pressOut` ordering, the re-arm on a second tap and the
|
|
150
|
+
// `onShowUnderlay` / `onHideUnderlay` callbacks all stay here, where Pressability is, because they
|
|
151
|
+
// run at gesture rate and call into app code. That is the browser's line too: a UA paints `:active`,
|
|
152
|
+
// the page decides what a click means.
|
|
153
|
+
//
|
|
154
|
+
// `focusable` left earlier the same day and carried a bug out with it — it read `props.disabled` off
|
|
155
|
+
// the BAG, which the engine's pressable rule strips, so every DISABLED highlight stayed in the focus
|
|
156
|
+
// order. One rule serves both touchable tags now (`foldPressableProps`), so there is no second copy
|
|
157
|
+
// to drift; pinned in `core/engine/cpp/tests/js/touchable-focusable-payload.itest.ts`.
|
|
158
|
+
//
|
|
159
|
+
// The underlay's own contract: `core/engine/cpp/tests/js/touchable-highlight-underlay.itest.ts`.
|
|
160
|
+
// A listener flip changes no payload by itself, so the commit after it is a no-op and no fold
|
|
161
|
+
// re-runs (`IHostBehavior.onOwnedListenerChange`) — same reason `./touchable-opacity` carries this.
|
|
162
|
+
function onOwnedListenerChange(node, name) {
|
|
163
|
+
if (name !== 'press')
|
|
164
|
+
return;
|
|
165
|
+
markPropsDirty(node);
|
|
166
|
+
requestCommitFor(node);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The behavior as PARTS, mirroring `./touchable-opacity`'s shape — a tag that is a
|
|
170
|
+
* TouchableHighlight plus something composes this instead of re-implementing the underlay.
|
|
171
|
+
*/
|
|
172
|
+
export function createTouchableHighlightBehavior(disabledOf) {
|
|
173
|
+
const machine = createPressBehavior(refine, disabledOf);
|
|
174
|
+
return {
|
|
175
|
+
...machine,
|
|
176
|
+
attach(node) {
|
|
177
|
+
states.set(node, {
|
|
178
|
+
shown: false,
|
|
179
|
+
runtime: createHighlightUnderlayRuntime(),
|
|
180
|
+
timers: new Set(),
|
|
181
|
+
});
|
|
182
|
+
machine.attach(node);
|
|
183
|
+
},
|
|
184
|
+
detach(node) {
|
|
185
|
+
machine.detach(node);
|
|
186
|
+
const state = states.get(node);
|
|
187
|
+
if (state === undefined)
|
|
188
|
+
return;
|
|
189
|
+
for (const id of state.timers)
|
|
190
|
+
clearTimeout(id);
|
|
191
|
+
state.timers.clear();
|
|
192
|
+
states.delete(node);
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
// Idempotent: an adapter entry may be imported more than once in a bundle.
|
|
197
|
+
export function registerTouchableHighlightBehavior() {
|
|
198
|
+
const touchable = createTouchableHighlightBehavior(touchableHighlightDisabled);
|
|
199
|
+
// Spread whole: the tag used to override `attach` purely to bind a per-node `payloadFold`, and
|
|
200
|
+
// with the underlay rule in the engine there is nothing left to add.
|
|
201
|
+
registerHostBehavior(TOUCHABLE_HIGHLIGHT_TAG, {
|
|
202
|
+
...touchable,
|
|
203
|
+
onOwnedListenerChange,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
@@ -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,254 @@
|
|
|
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.
|
|
57
|
+
import { SLOT_DERIVED_ALL, appListenerFor, dispatchViewCommand, markPropsDirty, Platform, registerHostBehavior, requestCommitFor, setBehaviorListener, } from '@symbiote-native/engine';
|
|
58
|
+
import { resolveButtonDisabled } from '../view/render-button.js';
|
|
59
|
+
import { asAccessibilityState, attachPressMachine, booleanOr, detachPressMachine, } from './pressable.js';
|
|
60
|
+
export const TOUCHABLE_NATIVE_FEEDBACK_TAG = 'touchable-native-feedback';
|
|
61
|
+
// TouchableNativeFeedback.js:214-221 — `disabled != null ? disabled : (aria-disabled ??
|
|
62
|
+
// accessibilityState.disabled)`, read by the OWNER's Pressability config (:213), same three-way
|
|
63
|
+
// answer Button/Opacity resolve. The clone's own `accessibilityState`/`focusable` already use the
|
|
64
|
+
// RAW `disabled` prop correctly (RN does too, :311-321,:369-371) — only the press machine, which
|
|
65
|
+
// lives on the CHILD and reads the OWNER, was missing this.
|
|
66
|
+
const touchableNativeFeedbackDisabled = props => resolveButtonDisabled(booleanOr(props.disabled), booleanOr(props['aria-disabled']), asAccessibilityState(props.accessibilityState));
|
|
67
|
+
// Read once, like `./button`'s: the platform cannot change under a running app.
|
|
68
|
+
const IS_ANDROID = Platform.OS === 'android';
|
|
69
|
+
// WHAT THE OWNER'S WRITES DIRTY, and it is EVERY name rather than a list of thirty.
|
|
70
|
+
//
|
|
71
|
+
// RN's clone list (`TouchableNativeFeedback.js:349-390`) lived here until 2026-09-18 as
|
|
72
|
+
// `CLONED_PROPS` + `DERIVED_FROM` + `ARIA_ALIAS_KEYS`, feeding `slotDerived`. The clone itself is
|
|
73
|
+
// `foldCloneOntoChild` in `SymbioteFabricProps.cpp`, so the list had stopped being the rule and
|
|
74
|
+
// become a MIRROR of `kNativeFeedbackClonedKeys` — two lists that must agree, failing silently (the
|
|
75
|
+
// clone goes stale on the one prop a list forgot) when they drift.
|
|
76
|
+
//
|
|
77
|
+
// `SLOT_DERIVED_ALL` is both the honest spelling and the cheaper one to keep: a `cloneElement` owner
|
|
78
|
+
// re-clones on every render whatever changed, so it never derived its slot from a NAMED set in the
|
|
79
|
+
// first place. The cost is a false dirty on an owner prop the clone does not carry, and for this tag
|
|
80
|
+
// that is nearly empty — its owner is an anchor whose props reach Fabric nowhere else, so every name
|
|
81
|
+
// it holds is either cloned or consumed by the press machine.
|
|
82
|
+
const SLOT_DERIVED = [SLOT_DERIVED_ALL];
|
|
83
|
+
// The two RN clones as EVENTS rather than props (:386-387). Owned, so the app's callback stashes on
|
|
84
|
+
// the owner and a trampoline installed on the child reads it at dispatch time — which keeps a fresh
|
|
85
|
+
// closure per render free, exactly as `ownedListeners` intends.
|
|
86
|
+
//
|
|
87
|
+
// Both are Fabric BOOLEAN-GATED events (`.claude/rules/fabric-boolean-event-gates.md`), so the
|
|
88
|
+
// trampoline goes in only while the app has one wired: `setBehaviorListener` writes and clears the
|
|
89
|
+
// flag with it, and installing eagerly would light the gate on every TNF child in the tree.
|
|
90
|
+
const FORWARDED_LISTENERS = [
|
|
91
|
+
'layout',
|
|
92
|
+
'accessibilityAction',
|
|
93
|
+
];
|
|
94
|
+
const PRESS_LISTENERS = [
|
|
95
|
+
'press',
|
|
96
|
+
'pressIn',
|
|
97
|
+
'pressOut',
|
|
98
|
+
'pressMove',
|
|
99
|
+
'longPress',
|
|
100
|
+
'startShouldSetResponder',
|
|
101
|
+
'responderMove',
|
|
102
|
+
'responderTerminationRequest',
|
|
103
|
+
];
|
|
104
|
+
// `asFeedbackBackground` went with the fold, and `backgroundProps` — the slot pick it fed — followed
|
|
105
|
+
// on 2026-09-18 as the orphan it had become. The first narrowed the app's `background` dict on its
|
|
106
|
+
// discriminant; the C++ rule asks only whether the value is an OBJECT and copies it into the slot,
|
|
107
|
+
// because the four factories that produce it (`render-touchable-native-feedback.ts`) are ours and
|
|
108
|
+
// the payload is not a place to re-validate what a typed factory already built.
|
|
109
|
+
// TouchableNativeFeedback.js:280 — `locationX ?? 0`. The bag is raw Fabric payload, so guard.
|
|
110
|
+
function hotspotAt(nativeEvent, key) {
|
|
111
|
+
const value = nativeEvent[key];
|
|
112
|
+
return typeof value === 'number' ? value : 0;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* TNF's own Pressability config, applied to whatever node carries the responder.
|
|
116
|
+
*
|
|
117
|
+
* SHARED WITH `./button`, which is what this file's arrival makes possible. Button's Android arm
|
|
118
|
+
* held a private copy with a comment saying a future `touchable-native-feedback` tag could not
|
|
119
|
+
* reuse it "while the wrappers wrap" — the tag is here now and the responder node is a parameter
|
|
120
|
+
* either way, so the copy is gone.
|
|
121
|
+
*
|
|
122
|
+
* `minPressDuration: 0` is UNCONDITIONAL in RN (:226), not Android-gated: TNF has no feedback
|
|
123
|
+
* machine of its own to hold, and the press machine's 130 ms deactivation floor would only defer
|
|
124
|
+
* `onPressOut` — which on Android leaves `setPressed(false)` ~130 ms late and the ripple lit after
|
|
125
|
+
* the finger is gone. The two view commands ARE Android-gated (:257,:276): the ripple is a drawable
|
|
126
|
+
* Android animates off the view's pressed state, and the JS responder consumes the touch before
|
|
127
|
+
* Android's own pressed handling ever runs, so without them the background is installed and never
|
|
128
|
+
* moves.
|
|
129
|
+
*/
|
|
130
|
+
export const nativeFeedbackRefinement = (node, config) => {
|
|
131
|
+
if (!IS_ANDROID)
|
|
132
|
+
return { ...config, minPressDuration: 0 };
|
|
133
|
+
const hotspot = (event) => {
|
|
134
|
+
dispatchViewCommand(node, 'hotspotUpdate', [
|
|
135
|
+
hotspotAt(event.nativeEvent, 'locationX'),
|
|
136
|
+
hotspotAt(event.nativeEvent, 'locationY'),
|
|
137
|
+
]);
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
140
|
+
...config,
|
|
141
|
+
minPressDuration: 0,
|
|
142
|
+
onPressIn(event) {
|
|
143
|
+
// RN's order: hotspot first, so the ripple starts where the finger is rather than at centre.
|
|
144
|
+
hotspot(event);
|
|
145
|
+
dispatchViewCommand(node, 'setPressed', [true]);
|
|
146
|
+
config.onPressIn?.(event);
|
|
147
|
+
},
|
|
148
|
+
onPressMove(event) {
|
|
149
|
+
hotspot(event);
|
|
150
|
+
// RN drops the app's own onPressMove here; ours forwards it, because on a tag that callback
|
|
151
|
+
// is the app's and iOS already delivers it.
|
|
152
|
+
config.onPressMove?.(event);
|
|
153
|
+
},
|
|
154
|
+
onPressOut(event) {
|
|
155
|
+
dispatchViewCommand(node, 'setPressed', [false]);
|
|
156
|
+
config.onPressOut?.(event);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
// `cloneFold` LEFT THIS FILE ON 2026-09-18 — it is `foldCloneOntoChild` in
|
|
161
|
+
// `SymbioteFabricProps.cpp`, and the seam it needed is the first rule keyed on the PARENT'S tag
|
|
162
|
+
// rather than on the node's own (`IOwner`). The child of a TNF is whatever the app wrote, usually a
|
|
163
|
+
// plain `<view>` with no tag at all, so nothing self-keyed could ever have reached it.
|
|
164
|
+
//
|
|
165
|
+
// What it cost to have had here: one JSI round trip per touchable per commit over an eighteen-key
|
|
166
|
+
// bag, and `tag-rule-cost.itest.ts` prices a fold by what it MARSHALS rather than by what it does.
|
|
167
|
+
//
|
|
168
|
+
// Contract: `core/engine/cpp/tests/js/clone-onto-child-payload.itest.ts`.
|
|
169
|
+
// Not RN's own list: RN drops these by never cloning them, and a tag has no clone to omit
|
|
170
|
+
// them from — they would ride into the child's payload as keys no ViewConfig declares. Same strip
|
|
171
|
+
// `./pressable`'s fold does for the machine-only half, applied to the owner's bag instead.
|
|
172
|
+
//
|
|
173
|
+
// (`background` / `useForeground` / `disabled` / `hitSlop` never reach the child's props at all —
|
|
174
|
+
// the fold only ever ADDS to the child's own bag — so nothing needs stripping here. Kept as a
|
|
175
|
+
// comment rather than an empty constant so the next reader does not go looking for the strip.)
|
|
176
|
+
// The app's callback, read at dispatch time so a fresh closure per render costs nothing.
|
|
177
|
+
function trampolineFor(owner, name) {
|
|
178
|
+
return (event) => {
|
|
179
|
+
const listener = appListenerFor(owner, name);
|
|
180
|
+
if (typeof listener === 'function')
|
|
181
|
+
listener(event);
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function forwardListener(owner, child, name, wired) {
|
|
185
|
+
setBehaviorListener(child, name, wired ? trampolineFor(owner, name) : undefined);
|
|
186
|
+
}
|
|
187
|
+
// The machine plus the forwarded listeners. Split from adoption because a parked subtree comes back
|
|
188
|
+
// with its child and its fold intact but its machine torn down — `attach` re-runs this, and must
|
|
189
|
+
// NOT re-chain the fold.
|
|
190
|
+
function arm(owner, child) {
|
|
191
|
+
attachPressMachine(child, {
|
|
192
|
+
source: owner,
|
|
193
|
+
refine: nativeFeedbackRefinement,
|
|
194
|
+
disabledOf: touchableNativeFeedbackDisabled,
|
|
195
|
+
});
|
|
196
|
+
for (const name of FORWARDED_LISTENERS) {
|
|
197
|
+
forwardListener(owner, child, name, appListenerFor(owner, name) !== undefined);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function attach(node) {
|
|
201
|
+
// First attach has no child yet; a re-attach after the sweep does. See `arm`.
|
|
202
|
+
if (node.childHost !== undefined)
|
|
203
|
+
arm(node, node.childHost);
|
|
204
|
+
}
|
|
205
|
+
function detach(node) {
|
|
206
|
+
const child = node.childHost;
|
|
207
|
+
if (child !== undefined)
|
|
208
|
+
detachPressMachine(child);
|
|
209
|
+
}
|
|
210
|
+
function onChildInserted(node, child) {
|
|
211
|
+
const previous = node.childHost;
|
|
212
|
+
if (previous === child)
|
|
213
|
+
return;
|
|
214
|
+
// A replaced child: release the machine still armed on the one that left. `removeChild` clears
|
|
215
|
+
// `childHost` (node.ts), so this only runs when a framework inserts without removing first.
|
|
216
|
+
if (previous !== undefined)
|
|
217
|
+
detachPressMachine(previous);
|
|
218
|
+
node.childHost = child;
|
|
219
|
+
// The owner's props were very likely written BEFORE this child existed (React and Solid set props
|
|
220
|
+
// at createInstance), so the CLONE owes a run even though nothing was written since. Still owed
|
|
221
|
+
// now that the rule is in C++ and no fold is chained here: the rule runs on the child's commit,
|
|
222
|
+
// and a child nothing dirtied has no commit.
|
|
223
|
+
markPropsDirty(child);
|
|
224
|
+
arm(node, child);
|
|
225
|
+
}
|
|
226
|
+
// `focusable` is a function of a LISTENER, and a listener flip changes no payload by itself — so
|
|
227
|
+
// the commit after it is a no-op and no fold re-runs (`IHostBehavior.onOwnedListenerChange`).
|
|
228
|
+
function onOwnedListenerChange(node, name, wired) {
|
|
229
|
+
const child = node.childHost;
|
|
230
|
+
if (child === undefined)
|
|
231
|
+
return;
|
|
232
|
+
if (FORWARDED_LISTENERS.includes(name)) {
|
|
233
|
+
forwardListener(node, child, name, wired);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (name !== 'press')
|
|
237
|
+
return;
|
|
238
|
+
markPropsDirty(child);
|
|
239
|
+
requestCommitFor(child);
|
|
240
|
+
}
|
|
241
|
+
// Idempotent: an adapter entry may be imported more than once in a bundle.
|
|
242
|
+
export function registerTouchableNativeFeedbackBehavior() {
|
|
243
|
+
const behavior = {
|
|
244
|
+
attach,
|
|
245
|
+
detach,
|
|
246
|
+
onChildInserted,
|
|
247
|
+
onOwnedListenerChange,
|
|
248
|
+
// Owned on the OWNER, which is where the app writes them, and read from there by the machine
|
|
249
|
+
// on the child (`attachPressMachine`'s `source`) and by the trampolines above.
|
|
250
|
+
ownedListeners: [...PRESS_LISTENERS, ...FORWARDED_LISTENERS],
|
|
251
|
+
slotDerived: SLOT_DERIVED,
|
|
252
|
+
};
|
|
253
|
+
registerHostBehavior(TOUCHABLE_NATIVE_FEEDBACK_TAG, behavior);
|
|
254
|
+
}
|
|
@@ -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;
|