@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,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;
|