@ccheever/exact-renderer 0.1.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/package.json +118 -0
- package/src/__tests__/adapter-window-state.test.tsx +190 -0
- package/src/__tests__/attrs.test.ts +157 -0
- package/src/__tests__/classname.test.ts +332 -0
- package/src/__tests__/color.test.ts +169 -0
- package/src/__tests__/dom-mirror.test.ts +682 -0
- package/src/__tests__/dom-shim.test.ts +274 -0
- package/src/__tests__/fixtures/SvelteCounter.svelte +7 -0
- package/src/__tests__/fixtures/SvelteInput.svelte +8 -0
- package/src/__tests__/host-config.test.ts +51 -0
- package/src/__tests__/host-ops.test.ts +2234 -0
- package/src/__tests__/image-source.test.ts +135 -0
- package/src/__tests__/liquid-glass.test.ts +72 -0
- package/src/__tests__/multi-root.test.ts +118 -0
- package/src/__tests__/native-view-events.test.ts +102 -0
- package/src/__tests__/nodes.test.ts +399 -0
- package/src/__tests__/normalize.test.ts +576 -0
- package/src/__tests__/paragraph-lowering.test.tsx +144 -0
- package/src/__tests__/props.test.ts +518 -0
- package/src/__tests__/protocol-encoder.test.ts +732 -0
- package/src/__tests__/protocol-fixture-bytes.test.ts +41 -0
- package/src/__tests__/reconciler.test.tsx +241 -0
- package/src/__tests__/svelte-adapter.test.ts +166 -0
- package/src/__tests__/svg-source.test.ts +71 -0
- package/src/__tests__/tags.test.ts +354 -0
- package/src/__tests__/toggle.test.ts +441 -0
- package/src/__tests__/transitions.test.ts +106 -0
- package/src/__tests__/web-primitives.test.tsx +454 -0
- package/src/__tests__/window-hooks.test.tsx +447 -0
- package/src/adapter-contract.ts +68 -0
- package/src/attrs.ts +596 -0
- package/src/classname-contract.ts +87 -0
- package/src/classname-resolve.ts +553 -0
- package/src/classname-runtime.ts +29 -0
- package/src/components.ts +214 -0
- package/src/css-variable-context.ts +83 -0
- package/src/dom-hydration.ts +160 -0
- package/src/dom-mirror.ts +1459 -0
- package/src/dom-shim.ts +1736 -0
- package/src/group-context.ts +69 -0
- package/src/host-config.ts +431 -0
- package/src/host-ops.ts +3167 -0
- package/src/image-source.native.ts +703 -0
- package/src/image-source.ts +554 -0
- package/src/index.ts +278 -0
- package/src/inspector-runtime.ts +244 -0
- package/src/inspector.ts +3570 -0
- package/src/jsx-augmentations.ts +54 -0
- package/src/keyboard-avoidance.ts +217 -0
- package/src/native-primitives.ts +43 -0
- package/src/native-view-events.ts +322 -0
- package/src/native-view.ts +60 -0
- package/src/nodes/index.ts +41 -0
- package/src/nodes/node.ts +531 -0
- package/src/peer-context.ts +100 -0
- package/src/primitives.native.ts +8 -0
- package/src/primitives.ts +8 -0
- package/src/props/index.ts +14 -0
- package/src/props/normalize.ts +816 -0
- package/src/protocol/encoder.ts +940 -0
- package/src/protocol/index.ts +33 -0
- package/src/reconciler.ts +581 -0
- package/src/runtime.ts +11 -0
- package/src/safe-area.ts +543 -0
- package/src/solid.ts +490 -0
- package/src/style/color.js +1 -0
- package/src/style/color.ts +15 -0
- package/src/style/index.js +1 -0
- package/src/style/index.ts +22 -0
- package/src/style/normalize.js +1 -0
- package/src/style/normalize.ts +1426 -0
- package/src/svelte.ts +349 -0
- package/src/svg-source.ts +222 -0
- package/src/tags/index.ts +21 -0
- package/src/tags/tag-map.ts +289 -0
- package/src/text/paragraph-lowering.ts +310 -0
- package/src/types.ts +1175 -0
- package/src/vue.ts +535 -0
- package/src/web-host.ts +19 -0
- package/src/web-primitives.ts +1654 -0
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prop Normalization
|
|
3
|
+
*
|
|
4
|
+
* This module converts user-provided props into the canonical
|
|
5
|
+
* internal format used by the protocol encoder.
|
|
6
|
+
*
|
|
7
|
+
* Design Principles:
|
|
8
|
+
* - Web props are processed with web semantics (src, alt, etc.)
|
|
9
|
+
* - RN props are processed with RN semantics (source, resizeMode)
|
|
10
|
+
* - Clear prop extraction for different element types
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
AccessibilityActionDescriptor,
|
|
15
|
+
CanonicalProps,
|
|
16
|
+
CanonicalTagType,
|
|
17
|
+
ColorSchemeImageSource,
|
|
18
|
+
ImagePlaceholder,
|
|
19
|
+
ResizeMode,
|
|
20
|
+
ImageSource,
|
|
21
|
+
SelectableMode,
|
|
22
|
+
SvgSource,
|
|
23
|
+
} from '../types.js';
|
|
24
|
+
import { isAssetRef } from '@exact/core/assets-fonts-state';
|
|
25
|
+
import { lowerTextPropsToParagraphSpec } from '../text/paragraph-lowering.js';
|
|
26
|
+
import {
|
|
27
|
+
resolveImagePlaceholderForNative,
|
|
28
|
+
resolveImageSourceForNative,
|
|
29
|
+
warnIfImageMissingAlt,
|
|
30
|
+
} from '../image-source.js';
|
|
31
|
+
import { resolveSvgSourceForNative } from '../svg-source.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extract text content from children.
|
|
35
|
+
* Handles both string children and nested text.
|
|
36
|
+
*
|
|
37
|
+
* @param children - The children prop
|
|
38
|
+
* @returns The text content string, or undefined if not text
|
|
39
|
+
*/
|
|
40
|
+
export function extractTextContent(children: unknown): string | undefined {
|
|
41
|
+
if (children === null || children === undefined) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (typeof children === 'string') {
|
|
46
|
+
return children;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof children === 'number') {
|
|
50
|
+
return String(children);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (typeof children === 'boolean') {
|
|
54
|
+
// React ignores booleans
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Array of children - concatenate text
|
|
59
|
+
if (Array.isArray(children)) {
|
|
60
|
+
const parts: string[] = [];
|
|
61
|
+
for (const child of children) {
|
|
62
|
+
const text = extractTextContent(child);
|
|
63
|
+
if (text !== undefined) {
|
|
64
|
+
parts.push(text);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return parts.length > 0 ? parts.join('') : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (typeof children === 'object' && children !== null && 'props' in children) {
|
|
71
|
+
const props = (children as { props?: unknown }).props;
|
|
72
|
+
if (typeof props === 'object' && props !== null && 'children' in props) {
|
|
73
|
+
return extractTextContent((props as { children?: unknown }).children);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Extract image source from web-style props.
|
|
82
|
+
*
|
|
83
|
+
* @param props - The props object
|
|
84
|
+
* @returns The image source URL, or undefined
|
|
85
|
+
*/
|
|
86
|
+
export function extractWebImageSource(props: Record<string, unknown>): string | undefined {
|
|
87
|
+
const themed = imageSourceFromThemeProps(props);
|
|
88
|
+
if (themed) {
|
|
89
|
+
return resolveImageSourceForNative(themed);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const src = props.src;
|
|
93
|
+
if (typeof src === 'string' && src.length > 0) {
|
|
94
|
+
return src;
|
|
95
|
+
}
|
|
96
|
+
if (typeof src === 'object' && src !== null) {
|
|
97
|
+
return resolveImageSourceForNative(src as ImageSource);
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extract image source from RN-style props.
|
|
104
|
+
*
|
|
105
|
+
* @param props - The props object
|
|
106
|
+
* @returns The image source URL, or undefined
|
|
107
|
+
*/
|
|
108
|
+
export function extractRNImageSource(props: Record<string, unknown>): string | undefined {
|
|
109
|
+
const themed = imageSourceFromThemeProps(props);
|
|
110
|
+
if (themed) {
|
|
111
|
+
return resolveImageSourceForNative(themed);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const source = props.source as ImageSource | undefined;
|
|
115
|
+
if (!source) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return resolveImageSourceForNative(source);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function imageSourceFromThemeProps(props: Record<string, unknown>): ColorSchemeImageSource | undefined {
|
|
123
|
+
const light = typeof props.lightSrc === 'string' && props.lightSrc.length > 0
|
|
124
|
+
? props.lightSrc
|
|
125
|
+
: undefined;
|
|
126
|
+
const dark = typeof props.darkSrc === 'string' && props.darkSrc.length > 0
|
|
127
|
+
? props.darkSrc
|
|
128
|
+
: undefined;
|
|
129
|
+
if (!light && !dark) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
...(light ? { light } : {}),
|
|
135
|
+
...(dark ? { dark } : {}),
|
|
136
|
+
...(typeof props.src === 'string' && props.src.length > 0 ? { fallback: props.src } : {}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function extractSvgSource(props: Record<string, unknown>): string | undefined {
|
|
141
|
+
const source = props.source as SvgSource | undefined;
|
|
142
|
+
if (!source) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (typeof source === 'string' && source.length > 0) {
|
|
147
|
+
return source;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (isAssetRef(source)) {
|
|
151
|
+
return source.url;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (typeof source === 'object' && source !== null && typeof source.uri === 'string' && source.uri.length > 0) {
|
|
155
|
+
return source.uri;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function extractSelectableMode(value: unknown): SelectableMode | undefined {
|
|
162
|
+
if (typeof value === 'boolean') {
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (value === 'contain' || value === 'all') {
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function readBooleanProp(
|
|
174
|
+
props: Record<string, unknown>,
|
|
175
|
+
key: string
|
|
176
|
+
): boolean | undefined {
|
|
177
|
+
return typeof props[key] === 'boolean' ? props[key] as boolean : undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Compose the Liquid Glass inputs (`glassEffect`, `glass`, `glassVariant`,
|
|
182
|
+
* `glassTint`, `glassInteractive`) into the single `glassEffect` spec string
|
|
183
|
+
* the protocol carries. Returns `undefined` when no glass input is present at
|
|
184
|
+
* all (the caller keeps any boolean glassEffect the toggle path set, so the
|
|
185
|
+
* legacy truthy `"true"` value survives for native controls) and the explicit
|
|
186
|
+
* `"false"` spec for an explicit off — see the removal note in the body.
|
|
187
|
+
*
|
|
188
|
+
* Spec grammar: `<variant>` (`true` | `regular` | `clear`) optionally followed
|
|
189
|
+
* by `;tint=<color>` and/or `;interactive`. Examples: `"regular"`, `"clear"`,
|
|
190
|
+
* `"regular;tint=#7c4dffaa"`, `"clear;tint=#ff8800;interactive"`.
|
|
191
|
+
*/
|
|
192
|
+
function composeGlassSpec(
|
|
193
|
+
props: Record<string, unknown>,
|
|
194
|
+
existing: CanonicalProps['glassEffect'],
|
|
195
|
+
): string | boolean | undefined {
|
|
196
|
+
// `glass` is the Contract alias for `glassEffect`; accept either, and accept a
|
|
197
|
+
// string value (`glass="clear"`) as both enable + variant.
|
|
198
|
+
const enableRaw = props.glassEffect ?? props.glass;
|
|
199
|
+
// An explicit off wins even when a variant/tint is present, so a single
|
|
200
|
+
// `glass={flag}` toggle can switch a fully-specified surface on and off.
|
|
201
|
+
// Emit the explicit "false" spec rather than dropping the prop: the
|
|
202
|
+
// protocol's prop encoding has no removal op, so an omitted `glassEffect`
|
|
203
|
+
// leaves a previously glassy node's stale spec live on the native side and
|
|
204
|
+
// the material never tears down (ENG-22484 iOS visual pass). Both native
|
|
205
|
+
// parsers (`applyGlassSpec`) and the web fallback read "false" as off.
|
|
206
|
+
if (enableRaw === false || enableRaw === 'false') {
|
|
207
|
+
return 'false';
|
|
208
|
+
}
|
|
209
|
+
const variantInput =
|
|
210
|
+
readStringProp(props, 'glassVariant') ??
|
|
211
|
+
(typeof enableRaw === 'string' ? enableRaw : undefined);
|
|
212
|
+
const tint = readStringProp(props, 'glassTint');
|
|
213
|
+
const interactive = readBooleanProp(props, 'glassInteractive') === true;
|
|
214
|
+
const enabled =
|
|
215
|
+
enableRaw === true ||
|
|
216
|
+
typeof enableRaw === 'string' ||
|
|
217
|
+
variantInput !== undefined ||
|
|
218
|
+
tint !== undefined ||
|
|
219
|
+
interactive ||
|
|
220
|
+
existing === true ||
|
|
221
|
+
(typeof existing === 'string' && existing.length > 0 && existing !== 'false');
|
|
222
|
+
if (!enabled) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
const hasRefinement =
|
|
226
|
+
variantInput !== undefined || tint !== undefined || interactive;
|
|
227
|
+
if (!hasRefinement) {
|
|
228
|
+
// Bare enable: keep glassEffect a plain boolean. The encoder stringifies it
|
|
229
|
+
// to "true" (which the native toggle's `is_truthy` check matches and the
|
|
230
|
+
// container glass parser reads as the regular variant), so nothing is lost
|
|
231
|
+
// and the toggle path's boolean contract is preserved.
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
const variant = variantInput === 'clear' ? 'clear' : 'regular';
|
|
235
|
+
const parts = [variant];
|
|
236
|
+
if (tint !== undefined) {
|
|
237
|
+
parts.push(`tint=${tint}`);
|
|
238
|
+
}
|
|
239
|
+
if (interactive) {
|
|
240
|
+
parts.push('interactive');
|
|
241
|
+
}
|
|
242
|
+
return parts.join(';');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function readStringProp(
|
|
246
|
+
props: Record<string, unknown>,
|
|
247
|
+
key: string
|
|
248
|
+
): string | undefined {
|
|
249
|
+
return typeof props[key] === 'string' && (props[key] as string).length > 0
|
|
250
|
+
? props[key] as string
|
|
251
|
+
: undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function readNumberProp(
|
|
255
|
+
props: Record<string, unknown>,
|
|
256
|
+
key: string
|
|
257
|
+
): number | undefined {
|
|
258
|
+
return typeof props[key] === 'number' && isFinite(props[key] as number)
|
|
259
|
+
? props[key] as number
|
|
260
|
+
: undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
type PropAliasKind = 'string' | 'rawString' | 'boolean' | 'number' | 'truncNumber';
|
|
264
|
+
type PropAliasEncoding = readonly [keyof CanonicalProps, PropAliasKind, ...string[]];
|
|
265
|
+
|
|
266
|
+
const TEST_ID_PROP_ALIASES: readonly PropAliasEncoding[] = [
|
|
267
|
+
['testId', 'string', 'testId', 'testID', 'data-testid', 'data-test-id'],
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
const COMMON_PROP_ALIASES: readonly PropAliasEncoding[] = [
|
|
271
|
+
['inert', 'boolean', 'inert'],
|
|
272
|
+
['scrollLocked', 'boolean', 'scrollLocked'],
|
|
273
|
+
['portalTarget', 'string', 'portalTarget'],
|
|
274
|
+
['accessibilityRole', 'string', 'accessibilityRole', 'role'],
|
|
275
|
+
['accessibilityHint', 'string', 'accessibilityHint', 'aria-description'],
|
|
276
|
+
['accessibilityModal', 'boolean', 'accessibilityModal', 'aria-modal'],
|
|
277
|
+
['accessibilityExpanded', 'boolean', 'accessibilityExpanded', 'aria-expanded'],
|
|
278
|
+
['accessibilitySelected', 'boolean', 'accessibilitySelected', 'aria-selected'],
|
|
279
|
+
['accessibilityDisabled', 'boolean', 'accessibilityDisabled', 'aria-disabled'],
|
|
280
|
+
['accessibilityLive', 'string', 'accessibilityLive', 'aria-live'],
|
|
281
|
+
['accessibilityValueNow', 'number', 'accessibilityValueNow', 'aria-valuenow'],
|
|
282
|
+
['accessibilityValueMin', 'number', 'accessibilityValueMin', 'aria-valuemin'],
|
|
283
|
+
['accessibilityValueMax', 'number', 'accessibilityValueMax', 'aria-valuemax'],
|
|
284
|
+
['accessibilityValueText', 'string', 'accessibilityValueText', 'aria-valuetext'],
|
|
285
|
+
['nativeID', 'string', 'nativeID', 'id'],
|
|
286
|
+
['href', 'string', 'href'],
|
|
287
|
+
['accessibilityLabelledBy', 'string', 'accessibilityLabelledBy', 'aria-labelledby'],
|
|
288
|
+
['accessibilityDescribedBy', 'string', 'accessibilityDescribedBy', 'aria-describedby'],
|
|
289
|
+
['accessibilityBusy', 'boolean', 'accessibilityBusy', 'aria-busy'],
|
|
290
|
+
['accessibilityElementsHidden', 'boolean', 'accessibilityElementsHidden', 'aria-hidden'],
|
|
291
|
+
['accessibilityHeadingLevel', 'truncNumber', 'accessibilityHeadingLevel', 'aria-level'],
|
|
292
|
+
['accessibilitySynthetic', 'boolean', 'accessibilitySynthetic'],
|
|
293
|
+
['allowFontScaling', 'boolean', 'allowFontScaling'],
|
|
294
|
+
['maxFontSizeMultiplier', 'number', 'maxFontSizeMultiplier'],
|
|
295
|
+
['minimumFontSize', 'number', 'minimumFontSize'],
|
|
296
|
+
['__exactPresencePhase', 'string', '__exactPresencePhase'],
|
|
297
|
+
['__exactPortalLevel', 'string', '__exactPortalLevel'],
|
|
298
|
+
['__exactPortalPresentation', 'string', '__exactPortalPresentation'],
|
|
299
|
+
['__exactDismissableLayer', 'boolean', '__exactDismissableLayer'],
|
|
300
|
+
['__exactDismissAction', 'string', '__exactDismissAction'],
|
|
301
|
+
['__exactFocusRestore', 'boolean', '__exactFocusRestore'],
|
|
302
|
+
['__exactAnchorTarget', 'string', '__exactAnchorTarget'],
|
|
303
|
+
['__exactAnchorPlacement', 'string', '__exactAnchorPlacement'],
|
|
304
|
+
['__exactAnchorStrategy', 'string', '__exactAnchorStrategy'],
|
|
305
|
+
['__exactAnchorOffset', 'number', '__exactAnchorOffset'],
|
|
306
|
+
['__exactComponentName', 'string', '__exactComponentName'],
|
|
307
|
+
['__exactComponentSlot', 'string', '__exactComponentSlot'],
|
|
308
|
+
['__exactSourceFilePath', 'string', '__exactSourceFilePath'],
|
|
309
|
+
['__exactVariantProps', 'string', '__exactVariantProps'],
|
|
310
|
+
['__exactInteractionState', 'string', '__exactInteractionState'],
|
|
311
|
+
['__exactRenderMode', 'string', '__exactRenderMode'],
|
|
312
|
+
['disabled', 'boolean', 'disabled'],
|
|
313
|
+
['disabledReason', 'rawString', 'disabledReason'],
|
|
314
|
+
['agentSummary', 'rawString', 'agentSummary'],
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
const AGENT_ID_PROP_ALIASES: readonly PropAliasEncoding[] = [
|
|
318
|
+
['agentId', 'rawString', 'agentId'],
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
function readAliasProp(
|
|
322
|
+
props: Record<string, unknown>,
|
|
323
|
+
encoding: PropAliasEncoding,
|
|
324
|
+
): unknown {
|
|
325
|
+
const kind = encoding[1];
|
|
326
|
+
for (let index = 2; index < encoding.length; index += 1) {
|
|
327
|
+
const key = encoding[index] as string;
|
|
328
|
+
const value =
|
|
329
|
+
kind === 'string'
|
|
330
|
+
? readStringProp(props, key)
|
|
331
|
+
: kind === 'rawString'
|
|
332
|
+
? (typeof props[key] === 'string' ? props[key] as string : undefined)
|
|
333
|
+
: kind === 'boolean'
|
|
334
|
+
? readBooleanProp(props, key)
|
|
335
|
+
: readNumberProp(props, key);
|
|
336
|
+
|
|
337
|
+
if (value !== undefined) {
|
|
338
|
+
return kind === 'truncNumber' ? Math.trunc(value as number) : value;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function assignPropAliases(
|
|
345
|
+
canonical: CanonicalProps,
|
|
346
|
+
props: Record<string, unknown>,
|
|
347
|
+
encodings: readonly PropAliasEncoding[],
|
|
348
|
+
): void {
|
|
349
|
+
const target = canonical as Record<string, unknown>;
|
|
350
|
+
for (const encoding of encodings) {
|
|
351
|
+
const value = readAliasProp(props, encoding);
|
|
352
|
+
if (value !== undefined) {
|
|
353
|
+
target[encoding[0]] = value;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function normalizeAccessibilityActions(
|
|
359
|
+
value: unknown,
|
|
360
|
+
): string | undefined {
|
|
361
|
+
if (!Array.isArray(value)) {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const actions = value
|
|
366
|
+
.filter((candidate): candidate is AccessibilityActionDescriptor => (
|
|
367
|
+
typeof candidate === 'object' &&
|
|
368
|
+
candidate !== null &&
|
|
369
|
+
typeof (candidate as AccessibilityActionDescriptor).name === 'string'
|
|
370
|
+
))
|
|
371
|
+
.map((candidate) => ({
|
|
372
|
+
name: candidate.name.trim(),
|
|
373
|
+
label:
|
|
374
|
+
typeof candidate.label === 'string' && candidate.label.trim().length > 0
|
|
375
|
+
? candidate.label.trim()
|
|
376
|
+
: undefined,
|
|
377
|
+
}))
|
|
378
|
+
.filter((candidate) => candidate.name.length > 0);
|
|
379
|
+
|
|
380
|
+
if (actions.length === 0) {
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return JSON.stringify(actions);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function normalizeAccessibilityOrder(
|
|
388
|
+
value: unknown,
|
|
389
|
+
): string | undefined {
|
|
390
|
+
if (!Array.isArray(value)) {
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const order = value
|
|
395
|
+
.map((candidate) => {
|
|
396
|
+
if (typeof candidate === 'string') {
|
|
397
|
+
return candidate.trim();
|
|
398
|
+
}
|
|
399
|
+
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
|
|
400
|
+
return String(candidate);
|
|
401
|
+
}
|
|
402
|
+
return '';
|
|
403
|
+
})
|
|
404
|
+
.filter((candidate) => candidate.length > 0);
|
|
405
|
+
|
|
406
|
+
if (order.length === 0) {
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return JSON.stringify(order);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Map web objectFit to RN-style resizeMode.
|
|
415
|
+
*/
|
|
416
|
+
function mapObjectFitToResizeMode(
|
|
417
|
+
objectFit: string | undefined
|
|
418
|
+
): ResizeMode | undefined {
|
|
419
|
+
switch (objectFit) {
|
|
420
|
+
case 'cover':
|
|
421
|
+
case 'contain':
|
|
422
|
+
return objectFit;
|
|
423
|
+
case 'fill':
|
|
424
|
+
return 'stretch';
|
|
425
|
+
case 'none':
|
|
426
|
+
return 'center';
|
|
427
|
+
case 'scale-down':
|
|
428
|
+
return 'contain';
|
|
429
|
+
default:
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Extract resize mode from props.
|
|
436
|
+
*
|
|
437
|
+
* @param props - The props object
|
|
438
|
+
* @param isRNStyle - Whether to use RN prop names
|
|
439
|
+
* @returns The resize mode, or undefined
|
|
440
|
+
*/
|
|
441
|
+
export function extractResizeMode(
|
|
442
|
+
props: Record<string, unknown>,
|
|
443
|
+
isRNStyle: boolean
|
|
444
|
+
): ResizeMode | undefined {
|
|
445
|
+
if (isRNStyle) {
|
|
446
|
+
const resizeMode = props.resizeMode;
|
|
447
|
+
if (
|
|
448
|
+
resizeMode === 'cover' ||
|
|
449
|
+
resizeMode === 'contain' ||
|
|
450
|
+
resizeMode === 'stretch' ||
|
|
451
|
+
resizeMode === 'center'
|
|
452
|
+
) {
|
|
453
|
+
return resizeMode;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const objectFit = props.objectFit;
|
|
458
|
+
return typeof objectFit === 'string'
|
|
459
|
+
? mapObjectFitToResizeMode(objectFit)
|
|
460
|
+
: undefined;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Normalize props for an element into canonical format.
|
|
465
|
+
*
|
|
466
|
+
* @param tagType - The canonical tag type
|
|
467
|
+
* @param props - The user-provided props
|
|
468
|
+
* @param isRNStyle - Whether to use RN prop names
|
|
469
|
+
* @returns The normalized canonical props
|
|
470
|
+
*/
|
|
471
|
+
export function normalizeProps(
|
|
472
|
+
tagType: CanonicalTagType,
|
|
473
|
+
props: Record<string, unknown>,
|
|
474
|
+
isRNStyle: boolean
|
|
475
|
+
): CanonicalProps {
|
|
476
|
+
const canonical: CanonicalProps = {};
|
|
477
|
+
|
|
478
|
+
switch (tagType) {
|
|
479
|
+
case 'text': {
|
|
480
|
+
const paragraph = lowerTextPropsToParagraphSpec(props);
|
|
481
|
+
if (paragraph !== null) {
|
|
482
|
+
canonical.textContent = paragraph.plainText;
|
|
483
|
+
} else if (typeof props.textContent === 'string') {
|
|
484
|
+
canonical.textContent = props.textContent;
|
|
485
|
+
}
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
case 'image': {
|
|
490
|
+
warnIfImageMissingAlt({
|
|
491
|
+
alt: typeof props.alt === 'string' ? props.alt : undefined,
|
|
492
|
+
decorative: props.decorative === true,
|
|
493
|
+
source: (imageSourceFromThemeProps(props) ?? (isRNStyle ? props.source : props.src)) as ImageSource | undefined,
|
|
494
|
+
testID: typeof props.testID === 'string' ? props.testID : undefined,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// Extract image source
|
|
498
|
+
if (isRNStyle) {
|
|
499
|
+
const source = extractRNImageSource(props);
|
|
500
|
+
if (source) {
|
|
501
|
+
canonical.imageSource = source;
|
|
502
|
+
}
|
|
503
|
+
} else {
|
|
504
|
+
const source = extractWebImageSource(props);
|
|
505
|
+
if (source) {
|
|
506
|
+
canonical.imageSource = source;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Inline-SVG glyphs (Contract `image svgSource=...`, the dom-mirror's
|
|
511
|
+
// `svgSource` host prop) carry their artwork as markup rather than a
|
|
512
|
+
// `src`. The DOM mirror inlines that markup into a live `<svg>`; native
|
|
513
|
+
// cannot, so resolve it to a `data:image/svg+xml` URI the image pipeline
|
|
514
|
+
// loads, and forward `tintColor` as the glyph color (read for image
|
|
515
|
+
// nodes by the AppKit/UIKit presenters). A raster `src`, if present,
|
|
516
|
+
// wins.
|
|
517
|
+
if (canonical.imageSource === undefined && props.svgSource !== undefined) {
|
|
518
|
+
const svgImageSource = resolveSvgSourceForNative(props.svgSource as SvgSource);
|
|
519
|
+
if (svgImageSource !== undefined) {
|
|
520
|
+
canonical.imageSource = svgImageSource;
|
|
521
|
+
if (typeof props.tintColor === 'string') {
|
|
522
|
+
canonical.tintColor = props.tintColor;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Extract resize mode
|
|
528
|
+
const resizeMode = extractResizeMode(props, isRNStyle);
|
|
529
|
+
if (resizeMode) {
|
|
530
|
+
canonical.resizeMode = resizeMode;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const placeholder = resolveImagePlaceholderForNative(
|
|
534
|
+
props.placeholder as ImagePlaceholder | undefined,
|
|
535
|
+
);
|
|
536
|
+
if (placeholder) {
|
|
537
|
+
canonical.placeholder = placeholder;
|
|
538
|
+
}
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
case 'svg': {
|
|
543
|
+
warnIfImageMissingAlt({
|
|
544
|
+
alt: typeof props.alt === 'string' ? props.alt : undefined,
|
|
545
|
+
decorative: props.decorative === true,
|
|
546
|
+
source: props.source as ImageSource | undefined,
|
|
547
|
+
testID: typeof props.testID === 'string' ? props.testID : undefined,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const source = extractSvgSource(props);
|
|
551
|
+
if (source) {
|
|
552
|
+
canonical.svgSource = source;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const resizeMode = extractResizeMode(props, true);
|
|
556
|
+
if (resizeMode) {
|
|
557
|
+
canonical.resizeMode = resizeMode;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (typeof props.tintColor === 'string') {
|
|
561
|
+
canonical.tintColor = props.tintColor;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (typeof props.objectPosition === 'string' && props.objectPosition.trim().length > 0) {
|
|
565
|
+
canonical.svgObjectPosition = props.objectPosition.trim();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (typeof props.pixelDensity === 'number' && isFinite(props.pixelDensity) && props.pixelDensity > 0) {
|
|
569
|
+
canonical.svgPixelDensity = String(props.pixelDensity);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (typeof props.colors === 'object' && props.colors !== null) {
|
|
573
|
+
const colorEntries = Object.entries(props.colors as Record<string, unknown>)
|
|
574
|
+
.filter(([key, value]) => key.startsWith('--') && typeof value === 'string');
|
|
575
|
+
if (colorEntries.length > 0) {
|
|
576
|
+
canonical.svgColors = JSON.stringify(Object.fromEntries(colorEntries));
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
case 'video': {
|
|
583
|
+
if (typeof props.videoPlayerId === 'string' && props.videoPlayerId.length > 0) {
|
|
584
|
+
canonical.videoPlayerId = props.videoPlayerId;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (typeof props.videoViewConfig === 'string' && props.videoViewConfig.length > 0) {
|
|
588
|
+
canonical.videoViewConfig = props.videoViewConfig;
|
|
589
|
+
}
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
case 'input': {
|
|
594
|
+
// Text input props
|
|
595
|
+
if (typeof props.value === 'string') {
|
|
596
|
+
canonical.value = props.value;
|
|
597
|
+
} else if (typeof props.defaultValue === 'string') {
|
|
598
|
+
canonical.value = props.defaultValue;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (typeof props.placeholder === 'string') {
|
|
602
|
+
canonical.placeholder = props.placeholder;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (typeof props.editable === 'boolean') {
|
|
606
|
+
canonical.editable = props.editable;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (typeof props.multiline === 'boolean') {
|
|
610
|
+
canonical.multiline = props.multiline;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (typeof props.maxLength === 'number' && props.maxLength > 0) {
|
|
614
|
+
canonical.maxLength = props.maxLength;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (typeof props.secureTextEntry === 'boolean') {
|
|
618
|
+
canonical.secureTextEntry = props.secureTextEntry;
|
|
619
|
+
}
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
case 'scroll':
|
|
624
|
+
case 'list': {
|
|
625
|
+
// Scroll container props
|
|
626
|
+
if (typeof props.horizontal === 'boolean') {
|
|
627
|
+
canonical.horizontal = props.horizontal;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Combine indicator visibility
|
|
631
|
+
const showsV = props.showsVerticalScrollIndicator;
|
|
632
|
+
const showsH = props.showsHorizontalScrollIndicator;
|
|
633
|
+
if (typeof showsV === 'boolean' || typeof showsH === 'boolean') {
|
|
634
|
+
canonical.showsScrollIndicator = showsV !== false && showsH !== false;
|
|
635
|
+
}
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
case 'view':
|
|
640
|
+
case 'nativeView':
|
|
641
|
+
case 'pressable':
|
|
642
|
+
// View and pressable don't have special props
|
|
643
|
+
break;
|
|
644
|
+
|
|
645
|
+
case 'toggle': {
|
|
646
|
+
// Toggle/switch props
|
|
647
|
+
if (typeof props.value === 'boolean') {
|
|
648
|
+
canonical.toggleValue = props.value;
|
|
649
|
+
} else if (typeof props.defaultValue === 'boolean') {
|
|
650
|
+
canonical.toggleValue = props.defaultValue;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (typeof props.glassEffect === 'boolean') {
|
|
654
|
+
canonical.glassEffect = props.glassEffect;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (typeof props.tintColor === 'string') {
|
|
658
|
+
canonical.tintColor = props.tintColor;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (typeof props.disabled === 'boolean') {
|
|
662
|
+
canonical.disabled = props.disabled;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Extract label from children or label prop
|
|
666
|
+
const label = typeof props.label === 'string'
|
|
667
|
+
? props.label
|
|
668
|
+
: extractTextContent(props.children);
|
|
669
|
+
if (label !== undefined) {
|
|
670
|
+
canonical.textContent = label;
|
|
671
|
+
}
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const selectable = extractSelectableMode(props.selectable);
|
|
677
|
+
if (selectable !== undefined) {
|
|
678
|
+
canonical.selectable = selectable;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (typeof props.lang === 'string' && props.lang.trim().length > 0) {
|
|
682
|
+
canonical.lang = props.lang.trim();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (typeof props.selectionCopyText === 'string') {
|
|
686
|
+
canonical.selectionCopyText = props.selectionCopyText;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (
|
|
690
|
+
tagType === 'input' &&
|
|
691
|
+
typeof props.selection === 'object' &&
|
|
692
|
+
props.selection !== null
|
|
693
|
+
) {
|
|
694
|
+
const selection = props.selection as { start?: unknown; end?: unknown };
|
|
695
|
+
if (
|
|
696
|
+
typeof selection.start === 'number' &&
|
|
697
|
+
typeof selection.end === 'number' &&
|
|
698
|
+
Number.isFinite(selection.start) &&
|
|
699
|
+
Number.isFinite(selection.end) &&
|
|
700
|
+
selection.start >= 0 &&
|
|
701
|
+
selection.end >= 0
|
|
702
|
+
) {
|
|
703
|
+
canonical.selectionStart = selection.start;
|
|
704
|
+
canonical.selectionEnd = selection.end;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Common props
|
|
709
|
+
|
|
710
|
+
// Liquid Glass (Apple iOS 26 / macOS 26). The `glass*` inputs compose into a
|
|
711
|
+
// single `glassEffect` spec string that rides the existing PropId.GlassEffect
|
|
712
|
+
// wire prop. The bare-boolean form stays `true` so the native toggle's truthy
|
|
713
|
+
// check keeps working; richer forms ("regular"/"clear" + ;tint= + ;interactive)
|
|
714
|
+
// are parsed by the native host and the web fallback. The toggle case above
|
|
715
|
+
// may already have set a boolean glassEffect — only override when there is a
|
|
716
|
+
// container-glass refinement to encode.
|
|
717
|
+
const glassSpec = composeGlassSpec(props, canonical.glassEffect);
|
|
718
|
+
if (glassSpec !== undefined) {
|
|
719
|
+
canonical.glassEffect = glassSpec;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
assignPropAliases(canonical, props, TEST_ID_PROP_ALIASES);
|
|
723
|
+
|
|
724
|
+
// Accessibility
|
|
725
|
+
if (props.decorative === true) {
|
|
726
|
+
canonical.accessibilityLabel = '';
|
|
727
|
+
} else if (typeof props.accessibilityLabel === 'string') {
|
|
728
|
+
canonical.accessibilityLabel = props.accessibilityLabel;
|
|
729
|
+
} else if (typeof props['aria-label'] === 'string') {
|
|
730
|
+
canonical.accessibilityLabel = props['aria-label'];
|
|
731
|
+
} else if (typeof props.alt === 'string') {
|
|
732
|
+
// Web alt attribute maps to accessibility label
|
|
733
|
+
canonical.accessibilityLabel = props.alt;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (props.focusScope !== undefined && (typeof props.focusScope === 'boolean' || typeof props.focusScope === 'string')) {
|
|
737
|
+
canonical.focusScope = props.focusScope;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
assignPropAliases(canonical, props, COMMON_PROP_ALIASES);
|
|
741
|
+
|
|
742
|
+
if (
|
|
743
|
+
typeof props.accessibilityChecked === 'boolean' ||
|
|
744
|
+
props.accessibilityChecked === 'mixed'
|
|
745
|
+
) {
|
|
746
|
+
canonical.accessibilityChecked = props.accessibilityChecked;
|
|
747
|
+
} else if (
|
|
748
|
+
typeof props['aria-checked'] === 'boolean' ||
|
|
749
|
+
props['aria-checked'] === 'mixed'
|
|
750
|
+
) {
|
|
751
|
+
canonical.accessibilityChecked = props['aria-checked'] as boolean | 'mixed';
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const focusable =
|
|
755
|
+
readBooleanProp(props, 'focusable') ??
|
|
756
|
+
(typeof props.tabIndex === 'number' ? props.tabIndex >= 0 : undefined);
|
|
757
|
+
if (focusable !== undefined) {
|
|
758
|
+
canonical.focusable = focusable;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (typeof props.tabIndex === 'number' && Number.isFinite(props.tabIndex)) {
|
|
762
|
+
canonical.tabIndex = Math.trunc(props.tabIndex);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const accessibilityActions = normalizeAccessibilityActions(props.accessibilityActions);
|
|
766
|
+
if (accessibilityActions !== undefined) {
|
|
767
|
+
canonical.accessibilityActions = accessibilityActions;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const accessibilityOrder = normalizeAccessibilityOrder(props.accessibilityOrder);
|
|
771
|
+
if (accessibilityOrder !== undefined) {
|
|
772
|
+
canonical.accessibilityOrder = accessibilityOrder;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// `agentSemantics` follows the same renderer-local rule as `agentSummary`,
|
|
776
|
+
// but it carries structured metadata. Keep it serialized so CanonicalProps
|
|
777
|
+
// stays shallow and the props equality check remains a cheap string compare.
|
|
778
|
+
if (props.agentSemantics != null && typeof props.agentSemantics === 'object') {
|
|
779
|
+
try {
|
|
780
|
+
const serialized = JSON.stringify(props.agentSemantics);
|
|
781
|
+
if (typeof serialized === 'string') {
|
|
782
|
+
canonical.agentSemantics = serialized;
|
|
783
|
+
}
|
|
784
|
+
} catch {
|
|
785
|
+
// Ignore non-serializable metadata rather than letting authoring-only
|
|
786
|
+
// hints break rendering. The app surface simply falls back to heuristics.
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
assignPropAliases(canonical, props, AGENT_ID_PROP_ALIASES);
|
|
791
|
+
|
|
792
|
+
return canonical;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Compare two canonical props and return true if they are equal.
|
|
797
|
+
*
|
|
798
|
+
* @param a - First props
|
|
799
|
+
* @param b - Second props
|
|
800
|
+
* @returns True if props are equal
|
|
801
|
+
*/
|
|
802
|
+
export function propsEqual(a: CanonicalProps, b: CanonicalProps): boolean {
|
|
803
|
+
// Quick reference check
|
|
804
|
+
if (a === b) return true;
|
|
805
|
+
|
|
806
|
+
const keysA = Object.keys(a) as (keyof CanonicalProps)[];
|
|
807
|
+
const keysB = Object.keys(b) as (keyof CanonicalProps)[];
|
|
808
|
+
|
|
809
|
+
if (keysA.length !== keysB.length) return false;
|
|
810
|
+
|
|
811
|
+
for (const key of keysA) {
|
|
812
|
+
if (a[key] !== b[key]) return false;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return true;
|
|
816
|
+
}
|