@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.
Files changed (80) hide show
  1. package/package.json +118 -0
  2. package/src/__tests__/adapter-window-state.test.tsx +190 -0
  3. package/src/__tests__/attrs.test.ts +157 -0
  4. package/src/__tests__/classname.test.ts +332 -0
  5. package/src/__tests__/color.test.ts +169 -0
  6. package/src/__tests__/dom-mirror.test.ts +682 -0
  7. package/src/__tests__/dom-shim.test.ts +274 -0
  8. package/src/__tests__/fixtures/SvelteCounter.svelte +7 -0
  9. package/src/__tests__/fixtures/SvelteInput.svelte +8 -0
  10. package/src/__tests__/host-config.test.ts +51 -0
  11. package/src/__tests__/host-ops.test.ts +2234 -0
  12. package/src/__tests__/image-source.test.ts +135 -0
  13. package/src/__tests__/liquid-glass.test.ts +72 -0
  14. package/src/__tests__/multi-root.test.ts +118 -0
  15. package/src/__tests__/native-view-events.test.ts +102 -0
  16. package/src/__tests__/nodes.test.ts +399 -0
  17. package/src/__tests__/normalize.test.ts +576 -0
  18. package/src/__tests__/paragraph-lowering.test.tsx +144 -0
  19. package/src/__tests__/props.test.ts +518 -0
  20. package/src/__tests__/protocol-encoder.test.ts +732 -0
  21. package/src/__tests__/protocol-fixture-bytes.test.ts +41 -0
  22. package/src/__tests__/reconciler.test.tsx +241 -0
  23. package/src/__tests__/svelte-adapter.test.ts +166 -0
  24. package/src/__tests__/svg-source.test.ts +71 -0
  25. package/src/__tests__/tags.test.ts +354 -0
  26. package/src/__tests__/toggle.test.ts +441 -0
  27. package/src/__tests__/transitions.test.ts +106 -0
  28. package/src/__tests__/web-primitives.test.tsx +454 -0
  29. package/src/__tests__/window-hooks.test.tsx +447 -0
  30. package/src/adapter-contract.ts +68 -0
  31. package/src/attrs.ts +596 -0
  32. package/src/classname-contract.ts +87 -0
  33. package/src/classname-resolve.ts +553 -0
  34. package/src/classname-runtime.ts +29 -0
  35. package/src/components.ts +214 -0
  36. package/src/css-variable-context.ts +83 -0
  37. package/src/dom-hydration.ts +160 -0
  38. package/src/dom-mirror.ts +1459 -0
  39. package/src/dom-shim.ts +1736 -0
  40. package/src/group-context.ts +69 -0
  41. package/src/host-config.ts +431 -0
  42. package/src/host-ops.ts +3167 -0
  43. package/src/image-source.native.ts +703 -0
  44. package/src/image-source.ts +554 -0
  45. package/src/index.ts +278 -0
  46. package/src/inspector-runtime.ts +244 -0
  47. package/src/inspector.ts +3570 -0
  48. package/src/jsx-augmentations.ts +54 -0
  49. package/src/keyboard-avoidance.ts +217 -0
  50. package/src/native-primitives.ts +43 -0
  51. package/src/native-view-events.ts +322 -0
  52. package/src/native-view.ts +60 -0
  53. package/src/nodes/index.ts +41 -0
  54. package/src/nodes/node.ts +531 -0
  55. package/src/peer-context.ts +100 -0
  56. package/src/primitives.native.ts +8 -0
  57. package/src/primitives.ts +8 -0
  58. package/src/props/index.ts +14 -0
  59. package/src/props/normalize.ts +816 -0
  60. package/src/protocol/encoder.ts +940 -0
  61. package/src/protocol/index.ts +33 -0
  62. package/src/reconciler.ts +581 -0
  63. package/src/runtime.ts +11 -0
  64. package/src/safe-area.ts +543 -0
  65. package/src/solid.ts +490 -0
  66. package/src/style/color.js +1 -0
  67. package/src/style/color.ts +15 -0
  68. package/src/style/index.js +1 -0
  69. package/src/style/index.ts +22 -0
  70. package/src/style/normalize.js +1 -0
  71. package/src/style/normalize.ts +1426 -0
  72. package/src/svelte.ts +349 -0
  73. package/src/svg-source.ts +222 -0
  74. package/src/tags/index.ts +21 -0
  75. package/src/tags/tag-map.ts +289 -0
  76. package/src/text/paragraph-lowering.ts +310 -0
  77. package/src/types.ts +1175 -0
  78. package/src/vue.ts +535 -0
  79. package/src/web-host.ts +19 -0
  80. package/src/web-primitives.ts +1654 -0
