@symbiote-native/components 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/LICENSE +21 -0
- package/README.md +110 -0
- package/build/accessibility-props.d.ts +64 -0
- package/build/accessibility-props.js +150 -0
- package/build/component-names/index.android.d.ts +3 -0
- package/build/component-names/index.android.js +32 -0
- package/build/component-names/index.d.ts +1 -0
- package/build/component-names/index.ios.d.ts +3 -0
- package/build/component-names/index.ios.js +26 -0
- package/build/component-names/index.js +5 -0
- package/build/component-names/shared.d.ts +7 -0
- package/build/component-names/shared.js +36 -0
- package/build/descriptor.d.ts +11 -0
- package/build/descriptor.js +17 -0
- package/build/index.d.ts +51 -0
- package/build/index.js +63 -0
- package/build/responder-props.d.ts +18 -0
- package/build/responder-props.js +9 -0
- package/build/scroll-view-commands.d.ts +23 -0
- package/build/scroll-view-commands.js +123 -0
- package/build/state/drawer-layout-android.d.ts +22 -0
- package/build/state/drawer-layout-android.js +53 -0
- package/build/state/flat-list.d.ts +12 -0
- package/build/state/flat-list.js +40 -0
- package/build/state/modal.d.ts +11 -0
- package/build/state/modal.js +28 -0
- package/build/state/pressable.d.ts +90 -0
- package/build/state/pressable.js +236 -0
- package/build/state/section-list.d.ts +46 -0
- package/build/state/section-list.js +51 -0
- package/build/state/switch.d.ts +12 -0
- package/build/state/switch.js +31 -0
- package/build/state/text-input.d.ts +103 -0
- package/build/state/text-input.js +205 -0
- package/build/state/touchable.d.ts +15 -0
- package/build/state/touchable.js +22 -0
- package/build/state/virtualized-list.d.ts +161 -0
- package/build/state/virtualized-list.js +306 -0
- package/build/view/render-activity-indicator.d.ts +25 -0
- package/build/view/render-activity-indicator.js +54 -0
- package/build/view/render-button.d.ts +19 -0
- package/build/view/render-button.js +24 -0
- package/build/view/render-drawer-layout-android.d.ts +23 -0
- package/build/view/render-drawer-layout-android.js +56 -0
- package/build/view/render-image-background.d.ts +9 -0
- package/build/view/render-image-background.js +48 -0
- package/build/view/render-image.d.ts +86 -0
- package/build/view/render-image.js +298 -0
- package/build/view/render-input-accessory-view.d.ts +9 -0
- package/build/view/render-input-accessory-view.js +18 -0
- package/build/view/render-keyboard-avoiding-view.d.ts +30 -0
- package/build/view/render-keyboard-avoiding-view.js +75 -0
- package/build/view/render-modal.d.ts +23 -0
- package/build/view/render-modal.js +70 -0
- package/build/view/render-pressable.d.ts +8 -0
- package/build/view/render-pressable.js +42 -0
- package/build/view/render-scroll-sticky.d.ts +24 -0
- package/build/view/render-scroll-sticky.js +81 -0
- package/build/view/render-scroll-view.d.ts +18 -0
- package/build/view/render-scroll-view.js +85 -0
- package/build/view/render-switch.d.ts +29 -0
- package/build/view/render-switch.js +33 -0
- package/build/view/render-text-input.d.ts +11 -0
- package/build/view/render-text-input.js +35 -0
- package/build/view/render-touchable-native-feedback.d.ts +17 -0
- package/build/view/render-touchable-native-feedback.js +39 -0
- package/package.json +38 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { dlog, flattenStyle, getNativeModule, Platform, } from '@symbiote-native/engine';
|
|
2
|
+
import { el } from '../descriptor';
|
|
3
|
+
// Default resolver: identity. RN's resolveAssetSource (which turns a require()
|
|
4
|
+
// number into {uri, scale, width, height}) is wired in by the app at startup.
|
|
5
|
+
let resolveSource = source => source;
|
|
6
|
+
export function setImageSourceResolver(resolve) {
|
|
7
|
+
resolveSource = resolve;
|
|
8
|
+
}
|
|
9
|
+
// Resolve the source, then normalize to the array shape native expects. A single
|
|
10
|
+
// object/number becomes a one-element array; an already-array source passes through.
|
|
11
|
+
function normalizeSource(source) {
|
|
12
|
+
const resolved = resolveSource(source);
|
|
13
|
+
const sources = Array.isArray(resolved) ? resolved : [resolved];
|
|
14
|
+
dlog(`Image source resolved to ${JSON.stringify(sources)}`);
|
|
15
|
+
return sources;
|
|
16
|
+
}
|
|
17
|
+
// The HTTP headers the W3C aliases (crossOrigin / referrerPolicy) contribute to
|
|
18
|
+
// every folded source, mirroring ImageSourceUtils.js getImageSourcesFromImageProps:
|
|
19
|
+
// 'use-credentials' adds the credentials header; referrerPolicy adds Referrer-Policy.
|
|
20
|
+
function headersFromAliases(view) {
|
|
21
|
+
const headers = {};
|
|
22
|
+
if (view.crossOrigin === 'use-credentials') {
|
|
23
|
+
headers['Access-Control-Allow-Credentials'] = 'true';
|
|
24
|
+
}
|
|
25
|
+
if (view.referrerPolicy !== undefined) {
|
|
26
|
+
headers['Referrer-Policy'] = view.referrerPolicy;
|
|
27
|
+
}
|
|
28
|
+
return headers;
|
|
29
|
+
}
|
|
30
|
+
// Expand a `srcSet` descriptor list into scaled sources, falling back to `src` for
|
|
31
|
+
// the 1x slot when srcSet omits it. Direct port of getImageSourcesFromImageProps'
|
|
32
|
+
// srcSet branch (ImageSourceUtils.js:48). Invalid scale tokens are skipped, matching
|
|
33
|
+
// RN's parse-and-warn behavior.
|
|
34
|
+
function expandSrcSet(srcSet, view, headers) {
|
|
35
|
+
const sources = [];
|
|
36
|
+
let useSrcForDefaultScale = true;
|
|
37
|
+
for (const entry of srcSet.split(', ')) {
|
|
38
|
+
const [uri, xScale = '1x'] = entry.split(' ');
|
|
39
|
+
if (!xScale.endsWith('x')) {
|
|
40
|
+
dlog(`Image srcSet: unsupported scale token "${xScale}", skipping`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const scale = parseInt(xScale.slice(0, -1), 10);
|
|
44
|
+
if (Number.isNaN(scale))
|
|
45
|
+
continue;
|
|
46
|
+
if (scale === 1)
|
|
47
|
+
useSrcForDefaultScale = false;
|
|
48
|
+
sources.push({ uri, scale, width: view.width, height: view.height, ...{ headers } });
|
|
49
|
+
}
|
|
50
|
+
if (useSrcForDefaultScale && view.src !== undefined) {
|
|
51
|
+
sources.push({
|
|
52
|
+
uri: view.src,
|
|
53
|
+
scale: 1,
|
|
54
|
+
width: view.width,
|
|
55
|
+
height: view.height,
|
|
56
|
+
...{ headers },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (sources.length === 0)
|
|
60
|
+
dlog('Image srcSet: produced no valid sources');
|
|
61
|
+
return sources;
|
|
62
|
+
}
|
|
63
|
+
// Resolve the native `source` array from whichever of source / src / srcSet the
|
|
64
|
+
// caller provided. Mirrors ImageSourceUtils.js getImageSourcesFromImageProps:
|
|
65
|
+
// srcSet wins, then src, then a header-decorated source, then the plain source.
|
|
66
|
+
// Always returns the array shape native expects (the same contract normalizeSource
|
|
67
|
+
// guarantees), so the component never sends a bare object.
|
|
68
|
+
function resolveSourceArray(view) {
|
|
69
|
+
const headers = headersFromAliases(view);
|
|
70
|
+
if (view.srcSet !== undefined) {
|
|
71
|
+
return expandSrcSet(view.srcSet, view, headers);
|
|
72
|
+
}
|
|
73
|
+
if (view.src !== undefined) {
|
|
74
|
+
return [{ uri: view.src, width: view.width, height: view.height, ...{ headers } }];
|
|
75
|
+
}
|
|
76
|
+
if (view.source === undefined) {
|
|
77
|
+
dlog('Image: no source / src / srcSet provided');
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
const sources = normalizeSource(view.source);
|
|
81
|
+
// A header-decorated single object source gets the headers merged in, per RN's
|
|
82
|
+
// `source.uri && headers` branch; the array/number shapes pass through untouched.
|
|
83
|
+
if (Object.keys(headers).length > 0 && sources.length === 1) {
|
|
84
|
+
const [only] = sources;
|
|
85
|
+
if (typeof only === 'object' && only !== null && typeof Reflect.get(only, 'uri') === 'string') {
|
|
86
|
+
return [{ ...only, headers }];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return sources;
|
|
90
|
+
}
|
|
91
|
+
function readStyleString(style, key) {
|
|
92
|
+
if (style === undefined)
|
|
93
|
+
return undefined;
|
|
94
|
+
// style is a StyleProp (possibly a nested array), so flatten before reading a key.
|
|
95
|
+
const flat = flattenStyle(style);
|
|
96
|
+
const value = Object.hasOwn(flat, key) ? flat[key] : undefined;
|
|
97
|
+
return typeof value === 'string' ? value : undefined;
|
|
98
|
+
}
|
|
99
|
+
// Resolve an asset source and read its single uri. RN forwards the Android
|
|
100
|
+
// loading indicator as a bare uri string (`loadingIndicatorSrc`), not the
|
|
101
|
+
// array shape the main source uses, so we resolve and pluck the uri.
|
|
102
|
+
function readSourceUri(source) {
|
|
103
|
+
const [resolved] = normalizeSource(source);
|
|
104
|
+
if (typeof resolved === 'object' && resolved !== null) {
|
|
105
|
+
const uri = Reflect.get(resolved, 'uri');
|
|
106
|
+
if (typeof uri === 'string')
|
|
107
|
+
return uri;
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
// The iOS native module name RN registers this under (NativeImageLoaderIOS.js
|
|
112
|
+
// resolves `TurboModuleRegistry.getEnforcing<Spec>('ImageLoader')`). Per the
|
|
113
|
+
// symbiote invariant, a module name is only provable on a real host (a headless
|
|
114
|
+
// fake answers to any name); this iOS name is device-verify-pending. See
|
|
115
|
+
// .docs/native-module-platform-routing.md.
|
|
116
|
+
const IMAGE_LOADER_MODULE = 'ImageLoader';
|
|
117
|
+
let imageLoaderModule;
|
|
118
|
+
function getImageLoader() {
|
|
119
|
+
if (imageLoaderModule === undefined) {
|
|
120
|
+
imageLoaderModule = getNativeModule(IMAGE_LOADER_MODULE);
|
|
121
|
+
dlog(`Image: ImageLoader module ${imageLoaderModule ? 'resolved' : 'NOT resolved (null)'}`);
|
|
122
|
+
}
|
|
123
|
+
return imageLoaderModule;
|
|
124
|
+
}
|
|
125
|
+
function isNumber(value) {
|
|
126
|
+
return typeof value === 'number';
|
|
127
|
+
}
|
|
128
|
+
// Narrow native's getSize result. The spec resolves a `[width, height]` array,
|
|
129
|
+
// but tolerate a `{width, height}` object too (getSizeWithHeaders uses that shape).
|
|
130
|
+
function toImageSize(result) {
|
|
131
|
+
if (Array.isArray(result) && isNumber(result[0]) && isNumber(result[1])) {
|
|
132
|
+
return { width: result[0], height: result[1] };
|
|
133
|
+
}
|
|
134
|
+
if (typeof result === 'object' && result !== null) {
|
|
135
|
+
const width = Reflect.get(result, 'width');
|
|
136
|
+
const height = Reflect.get(result, 'height');
|
|
137
|
+
if (isNumber(width) && isNumber(height))
|
|
138
|
+
return { width, height };
|
|
139
|
+
}
|
|
140
|
+
throw new Error(`Image: unexpected size result from native: ${JSON.stringify(result)}`);
|
|
141
|
+
}
|
|
142
|
+
function requireLoader(method) {
|
|
143
|
+
const loader = getImageLoader();
|
|
144
|
+
if (loader === null) {
|
|
145
|
+
throw new Error(`Image.${method}: ImageLoader native module is not available ` +
|
|
146
|
+
'(running headless or not linked on this host).');
|
|
147
|
+
}
|
|
148
|
+
return loader;
|
|
149
|
+
}
|
|
150
|
+
// Resolve image dimensions, optionally via success/failure callbacks. Always
|
|
151
|
+
// returns the Promise too (RN returns void when a callback is passed, but a
|
|
152
|
+
// promise-and-callback shape is friendlier and a strict superset).
|
|
153
|
+
function getSize(uri, success, failure) {
|
|
154
|
+
const promise = Promise.resolve()
|
|
155
|
+
.then(() => requireLoader('getSize').getSize(uri))
|
|
156
|
+
.then(toImageSize);
|
|
157
|
+
if (typeof success === 'function') {
|
|
158
|
+
promise
|
|
159
|
+
.then(size => success(size.width, size.height))
|
|
160
|
+
.catch((error) => {
|
|
161
|
+
if (typeof failure === 'function')
|
|
162
|
+
failure(error);
|
|
163
|
+
else
|
|
164
|
+
dlog(`Image.getSize failed for ${uri}: ${String(error)}`);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return promise;
|
|
168
|
+
}
|
|
169
|
+
function getSizeWithHeaders(uri, headers, success, failure) {
|
|
170
|
+
const promise = Promise.resolve()
|
|
171
|
+
.then(() => requireLoader('getSizeWithHeaders').getSizeWithHeaders(uri, headers))
|
|
172
|
+
.then(toImageSize);
|
|
173
|
+
if (typeof success === 'function') {
|
|
174
|
+
promise
|
|
175
|
+
.then(size => success(size.width, size.height))
|
|
176
|
+
.catch((error) => {
|
|
177
|
+
if (typeof failure === 'function')
|
|
178
|
+
failure(error);
|
|
179
|
+
else
|
|
180
|
+
dlog(`Image.getSizeWithHeaders failed for ${uri}: ${String(error)}`);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return promise;
|
|
184
|
+
}
|
|
185
|
+
// Android keys an in-flight prefetch by a monotonic requestId (so abortRequest can
|
|
186
|
+
// cancel it); RN's Image.android.js generates the same way. iOS ignores the arg.
|
|
187
|
+
let prefetchRequestId = 0;
|
|
188
|
+
// Download a remote image into the disk cache. Resolves to whether it succeeded.
|
|
189
|
+
// `callback` receives the requestId (RN's Image.android.js shape) so the caller
|
|
190
|
+
// can later pass it to abortPrefetch.
|
|
191
|
+
async function prefetch(uri, callback) {
|
|
192
|
+
prefetchRequestId += 1;
|
|
193
|
+
const requestId = prefetchRequestId;
|
|
194
|
+
if (typeof callback === 'function')
|
|
195
|
+
callback(requestId);
|
|
196
|
+
const loader = requireLoader('prefetch');
|
|
197
|
+
return (Promise.resolve()
|
|
198
|
+
// Android's prefetchImage keys an abortable request on requestId; iOS takes ONLY the uri and
|
|
199
|
+
// throws on an extra arg (bridgeless TurboModule arg-count check). Match RN's per-platform call.
|
|
200
|
+
.then(() => Platform.OS === 'android'
|
|
201
|
+
? loader.prefetchImage(uri, requestId)
|
|
202
|
+
: loader.prefetchImage(uri))
|
|
203
|
+
.then(result => result === true)
|
|
204
|
+
.catch((error) => {
|
|
205
|
+
dlog(`Image.prefetch failed for ${uri}: ${String(error)}`);
|
|
206
|
+
throw error;
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
// Cancel an in-flight prefetch by the requestId prefetch handed back. Android
|
|
210
|
+
// only (mirrors Image.android.js -> NativeImageLoaderAndroid.abortRequest); a
|
|
211
|
+
// missing abortRequest (iOS, headless) is a no-op rather than a throw.
|
|
212
|
+
function abortPrefetch(requestId) {
|
|
213
|
+
const loader = getImageLoader();
|
|
214
|
+
if (loader === null || typeof loader.abortRequest !== 'function') {
|
|
215
|
+
dlog(`Image.abortPrefetch(${requestId}): no abortRequest on this host, ignoring`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
loader.abortRequest(requestId);
|
|
219
|
+
}
|
|
220
|
+
// Narrow native's queryCache result: an object mapping each known uri to its
|
|
221
|
+
// cache status. Unknown statuses are dropped rather than trusted blindly.
|
|
222
|
+
const CACHE_STATUS = {
|
|
223
|
+
memory: 'memory',
|
|
224
|
+
disk: 'disk',
|
|
225
|
+
'disk/memory': 'disk/memory',
|
|
226
|
+
};
|
|
227
|
+
function toCacheRecord(result) {
|
|
228
|
+
const record = {};
|
|
229
|
+
if (typeof result !== 'object' || result === null)
|
|
230
|
+
return record;
|
|
231
|
+
for (const key of Object.keys(result)) {
|
|
232
|
+
const value = Reflect.get(result, key);
|
|
233
|
+
if (typeof value === 'string' && Object.hasOwn(CACHE_STATUS, value)) {
|
|
234
|
+
record[key] = CACHE_STATUS[value];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return record;
|
|
238
|
+
}
|
|
239
|
+
async function queryCache(uris) {
|
|
240
|
+
return Promise.resolve()
|
|
241
|
+
.then(() => {
|
|
242
|
+
const loader = requireLoader('queryCache');
|
|
243
|
+
// The native queryCache never rejects (RCTImageLoader resolves getImageCacheStatus), so a
|
|
244
|
+
// rejection here is a JS↔native boundary fault: log whether the method is even callable and
|
|
245
|
+
// the arg shape, to tell "not a function" (interop gap) from a marshalling reject.
|
|
246
|
+
dlog(`Image.queryCache: typeof loader.queryCache=${typeof loader.queryCache} uris=${uris.length}`);
|
|
247
|
+
return loader.queryCache(uris);
|
|
248
|
+
})
|
|
249
|
+
.then(toCacheRecord)
|
|
250
|
+
.catch((error) => {
|
|
251
|
+
dlog(`Image.queryCache failed: ${String(error)}`);
|
|
252
|
+
throw error;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
// PURE JS: run the currently-installed source resolver (the same machinery the
|
|
256
|
+
// Image component uses via normalizeSource). RN's resolveAssetSource turns a
|
|
257
|
+
// require() asset id into {uri, scale, …}; the app injects the real one with
|
|
258
|
+
// setImageSourceResolver, and this exposes its output to callers directly.
|
|
259
|
+
function resolveAssetSource(source) {
|
|
260
|
+
return resolveSource(source);
|
|
261
|
+
}
|
|
262
|
+
export const imageStatics = {
|
|
263
|
+
getSize,
|
|
264
|
+
getSizeWithHeaders,
|
|
265
|
+
prefetch,
|
|
266
|
+
abortPrefetch,
|
|
267
|
+
queryCache,
|
|
268
|
+
resolveAssetSource,
|
|
269
|
+
};
|
|
270
|
+
export function renderImage(view) {
|
|
271
|
+
// `width` / `height` aliases fold into style (ImageProps.js:195,202); explicit
|
|
272
|
+
// style keys win, matching RN's `{width, height}, ...style` ordering.
|
|
273
|
+
const foldedStyle = view.width === undefined && view.height === undefined
|
|
274
|
+
? view.style
|
|
275
|
+
: [{ width: view.width, height: view.height }, view.style];
|
|
276
|
+
const mapped = {
|
|
277
|
+
...view.passthrough,
|
|
278
|
+
style: foldedStyle,
|
|
279
|
+
source: resolveSourceArray(view),
|
|
280
|
+
resizeMode: view.resizeMode ?? readStyleString(view.style, 'resizeMode'),
|
|
281
|
+
tintColor: view.tintColor ?? readStyleString(view.style, 'tintColor'),
|
|
282
|
+
};
|
|
283
|
+
// `alt` is the accessibility text: it sets accessibilityLabel and marks the image
|
|
284
|
+
// accessible (Image.ios.js / Image.android.js: alt -> accessibilityLabel + accessible).
|
|
285
|
+
// An explicit accessibilityLabel (already folded into passthrough) still wins.
|
|
286
|
+
if (view.alt !== undefined) {
|
|
287
|
+
if (mapped.accessibilityLabel === undefined)
|
|
288
|
+
mapped.accessibilityLabel = view.alt;
|
|
289
|
+
mapped.accessible = true;
|
|
290
|
+
}
|
|
291
|
+
if (view.defaultSource !== undefined)
|
|
292
|
+
mapped.defaultSource = normalizeSource(view.defaultSource);
|
|
293
|
+
if (view.loadingIndicatorSource !== undefined) {
|
|
294
|
+
mapped.loadingIndicatorSrc = readSourceUri(view.loadingIndicatorSource);
|
|
295
|
+
}
|
|
296
|
+
dlog('Image -> RCTImageView');
|
|
297
|
+
return el('symbiote-image', mapped);
|
|
298
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type IStyleProp, type IViewStyle } from '@symbiote-native/engine';
|
|
2
|
+
import { type IDescriptor } from '../descriptor';
|
|
3
|
+
export type IInputAccessoryViewViewProps = {
|
|
4
|
+
nativeID?: string;
|
|
5
|
+
backgroundColor?: string;
|
|
6
|
+
style?: IStyleProp<IViewStyle>;
|
|
7
|
+
passthrough: Record<string, unknown>;
|
|
8
|
+
};
|
|
9
|
+
export declare function renderInputAccessoryView(view: IInputAccessoryViewViewProps): IDescriptor;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// InputAccessoryView: the render half (framework-agnostic, iOS). A real Fabric host node,
|
|
2
|
+
// RCTInputAccessoryView, that docks its content above the keyboard. It is referenced by
|
|
3
|
+
// `nativeID`, which a TextInput points at through its `inputAccessoryViewID` prop; native pairs
|
|
4
|
+
// the two by id. There is no JS-side translation: style / nativeID / backgroundColor map straight
|
|
5
|
+
// onto the intrinsic and the user children (injected by the adapter) nest under it. Shared
|
|
6
|
+
// verbatim across adapters: React and Vue both bridge this Descriptor.
|
|
7
|
+
import { dlog } from '@symbiote-native/engine';
|
|
8
|
+
import { el } from '../descriptor';
|
|
9
|
+
export function renderInputAccessoryView(view) {
|
|
10
|
+
const props = { ...view.passthrough, style: view.style };
|
|
11
|
+
if (view.nativeID !== undefined)
|
|
12
|
+
props.nativeID = view.nativeID;
|
|
13
|
+
if (view.backgroundColor !== undefined)
|
|
14
|
+
props.backgroundColor = view.backgroundColor;
|
|
15
|
+
dlog('InputAccessoryView -> RCTInputAccessoryView');
|
|
16
|
+
// Empty structural children: the adapter appends the user children directly under the host.
|
|
17
|
+
return el('symbiote-input-accessory-view', props, []);
|
|
18
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { IStyleProp, IViewStyle } from '@symbiote-native/engine';
|
|
2
|
+
export type IKeyboardAvoidingBehavior = 'height' | 'position' | 'padding';
|
|
3
|
+
export declare const DEFAULT_VERTICAL_OFFSET = 0;
|
|
4
|
+
export interface IMeasuredFrame {
|
|
5
|
+
y: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
export interface IKeyboardFrame {
|
|
9
|
+
screenY: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function readKeyboardFrame(payload: unknown): IKeyboardFrame | undefined;
|
|
13
|
+
export declare function readLayoutFrame(layout: unknown): IMeasuredFrame | undefined;
|
|
14
|
+
export declare function computeInset(frame: IMeasuredFrame | undefined, keyboard: IKeyboardFrame | undefined, verticalOffset: number): number;
|
|
15
|
+
export type IKeyboardAvoidingLayout = {
|
|
16
|
+
kind: 'nested';
|
|
17
|
+
wrapperStyle?: IStyleProp<IViewStyle>;
|
|
18
|
+
innerStyle: IStyleProp<IViewStyle>;
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'wrapper';
|
|
21
|
+
wrapperStyle?: IStyleProp<IViewStyle>;
|
|
22
|
+
};
|
|
23
|
+
export interface IResolveKeyboardAvoidingLayoutParams {
|
|
24
|
+
behavior?: IKeyboardAvoidingBehavior;
|
|
25
|
+
effectiveInset: number;
|
|
26
|
+
initialHeight?: number;
|
|
27
|
+
style?: IStyleProp<IViewStyle>;
|
|
28
|
+
contentContainerStyle?: IStyleProp<IViewStyle>;
|
|
29
|
+
}
|
|
30
|
+
export declare function resolveKeyboardAvoidingLayout(params: IResolveKeyboardAvoidingLayoutParams): IKeyboardAvoidingLayout;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// KeyboardAvoidingView: the pure logic + view-contract half (framework-agnostic). It owns
|
|
2
|
+
// every piece that does NOT need a framework: the keyboard/frame inset math, the onLayout
|
|
3
|
+
// frame read, and the behavior → style/structure decision. Each adapter supplies ONLY the
|
|
4
|
+
// lifecycle (subscribe to the Keyboard module, measure via onLayout, hold the inset in its
|
|
5
|
+
// reactive primitive) and assembles the wrapper element around its own opaque children.
|
|
6
|
+
//
|
|
7
|
+
// We do NOT emit a Descriptor tree here the way render-switch does, because KAV wraps
|
|
8
|
+
// arbitrary user-provided children (React nodes / Vue slots) that the Descriptor model can't
|
|
9
|
+
// carry. Instead we return a layout DESCRIPTION (which styles to apply and whether to nest)
|
|
10
|
+
// and the adapter builds its own element tree with its children. Mirrors RN's
|
|
11
|
+
// Libraries/Components/Keyboard/KeyboardAvoidingView.js inset/behavior logic.
|
|
12
|
+
// RN's default keyboardVerticalOffset (KeyboardAvoidingView.js).
|
|
13
|
+
export const DEFAULT_VERTICAL_OFFSET = 0;
|
|
14
|
+
// RN rounds nothing here; 'height' mode collapses flex so the shrunk height holds.
|
|
15
|
+
const COLLAPSED_FLEX = 0;
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return typeof value === 'object' && value !== null;
|
|
18
|
+
}
|
|
19
|
+
// Pull the keyboard's top edge (screenY) and height off the raw native payload. The shape is
|
|
20
|
+
// the consumer's knowledge, so we narrow `unknown` here rather than trust a type, no `as`.
|
|
21
|
+
// Returns undefined when the payload isn't a keyboard frame.
|
|
22
|
+
export function readKeyboardFrame(payload) {
|
|
23
|
+
if (!isRecord(payload))
|
|
24
|
+
return undefined;
|
|
25
|
+
const end = payload.endCoordinates;
|
|
26
|
+
if (!isRecord(end))
|
|
27
|
+
return undefined;
|
|
28
|
+
const { screenY, height } = end;
|
|
29
|
+
if (typeof screenY !== 'number' || typeof height !== 'number')
|
|
30
|
+
return undefined;
|
|
31
|
+
return { screenY, height };
|
|
32
|
+
}
|
|
33
|
+
// Pull the measured wrapper frame ({ y, height }) off a raw onLayout layout object. Returns
|
|
34
|
+
// undefined when the shape doesn't carry both numbers, so a bad payload never poisons the math.
|
|
35
|
+
export function readLayoutFrame(layout) {
|
|
36
|
+
if (!isRecord(layout))
|
|
37
|
+
return undefined;
|
|
38
|
+
const { y, height } = layout;
|
|
39
|
+
if (typeof y !== 'number' || typeof height !== 'number')
|
|
40
|
+
return undefined;
|
|
41
|
+
return { y, height };
|
|
42
|
+
}
|
|
43
|
+
// RN's _relativeKeyboardHeight: how far up the view must move so it no longer overlaps the
|
|
44
|
+
// keyboard. keyboardY is the keyboard's top edge minus the caller's vertical offset; the inset
|
|
45
|
+
// is the overlap of the view's bottom past that edge, clamped at 0.
|
|
46
|
+
export function computeInset(frame, keyboard, verticalOffset) {
|
|
47
|
+
if (frame === undefined || keyboard === undefined)
|
|
48
|
+
return 0;
|
|
49
|
+
const keyboardY = keyboard.screenY - verticalOffset;
|
|
50
|
+
return Math.max(frame.y + frame.height - keyboardY, 0);
|
|
51
|
+
}
|
|
52
|
+
// Map the behavior + effective inset onto the wrapper/inner styles and the nesting decision:
|
|
53
|
+
// the framework-agnostic core of RN's render(). 'position' nests; the others adjust the
|
|
54
|
+
// wrapper directly. 'height' shrinks the wrapper from its initial measured height (only while
|
|
55
|
+
// the keyboard is up, matching RN).
|
|
56
|
+
export function resolveKeyboardAvoidingLayout(params) {
|
|
57
|
+
const { behavior, effectiveInset, initialHeight, style, contentContainerStyle } = params;
|
|
58
|
+
if (behavior === 'position') {
|
|
59
|
+
return {
|
|
60
|
+
kind: 'nested',
|
|
61
|
+
wrapperStyle: style,
|
|
62
|
+
innerStyle: [contentContainerStyle, { bottom: effectiveInset }],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (behavior === 'padding') {
|
|
66
|
+
return { kind: 'wrapper', wrapperStyle: [style, { paddingBottom: effectiveInset }] };
|
|
67
|
+
}
|
|
68
|
+
if (behavior === 'height' && effectiveInset > 0 && initialHeight !== undefined) {
|
|
69
|
+
return {
|
|
70
|
+
kind: 'wrapper',
|
|
71
|
+
wrapperStyle: [style, { height: initialHeight - effectiveInset, flex: COLLAPSED_FLEX }],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return { kind: 'wrapper', wrapperStyle: style };
|
|
75
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type IStyleProp, type IViewStyle } from '@symbiote-native/engine';
|
|
2
|
+
import { type IDescriptor } from '../descriptor';
|
|
3
|
+
export type IModalAnimationType = 'none' | 'slide' | 'fade';
|
|
4
|
+
export type IModalPresentationStyle = 'fullScreen' | 'pageSheet' | 'formSheet' | 'overFullScreen';
|
|
5
|
+
export type IModalOrientation = 'portrait' | 'portrait-upside-down' | 'landscape' | 'landscape-left' | 'landscape-right';
|
|
6
|
+
export interface IModalOrientationChangeEvent {
|
|
7
|
+
orientation: 'portrait' | 'landscape';
|
|
8
|
+
}
|
|
9
|
+
export type IModalViewProps = {
|
|
10
|
+
visible?: boolean;
|
|
11
|
+
transparent?: boolean;
|
|
12
|
+
backdropColor?: string;
|
|
13
|
+
animationType?: IModalAnimationType;
|
|
14
|
+
presentationStyle?: IModalPresentationStyle;
|
|
15
|
+
supportedOrientations?: ReadonlyArray<IModalOrientation>;
|
|
16
|
+
hardwareAccelerated?: boolean;
|
|
17
|
+
statusBarTranslucent?: boolean;
|
|
18
|
+
navigationBarTranslucent?: boolean;
|
|
19
|
+
allowSwipeDismissal?: boolean;
|
|
20
|
+
style?: IStyleProp<IViewStyle>;
|
|
21
|
+
passthrough: Record<string, unknown>;
|
|
22
|
+
};
|
|
23
|
+
export declare function renderModal(view: IModalViewProps): IDescriptor;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Modal: the render half (framework-agnostic). RCTModalHostView is an ordinary Fabric host
|
|
2
|
+
// node: it lives in the SAME childSet and commits through the SAME completeRoot as the rest of
|
|
3
|
+
// the tree. The native iOS/Android view presents its own window internally; there is no second
|
|
4
|
+
// root or second surface on the JS side. So this is a thin render exactly like the others: it
|
|
5
|
+
// maps to the `symbiote-modal` intrinsic the host config routes to ModalHostView, wrapping a
|
|
6
|
+
// full-screen container View that holds the user children (injected by the adapter). Shared
|
|
7
|
+
// verbatim: React and Vue both bridge this Descriptor; the keep-alive state lives in state/modal.ts.
|
|
8
|
+
import { dlog } from '@symbiote-native/engine';
|
|
9
|
+
import { el } from '../descriptor';
|
|
10
|
+
// The full-screen box RN anchors the modal content in (Modal.js styles.container: [side]:0,
|
|
11
|
+
// top:0, flex:1, backgroundColor:'white'). It is NOT position:absolute, it is a flex child that
|
|
12
|
+
// fills the ModalHostView, whose shadow node self-sizes to the screen
|
|
13
|
+
// (ModalHostViewComponentDescriptor sets the node size to screenSize). An absolute container with
|
|
14
|
+
// only top/left would collapse to its content instead. The backdrop color is layered on at render
|
|
15
|
+
// time so transparent/backdropColor win.
|
|
16
|
+
const CONTAINER_STYLE = {
|
|
17
|
+
left: 0,
|
|
18
|
+
top: 0,
|
|
19
|
+
flex: 1,
|
|
20
|
+
};
|
|
21
|
+
// RN sets styles.modal (position:'absolute') on RCTModalHostView itself (Modal.js styles.modal +
|
|
22
|
+
// style={styles.modal} on the host).
|
|
23
|
+
const MODAL_HOST_STYLE = {
|
|
24
|
+
position: 'absolute',
|
|
25
|
+
};
|
|
26
|
+
const TRANSPARENT_BACKDROP = 'transparent';
|
|
27
|
+
const OPAQUE_BACKDROP = 'white';
|
|
28
|
+
const DEFAULT_ANIMATION_TYPE = 'none';
|
|
29
|
+
// presentationStyle default (Modal.js: undefined -> 'fullScreen', but transparent flips it to
|
|
30
|
+
// 'overFullScreen').
|
|
31
|
+
const PRESENTATION_FULL_SCREEN = 'fullScreen';
|
|
32
|
+
const PRESENTATION_OVER_FULL_SCREEN = 'overFullScreen';
|
|
33
|
+
export function renderModal(view) {
|
|
34
|
+
// Only override backgroundColor when transparent or backdropColor are explicitly set, so these
|
|
35
|
+
// Modal-specific props take precedence over the generic style prop (Modal.js: containerStyles
|
|
36
|
+
// composed LAST in [styles.container, props.style, containerStyles]).
|
|
37
|
+
const backdropOverride = view.transparent === true
|
|
38
|
+
? { backgroundColor: TRANSPARENT_BACKDROP }
|
|
39
|
+
: view.backdropColor !== undefined
|
|
40
|
+
? { backgroundColor: view.backdropColor }
|
|
41
|
+
: {};
|
|
42
|
+
const containerStyle = [
|
|
43
|
+
{ ...CONTAINER_STYLE, backgroundColor: OPAQUE_BACKDROP },
|
|
44
|
+
view.style,
|
|
45
|
+
backdropOverride,
|
|
46
|
+
];
|
|
47
|
+
const resolvedPresentationStyle = view.presentationStyle ??
|
|
48
|
+
(view.transparent === true ? PRESENTATION_OVER_FULL_SCREEN : PRESENTATION_FULL_SCREEN);
|
|
49
|
+
dlog('Modal visible -> committing ModalHostView(container View)');
|
|
50
|
+
// collapsable:false keeps the container as a real shadow node (RN sets this so the wrapper is
|
|
51
|
+
// never flattened away under the host). Empty structural children: the adapter injects the
|
|
52
|
+
// user children UNDER this container, never as a direct sibling of the host.
|
|
53
|
+
const container = el('symbiote-view', { style: containerStyle, collapsable: false }, []);
|
|
54
|
+
return el('symbiote-modal', {
|
|
55
|
+
...view.passthrough,
|
|
56
|
+
style: MODAL_HOST_STYLE,
|
|
57
|
+
transparent: view.transparent,
|
|
58
|
+
animationType: view.animationType ?? DEFAULT_ANIMATION_TYPE,
|
|
59
|
+
presentationStyle: resolvedPresentationStyle,
|
|
60
|
+
// Platform props named-forwarded to match RCTModalHostView (Modal.js ~336-350): iOS
|
|
61
|
+
// supportedOrientations/allowSwipeDismissal, Android hardwareAccelerated/
|
|
62
|
+
// statusBarTranslucent/navigationBarTranslucent.
|
|
63
|
+
supportedOrientations: view.supportedOrientations,
|
|
64
|
+
hardwareAccelerated: view.hardwareAccelerated,
|
|
65
|
+
statusBarTranslucent: view.statusBarTranslucent,
|
|
66
|
+
navigationBarTranslucent: view.navigationBarTranslucent,
|
|
67
|
+
allowSwipeDismissal: view.allowSwipeDismissal,
|
|
68
|
+
visible: view.visible,
|
|
69
|
+
}, [container]);
|
|
70
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IAccessibilityStateValue } from '../accessibility-props';
|
|
2
|
+
import type { IPressHandlers } from '../state/pressable';
|
|
3
|
+
export declare function resolveDisabledAccessibilityState(accessibilityState: IAccessibilityStateValue | undefined, disabled: boolean | undefined): IAccessibilityStateValue | undefined;
|
|
4
|
+
export declare function buildPressableListeners(handlers: IPressHandlers, options: {
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
cancelable?: boolean;
|
|
7
|
+
}): Record<string, unknown>;
|
|
8
|
+
export declare function noteHoverNoop(onHoverIn: unknown, onHoverOut: unknown): void;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Pressable: the render half (framework-agnostic). Pressable owns no host element of its own:
|
|
2
|
+
// it composes the adapter's View (so children stay framework nodes), so this layer does not paint
|
|
3
|
+
// a Descriptor. It resolves the two prop decisions that are identical across adapters: which
|
|
4
|
+
// listeners the responder View carries (gated on disabled + cancelable), and how `disabled` folds
|
|
5
|
+
// into accessibilityState. The adapter feeds these into its View element. Pure, no framework.
|
|
6
|
+
import { dlog } from '@symbiote-native/engine';
|
|
7
|
+
// RN merges `disabled` into the resolved accessibilityState so a disabled Pressable reports the
|
|
8
|
+
// disabled state even if the caller passed none (Pressable.js: disabled != null ? {...state,
|
|
9
|
+
// disabled} : state). Untouched when disabled is unset.
|
|
10
|
+
export function resolveDisabledAccessibilityState(accessibilityState, disabled) {
|
|
11
|
+
return disabled !== undefined ? { ...accessibilityState, disabled } : accessibilityState;
|
|
12
|
+
}
|
|
13
|
+
// The listeners the responder View carries. When disabled, leave them off entirely. A press
|
|
14
|
+
// never fires and pressed-state never flips, exactly as RN's disabled Pressable. cancelable ===
|
|
15
|
+
// false refuses to yield the responder (RN routes cancelable to onResponderTerminationRequest,
|
|
16
|
+
// default true when unset).
|
|
17
|
+
export function buildPressableListeners(handlers, options) {
|
|
18
|
+
if (options.disabled === true) {
|
|
19
|
+
dlog('Pressable disabled — listeners suppressed');
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
const listeners = {
|
|
23
|
+
onPress: handlers.handlePress,
|
|
24
|
+
onPressIn: handlers.handlePressIn,
|
|
25
|
+
onPressOut: handlers.handlePressOut,
|
|
26
|
+
// Claim the responder so the move stream reaches this View; retention reads it.
|
|
27
|
+
onStartShouldSetResponder: () => true,
|
|
28
|
+
onResponderMove: handlers.handleResponderMove,
|
|
29
|
+
};
|
|
30
|
+
if (options.cancelable !== undefined) {
|
|
31
|
+
listeners.onResponderTerminationRequest = () => options.cancelable;
|
|
32
|
+
}
|
|
33
|
+
return listeners;
|
|
34
|
+
}
|
|
35
|
+
// Hover has no event on a touch host: there is no pointer-enter/leave. The adapter accepts and
|
|
36
|
+
// types the RN hover props but forwards nothing; this records the no-op so a missing hover
|
|
37
|
+
// callback on device is explained, not silent (RN onHoverIn/onHoverOut).
|
|
38
|
+
export function noteHoverNoop(onHoverIn, onHoverOut) {
|
|
39
|
+
if (onHoverIn !== undefined || onHoverOut !== undefined) {
|
|
40
|
+
dlog('Pressable hover is a no-op on this host (no pointer-enter/leave event)');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AnimatedValue, ISymbioteEvent } from '@symbiote-native/engine';
|
|
2
|
+
export declare const STICKY_HEADER_Z_INDEX = 10;
|
|
3
|
+
export declare function stickyDebounceMs(os: string): number;
|
|
4
|
+
export type IStickyHeaderProps = {
|
|
5
|
+
nextHeaderLayoutY: number | undefined;
|
|
6
|
+
onLayout: (event: ISymbioteEvent) => void;
|
|
7
|
+
scrollAnimatedValue: AnimatedValue;
|
|
8
|
+
inverted: boolean | undefined;
|
|
9
|
+
scrollViewHeight: number | undefined;
|
|
10
|
+
};
|
|
11
|
+
export declare function readLayoutNumber(event: ISymbioteEvent, key: 'y' | 'height'): number | undefined;
|
|
12
|
+
export type IStickyInterpolationParams = {
|
|
13
|
+
measured: boolean;
|
|
14
|
+
inverted: boolean | undefined;
|
|
15
|
+
scrollViewHeight: number | undefined;
|
|
16
|
+
layoutY: number;
|
|
17
|
+
layoutHeight: number;
|
|
18
|
+
nextHeaderLayoutY: number | undefined;
|
|
19
|
+
};
|
|
20
|
+
export declare function computeStickyInterpolation(params: IStickyInterpolationParams): {
|
|
21
|
+
inputRange: number[];
|
|
22
|
+
outputRange: number[];
|
|
23
|
+
};
|
|
24
|
+
export declare function nextStickyHeaderY(stickyHeaderIndices: number[], indexOfIndex: number, headerLayoutYs: ReadonlyMap<number, number>): number | undefined;
|