@symbiote-native/components 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/build/accessibility-props.js +2 -1
- package/build/bootstrap/index.js +1 -1
- package/build/component-names/index.android.js +1 -1
- package/build/component-names/index.ios.js +1 -1
- package/build/component-names/shared.js +4 -1
- package/build/descriptor.d.ts +8 -0
- package/build/descriptor.js +27 -0
- package/build/index.d.ts +19 -11
- package/build/index.js +12 -5
- package/build/scroll-view-commands.js +5 -1
- package/build/state/pressable.js +13 -3
- package/build/state/sticky-header-reducer.d.ts +1 -0
- package/build/state/sticky-header-reducer.js +97 -5
- package/build/state/switch.js +1 -1
- package/build/state/text-input.d.ts +2 -1
- package/build/state/text-input.js +35 -8
- package/build/state/touchable.d.ts +30 -1
- package/build/state/touchable.js +94 -5
- package/build/state/virtualized-list-diagnostics.d.ts +31 -0
- package/build/state/virtualized-list-diagnostics.js +33 -0
- package/build/state/virtualized-list-reducer.d.ts +14 -0
- package/build/state/virtualized-list-reducer.js +179 -45
- package/build/state/virtualized-list.d.ts +7 -2
- package/build/state/virtualized-list.js +178 -37
- package/build/text-props.d.ts +9 -0
- package/build/text-props.js +24 -0
- package/build/view/render-activity-indicator.js +11 -3
- package/build/view/render-button.js +3 -4
- package/build/view/render-image/index.js +13 -3
- package/build/view/render-input-accessory-view.js +5 -2
- package/build/view/render-keyboard-avoiding-view.d.ts +14 -2
- package/build/view/render-keyboard-avoiding-view.js +52 -6
- package/build/view/render-modal.js +4 -2
- package/build/view/render-pressable/index.js +3 -1
- package/build/view/render-scroll-sticky.js +1 -1
- package/build/view/render-scroll-view.js +9 -3
- package/build/view/render-switch.js +4 -1
- package/build/view/render-touchable-highlight.d.ts +11 -1
- package/build/view/render-touchable-highlight.js +11 -10
- package/build/view/render-touchable-native-feedback.js +5 -1
- package/package.json +2 -2
- package/build/state/scroll-routing-handle/index.d.ts +0 -10
- package/build/state/scroll-routing-handle/index.js +0 -6
- package/build/view/layout-event/index.d.ts +0 -2
- package/build/view/layout-event/index.js +0 -12
|
@@ -56,8 +56,6 @@ function readNumber(source, key) {
|
|
|
56
56
|
function asRecord(value) {
|
|
57
57
|
return typeof value === 'object' && value !== null ? { ...value } : undefined;
|
|
58
58
|
}
|
|
59
|
-
// onScroll -> the offset along the scroll axis. Vertical reads contentOffset.y,
|
|
60
|
-
// horizontal reads contentOffset.x.
|
|
61
59
|
export function readScrollOffset(event, horizontal) {
|
|
62
60
|
const native = asRecord(event.nativeEvent);
|
|
63
61
|
if (native === undefined)
|
|
@@ -67,7 +65,19 @@ export function readScrollOffset(event, horizontal) {
|
|
|
67
65
|
return undefined;
|
|
68
66
|
return readNumber(offset, horizontal ? 'x' : 'y');
|
|
69
67
|
}
|
|
70
|
-
//
|
|
68
|
+
// The cell's own position inside the scroll content, as the host reported it. Paired with
|
|
69
|
+
// readLayoutLength at the same onLayout: this is the value buildOffsets stores VERBATIM, and it is
|
|
70
|
+
// what lets the table describe where the content actually is rather than where a sum of heights
|
|
71
|
+
// says it should be.
|
|
72
|
+
export function readLayoutOffset(event, horizontal) {
|
|
73
|
+
const native = asRecord(event.nativeEvent);
|
|
74
|
+
if (native === undefined)
|
|
75
|
+
return undefined;
|
|
76
|
+
const layout = asRecord(native.layout);
|
|
77
|
+
if (layout === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
return readNumber(layout, horizontal ? 'x' : 'y');
|
|
80
|
+
}
|
|
71
81
|
export function readLayoutLength(event, horizontal) {
|
|
72
82
|
const native = asRecord(event.nativeEvent);
|
|
73
83
|
if (native === undefined)
|
|
@@ -80,23 +90,53 @@ export function readLayoutLength(event, horizontal) {
|
|
|
80
90
|
// Resolve every cell offset/length from the cache (or getItemLayout), filling gaps with
|
|
81
91
|
// the running average so an unmeasured tail still has a plausible total. Returns the
|
|
82
92
|
// per-index offset table plus the grand total extent.
|
|
83
|
-
|
|
93
|
+
//
|
|
94
|
+
// THE COORDINATE SPACE IS THE HOST'S, NOT A MODEL'S. `measuredOffsets` holds each cell's raw y (x
|
|
95
|
+
// when horizontal) exactly as onLayout reported it — content-container relative, so it carries the
|
|
96
|
+
// container's padding, the list header, and whatever spacer stood above the cell at the time. A
|
|
97
|
+
// measured cell is placed at that value VERBATIM. It is never re-derived from a neighbour, never
|
|
98
|
+
// rebased onto a running sum. This is react-native's ListMetricsAggregator: `getCellMetricsApprox`
|
|
99
|
+
// returns a laid-out cell's real frame untouched and approximates only what has never been seen.
|
|
100
|
+
//
|
|
101
|
+
// That "verbatim" is the whole safety property, and it is not a stylistic preference — it is the
|
|
102
|
+
// fix for the canary going blank mid-scroll (diagnosed on device 2026-08-19). The table is not
|
|
103
|
+
// merely an output: buildListPlan sizes the leading spacer from it, the host lays the window's
|
|
104
|
+
// cells out below that spacer, and their measured y — spacer included — comes straight back in
|
|
105
|
+
// here. It is a closed loop. Combining two measurements arithmetically inside that loop feeds the
|
|
106
|
+
// model's own error back to itself: with a Yoga `gap` on the content container (the canary's .grid
|
|
107
|
+
// has one) a spacer is an extra flex child, so its presence shifts the layout by one more gap than
|
|
108
|
+
// any pure-arithmetic model predicts, and differencing two cells measured either side of that
|
|
109
|
+
// change banks the difference. Measured, at one gap per recompute, unbounded — onScroll fires per
|
|
110
|
+
// frame, so the spacer walks thousands of pixels away from reality within a second of dragging and
|
|
111
|
+
// the window lands nowhere near the viewport. Regression test:
|
|
112
|
+
// core/components/src/state/virtualized-list-feedback.test.ts.
|
|
113
|
+
//
|
|
114
|
+
// Unmeasured cells are the only place an estimate lives, and they advance by the average STRIDE
|
|
115
|
+
// (measured cell-origin to cell-origin) rather than by the average LENGTH. A height is not the
|
|
116
|
+
// distance to the next cell: separators, section gaps and container `gap` all live in between, and
|
|
117
|
+
// sizing an unmeasured region by heights alone leaves it short by exactly that chrome.
|
|
118
|
+
//
|
|
119
|
+
// A fixed getItemLayout skips all of it — authoritative by contract, and its offsets are exact.
|
|
120
|
+
export function buildOffsets(count, measured, measuredOffsets, fixedLayout, averageLength, averageStride) {
|
|
84
121
|
const offsets = new Array(count);
|
|
85
122
|
const lengths = new Array(count);
|
|
86
|
-
|
|
123
|
+
// What the average stride has left over once the average cell is accounted for: the chrome drawn
|
|
124
|
+
// BETWEEN two cells. Estimating with this rather than the stride itself keeps a cell whose own
|
|
125
|
+
// length IS known from being overwritten by an average — the stride is only ever used for the
|
|
126
|
+
// part nobody measured.
|
|
127
|
+
const interCellChrome = Math.max(EMPTY_OFFSET, averageStride - averageLength);
|
|
128
|
+
// Where the next cell goes when its own position has never been reported.
|
|
129
|
+
let cursor = EMPTY_OFFSET;
|
|
87
130
|
for (let index = FIRST_INDEX; index < count; index += 1) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
else {
|
|
94
|
-
length = measured.get(index) ?? averageLength;
|
|
95
|
-
}
|
|
96
|
-
lengths[index] = length;
|
|
97
|
-
running += length;
|
|
131
|
+
const layout = fixedLayout?.(index);
|
|
132
|
+
const known = layout?.offset ?? measuredOffsets.get(index);
|
|
133
|
+
offsets[index] = known ?? cursor;
|
|
134
|
+
lengths[index] = layout?.length ?? measured.get(index) ?? averageLength;
|
|
135
|
+
cursor = offsets[index] + lengths[index] + interCellChrome;
|
|
98
136
|
}
|
|
99
|
-
|
|
137
|
+
const last = count - 1;
|
|
138
|
+
const total = count > FIRST_INDEX ? offsets[last] + lengths[last] : EMPTY_OFFSET;
|
|
139
|
+
return { offsets, lengths, total };
|
|
100
140
|
}
|
|
101
141
|
// Pick the resident window: every index whose box overlaps
|
|
102
142
|
// [offset - buffer, offset + viewport + buffer]. The buffer is (windowSize - 1) / 2
|
|
@@ -106,7 +146,10 @@ export function computeWindow(count, offsets, lengths, scrollOffset, viewportLen
|
|
|
106
146
|
return { first: FIRST_INDEX, last: NO_INDEX };
|
|
107
147
|
// Before the viewport is known, paint a bounded prefix.
|
|
108
148
|
if (viewportLength <= EMPTY_OFFSET) {
|
|
109
|
-
return {
|
|
149
|
+
return {
|
|
150
|
+
first: FIRST_INDEX,
|
|
151
|
+
last: Math.min(count, initialNumToRender) - 1,
|
|
152
|
+
};
|
|
110
153
|
}
|
|
111
154
|
const overscan = ((windowSize - 1) / 2) * viewportLength;
|
|
112
155
|
const windowTop = scrollOffset - overscan;
|
|
@@ -152,7 +195,8 @@ export function isCellViewable(percent, config) {
|
|
|
152
195
|
const itemThreshold = config.itemVisiblePercentThreshold;
|
|
153
196
|
if (itemThreshold !== undefined)
|
|
154
197
|
return percent >= itemThreshold;
|
|
155
|
-
const areaThreshold = config.viewAreaCoveragePercentThreshold ??
|
|
198
|
+
const areaThreshold = config.viewAreaCoveragePercentThreshold ??
|
|
199
|
+
DEFAULT_VIEW_AREA_COVERAGE_PERCENT_THRESHOLD;
|
|
156
200
|
return percent > areaThreshold || percent >= FULLY_VISIBLE_PERCENT;
|
|
157
201
|
}
|
|
158
202
|
// Resolve an index to a pixel offset, optionally biasing where in the viewport the item
|
|
@@ -165,6 +209,24 @@ export function offsetForIndex(index, viewPosition, viewOffset, count, offsets,
|
|
|
165
209
|
const positioned = cellOffset - viewPosition * (viewportLength - cellLength);
|
|
166
210
|
return Math.max(EMPTY_OFFSET, positioned - viewOffset);
|
|
167
211
|
}
|
|
212
|
+
// Two layout readings are the SAME measurement unless they differ by more than this.
|
|
213
|
+
//
|
|
214
|
+
// A relayout does not reproduce a float bit-for-bit: the cell positions are derived from a spacer
|
|
215
|
+
// height that is itself a float, so an onLayout that changed nothing observable still comes back a
|
|
216
|
+
// few ulps off. Compared with ===, every one of those counts as a change — the reducer stores it,
|
|
217
|
+
// the spacer derived from it moves in its last bits, Fabric commits the new value, Yoga relays out,
|
|
218
|
+
// and the fresh onLayout starts the next turn. A loop at frame rate, from a difference no screen
|
|
219
|
+
// can show. Device-measured 2026-08-19: 1203 recomputes over one short drag, its log full of
|
|
220
|
+
// `27.33 -> 27.33 (-0.00)`.
|
|
221
|
+
//
|
|
222
|
+
// The smallest change a host can actually express is one device pixel — a third of a point at @3x —
|
|
223
|
+
// so this sits ~30x below any real move and ~1e11 above the noise.
|
|
224
|
+
export const LAYOUT_EPSILON = 0.01;
|
|
225
|
+
// `known` is optional because a first measurement has nothing to settle against, and must count as
|
|
226
|
+
// a change.
|
|
227
|
+
export function isSettledLayout(known, reported) {
|
|
228
|
+
return known !== undefined && Math.abs(known - reported) < LAYOUT_EPSILON;
|
|
229
|
+
}
|
|
168
230
|
// Running average of known cell lengths, used to size not-yet-measured cells and the
|
|
169
231
|
// trailing spacer so the total is plausible before full measurement.
|
|
170
232
|
export function averageMeasuredLength(measured) {
|
|
@@ -175,6 +237,27 @@ export function averageMeasuredLength(measured) {
|
|
|
175
237
|
sum += length;
|
|
176
238
|
return sum / measured.size;
|
|
177
239
|
}
|
|
240
|
+
// Average origin-to-origin distance between two ADJACENT measured cells — the length plus whatever
|
|
241
|
+
// chrome the list draws in the gap (a separator, a section gap, the content container's Yoga
|
|
242
|
+
// `gap`). Only adjacent pairs qualify: across a hole the distance covers cells nobody measured.
|
|
243
|
+
//
|
|
244
|
+
// This is what an unmeasured cell advances by, and it is deliberately not averageMeasuredLength.
|
|
245
|
+
// Sizing an unmeasured region by heights alone makes the model shorter than the content, so the
|
|
246
|
+
// spacer standing in for that region under-reserves and everything below it slides up — the
|
|
247
|
+
// jump-and-return the canary showed before the offsets became host-absolute. Falls back to the
|
|
248
|
+
// length average while no adjacent pair has been measured yet.
|
|
249
|
+
export function averageMeasuredStride(measuredOffsets, fallback) {
|
|
250
|
+
let sum = EMPTY_OFFSET;
|
|
251
|
+
let pairs = EMPTY_OFFSET;
|
|
252
|
+
for (const [index, offset] of measuredOffsets) {
|
|
253
|
+
const next = measuredOffsets.get(index + 1);
|
|
254
|
+
if (next === undefined)
|
|
255
|
+
continue;
|
|
256
|
+
sum += next - offset;
|
|
257
|
+
pairs += 1;
|
|
258
|
+
}
|
|
259
|
+
return pairs === EMPTY_OFFSET ? fallback : sum / pairs;
|
|
260
|
+
}
|
|
178
261
|
// The largest index whose length has actually been measured (RN
|
|
179
262
|
// ListMetricsAggregator.getHighestMeasuredCellIndex). NO_INDEX when nothing is measured.
|
|
180
263
|
export function highestMeasuredIndex(measured) {
|
|
@@ -207,7 +290,10 @@ export function computeStartReached(scrollOffset, viewportLength, thresholdMulti
|
|
|
207
290
|
export function buildViewabilityPairs(onViewableItemsChanged, viewabilityConfig, pairs) {
|
|
208
291
|
const result = [];
|
|
209
292
|
if (onViewableItemsChanged !== undefined) {
|
|
210
|
-
result.push({
|
|
293
|
+
result.push({
|
|
294
|
+
viewabilityConfig: viewabilityConfig ?? {},
|
|
295
|
+
onViewableItemsChanged,
|
|
296
|
+
});
|
|
211
297
|
}
|
|
212
298
|
if (pairs !== undefined)
|
|
213
299
|
result.push(...pairs);
|
|
@@ -222,10 +308,13 @@ export function computeViewableSet(params) {
|
|
|
222
308
|
for (let index = params.first; index <= params.last && index < params.count; index += 1) {
|
|
223
309
|
const percent = visiblePercent(params.offsets[index], params.lengths[index], params.scrollOffset, params.viewportLength);
|
|
224
310
|
const item = params.getItem(params.data, index);
|
|
225
|
-
const key = params.keyExtractor
|
|
311
|
+
const key = params.keyExtractor
|
|
312
|
+
? params.keyExtractor(item, index)
|
|
313
|
+
: String(index);
|
|
226
314
|
let anyViewable = false;
|
|
227
315
|
for (const pair of params.pairs) {
|
|
228
|
-
if (pair.viewabilityConfig.waitForInteraction === true &&
|
|
316
|
+
if (pair.viewabilityConfig.waitForInteraction === true &&
|
|
317
|
+
!params.hasInteracted) {
|
|
229
318
|
continue;
|
|
230
319
|
}
|
|
231
320
|
if (isCellViewable(percent, pair.viewabilityConfig)) {
|
|
@@ -279,31 +368,75 @@ export function maxMinimumViewTime(pairs) {
|
|
|
279
368
|
}
|
|
280
369
|
return max;
|
|
281
370
|
}
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
|
|
371
|
+
// Find the nearest sticky index strictly below `first` — the RN _ensureClosestStickyHeader
|
|
372
|
+
// backward walk. Returns NO_INDEX when none exists (no sticky section applies yet, or the
|
|
373
|
+
// applicable one is already inside the window).
|
|
374
|
+
function findClosestStickyIndexBelow(first, stickyIndices) {
|
|
375
|
+
for (let index = first - 1; index >= FIRST_INDEX; index -= 1) {
|
|
376
|
+
if (stickyIndices.has(index))
|
|
377
|
+
return index;
|
|
378
|
+
}
|
|
379
|
+
return NO_INDEX;
|
|
380
|
+
}
|
|
381
|
+
// Compute the windowed child PLAN: the spacer extents, the in-window cells (index + key),
|
|
382
|
+
// the force-mounted sticky cell (if any) ahead of the window, and the sticky child
|
|
383
|
+
// positions. The adapter walks this plan and creates the host elements (createElement / h)
|
|
384
|
+
// plus the framework cell content. This is the shared half of the render; only the element
|
|
385
|
+
// creation and the user's renderItem stay per-adapter.
|
|
286
386
|
export function buildListPlan(params) {
|
|
287
387
|
const cells = [];
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
388
|
+
const closestStickyIndex = params.stickyIndices !== undefined
|
|
389
|
+
? findClosestStickyIndexBelow(params.first, params.stickyIndices)
|
|
390
|
+
: NO_INDEX;
|
|
391
|
+
const forcedStickyCell = closestStickyIndex === NO_INDEX
|
|
392
|
+
? undefined
|
|
393
|
+
: { index: closestStickyIndex, key: params.keyFor(closestStickyIndex) };
|
|
394
|
+
// A spacer stands in for a contiguous run of cells, so its extent is the distance from the first
|
|
395
|
+
// of them to the far edge of the last — a difference between two positions the host itself
|
|
396
|
+
// reported, never a sum of heights. That is what carries the chrome BETWEEN those cells
|
|
397
|
+
// (separators, section gaps, the container's Yoga `gap`) without the model having to know it
|
|
398
|
+
// exists, and it is why the spacer lands the following cell exactly where it already was: the
|
|
399
|
+
// spacer occupies one child slot, precisely as the region it replaces began and ended on a cell
|
|
400
|
+
// boundary. Summing heights instead under-reserves by the chrome; rebasing onto a running model
|
|
401
|
+
// re-introduces the feedback loop buildOffsets exists to avoid.
|
|
402
|
+
const regionExtent = (from, to) => to < from
|
|
403
|
+
? EMPTY_OFFSET
|
|
404
|
+
: params.offsets[to] + params.lengths[to] - params.offsets[from];
|
|
405
|
+
const windowLeadingExtent = regionExtent(FIRST_INDEX, params.first - 1);
|
|
406
|
+
const leadingExtent = forcedStickyCell === undefined
|
|
407
|
+
? windowLeadingExtent
|
|
408
|
+
: regionExtent(FIRST_INDEX, closestStickyIndex - 1);
|
|
409
|
+
const gapExtent = forcedStickyCell === undefined
|
|
410
|
+
? EMPTY_OFFSET
|
|
411
|
+
: regionExtent(closestStickyIndex + 1, params.first - 1);
|
|
412
|
+
const trailingExtent = params.last < params.count - 1
|
|
413
|
+
? params.total - params.offsets[params.last + 1]
|
|
291
414
|
: EMPTY_OFFSET;
|
|
292
|
-
const trailingExtent = params.total - leadingExtent - renderedExtent;
|
|
293
415
|
const stickyChildPositions = [];
|
|
294
416
|
// The header (when present) is child 0; the leading spacer (when non-empty) is the next
|
|
295
|
-
// child
|
|
296
|
-
//
|
|
417
|
+
// child; the forced sticky cell (when present) plus its own gap spacer follow. Each cell is
|
|
418
|
+
// EXACTLY one child — an ItemSeparatorComponent rides INSIDE the cell's own measuring wrapper
|
|
419
|
+
// (RN VirtualizedListCellRenderer.js:218-221), so it neither shifts these positions nor shows
|
|
420
|
+
// up in the geometry as chrome the spacers would have to account for separately.
|
|
297
421
|
let childPosition = (params.hasHeader ? 1 : 0) + (leadingExtent > EMPTY_OFFSET ? 1 : 0);
|
|
422
|
+
if (forcedStickyCell !== undefined) {
|
|
423
|
+
stickyChildPositions.push(childPosition);
|
|
424
|
+
childPosition += 1 + (gapExtent > EMPTY_OFFSET ? 1 : 0);
|
|
425
|
+
}
|
|
298
426
|
for (let index = params.first; index <= params.last; index += 1) {
|
|
299
427
|
cells.push({ index, key: params.keyFor(index) });
|
|
300
428
|
if (params.stickyIndices?.has(index) === true)
|
|
301
429
|
stickyChildPositions.push(childPosition);
|
|
302
430
|
childPosition += 1;
|
|
303
|
-
if (params.hasSeparators && index < params.last)
|
|
304
|
-
childPosition += 1;
|
|
305
431
|
}
|
|
306
|
-
return {
|
|
432
|
+
return {
|
|
433
|
+
leadingExtent,
|
|
434
|
+
gapExtent,
|
|
435
|
+
trailingExtent,
|
|
436
|
+
cells,
|
|
437
|
+
forcedStickyCell,
|
|
438
|
+
stickyChildPositions,
|
|
439
|
+
};
|
|
307
440
|
}
|
|
308
441
|
export function computeMvcpAdjustment(params) {
|
|
309
442
|
const { minIndexForVisible, count, keyFor } = params;
|
|
@@ -316,7 +449,9 @@ export function computeMvcpAdjustment(params) {
|
|
|
316
449
|
firstVisibleKey: newFirstVisibleKey,
|
|
317
450
|
action: { kind: 'none' },
|
|
318
451
|
};
|
|
319
|
-
if (prevKey === null ||
|
|
452
|
+
if (prevKey === null ||
|
|
453
|
+
newFirstVisibleKey === null ||
|
|
454
|
+
prevKey === newFirstVisibleKey) {
|
|
320
455
|
return settled;
|
|
321
456
|
}
|
|
322
457
|
let anchorIndex = NO_INDEX;
|
|
@@ -341,7 +476,10 @@ export function computeMvcpAdjustment(params) {
|
|
|
341
476
|
const anchoredNearTop = autoThreshold !== undefined && params.scrollOffset <= autoThreshold;
|
|
342
477
|
if (anchoredNearTop) {
|
|
343
478
|
dlog(`VirtualizedList MVCP autoscroll-to-top (offset=${params.scrollOffset} <= ${autoThreshold})`);
|
|
344
|
-
return {
|
|
479
|
+
return {
|
|
480
|
+
firstVisibleKey: newFirstVisibleKey,
|
|
481
|
+
action: { kind: 'autoscroll-top' },
|
|
482
|
+
};
|
|
345
483
|
}
|
|
346
484
|
dlog(`VirtualizedList MVCP adjust +${insertedExtent}px ` +
|
|
347
485
|
`(anchor "${prevKey}" moved ${minIndexForVisible}->${anchorIndex})`);
|
|
@@ -385,7 +523,10 @@ export function decideEdgeReached(params) {
|
|
|
385
523
|
}
|
|
386
524
|
// Re-arm once out of threshold so the next approach can fire again.
|
|
387
525
|
if (!withinThreshold) {
|
|
388
|
-
return {
|
|
526
|
+
return {
|
|
527
|
+
shouldFire: false,
|
|
528
|
+
nextSentForContentLength: NO_CONTENT_LENGTH_SENT,
|
|
529
|
+
};
|
|
389
530
|
}
|
|
390
531
|
return { shouldFire: false, nextSentForContentLength: sentForContentLength };
|
|
391
532
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type IEllipsizeMode = 'head' | 'middle' | 'tail' | 'clip';
|
|
2
|
+
export interface ITextDefaultableProps {
|
|
3
|
+
ellipsizeMode?: IEllipsizeMode;
|
|
4
|
+
allowFontScaling?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function resolveTextProps<T extends ITextDefaultableProps>(props: T): T & {
|
|
7
|
+
ellipsizeMode: IEllipsizeMode;
|
|
8
|
+
allowFontScaling: boolean;
|
|
9
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The defaults RN's Text.js applies before handing props to native, folded once for every adapter.
|
|
2
|
+
//
|
|
3
|
+
// RN does this unconditionally on the outer (non-virtual) path, Text.js:288-291:
|
|
4
|
+
//
|
|
5
|
+
// processedProps.allowFontScaling = allowFontScaling !== false;
|
|
6
|
+
// processedProps.ellipsizeMode = ellipsizeMode ?? 'tail';
|
|
7
|
+
//
|
|
8
|
+
// We declared both props in all four adapters and applied NEITHER default, so native fell back to
|
|
9
|
+
// its own: `clip` instead of `tail`. Device-observed 2026-08-19 on examples/svelte — a Text with
|
|
10
|
+
// numberOfLines={1} cut mid-word with no ellipsis at all. Nothing failed; the text was simply
|
|
11
|
+
// wrong, which is why four adapters carried it.
|
|
12
|
+
//
|
|
13
|
+
// Both keys are emitted UNCONDITIONALLY, matching RN and — separately — keeping the key set
|
|
14
|
+
// stable. A fold with two branches that emit different key sets is the exact hazard
|
|
15
|
+
// .claude/rules/solid-descriptor-bridge.md §1 exists for, and Svelte's shim diffs the same way.
|
|
16
|
+
export function resolveTextProps(props) {
|
|
17
|
+
return {
|
|
18
|
+
...props,
|
|
19
|
+
ellipsizeMode: props.ellipsizeMode ?? 'tail',
|
|
20
|
+
// `!== false`, not `?? true`: RN treats an explicit `undefined` and a missing prop alike, and
|
|
21
|
+
// only a literal `false` opts out.
|
|
22
|
+
allowFontScaling: props.allowFontScaling !== false,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -20,10 +20,16 @@ const CONTAINER_STYLE = {
|
|
|
20
20
|
};
|
|
21
21
|
function resolveSize(size) {
|
|
22
22
|
if (size === 'small') {
|
|
23
|
-
return {
|
|
23
|
+
return {
|
|
24
|
+
sizeStyle: { width: SIZE_SMALL_PX, height: SIZE_SMALL_PX },
|
|
25
|
+
sizeProp: 'small',
|
|
26
|
+
};
|
|
24
27
|
}
|
|
25
28
|
if (size === 'large') {
|
|
26
|
-
return {
|
|
29
|
+
return {
|
|
30
|
+
sizeStyle: { width: SIZE_LARGE_PX, height: SIZE_LARGE_PX },
|
|
31
|
+
sizeProp: 'large',
|
|
32
|
+
};
|
|
27
33
|
}
|
|
28
34
|
return { sizeStyle: { width: size, height: size } };
|
|
29
35
|
}
|
|
@@ -50,5 +56,7 @@ export function renderActivityIndicator(view, platform) {
|
|
|
50
56
|
...view.passthrough,
|
|
51
57
|
style: [CONTAINER_STYLE, view.style],
|
|
52
58
|
};
|
|
53
|
-
return el('symbiote-view', wrapperProps, [
|
|
59
|
+
return el('symbiote-view', wrapperProps, [
|
|
60
|
+
el('symbiote-activity-indicator', nativeProps),
|
|
61
|
+
]);
|
|
54
62
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// Button:
|
|
2
|
-
// TouchableOpacity wrapping a Text.
|
|
3
|
-
//
|
|
4
|
-
// paints the identical button. The adapter only composes its TouchableOpacity + Text around them.
|
|
1
|
+
// Button: shared render half (framework-agnostic), matching RN's iOS shape (Button.js): a
|
|
2
|
+
// TouchableOpacity wrapping a Text. Each adapter composes its own TouchableOpacity + Text
|
|
3
|
+
// around the pieces below.
|
|
5
4
|
const IOS_BUTTON_BLUE = '#007AFF';
|
|
6
5
|
const IOS_DISABLED_GREY = '#cdcdcd';
|
|
7
6
|
// RN's Button is accessibilityRole="button"; the role string is a native accessibility enum value.
|
|
@@ -39,7 +39,13 @@ function expandSrcSet(srcSet, view, headers) {
|
|
|
39
39
|
continue;
|
|
40
40
|
if (scale === 1)
|
|
41
41
|
useSrcForDefaultScale = false;
|
|
42
|
-
sources.push({
|
|
42
|
+
sources.push({
|
|
43
|
+
uri,
|
|
44
|
+
scale,
|
|
45
|
+
width: view.width,
|
|
46
|
+
height: view.height,
|
|
47
|
+
...{ headers },
|
|
48
|
+
});
|
|
43
49
|
}
|
|
44
50
|
if (useSrcForDefaultScale && view.src !== undefined) {
|
|
45
51
|
sources.push({
|
|
@@ -65,7 +71,9 @@ function resolveSourceArray(view) {
|
|
|
65
71
|
return expandSrcSet(view.srcSet, view, headers);
|
|
66
72
|
}
|
|
67
73
|
if (view.src !== undefined) {
|
|
68
|
-
return [
|
|
74
|
+
return [
|
|
75
|
+
{ uri: view.src, width: view.width, height: view.height, ...{ headers } },
|
|
76
|
+
];
|
|
69
77
|
}
|
|
70
78
|
if (view.source === undefined) {
|
|
71
79
|
dlog('Image: no source / src / srcSet provided');
|
|
@@ -76,7 +84,9 @@ function resolveSourceArray(view) {
|
|
|
76
84
|
// `source.uri && headers` branch; the array/number shapes pass through untouched.
|
|
77
85
|
if (Object.keys(headers).length > 0 && sources.length === 1) {
|
|
78
86
|
const [only] = sources;
|
|
79
|
-
if (typeof only === 'object' &&
|
|
87
|
+
if (typeof only === 'object' &&
|
|
88
|
+
only !== null &&
|
|
89
|
+
typeof Reflect.get(only, 'uri') === 'string') {
|
|
80
90
|
return [{ ...only, headers }];
|
|
81
91
|
}
|
|
82
92
|
}
|
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
// the two by id. There is no JS-side translation: style / nativeID / backgroundColor map straight
|
|
5
5
|
// onto the intrinsic and the user children (injected by the adapter) nest under it. Shared
|
|
6
6
|
// verbatim across adapters: React and Vue both bridge this Descriptor.
|
|
7
|
-
import { dlog } from '@symbiote-native/engine';
|
|
7
|
+
import { dlog, } from '@symbiote-native/engine';
|
|
8
8
|
import { el } from '../descriptor.js';
|
|
9
9
|
export function renderInputAccessoryView(view) {
|
|
10
|
-
const props = {
|
|
10
|
+
const props = {
|
|
11
|
+
...view.passthrough,
|
|
12
|
+
style: view.style,
|
|
13
|
+
};
|
|
11
14
|
if (view.nativeID !== undefined)
|
|
12
15
|
props.nativeID = view.nativeID;
|
|
13
16
|
if (view.backgroundColor !== undefined)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IStyleProp, IViewStyle } from '@symbiote-native/engine';
|
|
1
|
+
import type { IKeyboardEventName, IPlatformOSType, IStyleProp, IViewStyle } from '@symbiote-native/engine';
|
|
2
2
|
export type IKeyboardAvoidingBehavior = 'height' | 'position' | 'padding';
|
|
3
3
|
export declare const DEFAULT_VERTICAL_OFFSET = 0;
|
|
4
4
|
export interface IMeasuredFrame {
|
|
@@ -11,7 +11,19 @@ export interface IKeyboardFrame {
|
|
|
11
11
|
}
|
|
12
12
|
export declare function readKeyboardFrame(payload: unknown): IKeyboardFrame | undefined;
|
|
13
13
|
export declare function readLayoutFrame(layout: unknown): IMeasuredFrame | undefined;
|
|
14
|
-
export
|
|
14
|
+
export type IKeyboardAvoidingEventNames = {
|
|
15
|
+
show: IKeyboardEventName;
|
|
16
|
+
hide: IKeyboardEventName;
|
|
17
|
+
};
|
|
18
|
+
export declare function keyboardAvoidingEventNamesFor(os: IPlatformOSType): IKeyboardAvoidingEventNames;
|
|
19
|
+
export declare function readPrefersCrossFadeTransitions(query?: () => Promise<boolean>): Promise<boolean>;
|
|
20
|
+
export type IComputeInsetOptions = {
|
|
21
|
+
behavior?: IKeyboardAvoidingBehavior;
|
|
22
|
+
previousInset?: number;
|
|
23
|
+
os?: IPlatformOSType;
|
|
24
|
+
prefersCrossFadeTransitions?: boolean;
|
|
25
|
+
};
|
|
26
|
+
export declare function computeInset(frame: IMeasuredFrame | undefined, keyboard: IKeyboardFrame | undefined, verticalOffset: number, options?: IComputeInsetOptions): number;
|
|
15
27
|
export type IKeyboardAvoidingLayout = {
|
|
16
28
|
kind: 'nested';
|
|
17
29
|
wrapperStyle?: IStyleProp<IViewStyle>;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// carry. Instead we return a layout DESCRIPTION (which styles to apply and whether to nest)
|
|
10
10
|
// and the adapter builds its own element tree with its children. Mirrors RN's
|
|
11
11
|
// Libraries/Components/Keyboard/KeyboardAvoidingView.js inset/behavior logic.
|
|
12
|
-
import { isRecord } from '@symbiote-native/engine';
|
|
12
|
+
import { AccessibilityInfo, dlog, isRecord, KEYBOARD_EVENT, Platform, } from '@symbiote-native/engine';
|
|
13
13
|
// RN's default keyboardVerticalOffset (KeyboardAvoidingView.js).
|
|
14
14
|
export const DEFAULT_VERTICAL_OFFSET = 0;
|
|
15
15
|
// RN rounds nothing here; 'height' mode collapses flex so the shrunk height holds.
|
|
@@ -38,13 +38,51 @@ export function readLayoutFrame(layout) {
|
|
|
38
38
|
return undefined;
|
|
39
39
|
return { y, height };
|
|
40
40
|
}
|
|
41
|
+
export function keyboardAvoidingEventNamesFor(os) {
|
|
42
|
+
if (os === 'ios')
|
|
43
|
+
return { show: KEYBOARD_EVENT.willShow, hide: KEYBOARD_EVENT.willHide };
|
|
44
|
+
return { show: KEYBOARD_EVENT.didShow, hide: KEYBOARD_EVENT.didHide };
|
|
45
|
+
}
|
|
46
|
+
// The iOS "Prefer Cross-Fade Transitions" setting, read once per mount and fed to computeInset.
|
|
47
|
+
//
|
|
48
|
+
// AccessibilityInfo's iOS getters REJECT when the native error callback fires. That is deliberate
|
|
49
|
+
// RN parity and the engine must keep it — but this caller runs at mount and nobody awaits it, so a
|
|
50
|
+
// rejection would surface as an unhandled promise. Five adapters would each need the same `.catch`,
|
|
51
|
+
// which is five chances to forget one, so the fallback lives here instead. A failed read and "the
|
|
52
|
+
// user does not prefer cross-fade" are indistinguishable to every caller, so false is the honest
|
|
53
|
+
// answer, not a swallowed error.
|
|
54
|
+
//
|
|
55
|
+
// The query is injectable purely so both branches are unit-testable without a native-module fake;
|
|
56
|
+
// adapters call it with no arguments.
|
|
57
|
+
export function readPrefersCrossFadeTransitions(query = () => AccessibilityInfo.prefersCrossFadeTransitions()) {
|
|
58
|
+
return query().catch((error) => {
|
|
59
|
+
dlog(`KeyboardAvoidingView: prefersCrossFadeTransitions read failed -> false (${String(error)})`);
|
|
60
|
+
return false;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
41
63
|
// RN's _relativeKeyboardHeight: how far up the view must move so it no longer overlaps the
|
|
42
64
|
// keyboard. keyboardY is the keyboard's top edge minus the caller's vertical offset; the inset
|
|
43
65
|
// is the overlap of the view's bottom past that edge, clamped at 0.
|
|
44
|
-
export function computeInset(frame, keyboard, verticalOffset) {
|
|
66
|
+
export function computeInset(frame, keyboard, verticalOffset, options = {}) {
|
|
45
67
|
if (frame === undefined || keyboard === undefined)
|
|
46
68
|
return 0;
|
|
69
|
+
// With the iOS Prefer Cross-Fade Transitions setting on, the keyboard reports screenY as 0
|
|
70
|
+
// rather than its real top edge (RN:88-96). Run through the ordinary math that makes the overlap
|
|
71
|
+
// the view's ENTIRE y + height and pushes the content clean off screen, so RN answers 0.
|
|
72
|
+
const os = options.os ?? Platform.OS;
|
|
73
|
+
if (os === 'ios' &&
|
|
74
|
+
keyboard.screenY === 0 &&
|
|
75
|
+
options.prefersCrossFadeTransitions === true) {
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
47
78
|
const keyboardY = keyboard.screenY - verticalOffset;
|
|
79
|
+
// The 'height' fixpoint (RN:100-105). In this mode the wrapper is SHRUNK by the inset, so its
|
|
80
|
+
// next onLayout reports a frame shorter by exactly that much. Adding the previous inset back
|
|
81
|
+
// cancels the shrink; without it each subsequent keyboard event computes a smaller overlap and
|
|
82
|
+
// the view walks back down under the keyboard. A correction, not an accumulation.
|
|
83
|
+
if (options.behavior === 'height') {
|
|
84
|
+
return Math.max((options.previousInset ?? 0) + frame.y + frame.height - keyboardY, 0);
|
|
85
|
+
}
|
|
48
86
|
return Math.max(frame.y + frame.height - keyboardY, 0);
|
|
49
87
|
}
|
|
50
88
|
// Map the behavior + effective inset onto the wrapper/inner styles and the nesting decision:
|
|
@@ -52,7 +90,7 @@ export function computeInset(frame, keyboard, verticalOffset) {
|
|
|
52
90
|
// wrapper directly. 'height' shrinks the wrapper from its initial measured height (only while
|
|
53
91
|
// the keyboard is up, matching RN).
|
|
54
92
|
export function resolveKeyboardAvoidingLayout(params) {
|
|
55
|
-
const { behavior, effectiveInset, initialHeight, style, contentContainerStyle } = params;
|
|
93
|
+
const { behavior, effectiveInset, initialHeight, style, contentContainerStyle, } = params;
|
|
56
94
|
if (behavior === 'position') {
|
|
57
95
|
return {
|
|
58
96
|
kind: 'nested',
|
|
@@ -61,12 +99,20 @@ export function resolveKeyboardAvoidingLayout(params) {
|
|
|
61
99
|
};
|
|
62
100
|
}
|
|
63
101
|
if (behavior === 'padding') {
|
|
64
|
-
return {
|
|
102
|
+
return {
|
|
103
|
+
kind: 'wrapper',
|
|
104
|
+
wrapperStyle: [style, { paddingBottom: effectiveInset }],
|
|
105
|
+
};
|
|
65
106
|
}
|
|
66
|
-
if (behavior === 'height' &&
|
|
107
|
+
if (behavior === 'height' &&
|
|
108
|
+
effectiveInset > 0 &&
|
|
109
|
+
initialHeight !== undefined) {
|
|
67
110
|
return {
|
|
68
111
|
kind: 'wrapper',
|
|
69
|
-
wrapperStyle: [
|
|
112
|
+
wrapperStyle: [
|
|
113
|
+
style,
|
|
114
|
+
{ height: initialHeight - effectiveInset, flex: COLLAPSED_FLEX },
|
|
115
|
+
],
|
|
70
116
|
};
|
|
71
117
|
}
|
|
72
118
|
return { kind: 'wrapper', wrapperStyle: style };
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// maps to the `symbiote-modal` intrinsic the host config routes to ModalHostView, wrapping a
|
|
6
6
|
// full-screen container View that holds the user children (injected by the adapter). Shared
|
|
7
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';
|
|
8
|
+
import { dlog, } from '@symbiote-native/engine';
|
|
9
9
|
import { el } from '../descriptor.js';
|
|
10
10
|
// The full-screen box RN anchors the modal content in (Modal.js styles.container: [side]:0,
|
|
11
11
|
// top:0, flex:1, backgroundColor:'white'). It is NOT position:absolute, it is a flex child that
|
|
@@ -45,7 +45,9 @@ export function renderModal(view) {
|
|
|
45
45
|
backdropOverride,
|
|
46
46
|
];
|
|
47
47
|
const resolvedPresentationStyle = view.presentationStyle ??
|
|
48
|
-
(view.transparent === true
|
|
48
|
+
(view.transparent === true
|
|
49
|
+
? PRESENTATION_OVER_FULL_SCREEN
|
|
50
|
+
: PRESENTATION_FULL_SCREEN);
|
|
49
51
|
dlog('Modal visible -> committing ModalHostView(container View)');
|
|
50
52
|
// collapsable:false keeps the container as a real shadow node (RN sets this so the wrapper is
|
|
51
53
|
// never flattened away under the host). Empty structural children: the adapter injects the
|
|
@@ -8,7 +8,9 @@ import { dlog } from '@symbiote-native/engine';
|
|
|
8
8
|
// disabled state even if the caller passed none (Pressable.js: disabled != null ? {...state,
|
|
9
9
|
// disabled} : state). Untouched when disabled is unset.
|
|
10
10
|
export function resolveDisabledAccessibilityState(accessibilityState, disabled) {
|
|
11
|
-
return disabled !== undefined
|
|
11
|
+
return disabled !== undefined
|
|
12
|
+
? { ...accessibilityState, disabled }
|
|
13
|
+
: accessibilityState;
|
|
12
14
|
}
|
|
13
15
|
// The 3 agnostic gating predicates behind the listener bag below. Angular has no bag to spread -
|
|
14
16
|
// it binds these directly onto template event outputs (see adapters/angular/src/components/
|
|
@@ -32,7 +32,7 @@ export function readLayoutNumber(event, key) {
|
|
|
32
32
|
// layoutY and tracks 1:1 until the next header pushes it off, while the inverted branch pins
|
|
33
33
|
// at the viewport bottom (stickStartPoint) and tracks up to the collision point.
|
|
34
34
|
export function computeStickyInterpolation(params) {
|
|
35
|
-
const { measured, inverted, scrollViewHeight, layoutY, layoutHeight, nextHeaderLayoutY } = params;
|
|
35
|
+
const { measured, inverted, scrollViewHeight, layoutY, layoutHeight, nextHeaderLayoutY, } = params;
|
|
36
36
|
const inputRange = [-1, 0];
|
|
37
37
|
const outputRange = [0, 0];
|
|
38
38
|
if (measured) {
|