@@ -0,0 +1,1654 @@
1
+ /**
2
+ * Web-Aware Primitive Components
3
+ *
4
+ * When Exact runs on web, React DOM is the renderer — not the Exact custom
5
+ * reconciler. React DOM doesn't know how to handle custom element names like
6
+ * `View` or `Pressable`, so they end up as inert, unstyled DOM nodes.
7
+ *
8
+ * This module wraps each React Native-style primitive in a thin function
9
+ * component that:
10
+ * - On web → renders a standard HTML element (`div`, `span`, `input`, `img`)
11
+ * with the appropriate RN-like defaults (flex column, box-sizing, etc.)
12
+ * and maps RN props (onPress, onChangeText) to DOM equivalents.
13
+ * - On native → renders the raw string tag (`'View'`, `'Text'`, etc.) so
14
+ * that the Exact custom reconciler handles it through the normal tag-map
15
+ * and binary protocol path.
16
+ *
17
+ * The detection is a one-time check at module init: if `document` exists we
18
+ * are on web; otherwise we are on native.
19
+ */
20
+
21
+ import React from 'react';
22
+
23
+ import { colorToRgba, parseColor } from '@exact/core/style/color';
24
+
25
+ import { cssTransform, cssTransition } from './dom-mirror.js';
26
+ import type {
27
+ ContainerProps,
28
+ TextElementProps,
29
+ PressableElementProps,
30
+ PressEvent,
31
+ ScrollContainerProps,
32
+ TextInputProps,
33
+ RNImageProps,
34
+ ToggleProps,
35
+ SvgProps,
36
+ ImageObjectFit,
37
+ ImageSource,
38
+ } from './types.js';
39
+ import {
40
+ normalizeImageLoading,
41
+ resolveImagePlaceholderForDOM,
42
+ resolveImageSourceForDOM,
43
+ resolveThemeAwareImageSource,
44
+ warnIfImageMissingAlt,
45
+ } from './image-source.js';
46
+ import {
47
+ coerceImageSourceToSvgSource,
48
+ filterSvgColors,
49
+ getDefaultSvgPixelDensity,
50
+ isInlineSvgSource,
51
+ resolveSvgSourceForDOM,
52
+ resolveSvgSourceForFetch,
53
+ } from './svg-source.js';
54
+ import { filterDOMProps, lowerHostAttrs } from './attrs.js';
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Platform detection
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function isWebRenderEnvironment(): boolean {
61
+ const renderEnvironment = globalThis as typeof globalThis & {
62
+ __exactPlatform?: unknown;
63
+ __exactWebSsr?: unknown;
64
+ };
65
+
66
+ // Router SSR and static prerender toggle `__exactWebSsr` while route modules
67
+ // load and render. This check must stay dynamic because Vite can reuse one
68
+ // module instance across both non-SSR and SSR test phases.
69
+ return (
70
+ renderEnvironment.__exactPlatform === 'web' ||
71
+ renderEnvironment.__exactWebSsr === true ||
72
+ (
73
+ typeof document !== 'undefined' &&
74
+ typeof renderEnvironment.__exactPlatform === 'undefined'
75
+ )
76
+ );
77
+ }
78
+
79
+ const SelectableContext = React.createContext<TextElementProps['selectable'] | undefined>(undefined);
80
+
81
+ function selectableModeToCss(mode: TextElementProps['selectable']): React.CSSProperties['userSelect'] {
82
+ switch (mode) {
83
+ case false:
84
+ return 'none';
85
+ case 'contain':
86
+ return 'contain' as React.CSSProperties['userSelect'];
87
+ case 'all':
88
+ return 'all';
89
+ case true:
90
+ default:
91
+ return 'text';
92
+ }
93
+ }
94
+
95
+ function resolveSelectableMode(
96
+ explicit: TextElementProps['selectable'],
97
+ inherited: TextElementProps['selectable'],
98
+ fallback: TextElementProps['selectable'],
99
+ ): TextElementProps['selectable'] {
100
+ if (explicit !== undefined) {
101
+ return explicit;
102
+ }
103
+
104
+ if (inherited !== undefined) {
105
+ return inherited;
106
+ }
107
+
108
+ return fallback;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Style helpers
113
+ // ---------------------------------------------------------------------------
114
+
115
+ // ENG-22206 (sibling of ENG-22199, the DOM-mirror analog): RN/Yoga and the
116
+ // kernel/Taffy path default flexShrink to 0 — flex items keep their content
117
+ // size and overflow rather than shrinking. CSS flexbox defaults flexShrink to
118
+ // 1, so a bare View/row inside an overflowing flex column would shrink below
119
+ // its content (down to the minHeight:0 below) and collapse to height 0 while
120
+ // its children paint full-size, overlapping the next sibling. Matching the RN
121
+ // default keeps the React-tier web renderer consistent with native and with
122
+ // the Contract DOM mirror. Explicit `flex`/`flexShrink` in a component's style
123
+ // is applied after these defaults and still wins (and WebScrollView overrides
124
+ // flexShrink back to 1 so scroll regions stay bounded + scrollable).
125
+ const viewDefaults: React.CSSProperties = {
126
+ display: 'flex',
127
+ flexDirection: 'column',
128
+ alignItems: 'stretch',
129
+ boxSizing: 'border-box',
130
+ position: 'relative',
131
+ minHeight: 0,
132
+ minWidth: 0,
133
+ flexShrink: 0,
134
+ };
135
+
136
+ const rowViewDefaults: React.CSSProperties = {
137
+ display: 'flex',
138
+ flexDirection: 'row',
139
+ alignItems: 'stretch',
140
+ boxSizing: 'border-box',
141
+ position: 'relative',
142
+ minHeight: 0,
143
+ minWidth: 0,
144
+ flexShrink: 0,
145
+ };
146
+
147
+ const buttonResetStyles: React.CSSProperties = {
148
+ appearance: 'none',
149
+ WebkitAppearance: 'none',
150
+ background: 'none',
151
+ border: 'none',
152
+ padding: 0,
153
+ margin: 0,
154
+ color: 'inherit',
155
+ font: 'inherit',
156
+ textAlign: 'inherit',
157
+ };
158
+
159
+ function mergeViewStyle(style: unknown): React.CSSProperties {
160
+ if (!style || typeof style !== 'object') {
161
+ return viewDefaults;
162
+ }
163
+
164
+ return { ...viewDefaults, ...expandShorthands(style as Record<string, unknown>) };
165
+ }
166
+
167
+ function mergeRowViewStyle(style: unknown): React.CSSProperties {
168
+ if (!style || typeof style !== 'object') {
169
+ return rowViewDefaults;
170
+ }
171
+
172
+ return { ...rowViewDefaults, ...expandShorthands(style as Record<string, unknown>) };
173
+ }
174
+
175
+ function mergeTextStyle(style: unknown): React.CSSProperties | undefined {
176
+ if (!style || typeof style !== 'object') {
177
+ return undefined;
178
+ }
179
+ return expandShorthands(style as Record<string, unknown>);
180
+ }
181
+
182
+ function createPressEvent(event: { clientX: number; clientY: number }): PressEvent {
183
+ return {
184
+ nativeEvent: {
185
+ locationX: event.clientX,
186
+ locationY: event.clientY,
187
+ timestamp: Date.now(),
188
+ },
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Expand React Native shorthand properties (paddingHorizontal, etc.) into
194
+ * their CSS equivalents so React DOM applies them correctly.
195
+ */
196
+ function expandShorthands(style: Record<string, unknown>): React.CSSProperties {
197
+ const expanded: Record<string, unknown> = {};
198
+ let shadow: { color?: string; x?: number; y?: number; radius?: number; opacity?: number } | null = null;
199
+
200
+ for (const key of Object.keys(style)) {
201
+ const value = style[key];
202
+
203
+ switch (key) {
204
+ // Exact transition maps and transform arrays are renderer-neutral data;
205
+ // React DOM needs the CSS string forms (shared with the DOM mirror so
206
+ // both web paths animate identically).
207
+ case 'transition': {
208
+ const converted = cssTransition(value);
209
+ if (converted) {
210
+ expanded.transition = converted;
211
+ }
212
+ break;
213
+ }
214
+ case 'transform': {
215
+ const converted = cssTransform(value);
216
+ if (converted) {
217
+ expanded.transform = converted;
218
+ }
219
+ break;
220
+ }
221
+ // Native draws a border from width + color alone; CSS additionally
222
+ // needs border-style or the border is invisible.
223
+ case 'borderWidth':
224
+ expanded.borderWidth = value;
225
+ expanded.borderStyle = style.borderStyle ?? 'solid';
226
+ break;
227
+ case 'borderTopWidth':
228
+ expanded.borderTopWidth = value;
229
+ expanded.borderTopStyle = 'solid';
230
+ break;
231
+ case 'borderRightWidth':
232
+ expanded.borderRightWidth = value;
233
+ expanded.borderRightStyle = 'solid';
234
+ break;
235
+ case 'borderBottomWidth':
236
+ expanded.borderBottomWidth = value;
237
+ expanded.borderBottomStyle = 'solid';
238
+ break;
239
+ case 'borderLeftWidth':
240
+ expanded.borderLeftWidth = value;
241
+ expanded.borderLeftStyle = 'solid';
242
+ break;
243
+ // Native shadow fields fold into a boxShadow only when no explicit
244
+ // boxShadow string is provided; either way they must not leak to DOM.
245
+ case 'shadowColor':
246
+ shadow = { ...(shadow ?? {}), color: value as string };
247
+ break;
248
+ case 'shadowOffset': {
249
+ const offset = value as { width?: number; height?: number } | null;
250
+ shadow = { ...(shadow ?? {}), x: offset?.width ?? 0, y: offset?.height ?? 0 };
251
+ break;
252
+ }
253
+ case 'shadowRadius':
254
+ shadow = { ...(shadow ?? {}), radius: value as number };
255
+ break;
256
+ case 'shadowOpacity':
257
+ shadow = { ...(shadow ?? {}), opacity: value as number };
258
+ break;
259
+ case 'paddingHorizontal':
260
+ expanded.paddingLeft ??= normalizeDOMStyleValue('paddingLeft', value);
261
+ expanded.paddingRight ??= normalizeDOMStyleValue('paddingRight', value);
262
+ break;
263
+ case 'paddingVertical':
264
+ expanded.paddingTop ??= normalizeDOMStyleValue('paddingTop', value);
265
+ expanded.paddingBottom ??= normalizeDOMStyleValue('paddingBottom', value);
266
+ break;
267
+ case 'marginHorizontal':
268
+ expanded.marginLeft ??= normalizeDOMStyleValue('marginLeft', value);
269
+ expanded.marginRight ??= normalizeDOMStyleValue('marginRight', value);
270
+ break;
271
+ case 'marginVertical':
272
+ expanded.marginTop ??= normalizeDOMStyleValue('marginTop', value);
273
+ expanded.marginBottom ??= normalizeDOMStyleValue('marginBottom', value);
274
+ break;
275
+ case 'paddingStart':
276
+ case 'paddingInlineStart':
277
+ case 'padding-inline-start':
278
+ expanded.paddingInlineStart ??= normalizeDOMStyleValue('paddingInlineStart', value);
279
+ break;
280
+ case 'paddingEnd':
281
+ case 'paddingInlineEnd':
282
+ case 'padding-inline-end':
283
+ expanded.paddingInlineEnd ??= normalizeDOMStyleValue('paddingInlineEnd', value);
284
+ break;
285
+ case 'marginStart':
286
+ case 'marginInlineStart':
287
+ case 'margin-inline-start':
288
+ expanded.marginInlineStart ??= normalizeDOMStyleValue('marginInlineStart', value);
289
+ break;
290
+ case 'marginEnd':
291
+ case 'marginInlineEnd':
292
+ case 'margin-inline-end':
293
+ expanded.marginInlineEnd ??= normalizeDOMStyleValue('marginInlineEnd', value);
294
+ break;
295
+ case 'start':
296
+ case 'insetInlineStart':
297
+ case 'inset-inline-start':
298
+ expanded.insetInlineStart ??= normalizeDOMStyleValue('insetInlineStart', value);
299
+ break;
300
+ case 'end':
301
+ case 'insetInlineEnd':
302
+ case 'inset-inline-end':
303
+ expanded.insetInlineEnd ??= normalizeDOMStyleValue('insetInlineEnd', value);
304
+ break;
305
+ default:
306
+ expanded[key] = normalizeDOMStyleValue(key, value);
307
+ }
308
+ }
309
+
310
+ if (expanded.boxShadow === undefined && shadow && (shadow.radius ?? 0) > 0 && shadow.color) {
311
+ const opacity = Math.max(0, Math.min(shadow.opacity ?? 1, 1));
312
+ const parsed = parseColor(shadow.color);
313
+ const cssColor = parsed
314
+ ? colorToRgba({ ...parsed, a: Math.round(parsed.a * opacity) })
315
+ : shadow.color;
316
+ expanded.boxShadow = `${shadow.x ?? 0}px ${shadow.y ?? 0}px ${shadow.radius}px ${cssColor}`;
317
+ }
318
+
319
+ return expanded as React.CSSProperties;
320
+ }
321
+
322
+ /**
323
+ * Web fallback for Liquid Glass. The real material only exists on iOS 26 /
324
+ * macOS 26 native surfaces; on web we approximate it with a translucent
325
+ * backdrop blur + saturation, a faint top highlight, and a soft shadow so a
326
+ * `glass` container still reads as a frosted floating panel. The native host
327
+ * keeps ownership of pixels inside a platform host, so this only fires on a
328
+ * plain browser tab / DOM-mirror render.
329
+ */
330
+ function glassStyleFromProps(props: Record<string, unknown>): React.CSSProperties | undefined {
331
+ const enable = props.glassEffect ?? props.glass;
332
+ // An explicit off wins even when a variant/tint is present.
333
+ if (enable === false || enable === 'false') {
334
+ return undefined;
335
+ }
336
+ const enabled =
337
+ enable === true ||
338
+ typeof enable === 'string' ||
339
+ typeof props.glassVariant === 'string' ||
340
+ (typeof props.glassTint === 'string' && props.glassTint.length > 0) ||
341
+ props.glassInteractive === true;
342
+ if (!enabled) {
343
+ return undefined;
344
+ }
345
+ const variant =
346
+ props.glassVariant === 'clear' || enable === 'clear' ? 'clear' : 'regular';
347
+ const tint =
348
+ typeof props.glassTint === 'string' && props.glassTint.length > 0
349
+ ? props.glassTint
350
+ : undefined;
351
+ const blur = variant === 'clear' ? 10 : 18;
352
+ const base = variant === 'clear' ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.16)';
353
+ const filter = `blur(${blur}px) saturate(180%)`;
354
+ return {
355
+ backdropFilter: filter,
356
+ WebkitBackdropFilter: filter,
357
+ backgroundColor: tint ?? base,
358
+ borderWidth: 1,
359
+ borderStyle: 'solid',
360
+ borderColor: 'rgba(255,255,255,0.22)',
361
+ boxShadow:
362
+ '0 8px 32px rgba(0,0,0,0.18), inset 0 1px 0 rgba(255,255,255,0.35)',
363
+ };
364
+ }
365
+
366
+ function normalizeDOMStyleValue(key: string, value: unknown): unknown {
367
+ if (key === 'backdropBlur' && typeof value === 'number' && isFinite(value)) {
368
+ return `blur(${value}px)`;
369
+ }
370
+
371
+ if (key === 'lineHeight' && typeof value === 'number' && isFinite(value)) {
372
+ return `${value}px`;
373
+ }
374
+
375
+ return value;
376
+ }
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // Web wrapper components
380
+ // ---------------------------------------------------------------------------
381
+
382
+ /**
383
+ * Render a web-style container while preserving Exact's selectable-text context.
384
+ *
385
+ * We use this helper for lowercase web tags (`div`, `span`) because they need
386
+ * the same DOM-prop filtering and inherited-selection behavior as the
387
+ * React-Native-style wrappers, but with different layout defaults.
388
+ */
389
+ function renderWebContainer(
390
+ element: 'div' | 'span',
391
+ style: unknown,
392
+ children: React.ReactNode,
393
+ testID: string | undefined,
394
+ rest: Record<string, unknown>,
395
+ defaultStyle: React.CSSProperties,
396
+ inheritedSelectable: TextElementProps['selectable'],
397
+ resolvedSelectable: TextElementProps['selectable'],
398
+ ): React.ReactElement {
399
+ const baseStyle = style && typeof style === 'object'
400
+ ? expandShorthands(style as Record<string, unknown>)
401
+ : {};
402
+ const lowered = lowerHostAttrs({
403
+ tag: element,
404
+ props: { ...rest, testID },
405
+ }, undefined, { defaultTag: element });
406
+ const rendered = React.createElement(lowered.tag, {
407
+ style: {
408
+ ...defaultStyle,
409
+ ...baseStyle,
410
+ ...glassStyleFromProps(rest),
411
+ },
412
+ ...lowered.attrs,
413
+ }, children);
414
+
415
+ if (resolvedSelectable === inheritedSelectable) {
416
+ return rendered;
417
+ }
418
+
419
+ return React.createElement(SelectableContext.Provider, { value: resolvedSelectable }, rendered);
420
+ }
421
+
422
+ /**
423
+ * Lowercase `div` preserves web/CSS defaults: row layout, standard DOM
424
+ * semantics, and pass-through accessibility props. This is the wrapper Facet's
425
+ * token-only components rely on when they render through React DOM.
426
+ */
427
+ function WebDiv({ style, children, testID, ...rest }: ContainerProps): React.ReactElement {
428
+ const inheritedSelectable = React.useContext(SelectableContext);
429
+ const resolvedSelectable = resolveSelectableMode(rest.selectable, inheritedSelectable, undefined);
430
+ return renderWebContainer(
431
+ 'div',
432
+ style,
433
+ children,
434
+ testID,
435
+ rest as Record<string, unknown>,
436
+ rowViewDefaults,
437
+ inheritedSelectable,
438
+ resolvedSelectable,
439
+ );
440
+ }
441
+
442
+ /**
443
+ * Lowercase `span` stays inline-level on web while still accepting the same
444
+ * style/object prop surface as Exact containers. We intentionally use
445
+ * `inline-flex` here so row-layout children continue to behave predictably.
446
+ */
447
+ function WebSpan({ style, children, testID, ...rest }: ContainerProps): React.ReactElement {
448
+ const inheritedSelectable = React.useContext(SelectableContext);
449
+ const resolvedSelectable = resolveSelectableMode(rest.selectable, inheritedSelectable, undefined);
450
+ return renderWebContainer(
451
+ 'span',
452
+ style,
453
+ children,
454
+ testID,
455
+ rest as Record<string, unknown>,
456
+ {
457
+ ...rowViewDefaults,
458
+ display: 'inline-flex',
459
+ },
460
+ inheritedSelectable,
461
+ resolvedSelectable,
462
+ );
463
+ }
464
+
465
+ function WebView({ style, children, testID, ...rest }: ContainerProps): React.ReactElement {
466
+ const inheritedSelectable = React.useContext(SelectableContext);
467
+ const resolvedSelectable = resolveSelectableMode(rest.selectable, inheritedSelectable, undefined);
468
+ const lowered = lowerHostAttrs({
469
+ tag: 'View',
470
+ props: { ...rest, testID },
471
+ }, undefined, { defaultTag: 'div' });
472
+ const element = React.createElement(lowered.tag, {
473
+ style: { ...mergeViewStyle(style), ...glassStyleFromProps(rest) },
474
+ ...lowered.attrs,
475
+ }, children);
476
+
477
+ if (resolvedSelectable === inheritedSelectable) {
478
+ return element;
479
+ }
480
+
481
+ return React.createElement(SelectableContext.Provider, { value: resolvedSelectable }, element);
482
+ }
483
+
484
+ function WebText({
485
+ style,
486
+ children,
487
+ textContent,
488
+ numberOfLines,
489
+ selectable,
490
+ testID,
491
+ ...rest
492
+ }: TextElementProps): React.ReactElement {
493
+ const inheritedSelectable = React.useContext(SelectableContext);
494
+ const resolvedSelectable = resolveSelectableMode(selectable, inheritedSelectable, true);
495
+ const baseStyle = mergeTextStyle(style);
496
+ const clampStyle: React.CSSProperties | undefined =
497
+ numberOfLines != null && numberOfLines > 0
498
+ ? numberOfLines === 1
499
+ ? {
500
+ display: 'block',
501
+ overflow: 'hidden',
502
+ textOverflow: 'ellipsis',
503
+ whiteSpace: 'nowrap',
504
+ }
505
+ : {
506
+ display: '-webkit-box',
507
+ WebkitLineClamp: numberOfLines,
508
+ WebkitBoxOrient: 'vertical' as const,
509
+ overflow: 'hidden',
510
+ }
511
+ : undefined;
512
+ const selectionStyle: React.CSSProperties | undefined =
513
+ resolvedSelectable === undefined
514
+ ? undefined
515
+ : {
516
+ userSelect: selectableModeToCss(resolvedSelectable),
517
+ };
518
+ const lowered = lowerHostAttrs({
519
+ tag: 'Text',
520
+ props: { ...rest, testID },
521
+ }, undefined, { defaultTag: 'span' });
522
+
523
+ return React.createElement(
524
+ SelectableContext.Provider,
525
+ { value: resolvedSelectable },
526
+ React.createElement(lowered.tag, {
527
+ style: {
528
+ ...baseStyle,
529
+ ...(clampStyle ?? {}),
530
+ ...(selectionStyle ?? {}),
531
+ },
532
+ ...lowered.attrs,
533
+ }, children ?? textContent),
534
+ );
535
+ }
536
+
537
+ function WebScrollView({
538
+ style,
539
+ children,
540
+ horizontal,
541
+ testID,
542
+ ...rest
543
+ }: ScrollContainerProps): React.ReactElement {
544
+ const lowered = lowerHostAttrs({
545
+ tag: 'ScrollView',
546
+ props: { ...rest, testID },
547
+ }, undefined, { defaultTag: 'div' });
548
+ return React.createElement(lowered.tag, {
549
+ style: {
550
+ ...viewDefaults,
551
+ // A scroll region must shrink to fit its flex parent and scroll its
552
+ // overflow, so it opts back into shrinking (viewDefaults sets flexShrink:0
553
+ // for ENG-22206). Mirrors the DOM mirror's ScrollView `flex: 1 1 0%`.
554
+ flexShrink: 1,
555
+ overflow: 'auto',
556
+ flexDirection: horizontal ? 'row' : 'column',
557
+ ...(style && typeof style === 'object' ? expandShorthands(style as Record<string, unknown>) : {}),
558
+ ...glassStyleFromProps(rest),
559
+ },
560
+ ...lowered.attrs,
561
+ }, children);
562
+ }
563
+
564
+ function WebPressable({
565
+ style,
566
+ children,
567
+ onPress,
568
+ onPressIn,
569
+ onPressOut,
570
+ onLongPress,
571
+ disabled,
572
+ testID,
573
+ ...rest
574
+ }: PressableElementProps): React.ReactElement {
575
+ const inheritedSelectable = React.useContext(SelectableContext);
576
+ const resolvedSelectable = resolveSelectableMode(rest.selectable, inheritedSelectable, false);
577
+ const longPressRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
578
+ const lowered = lowerHostAttrs({
579
+ tag: 'Pressable',
580
+ props: { ...(rest as Record<string, unknown>), testID },
581
+ }, undefined, { defaultTag: 'div', defaultRole: 'button' });
582
+ const domProps = lowered.attrs;
583
+ const requestedTabIndex =
584
+ typeof domProps.tabIndex === 'number' ? domProps.tabIndex : undefined;
585
+ const requestedFocusable =
586
+ typeof rest.focusable === 'boolean' ? rest.focusable : undefined;
587
+
588
+ delete domProps.tabIndex;
589
+
590
+ // Compose passthrough DOM handlers with the synthesized press handlers so the
591
+ // trailing `...domProps` spread can't clobber onPress/press-in/out with a
592
+ // (frequently undefined) passthrough value. See WebButton for the same fix.
593
+ const userOnClick = domProps.onClick as ((event: unknown) => void) | undefined;
594
+ const userOnMouseDown = domProps.onMouseDown as ((event: unknown) => void) | undefined;
595
+ const userOnMouseUp = domProps.onMouseUp as ((event: unknown) => void) | undefined;
596
+ const userOnKeyDown = domProps.onKeyDown as ((event: unknown) => void) | undefined;
597
+ delete domProps.onClick;
598
+ delete domProps.onMouseDown;
599
+ delete domProps.onMouseUp;
600
+ delete domProps.onKeyDown;
601
+
602
+ const handleMouseDown = React.useCallback(
603
+ (event: React.MouseEvent) => {
604
+ if (onPressIn) {
605
+ onPressIn(createPressEvent(event));
606
+ }
607
+ if (onLongPress) {
608
+ longPressRef.current = setTimeout(() => {
609
+ onLongPress(createPressEvent(event));
610
+ }, 500);
611
+ }
612
+ if (userOnMouseDown) {
613
+ userOnMouseDown(event);
614
+ }
615
+ },
616
+ [onPressIn, onLongPress, userOnMouseDown],
617
+ );
618
+
619
+ const handleMouseUp = React.useCallback(
620
+ (event: React.MouseEvent) => {
621
+ if (longPressRef.current != null) {
622
+ clearTimeout(longPressRef.current);
623
+ longPressRef.current = null;
624
+ }
625
+ if (onPressOut) {
626
+ onPressOut(createPressEvent(event));
627
+ }
628
+ if (userOnMouseUp) {
629
+ userOnMouseUp(event);
630
+ }
631
+ },
632
+ [onPressOut, userOnMouseUp],
633
+ );
634
+
635
+ const handleClick = React.useCallback(
636
+ (event: React.MouseEvent) => {
637
+ if (onPress) {
638
+ onPress(createPressEvent(event));
639
+ }
640
+ if (userOnClick) {
641
+ userOnClick(event);
642
+ }
643
+ },
644
+ [onPress, userOnClick],
645
+ );
646
+
647
+ const handleKeyDown = React.useCallback(
648
+ (event: React.KeyboardEvent) => {
649
+ if ((event.key === 'Enter' || event.key === ' ') && onPress) {
650
+ event.preventDefault();
651
+ onPress({ nativeEvent: { locationX: 0, locationY: 0, timestamp: Date.now() } });
652
+ }
653
+ if (userOnKeyDown) {
654
+ userOnKeyDown(event);
655
+ }
656
+ },
657
+ [onPress, userOnKeyDown],
658
+ );
659
+
660
+ // Clean up pending long press timeout on unmount
661
+ React.useEffect(() => {
662
+ return () => {
663
+ if (longPressRef.current != null) {
664
+ clearTimeout(longPressRef.current);
665
+ }
666
+ };
667
+ }, []);
668
+
669
+ return React.createElement(
670
+ SelectableContext.Provider,
671
+ { value: resolvedSelectable },
672
+ React.createElement(lowered.tag, {
673
+ style: {
674
+ ...mergeViewStyle(style),
675
+ cursor: disabled ? 'default' : onPress ? 'pointer' : undefined,
676
+ opacity: disabled ? 0.5 : undefined,
677
+ ...glassStyleFromProps(rest as Record<string, unknown>),
678
+ },
679
+ onClick: disabled ? undefined : handleClick,
680
+ onMouseDown: disabled ? undefined : handleMouseDown,
681
+ onMouseUp: disabled ? undefined : handleMouseUp,
682
+ onKeyDown: disabled ? undefined : handleKeyDown,
683
+ role: 'button',
684
+ // Honor roving-focus / explicit focusability requests instead of forcing
685
+ // every pressable into the tab order. This is required for compound
686
+ // widgets like radio groups, menus, and listboxes.
687
+ tabIndex: disabled
688
+ ? -1
689
+ : requestedFocusable === false
690
+ ? -1
691
+ : requestedTabIndex ?? 0,
692
+ 'aria-disabled': disabled || undefined,
693
+ ...domProps,
694
+ }, children),
695
+ );
696
+ }
697
+
698
+ const WebButton = React.forwardRef<HTMLButtonElement, PressableElementProps>(
699
+ function WebButton({
700
+ style,
701
+ children,
702
+ onPress,
703
+ onPressIn,
704
+ onPressOut,
705
+ onLongPress,
706
+ disabled,
707
+ testID,
708
+ accessibilityRole,
709
+ accessibilityLabel,
710
+ ...rest
711
+ }, ref): React.ReactElement {
712
+ const inheritedSelectable = React.useContext(SelectableContext);
713
+ const resolvedSelectable = resolveSelectableMode(rest.selectable, inheritedSelectable, false);
714
+ const longPressRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
715
+
716
+ // Any passthrough DOM handlers must COMPOSE with the synthesized press
717
+ // handlers, never clobber them. They are pulled out of `rest` here so the
718
+ // trailing `...domProps` spread below can't overwrite onClick/onMouse* with
719
+ // a (frequently undefined) passthrough value — the bug that silently killed
720
+ // onPress on every Facet Button, which forwards `onClick={undefined}`.
721
+ const restRecord = rest as Record<string, unknown>;
722
+ const userOnClick = restRecord.onClick as ((event: unknown) => void) | undefined;
723
+ const userOnMouseDown = restRecord.onMouseDown as ((event: unknown) => void) | undefined;
724
+ const userOnMouseUp = restRecord.onMouseUp as ((event: unknown) => void) | undefined;
725
+ delete (rest as Record<string, unknown>).onClick;
726
+ delete (rest as Record<string, unknown>).onMouseDown;
727
+ delete (rest as Record<string, unknown>).onMouseUp;
728
+
729
+ const handleMouseDown = React.useCallback(
730
+ (event: React.MouseEvent<HTMLButtonElement>) => {
731
+ if (onPressIn) {
732
+ onPressIn(createPressEvent(event));
733
+ }
734
+ if (onLongPress) {
735
+ longPressRef.current = setTimeout(() => {
736
+ onLongPress(createPressEvent(event));
737
+ }, 500);
738
+ }
739
+ if (userOnMouseDown) {
740
+ userOnMouseDown(event);
741
+ }
742
+ },
743
+ [onPressIn, onLongPress, userOnMouseDown],
744
+ );
745
+
746
+ const handleMouseUp = React.useCallback(
747
+ (event: React.MouseEvent<HTMLButtonElement>) => {
748
+ if (longPressRef.current != null) {
749
+ clearTimeout(longPressRef.current);
750
+ longPressRef.current = null;
751
+ }
752
+ if (onPressOut) {
753
+ onPressOut(createPressEvent(event));
754
+ }
755
+ if (userOnMouseUp) {
756
+ userOnMouseUp(event);
757
+ }
758
+ },
759
+ [onPressOut, userOnMouseUp],
760
+ );
761
+
762
+ const handleClick = React.useCallback(
763
+ (event: React.MouseEvent<HTMLButtonElement>) => {
764
+ if (onPress) {
765
+ onPress(createPressEvent(event));
766
+ }
767
+ if (userOnClick) {
768
+ userOnClick(event);
769
+ }
770
+ },
771
+ [onPress, userOnClick],
772
+ );
773
+
774
+ React.useEffect(() => {
775
+ return () => {
776
+ if (longPressRef.current != null) {
777
+ clearTimeout(longPressRef.current);
778
+ }
779
+ };
780
+ }, []);
781
+
782
+ const lowered = lowerHostAttrs({
783
+ tag: 'button',
784
+ props: {
785
+ ...(rest as Record<string, unknown>),
786
+ accessibilityRole,
787
+ accessibilityLabel,
788
+ testID,
789
+ },
790
+ }, undefined, { defaultTag: 'button', defaultRole: 'button' });
791
+ const domProps = lowered.attrs;
792
+ const requestedTabIndex =
793
+ typeof domProps.tabIndex === 'number' ? domProps.tabIndex : undefined;
794
+ const requestedFocusable =
795
+ typeof rest.focusable === 'boolean' ? rest.focusable : undefined;
796
+ const resolvedRole = typeof domProps.role === 'string' ? domProps.role : undefined;
797
+
798
+ delete domProps.role;
799
+ delete domProps['aria-disabled'];
800
+ delete domProps.tabIndex;
801
+
802
+ return React.createElement(
803
+ SelectableContext.Provider,
804
+ { value: resolvedSelectable },
805
+ React.createElement('button', {
806
+ ref,
807
+ type: 'button',
808
+ style: {
809
+ ...buttonResetStyles,
810
+ ...mergeRowViewStyle(style),
811
+ cursor: disabled ? 'default' : onPress ? 'pointer' : undefined,
812
+ },
813
+ onClick: disabled ? undefined : handleClick,
814
+ onMouseDown: disabled ? undefined : handleMouseDown,
815
+ onMouseUp: disabled ? undefined : handleMouseUp,
816
+ disabled: disabled || undefined,
817
+ // Preserve any tab-order decision made higher up the tree. This keeps
818
+ // web buttons compatible with roving-focus widgets built on ButtonTag.
819
+ tabIndex: disabled
820
+ ? -1
821
+ : requestedFocusable === false
822
+ ? -1
823
+ : requestedTabIndex ?? 0,
824
+ role: resolvedRole,
825
+ ...domProps,
826
+ }, children),
827
+ );
828
+ },
829
+ );
830
+
831
+ function WebTextInput({
832
+ style,
833
+ value,
834
+ defaultValue,
835
+ placeholder,
836
+ placeholderTextColor,
837
+ multiline,
838
+ numberOfLines,
839
+ maxLength,
840
+ editable,
841
+ autoFocus,
842
+ selection,
843
+ secureTextEntry,
844
+ keyboardType,
845
+ onChangeText,
846
+ onChange,
847
+ onSelectionChange,
848
+ onFocus,
849
+ onBlur,
850
+ onSubmitEditing,
851
+ testID,
852
+ ...rest
853
+ }: TextInputProps): React.ReactElement {
854
+ const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
855
+
856
+ React.useEffect(() => {
857
+ const input = inputRef.current;
858
+ if (!input || !selection) {
859
+ return;
860
+ }
861
+
862
+ const textLength = input.value.length;
863
+ const start = Math.max(0, Math.min(textLength, selection.start));
864
+ const end = Math.max(0, Math.min(textLength, selection.end));
865
+ if (input.selectionStart !== start || input.selectionEnd !== end) {
866
+ input.setSelectionRange(start, end);
867
+ }
868
+ }, [selection, value, defaultValue]);
869
+
870
+ const publishSelection = React.useCallback(
871
+ (input: HTMLInputElement | HTMLTextAreaElement) => {
872
+ if (!onSelectionChange) {
873
+ return;
874
+ }
875
+
876
+ const start = input.selectionStart ?? 0;
877
+ const end = input.selectionEnd ?? start;
878
+ onSelectionChange({
879
+ nativeEvent: {
880
+ selection: { start, end },
881
+ text: input.value,
882
+ },
883
+ });
884
+ },
885
+ [onSelectionChange],
886
+ );
887
+
888
+ const handleChange = React.useCallback(
889
+ (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
890
+ const text = event.target.value;
891
+ const start = event.target.selectionStart ?? 0;
892
+ const end = event.target.selectionEnd ?? start;
893
+ if (onChangeText) {
894
+ onChangeText(text);
895
+ }
896
+ if (onChange) {
897
+ onChange({ nativeEvent: { text, selection: { start, end } } });
898
+ }
899
+ publishSelection(event.target);
900
+ },
901
+ [onChangeText, onChange, publishSelection],
902
+ );
903
+
904
+ const handleSelect = React.useCallback(
905
+ (event: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>) => {
906
+ publishSelection(event.currentTarget);
907
+ },
908
+ [publishSelection],
909
+ );
910
+
911
+ const handleKeyDown = React.useCallback(
912
+ (event: React.KeyboardEvent) => {
913
+ if (event.key === 'Enter' && !multiline && onSubmitEditing) {
914
+ onSubmitEditing();
915
+ }
916
+ },
917
+ [multiline, onSubmitEditing],
918
+ );
919
+
920
+ const inputType = secureTextEntry
921
+ ? 'password'
922
+ : keyboardType === 'numeric'
923
+ ? 'number'
924
+ : keyboardType === 'email-address'
925
+ ? 'email'
926
+ : keyboardType === 'phone-pad'
927
+ ? 'tel'
928
+ : 'text';
929
+
930
+ const baseStyle = style && typeof style === 'object'
931
+ ? expandShorthands(style as Record<string, unknown>)
932
+ : {};
933
+
934
+ const tag = multiline ? 'textarea' : 'input';
935
+ const lowered = lowerHostAttrs({
936
+ tag: 'TextInput',
937
+ props: { ...(rest as Record<string, unknown>), testID },
938
+ }, undefined, { defaultTag: tag });
939
+ delete lowered.attrs.onKeyDown;
940
+ const props: Record<string, unknown> = {
941
+ ref: inputRef,
942
+ style: { boxSizing: 'border-box' as const, ...baseStyle },
943
+ value,
944
+ defaultValue,
945
+ placeholder,
946
+ maxLength,
947
+ readOnly: editable === false ? true : undefined,
948
+ autoFocus,
949
+ onChange: handleChange,
950
+ onFocus,
951
+ onBlur,
952
+ onSelect: onSelectionChange ? handleSelect : undefined,
953
+ onKeyDown: onSubmitEditing ? handleKeyDown : undefined,
954
+ ...lowered.attrs,
955
+ };
956
+
957
+ if (!multiline) {
958
+ props.type = inputType;
959
+ } else if (numberOfLines) {
960
+ props.rows = numberOfLines;
961
+ }
962
+
963
+ return React.createElement(tag, props);
964
+ }
965
+
966
+ /**
967
+ * Lowercase `img` is the web-style image surface. Unlike `Image`, it accepts
968
+ * `src`/`alt`/`objectFit` directly and should render as a plain DOM `<img>`
969
+ * when React DOM is active.
970
+ */
971
+ function WebImg({
972
+ style,
973
+ src,
974
+ lightSrc,
975
+ darkSrc,
976
+ alt,
977
+ width,
978
+ height,
979
+ objectFit,
980
+ objectPosition,
981
+ loading,
982
+ themeTreatment,
983
+ testID,
984
+ ...rest
985
+ }: import('./types.js').ImageElementProps): React.ReactElement {
986
+ const baseStyle = style && typeof style === 'object'
987
+ ? expandShorthands(style as Record<string, unknown>)
988
+ : {};
989
+ const resolvedSource = resolveImageSourceForDOM(
990
+ lightSrc || darkSrc
991
+ ? {
992
+ ...(lightSrc ? { light: lightSrc } : {}),
993
+ ...(darkSrc ? { dark: darkSrc } : {}),
994
+ fallback: src,
995
+ }
996
+ : src,
997
+ );
998
+
999
+ return React.createElement('img', {
1000
+ src: resolvedSource?.src ?? src,
1001
+ alt: alt ?? '',
1002
+ width,
1003
+ height,
1004
+ loading,
1005
+ style: {
1006
+ display: 'block',
1007
+ boxSizing: 'border-box',
1008
+ objectFit,
1009
+ objectPosition,
1010
+ ...baseStyle,
1011
+ },
1012
+ 'data-testid': testID,
1013
+ 'data-exact-theme-image': lightSrc || darkSrc || themeTreatment ? 'color-scheme' : undefined,
1014
+ 'data-light-src': lightSrc,
1015
+ 'data-dark-src': darkSrc,
1016
+ 'data-exact-theme-treatment': themeTreatment === 'none' ? undefined : themeTreatment,
1017
+ ...filterDOMProps(rest as Record<string, unknown>),
1018
+ });
1019
+ }
1020
+
1021
+ function WebImage({
1022
+ source,
1023
+ lightSrc,
1024
+ darkSrc,
1025
+ style,
1026
+ resizeMode,
1027
+ objectFit,
1028
+ objectPosition,
1029
+ alt,
1030
+ decorative,
1031
+ longDescription,
1032
+ placeholder,
1033
+ loading,
1034
+ onLoadStart,
1035
+ onLoad,
1036
+ onError,
1037
+ onDisplay,
1038
+ themeTreatment,
1039
+ testID,
1040
+ }: RNImageProps): React.ReactElement {
1041
+ const themedSource = React.useMemo<ImageSource>(() => {
1042
+ if (!lightSrc && !darkSrc) {
1043
+ return source;
1044
+ }
1045
+ return {
1046
+ ...(lightSrc ? { light: lightSrc } : {}),
1047
+ ...(darkSrc ? { dark: darkSrc } : {}),
1048
+ fallback: source,
1049
+ };
1050
+ }, [source, lightSrc, darkSrc]);
1051
+ const selectedSource = React.useMemo(() => resolveThemeAwareImageSource(themedSource), [themedSource]);
1052
+ const delegatedSvgSource = React.useMemo(() => coerceImageSourceToSvgSource(selectedSource), [selectedSource]);
1053
+ if (delegatedSvgSource !== undefined) {
1054
+ return React.createElement(WebSvg, {
1055
+ source: delegatedSvgSource,
1056
+ style,
1057
+ objectFit: objectFit ?? mapResizeModeToObjectFit(resizeMode),
1058
+ objectPosition,
1059
+ alt,
1060
+ decorative,
1061
+ testID,
1062
+ });
1063
+ }
1064
+
1065
+ React.useEffect(() => {
1066
+ warnIfImageMissingAlt({ source: themedSource, alt, decorative, testID });
1067
+ }, [themedSource, alt, decorative, testID]);
1068
+
1069
+ const resolvedSource = React.useMemo(() => resolveImageSourceForDOM(themedSource), [themedSource]);
1070
+ const resolvedPlaceholder = React.useMemo(
1071
+ () => resolveImagePlaceholderForDOM(placeholder),
1072
+ [placeholder],
1073
+ );
1074
+
1075
+ React.useEffect(() => {
1076
+ if (resolvedSource?.src && onLoadStart) {
1077
+ onLoadStart();
1078
+ }
1079
+ }, [resolvedSource?.src, onLoadStart]);
1080
+
1081
+ const fit = objectFit ?? mapResizeModeToObjectFit(resizeMode);
1082
+ const baseStyle = style && typeof style === 'object'
1083
+ ? expandShorthands(style as Record<string, unknown>)
1084
+ : {};
1085
+ const layoutWidth = readPositivePixelDimension(baseStyle.width);
1086
+ const layoutHeight = readPositivePixelDimension(baseStyle.height);
1087
+ const computedAlt = decorative ? '' : alt ?? '';
1088
+ const imageStyle: React.CSSProperties = {
1089
+ display: 'block',
1090
+ boxSizing: 'border-box',
1091
+ objectFit: fit,
1092
+ objectPosition,
1093
+ backgroundColor: resolvedPlaceholder?.backgroundColor,
1094
+ backgroundImage: resolvedPlaceholder?.backgroundImage,
1095
+ backgroundPosition: objectPosition ?? 'center',
1096
+ backgroundRepeat: 'no-repeat',
1097
+ backgroundSize: fit ?? 'cover',
1098
+ ...baseStyle,
1099
+ };
1100
+
1101
+ const handleLoad = React.useCallback((event: React.SyntheticEvent<HTMLImageElement>) => {
1102
+ if (onLoad) {
1103
+ onLoad({
1104
+ source: {
1105
+ uri: resolvedSource?.src ?? '',
1106
+ width: event.currentTarget.naturalWidth,
1107
+ height: event.currentTarget.naturalHeight,
1108
+ },
1109
+ cacheType: 'none',
1110
+ });
1111
+ }
1112
+ onDisplay?.();
1113
+ }, [onLoad, onDisplay, resolvedSource?.src]);
1114
+
1115
+ const handleError = React.useCallback(() => {
1116
+ onError?.({ error: 'Image failed to load.' });
1117
+ }, [onError]);
1118
+
1119
+ const imgElement = React.createElement('img', {
1120
+ src: resolvedSource?.src ?? '',
1121
+ srcSet: resolvedSource?.srcSet,
1122
+ alt: computedAlt,
1123
+ width: layoutWidth,
1124
+ height: layoutHeight,
1125
+ loading: normalizeImageLoading(loading),
1126
+ style: imageStyle,
1127
+ onLoad: handleLoad,
1128
+ onError: handleError,
1129
+ 'data-testid': testID,
1130
+ 'aria-hidden': decorative ? true : undefined,
1131
+ 'aria-description': longDescription,
1132
+ 'data-exact-theme-image': resolvedSource?.themeSources || themeTreatment ? 'color-scheme' : undefined,
1133
+ 'data-light-src': resolvedSource?.themeSources?.light,
1134
+ 'data-dark-src': resolvedSource?.themeSources?.dark,
1135
+ 'data-exact-theme-treatment': themeTreatment === 'none' ? undefined : themeTreatment,
1136
+ });
1137
+
1138
+ if (!resolvedSource?.pictureSources || resolvedSource.pictureSources.length === 0) {
1139
+ return imgElement;
1140
+ }
1141
+
1142
+ return React.createElement(
1143
+ 'picture',
1144
+ null,
1145
+ ...resolvedSource.pictureSources.map((entry, index) => React.createElement('source', {
1146
+ key: `${entry.type ?? 'fallback'}-${index}`,
1147
+ type: entry.type,
1148
+ srcSet: entry.srcSet,
1149
+ })),
1150
+ imgElement,
1151
+ );
1152
+ }
1153
+
1154
+ function readPositivePixelDimension(value: unknown): number | undefined {
1155
+ if (typeof value === 'number' && isFinite(value) && value > 0) {
1156
+ return Math.round(value);
1157
+ }
1158
+
1159
+ return undefined;
1160
+ }
1161
+
1162
+ function mapResizeModeToObjectFit(resizeMode: RNImageProps['resizeMode']): ImageObjectFit | undefined {
1163
+ switch (resizeMode) {
1164
+ case 'contain':
1165
+ return 'contain';
1166
+ case 'stretch':
1167
+ return 'fill';
1168
+ case 'center':
1169
+ return 'none';
1170
+ case 'cover':
1171
+ return 'cover';
1172
+ default:
1173
+ return undefined;
1174
+ }
1175
+ }
1176
+
1177
+ let nextInlineSvgTitleId = 1;
1178
+
1179
+ function WebSvg(props: SvgProps): React.ReactElement {
1180
+ const filteredColors = React.useMemo(() => filterSvgColors(props.colors), [props.colors]);
1181
+
1182
+ React.useEffect(() => {
1183
+ warnIfImageMissingAlt({
1184
+ source: props.source as unknown as ImageSource,
1185
+ alt: props.alt,
1186
+ decorative: props.decorative,
1187
+ testID: props.testID,
1188
+ });
1189
+ }, [props.source, props.alt, props.decorative, props.testID]);
1190
+
1191
+ const needsInlineMarkup =
1192
+ isInlineSvgSource(props.source) ||
1193
+ props.tintColor != null ||
1194
+ filteredColors != null ||
1195
+ (typeof props.source === 'object' && props.source !== null && 'headers' in props.source);
1196
+ const request = React.useMemo(() => resolveSvgSourceForFetch(props.source), [props.source]);
1197
+ const [markup, setMarkup] = React.useState<string | undefined>(request?.markup);
1198
+
1199
+ React.useEffect(() => {
1200
+ if (!needsInlineMarkup) {
1201
+ setMarkup(undefined);
1202
+ return;
1203
+ }
1204
+
1205
+ if (!request) {
1206
+ setMarkup(undefined);
1207
+ return;
1208
+ }
1209
+
1210
+ if (request.markup != null) {
1211
+ setMarkup(request.markup);
1212
+ return;
1213
+ }
1214
+
1215
+ if (!request.uri) {
1216
+ setMarkup(undefined);
1217
+ return;
1218
+ }
1219
+
1220
+ const abortController = new AbortController();
1221
+ let cancelled = false;
1222
+ fetch(request.uri, {
1223
+ headers: request.headers,
1224
+ signal: abortController.signal as AbortSignal,
1225
+ })
1226
+ .then(async (response) => {
1227
+ if (!response.ok) {
1228
+ throw new Error(`SVG request failed with ${response.status}`);
1229
+ }
1230
+ return response.text();
1231
+ })
1232
+ .then((text) => {
1233
+ if (!cancelled) {
1234
+ setMarkup(text);
1235
+ }
1236
+ })
1237
+ .catch((error: unknown) => {
1238
+ if (
1239
+ cancelled ||
1240
+ (typeof DOMException !== 'undefined' && error instanceof DOMException && error.name === 'AbortError')
1241
+ ) {
1242
+ return;
1243
+ }
1244
+ console.warn('[Exact] Failed to load SVG source.', error);
1245
+ setMarkup(undefined);
1246
+ });
1247
+
1248
+ return () => {
1249
+ cancelled = true;
1250
+ abortController.abort();
1251
+ };
1252
+ }, [needsInlineMarkup, request]);
1253
+
1254
+ if (needsInlineMarkup && markup != null) {
1255
+ return React.createElement(InlineSvgHost, { ...props, colors: filteredColors, markup });
1256
+ }
1257
+
1258
+ const src = resolveSvgSourceForDOM(props.source);
1259
+ const baseStyle = props.style && typeof props.style === 'object'
1260
+ ? expandShorthands(props.style as Record<string, unknown>)
1261
+ : {};
1262
+ const containerStyle: React.CSSProperties = {
1263
+ display: 'block',
1264
+ boxSizing: 'border-box',
1265
+ ...baseStyle,
1266
+ };
1267
+
1268
+ return React.createElement('img', {
1269
+ src: src ?? '',
1270
+ alt: props.decorative ? '' : (props.alt ?? ''),
1271
+ style: {
1272
+ ...containerStyle,
1273
+ objectFit: props.objectFit ?? 'contain',
1274
+ objectPosition: props.objectPosition,
1275
+ },
1276
+ 'data-testid': props.testID,
1277
+ 'aria-hidden': props.decorative ? true : undefined,
1278
+ });
1279
+ }
1280
+
1281
+ function InlineSvgHost(
1282
+ props: SvgProps & { markup: string },
1283
+ ): React.ReactElement {
1284
+ const hostRef = React.useRef<HTMLSpanElement | null>(null);
1285
+ const baseStyle = props.style && typeof props.style === 'object'
1286
+ ? expandShorthands(props.style as Record<string, unknown>)
1287
+ : {};
1288
+
1289
+ React.useEffect(() => {
1290
+ const host = hostRef.current;
1291
+ if (!host) {
1292
+ return;
1293
+ }
1294
+
1295
+ while (host.firstChild) {
1296
+ host.removeChild(host.firstChild);
1297
+ }
1298
+
1299
+ const parser = new DOMParser();
1300
+ const doc = parser.parseFromString(props.markup, 'image/svg+xml');
1301
+ if (doc.querySelector('parsererror')) {
1302
+ console.warn('[Exact] Invalid inline SVG source.');
1303
+ return;
1304
+ }
1305
+
1306
+ const documentElement = doc.documentElement;
1307
+ if (!documentElement || documentElement.tagName.toLowerCase() !== 'svg') {
1308
+ console.warn('[Exact] Inline SVG source must contain an <svg> root element.');
1309
+ return;
1310
+ }
1311
+
1312
+ const svgEl = document.adoptNode(documentElement) as unknown as SVGSVGElement;
1313
+ svgEl.style.display = 'block';
1314
+ svgEl.style.width = '100%';
1315
+ svgEl.style.height = '100%';
1316
+
1317
+ applySvgSizing(svgEl, props.objectFit, props.objectPosition);
1318
+ applySvgTint(svgEl, props.tintColor);
1319
+
1320
+ if (props.colors) {
1321
+ for (const [key, value] of Object.entries(props.colors)) {
1322
+ svgEl.style.setProperty(key, value);
1323
+ }
1324
+ }
1325
+
1326
+ if (props.decorative) {
1327
+ svgEl.setAttribute('aria-hidden', 'true');
1328
+ } else if (props.alt) {
1329
+ svgEl.setAttribute('role', 'img');
1330
+ let title = svgEl.querySelector<SVGElement>('title');
1331
+ if (!title) {
1332
+ title = document.createElementNS('http://www.w3.org/2000/svg', 'title') as SVGElement;
1333
+ svgEl.prepend(title);
1334
+ }
1335
+ title.textContent = props.alt;
1336
+ const titleId = `exact-svg-title-${nextInlineSvgTitleId++}`;
1337
+ title.setAttribute('id', titleId);
1338
+ svgEl.setAttribute('aria-labelledby', titleId);
1339
+ }
1340
+
1341
+ host.appendChild(svgEl);
1342
+
1343
+ return () => {
1344
+ if (host.contains(svgEl)) {
1345
+ host.removeChild(svgEl);
1346
+ }
1347
+ };
1348
+ }, [
1349
+ props.colors,
1350
+ props.alt,
1351
+ props.decorative,
1352
+ props.markup,
1353
+ props.objectFit,
1354
+ props.objectPosition,
1355
+ props.tintColor,
1356
+ ]);
1357
+
1358
+ return React.createElement('span', {
1359
+ ref: hostRef,
1360
+ style: {
1361
+ display: 'block',
1362
+ boxSizing: 'border-box',
1363
+ overflow: 'hidden',
1364
+ ...baseStyle,
1365
+ },
1366
+ 'data-testid': props.testID,
1367
+ });
1368
+ }
1369
+
1370
+ function applySvgSizing(
1371
+ svgEl: SVGSVGElement,
1372
+ objectFit: SvgProps['objectFit'],
1373
+ objectPosition: SvgProps['objectPosition'],
1374
+ ): void {
1375
+ const alignment = mapObjectPositionToSvgAlign(objectPosition);
1376
+ const fit = objectFit ?? 'contain';
1377
+
1378
+ switch (fit) {
1379
+ case 'cover':
1380
+ svgEl.setAttribute('preserveAspectRatio', `${alignment} slice`);
1381
+ break;
1382
+ case 'fill':
1383
+ svgEl.setAttribute('preserveAspectRatio', 'none');
1384
+ break;
1385
+ case 'none':
1386
+ svgEl.setAttribute('preserveAspectRatio', alignment);
1387
+ break;
1388
+ case 'contain':
1389
+ case 'scale-down':
1390
+ default:
1391
+ svgEl.setAttribute('preserveAspectRatio', `${alignment} meet`);
1392
+ break;
1393
+ }
1394
+ }
1395
+
1396
+ function mapObjectPositionToSvgAlign(objectPosition: string | undefined): string {
1397
+ if (!objectPosition) {
1398
+ return 'xMidYMid';
1399
+ }
1400
+
1401
+ const tokens = objectPosition.toLowerCase().trim().split(/\s+/).filter(Boolean);
1402
+ let horizontal: 'xMin' | 'xMid' | 'xMax' = 'xMid';
1403
+ let vertical: 'YMin' | 'YMid' | 'YMax' = 'YMid';
1404
+
1405
+ for (const token of tokens) {
1406
+ if (token === 'left') {
1407
+ horizontal = 'xMin';
1408
+ } else if (token === 'right') {
1409
+ horizontal = 'xMax';
1410
+ } else if (token === 'top') {
1411
+ vertical = 'YMin';
1412
+ } else if (token === 'bottom') {
1413
+ vertical = 'YMax';
1414
+ } else if (token === 'center') {
1415
+ if (!tokens.some((value) => value === 'left' || value === 'right')) {
1416
+ horizontal = 'xMid';
1417
+ }
1418
+ if (!tokens.some((value) => value === 'top' || value === 'bottom')) {
1419
+ vertical = 'YMid';
1420
+ }
1421
+ }
1422
+ }
1423
+
1424
+ return `${horizontal}${vertical}`;
1425
+ }
1426
+
1427
+ function applySvgTint(svgEl: SVGSVGElement, tintColor: string | undefined): void {
1428
+ if (!tintColor) {
1429
+ return;
1430
+ }
1431
+
1432
+ svgEl.style.color = tintColor;
1433
+ const paintedNodes = svgEl.querySelectorAll<SVGElement>('[fill], [stroke]');
1434
+ paintedNodes.forEach((node) => {
1435
+ const fill = node.getAttribute('fill');
1436
+ if (fill && fill !== 'none' && !fill.startsWith('url(')) {
1437
+ node.setAttribute('fill', 'currentColor');
1438
+ }
1439
+
1440
+ const stroke = node.getAttribute('stroke');
1441
+ if (stroke && stroke !== 'none' && !stroke.startsWith('url(')) {
1442
+ node.setAttribute('stroke', 'currentColor');
1443
+ }
1444
+ });
1445
+ }
1446
+
1447
+ function WebToggle({ value, onValueChange, disabled, testID, style }: ToggleProps): React.ReactElement {
1448
+ const handleChange = React.useCallback(
1449
+ (event: React.ChangeEvent<HTMLInputElement>) => {
1450
+ if (onValueChange) {
1451
+ onValueChange(event.target.checked);
1452
+ }
1453
+ },
1454
+ [onValueChange],
1455
+ );
1456
+
1457
+ const baseStyle = style && typeof style === 'object'
1458
+ ? expandShorthands(style as Record<string, unknown>)
1459
+ : {};
1460
+
1461
+ return React.createElement('input', {
1462
+ type: 'checkbox',
1463
+ checked: value,
1464
+ onChange: handleChange,
1465
+ disabled,
1466
+ style: baseStyle,
1467
+ 'data-testid': testID,
1468
+ });
1469
+ }
1470
+
1471
+ // ---------------------------------------------------------------------------
1472
+ // Exports — platform-aware
1473
+ // ---------------------------------------------------------------------------
1474
+
1475
+ export const text: React.FC<TextElementProps> = function ExactTextPrimitive(props) {
1476
+ return isWebRenderEnvironment()
1477
+ ? React.createElement(WebText, props)
1478
+ : React.createElement('text', props);
1479
+ };
1480
+
1481
+ export const div: React.FC<ContainerProps> = function ExactDivPrimitive(props) {
1482
+ return isWebRenderEnvironment()
1483
+ ? React.createElement(WebDiv, props)
1484
+ : React.createElement('div', props);
1485
+ };
1486
+
1487
+ export const span: React.FC<ContainerProps> = function ExactSpanPrimitive(props) {
1488
+ return isWebRenderEnvironment()
1489
+ ? React.createElement(WebSpan, props)
1490
+ : React.createElement('span', props);
1491
+ };
1492
+
1493
+ export const button = React.forwardRef<HTMLButtonElement, PressableElementProps>(
1494
+ function ExactButtonPrimitive(props, ref) {
1495
+ return isWebRenderEnvironment()
1496
+ ? React.createElement(WebButton, { ...props, ref })
1497
+ : React.createElement('button', { ...props, ref } as any);
1498
+ },
1499
+ );
1500
+
1501
+ export const input: React.FC<TextInputProps> = function ExactInputPrimitive(props) {
1502
+ return isWebRenderEnvironment()
1503
+ ? React.createElement(WebTextInput, props)
1504
+ : React.createElement('input', props);
1505
+ };
1506
+
1507
+ export const img: React.FC<import('./types.js').ImageElementProps> = function ExactImgPrimitive(props) {
1508
+ return isWebRenderEnvironment()
1509
+ ? React.createElement(WebImg, props)
1510
+ : React.createElement('img', props);
1511
+ };
1512
+
1513
+ export const View: React.FC<ContainerProps> = function ExactViewPrimitive(props) {
1514
+ return isWebRenderEnvironment()
1515
+ ? React.createElement(WebView, props)
1516
+ : React.createElement('View', props);
1517
+ };
1518
+
1519
+ export const Text: React.FC<TextElementProps> = function ExactTextComponentPrimitive(props) {
1520
+ return isWebRenderEnvironment()
1521
+ ? React.createElement(WebText, props)
1522
+ : React.createElement('Text', props);
1523
+ };
1524
+
1525
+ export const ScrollView: React.FC<ScrollContainerProps> = function ExactScrollViewPrimitive(props) {
1526
+ return isWebRenderEnvironment()
1527
+ ? React.createElement(WebScrollView, props)
1528
+ : React.createElement('ScrollView', props);
1529
+ };
1530
+
1531
+ export const List: React.FC<ScrollContainerProps> = function ExactListPrimitive(props) {
1532
+ return isWebRenderEnvironment()
1533
+ ? React.createElement(WebScrollView, props)
1534
+ : React.createElement('List', props);
1535
+ };
1536
+
1537
+ export const Pressable: React.FC<PressableElementProps> = function ExactPressablePrimitive(props) {
1538
+ return isWebRenderEnvironment()
1539
+ ? React.createElement(WebPressable, props)
1540
+ : React.createElement('Pressable', props);
1541
+ };
1542
+
1543
+ export const TextInput: React.FC<TextInputProps> = function ExactTextInputPrimitive(props) {
1544
+ return isWebRenderEnvironment()
1545
+ ? React.createElement(WebTextInput, props)
1546
+ : React.createElement('TextInput', props);
1547
+ };
1548
+
1549
+ function NativeImage(props: RNImageProps): React.ReactElement {
1550
+ const delegatedSvgSource = coerceImageSourceToSvgSource(props.source);
1551
+ if (delegatedSvgSource !== undefined) {
1552
+ return React.createElement(NativeSvg, {
1553
+ source: delegatedSvgSource,
1554
+ style: props.style,
1555
+ objectFit: props.objectFit ?? mapResizeModeToObjectFit(props.resizeMode),
1556
+ objectPosition: props.objectPosition,
1557
+ alt: props.alt,
1558
+ decorative: props.decorative,
1559
+ testID: props.testID,
1560
+ });
1561
+ }
1562
+
1563
+ warnIfImageMissingAlt({
1564
+ source: props.source,
1565
+ alt: props.alt,
1566
+ decorative: props.decorative,
1567
+ testID: props.testID,
1568
+ });
1569
+
1570
+ return React.createElement('Image', props);
1571
+ }
1572
+
1573
+ export const Image: React.FC<RNImageProps> = function ExactImagePrimitive(props) {
1574
+ return isWebRenderEnvironment()
1575
+ ? React.createElement(WebImage, props)
1576
+ : React.createElement(NativeImage, props);
1577
+ };
1578
+
1579
+ function NativeSvg(props: SvgProps): React.ReactElement {
1580
+ const request = React.useMemo(() => resolveSvgSourceForFetch(props.source), [props.source]);
1581
+ const [resolvedMarkup, setResolvedMarkup] = React.useState<string>(request?.markup ?? '');
1582
+ const pixelDensity = props.pixelDensity ?? getDefaultSvgPixelDensity();
1583
+
1584
+ React.useEffect(() => {
1585
+ if (!request) {
1586
+ setResolvedMarkup('');
1587
+ return;
1588
+ }
1589
+
1590
+ if (request.markup != null) {
1591
+ setResolvedMarkup(request.markup);
1592
+ return;
1593
+ }
1594
+
1595
+ if (!request.uri) {
1596
+ setResolvedMarkup('');
1597
+ return;
1598
+ }
1599
+
1600
+ const abortController = new AbortController();
1601
+ let cancelled = false;
1602
+ fetch(request.uri, {
1603
+ headers: request.headers,
1604
+ signal: abortController.signal as AbortSignal,
1605
+ })
1606
+ .then(async (response) => {
1607
+ if (!response.ok) {
1608
+ throw new Error(`SVG request failed with ${response.status}`);
1609
+ }
1610
+ return response.text();
1611
+ })
1612
+ .then((text) => {
1613
+ if (!cancelled) {
1614
+ setResolvedMarkup(text);
1615
+ }
1616
+ })
1617
+ .catch((error: unknown) => {
1618
+ if (
1619
+ cancelled ||
1620
+ (typeof DOMException !== 'undefined' && error instanceof DOMException && error.name === 'AbortError')
1621
+ ) {
1622
+ return;
1623
+ }
1624
+ console.warn('[Exact] Failed to resolve SVG source for native rendering.', error);
1625
+ setResolvedMarkup('');
1626
+ });
1627
+
1628
+ return () => {
1629
+ cancelled = true;
1630
+ abortController.abort();
1631
+ };
1632
+ }, [request]);
1633
+
1634
+ return React.createElement('Svg', {
1635
+ ...props,
1636
+ colors: filterSvgColors(props.colors),
1637
+ pixelDensity,
1638
+ source: resolvedMarkup,
1639
+ });
1640
+ }
1641
+
1642
+ export const Svg: React.FC<SvgProps> = function ExactSvgPrimitive(props) {
1643
+ return isWebRenderEnvironment()
1644
+ ? React.createElement(WebSvg, props)
1645
+ : React.createElement(NativeSvg, props);
1646
+ };
1647
+
1648
+ export const Toggle: React.FC<ToggleProps> = function ExactTogglePrimitive(props) {
1649
+ return isWebRenderEnvironment()
1650
+ ? React.createElement(WebToggle, props)
1651
+ : React.createElement('Toggle', props);
1652
+ };
1653
+
1654
+ export const Switch: React.FC<ToggleProps> = Toggle;