@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.
- package/README.md +23 -10
- package/build/accessibility-props.d.ts +11 -0
- package/build/accessibility-props.js +30 -118
- 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 +149 -0
- package/build/behaviors/button.d.ts +2 -0
- package/build/behaviors/button.js +328 -0
- package/build/behaviors/image-background.d.ts +2 -0
- package/build/behaviors/image-background.js +139 -0
- package/build/behaviors/image.d.ts +3 -0
- package/build/behaviors/image.js +123 -0
- package/build/behaviors/input-accessory-view.d.ts +3 -0
- package/build/behaviors/input-accessory-view.js +70 -0
- package/build/behaviors/pressable.d.ts +60 -0
- package/build/behaviors/pressable.js +385 -0
- package/build/behaviors/refresh-control.d.ts +2 -0
- package/build/behaviors/refresh-control.js +83 -0
- package/build/behaviors/scroll-view/index.android.d.ts +1 -0
- package/build/behaviors/scroll-view/index.android.js +52 -0
- package/build/behaviors/scroll-view/index.d.ts +2 -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 +5 -0
- package/build/behaviors/scroll-view/shared.d.ts +11 -0
- package/build/behaviors/scroll-view/shared.js +291 -0
- package/build/behaviors/scroll-view/sticky.d.ts +17 -0
- package/build/behaviors/scroll-view/sticky.js +568 -0
- package/build/behaviors/switch.d.ts +2 -0
- package/build/behaviors/switch.js +186 -0
- package/build/behaviors/text-input.d.ts +14 -0
- package/build/behaviors/text-input.js +319 -0
- package/build/behaviors/touchable-highlight.d.ts +9 -0
- package/build/behaviors/touchable-highlight.js +192 -0
- package/build/behaviors/touchable-native-feedback.d.ts +20 -0
- package/build/behaviors/touchable-native-feedback.js +333 -0
- package/build/behaviors/touchable-opacity.d.ts +12 -0
- package/build/behaviors/touchable-opacity.js +227 -0
- package/build/behaviors/touchable-without-feedback.d.ts +2 -0
- package/build/behaviors/touchable-without-feedback.js +296 -0
- package/build/component-names/index.android.js +47 -15
- package/build/component-names/index.ios.js +35 -15
- package/build/component-names/shared.d.ts +2 -1
- package/build/component-names/shared.js +49 -3
- package/build/descriptor.js +4 -4
- package/build/fold-host-bag.d.ts +15 -0
- package/build/fold-host-bag.js +99 -0
- package/build/index.d.ts +23 -12
- package/build/index.js +50 -9
- package/build/register.d.ts +1 -0
- package/build/register.js +55 -0
- package/build/resolve-intrinsic.d.ts +7 -0
- package/build/resolve-intrinsic.js +49 -0
- package/build/scroll-view-commands.d.ts +4 -0
- package/build/scroll-view-commands.js +30 -31
- package/build/state/pressable.d.ts +9 -0
- package/build/state/pressable.js +120 -34
- package/build/state/text-input.d.ts +10 -2
- package/build/state/text-input.js +11 -0
- package/build/text-props.js +2 -1
- package/build/view/render-button.d.ts +36 -1
- package/build/view/render-button.js +101 -12
- package/build/view/render-image/index.d.ts +2 -0
- package/build/view/render-image/index.js +29 -2
- package/build/view/render-input-accessory-view.d.ts +2 -0
- package/build/view/render-input-accessory-view.js +37 -5
- package/build/view/render-modal.js +3 -3
- package/build/view/render-pressable/index.d.ts +2 -0
- package/build/view/render-pressable/index.js +24 -0
- package/build/view/render-scroll-view.d.ts +1 -0
- package/build/view/render-scroll-view.js +18 -9
- package/build/view/render-switch.d.ts +4 -1
- package/build/view/render-switch.js +8 -2
- package/build/view/render-text-input.js +6 -2
- package/build/view/render-touchable-native-feedback.d.ts +19 -0
- package/build/view/render-touchable-native-feedback.js +19 -0
- package/host-primitives.cjs +432 -0
- package/host-primitives.d.cts +33 -0
- package/package.json +33 -5
- package/build/view/render-activity-indicator.d.ts +0 -25
- package/build/view/render-activity-indicator.js +0 -62
- package/build/view/render-image-background.d.ts +0 -9
- package/build/view/render-image-background.js +0 -48
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// Button as an ENGINE-NODE behavior, so RN's one batteries-included control can be an intrinsic
|
|
2
|
+
// tag instead of five framework components (`.claude/rules/host-primitive-tier.md`, tier 2).
|
|
3
|
+
//
|
|
4
|
+
// THE WHOLE PRIMITIVE IS COMPOSITION. RN's Button is a touchable wrapping a View wrapping a Text
|
|
5
|
+
// (Button.js:363-388) and takes NO children — `title` is a string prop. So `buildStructure` owns
|
|
6
|
+
// the entire subtree, and the nodes below it are a PROJECTION of three owner props.
|
|
7
|
+
//
|
|
8
|
+
// AND THE TOUCHABLE IS NOT THE SAME ONE ON BOTH PLATFORMS (Button.js:281-284), which is why the
|
|
9
|
+
// two trees have different HEIGHTS. TouchableOpacity WRAPS — it renders its own `<Animated.View>`
|
|
10
|
+
// and puts the child inside it (TouchableOpacity.js:302,344). TouchableNativeFeedback RENDERS
|
|
11
|
+
// NOTHING and clones its props onto the child instead (TouchableNativeFeedback.js:339), so on
|
|
12
|
+
// Android Button's own `<View style={buttonStyles}>` IS the responder:
|
|
13
|
+
//
|
|
14
|
+
// iOS button RCTView TouchableOpacity's Animated.View — the responder + the fade
|
|
15
|
+
// └ view RCTView resolveButtonViewStyle(color, disabled) — `{}` here
|
|
16
|
+
// └ text RCTText resolveButtonTextStyle(color, disabled) + RN's Text defaults
|
|
17
|
+
// └ raw RCTRawText resolveButtonTitle(title) FOUR nodes
|
|
18
|
+
//
|
|
19
|
+
// Android button RCTView the styled button view, CLONED onto: the responder, the ripple
|
|
20
|
+
// │ background, the whole a11y fold. No fade, no wrapper.
|
|
21
|
+
// └ text RCTText
|
|
22
|
+
// └ raw RCTRawText UPPERCASED (Button.js:352-353) THREE nodes
|
|
23
|
+
//
|
|
24
|
+
// EVERY FOLD IS ALREADY WRITTEN AND TESTED in `../view/render-button`; nothing here re-derives one.
|
|
25
|
+
// What is new is only WHERE they run: on engine nodes, instead of in a component body.
|
|
26
|
+
//
|
|
27
|
+
// ---------------------------------------------------------------------------------------------
|
|
28
|
+
// HOW THE PROJECTION REACHES ITS NODES, given that each `payloadFold` MUST be pure:
|
|
29
|
+
//
|
|
30
|
+
// title a REDIRECT. The raw text is the slot, and `slotProps` renames `title` -> `text` on it,
|
|
31
|
+
// so the app's write lands on the label through the label's own `routeProp` and marks
|
|
32
|
+
// it. `resolveButtonTitle`'s uppercase is then the label's own fold over its OWN props —
|
|
33
|
+
// no owner to read, and `isEmptyRawText` still sees the real title, so an empty one is
|
|
34
|
+
// dropped by the commit walk exactly as it was before.
|
|
35
|
+
// color a per-node FOLD over the owner, on the text and — on iOS — on the view (the shape
|
|
36
|
+
// disabled `behaviors/scroll-view/shared.ts` uses). `slotDerived` marks the slot, and
|
|
37
|
+
// `addDerivedNode` extends that mark to the nodes past it. On Android the second
|
|
38
|
+
// consumer is the OWNER itself, which `setProp` already dirties.
|
|
39
|
+
//
|
|
40
|
+
// So no node writes to another and no follow-up commit is needed. Two seams that do NOT work here,
|
|
41
|
+
// measured against the real commit path, so neither is tried again:
|
|
42
|
+
//
|
|
43
|
+
// slotDerived alone marks `node.childHost` and nothing else — ONE node, where a colour change
|
|
44
|
+
// moves two. `addDerivedNode` is the hop past it.
|
|
45
|
+
// afterCommit UNREACHABLE for exactly the props that matter. `title` and `color` never
|
|
46
|
+
// reach the host payload, so a write to either produces a byte-identical
|
|
47
|
+
// payload and `commitContainer` returns on a no-op ABOVE
|
|
48
|
+
// `runDeferredAttaches`. Recorded at `IHostBehavior.afterCommit`.
|
|
49
|
+
//
|
|
50
|
+
// WHY THE OWNER'S FOLD IS BOUND IN `buildStructure` rather than declared as `behavior.foldPayload`.
|
|
51
|
+
// It needs two things that are not in the bag it is handed: `onPress` lives in the listener STASH
|
|
52
|
+
// (`ownedListeners` diverts it, so `props.onPress` is always undefined), and `focusable` is a
|
|
53
|
+
// function of it. `scroll-view/index.android.ts` assigns `owner.payloadFold` from `onWrapChange`
|
|
54
|
+
// for the same reason; `attachHostBehavior` sets the field one line BEFORE it calls
|
|
55
|
+
// `buildStructure`, so the binding here is what stands.
|
|
56
|
+
// ---------------------------------------------------------------------------------------------
|
|
57
|
+
// KNOWN DIVERGENCES, stated rather than left to be discovered on a device:
|
|
58
|
+
//
|
|
59
|
+
// 1. CLOSED 2026-09-09, and it closed by DELETION rather than by a fix. The gap was that all five
|
|
60
|
+
// wrappers rendered TouchableOpacity unconditionally where RN swaps in TouchableNativeFeedback
|
|
61
|
+
// (Button.js:280-283), so a Button faded on Android and committed four nodes where RN ripples
|
|
62
|
+
// and commits three. There is no wrapper left to diverge: `button` is a tag, registered by all
|
|
63
|
+
// five adapters, and the swap above is the only implementation.
|
|
64
|
+
//
|
|
65
|
+
// THE ORDER MATTERED AND IS THE REUSABLE HALF. The registry is keyed by TAG, so registering
|
|
66
|
+
// while a wrapper still built its own view and text would have given every Button a SECOND copy
|
|
67
|
+
// of the subtree — the hazard `behaviors/scroll-view/shared.ts` records. Entry, registration and
|
|
68
|
+
// the five deletions are one change, which is also what `touchable-native-feedback` did hours
|
|
69
|
+
// earlier and for the same reason.
|
|
70
|
+
//
|
|
71
|
+
// WHAT AN APP SEES, stated because it is a behaviour change and not a refactor: on Android a
|
|
72
|
+
// Button now ripples instead of fading and commits three nodes instead of four. That is the RN
|
|
73
|
+
// parity this whole line of work was for.
|
|
74
|
+
//
|
|
75
|
+
// 2. CLOSED 2026-09-09, kept for the seam rather than the gap. `aria-disabled` — and an authored
|
|
76
|
+
// `accessibilityState.disabled` — now suppress the press, not just grey the label.
|
|
77
|
+
//
|
|
78
|
+
// THE RESOLUTION IS BUTTON'S, NOT THE MACHINE'S, and that asymmetry is the finding. RN hands
|
|
79
|
+
// Pressability the RAW prop (Pressable.js:266), so on a bare `pressable` `aria-disabled` changes
|
|
80
|
+
// only what is ANNOUNCED and the press still fires; resolving it down there would be a new
|
|
81
|
+
// divergence pointing the other way. Button is the outlier (Button.js:337), so it hands the
|
|
82
|
+
// touchable an `IDisabledResolver` (`./pressable`) and `rebuild` calls it at every gesture start.
|
|
83
|
+
//
|
|
84
|
+
// A RESOLVER RATHER THAN A WRITE, because writing the answer into `node.props.disabled` LATCHES:
|
|
85
|
+
// `resolveButtonDisabled` short-circuits on `disabled !== undefined`, so the injected value would
|
|
86
|
+
// answer the next resolution as the app's own and the button could never re-enable. Reading per
|
|
87
|
+
// gesture also means a flip needs no commit to reach the machine.
|
|
88
|
+
//
|
|
89
|
+
// AND THE PRESS WAS ONLY HALF OF IT. `./touchable-opacity`'s `afterCommit` re-settles the fade
|
|
90
|
+
// when `disabled` moves, and it read the RAW prop — so for an hour after the press half closed, a
|
|
91
|
+
// Button disabled by `aria-disabled` mid-press stayed at its ACTIVE opacity while already
|
|
92
|
+
// refusing the press. It reads through the same resolver now. The general shape: one prop
|
|
93
|
+
// resolved in two places, and closing the first makes the second look done.
|
|
94
|
+
//
|
|
95
|
+
// 3. CLOSED 2026-09-09, repo-wide, and kept here for the finding rather than the gap. `focusable`
|
|
96
|
+
// was emitted by NOTHING in `core/components` — not a wrapper, not the press behavior — so a
|
|
97
|
+
// keyboard or TV host could focus a disabled button, on every adapter and both paths. RN carries
|
|
98
|
+
// two formulas (`Pressable.js:258` vs the four `Touchable*`, which also require a press handler
|
|
99
|
+
// and a non-disabled state); both now live in `../view/render-pressable` and every behavior and
|
|
100
|
+
// surviving wrapper calls them.
|
|
101
|
+
// ---------------------------------------------------------------------------------------------
|
|
102
|
+
//
|
|
103
|
+
// REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter entry does
|
|
104
|
+
// a bare `import './register';` that the barrel does not re-export. Registered by ALL FIVE adapters
|
|
105
|
+
// since 2026-09-09, in the same commit that deleted the five wrappers, which is what makes it safe:
|
|
106
|
+
// while a wrapper still built its own view and text under this tag, registering would have given
|
|
107
|
+
// every Button a second copy of the subtree.
|
|
108
|
+
import { addDerivedNode, appendChild, appListenerFor, createElement, createRawText, markPropsDirty, Platform, registerHostBehavior, requestCommitFor, } from '@symbiote-native/engine';
|
|
109
|
+
import { descriptorFor } from '../component-names';
|
|
110
|
+
import { resolveTextProps } from '../text-props.js';
|
|
111
|
+
import { BUTTON_ACCESSIBILITY_ROLE, resolveButtonDisabled, resolveButtonImportantForAccessibility, resolveButtonTextStyle, resolveButtonTitle, resolveButtonViewStyle, } from '../view/render-button.js';
|
|
112
|
+
import { backgroundProps, selectableBackground, } from '../view/render-touchable-native-feedback.js';
|
|
113
|
+
import { resolveTouchableFocusable } from '../view/render-pressable/index.js';
|
|
114
|
+
import { booleanOr, createPressBehavior, } from './pressable.js';
|
|
115
|
+
import { nativeFeedbackRefinement } from './touchable-native-feedback.js';
|
|
116
|
+
import { createTouchableOpacityBehavior } from './touchable-opacity.js';
|
|
117
|
+
export const BUTTON_TAG = 'button';
|
|
118
|
+
// Read once, like `render-button`'s own module-level `buttonViewStyle`: the platform cannot change
|
|
119
|
+
// under a running app, and every test that needs the other branch already has to mock `Platform`
|
|
120
|
+
// for `render-button` regardless — which is why this is a branch rather than a `button/` folder
|
|
121
|
+
// split. A file split would move the behavior and leave its style half still reading `Platform`.
|
|
122
|
+
const IS_ANDROID = Platform.OS === 'android';
|
|
123
|
+
// The owner props the derived nodes' styles are derived from. A name missing here is a node frozen
|
|
124
|
+
// at its mount value, which is the whole failure mode this list has. `title` is NOT one of them: it
|
|
125
|
+
// is redirected by `SLOT_PROPS` and never reaches `setProp` on the owner, so listing it would be
|
|
126
|
+
// dead.
|
|
127
|
+
const SLOT_DERIVED = [
|
|
128
|
+
'color',
|
|
129
|
+
'disabled',
|
|
130
|
+
'aria-disabled',
|
|
131
|
+
'accessibilityState',
|
|
132
|
+
];
|
|
133
|
+
// Button.js:386 renders `<Text>{title}</Text>`; the raw text is where that string lives.
|
|
134
|
+
const SLOT_PROPS = { title: 'text' };
|
|
135
|
+
function stringOr(value) {
|
|
136
|
+
return typeof value === 'string' ? value : undefined;
|
|
137
|
+
}
|
|
138
|
+
// `accessibilityState` arrives as `unknown` off `node.props`, and only `disabled` decides anything
|
|
139
|
+
// here. Narrowed field by field rather than cast, the idiom `./pressable` uses for the same bag.
|
|
140
|
+
function accessibilityDisabled(value) {
|
|
141
|
+
if (typeof value !== 'object' || value === null)
|
|
142
|
+
return {};
|
|
143
|
+
const disabled = Reflect.get(value, 'disabled');
|
|
144
|
+
return typeof disabled === 'boolean' ? { disabled } : {};
|
|
145
|
+
}
|
|
146
|
+
// Takes the PROP BAG rather than the node, so the owner's own fold — which is handed a bag and not
|
|
147
|
+
// a node — resolves the same projection its derived children do.
|
|
148
|
+
function projectionOf(props) {
|
|
149
|
+
return {
|
|
150
|
+
color: stringOr(props.color),
|
|
151
|
+
// Button.js:337 — `disabled` may be decided by `aria-disabled` or by an authored
|
|
152
|
+
// `accessibilityState.disabled`. The engine folds both into the COMMITTED accessibilityState
|
|
153
|
+
// already; this is the half a payload fold cannot do, which is greying the label.
|
|
154
|
+
disabled: resolveButtonDisabled(booleanOr(props.disabled), booleanOr(props['aria-disabled']), accessibilityDisabled(props.accessibilityState)),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// What the press machine reads instead of the raw prop — see KNOWN DIVERGENCES 2. Pure: the
|
|
158
|
+
// projection is derived per call and nothing is written back.
|
|
159
|
+
const buttonDisabled = props => projectionOf(props).disabled;
|
|
160
|
+
/**
|
|
161
|
+
* NO MEMO, and the earlier version's memo is deliberately gone. It guarded `setProp`'s `Object.is`,
|
|
162
|
+
* which a FRESH style object per call can never satisfy — so pushing unconditionally would have
|
|
163
|
+
* dirtied a node on every commit and re-committed forever
|
|
164
|
+
* (`.claude/rules/list-geometry-feedback-loop.md`). A payload fold does not go through `setProp`:
|
|
165
|
+
* its result reaches `reconcile`, which compares against the mirror with a recursive `propsEqual`
|
|
166
|
+
* (commit.ts) and reuses the committed handle when nothing moved. An equal-but-fresh style is
|
|
167
|
+
* therefore not a change, and there is nothing to feed back.
|
|
168
|
+
*/
|
|
169
|
+
function viewFold(owner) {
|
|
170
|
+
return props => {
|
|
171
|
+
const { color, disabled } = projectionOf(owner.props);
|
|
172
|
+
return { ...props, style: resolveButtonViewStyle(color, disabled) };
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function textFold(owner) {
|
|
176
|
+
return props => {
|
|
177
|
+
const { color, disabled } = projectionOf(owner.props);
|
|
178
|
+
return {
|
|
179
|
+
...props,
|
|
180
|
+
style: resolveButtonTextStyle(color, disabled),
|
|
181
|
+
// RN puts `disabled` on the Text as well (Button.js:386) — a real RCTText prop read by
|
|
182
|
+
// Android's accessibility layer, and not the same thing as the greyed colour above.
|
|
183
|
+
disabled,
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
// Reads its OWN `text`, which `SLOT_PROPS` redirected the app's `title` into — no owner closure, so
|
|
188
|
+
// the fold is shared by every button. `fabricProps` reads only `.text` off a raw-text fold.
|
|
189
|
+
const labelFold = props => ({
|
|
190
|
+
text: resolveButtonTitle(stringOr(props.text) ?? ''),
|
|
191
|
+
});
|
|
192
|
+
// ---- the Android touchable -------------------------------------------------------------------
|
|
193
|
+
// Button.js:281-284. Not two variants of one component: see the tree diagram at the top for what
|
|
194
|
+
// wrapping instead of cloning costs. The Android arm composes the bare press machine, so no
|
|
195
|
+
// opacity value is opened and no fade runs — the ripple IS the feedback there.
|
|
196
|
+
//
|
|
197
|
+
// The refinement is TNF's own and now lives with TNF (`./touchable-native-feedback`). This file
|
|
198
|
+
// held a private copy while the `touchable-native-feedback` TAG did not exist and its wrappers
|
|
199
|
+
// still wrapped where RN clones; the tag landed, the responder node was already a parameter, and
|
|
200
|
+
// one caller became two.
|
|
201
|
+
const touchable = IS_ANDROID
|
|
202
|
+
? createPressBehavior(nativeFeedbackRefinement, buttonDisabled)
|
|
203
|
+
: createTouchableOpacityBehavior(buttonDisabled);
|
|
204
|
+
// ---- the owner's own payload -------------------------------------------------------------------
|
|
205
|
+
/**
|
|
206
|
+
* The wrapper-body folds, over the touchable's own. What is deliberately NOT here:
|
|
207
|
+
*
|
|
208
|
+
* accessible the touchable's fold already applies `accessible !== false`, which is
|
|
209
|
+
* exactly RN's split — Button forwards the caller's value RAW (Button.js:365)
|
|
210
|
+
* and the touchable one level down defaults it (TouchableOpacity.js:303).
|
|
211
|
+
* accessibilityState the engine's aria fold gives `ariaDisabled ?? state.disabled` and the press
|
|
212
|
+
* fold then merges `props.disabled` over it, which composes to RN's
|
|
213
|
+
* `props.disabled ?? aria ?? state.disabled` — the same value, with
|
|
214
|
+
* busy/checked/expanded/selected preserved, without a Button-specific fold.
|
|
215
|
+
*/
|
|
216
|
+
function ownerFold(node) {
|
|
217
|
+
return props => {
|
|
218
|
+
const next = {
|
|
219
|
+
...(touchable.foldPayload === undefined
|
|
220
|
+
? props
|
|
221
|
+
: touchable.foldPayload(props)),
|
|
222
|
+
};
|
|
223
|
+
next.accessibilityRole = BUTTON_ACCESSIBILITY_ROLE;
|
|
224
|
+
// 'no' is the only value the resolver moves (Button.js:356), so checking for it IS the
|
|
225
|
+
// narrowing this bag needs — the shared resolver still owns what 'no' becomes.
|
|
226
|
+
if (next.importantForAccessibility === 'no')
|
|
227
|
+
next.importantForAccessibility =
|
|
228
|
+
resolveButtonImportantForAccessibility('no');
|
|
229
|
+
// Re-mapped, so the raw name must not also reach Fabric. Where the wrappers put it too — the
|
|
230
|
+
// pressable owns sound suppression (Button.js:377 hands `touchSoundDisabled` to the touchable).
|
|
231
|
+
if (Object.hasOwn(next, 'touchSoundDisabled')) {
|
|
232
|
+
next.android_disableSound = next.touchSoundDisabled;
|
|
233
|
+
delete next.touchSoundDisabled;
|
|
234
|
+
}
|
|
235
|
+
const { color, disabled } = projectionOf(props);
|
|
236
|
+
// TouchableOpacity.js:336 and TouchableNativeFeedback.js:369 — the SAME expression, so the tag
|
|
237
|
+
// owes it on both platforms. `onPress` is an owned name, so it is in the stash and never in
|
|
238
|
+
// `props`; a flip of it dirties nothing by itself, which `onOwnedListenerChange` answers.
|
|
239
|
+
next.focusable = resolveTouchableFocusable(booleanOr(props.focusable), appListenerFor(node, 'press') !== undefined, disabled);
|
|
240
|
+
if (IS_ANDROID) {
|
|
241
|
+
// TNF renders no view, it CLONES onto Button's `<View style={buttonStyles}>`
|
|
242
|
+
// (TouchableNativeFeedback.js:339), so this host IS that view. Overwritten rather than merged
|
|
243
|
+
// because RN's Button declares no `style` prop at all — there is nothing to compose with.
|
|
244
|
+
next.style = resolveButtonViewStyle(color, disabled);
|
|
245
|
+
// Button passes no `background` and no `useForeground`, so TNF resolves the theme's
|
|
246
|
+
// selectable background onto the background slot (TouchableNativeFeedback.js:343-348,
|
|
247
|
+
// :402). The dicts are the shared factories', never restated here.
|
|
248
|
+
Object.assign(next, backgroundProps(selectableBackground(), false));
|
|
249
|
+
}
|
|
250
|
+
// Read by the folds above and declared by no ViewConfig. A key Fabric does not know throws
|
|
251
|
+
// nothing, logs nothing and paints nothing, so the strip has to be here or it is never noticed.
|
|
252
|
+
// `title` needs none — `SLOT_PROPS` redirects it before it can land on this node.
|
|
253
|
+
delete next.color;
|
|
254
|
+
return next;
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Builds the whole subtree, once, at `attachHostBehavior`.
|
|
259
|
+
*
|
|
260
|
+
* RETURNS THE RAW TEXT. RN's Button declares no `children` prop and renders none, so the slot is
|
|
261
|
+
* not where the app's children go — it is where its `title` goes, which is what `SLOT_PROPS`
|
|
262
|
+
* redirects onto it. `childHost` is also the GATE on both engine seams this behavior uses:
|
|
263
|
+
* `slotDerived`'s mark and the prop redirect are both skipped unless it is set (node.ts), so
|
|
264
|
+
* returning `undefined` would leave the whole subtree frozen at its mount values.
|
|
265
|
+
*/
|
|
266
|
+
function buildStructure(node) {
|
|
267
|
+
const textDescriptor = descriptorFor('text');
|
|
268
|
+
const text = createElement(textDescriptor.component, textDescriptor.isText, 'text');
|
|
269
|
+
// RN's Text.js applies these to every non-virtual Text on its way to native, and a hand-written
|
|
270
|
+
// host tag inherits nothing a `<Text>` component did — Svelte's Button clipped long labels
|
|
271
|
+
// mid-word for exactly this reason (`.claude/rules/host-primitive-tier.md`, "The THIRD path").
|
|
272
|
+
// Constants, because the app cannot reach this node to override them.
|
|
273
|
+
text.props = resolveTextProps({});
|
|
274
|
+
// Empty until the redirected `title` arrives. The commit walk drops an empty raw text
|
|
275
|
+
// (`isEmptyRawText`, node.ts), so no Fabric node exists for it until it has a label — and that
|
|
276
|
+
// check reads `props.text`, which the redirect writes, not the fold's uppercased output.
|
|
277
|
+
const label = createRawText('');
|
|
278
|
+
text.payloadFold = textFold(node);
|
|
279
|
+
label.payloadFold = labelFold;
|
|
280
|
+
// The hop `slotDerived` alone does not make: it marks the slot (the label), and this is past it.
|
|
281
|
+
addDerivedNode(node, text);
|
|
282
|
+
appendChild(text, label);
|
|
283
|
+
if (IS_ANDROID) {
|
|
284
|
+
// No fourth node: TNF clones onto the styled view, so the host IS it and the label's parent
|
|
285
|
+
// hangs straight off it.
|
|
286
|
+
appendChild(node, text);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
const viewDescriptor = descriptorFor('view');
|
|
290
|
+
const view = createElement(viewDescriptor.component, viewDescriptor.isText, 'view');
|
|
291
|
+
view.payloadFold = viewFold(node);
|
|
292
|
+
addDerivedNode(node, view);
|
|
293
|
+
appendChild(view, text);
|
|
294
|
+
// Lands on the owner, because `node.childHost` is still undefined here — the engine assigns it
|
|
295
|
+
// from what this returns. That ordering is why `buildStructure` RETURNS the slot instead of
|
|
296
|
+
// setting the field itself.
|
|
297
|
+
appendChild(node, view);
|
|
298
|
+
}
|
|
299
|
+
// See the header: the owner's fold needs its own node, and this runs after
|
|
300
|
+
// `attachHostBehavior` has already written `behavior.foldPayload` into the field.
|
|
301
|
+
node.payloadFold = ownerFold(node);
|
|
302
|
+
return label;
|
|
303
|
+
}
|
|
304
|
+
// `focusable` is a function of a LISTENER, and a listener flip changes no payload by itself — so
|
|
305
|
+
// the commit after it is a no-op and no fold re-runs (`IHostBehavior.onOwnedListenerChange`).
|
|
306
|
+
function onOwnedListenerChange(node, name) {
|
|
307
|
+
if (name !== 'press')
|
|
308
|
+
return;
|
|
309
|
+
markPropsDirty(node);
|
|
310
|
+
requestCommitFor(node);
|
|
311
|
+
}
|
|
312
|
+
// Idempotent: an adapter entry may be imported more than once in a bundle.
|
|
313
|
+
export function registerButtonBehavior() {
|
|
314
|
+
// `attach`/`detach` come from the touchable unwrapped: the internal nodes are ordinary children
|
|
315
|
+
// that leave with the sweep, and each carries only a pure fold, so this behavior owns no per-node
|
|
316
|
+
// runtime of its own to release.
|
|
317
|
+
//
|
|
318
|
+
// No `foldPayload` here on purpose — `buildStructure` binds the owner's fold to its node.
|
|
319
|
+
const behavior = {
|
|
320
|
+
...touchable,
|
|
321
|
+
foldPayload: undefined,
|
|
322
|
+
buildStructure,
|
|
323
|
+
onOwnedListenerChange,
|
|
324
|
+
slotProps: SLOT_PROPS,
|
|
325
|
+
slotDerived: SLOT_DERIVED,
|
|
326
|
+
};
|
|
327
|
+
registerHostBehavior(BUTTON_TAG, behavior);
|
|
328
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// ImageBackground's host behavior: the composition and the prop split the wrapper component did,
|
|
2
|
+
// moved below the framework so the primitive can be a bare tag.
|
|
3
|
+
//
|
|
4
|
+
// THE TWO-NODE SHAPE IS RN'S. `ImageBackground.js:74-103` opens a `<View>` carrying the app's
|
|
5
|
+
// `style`, puts an absolutely-filled `<Image>` inside it, and lays the app's `{children}` AFTER
|
|
6
|
+
// that image so they paint on top. The lowered form is the same two nodes — `image-background`
|
|
7
|
+
// (an RCTView, the tag an app writes) with an RCTImageView built under it.
|
|
8
|
+
//
|
|
9
|
+
// WHY THE SLOT TAKES NO CHILDREN, which is the one thing this primitive needed that ScrollView,
|
|
10
|
+
// ActivityIndicator and Button did not. `childHost` answers two questions at once — which node an
|
|
11
|
+
// owner prop redirects onto, and which node the app's children go under — and those had the same
|
|
12
|
+
// answer for every primitive until this one. Here they differ: the image takes `imageStyle` and the
|
|
13
|
+
// whole `...props` spread, while the children belong beside it. `slotTakesNoChildren` is what
|
|
14
|
+
// splits them (`IHostBehavior`, and it is not a JSX nicety upstream could have collapsed — an
|
|
15
|
+
// Android `<Image>` is an `ImageView`, not a `ViewGroup`).
|
|
16
|
+
//
|
|
17
|
+
// WHERE THE APP'S PROPS GO. RN destructures `children, style, imageStyle, imageRef,
|
|
18
|
+
// importantForAccessibility, ...props` and spreads `...props` onto the Image
|
|
19
|
+
// (`ImageBackground.js:62-81`), so the set that moves is OPEN — every event, every accessibility
|
|
20
|
+
// prop, `testID`, `id`, whatever an app writes next — and only a complement can express it.
|
|
21
|
+
// `IMAGE_BACKGROUND_HOST_PROPS` is the short list that stays behind, and it is shorter than RN's:
|
|
22
|
+
// `importantForAccessibility` rides to the image alone, which is what all five wrappers did, so
|
|
23
|
+
// the tag and the component it replaces commit the same payload. The divergence from RN predates
|
|
24
|
+
// lowering and is unchanged by it.
|
|
25
|
+
//
|
|
26
|
+
// WHAT THE IMAGE'S FOLD OWES. Everything on the image arrives as a real prop write, so its payload
|
|
27
|
+
// is built by the shared `foldImagePayload` like any other `image`. Two things cannot arrive that
|
|
28
|
+
// way and are folded here: the style, which is DERIVED from the owner's own `style` (RN proxies the
|
|
29
|
+
// wrapper's width/height onto the image so it fills the box rather than collapsing to the source's
|
|
30
|
+
// intrinsic size), and `id`, whose rename to `nativeID` is applied per adapter on the tag THEY
|
|
31
|
+
// create and so never reaches a node a behavior built.
|
|
32
|
+
import { appendChild, createElement, flattenStyle, registerHostBehavior, } from '@symbiote-native/engine';
|
|
33
|
+
import { descriptorFor } from '../component-names';
|
|
34
|
+
import { foldImagePayload, IMAGE_TAG } from './image.js';
|
|
35
|
+
export const IMAGE_BACKGROUND_TAG = 'image-background';
|
|
36
|
+
// The inner Image's positioning: absolute-fill behind the box's children.
|
|
37
|
+
const ABSOLUTE_FILL = {
|
|
38
|
+
position: 'absolute',
|
|
39
|
+
left: 0,
|
|
40
|
+
right: 0,
|
|
41
|
+
top: 0,
|
|
42
|
+
bottom: 0,
|
|
43
|
+
};
|
|
44
|
+
// The props RN keeps on the wrapper View (`ImageBackground.js:74-78`), plus the two spellings of a
|
|
45
|
+
// class name — `routeProp`'s slot redirect runs above its own class branch, so an unlisted `class`
|
|
46
|
+
// would style the image instead of the box.
|
|
47
|
+
const IMAGE_BACKGROUND_HOST_PROPS = ['style', 'class', 'className'];
|
|
48
|
+
// The owner props the image's payload is derived from. Only `style`, because a class name is
|
|
49
|
+
// published INTO `node.props.style` by `pushClassStyle` — so a `class` write arrives here spelled
|
|
50
|
+
// `style` and the proxied width/height follow a class-declared box as well as an inline one.
|
|
51
|
+
const IMAGE_BACKGROUND_SLOT_DERIVED = ['style'];
|
|
52
|
+
// `imageStyle` is the wrapper's own name for the image's `style`. A bare class NAME is a legal
|
|
53
|
+
// value for it — every adapter's wrapper resolved one — and `routeProp` routes a string landing on
|
|
54
|
+
// `style` as `class` instead, so the registry resolves it on the image with nothing needed here.
|
|
55
|
+
const IMAGE_BACKGROUND_SLOT_PROPS = { imageStyle: 'style' };
|
|
56
|
+
// A StyleProp is an object, an array of them, or a registered class array — all of which
|
|
57
|
+
// `flattenStyle` already handles one layer down. The only thing to exclude is a scalar.
|
|
58
|
+
function styleOf(value) {
|
|
59
|
+
if (typeof value !== 'object' || value === null)
|
|
60
|
+
return undefined;
|
|
61
|
+
if (Array.isArray(value))
|
|
62
|
+
return value;
|
|
63
|
+
return { ...value };
|
|
64
|
+
}
|
|
65
|
+
// RN opts the wrapper out of iOS's Smart Invert for its whole subtree (`ImageBackground.js:75`) —
|
|
66
|
+
// a photograph inverted by accessibility settings is the case that prop exists for. None of the
|
|
67
|
+
// five wrappers ever wrote it, so this closes a standing gap rather than reproducing one.
|
|
68
|
+
const hostFold = props => ({
|
|
69
|
+
...props,
|
|
70
|
+
accessibilityIgnoresInvertColors: true,
|
|
71
|
+
});
|
|
72
|
+
// Read one explicit dimension off the (already-flattened) box style. A dp number or a percentage
|
|
73
|
+
// string is a valid IDimensionValue; anything else (auto / undefined) yields undefined.
|
|
74
|
+
function readDimension(style, key) {
|
|
75
|
+
const value = Object.hasOwn(style, key) ? Reflect.get(style, key) : undefined;
|
|
76
|
+
if (typeof value === 'number' || typeof value === 'string')
|
|
77
|
+
return value;
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
function imageFold(owner) {
|
|
81
|
+
return props => {
|
|
82
|
+
// RN's own workaround, and its comment is worth reading before "simplifying" this
|
|
83
|
+
// (`ImageBackground.js:86-96`): an RN Image overwrites its own width/height from the source's
|
|
84
|
+
// intrinsic size, which fights the box's explicit dimensions, so they are proxied back on.
|
|
85
|
+
// Reads the OWNER's live style — a class name lands there too, published by `pushClassStyle`.
|
|
86
|
+
const box = flattenStyle(styleOf(owner.props.style));
|
|
87
|
+
const next = {
|
|
88
|
+
...props,
|
|
89
|
+
// `imageStyle` last, so a caller still wins over the fill and the proxy.
|
|
90
|
+
style: [
|
|
91
|
+
ABSOLUTE_FILL,
|
|
92
|
+
{
|
|
93
|
+
width: readDimension(box, 'width'),
|
|
94
|
+
height: readDimension(box, 'height'),
|
|
95
|
+
},
|
|
96
|
+
styleOf(props.style),
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
// Unconditional priority when both are set, matching RN (`View.js:77-79`) and `foldHostBag`.
|
|
100
|
+
// A raw `id` is a key no ViewConfig declares, so Fabric drops it and the nativeID is lost.
|
|
101
|
+
if (Object.hasOwn(next, 'id')) {
|
|
102
|
+
next.nativeID = next.id;
|
|
103
|
+
delete next.id;
|
|
104
|
+
}
|
|
105
|
+
return foldImagePayload(next);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// Returns the image, so `slotProps` / `slotPropsExcept` / `slotDerived` all point at it — and the
|
|
109
|
+
// app's children stay on the owner because of `slotTakesNoChildren`, not because of what this
|
|
110
|
+
// returns. The image is appended FIRST and nothing else is ever placed in front of it, which is
|
|
111
|
+
// what makes the children paint over it.
|
|
112
|
+
//
|
|
113
|
+
// Built WITHOUT handing `IMAGE_TAG` to `createElement`, deliberately: that would attach Image's own
|
|
114
|
+
// behavior and set `payloadFold` to `foldImagePayload` alone, and this node's style has to be
|
|
115
|
+
// derived from the owner. `payloadFold` is a single slot, so the composition is spelled here — and
|
|
116
|
+
// it still calls the one shared mapping rather than restating it.
|
|
117
|
+
function buildBackgroundImage(node) {
|
|
118
|
+
const descriptor = descriptorFor(IMAGE_TAG);
|
|
119
|
+
const image = createElement(descriptor.component, descriptor.isText);
|
|
120
|
+
image.payloadFold = imageFold(node);
|
|
121
|
+
appendChild(node, image);
|
|
122
|
+
return image;
|
|
123
|
+
}
|
|
124
|
+
const imageBackgroundBehavior = {
|
|
125
|
+
slotProps: IMAGE_BACKGROUND_SLOT_PROPS,
|
|
126
|
+
slotPropsExcept: IMAGE_BACKGROUND_HOST_PROPS,
|
|
127
|
+
slotDerived: IMAGE_BACKGROUND_SLOT_DERIVED,
|
|
128
|
+
slotTakesNoChildren: true,
|
|
129
|
+
buildStructure: buildBackgroundImage,
|
|
130
|
+
foldPayload: hostFold,
|
|
131
|
+
// Required by the interface and deliberately empty: this primitive owns no timer, no listener
|
|
132
|
+
// and no native handshake. Written out rather than shared with a `noop` so the emptiness reads
|
|
133
|
+
// as a decision.
|
|
134
|
+
attach() { },
|
|
135
|
+
detach() { },
|
|
136
|
+
};
|
|
137
|
+
export function registerImageBackgroundBehavior() {
|
|
138
|
+
registerHostBehavior(IMAGE_BACKGROUND_TAG, imageBackgroundBehavior);
|
|
139
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Image's host behavior, and it carries NOTHING but a prop fold — no listeners, no timers, no
|
|
2
|
+
// commit hook. That is what a "fold-only" primitive means: the wrapper's whole body was prop
|
|
3
|
+
// mapping, so its lowered form owes exactly that and nothing else.
|
|
4
|
+
//
|
|
5
|
+
// The fold itself is not written here. `mapImageProps` in `../view/render-image` is the one
|
|
6
|
+
// implementation, and this file only narrows a flat prop bag into the typed view it takes — the
|
|
7
|
+
// same shape `behaviors/text-input.ts` uses (`stringOf` / `booleanOf` guards, then hand the shared
|
|
8
|
+
// resolver a typed object). A second copy of the mapping is the exact drift this primitive has
|
|
9
|
+
// already paid for once: `adapters/svelte/src/components/image/image-logic.ts` reproduces it by
|
|
10
|
+
// hand and says so in its own header, because nothing was exported to call.
|
|
11
|
+
//
|
|
12
|
+
// WHY THIS MAY SHARE THE WRAPPER'S TAG, where TextInput needed `-managed`. A behavior fold is keyed
|
|
13
|
+
// on the tag, and `renderImage` emits `image` too — so on a wrapper-built node this fold
|
|
14
|
+
// runs on ALREADY-FOLDED props. That is safe here and only here, because the mapping is idempotent:
|
|
15
|
+
// every alias it consumes (`src`, `srcSet`, `alt`, `width`, `height`, …) is absent from its own
|
|
16
|
+
// output, `source` comes back in the array shape `normalizeSource` guarantees, and
|
|
17
|
+
// `loadingIndicatorSource` leaves under a DIFFERENT name (`loadingIndicatorSrc`), so a second pass
|
|
18
|
+
// finds nothing left to fold. Idempotence is asserted in `image.test.ts` rather than assumed — the
|
|
19
|
+
// audit rule's point is that a double fold is invisible precisely when it happens to be harmless,
|
|
20
|
+
// so the property has to be pinned or it is an accident waiting to be broken.
|
|
21
|
+
import { registerHostBehavior, } from '@symbiote-native/engine';
|
|
22
|
+
import { IMAGE_VIEW_PROP_NAMES, mapImageProps, } from '../view/render-image/index.js';
|
|
23
|
+
export const IMAGE_TAG = 'image';
|
|
24
|
+
const RESIZE_MODES = new Set([
|
|
25
|
+
'cover',
|
|
26
|
+
'contain',
|
|
27
|
+
'stretch',
|
|
28
|
+
'repeat',
|
|
29
|
+
'center',
|
|
30
|
+
]);
|
|
31
|
+
const CROSS_ORIGINS = new Set([
|
|
32
|
+
'anonymous',
|
|
33
|
+
'use-credentials',
|
|
34
|
+
]);
|
|
35
|
+
const CONSUMED = new Set(IMAGE_VIEW_PROP_NAMES);
|
|
36
|
+
function stringOf(value) {
|
|
37
|
+
return typeof value === 'string' ? value : undefined;
|
|
38
|
+
}
|
|
39
|
+
function numberOf(value) {
|
|
40
|
+
return typeof value === 'number' ? value : undefined;
|
|
41
|
+
}
|
|
42
|
+
function resizeModeOf(value) {
|
|
43
|
+
if (typeof value !== 'string' || !RESIZE_MODES.has(value))
|
|
44
|
+
return undefined;
|
|
45
|
+
// Narrowed by the set membership above, then re-stated as the union through a switch rather than
|
|
46
|
+
// a cast: the set and the type are two declarations of one list, and only this makes them agree.
|
|
47
|
+
switch (value) {
|
|
48
|
+
case 'cover':
|
|
49
|
+
case 'contain':
|
|
50
|
+
case 'stretch':
|
|
51
|
+
case 'repeat':
|
|
52
|
+
case 'center':
|
|
53
|
+
return value;
|
|
54
|
+
default:
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function crossOriginOf(value) {
|
|
59
|
+
if (typeof value !== 'string' || !CROSS_ORIGINS.has(value))
|
|
60
|
+
return undefined;
|
|
61
|
+
return value === 'anonymous' ? 'anonymous' : 'use-credentials';
|
|
62
|
+
}
|
|
63
|
+
// A source is an asset id (number), a `{uri}` object, or an array of those. Anything else cannot be
|
|
64
|
+
// resolved and is dropped rather than forwarded — a malformed source reaching Fabric paints nothing
|
|
65
|
+
// and reports nothing, which is worse than an image that is simply absent.
|
|
66
|
+
function sourceOf(value) {
|
|
67
|
+
if (typeof value === 'number')
|
|
68
|
+
return value;
|
|
69
|
+
if (typeof value !== 'object' || value === null)
|
|
70
|
+
return undefined;
|
|
71
|
+
if (Array.isArray(value))
|
|
72
|
+
return value;
|
|
73
|
+
if (typeof Reflect.get(value, 'uri') === 'string')
|
|
74
|
+
return { ...value };
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
// A StyleProp is an object, an array of them, or a registered class array — all of which
|
|
78
|
+
// `flattenStyle` already handles. The only thing to exclude is a scalar.
|
|
79
|
+
function styleOf(value) {
|
|
80
|
+
if (typeof value !== 'object' || value === null)
|
|
81
|
+
return undefined;
|
|
82
|
+
if (Array.isArray(value))
|
|
83
|
+
return value;
|
|
84
|
+
return { ...value };
|
|
85
|
+
}
|
|
86
|
+
export function foldImagePayload(props) {
|
|
87
|
+
const passthrough = {};
|
|
88
|
+
for (const key of Object.keys(props)) {
|
|
89
|
+
if (!CONSUMED.has(key))
|
|
90
|
+
passthrough[key] = props[key];
|
|
91
|
+
}
|
|
92
|
+
return mapImageProps({
|
|
93
|
+
source: sourceOf(props.source),
|
|
94
|
+
defaultSource: sourceOf(props.defaultSource),
|
|
95
|
+
loadingIndicatorSource: sourceOf(props.loadingIndicatorSource),
|
|
96
|
+
style: styleOf(props.style),
|
|
97
|
+
resizeMode: resizeModeOf(props.resizeMode),
|
|
98
|
+
tintColor: stringOf(props.tintColor),
|
|
99
|
+
src: stringOf(props.src),
|
|
100
|
+
srcSet: stringOf(props.srcSet),
|
|
101
|
+
alt: stringOf(props.alt),
|
|
102
|
+
width: numberOf(props.width),
|
|
103
|
+
height: numberOf(props.height),
|
|
104
|
+
crossOrigin: crossOriginOf(props.crossOrigin),
|
|
105
|
+
referrerPolicy: stringOf(props.referrerPolicy),
|
|
106
|
+
passthrough,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// Required by IHostBehavior and deliberately empty: Image owns no per-node runtime. Written out
|
|
110
|
+
// rather than shared with a `noop` helper so the emptiness reads as a decision.
|
|
111
|
+
function attach(_node) {
|
|
112
|
+
// nothing to set up
|
|
113
|
+
}
|
|
114
|
+
function detach(_node) {
|
|
115
|
+
// nothing to release
|
|
116
|
+
}
|
|
117
|
+
export function registerImageBehavior() {
|
|
118
|
+
registerHostBehavior(IMAGE_TAG, {
|
|
119
|
+
attach,
|
|
120
|
+
detach,
|
|
121
|
+
foldPayload: foldImagePayload,
|
|
122
|
+
});
|
|
123
|
+
}
|