@symbiote-native/components 0.5.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 +20 -6
- package/build/accessibility-props.d.ts +11 -0
- package/build/accessibility-props.js +30 -118
- 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/component-names/index.android.js +22 -0
- package/build/component-names/index.ios.js +8 -0
- package/build/component-names/shared.d.ts +1 -1
- package/build/component-names/shared.js +36 -0
- package/build/fold-host-bag.d.ts +15 -0
- package/build/fold-host-bag.js +99 -0
- package/build/index.d.ts +6 -1
- package/build/index.js +6 -1
- 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 +120 -34
- package/build/state/text-input.d.ts +6 -1
- package/build/state/text-input.js +11 -0
- package/build/state-style.d.ts +15 -0
- package/build/state-style.js +47 -0
- package/build/text-props.js +2 -1
- package/build/view/render-activity-indicator.js +26 -0
- 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-switch.js +7 -1
- package/build/view/render-text-input.js +6 -2
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ISymbioteEvent } from '@symbiote-native/engine';
|
|
2
2
|
export declare const DEFAULT_DELAY_LONG_PRESS_MS = 500;
|
|
3
|
+
export declare const DEFAULT_MIN_PRESS_DURATION_MS = 130;
|
|
3
4
|
export declare const DEFAULT_PRESS_RECT_OFFSETS: {
|
|
4
5
|
top: number;
|
|
5
6
|
left: number;
|
|
@@ -56,19 +57,26 @@ export interface IPressRuntime {
|
|
|
56
57
|
longPressCancel: (() => void) | undefined;
|
|
57
58
|
longPressFired: boolean;
|
|
58
59
|
pressDelayCancel: (() => void) | undefined;
|
|
60
|
+
pressOutCancel: (() => void) | undefined;
|
|
59
61
|
pressOrigin: {
|
|
60
62
|
x: number;
|
|
61
63
|
y: number;
|
|
62
64
|
} | undefined;
|
|
63
65
|
driftedOut: boolean;
|
|
64
66
|
region: IResponderRegion | undefined;
|
|
67
|
+
active: boolean;
|
|
68
|
+
activatedAt: number | undefined;
|
|
69
|
+
delayElapsed: boolean;
|
|
70
|
+
disposed: boolean;
|
|
65
71
|
}
|
|
66
72
|
export declare function createPressRuntime(): IPressRuntime;
|
|
73
|
+
export declare function disposePressRuntime(runtime: IPressRuntime): void;
|
|
67
74
|
export type IFrameCallback = (x: number, y: number, width: number, height: number, pageX: number, pageY: number) => void;
|
|
68
75
|
export interface IPressHost {
|
|
69
76
|
setPressed: (pressed: boolean) => void;
|
|
70
77
|
getMeasureFn: () => ((callback: IFrameCallback) => void) | undefined;
|
|
71
78
|
schedule: (callback: () => void, ms: number) => () => void;
|
|
79
|
+
now: () => number;
|
|
72
80
|
}
|
|
73
81
|
export interface IPressMachineConfig {
|
|
74
82
|
onPress?: IPressHandler;
|
|
@@ -78,6 +86,7 @@ export interface IPressMachineConfig {
|
|
|
78
86
|
onLongPress?: IPressHandler;
|
|
79
87
|
delayLongPress: number;
|
|
80
88
|
unstable_pressDelay: number;
|
|
89
|
+
minPressDuration?: number;
|
|
81
90
|
hitSlop?: IRectOffset;
|
|
82
91
|
pressRetentionOffset?: IRectOffset;
|
|
83
92
|
}
|
package/build/state/pressable.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// the decision of when each callback fires) is here, shared by every adapter.
|
|
12
12
|
import { dlog, Platform } from '@symbiote-native/engine';
|
|
13
13
|
export const DEFAULT_DELAY_LONG_PRESS_MS = 500;
|
|
14
|
+
// Pressability's default active-visual floor for a plain Pressable. Touchable* overrides this to 0.
|
|
15
|
+
export const DEFAULT_MIN_PRESS_DURATION_MS = 130;
|
|
14
16
|
// RN's default extra slop kept around a press once it is active, before a drift fires pressOut.
|
|
15
17
|
// The PressRect extension (Pressability.js DEFAULT_PRESS_RECT_OFFSETS). Per-edge, deeper bottom.
|
|
16
18
|
export const DEFAULT_PRESS_RECT_OFFSETS = {
|
|
@@ -91,11 +93,40 @@ export function createPressRuntime() {
|
|
|
91
93
|
longPressCancel: undefined,
|
|
92
94
|
longPressFired: false,
|
|
93
95
|
pressDelayCancel: undefined,
|
|
96
|
+
pressOutCancel: undefined,
|
|
94
97
|
pressOrigin: undefined,
|
|
95
98
|
driftedOut: false,
|
|
96
99
|
region: undefined,
|
|
100
|
+
active: false,
|
|
101
|
+
activatedAt: undefined,
|
|
102
|
+
delayElapsed: false,
|
|
103
|
+
disposed: false,
|
|
97
104
|
};
|
|
98
105
|
}
|
|
106
|
+
function cancelRuntimeTimer(runtime, key) {
|
|
107
|
+
const cancel = runtime[key];
|
|
108
|
+
if (cancel === undefined)
|
|
109
|
+
return;
|
|
110
|
+
cancel();
|
|
111
|
+
runtime[key] = undefined;
|
|
112
|
+
}
|
|
113
|
+
// RN Pressability.reset(): cancel every timer and make any already-queued callback inert. The
|
|
114
|
+
// adapter invokes this exactly once from its own unmount/destroy hook.
|
|
115
|
+
export function disposePressRuntime(runtime) {
|
|
116
|
+
if (runtime.disposed)
|
|
117
|
+
return;
|
|
118
|
+
runtime.disposed = true;
|
|
119
|
+
cancelRuntimeTimer(runtime, 'longPressCancel');
|
|
120
|
+
cancelRuntimeTimer(runtime, 'pressDelayCancel');
|
|
121
|
+
cancelRuntimeTimer(runtime, 'pressOutCancel');
|
|
122
|
+
runtime.longPressFired = false;
|
|
123
|
+
runtime.pressOrigin = undefined;
|
|
124
|
+
runtime.driftedOut = false;
|
|
125
|
+
runtime.region = undefined;
|
|
126
|
+
runtime.active = false;
|
|
127
|
+
runtime.activatedAt = undefined;
|
|
128
|
+
runtime.delayElapsed = false;
|
|
129
|
+
}
|
|
99
130
|
// Measure the responder's on-screen frame and cache it as the retention region for the life of
|
|
100
131
|
// the press (RN measures on responder grant, _measureResponderRegion). When measure is
|
|
101
132
|
// unavailable (no node yet, an uncommitted node, or a host slot without a measure method,
|
|
@@ -123,7 +154,7 @@ function measureRegion(runtime, measureFn) {
|
|
|
123
154
|
// (the React adapter's useMemo body) lives here, shared by every adapter. The adapter rebuilds
|
|
124
155
|
// these per render (the closures capture the live config) while the runtime persists across them.
|
|
125
156
|
export function createPressHandlers(config, runtime, host) {
|
|
126
|
-
const { onPress, onPressIn, onPressOut, onPressMove, onLongPress, delayLongPress, unstable_pressDelay, } = config;
|
|
157
|
+
const { onPress, onPressIn, onPressOut, onPressMove, onLongPress, delayLongPress, unstable_pressDelay, minPressDuration = DEFAULT_MIN_PRESS_DURATION_MS, } = config;
|
|
127
158
|
// Per-edge offsets for the measured-rect retention test (RN's hitSlop + pressRectOffset).
|
|
128
159
|
// pressRetentionOffset defaults to RN's DEFAULT_PRESS_RECT_OFFSETS when unset; hitSlop to zero.
|
|
129
160
|
const hitSlopRect = normalizeRect(config.hitSlop);
|
|
@@ -145,20 +176,28 @@ export function createPressHandlers(config, runtime, host) {
|
|
|
145
176
|
return (Math.hypot(point.x - origin.x, point.y - origin.y) <= fallbackThreshold);
|
|
146
177
|
}
|
|
147
178
|
function clearLongPress() {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
179
|
+
cancelRuntimeTimer(runtime, 'longPressCancel');
|
|
180
|
+
}
|
|
181
|
+
function clearPressDelay() {
|
|
182
|
+
cancelRuntimeTimer(runtime, 'pressDelayCancel');
|
|
183
|
+
}
|
|
184
|
+
function clearPressOut() {
|
|
185
|
+
cancelRuntimeTimer(runtime, 'pressOutCancel');
|
|
152
186
|
}
|
|
153
|
-
// The real activation: flip pressed-state on, arm the long-press timer, fire onPressIn. Split
|
|
154
|
-
// out so unstable_pressDelay can defer it behind a timer while an early release flushes it.
|
|
155
187
|
function activate(event) {
|
|
188
|
+
if (runtime.disposed || runtime.active || runtime.driftedOut)
|
|
189
|
+
return;
|
|
190
|
+
// A new grant or drift-back-in supersedes a delayed out from the previous active interval.
|
|
191
|
+
clearPressOut();
|
|
192
|
+
runtime.active = true;
|
|
193
|
+
runtime.activatedAt = host.now();
|
|
156
194
|
dlog('Pressable pressIn');
|
|
157
195
|
host.setPressed(true);
|
|
158
|
-
runtime.longPressFired
|
|
159
|
-
if (onLongPress) {
|
|
196
|
+
if (!runtime.longPressFired && onLongPress) {
|
|
160
197
|
runtime.longPressCancel = host.schedule(() => {
|
|
161
198
|
runtime.longPressCancel = undefined;
|
|
199
|
+
if (runtime.disposed || !runtime.active || runtime.driftedOut)
|
|
200
|
+
return;
|
|
162
201
|
runtime.longPressFired = true;
|
|
163
202
|
dlog('Pressable longPress timer fired');
|
|
164
203
|
onLongPress(event);
|
|
@@ -166,51 +205,99 @@ export function createPressHandlers(config, runtime, host) {
|
|
|
166
205
|
}
|
|
167
206
|
onPressIn?.(event);
|
|
168
207
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
208
|
+
function deactivate(event) {
|
|
209
|
+
if (runtime.disposed || !runtime.active)
|
|
210
|
+
return;
|
|
211
|
+
runtime.active = false;
|
|
212
|
+
clearLongPress();
|
|
213
|
+
const activatedAt = runtime.activatedAt ?? host.now();
|
|
214
|
+
runtime.activatedAt = undefined;
|
|
215
|
+
const heldFor = Math.max(0, host.now() - activatedAt);
|
|
216
|
+
const wait = Math.max(minPressDuration - heldFor, 0);
|
|
217
|
+
const finish = () => {
|
|
218
|
+
runtime.pressOutCancel = undefined;
|
|
219
|
+
if (runtime.disposed)
|
|
220
|
+
return;
|
|
221
|
+
host.setPressed(false);
|
|
222
|
+
onPressOut?.(event);
|
|
223
|
+
};
|
|
224
|
+
if (wait > 0) {
|
|
225
|
+
dlog(`Pressable pressOut deferred ${wait}ms`);
|
|
226
|
+
runtime.pressOutCancel = host.schedule(finish, wait);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
finish();
|
|
176
230
|
}
|
|
177
231
|
}
|
|
232
|
+
// An honest release arrives as press then pressOut. If it beat delayPressIn, activate now so the
|
|
233
|
+
// tap still flashes and calls onPressIn; a cancelled/out-of-bounds gesture never calls this path.
|
|
234
|
+
function completePressDelay(event) {
|
|
235
|
+
if (runtime.pressDelayCancel !== undefined)
|
|
236
|
+
clearPressDelay();
|
|
237
|
+
if (!runtime.delayElapsed)
|
|
238
|
+
runtime.delayElapsed = true;
|
|
239
|
+
if (!runtime.driftedOut)
|
|
240
|
+
activate(event);
|
|
241
|
+
}
|
|
242
|
+
function resetGesture() {
|
|
243
|
+
runtime.pressOrigin = undefined;
|
|
244
|
+
runtime.region = undefined;
|
|
245
|
+
runtime.driftedOut = false;
|
|
246
|
+
runtime.delayElapsed = false;
|
|
247
|
+
runtime.longPressFired = false;
|
|
248
|
+
}
|
|
178
249
|
return {
|
|
179
250
|
handlePressIn(event) {
|
|
251
|
+
if (runtime.disposed)
|
|
252
|
+
return;
|
|
253
|
+
// A new grant cancels a delayed out from the prior gesture, matching Pressability's
|
|
254
|
+
// onResponderGrant -> _cancelPressOutDelayTimeout.
|
|
255
|
+
clearPressDelay();
|
|
256
|
+
clearLongPress();
|
|
257
|
+
clearPressOut();
|
|
258
|
+
runtime.active = false;
|
|
259
|
+
runtime.activatedAt = undefined;
|
|
260
|
+
runtime.longPressFired = false;
|
|
261
|
+
runtime.delayElapsed = false;
|
|
180
262
|
runtime.pressOrigin = readPoint(event);
|
|
181
263
|
runtime.driftedOut = false;
|
|
182
|
-
// Measure now so the move stream tests against the real frame, not a press-start radius.
|
|
183
264
|
measureRegion(runtime, host.getMeasureFn());
|
|
184
265
|
if (unstable_pressDelay > 0) {
|
|
185
266
|
dlog(`Pressable pressIn deferred ${unstable_pressDelay}ms`);
|
|
186
267
|
runtime.pressDelayCancel = host.schedule(() => {
|
|
187
268
|
runtime.pressDelayCancel = undefined;
|
|
188
|
-
|
|
269
|
+
if (runtime.disposed)
|
|
270
|
+
return;
|
|
271
|
+
runtime.delayElapsed = true;
|
|
272
|
+
if (!runtime.driftedOut)
|
|
273
|
+
activate(event);
|
|
189
274
|
}, unstable_pressDelay);
|
|
190
275
|
return;
|
|
191
276
|
}
|
|
277
|
+
runtime.delayElapsed = true;
|
|
192
278
|
activate(event);
|
|
193
279
|
},
|
|
194
280
|
handlePressOut(event) {
|
|
281
|
+
if (runtime.disposed)
|
|
282
|
+
return;
|
|
195
283
|
dlog('Pressable pressOut');
|
|
196
|
-
|
|
284
|
+
// A cancellation before delayPressIn must stay inert. An honest release already flushed the
|
|
285
|
+
// delay in handlePress, because the engine emits press immediately before pressOut.
|
|
286
|
+
clearPressDelay();
|
|
197
287
|
clearLongPress();
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
host.setPressed(false);
|
|
201
|
-
onPressOut?.(event);
|
|
288
|
+
deactivate(event);
|
|
289
|
+
resetGesture();
|
|
202
290
|
},
|
|
203
291
|
handlePress(event) {
|
|
292
|
+
if (runtime.disposed)
|
|
293
|
+
return;
|
|
204
294
|
dlog('Pressable press');
|
|
205
|
-
|
|
295
|
+
completePressDelay(event);
|
|
206
296
|
clearLongPress();
|
|
207
|
-
// A drift past the retention region cancels the tap (RN); reset and suppress.
|
|
208
297
|
if (runtime.driftedOut) {
|
|
209
|
-
runtime.driftedOut = false;
|
|
210
298
|
dlog('Pressable press suppressed by drift past retention region');
|
|
211
299
|
return;
|
|
212
300
|
}
|
|
213
|
-
// A fired long press cancels the tap: reset the flag and suppress onPress.
|
|
214
301
|
if (runtime.longPressFired) {
|
|
215
302
|
runtime.longPressFired = false;
|
|
216
303
|
dlog('Pressable press suppressed by prior longPress');
|
|
@@ -218,11 +305,9 @@ export function createPressHandlers(config, runtime, host) {
|
|
|
218
305
|
}
|
|
219
306
|
onPress?.(event);
|
|
220
307
|
},
|
|
221
|
-
// Responder-move stream of the Pressable's own View. onPressMove fires on every move while
|
|
222
|
-
// the press is live (RN). Then the retention test: a move outside the measured region
|
|
223
|
-
// (expanded by hitSlop+pressRectOffset) fires an early pressOut and marks the tap suppressed;
|
|
224
|
-
// a move back inside re-activates.
|
|
225
308
|
handleResponderMove(event) {
|
|
309
|
+
if (runtime.disposed)
|
|
310
|
+
return;
|
|
226
311
|
onPressMove?.(event);
|
|
227
312
|
const here = readPoint(event);
|
|
228
313
|
if (!here)
|
|
@@ -232,14 +317,15 @@ export function createPressHandlers(config, runtime, host) {
|
|
|
232
317
|
dlog('Pressable drifted past retention region — deactivating');
|
|
233
318
|
runtime.driftedOut = true;
|
|
234
319
|
clearLongPress();
|
|
235
|
-
|
|
236
|
-
|
|
320
|
+
// Before delayPressIn there was no activation, so there is no onPressOut to emit.
|
|
321
|
+
deactivate(event);
|
|
237
322
|
}
|
|
238
323
|
}
|
|
239
324
|
else if (runtime.driftedOut) {
|
|
240
325
|
dlog('Pressable returned inside retention region — reactivating');
|
|
241
326
|
runtime.driftedOut = false;
|
|
242
|
-
|
|
327
|
+
if (runtime.delayElapsed)
|
|
328
|
+
activate(event);
|
|
243
329
|
}
|
|
244
330
|
},
|
|
245
331
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IPlatformOSType, ISymbioteEvent, ITextStyle } from '@symbiote-native/engine';
|
|
1
|
+
import type { IMeasureOnSuccess, IMeasureInWindowOnSuccess, IMeasureLayoutOnSuccess, ISymbioteNode, IPlatformOSType, ISymbioteEvent, ITextStyle } from '@symbiote-native/engine';
|
|
2
2
|
import type { IAccessibilityProps, IAriaProps } from '../accessibility-props';
|
|
3
3
|
export type IInputMode = 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
|
|
4
4
|
export type IEnterKeyHint = 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send';
|
|
@@ -95,10 +95,15 @@ export type ITextInputProps = IAccessibilityProps & IAriaProps & {
|
|
|
95
95
|
onSelectionChange?: ITextInputEventHandler;
|
|
96
96
|
onContentSizeChange?: ITextInputEventHandler;
|
|
97
97
|
};
|
|
98
|
+
export declare const TEXT_INPUT_CALLBACK_NAMES: readonly string[];
|
|
98
99
|
export type ITextInputHandle = {
|
|
99
100
|
focus(): void;
|
|
100
101
|
blur(): void;
|
|
101
102
|
clear(): void;
|
|
102
103
|
isFocused(): boolean;
|
|
103
104
|
setSelection(start: number, end: number): void;
|
|
105
|
+
measure(callback: IMeasureOnSuccess): void;
|
|
106
|
+
measureInWindow(callback: IMeasureInWindowOnSuccess): void;
|
|
107
|
+
measureLayout(relativeToNativeNode: ISymbioteNode | number, onSuccess: IMeasureLayoutOnSuccess, onFail?: () => void): void;
|
|
108
|
+
setNativeProps(nativeProps: Record<string, unknown>): void;
|
|
104
109
|
};
|
|
@@ -230,3 +230,14 @@ export function resolveTextInputProps(input) {
|
|
|
230
230
|
: input.showSoftInputOnFocus,
|
|
231
231
|
};
|
|
232
232
|
}
|
|
233
|
+
const TEXT_INPUT_CALLBACKS = {
|
|
234
|
+
onValueChange: true,
|
|
235
|
+
onFocus: true,
|
|
236
|
+
onBlur: true,
|
|
237
|
+
onEndEditing: true,
|
|
238
|
+
onSubmitEditing: true,
|
|
239
|
+
onKeyPress: true,
|
|
240
|
+
onSelectionChange: true,
|
|
241
|
+
onContentSizeChange: true,
|
|
242
|
+
};
|
|
243
|
+
export const TEXT_INPUT_CALLBACK_NAMES = Object.keys(TEXT_INPUT_CALLBACKS);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface IPressStateArgument {
|
|
2
|
+
pressed: boolean;
|
|
3
|
+
}
|
|
4
|
+
export interface IResolvedStateStyle {
|
|
5
|
+
style: unknown;
|
|
6
|
+
activeStyle: unknown;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Splits an authored `style` into its resting and pressed halves, reading the value once.
|
|
10
|
+
*
|
|
11
|
+
* A non-callback passes through untouched with no active variant, which the engine reads as "leave
|
|
12
|
+
* slot 1 alone" — that is what makes one emission safe for an expression whose value cannot be
|
|
13
|
+
* known at compile time.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveStateStyle(value: unknown): IResolvedStateStyle;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The runtime half of state-style lowering: splits an authored `style` into its resting and pressed
|
|
2
|
+
// halves. Referenced by the code every lowering transform EMITS, never by app code.
|
|
3
|
+
//
|
|
4
|
+
// SHARED for the same reason `HOST_PRIMITIVES` and `specializeStateStyle` are — two adapters had
|
|
5
|
+
// written byte-identical copies within an hour of each other, which is the duplication
|
|
6
|
+
// `<adapters_stay_thin>` exists to stop. Each adapter re-exports it from its own `./state-style`
|
|
7
|
+
// subpath so the emitted import specifier stays inside the package the app already depends on.
|
|
8
|
+
//
|
|
9
|
+
// WHY A HELPER EXISTS BESIDE THE INLINE GUARD, since a transform may emit either. Building the pair
|
|
10
|
+
// needs the authored `style` expression, and the two emissions are NOT equivalent:
|
|
11
|
+
//
|
|
12
|
+
// inline typeof e === 'function' ? e({pressed:false}) : e prints `e` on both props, so a
|
|
13
|
+
// `getStyle()` runs twice and a `flag ? a : b` can take DIFFERENT branches
|
|
14
|
+
// helper resolveStateStyle(e) prints `e` once and calls its
|
|
15
|
+
// RESULT twice
|
|
16
|
+
//
|
|
17
|
+
// The rule the two must satisfy is `REFUSAL_CATEGORIES.emitStyleExpressionOnce`, and it is about
|
|
18
|
+
// the output rather than the input: **an expression capable of DOING WORK is printed once.** A bare
|
|
19
|
+
// name or a non-computed dotted path is a read, not work, so the inline form is legitimate there —
|
|
20
|
+
// and it matters, because a helper returns one object, which a framework must spread, and a spread
|
|
21
|
+
// costs the element its patch flag (Vue measured `12 /* STYLE, PROPS */` becoming
|
|
22
|
+
// `16 /* FULL_PROPS */` plus a `mergeProps` per render on the hottest element in the tree).
|
|
23
|
+
//
|
|
24
|
+
// So the VERDICT is identical across adapters — every shape lowers — and only the cost differs,
|
|
25
|
+
// exactly as compile-time substitution differs between a JSX path and an SFC one. The verdicts are
|
|
26
|
+
// what `@symbiote-native/components/lowering-fixtures` pins.
|
|
27
|
+
//
|
|
28
|
+
// THE CONTRACT THIS IMPOSES ON APP CODE: a style callback must be PURE in `pressed`. Its result is
|
|
29
|
+
// invoked twice under every emission, so a side-effecting body is observable now.
|
|
30
|
+
function isStyleCallback(value) {
|
|
31
|
+
return typeof value === 'function';
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Splits an authored `style` into its resting and pressed halves, reading the value once.
|
|
35
|
+
*
|
|
36
|
+
* A non-callback passes through untouched with no active variant, which the engine reads as "leave
|
|
37
|
+
* slot 1 alone" — that is what makes one emission safe for an expression whose value cannot be
|
|
38
|
+
* known at compile time.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveStateStyle(value) {
|
|
41
|
+
if (!isStyleCallback(value))
|
|
42
|
+
return { style: value, activeStyle: undefined };
|
|
43
|
+
return {
|
|
44
|
+
style: value({ pressed: false }),
|
|
45
|
+
activeStyle: value({ pressed: true }),
|
|
46
|
+
};
|
|
47
|
+
}
|
package/build/text-props.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The defaults RN's Text.js applies before handing props to native, folded once for every adapter.
|
|
2
2
|
//
|
|
3
|
-
// RN does this unconditionally on the outer (non-virtual) path, Text.js:
|
|
3
|
+
// RN does this unconditionally on the outer (non-virtual) path, Text.js:289 and :291 (the two
|
|
4
|
+
// lines between them, 288 and 290, are `accessible` and `disabled` — not part of this fold):
|
|
4
5
|
//
|
|
5
6
|
// processedProps.allowFontScaling = allowFontScaling !== false;
|
|
6
7
|
// processedProps.ellipsizeMode = ellipsizeMode ?? 'tail';
|
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
// ACTIVITYINDICATOR IS NOT A LOWERABLE PRIMITIVE, AND WILL NOT BECOME ONE. Decided 2026-09-01.
|
|
2
|
+
// It stays a component in every adapter; do not add it to `HOST_PRIMITIVES`.
|
|
3
|
+
//
|
|
4
|
+
// The reason is the `el('symbiote-view', wrapperProps, [el('symbiote-activity-indicator', …)])` at
|
|
5
|
+
// the bottom of this file: the render SYNTHESIZES a node that is not the primitive itself. A lowered
|
|
6
|
+
// tag is ONE engine node, and a host behavior's `foldPayload` maps props to props on that node — it
|
|
7
|
+
// cannot create a child. Lowering would therefore drop the centering container and change layout,
|
|
8
|
+
// which is an optimisation moving the observable surface, the one thing this design may not do.
|
|
9
|
+
//
|
|
10
|
+
// The wrapper is not over-building: RN's own `ActivityIndicator.js:112` renders a `<View>` around
|
|
11
|
+
// the native spinner too, so the two nodes are inherent. And the container cannot be folded INTO the
|
|
12
|
+
// spinner — it carries `alignItems`/`justifyContent`, which centre the spinner inside the space it
|
|
13
|
+
// was given; moved onto the spinner they would centre its children, of which it has none.
|
|
14
|
+
//
|
|
15
|
+
// The two alternatives were priced and both cost more than the primitive is worth. A behavior that
|
|
16
|
+
// CREATES a node needs a commit hook, i.e. a machine, i.e. a `-managed` tag split — a new category,
|
|
17
|
+
// not a fold. Synthesising the container inside the engine's commit walk is NOT the `RCTVirtualText`
|
|
18
|
+
// precedent it resembles: `viewNameFor` changes what one node IS and never ADDS one, so adding one
|
|
19
|
+
// would make the retained and committed trees disagree on node count — which every counter, census
|
|
20
|
+
// probe and benchmark in this repo assumes. Spinners are rare on a screen, so the per-instance cost
|
|
21
|
+
// lowering removes is not measurably paid here; the objection to leaving it is uniformity, not speed.
|
|
22
|
+
//
|
|
23
|
+
// Under half A this costs an app NOTHING: `View` and `ActivityIndicator` are both ordinary imports
|
|
24
|
+
// from the adapter barrel, so which one is internally a tag and which a component is invisible at
|
|
25
|
+
// the call site. The general rule is in `.claude/rules/host-primitive-tier.md`.
|
|
26
|
+
//
|
|
1
27
|
// ActivityIndicator: the render half (framework-agnostic). RN wraps the native spinner
|
|
2
28
|
// in a centering View and translates `size` in JS: 'small'/'large' map to a native size
|
|
3
29
|
// enum AND a fixed box style; a numeric size never reaches native (it sizes the spinner
|
|
@@ -53,4 +53,6 @@ export type IImageViewProps = {
|
|
|
53
53
|
referrerPolicy?: string;
|
|
54
54
|
passthrough: Record<string, unknown>;
|
|
55
55
|
};
|
|
56
|
+
export declare const IMAGE_VIEW_PROP_NAMES: readonly ["source", "defaultSource", "loadingIndicatorSource", "style", "resizeMode", "tintColor", "src", "srcSet", "alt", "width", "height", "crossOrigin", "referrerPolicy"];
|
|
57
|
+
export declare function mapImageProps(view: IImageViewProps): Record<string, unknown>;
|
|
56
58
|
export declare function renderImage(view: IImageViewProps): IDescriptor;
|
|
@@ -112,7 +112,31 @@ function readSourceUri(source) {
|
|
|
112
112
|
}
|
|
113
113
|
return undefined;
|
|
114
114
|
}
|
|
115
|
-
|
|
115
|
+
// The names `renderImage` CONSUMES rather than forwards — the fold's input list, and the reason it
|
|
116
|
+
// is exported: an adapter that splits props before calling renderImage was copying this list by
|
|
117
|
+
// hand, and a name added here reached the copy only if someone remembered. The Image behavior's
|
|
118
|
+
// `foldPayload` reads it too, so the flat path and the view path cannot drift apart.
|
|
119
|
+
export const IMAGE_VIEW_PROP_NAMES = [
|
|
120
|
+
'source',
|
|
121
|
+
'defaultSource',
|
|
122
|
+
'loadingIndicatorSource',
|
|
123
|
+
'style',
|
|
124
|
+
'resizeMode',
|
|
125
|
+
'tintColor',
|
|
126
|
+
'src',
|
|
127
|
+
'srcSet',
|
|
128
|
+
'alt',
|
|
129
|
+
'width',
|
|
130
|
+
'height',
|
|
131
|
+
'crossOrigin',
|
|
132
|
+
'referrerPolicy',
|
|
133
|
+
];
|
|
134
|
+
// The whole of Image's prop mapping, with the `el()` wrap taken off. Split out because a LOWERED
|
|
135
|
+
// `<symbiote-image>` has no component body to run it in: its behavior's `foldPayload` needs the same
|
|
136
|
+
// bag at commit time, and a second implementation of this is the exact drift this file already paid
|
|
137
|
+
// for once (`adapters/svelte/src/components/image/image-logic.ts` reproduces it by hand, with a
|
|
138
|
+
// comment saying it had to because nothing here was exported).
|
|
139
|
+
export function mapImageProps(view) {
|
|
116
140
|
// `width` / `height` aliases fold into style (ImageProps.js:195,202); explicit
|
|
117
141
|
// style keys win, matching RN's `{width, height}, ...style` ordering.
|
|
118
142
|
const foldedStyle = view.width === undefined && view.height === undefined
|
|
@@ -138,6 +162,9 @@ export function renderImage(view) {
|
|
|
138
162
|
if (view.loadingIndicatorSource !== undefined) {
|
|
139
163
|
mapped.loadingIndicatorSrc = readSourceUri(view.loadingIndicatorSource);
|
|
140
164
|
}
|
|
165
|
+
return mapped;
|
|
166
|
+
}
|
|
167
|
+
export function renderImage(view) {
|
|
141
168
|
dlog('Image -> RCTImageView');
|
|
142
|
-
return el('symbiote-image',
|
|
169
|
+
return el('symbiote-image', mapImageProps(view));
|
|
143
170
|
}
|
|
@@ -6,4 +6,6 @@ export type IInputAccessoryViewViewProps = {
|
|
|
6
6
|
style?: IStyleProp<IViewStyle>;
|
|
7
7
|
passthrough: Record<string, unknown>;
|
|
8
8
|
};
|
|
9
|
+
export declare const INPUT_ACCESSORY_VIEW_PROP_NAMES: readonly ["nativeID", "backgroundColor", "style"];
|
|
10
|
+
export declare function mapInputAccessoryViewProps(view: IInputAccessoryViewViewProps): Record<string, unknown>;
|
|
9
11
|
export declare function renderInputAccessoryView(view: IInputAccessoryViewViewProps): IDescriptor;
|
|
@@ -6,16 +6,48 @@
|
|
|
6
6
|
// verbatim across adapters: React and Vue both bridge this Descriptor.
|
|
7
7
|
import { dlog, } from '@symbiote-native/engine';
|
|
8
8
|
import { el } from '../descriptor.js';
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
// The names this fold CONSUMES, exported so an adapter splitting props before calling the render fn
|
|
10
|
+
// reads the list instead of copying it — the drift `render-image.ts` records paying for once.
|
|
11
|
+
export const INPUT_ACCESSORY_VIEW_PROP_NAMES = [
|
|
12
|
+
'nativeID',
|
|
13
|
+
'backgroundColor',
|
|
14
|
+
'style',
|
|
15
|
+
];
|
|
16
|
+
// The whole mapping, so the wrapper path and the lowered path share ONE implementation. There is no
|
|
17
|
+
// aliasing here at all: every consumed name leaves under the same name, which is what makes the
|
|
18
|
+
// fold idempotent by construction (asserted in `behaviors/input-accessory-view.test.ts`, not
|
|
19
|
+
// assumed).
|
|
20
|
+
//
|
|
21
|
+
// The `undefined` guards on `nativeID` / `backgroundColor` are LOAD-BEARING, and the asymmetry with
|
|
22
|
+
// the unguarded `style` above is not the tell it looks like.
|
|
23
|
+
//
|
|
24
|
+
// They were removed on 2026-09-01 on the reasoning `.claude/rules/fabric-boolean-event-gates.md`
|
|
25
|
+
// states — `setProp` collapses an undefined value to an absent key, so a conditional write in a
|
|
26
|
+
// shared render fn is cosmetic — and that reasoning was checked against a payload carrying no `id`,
|
|
27
|
+
// which is exactly the case where it holds. The equivalence oracle caught it on its first run:
|
|
28
|
+
//
|
|
29
|
+
// authored <InputAccessoryView id="p" testID="p" />
|
|
30
|
+
// guarded RCTInputAccessoryView{testID, nativeID:"p"}
|
|
31
|
+
// unguarded RCTInputAccessoryView{testID} <- the alias result, deleted
|
|
32
|
+
//
|
|
33
|
+
// `nativeID` has an ALIAS SOURCE. The wrapper leaves `id` in `passthrough`, the renderer's
|
|
34
|
+
// PROP_ALIASES renames it to `nativeID`, and a `nativeID: undefined` written afterwards deletes what
|
|
35
|
+
// the rename just produced — last write wins, and `undefined` collapsing to "absent" is precisely
|
|
36
|
+
// what makes it destructive rather than inert. So the guard is cosmetic for a key nothing else can
|
|
37
|
+
// produce, and required for a key an alias also targets.
|
|
38
|
+
export function mapInputAccessoryViewProps(view) {
|
|
39
|
+
const out = {
|
|
11
40
|
...view.passthrough,
|
|
12
41
|
style: view.style,
|
|
13
42
|
};
|
|
14
43
|
if (view.nativeID !== undefined)
|
|
15
|
-
|
|
44
|
+
out.nativeID = view.nativeID;
|
|
16
45
|
if (view.backgroundColor !== undefined)
|
|
17
|
-
|
|
46
|
+
out.backgroundColor = view.backgroundColor;
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
export function renderInputAccessoryView(view) {
|
|
18
50
|
dlog('InputAccessoryView -> RCTInputAccessoryView');
|
|
19
51
|
// Empty structural children: the adapter appends the user children directly under the host.
|
|
20
|
-
return el('symbiote-input-accessory-view',
|
|
52
|
+
return el('symbiote-input-accessory-view', mapInputAccessoryViewProps(view), []);
|
|
21
53
|
}
|
|
@@ -8,6 +8,12 @@ import { dlog } from '@symbiote-native/engine';
|
|
|
8
8
|
import { el } from '../descriptor.js';
|
|
9
9
|
// RN rounds the iOS background pill to this radius when ios_backgroundColor is set.
|
|
10
10
|
const IOS_BACKGROUND_BORDER_RADIUS = 16;
|
|
11
|
+
// The `-managed` spelling, NOT the plain one the engine's Switch behavior registers under
|
|
12
|
+
// (`core/components/src/behaviors/switch.ts`) — the wrapper still runs its own lifecycle
|
|
13
|
+
// (lastNativeReport, the snap-back effect), so it may not share a tag the behavior registry would
|
|
14
|
+
// also attach to; see that behavior's module header and `.claude/rules/host-primitive-tier.md`,
|
|
15
|
+
// "A lowered element and its wrapper must not share an intrinsic tag" (the TextInput precedent).
|
|
16
|
+
const SWITCH_MANAGED_INTRINSIC = 'symbiote-switch-managed';
|
|
11
17
|
// Fold ios_backgroundColor into the style, matching RN's iOS branch: it paints the
|
|
12
18
|
// background that shows through the shrunken track. Untouched when unset, so a caller's own
|
|
13
19
|
// backgroundColor wins by simply not passing ios_backgroundColor.
|
|
@@ -32,5 +38,5 @@ export function renderSwitch(view, platform) {
|
|
|
32
38
|
thumbTintColor: view.thumbColor,
|
|
33
39
|
style: foldIosBackground(view.style, view.ios_backgroundColor),
|
|
34
40
|
};
|
|
35
|
-
return el(
|
|
41
|
+
return el(SWITCH_MANAGED_INTRINSIC, props);
|
|
36
42
|
}
|
|
@@ -8,8 +8,12 @@ import { dlog } from '@symbiote-native/engine';
|
|
|
8
8
|
import { el } from '../descriptor.js';
|
|
9
9
|
// One host element per native input class. Text carries the only non-trivial nesting elsewhere;
|
|
10
10
|
// here the choice is binary and runtime (the `multiline` prop), so the module stays flat.
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// The `-managed` spelling, NOT the plain one the lowering transforms emit. Both resolve to the
|
|
12
|
+
// same native view; the tag is what decides whether the engine's TextInput machine attaches, and
|
|
13
|
+
// on this path the adapter's own lifecycle already owns that state. Sharing the tag would run both
|
|
14
|
+
// copies at once. Full reasoning in `component-names/shared.ts`.
|
|
15
|
+
const SINGLELINE_INTRINSIC = 'symbiote-text-input-managed';
|
|
16
|
+
const MULTILINE_INTRINSIC = 'symbiote-text-input-multiline-managed';
|
|
13
17
|
export function renderTextInput(view) {
|
|
14
18
|
const intrinsic = view.multiline ? MULTILINE_INTRINSIC : SINGLELINE_INTRINSIC;
|
|
15
19
|
dlog(`TextInput render multiline=${String(view.multiline)} ` +
|