@symbiote-native/components 2.0.0 → 3.0.1
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 +8 -9
- package/build/accessibility-props.d.ts +1 -1
- package/build/accessibility-props.js +2 -2
- package/build/behaviors/activity-indicator/shared.js +41 -70
- package/build/behaviors/button.d.ts +11 -0
- package/build/behaviors/button.js +101 -89
- package/build/behaviors/image-background.d.ts +1 -0
- package/build/behaviors/image-background.js +97 -81
- package/build/behaviors/image.d.ts +0 -1
- package/build/behaviors/image.js +24 -105
- package/build/behaviors/input-accessory-view.d.ts +0 -1
- package/build/behaviors/input-accessory-view.js +47 -53
- package/build/behaviors/pressable.d.ts +1 -1
- package/build/behaviors/pressable.js +89 -100
- package/build/behaviors/refresh-control.js +15 -2
- package/build/behaviors/scroll-view/index.android.js +25 -37
- package/build/behaviors/scroll-view/index.d.ts +1 -0
- package/build/behaviors/scroll-view/index.js +3 -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 +1 -3
- package/build/behaviors/scroll-view/shared.js +95 -95
- package/build/behaviors/scroll-view/sticky.d.ts +1 -0
- package/build/behaviors/scroll-view/sticky.js +62 -49
- package/build/behaviors/switch.js +43 -86
- package/build/behaviors/text-input.js +219 -107
- package/build/behaviors/touchable-highlight.js +74 -61
- package/build/behaviors/touchable-native-feedback.js +43 -122
- package/build/behaviors/touchable-opacity.js +71 -59
- package/build/behaviors/touchable-without-feedback.js +35 -100
- package/build/component-names/index.android.js +7 -11
- package/build/component-names/index.ios.js +0 -7
- package/build/component-names/shared.d.ts +1 -1
- package/build/index.d.ts +15 -21
- package/build/index.js +21 -28
- package/build/resolve-intrinsic.js +3 -9
- package/build/scroll-view-commands.d.ts +1 -9
- package/build/scroll-view-commands.js +13 -74
- 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 +7 -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 +1 -29
- package/build/view/render-button.js +44 -81
- 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 +15 -5
- package/build/view/render-pressable/index.d.ts +1 -0
- package/build/view/render-pressable/index.js +4 -0
- package/build/view/render-scroll-view.d.ts +0 -4
- package/build/view/render-scroll-view.js +3 -49
- package/build/view/render-switch.d.ts +0 -14
- 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 +0 -1
- package/build/view/render-touchable-native-feedback.js +15 -9
- package/host-primitives.cjs +49 -203
- package/host-primitives.d.cts +0 -1
- package/package.json +3 -7
- package/build/fold-host-bag.d.ts +0 -15
- package/build/fold-host-bag.js +0 -99
- package/build/view/render-text-input.d.ts +0 -11
- package/build/view/render-text-input.js +0 -39
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
// TouchableHighlight as an ENGINE-NODE behavior, so it can be an intrinsic tag instead of a
|
|
2
2
|
// framework component (`.claude/rules/host-primitive-tier.md`, tier 2).
|
|
3
3
|
//
|
|
4
|
-
// ONE NODE, THE SAME SIMPLIFICATION EVERY WRAPPER ALREADY
|
|
4
|
+
// ONE NODE, THE SAME SIMPLIFICATION EVERY WRAPPER ALREADY SHIPPED. RN's own TouchableHighlight
|
|
5
5
|
// renders a container View (the responder, the underlay backgroundColor, the whole accessibility
|
|
6
6
|
// fold) and CLONES an extra opacity style onto its single child (TouchableHighlight.js:281-320,
|
|
7
|
-
// `_createExtraStyles` + `cloneElement`)
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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.
|
|
15
30
|
//
|
|
16
31
|
// WHAT IS SHARED AND WHAT IS NEW. The underlay show/hide state machine
|
|
17
32
|
// (createHighlightUnderlayHandlers/createHighlightUnderlayRuntime, `../state/touchable`) is already
|
|
@@ -27,19 +42,26 @@
|
|
|
27
42
|
//
|
|
28
43
|
// REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter's entry does
|
|
29
44
|
// a bare `import './register';` that the barrel does not re-export.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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';
|
|
33
54
|
import { createHighlightUnderlayHandlers, createHighlightUnderlayRuntime, hasTouchablePressHandler, } from '../state/touchable.js';
|
|
34
|
-
import { resolveHighlightExtraStyles } from '../view/render-touchable-highlight.js';
|
|
35
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));
|
|
36
61
|
const states = new WeakMap();
|
|
37
62
|
function numberOr(value, fallback) {
|
|
38
63
|
return typeof value === 'number' ? value : fallback;
|
|
39
64
|
}
|
|
40
|
-
function stringOr(value, fallback) {
|
|
41
|
-
return typeof value === 'string' ? value : fallback;
|
|
42
|
-
}
|
|
43
65
|
// Runs once per GESTURE, matching `./touchable-opacity`'s `refine` — the config it reads is
|
|
44
66
|
// whatever the props hold when a finger lands, and the underlay handlers it builds read `shown`
|
|
45
67
|
// through the closure below rather than owning it, so a re-arm reads the current props.
|
|
@@ -54,8 +76,9 @@ const refine = (node, config) => {
|
|
|
54
76
|
onLongPress: config.onLongPress,
|
|
55
77
|
});
|
|
56
78
|
const underlay = createHighlightUnderlayHandlers({
|
|
57
|
-
delayPressOut: numberOr(node
|
|
79
|
+
delayPressOut: numberOr(propOf(node, 'delayPressOut'), 0),
|
|
58
80
|
hasPressHandler,
|
|
81
|
+
testOnlyPressed: propOf(node, 'testOnly_pressed') === true,
|
|
59
82
|
schedule: (callback, ms) => {
|
|
60
83
|
const id = setTimeout(() => {
|
|
61
84
|
state.timers.delete(id);
|
|
@@ -72,16 +95,21 @@ const refine = (node, config) => {
|
|
|
72
95
|
if (state.shown === shown)
|
|
73
96
|
return;
|
|
74
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);
|
|
75
103
|
markPropsDirty(node);
|
|
76
104
|
requestCommitFor(node);
|
|
77
105
|
},
|
|
78
106
|
onShowUnderlay: () => {
|
|
79
|
-
const onShowUnderlay = node
|
|
107
|
+
const onShowUnderlay = propOf(node, 'onShowUnderlay');
|
|
80
108
|
if (typeof onShowUnderlay === 'function')
|
|
81
109
|
onShowUnderlay();
|
|
82
110
|
},
|
|
83
111
|
onHideUnderlay: () => {
|
|
84
|
-
const onHideUnderlay = node
|
|
112
|
+
const onHideUnderlay = propOf(node, 'onHideUnderlay');
|
|
85
113
|
if (typeof onHideUnderlay === 'function')
|
|
86
114
|
onHideUnderlay();
|
|
87
115
|
},
|
|
@@ -105,41 +133,30 @@ const refine = (node, config) => {
|
|
|
105
133
|
},
|
|
106
134
|
};
|
|
107
135
|
};
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
}
|
|
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`.
|
|
143
160
|
// A listener flip changes no payload by itself, so the commit after it is a no-op and no fold
|
|
144
161
|
// re-runs (`IHostBehavior.onOwnedListenerChange`) — same reason `./touchable-opacity` carries this.
|
|
145
162
|
function onOwnedListenerChange(node, name) {
|
|
@@ -156,7 +173,6 @@ export function createTouchableHighlightBehavior(disabledOf) {
|
|
|
156
173
|
const machine = createPressBehavior(refine, disabledOf);
|
|
157
174
|
return {
|
|
158
175
|
...machine,
|
|
159
|
-
foldPayload,
|
|
160
176
|
attach(node) {
|
|
161
177
|
states.set(node, {
|
|
162
178
|
shown: false,
|
|
@@ -179,14 +195,11 @@ export function createTouchableHighlightBehavior(disabledOf) {
|
|
|
179
195
|
}
|
|
180
196
|
// Idempotent: an adapter entry may be imported more than once in a bundle.
|
|
181
197
|
export function registerTouchableHighlightBehavior() {
|
|
182
|
-
const touchable = createTouchableHighlightBehavior();
|
|
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.
|
|
183
201
|
registerHostBehavior(TOUCHABLE_HIGHLIGHT_TAG, {
|
|
184
202
|
...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
203
|
onOwnedListenerChange,
|
|
191
204
|
});
|
|
192
205
|
}
|
|
@@ -53,61 +53,33 @@
|
|
|
53
53
|
// bare `import './register';` that the barrel does not re-export. Registered by ALL FIVE adapters
|
|
54
54
|
// since 2026-09-09, in the same commit that deleted the five wrappers, which is what makes it safe:
|
|
55
55
|
// while a wrapper still built its own Pressable + feedback View, registering would have left every
|
|
56
|
-
// TouchableNativeFeedback with two responders.
|
|
57
|
-
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
60
|
-
import { backgroundProps, selectableBackground, } from '../view/render-touchable-native-feedback.js';
|
|
61
|
-
import { accessibleUnlessOptedOut, asAccessibilityState, attachPressMachine, booleanOr, detachPressMachine, } from './pressable.js';
|
|
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';
|
|
62
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));
|
|
63
67
|
// Read once, like `./button`'s: the platform cannot change under a running app.
|
|
64
68
|
const IS_ANDROID = Platform.OS === 'android';
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
// `
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
];
|
|
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];
|
|
111
83
|
// The two RN clones as EVENTS rather than props (:386-387). Owned, so the app's callback stashes on
|
|
112
84
|
// the owner and a trampoline installed on the child reads it at dispatch time — which keeps a fresh
|
|
113
85
|
// closure per render free, exactly as `ownedListeners` intends.
|
|
@@ -129,33 +101,11 @@ const PRESS_LISTENERS = [
|
|
|
129
101
|
'responderMove',
|
|
130
102
|
'responderTerminationRequest',
|
|
131
103
|
];
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
//
|
|
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
|
-
}
|
|
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.
|
|
159
109
|
// TouchableNativeFeedback.js:280 — `locationX ?? 0`. The bag is raw Fabric payload, so guard.
|
|
160
110
|
function hotspotAt(nativeEvent, key) {
|
|
161
111
|
const value = nativeEvent[key];
|
|
@@ -207,47 +157,16 @@ export const nativeFeedbackRefinement = (node, config) => {
|
|
|
207
157
|
},
|
|
208
158
|
};
|
|
209
159
|
};
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
|
251
170
|
// them from — they would ride into the child's payload as keys no ViewConfig declares. Same strip
|
|
252
171
|
// `./pressable`'s fold does for the machine-only half, applied to the owner's bag instead.
|
|
253
172
|
//
|
|
@@ -272,6 +191,7 @@ function arm(owner, child) {
|
|
|
272
191
|
attachPressMachine(child, {
|
|
273
192
|
source: owner,
|
|
274
193
|
refine: nativeFeedbackRefinement,
|
|
194
|
+
disabledOf: touchableNativeFeedbackDisabled,
|
|
275
195
|
});
|
|
276
196
|
for (const name of FORWARDED_LISTENERS) {
|
|
277
197
|
forwardListener(owner, child, name, appListenerFor(owner, name) !== undefined);
|
|
@@ -296,9 +216,10 @@ function onChildInserted(node, child) {
|
|
|
296
216
|
if (previous !== undefined)
|
|
297
217
|
detachPressMachine(previous);
|
|
298
218
|
node.childHost = child;
|
|
299
|
-
child.payloadFold = cloneFold(node, child.payloadFold);
|
|
300
219
|
// The owner's props were very likely written BEFORE this child existed (React and Solid set props
|
|
301
|
-
// at createInstance), so the
|
|
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.
|
|
302
223
|
markPropsDirty(child);
|
|
303
224
|
arm(node, child);
|
|
304
225
|
}
|
|
@@ -18,10 +18,17 @@
|
|
|
18
18
|
//
|
|
19
19
|
// REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter's entry
|
|
20
20
|
// does a bare `import './register';` that the barrel does not re-export.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
//
|
|
22
|
+
// TODO(rn-parity, low priority): `TouchableOpacity.js:197-220` gates the active/inactive opacity
|
|
23
|
+
// fade on `onFocus`/`onBlur` too (TV remote focus fires the same fade `onPress` triggers). Neither
|
|
24
|
+
// name is wired into the feedback machine here; they pass through as ordinary, un-intercepted
|
|
25
|
+
// event props. Deliberately not implemented — dead on every device this project targets (no tvOS
|
|
26
|
+
// build, no example app; iOS/Android are the only targets). Audit skill, "Found, NOT fixed: TV
|
|
27
|
+
// (Platform.isTV) focus/blur feedback on TouchableOpacity/Highlight".
|
|
28
|
+
import { AnimatedMock, AnimatedValue, Easing, Platform, dlog, getExplicitStyle, markPropsDirty, registerHostBehavior, requestCommitFor, setAnimatedBehaviorStyle, timing, propOf, propsOf, } from '@symbiote-native/engine';
|
|
29
|
+
import { resolveButtonDisabled } from '../view/render-button.js';
|
|
30
|
+
import { asAccessibilityState, booleanOr, createPressBehavior, } from './pressable.js';
|
|
31
|
+
import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS, OPACITY_ACTIVE_GRANT_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, restingOpacityFromStyle, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '../state/touchable.js';
|
|
25
32
|
export const TOUCHABLE_OPACITY_TAG = 'touchable-opacity';
|
|
26
33
|
const states = new WeakMap();
|
|
27
34
|
// `modules/animated`'s barrel swaps the whole driver namespace for the mock when the host reports
|
|
@@ -58,13 +65,13 @@ const refine = (node, config) => {
|
|
|
58
65
|
if (state === undefined)
|
|
59
66
|
return config;
|
|
60
67
|
const resting = restingOpacityOf(node);
|
|
61
|
-
const activeOpacity = numberOr(node
|
|
68
|
+
const activeOpacity = numberOr(propOf(node, 'activeOpacity'), DEFAULT_ACTIVE_OPACITY);
|
|
62
69
|
const handlers = createTouchableFeedbackHandlers({
|
|
63
|
-
delayPressIn: numberOr(node
|
|
64
|
-
delayPressOut: numberOr(node
|
|
70
|
+
delayPressIn: numberOr(propOf(node, 'delayPressIn'), 0),
|
|
71
|
+
delayPressOut: numberOr(propOf(node, 'delayPressOut'), 0),
|
|
65
72
|
// Read raw rather than off `config`, which has already defaulted the absent case to the press
|
|
66
73
|
// machine's own 130 ms — a floor RN's Touchables never see (TOUCHABLE_MIN_PRESS_DURATION_MS).
|
|
67
|
-
minPressDuration: numberOr(node
|
|
74
|
+
minPressDuration: numberOr(propOf(node, 'minPressDuration'), TOUCHABLE_MIN_PRESS_DURATION_MS),
|
|
68
75
|
schedule: (callback, ms) => {
|
|
69
76
|
const id = setTimeout(() => {
|
|
70
77
|
state.timers.delete(id);
|
|
@@ -79,9 +86,14 @@ const refine = (node, config) => {
|
|
|
79
86
|
now: Date.now,
|
|
80
87
|
}, state.runtime, {
|
|
81
88
|
activate(event) {
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
89
|
+
// TouchableOpacity.js:215-220 picks the duration from WHERE the press-in came from: our
|
|
90
|
+
// engine's own 'pressIn' event (an ordinary grant, or its delayed replay) is the 0ms leg;
|
|
91
|
+
// a drift-out/drift-back-in reactivation arrives as 'responderMove'
|
|
92
|
+
// (`state/pressable.ts`'s `handleResponderMove`) and gets the 150ms ease-back-in instead.
|
|
93
|
+
const duration = event.type === 'responderMove'
|
|
94
|
+
? OPACITY_ACTIVE_DURATION_MS
|
|
95
|
+
: OPACITY_ACTIVE_GRANT_DURATION_MS;
|
|
96
|
+
fadeTo(state, activeOpacity, duration);
|
|
85
97
|
config.onPressIn?.(event);
|
|
86
98
|
},
|
|
87
99
|
deactivate(event) {
|
|
@@ -98,25 +110,11 @@ const refine = (node, config) => {
|
|
|
98
110
|
onPressOut: handlers.handlePressOut,
|
|
99
111
|
};
|
|
100
112
|
};
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
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
|
-
};
|
|
113
|
+
// TouchableOpacity.js:186-189 — `disabled ?? aria-disabled ?? accessibilityState.disabled`, the
|
|
114
|
+
// same three-way answer Button resolves, just never wired to this tag's OWN registration before.
|
|
115
|
+
// Without it, a caller who sets only `accessibilityState={{disabled: true}}` (no `disabled` prop)
|
|
116
|
+
// gets the greyed-out RN look but a press that still fires here — RN suppresses it.
|
|
117
|
+
const touchableOpacityDisabled = props => resolveButtonDisabled(booleanOr(props.disabled), booleanOr(props['aria-disabled']), asAccessibilityState(props.accessibilityState));
|
|
120
118
|
/**
|
|
121
119
|
* The behavior as PARTS, so a tag that is a TouchableOpacity plus something — `button`, which RN
|
|
122
120
|
* builds as exactly that (Button.js:283) — composes the fade instead of re-implementing it.
|
|
@@ -126,11 +124,11 @@ const foldPayload = props => {
|
|
|
126
124
|
*/
|
|
127
125
|
export function createTouchableOpacityBehavior(disabledOf) {
|
|
128
126
|
// Its own machine rather than the module-level `press`, so a composing tag can say what
|
|
129
|
-
// `disabled` MEANS to it
|
|
127
|
+
// `disabled` MEANS to it — the answer feeds the machine, not a payload: the props half of that
|
|
128
|
+
// question is `foldPressableProps` in `SymbioteFabricProps.cpp` and reads the authored bag.
|
|
130
129
|
const machine = createPressBehavior(refine, disabledOf);
|
|
131
130
|
return {
|
|
132
131
|
...machine,
|
|
133
|
-
foldPayload,
|
|
134
132
|
attach(node) {
|
|
135
133
|
const state = {
|
|
136
134
|
opacity: new AnimatedValue(RESTING_OPACITY),
|
|
@@ -157,14 +155,28 @@ export function createTouchableOpacityBehavior(disabledOf) {
|
|
|
157
155
|
// (Button.js:331,337), so reading the prop here left an aria-only flip un-settled — the view
|
|
158
156
|
// stayed at its active opacity while the press was already suppressed. RN's Button has no
|
|
159
157
|
// such gap because it passes the resolved value DOWN as its touchable's prop.
|
|
160
|
-
const
|
|
158
|
+
const props = propsOf(node);
|
|
159
|
+
const disabled = disabledOf?.(props) ?? props.disabled;
|
|
161
160
|
const previous = state.settled;
|
|
162
161
|
state.settled = { disabled, resting };
|
|
163
162
|
if (previous === undefined) {
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
163
|
+
// NOT vendor parity, despite this comment's old claim: `TouchableOpacity-itest.js` ("does
|
|
164
|
+
// not render explicit opacity when using default") proves a resting, untouched
|
|
165
|
+
// TouchableOpacity commits no `opacity` key at all. Vendor's native-animated leaf is a
|
|
166
|
+
// separate channel from the ordinary style diff, so forcing non-flattening never implies
|
|
167
|
+
// publishing a value. Ours publishes anyway: `setAnimatedBehaviorStyle`/`bindAnimatedValue`
|
|
168
|
+
// (`host-binding.ts`) force `collapsable: false` and resolve+write the leaf's value in the
|
|
169
|
+
// SAME call. Untangling that is a change to shared animation infrastructure, not a one-line
|
|
170
|
+
// fix here — deferred, see the audit skill. Set rather than animate: this is the mount, and
|
|
171
|
+
// RN re-settles on componentDidUpdate only.
|
|
172
|
+
//
|
|
173
|
+
// TODO(rn-parity): skip this initial publish when `resting === RESTING_OPACITY` (1, Fabric's
|
|
174
|
+
// own opacity default) and no authored `style.opacity` needs another route to Fabric, so a
|
|
175
|
+
// resting default TouchableOpacity commits no `opacity` key — matching vendor. Needs
|
|
176
|
+
// `bindAnimatedValue`/`host-binding.ts` to separate "force collapsable:false" from "publish
|
|
177
|
+
// the leaf's value" (shared by every animated-behavior consumer, not just this tag), plus a
|
|
178
|
+
// new test for the untouched-default case (no existing case in touchable-opacity.test.ts
|
|
179
|
+
// covers it — every case authors an explicit style.opacity first).
|
|
168
180
|
state.opacity.setValue(resting);
|
|
169
181
|
return;
|
|
170
182
|
}
|
|
@@ -189,21 +201,28 @@ export function createTouchableOpacityBehavior(disabledOf) {
|
|
|
189
201
|
},
|
|
190
202
|
};
|
|
191
203
|
}
|
|
192
|
-
//
|
|
193
|
-
// (TouchableOpacity.js:338) — is an OWNED
|
|
194
|
-
//
|
|
195
|
-
// here, over the node.
|
|
204
|
+
// THIS TAG NOW COSTS ZERO TRIPS INTO JS (2026-09-18). `focusable` was the last thing here, and it
|
|
205
|
+
// held out because its middle leg — `onPress !== undefined` (TouchableOpacity.js:338) — is an OWNED
|
|
206
|
+
// name that lives in the stash and never becomes a prop, so a props-only rule could not see it.
|
|
196
207
|
//
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
//
|
|
208
|
+
// What moved was not the closure but ONE BIT. `setEventListener` already knew the flip (it computes
|
|
209
|
+
// `wasWired !== isHandler` to fire `onOwnedListenerChange`); it now also records
|
|
210
|
+
// `OP_SET_OWNED_LISTENER`, and `foldPressableProps` resolves the whole three-leg expression off the
|
|
211
|
+
// node. The callback itself never left JS and never will — that is the split a browser draws too,
|
|
212
|
+
// where the UA knows which elements carry a click handler and the handler's body stays the page's.
|
|
213
|
+
//
|
|
214
|
+
// So "a rule cannot read an owned listener" was two claims wearing one sentence, and only the one
|
|
215
|
+
// about the FUNCTION was true. Contract:
|
|
216
|
+
// `core/engine/cpp/tests/js/touchable-focusable-payload.itest.ts`.
|
|
217
|
+
//
|
|
218
|
+
// `./button` keeps its own fold and its own resolution, deliberately: it resolves `disabled` three
|
|
219
|
+
// ways through the projection its derived children share, and it folds an Android view style and
|
|
220
|
+
// ripple regardless — so moving only its `focusable` would duplicate that precedence and buy back
|
|
221
|
+
// no crossing.
|
|
222
|
+
// STILL NEEDED, and for a reason the wire did not remove: `markDirty` on the host marks the node
|
|
223
|
+
// whose payload must be rebuilt, but the COMMIT still has to be asked for. A listener flip changes
|
|
224
|
+
// no prop on this side, so nothing else would schedule one and the new answer would sit in the host
|
|
225
|
+
// until some unrelated write happened to dirty the node.
|
|
207
226
|
function onOwnedListenerChange(node, name) {
|
|
208
227
|
if (name !== 'press')
|
|
209
228
|
return;
|
|
@@ -212,16 +231,9 @@ function onOwnedListenerChange(node, name) {
|
|
|
212
231
|
}
|
|
213
232
|
// Idempotent: an adapter entry may be imported more than once in a bundle.
|
|
214
233
|
export function registerTouchableOpacityBehavior() {
|
|
215
|
-
const touchable = createTouchableOpacityBehavior();
|
|
234
|
+
const touchable = createTouchableOpacityBehavior(touchableOpacityDisabled);
|
|
216
235
|
registerHostBehavior(TOUCHABLE_OPACITY_TAG, {
|
|
217
236
|
...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
237
|
onOwnedListenerChange,
|
|
226
238
|
});
|
|
227
239
|
}
|