@symbiote-native/components 0.4.0 → 1.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 +30 -8
- package/build/accessibility-props.d.ts +11 -0
- package/build/accessibility-props.js +30 -117
- 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 +2 -0
- package/build/behaviors/pressable.js +310 -0
- package/build/behaviors/switch.d.ts +2 -0
- package/build/behaviors/switch.js +182 -0
- package/build/behaviors/text-input.d.ts +14 -0
- package/build/behaviors/text-input.js +291 -0
- package/build/bootstrap/index.js +1 -1
- package/build/component-names/index.android.js +23 -1
- package/build/component-names/index.ios.js +9 -1
- package/build/component-names/shared.d.ts +1 -1
- package/build/component-names/shared.js +40 -1
- package/build/descriptor.d.ts +8 -0
- package/build/descriptor.js +27 -0
- package/build/fold-host-bag.d.ts +15 -0
- package/build/fold-host-bag.js +99 -0
- package/build/index.d.ts +25 -12
- package/build/index.js +18 -6
- package/build/resolve-intrinsic.d.ts +7 -0
- package/build/resolve-intrinsic.js +49 -0
- package/build/state/pressable.d.ts +9 -0
- package/build/state/pressable.js +133 -37
- package/build/state/sticky-header-reducer.js +28 -5
- package/build/state/switch.js +1 -1
- package/build/state/text-input.d.ts +7 -1
- package/build/state/text-input.js +46 -8
- package/build/state/touchable.d.ts +30 -1
- package/build/state/touchable.js +94 -5
- package/build/state/virtualized-list-diagnostics.d.ts +31 -0
- package/build/state/virtualized-list-diagnostics.js +33 -0
- package/build/state/virtualized-list-reducer.d.ts +14 -0
- package/build/state/virtualized-list-reducer.js +179 -45
- package/build/state/virtualized-list.d.ts +5 -2
- package/build/state/virtualized-list.js +143 -34
- package/build/state-style.d.ts +15 -0
- package/build/state-style.js +47 -0
- package/build/text-props.d.ts +9 -0
- package/build/text-props.js +25 -0
- package/build/view/render-activity-indicator.js +37 -3
- package/build/view/render-image/index.d.ts +2 -0
- package/build/view/render-image/index.js +42 -5
- package/build/view/render-input-accessory-view.d.ts +2 -0
- package/build/view/render-input-accessory-view.js +41 -6
- package/build/view/render-keyboard-avoiding-view.d.ts +14 -2
- package/build/view/render-keyboard-avoiding-view.js +52 -6
- package/build/view/render-modal.js +4 -2
- package/build/view/render-pressable/index.js +3 -1
- package/build/view/render-scroll-sticky.js +1 -1
- package/build/view/render-scroll-view.js +9 -3
- package/build/view/render-switch.js +11 -2
- package/build/view/render-text-input.js +6 -2
- package/build/view/render-touchable-highlight.d.ts +11 -1
- package/build/view/render-touchable-highlight.js +11 -10
- package/build/view/render-touchable-native-feedback.js +5 -1
- package/host-primitives.cjs +380 -0
- package/host-primitives.d.cts +35 -0
- package/lowering-fixtures.cjs +259 -0
- package/lowering-fixtures.d.cts +17 -0
- package/package.json +42 -5
- package/specialize-state-style.cjs +219 -0
- package/specialize-state-style.d.cts +15 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// The press machine as an ENGINE-NODE behavior, so a pressable can be an intrinsic tag instead of
|
|
2
|
+
// a framework component (`.claude/rules/host-primitive-tier.md`, tier 2). Written once; every
|
|
3
|
+
// adapter inherits it by registering, and none re-implements it.
|
|
4
|
+
//
|
|
5
|
+
// The machine itself is unchanged and still shared with the component path — `createPressRuntime`
|
|
6
|
+
// / `createPressHandlers` in `../state/pressable`. What is new is only WHERE its lifecycle lives:
|
|
7
|
+
// on the engine node rather than in a component instance.
|
|
8
|
+
//
|
|
9
|
+
// REGISTRATION IS THE HAZARD, not the machine. Metro enables `inlineRequires` in production only,
|
|
10
|
+
// moving a `require` to the first place its binding is used as a VALUE, and a barrel's
|
|
11
|
+
// `export { X } from './x'` compiles to a lazy getter. A module whose only job is a side effect is
|
|
12
|
+
// never named as a value, so re-exporting it means it NEVER RUNS in a release build — dev perfect,
|
|
13
|
+
// release silently pressless. Each adapter therefore keeps its own `src/register.ts` calling
|
|
14
|
+
// `registerPressableBehavior()`, and its entry does a bare `import './register';` that the barrel
|
|
15
|
+
// does NOT re-export. A bare `import './register';` sitting NEXT TO such a re-export does not work
|
|
16
|
+
// either: Babel merges the two imports of one specifier and the merged dependency stays lazy.
|
|
17
|
+
import { appListenerFor, dlog, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, } from '@symbiote-native/engine';
|
|
18
|
+
import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS, rippleProps, } from '../state/pressable.js';
|
|
19
|
+
import { buildPressableListeners, resolveDisabledAccessibilityState, } from '../view/render-pressable/index.js';
|
|
20
|
+
export const PRESSABLE_TAG = 'symbiote-pressable';
|
|
21
|
+
const states = new WeakMap();
|
|
22
|
+
function isRecord(value) {
|
|
23
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
function numberOr(value, fallback) {
|
|
26
|
+
return typeof value === 'number' ? value : fallback;
|
|
27
|
+
}
|
|
28
|
+
// A scalar offset or the per-edge object; anything else reads as "no offset", which the machine
|
|
29
|
+
// turns into RN's defaults. Same narrowing the component path does — kept here rather than shared
|
|
30
|
+
// because the component's version narrows Vue attrs, and this one narrows engine props.
|
|
31
|
+
function asRectOffset(value) {
|
|
32
|
+
if (typeof value === 'number')
|
|
33
|
+
return value;
|
|
34
|
+
if (!isRecord(value))
|
|
35
|
+
return undefined;
|
|
36
|
+
const rect = {};
|
|
37
|
+
if (typeof value.top === 'number')
|
|
38
|
+
rect.top = value.top;
|
|
39
|
+
if (typeof value.left === 'number')
|
|
40
|
+
rect.left = value.left;
|
|
41
|
+
if (typeof value.bottom === 'number')
|
|
42
|
+
rect.bottom = value.bottom;
|
|
43
|
+
if (typeof value.right === 'number')
|
|
44
|
+
rect.right = value.right;
|
|
45
|
+
return rect;
|
|
46
|
+
}
|
|
47
|
+
// A predicate rather than a bare `typeof`: `typeof x === 'function'` narrows to `Function`, which
|
|
48
|
+
// carries no call signature and cannot satisfy IPressHandler. Guard, never cast. Also used for the
|
|
49
|
+
// listener bag, whose values are `unknown` for the same reason.
|
|
50
|
+
function isPressHandler(value) {
|
|
51
|
+
return typeof value === 'function';
|
|
52
|
+
}
|
|
53
|
+
// The props the MACHINE consumes and the host must never see. The wrapper drops them by
|
|
54
|
+
// destructuring — they go into `createPressHandlers` / `buildPressableListeners` and are simply
|
|
55
|
+
// absent from the object it spreads onto its View. A lowered element has no destructure, so every
|
|
56
|
+
// one of them rode into the payload as a key no ViewConfig declares.
|
|
57
|
+
const MACHINE_ONLY_KEYS = [
|
|
58
|
+
// Consumed below and replaced by the resolved `nativeBackgroundAndroid` /
|
|
59
|
+
// `nativeForegroundAndroid`; the raw config is not a native prop.
|
|
60
|
+
'android_ripple',
|
|
61
|
+
'disabled',
|
|
62
|
+
'cancelable',
|
|
63
|
+
'delayLongPress',
|
|
64
|
+
'unstable_pressDelay',
|
|
65
|
+
'pressRetentionOffset',
|
|
66
|
+
'delayHoverIn',
|
|
67
|
+
'delayHoverOut',
|
|
68
|
+
];
|
|
69
|
+
// Narrowed field by field rather than cast: the bag arrives as `unknown` off `node.props`. A local
|
|
70
|
+
// twin of the guard each adapter keeps for its own attrs (Vue's `asAccessibilityState`) — not
|
|
71
|
+
// hoisted to the shared barrel, because every adapter re-exports that barrel wholesale and a
|
|
72
|
+
// narrowing helper is not API anyone should be able to import.
|
|
73
|
+
function asAccessibilityState(value) {
|
|
74
|
+
if (!isRecord(value))
|
|
75
|
+
return undefined;
|
|
76
|
+
const state = {};
|
|
77
|
+
if (typeof value.disabled === 'boolean')
|
|
78
|
+
state.disabled = value.disabled;
|
|
79
|
+
if (typeof value.selected === 'boolean')
|
|
80
|
+
state.selected = value.selected;
|
|
81
|
+
if (value.checked === 'mixed' || typeof value.checked === 'boolean')
|
|
82
|
+
state.checked = value.checked;
|
|
83
|
+
if (typeof value.busy === 'boolean')
|
|
84
|
+
state.busy = value.busy;
|
|
85
|
+
if (typeof value.expanded === 'boolean')
|
|
86
|
+
state.expanded = value.expanded;
|
|
87
|
+
return state;
|
|
88
|
+
}
|
|
89
|
+
// Narrowed field by field, same reason as the accessibility guard above: the config arrives as
|
|
90
|
+
// `unknown` off `node.props`.
|
|
91
|
+
function asRippleConfig(value) {
|
|
92
|
+
if (!isRecord(value))
|
|
93
|
+
return undefined;
|
|
94
|
+
const config = {};
|
|
95
|
+
if (typeof value.color === 'string')
|
|
96
|
+
config.color = value.color;
|
|
97
|
+
if (typeof value.borderless === 'boolean')
|
|
98
|
+
config.borderless = value.borderless;
|
|
99
|
+
if (typeof value.radius === 'number')
|
|
100
|
+
config.radius = value.radius;
|
|
101
|
+
if (typeof value.foreground === 'boolean')
|
|
102
|
+
config.foreground = value.foreground;
|
|
103
|
+
return config;
|
|
104
|
+
}
|
|
105
|
+
// `disabled` reaches a screen reader ONLY as `accessibilityState.disabled` — it is not a native
|
|
106
|
+
// View prop, so the wrapper folds it (`resolveDisabledAccessibilityState`, called by all five) and
|
|
107
|
+
// forwards the composite. Lowering dropped that fold: press suppression still worked, because the
|
|
108
|
+
// machine reads `node.props.disabled` directly, so the button behaved correctly and announced
|
|
109
|
+
// itself as enabled. An accessibility regression with no visual tell and no failing test.
|
|
110
|
+
//
|
|
111
|
+
// Found by the wrapper-vs-behavior import audit (`.claude/rules/adapter-parity-audit.md`).
|
|
112
|
+
function foldPayload(props) {
|
|
113
|
+
const resolved = resolveDisabledAccessibilityState(asAccessibilityState(props.accessibilityState), typeof props.disabled === 'boolean' ? props.disabled : undefined);
|
|
114
|
+
// The Android ripple. Our WRAPPER paints it through a dedicated inner View, mirroring
|
|
115
|
+
// TouchableNativeFeedback — and that reading is what made this look unfixable for a lowered
|
|
116
|
+
// element, which has no child to put it on. RN's own `Pressable` does NOT do that: it spreads
|
|
117
|
+
// `useAndroidRippleForView`'s `viewProps` onto its own View (`Pressable.js:251`), so the ripple
|
|
118
|
+
// background is an ordinary prop of the responder itself and a single node carries it fine.
|
|
119
|
+
//
|
|
120
|
+
// `rippleProps` returns undefined off Android, so this whole branch is inert on iOS.
|
|
121
|
+
//
|
|
122
|
+
// STILL MISSING ON BOTH PATHS, and lowering did not cause it: RN also dispatches
|
|
123
|
+
// `Commands.hotspotUpdate(x, y)` on pressIn/pressMove and `Commands.setPressed` on
|
|
124
|
+
// pressIn/pressOut, which is what makes the ripple originate at the touch point. Neither our
|
|
125
|
+
// wrapper nor this behavior sends them — grep for `hotspotUpdate` returns nothing in the tree.
|
|
126
|
+
const rippleConfig = asRippleConfig(props.android_ripple);
|
|
127
|
+
const ripple = rippleConfig !== undefined ? rippleProps(rippleConfig) : undefined;
|
|
128
|
+
const out = { ...props };
|
|
129
|
+
for (const key of MACHINE_ONLY_KEYS)
|
|
130
|
+
delete out[key];
|
|
131
|
+
if (ripple !== undefined)
|
|
132
|
+
Object.assign(out, ripple);
|
|
133
|
+
// Written only when the fold produced something: an unconditional assignment would put an
|
|
134
|
+
// `accessibilityState: undefined` key on every lowered Pressable in the tree, and `fabricProps`
|
|
135
|
+
// skipping undefined is a coincidence to lean on, not a contract to rely on here.
|
|
136
|
+
if (resolved !== undefined)
|
|
137
|
+
out.accessibilityState = resolved;
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
// From the STASH, not from `node.props`. Every name below is in `ownedListeners`, so `routeProp`
|
|
141
|
+
// diverts the app's `onPress` away from `node.listeners` (where it would evict the behavior's own
|
|
142
|
+
// dispatcher) and into the stash — which makes the stash the only place it exists. Reading
|
|
143
|
+
// `node.props` here returns undefined for every callback and every press silently does nothing:
|
|
144
|
+
// the behavior runs, the machine runs, and it calls nobody.
|
|
145
|
+
function callbackAt(node, event) {
|
|
146
|
+
const value = appListenerFor(node, event);
|
|
147
|
+
return isPressHandler(value) ? value : undefined;
|
|
148
|
+
}
|
|
149
|
+
// Callbacks come from `callbackAt` (the stash), scalars from `node.props`. The split is not
|
|
150
|
+
// cosmetic: `delayLongPress` and `hitSlop` are ordinary props that `fabricProps` drops as unknown
|
|
151
|
+
// keys, while `onPress` and friends are OWNED event names that never reach `node.props` at all.
|
|
152
|
+
function configFor(node) {
|
|
153
|
+
return {
|
|
154
|
+
onPress: callbackAt(node, 'press'),
|
|
155
|
+
onPressIn: callbackAt(node, 'pressIn'),
|
|
156
|
+
onPressOut: callbackAt(node, 'pressOut'),
|
|
157
|
+
onPressMove: callbackAt(node, 'pressMove'),
|
|
158
|
+
onLongPress: callbackAt(node, 'longPress'),
|
|
159
|
+
delayLongPress: numberOr(node.props.delayLongPress, DEFAULT_DELAY_LONG_PRESS_MS),
|
|
160
|
+
unstable_pressDelay: numberOr(node.props.unstable_pressDelay, 0),
|
|
161
|
+
hitSlop: asRectOffset(node.props.hitSlop),
|
|
162
|
+
pressRetentionOffset: asRectOffset(node.props.pressRetentionOffset),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// Rebuilding at GESTURE START is the whole reason for the dispatcher indirection, and skipping it
|
|
166
|
+
// is a bug that looks like working code. `attach` runs inside `createElement`, before a single
|
|
167
|
+
// prop has been routed — `node.props` is literally `{}` there — so a machine built at attach would
|
|
168
|
+
// capture no `onPress` at all and every press would silently do nothing. `createPressHandlers`
|
|
169
|
+
// destructures its config eagerly, so it cannot be handed a live view either; it has to be re-made
|
|
170
|
+
// once the props exist. A gesture is one interaction, so a handful of closures per press is
|
|
171
|
+
// invisible — unlike doing it per prop write, which is the cost this whole tier exists to remove.
|
|
172
|
+
function rebuild(node, state) {
|
|
173
|
+
const handlers = createPressHandlers(configFor(node), state.runtime, state.host);
|
|
174
|
+
state.isBuilt = true;
|
|
175
|
+
state.listeners = buildPressableListeners(handlers, {
|
|
176
|
+
disabled: node.props.disabled === true ? true : undefined,
|
|
177
|
+
cancelable: typeof node.props.cancelable === 'boolean'
|
|
178
|
+
? node.props.cancelable
|
|
179
|
+
: undefined,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// WHICHEVER EVENT OPENS THE GESTURE REBUILDS, and pinning that to one name was a real bug.
|
|
183
|
+
// `onStartShouldSetResponder` looks like the opener and is not: `core/engine/src/events/index.ts`
|
|
184
|
+
// bubbles PRESS_IN and only THEN calls `negotiateResponder`, so `pressIn` arrives FIRST on every
|
|
185
|
+
// gesture. Rebuilding only on the responder claim therefore handed the first `pressIn` an empty
|
|
186
|
+
// listener bag — the press-in half of every press was dropped, the pressed style never reached
|
|
187
|
+
// Fabric, and `onPress` still fired because by then the machine existed. That is exactly the
|
|
188
|
+
// device report: the callback works, the button does not light up.
|
|
189
|
+
//
|
|
190
|
+
// So the trigger is a FLAG, not a name: build if this gesture has not built yet, and clear it when
|
|
191
|
+
// the gesture ends. Order-independent, and it survives the engine reordering its own events.
|
|
192
|
+
//
|
|
193
|
+
// A key `buildPressableListeners` omitted — every one of them when `disabled` is true — resolves to
|
|
194
|
+
// undefined here and the dispatcher returns undefined, which is what an absent listener would do.
|
|
195
|
+
const GESTURE_END_KEYS = new Set([
|
|
196
|
+
'onPressOut',
|
|
197
|
+
'onResponderTerminationRequest',
|
|
198
|
+
]);
|
|
199
|
+
function dispatch(node, state, key, args) {
|
|
200
|
+
if (!state.isBuilt)
|
|
201
|
+
rebuild(node, state);
|
|
202
|
+
const listener = state.listeners[key];
|
|
203
|
+
const result = isPressHandler(listener)
|
|
204
|
+
? Reflect.apply(listener, undefined, args)
|
|
205
|
+
: undefined;
|
|
206
|
+
// AFTER the handler, not before: `handlePressOut` is what settles the machine, and clearing the
|
|
207
|
+
// flag first would let a re-entrant dispatch rebuild mid-gesture.
|
|
208
|
+
if (GESTURE_END_KEYS.has(key))
|
|
209
|
+
state.isBuilt = false;
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
// Installed straight into the listener slot rather than through `routeProp`: the behavior OWNS
|
|
213
|
+
// these names, and `setEventListener` diverts an owned name into the app stash — routing its own
|
|
214
|
+
// dispatcher through there would stash it and leave the slot empty.
|
|
215
|
+
//
|
|
216
|
+
// The engine event names, not the `onX` prop spellings. `buildPressableListeners` speaks the prop
|
|
217
|
+
// spelling, so the two are mapped here rather than guessed at either end.
|
|
218
|
+
const KEY_BY_EVENT = new Map([
|
|
219
|
+
['press', 'onPress'],
|
|
220
|
+
['pressIn', 'onPressIn'],
|
|
221
|
+
['pressOut', 'onPressOut'],
|
|
222
|
+
['startShouldSetResponder', 'onStartShouldSetResponder'],
|
|
223
|
+
['responderMove', 'onResponderMove'],
|
|
224
|
+
['responderTerminationRequest', 'onResponderTerminationRequest'],
|
|
225
|
+
]);
|
|
226
|
+
function installListeners(node, state) {
|
|
227
|
+
for (const [event, key] of KEY_BY_EVENT) {
|
|
228
|
+
setBehaviorListener(node, event, symbioteEvent => dispatch(node, state, key, [symbioteEvent]));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function attach(node) {
|
|
232
|
+
const timers = new Set();
|
|
233
|
+
const runtime = createPressRuntime();
|
|
234
|
+
const host = {
|
|
235
|
+
// The whole reason this tier is possible: the pressed state resolves BELOW the framework,
|
|
236
|
+
// through the style registry's `:active` variant, and never crosses into it.
|
|
237
|
+
setPressed: pressed => {
|
|
238
|
+
setNodePressed(node, pressed);
|
|
239
|
+
// Dirtying is not publishing. A press arrives from a native event, outside every renderer
|
|
240
|
+
// mutation path, so nothing schedules a commit — `native-events.ts` requests none, and no
|
|
241
|
+
// adapter does either. `setNodeHidden`'s React twin never hit this because the reconciler is
|
|
242
|
+
// already in its commit phase when it calls.
|
|
243
|
+
requestCommitFor(node);
|
|
244
|
+
},
|
|
245
|
+
// `measure` needs a committed Fabric tag, which a node has by the time a human can touch it.
|
|
246
|
+
// Returning undefined before then makes the machine fall back to its radius test rather than
|
|
247
|
+
// throwing, which is the behaviour the component path already relies on.
|
|
248
|
+
getMeasureFn: () => callback => node.measure(callback),
|
|
249
|
+
// Tracked so `detach` can cancel them. An un-cancelled long-press timer outlives the tree that
|
|
250
|
+
// owned it, and `removeChild` visits only a subtree ROOT — a pressable is normally nested.
|
|
251
|
+
schedule: (callback, ms) => {
|
|
252
|
+
const id = setTimeout(() => {
|
|
253
|
+
timers.delete(id);
|
|
254
|
+
callback();
|
|
255
|
+
}, ms);
|
|
256
|
+
timers.add(id);
|
|
257
|
+
return () => {
|
|
258
|
+
clearTimeout(id);
|
|
259
|
+
timers.delete(id);
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
now: Date.now,
|
|
263
|
+
};
|
|
264
|
+
const state = {
|
|
265
|
+
runtime,
|
|
266
|
+
host,
|
|
267
|
+
timers,
|
|
268
|
+
listeners: {},
|
|
269
|
+
isBuilt: false,
|
|
270
|
+
};
|
|
271
|
+
states.set(node, state);
|
|
272
|
+
installListeners(node, state);
|
|
273
|
+
}
|
|
274
|
+
function detach(node) {
|
|
275
|
+
const state = states.get(node);
|
|
276
|
+
if (state === undefined)
|
|
277
|
+
return;
|
|
278
|
+
// The machine's own teardown, which every wrapper calls from its destroy hook. Not load-bearing
|
|
279
|
+
// here and no test can make it so: `host.schedule` puts every timer the machine arms into
|
|
280
|
+
// `state.timers` — the 130ms floor's deferred `pressOut` included — so the loop below already
|
|
281
|
+
// cancels them. Kept as the contract, and for a timer armed by some future route.
|
|
282
|
+
disposePressRuntime(state.runtime);
|
|
283
|
+
for (const id of state.timers)
|
|
284
|
+
clearTimeout(id);
|
|
285
|
+
state.timers.clear();
|
|
286
|
+
states.delete(node);
|
|
287
|
+
dlog('pressable behavior detached');
|
|
288
|
+
}
|
|
289
|
+
// Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
|
|
290
|
+
// same tag with an equivalent behavior must not double-install anything.
|
|
291
|
+
export function registerPressableBehavior() {
|
|
292
|
+
registerHostBehavior(PRESSABLE_TAG, {
|
|
293
|
+
attach,
|
|
294
|
+
detach,
|
|
295
|
+
foldPayload,
|
|
296
|
+
// Every name the machine needs as an INPUT. The responder pair is not optional — it is how a
|
|
297
|
+
// gesture starts at all, and `RESPONDER_EVENTS` makes those listeners on any node regardless
|
|
298
|
+
// of ViewConfig, so they collide exactly like `press` does.
|
|
299
|
+
ownedListeners: [
|
|
300
|
+
'press',
|
|
301
|
+
'pressIn',
|
|
302
|
+
'pressOut',
|
|
303
|
+
'pressMove',
|
|
304
|
+
'longPress',
|
|
305
|
+
'startShouldSetResponder',
|
|
306
|
+
'responderMove',
|
|
307
|
+
'responderTerminationRequest',
|
|
308
|
+
],
|
|
309
|
+
});
|
|
310
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Switch's machine, on the engine node instead of inside a framework component — the second
|
|
2
|
+
// STATEFUL primitive after TextInput (`.claude/rules/host-primitive-tier.md`, "cheapest: tag
|
|
3
|
+
// exists, no handle. lastNativeReport -> dispatchViewCommand").
|
|
4
|
+
//
|
|
5
|
+
// WHAT A `Switch` COMPONENT ACTUALLY DOES, and why none of it needs a framework. It mirrors the
|
|
6
|
+
// value native LAST REPORTED (not the value the app authors), and when the two disagree — the app
|
|
7
|
+
// rejected the toggle, or its handler is a no-op — it commands native back down with the
|
|
8
|
+
// platform's snap-back command (Switch.js:221-225: iOS `setValue`, Android `setNativeValue`). The
|
|
9
|
+
// TEMPLATE reads none of it.
|
|
10
|
+
//
|
|
11
|
+
// WHY THE DIVERGENCE CHECK IS DEFERRED A MICROTASK, NOT RUN SYNCHRONOUSLY INSIDE `onChange`. The
|
|
12
|
+
// obvious place to compare "what native just reported" against "what the app currently authors" is
|
|
13
|
+
// right where the report arrives. It is wrong: an ACCEPTED toggle updates the app's own state, and
|
|
14
|
+
// that state reaches `node.props.value` only once the app's OWN reconciliation runs — which, for
|
|
15
|
+
// every adapter here, happens strictly after `onChange` returns, never during it. Checking
|
|
16
|
+
// synchronously would read the STALE pre-accept value and send a spurious snap-back on every
|
|
17
|
+
// accepted toggle. The wrapper avoids this by running its own check from an effect that fires AFTER
|
|
18
|
+
// the app's state update has flowed into a new render (`useLayoutEffect`, `$effect`, a post-flush
|
|
19
|
+
// `watch`); Angular's OWN switch component (`adapters/angular/src/components/switch/shared.ts`,
|
|
20
|
+
// `snapBackIfNeeded`) already solves the identical problem for a node with no render the same way —
|
|
21
|
+
// `queueMicrotask` then a plain read of the current value — and this mirrors it rather than routing
|
|
22
|
+
// through `IHostBehavior.afterCommit`.
|
|
23
|
+
//
|
|
24
|
+
// `afterCommit` was the first design and it does NOT work here: it requires a commit that actually
|
|
25
|
+
// changes something to reach `runDeferredAttaches` at all (`commit.ts`'s `!result.changed` early
|
|
26
|
+
// return — the same gate `text-input.test.ts` documents), and this check on its own writes no prop.
|
|
27
|
+
// Pairing it with `requestCommitFor` — which schedules a commit but writes nothing either — hits
|
|
28
|
+
// exactly that early return and never fires; proven wrong by this file's own test before it shipped.
|
|
29
|
+
// `queueMicrotask` sidesteps the whole question: it needs no commit, only a later turn of the
|
|
30
|
+
// microtask queue, and `dispatchViewCommand` is an imperative call to native independent of Fabric's
|
|
31
|
+
// prop-commit pipeline anyway.
|
|
32
|
+
//
|
|
33
|
+
// The residual gap this leaves, and it is why `afterCommit` is STILL registered below: a check
|
|
34
|
+
// scheduled only from `onChange` never re-runs for a prop change with no preceding native event —
|
|
35
|
+
// e.g. the app moves `value` on its own initiative while a PAST toggle's disagreement is still
|
|
36
|
+
// unresolved. `afterCommit` costs nothing extra (it fires only on a commit that already changed
|
|
37
|
+
// something) and closes that one case; the microtask path is what the no-op-handler case actually
|
|
38
|
+
// needs.
|
|
39
|
+
import { appListenerFor, dispatchViewCommand, dlog, Platform, registerHostBehavior, setBehaviorListener, } from '@symbiote-native/engine';
|
|
40
|
+
import { createInitialSwitchState, shouldSnapBack, switchReducer, valueFromChange, } from '../state/switch.js';
|
|
41
|
+
// The LOWERED tag — NOT the wrapper's `symbiote-switch-managed` (`render-switch.ts`). One owner
|
|
42
|
+
// per node: the wrapper already runs this same machine in its own lifecycle, so registering here
|
|
43
|
+
// under the tag it emits would attach a second, redundant copy.
|
|
44
|
+
export const SWITCH_TAG = 'symbiote-switch';
|
|
45
|
+
const states = new WeakMap();
|
|
46
|
+
function stateOf(node) {
|
|
47
|
+
return states.get(node);
|
|
48
|
+
}
|
|
49
|
+
function stringOf(value) {
|
|
50
|
+
return typeof value === 'string' ? value : undefined;
|
|
51
|
+
}
|
|
52
|
+
function booleanOf(value) {
|
|
53
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
54
|
+
}
|
|
55
|
+
// `{ false?, true? }` narrowed at runtime — an authored object arriving through an untyped bag
|
|
56
|
+
// cannot be trusted to the type system without reading it field by field, the same idiom
|
|
57
|
+
// `text-input.ts`'s `selectionOf` uses for `{ start, end? }`.
|
|
58
|
+
function trackColorOf(value) {
|
|
59
|
+
if (typeof value !== 'object' || value === null)
|
|
60
|
+
return undefined;
|
|
61
|
+
const bag = { ...value };
|
|
62
|
+
const falseColor = stringOf(bag.false);
|
|
63
|
+
const trueColor = stringOf(bag.true);
|
|
64
|
+
if (falseColor === undefined && trueColor === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
return { false: falseColor, true: trueColor };
|
|
67
|
+
}
|
|
68
|
+
// The wrapper-body prop fold this primitive owes its lowered form: `trackColor` / `thumbColor` /
|
|
69
|
+
// `ios_backgroundColor` are AUTHORED names, none of them a real Fabric prop — RN's Switch view
|
|
70
|
+
// declares `onTintColor`/`tintColor` (iOS) or `trackColorFor*`/`trackTintColor` (Android), plus
|
|
71
|
+
// `thumbTintColor`. `render-switch.ts` folds them for the wrapper path via an adapter-supplied
|
|
72
|
+
// `ISwitchPlatform`; a lowered node has no adapter to supply one, so this reads `Platform.OS`
|
|
73
|
+
// directly — the same fact every adapter's own index.ios.ts/index.android.ts already encodes as a
|
|
74
|
+
// literal, just read once here instead of five times.
|
|
75
|
+
function trackColorPropsFor(value, trackColor) {
|
|
76
|
+
if (Platform.OS === 'android') {
|
|
77
|
+
return {
|
|
78
|
+
trackColorForFalse: trackColor?.false,
|
|
79
|
+
trackColorForTrue: trackColor?.true,
|
|
80
|
+
trackTintColor: value ? trackColor?.true : trackColor?.false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
onTintColor: trackColor?.true,
|
|
85
|
+
tintColor: trackColor?.false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// RN rounds the iOS background pill to this radius when `ios_backgroundColor` is set — the same
|
|
89
|
+
// constant `render-switch.ts` uses, kept independent rather than exported+imported for one
|
|
90
|
+
// primitive-local literal (see that file for the upstream fact it encodes).
|
|
91
|
+
const IOS_BACKGROUND_BORDER_RADIUS = 16;
|
|
92
|
+
// The platform-specific imperative command RN's own Switch sends to correct a rejected toggle
|
|
93
|
+
// (Switch.js:221-225) — read off Platform.OS for the same reason `trackColorPropsFor` is.
|
|
94
|
+
function snapBackCommand() {
|
|
95
|
+
return Platform.OS === 'android' ? 'setNativeValue' : 'setValue';
|
|
96
|
+
}
|
|
97
|
+
function foldPayload(props) {
|
|
98
|
+
const value = props.value === true;
|
|
99
|
+
const trackColor = trackColorOf(props.trackColor);
|
|
100
|
+
const iosBackground = stringOf(props.ios_backgroundColor);
|
|
101
|
+
const out = {
|
|
102
|
+
...props,
|
|
103
|
+
value,
|
|
104
|
+
disabled: booleanOf(props.disabled),
|
|
105
|
+
...trackColorPropsFor(value, trackColor),
|
|
106
|
+
thumbTintColor: stringOf(props.thumbColor),
|
|
107
|
+
style: iosBackground === undefined
|
|
108
|
+
? props.style
|
|
109
|
+
: [
|
|
110
|
+
props.style,
|
|
111
|
+
{
|
|
112
|
+
backgroundColor: iosBackground,
|
|
113
|
+
borderRadius: IOS_BACKGROUND_BORDER_RADIUS,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
// The authored names themselves must NOT ride along: none is a Fabric prop, and leaving them in
|
|
118
|
+
// the payload is how a reader concludes the fold ran when it did not.
|
|
119
|
+
delete out.trackColor;
|
|
120
|
+
delete out.thumbColor;
|
|
121
|
+
delete out.ios_backgroundColor;
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
// Shared by both triggers — see the module header for why there are two.
|
|
125
|
+
function evaluateSnapBack(node) {
|
|
126
|
+
const state = stateOf(node);
|
|
127
|
+
if (state === undefined)
|
|
128
|
+
return; // detached before this ran
|
|
129
|
+
const fabricValue = node.props.value === true;
|
|
130
|
+
if (!shouldSnapBack(state, fabricValue)) {
|
|
131
|
+
dlog(`Switch behavior snap-back no-op reported=${String(state.lastNativeReport)} value=${fabricValue}`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
dlog(`Switch behavior ${snapBackCommand()} snap-back reported=${String(state.lastNativeReport)} value=${fabricValue}`);
|
|
135
|
+
dispatchViewCommand(node, snapBackCommand(), [fabricValue]);
|
|
136
|
+
}
|
|
137
|
+
function onChange(node, event) {
|
|
138
|
+
const state = stateOf(node);
|
|
139
|
+
if (state === undefined)
|
|
140
|
+
return;
|
|
141
|
+
const value = valueFromChange(event);
|
|
142
|
+
if (value === undefined)
|
|
143
|
+
return;
|
|
144
|
+
dlog(`Switch behavior onChange value=${String(value)} eventCount=${String(event.nativeEvent.eventCount)}`);
|
|
145
|
+
states.set(node, switchReducer(state, { type: 'native-reported', value }));
|
|
146
|
+
// `onValueChange` is not a Fabric event — it is a fold the component wrapper does over the raw
|
|
147
|
+
// `change` payload (same class as TextInput's `onValueChange`, `text-input.ts`'s
|
|
148
|
+
// `callValueChange`), so it lands in `node.props` as a plain function key rather than through
|
|
149
|
+
// `ownedListeners`.
|
|
150
|
+
const listener = node.props.onValueChange;
|
|
151
|
+
if (typeof listener === 'function')
|
|
152
|
+
listener(value, event);
|
|
153
|
+
// A raw `change` listener authored directly on the bare tag — not part of any adapter's public
|
|
154
|
+
// surface today, but `change` is the name this behavior's own dispatcher owns
|
|
155
|
+
// (`ownedListeners` below), so one is stashed rather than silently evicting the machine.
|
|
156
|
+
const rawListener = appListenerFor(node, 'change');
|
|
157
|
+
if (typeof rawListener === 'function')
|
|
158
|
+
rawListener(event);
|
|
159
|
+
// See the module header: deferred so an ACCEPTING app's own state update has a turn of the
|
|
160
|
+
// microtask queue to reach `node.props.value` first.
|
|
161
|
+
queueMicrotask(() => evaluateSnapBack(node));
|
|
162
|
+
}
|
|
163
|
+
function attach(node) {
|
|
164
|
+
states.set(node, createInitialSwitchState());
|
|
165
|
+
setBehaviorListener(node, 'change', event => onChange(node, event));
|
|
166
|
+
}
|
|
167
|
+
function detach(node) {
|
|
168
|
+
states.delete(node);
|
|
169
|
+
}
|
|
170
|
+
// Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
|
|
171
|
+
// same tag with an equivalent behavior must not double-install anything.
|
|
172
|
+
export function registerSwitchBehavior() {
|
|
173
|
+
registerHostBehavior(SWITCH_TAG, {
|
|
174
|
+
attach,
|
|
175
|
+
// See the module header: closes the residual case a microtask scheduled only from `onChange`
|
|
176
|
+
// cannot — a prop change with no preceding native event.
|
|
177
|
+
afterCommit: evaluateSnapBack,
|
|
178
|
+
detach,
|
|
179
|
+
foldPayload,
|
|
180
|
+
ownedListeners: ['change'],
|
|
181
|
+
});
|
|
182
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ISymbioteNode } from '@symbiote-native/engine';
|
|
2
|
+
import { type ITextInputHandle } from '../state/text-input';
|
|
3
|
+
export declare const TEXT_INPUT_TAG = "symbiote-text-input";
|
|
4
|
+
export declare const TEXT_INPUT_MULTILINE_TAG = "symbiote-text-input-multiline";
|
|
5
|
+
/**
|
|
6
|
+
* The imperative API RN exposes on a TextInput ref, built over the engine node. Reached through
|
|
7
|
+
* each adapter's own `host-instance` accessor — the capability, not a shape
|
|
8
|
+
* (`.claude/rules/adapter-parity-audit.md`).
|
|
9
|
+
*
|
|
10
|
+
* `focus`/`blur` are native view commands; `clear` and `setSelection` reuse `setTextAndSelection`,
|
|
11
|
+
* the same stale-safe path a controlled write takes, so they cannot race a keystroke either.
|
|
12
|
+
*/
|
|
13
|
+
export declare function buildTextInputHandle(node: ISymbioteNode): ITextInputHandle;
|
|
14
|
+
export declare function registerTextInputBehavior(): void;
|