@symbiote-native/components 1.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +11 -13
  2. package/build/accessibility-props.d.ts +1 -1
  3. package/build/accessibility-props.js +2 -2
  4. package/build/behaviors/activity-indicator/index.android.d.ts +1 -0
  5. package/build/behaviors/activity-indicator/index.android.js +16 -0
  6. package/build/behaviors/activity-indicator/index.d.ts +3 -0
  7. package/build/behaviors/activity-indicator/index.ios.d.ts +1 -0
  8. package/build/behaviors/activity-indicator/index.ios.js +14 -0
  9. package/build/behaviors/activity-indicator/index.js +5 -0
  10. package/build/behaviors/activity-indicator/shared.d.ts +18 -0
  11. package/build/behaviors/activity-indicator/shared.js +120 -0
  12. package/build/behaviors/button.d.ts +13 -0
  13. package/build/behaviors/button.js +340 -0
  14. package/build/behaviors/image-background.d.ts +3 -0
  15. package/build/behaviors/image-background.js +155 -0
  16. package/build/behaviors/image.d.ts +1 -2
  17. package/build/behaviors/image.js +25 -106
  18. package/build/behaviors/input-accessory-view.d.ts +1 -2
  19. package/build/behaviors/input-accessory-view.js +49 -55
  20. package/build/behaviors/pressable.d.ts +59 -1
  21. package/build/behaviors/pressable.js +142 -96
  22. package/build/behaviors/refresh-control.d.ts +2 -0
  23. package/build/behaviors/refresh-control.js +96 -0
  24. package/build/behaviors/scroll-view/index.android.d.ts +1 -0
  25. package/build/behaviors/scroll-view/index.android.js +40 -0
  26. package/build/behaviors/scroll-view/index.d.ts +3 -0
  27. package/build/behaviors/scroll-view/index.ios.d.ts +1 -0
  28. package/build/behaviors/scroll-view/index.ios.js +10 -0
  29. package/build/behaviors/scroll-view/index.js +8 -0
  30. package/build/behaviors/scroll-view/responder.d.ts +4 -0
  31. package/build/behaviors/scroll-view/responder.js +202 -0
  32. package/build/behaviors/scroll-view/shared.d.ts +9 -0
  33. package/build/behaviors/scroll-view/shared.js +291 -0
  34. package/build/behaviors/scroll-view/sticky.d.ts +18 -0
  35. package/build/behaviors/scroll-view/sticky.js +581 -0
  36. package/build/behaviors/switch.d.ts +1 -1
  37. package/build/behaviors/switch.js +49 -88
  38. package/build/behaviors/text-input.d.ts +2 -2
  39. package/build/behaviors/text-input.js +244 -104
  40. package/build/behaviors/touchable-highlight.d.ts +9 -0
  41. package/build/behaviors/touchable-highlight.js +205 -0
  42. package/build/behaviors/touchable-native-feedback.d.ts +20 -0
  43. package/build/behaviors/touchable-native-feedback.js +254 -0
  44. package/build/behaviors/touchable-opacity.d.ts +12 -0
  45. package/build/behaviors/touchable-opacity.js +239 -0
  46. package/build/behaviors/touchable-without-feedback.d.ts +2 -0
  47. package/build/behaviors/touchable-without-feedback.js +231 -0
  48. package/build/component-names/index.android.js +31 -25
  49. package/build/component-names/index.ios.js +28 -23
  50. package/build/component-names/shared.d.ts +2 -1
  51. package/build/component-names/shared.js +16 -6
  52. package/build/descriptor.js +4 -4
  53. package/build/index.d.ts +26 -26
  54. package/build/index.js +56 -27
  55. package/build/register.d.ts +1 -0
  56. package/build/register.js +55 -0
  57. package/build/resolve-intrinsic.js +3 -9
  58. package/build/scroll-view-commands.d.ts +1 -5
  59. package/build/scroll-view-commands.js +23 -85
  60. package/build/state/flat-list.d.ts +2 -2
  61. package/build/state/flat-list.js +10 -2
  62. package/build/state/pressable.d.ts +6 -1
  63. package/build/state/pressable.js +63 -28
  64. package/build/state/section-list.d.ts +2 -0
  65. package/build/state/section-list.js +14 -7
  66. package/build/state/text-input.d.ts +10 -40
  67. package/build/state/text-input.js +17 -186
  68. package/build/state/touchable.d.ts +1 -0
  69. package/build/state/touchable.js +11 -8
  70. package/build/state/virtualized-list-reducer.d.ts +2 -2
  71. package/build/state/virtualized-list.d.ts +6 -6
  72. package/build/state/virtualized-list.js +71 -37
  73. package/build/text-props.d.ts +0 -8
  74. package/build/text-props.js +14 -25
  75. package/build/view/render-button.d.ts +11 -4
  76. package/build/view/render-button.js +74 -22
  77. package/build/view/render-image/index.d.ts +14 -1
  78. package/build/view/render-image/index.js +22 -147
  79. package/build/view/render-input-accessory-view.d.ts +1 -5
  80. package/build/view/render-input-accessory-view.js +26 -48
  81. package/build/view/render-keyboard-avoiding-view.d.ts +7 -1
  82. package/build/view/render-keyboard-avoiding-view.js +40 -1
  83. package/build/view/render-modal.d.ts +1 -1
  84. package/build/view/render-modal.js +18 -8
  85. package/build/view/render-pressable/index.d.ts +3 -0
  86. package/build/view/render-pressable/index.js +28 -0
  87. package/build/view/render-scroll-view.d.ts +1 -4
  88. package/build/view/render-scroll-view.js +17 -54
  89. package/build/view/render-switch.d.ts +4 -15
  90. package/build/view/render-switch.js +4 -42
  91. package/build/view/render-touchable-highlight.d.ts +1 -0
  92. package/build/view/render-touchable-native-feedback.d.ts +19 -1
  93. package/build/view/render-touchable-native-feedback.js +34 -9
  94. package/host-primitives.cjs +178 -280
  95. package/host-primitives.d.cts +0 -3
  96. package/package.json +8 -21
  97. package/build/fold-host-bag.d.ts +0 -15
  98. package/build/fold-host-bag.js +0 -99
  99. package/build/state-style.d.ts +0 -15
  100. package/build/state-style.js +0 -47
  101. package/build/view/render-activity-indicator.d.ts +0 -25
  102. package/build/view/render-activity-indicator.js +0 -88
  103. package/build/view/render-image-background.d.ts +0 -9
  104. package/build/view/render-image-background.js +0 -48
  105. package/build/view/render-text-input.d.ts +0 -11
  106. package/build/view/render-text-input.js +0 -39
  107. package/lowering-fixtures.cjs +0 -259
  108. package/lowering-fixtures.d.cts +0 -17
  109. package/specialize-state-style.cjs +0 -219
  110. package/specialize-state-style.d.cts +0 -15
@@ -3,11 +3,10 @@ import type { ISymbioteEvent } from '@symbiote-native/engine';
3
3
  import type { IScrollRoutingHandle } from './scroll-routing-handle';
4
4
  export declare const DEFAULT_WINDOW_SIZE = 21;
5
5
  export declare const DEFAULT_INITIAL_NUM_TO_RENDER = 10;
6
- export declare const DEFAULT_END_REACHED_THRESHOLD = 2;
7
6
  export declare const DEFAULT_MAX_TO_RENDER_PER_BATCH = 10;
8
7
  export declare const DEFAULT_UPDATE_CELLS_BATCHING_PERIOD = 50;
9
8
  export declare const DEFAULT_VIEW_AREA_COVERAGE_PERCENT_THRESHOLD = 0;
10
- export declare const DEFAULT_START_REACHED_THRESHOLD = 2;
9
+ export declare const DEFAULT_EDGE_REACHED_THRESHOLD_PX = 2;
11
10
  export declare const FIRST_INDEX = 0;
12
11
  export declare const EMPTY_OFFSET = 0;
13
12
  export declare const NO_INDEX = -1;
@@ -41,6 +40,7 @@ export interface IViewToken<ItemT> {
41
40
  export interface IViewableItemsChangedInfo<ItemT> {
42
41
  viewableItems: IViewToken<ItemT>[];
43
42
  changed: IViewToken<ItemT>[];
43
+ viewabilityConfig?: IViewabilityConfig;
44
44
  }
45
45
  export interface IViewabilityConfig {
46
46
  minimumViewTime?: number;
@@ -94,19 +94,18 @@ export declare function throttleWindow(target: {
94
94
  first: number;
95
95
  last: number;
96
96
  };
97
- export declare function visiblePercent(cellOffset: number, cellLength: number, scrollOffset: number, viewportLength: number): number;
98
- export declare function isCellViewable(percent: number, config: IViewabilityConfig): boolean;
97
+ export declare function isCellViewable(cellOffset: number, cellLength: number, scrollOffset: number, viewportLength: number, config: IViewabilityConfig): boolean;
99
98
  export declare function offsetForIndex(index: number, viewPosition: number, viewOffset: number, count: number, offsets: number[], lengths: number[], viewportLength: number): number;
100
99
  export declare const LAYOUT_EPSILON = 0.01;
101
100
  export declare function isSettledLayout(known: number | undefined, reported: number): boolean;
102
101
  export declare function averageMeasuredLength(measured: Map<number, number>): number;
103
102
  export declare function averageMeasuredStride(measuredOffsets: Map<number, number>, fallback: number): number;
104
103
  export declare function highestMeasuredIndex(measured: Map<number, number>): number;
105
- export declare function computeEndReached(total: number, scrollOffset: number, viewportLength: number, thresholdMultiplier: number): {
104
+ export declare function computeEndReached(total: number, scrollOffset: number, viewportLength: number, thresholdMultiplier: number | undefined): {
106
105
  distanceFromEnd: number;
107
106
  withinThreshold: boolean;
108
107
  };
109
- export declare function computeStartReached(scrollOffset: number, viewportLength: number, thresholdMultiplier: number): {
108
+ export declare function computeStartReached(scrollOffset: number, viewportLength: number, thresholdMultiplier: number | undefined): {
110
109
  distanceFromStart: number;
111
110
  withinThreshold: boolean;
112
111
  };
@@ -181,6 +180,7 @@ export interface IMvcpAdjustmentResult {
181
180
  action: IMvcpAction;
182
181
  }
183
182
  export declare function computeMvcpAdjustment(params: IMvcpAdjustmentParams): IMvcpAdjustmentResult;
183
+ export declare function defaultKeyExtractor<ItemT>(item: ItemT, index: number): string;
184
184
  export declare function resolveItemKey<ItemT>(item: ItemT, index: number, keyExtractor: ((item: ItemT, index: number) => string) | undefined): string;
185
185
  export declare function indexOfItem(data: unknown, getItem: (data: unknown, index: number) => unknown, count: number, item: unknown): number;
186
186
  export declare function offsetForEnd(total: number, viewportLength: number): number;
@@ -21,18 +21,20 @@
21
21
  // module, not a view/render-*.ts.
22
22
  import { dlog } from '@symbiote-native/engine';
23
23
  // Defaults match RN. windowSize is measured in viewport-lengths (21 => ten screens
24
- // of buffer on each side of the visible region). onEndReachedThreshold is a multiple
25
- // of the visible length (RN's onEndReachedThresholdOrDefault returns `?? 2`).
26
- // initialNumToRender bounds the first paint before any layout is measured.
27
- // maxToRenderPerBatch / batching period mirror RN's incremental fill defaults.
24
+ // of buffer on each side of the visible region). initialNumToRender bounds the first
25
+ // paint before any layout is measured. maxToRenderPerBatch / batching period mirror
26
+ // RN's incremental fill defaults.
28
27
  export const DEFAULT_WINDOW_SIZE = 21;
29
28
  export const DEFAULT_INITIAL_NUM_TO_RENDER = 10;
30
- export const DEFAULT_END_REACHED_THRESHOLD = 2;
31
29
  export const DEFAULT_MAX_TO_RENDER_PER_BATCH = 10;
32
30
  export const DEFAULT_UPDATE_CELLS_BATCHING_PERIOD = 50;
33
31
  export const DEFAULT_VIEW_AREA_COVERAGE_PERCENT_THRESHOLD = 0;
34
- // onStartReachedThreshold default, mirroring RN's onStartReachedThresholdOrDefault.
35
- export const DEFAULT_START_REACHED_THRESHOLD = 2;
32
+ // `_maybeCallOnEdgeReached`'s OWN fallback (`VirtualizedList.js:1567`) for whether to actually
33
+ // FIRE onEndReached/onStartReached when the app gives no threshold — a flat 2 PIXELS. This is a
34
+ // different RN default from `onEndReachedThresholdOrDefault`'s `?? 2`, which is a MULTIPLE of the
35
+ // visible length used only for internal render-ahead windowing (a concern this engine does not
36
+ // separate out); conflating the two here used to fire the callback two whole screens early.
37
+ export const DEFAULT_EDGE_REACHED_THRESHOLD_PX = 2;
36
38
  export const FIRST_INDEX = 0;
37
39
  export const EMPTY_OFFSET = 0;
38
40
  export const NO_INDEX = -1;
@@ -178,26 +180,39 @@ export function throttleWindow(target, previous, maxToRenderPerBatch) {
178
180
  return target;
179
181
  return { first, last };
180
182
  }
181
- // Fraction (0..100) of a cell's box that lies inside the viewport.
182
- export function visiblePercent(cellOffset, cellLength, scrollOffset, viewportLength) {
183
- if (cellLength <= EMPTY_OFFSET)
184
- return EMPTY_OFFSET;
185
- const top = Math.max(cellOffset, scrollOffset);
186
- const bottom = Math.min(cellOffset + cellLength, scrollOffset + viewportLength);
187
- const visible = Math.max(EMPTY_OFFSET, bottom - top);
188
- return (visible / cellLength) * FULLY_VISIBLE_PERCENT;
189
- }
190
- // A cell is viewable when its visible fraction clears the configured threshold.
191
- // itemVisiblePercentThreshold compares against the cell's own size;
192
- // viewAreaCoveragePercentThreshold compares against the viewport. The former wins when
193
- // set, else the latter, matching RN's precedence.
194
- export function isCellViewable(percent, config) {
195
- const itemThreshold = config.itemVisiblePercentThreshold;
196
- if (itemThreshold !== undefined)
197
- return percent >= itemThreshold;
198
- const areaThreshold = config.viewAreaCoveragePercentThreshold ??
199
- DEFAULT_VIEW_AREA_COVERAGE_PERCENT_THRESHOLD;
200
- return percent > areaThreshold || percent >= FULLY_VISIBLE_PERCENT;
183
+ // A cell is viewable when its visible fraction clears the configured threshold
184
+ // (`ViewabilityHelper.js`'s `_isViewable` + `computeViewableItems`). Two percents exist and they
185
+ // are NOT interchangeable: `viewAreaCoveragePercentThreshold` is a fraction of the VIEWPORT,
186
+ // `itemVisiblePercentThreshold` a fraction of the CELL's own length — a short cell mostly visible
187
+ // in a tall viewport clears the second easily while failing the first. Area wins whenever it is
188
+ // set (vendor checks `viewAreaCoveragePercentThreshold != null` first); item only when it is not.
189
+ export function isCellViewable(cellOffset, cellLength, scrollOffset, viewportLength, config) {
190
+ const top = cellOffset - scrollOffset;
191
+ const bottom = top + cellLength;
192
+ // No overlap at all: RN's own caller loop never reaches `_isViewable` for such a cell
193
+ // (`top < viewportHeight && bottom > 0` gates the whole scan), so this is exclusion by
194
+ // construction, not a zero percent happening to clear a zero threshold.
195
+ if (bottom <= EMPTY_OFFSET || top >= viewportLength)
196
+ return false;
197
+ // RN's own `_isEntirelyVisible` shortcut: viewable in EITHER mode regardless of the cell's
198
+ // share of the viewport, since an area threshold sized to the viewport could otherwise reject
199
+ // every fully-visible cell smaller than that share. `bottom > top` excludes a zero-length cell
200
+ // (no measurement yet), which vendor falls through to the percent math instead.
201
+ if (top >= EMPTY_OFFSET && bottom <= viewportLength && bottom > top) {
202
+ return true;
203
+ }
204
+ const visiblePixels = Math.min(bottom, viewportLength) - Math.max(top, EMPTY_OFFSET);
205
+ const areaThreshold = config.viewAreaCoveragePercentThreshold;
206
+ const areaMode = areaThreshold !== undefined;
207
+ const threshold = areaMode
208
+ ? areaThreshold
209
+ : (config.itemVisiblePercentThreshold ??
210
+ DEFAULT_VIEW_AREA_COVERAGE_PERCENT_THRESHOLD);
211
+ const denominator = areaMode ? viewportLength : cellLength;
212
+ if (denominator <= EMPTY_OFFSET)
213
+ return false;
214
+ const percent = (visiblePixels / denominator) * FULLY_VISIBLE_PERCENT;
215
+ return percent >= threshold;
201
216
  }
202
217
  // Resolve an index to a pixel offset, optionally biasing where in the viewport the item
203
218
  // lands (viewPosition 0=top, 1=bottom, 0.5=center) and an absolute viewOffset nudge,
@@ -271,11 +286,17 @@ export function highestMeasuredIndex(measured) {
271
286
  // onEndReached distance + threshold test (RN _maybeCallOnEdgeReached). The adapter still
272
287
  // gates on "the last cell is actually rendered" and dedups by content length via its own
273
288
  // ref; this returns only the pure geometry.
289
+ //
290
+ // `thresholdMultiplier` is `undefined` for "the app gave no onEndReachedThreshold" — RN's own
291
+ // unset-case answer is a flat `DEFAULT_EDGE_REACHED_THRESHOLD_PX`, never a viewport-length
292
+ // multiple, so `undefined` must NOT be defaulted to a multiplier at the call site.
274
293
  export function computeEndReached(total, scrollOffset, viewportLength, thresholdMultiplier) {
275
294
  let distanceFromEnd = total - (scrollOffset + viewportLength);
276
295
  if (distanceFromEnd < ON_EDGE_REACHED_EPSILON)
277
296
  distanceFromEnd = EMPTY_OFFSET;
278
- const threshold = thresholdMultiplier * viewportLength;
297
+ const threshold = thresholdMultiplier != null
298
+ ? thresholdMultiplier * viewportLength
299
+ : DEFAULT_EDGE_REACHED_THRESHOLD_PX;
279
300
  return { distanceFromEnd, withinThreshold: distanceFromEnd <= threshold };
280
301
  }
281
302
  // onStartReached twin of computeEndReached. distanceFromStart is just the scroll offset.
@@ -283,7 +304,9 @@ export function computeStartReached(scrollOffset, viewportLength, thresholdMulti
283
304
  let distanceFromStart = scrollOffset;
284
305
  if (distanceFromStart < ON_EDGE_REACHED_EPSILON)
285
306
  distanceFromStart = EMPTY_OFFSET;
286
- const threshold = thresholdMultiplier * viewportLength;
307
+ const threshold = thresholdMultiplier != null
308
+ ? thresholdMultiplier * viewportLength
309
+ : DEFAULT_EDGE_REACHED_THRESHOLD_PX;
287
310
  return { distanceFromStart, withinThreshold: distanceFromStart <= threshold };
288
311
  }
289
312
  // Fold the single-config and pairs forms into one list (RN supports either, not both).
@@ -306,18 +329,15 @@ export function computeViewableSet(params) {
306
329
  const tokens = [];
307
330
  const map = new Map();
308
331
  for (let index = params.first; index <= params.last && index < params.count; index += 1) {
309
- const percent = visiblePercent(params.offsets[index], params.lengths[index], params.scrollOffset, params.viewportLength);
310
332
  const item = params.getItem(params.data, index);
311
- const key = params.keyExtractor
312
- ? params.keyExtractor(item, index)
313
- : String(index);
333
+ const key = resolveItemKey(item, index, params.keyExtractor);
314
334
  let anyViewable = false;
315
335
  for (const pair of params.pairs) {
316
336
  if (pair.viewabilityConfig.waitForInteraction === true &&
317
337
  !params.hasInteracted) {
318
338
  continue;
319
339
  }
320
- if (isCellViewable(percent, pair.viewabilityConfig)) {
340
+ if (isCellViewable(params.offsets[index], params.lengths[index], params.scrollOffset, params.viewportLength, pair.viewabilityConfig)) {
321
341
  anyViewable = true;
322
342
  break;
323
343
  }
@@ -488,10 +508,24 @@ export function computeMvcpAdjustment(params) {
488
508
  action: { kind: 'shift', offset: params.scrollOffset + insertedExtent },
489
509
  };
490
510
  }
491
- // The default key extractor: the caller's keyExtractor when set, else the index as a string (RN's
492
- // default). Centralized so every adapter's keyForIndex resolves keys identically.
511
+ // RN's real default (`VirtualizeUtils.js`'s `keyExtractor`): an object item's own `key`, else its
512
+ // `id`, else the index. Most apps never pass `keyExtractor` at all and rely on this to keep list
513
+ // identity stable across inserts/removes — falling straight to the index (what this used to do)
514
+ // silently breaks that the moment two items swap position.
515
+ export function defaultKeyExtractor(item, index) {
516
+ if (typeof item === 'object' && item !== null) {
517
+ const record = item;
518
+ if (record.key !== undefined && record.key !== null)
519
+ return String(record.key);
520
+ if (record.id !== undefined && record.id !== null)
521
+ return String(record.id);
522
+ }
523
+ return String(index);
524
+ }
525
+ // The default key extractor: the caller's keyExtractor when set, else `defaultKeyExtractor`.
526
+ // Centralized so every adapter's keyForIndex resolves keys identically.
493
527
  export function resolveItemKey(item, index, keyExtractor) {
494
- return keyExtractor ? keyExtractor(item, index) : String(index);
528
+ return (keyExtractor ?? defaultKeyExtractor)(item, index);
495
529
  }
496
530
  // Linear item -> index lookup for scrollToItem (RN scans by reference identity). NO_INDEX when the
497
531
  // item is not in data.
@@ -1,9 +1 @@
1
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
- };
@@ -1,25 +1,14 @@
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:289 and :291 (the two
4
- // lines between them, 288 and 290, are `accessible` and `disabled` — not part of this fold):
5
- //
6
- // processedProps.allowFontScaling = allowFontScaling !== false;
7
- // processedProps.ellipsizeMode = ellipsizeMode ?? 'tail';
8
- //
9
- // We declared both props in all four adapters and applied NEITHER default, so native fell back to
10
- // its own: `clip` instead of `tail`. Device-observed 2026-08-19 on examples/svelte — a Text with
11
- // numberOfLines={1} cut mid-word with no ellipsis at all. Nothing failed; the text was simply
12
- // wrong, which is why four adapters carried it.
13
- //
14
- // Both keys are emitted UNCONDITIONALLY, matching RN and — separately — keeping the key set
15
- // stable. A fold with two branches that emit different key sets is the exact hazard
16
- // .claude/rules/solid-descriptor-bridge.md §1 exists for, and Svelte's shim diffs the same way.
17
- export function resolveTextProps(props) {
18
- return {
19
- ...props,
20
- ellipsizeMode: props.ellipsizeMode ?? 'tail',
21
- // `!== false`, not `?? true`: RN treats an explicit `undefined` and a missing prop alike, and
22
- // only a literal `false` opts out.
23
- allowFontScaling: props.allowFontScaling !== false,
24
- };
25
- }
1
+ // The ellipsize modes a Text accepts. THE DEFAULTS THAT USED TO BE APPLIED HERE ARE GONE (2026-09-18)
2
+ // — they are the platform's, so they live in the engine's payload builder and reach every `RCTText`
3
+ // whoever authored it: `foldTextDefaults`, `SymbioteFabricProps.cpp`.
4
+ //
5
+ // `resolveTextProps` was the third implementation of a two-line rule. Its one runtime caller was
6
+ // Button's label, which wrote both keys onto a node the builder was about to default anyway — the
7
+ // same seed shape `seedTextDefaults` had in three adapters, at a smaller scale. The rule reaches
8
+ // that node by being keyed on the COMPONENT, so nothing has to hand it down.
9
+ //
10
+ // Why the original fold existed at all, kept because the bug is easy to reintroduce: we declared both
11
+ // props in all four adapters and applied NEITHER default, so native fell back to its own `clip`.
12
+ // Device-observed 2026-08-19 on examples/svelte — a Text with `numberOfLines={1}` cut mid-word with
13
+ // no ellipsis. Nothing failed; the text was simply wrong.
14
+ export {};
@@ -1,4 +1,4 @@
1
- import type { ITextStyle, ISymbioteEvent } from '@symbiote-native/engine';
1
+ import type { ISymbioteEvent } from '@symbiote-native/engine';
2
2
  import type { IAccessibilityProps, IAriaProps } from '../accessibility-props';
3
3
  export interface IButtonProps extends IAccessibilityProps, IAriaProps {
4
4
  title: string;
@@ -14,6 +14,13 @@ export interface IButtonProps extends IAccessibilityProps, IAriaProps {
14
14
  nextFocusRight?: number;
15
15
  nextFocusUp?: number;
16
16
  }
17
- export declare const BUTTON_ACCESSIBILITY_ROLE = "button";
18
- export declare const buttonTextStyle: ITextStyle;
19
- export declare function resolveButtonTextStyle(color: string | undefined, disabled: boolean | undefined): ITextStyle;
17
+ /**
18
+ * Whether the button is disabled, which `aria-disabled` may decide on its own.
19
+ *
20
+ * Button.js:337-338 — `props.disabled != null ? props.disabled : accessibilityState.disabled`. The
21
+ * engine already folds `aria-disabled` into the committed `accessibilityState`, but that fold is a
22
+ * PAYLOAD fold: it cannot suppress the press or grey the label, which is what this is for.
23
+ */
24
+ export declare function resolveButtonDisabled(disabled: boolean | undefined, ariaDisabled: boolean | undefined, accessibilityState: {
25
+ disabled?: boolean;
26
+ } | undefined): boolean | undefined;
@@ -1,23 +1,75 @@
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.
4
- const IOS_BUTTON_BLUE = '#007AFF';
5
- const IOS_DISABLED_GREY = '#cdcdcd';
6
- // RN's Button is accessibilityRole="button"; the role string is a native accessibility enum value.
7
- export const BUTTON_ACCESSIBILITY_ROLE = 'button';
8
- export const buttonTextStyle = {
9
- color: IOS_BUTTON_BLUE,
10
- textAlign: 'center',
11
- padding: 8,
12
- fontSize: 18,
13
- };
14
- // The label text style with the color folded in: an explicit `color` tints the label (iOS), and
15
- // `disabled` greys it out (disabled wins over color, matching RN's Button.js).
16
- export function resolveButtonTextStyle(color, disabled) {
17
- const style = { ...buttonTextStyle };
18
- if (color !== undefined)
19
- style.color = color;
20
- if (disabled === true)
21
- style.color = IOS_DISABLED_GREY;
22
- return style;
1
+ // Button: shared render half (framework-agnostic). Ported against RN's own
2
+ // `Libraries/Components/Button.js` — a touchable wrapping a `View` wrapping a `Text`, where the
3
+ // VIEW is what carries the look on Android and is empty on iOS.
4
+ //
5
+ // The inner view is not optional chrome. RN's Button is the one control in the library that ships
6
+ // a finished appearance, and on Android that appearance — a filled, elevated, rounded Material
7
+ // button with an uppercased label — lives entirely on that node. A port that renders only the
8
+ // touchable and the text is an iOS port, which is what this file was until 2026-09-09: blue text on
9
+ // nothing, wherever Android was.
10
+ //
11
+ // What is NOT here, because a layer below already does it: the `aria-*` -> `accessibilityState`
12
+ // fold RN performs in `Button.js:326-331`. The engine folds those for every node
13
+ // (`core/engine/src/accessibility-props.ts`), so repeating it here would fold twice.
14
+ import { Platform } from '@symbiote-native/engine';
15
+ // Button.js:394-437, one constant per literal so a value cannot drift silently. The LABEL's seven
16
+ // went with `resolveButtonTextStyle` on 2026-09-18 — they live in `SymbioteFabricProps.cpp` beside
17
+ // the rule that reads them and are deliberately not duplicated here.
18
+ const ANDROID_BUTTON_BLUE = '#2196F3';
19
+ const ANDROID_DISABLED_BACKGROUND = '#dfdfdf';
20
+ const ANDROID_ELEVATION = 4;
21
+ const ANDROID_DISABLED_ELEVATION = 0;
22
+ const ANDROID_BORDER_RADIUS = 2;
23
+ // `BUTTON_ACCESSIBILITY_ROLE` and `resolveButtonImportantForAccessibility` WERE HERE and are gone
24
+ // (2026-09-18). Both are `foldButtonProps` in `SymbioteFabricProps.cpp` now, and neither had a
25
+ // caller left afterwards — only its own unit test, which is the shape this project calls a mirror:
26
+ // a JS copy of a rule that runs elsewhere, kept alive by the test that asserts it. It would have
27
+ // stayed green forever while meaning nothing.
28
+ // `buttonTextStyle` AND `resolveButtonTextStyle` ARE GONE (2026-09-18) — the label's style is
29
+ // `foldButtonLabelStyle` in `SymbioteFabricProps.cpp`, reached off the label text's own tag. Its
30
+ // constants live THERE now and are not mirrored here; this file keeps only what Android's own fold
31
+ // still needs.
32
+ //
33
+ // It was the last rule in this primitive to move and it needed a seam none of the others did. Its
34
+ // inputs are the BUTTON's `color` and `disabled`, and the node it hangs on is the button's
35
+ // GRANDCHILD on iOS (`button -> view -> text`) and its child on Android — so `ownerProps`, which
36
+ // answers "my parent", could not reach it. `IAncestorLookup` asks for the nearest ancestor carrying
37
+ // a tag instead, which is a CSS ancestor selector and makes one rule right on both trees.
38
+ // `buttonViewStyle` AND `resolveButtonViewStyle` ARE GONE (2026-09-18), and with them the last of
39
+ // Button's folds. The Material look is inside `foldButtonProps` in `SymbioteFabricProps.cpp`, behind
40
+ // `#ifdef ANDROID` — where it belongs, since `{}` on iOS was the whole of its other branch.
41
+ //
42
+ // Its five constants went too rather than staying as a copy nothing reads.
43
+ //
44
+ // WHAT MADE THIS ONE DIFFERENT from the four ports before it: the Android branch is no longer
45
+ // untestable. The test host grew an arm that compiles `#ifdef ANDROID`
46
+ // (`core/engine/cpp/tests/CMakeLists.txt`, `SYMBIOTE_PLATFORM_ANDROID`), so the style, the `color`
47
+ // override and the disabled greying are asserted against the COMMITTED PAYLOAD in
48
+ // `core/engine/cpp/tests/js/android-rules.itest.ts` — strictly better than the mocked-`Platform.OS`
49
+ // unit test that went with them, which asserted a JS function rather than what Fabric receives.
50
+ // `resolveButtonTitle` IS GONE (2026-09-18) — the uppercase-on-Android rule is `foldButtonLabel` in
51
+ // `SymbioteFabricProps.cpp`, reached off the label's own tag. It had no caller left but its own two
52
+ // unit tests, which is the orphan shape this migration keeps turning up: a JS copy of a rule that
53
+ // runs elsewhere, kept alive by the test asserting it, green forever and proving nothing.
54
+ //
55
+ // A COVERAGE GAP WENT WITH IT, recorded rather than hidden. The C++ rule is `#ifdef ANDROID` — a raw
56
+ // text commits as `RCTRawText` on both platforms, so unlike `Switch`/`AndroidSwitch` there is no view
57
+ // NAME for a rule to branch on — and this host is not Android. The deleted Android test reached the
58
+ // branch by mocking `Platform.OS`; what it mocked was a JS function that no longer exists. Same
59
+ // class as `android_ripple` and `decelerationRate`'s constants, and closing it means an Android arm
60
+ // of the test host, not a mock.
61
+ //
62
+ // One behaviour difference shipped with the move and is deliberate: RN uppercases through
63
+ // JavaScript's full-Unicode `toUpperCase`, and the C++ rule is ASCII-only. See `foldButtonLabel`.
64
+ /**
65
+ * Whether the button is disabled, which `aria-disabled` may decide on its own.
66
+ *
67
+ * Button.js:337-338 — `props.disabled != null ? props.disabled : accessibilityState.disabled`. The
68
+ * engine already folds `aria-disabled` into the committed `accessibilityState`, but that fold is a
69
+ * PAYLOAD fold: it cannot suppress the press or grey the label, which is what this is for.
70
+ */
71
+ export function resolveButtonDisabled(disabled, ariaDisabled, accessibilityState) {
72
+ if (disabled !== undefined)
73
+ return disabled;
74
+ return ariaDisabled ?? accessibilityState?.disabled;
23
75
  }
@@ -54,5 +54,18 @@ export type IImageViewProps = {
54
54
  passthrough: Record<string, unknown>;
55
55
  };
56
56
  export declare const IMAGE_VIEW_PROP_NAMES: readonly ["source", "defaultSource", "loadingIndicatorSource", "style", "resizeMode", "tintColor", "src", "srcSet", "alt", "width", "height", "crossOrigin", "referrerPolicy"];
57
- export declare function mapImageProps(view: IImageViewProps): Record<string, unknown>;
57
+ /**
58
+ * `mapImageProps` STOOD HERE and is now the engine's — `foldImageProps` in
59
+ * `SymbioteFabricProps.cpp`, with `core/engine/cpp/tests/js/image-payload.itest.ts` as its contract.
60
+ *
61
+ * WHAT IS LEFT IS A GATHER, NOT A FOLD, and the distinction is the whole change. This flattens a
62
+ * typed view object back into the bag an app authored — the `<image>` tag then receives those names
63
+ * verbatim and the engine resolves them at commit, once, for every adapter. Angular is the caller
64
+ * that kept this path alive (its `<Image>` component has typed `@Input()`s rather than a prop bag);
65
+ * it now hands the tag raw props like every other adapter does.
66
+ *
67
+ * So the aliases (`src`, `srcSet`, `alt`, `width`, `height`, `crossOrigin`, `referrerPolicy`) leave
68
+ * here UNRESOLVED on purpose. Resolving them would be the second implementation this file's own
69
+ * header spent four lines warning about.
70
+ */
58
71
  export declare function renderImage(view: IImageViewProps): IDescriptor;
@@ -1,117 +1,5 @@
1
- import { dlog, flattenStyle, resolveImageSource, } from '@symbiote-native/engine';
1
+ import { dlog, } from '@symbiote-native/engine';
2
2
  import { el } from '../../descriptor.js';
3
- // Resolve the source, then normalize to the array shape native expects. A single
4
- // object/number becomes a one-element array; an already-array source passes through.
5
- function normalizeSource(source) {
6
- const resolved = resolveImageSource(source);
7
- const sources = Array.isArray(resolved) ? resolved : [resolved];
8
- dlog(`Image source resolved to ${JSON.stringify(sources)}`);
9
- return sources;
10
- }
11
- // The HTTP headers the W3C aliases (crossOrigin / referrerPolicy) contribute to
12
- // every folded source, mirroring ImageSourceUtils.js getImageSourcesFromImageProps:
13
- // 'use-credentials' adds the credentials header; referrerPolicy adds Referrer-Policy.
14
- function headersFromAliases(view) {
15
- const headers = {};
16
- if (view.crossOrigin === 'use-credentials') {
17
- headers['Access-Control-Allow-Credentials'] = 'true';
18
- }
19
- if (view.referrerPolicy !== undefined) {
20
- headers['Referrer-Policy'] = view.referrerPolicy;
21
- }
22
- return headers;
23
- }
24
- // Expand a `srcSet` descriptor list into scaled sources, falling back to `src` for
25
- // the 1x slot when srcSet omits it. Direct port of getImageSourcesFromImageProps'
26
- // srcSet branch (ImageSourceUtils.js:48). Invalid scale tokens are skipped, matching
27
- // RN's parse-and-warn behavior.
28
- function expandSrcSet(srcSet, view, headers) {
29
- const sources = [];
30
- let useSrcForDefaultScale = true;
31
- for (const entry of srcSet.split(', ')) {
32
- const [uri, xScale = '1x'] = entry.split(' ');
33
- if (!xScale.endsWith('x')) {
34
- dlog(`Image srcSet: unsupported scale token "${xScale}", skipping`);
35
- continue;
36
- }
37
- const scale = parseInt(xScale.slice(0, -1), 10);
38
- if (Number.isNaN(scale))
39
- continue;
40
- if (scale === 1)
41
- useSrcForDefaultScale = false;
42
- sources.push({
43
- uri,
44
- scale,
45
- width: view.width,
46
- height: view.height,
47
- ...{ headers },
48
- });
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 [
75
- { uri: view.src, width: view.width, height: view.height, ...{ headers } },
76
- ];
77
- }
78
- if (view.source === undefined) {
79
- dlog('Image: no source / src / srcSet provided');
80
- return [];
81
- }
82
- const sources = normalizeSource(view.source);
83
- // A header-decorated single object source gets the headers merged in, per RN's
84
- // `source.uri && headers` branch; the array/number shapes pass through untouched.
85
- if (Object.keys(headers).length > 0 && sources.length === 1) {
86
- const [only] = sources;
87
- if (typeof only === 'object' &&
88
- only !== null &&
89
- typeof Reflect.get(only, 'uri') === 'string') {
90
- return [{ ...only, headers }];
91
- }
92
- }
93
- return sources;
94
- }
95
- function readStyleString(style, key) {
96
- if (style === undefined)
97
- return undefined;
98
- // style is a StyleProp (possibly a nested array), so flatten before reading a key.
99
- const flat = flattenStyle(style);
100
- const value = Object.hasOwn(flat, key) ? flat[key] : undefined;
101
- return typeof value === 'string' ? value : undefined;
102
- }
103
- // Resolve an asset source and read its single uri. RN forwards the Android
104
- // loading indicator as a bare uri string (`loadingIndicatorSrc`), not the
105
- // array shape the main source uses, so we resolve and pluck the uri.
106
- function readSourceUri(source) {
107
- const [resolved] = normalizeSource(source);
108
- if (typeof resolved === 'object' && resolved !== null) {
109
- const uri = Reflect.get(resolved, 'uri');
110
- if (typeof uri === 'string')
111
- return uri;
112
- }
113
- return undefined;
114
- }
115
3
  // The names `renderImage` CONSUMES rather than forwards — the fold's input list, and the reason it
116
4
  // is exported: an adapter that splits props before calling renderImage was copying this list by
117
5
  // hand, and a name added here reached the copy only if someone remembered. The Image behavior's
@@ -131,40 +19,27 @@ export const IMAGE_VIEW_PROP_NAMES = [
131
19
  'crossOrigin',
132
20
  'referrerPolicy',
133
21
  ];
134
- // The whole of Image's prop mapping, with the `el()` wrap taken off. Split out because a LOWERED
135
- // `<symbiote-image>` has no component body to run it in: its behavior's `foldPayload` needs the same
136
- // bag at commit time, and a second implementation of this is the exact drift this file already paid
137
- // for once (`adapters/svelte/src/components/image/image-logic.ts` reproduces it by hand, with a
138
- // comment saying it had to because nothing here was exported).
139
- export function mapImageProps(view) {
140
- // `width` / `height` aliases fold into style (ImageProps.js:195,202); explicit
141
- // style keys win, matching RN's `{width, height}, ...style` ordering.
142
- const foldedStyle = view.width === undefined && view.height === undefined
143
- ? view.style
144
- : [{ width: view.width, height: view.height }, view.style];
145
- const mapped = {
146
- ...view.passthrough,
147
- style: foldedStyle,
148
- source: resolveSourceArray(view),
149
- resizeMode: view.resizeMode ?? readStyleString(view.style, 'resizeMode'),
150
- tintColor: view.tintColor ?? readStyleString(view.style, 'tintColor'),
151
- };
152
- // `alt` is the accessibility text: it sets accessibilityLabel and marks the image
153
- // accessible (Image.ios.js / Image.android.js: alt -> accessibilityLabel + accessible).
154
- // An explicit accessibilityLabel (already folded into passthrough) still wins.
155
- if (view.alt !== undefined) {
156
- if (mapped.accessibilityLabel === undefined)
157
- mapped.accessibilityLabel = view.alt;
158
- mapped.accessible = true;
159
- }
160
- if (view.defaultSource !== undefined)
161
- mapped.defaultSource = normalizeSource(view.defaultSource);
162
- if (view.loadingIndicatorSource !== undefined) {
163
- mapped.loadingIndicatorSrc = readSourceUri(view.loadingIndicatorSource);
164
- }
165
- return mapped;
166
- }
22
+ /**
23
+ * `mapImageProps` STOOD HERE and is now the engine's — `foldImageProps` in
24
+ * `SymbioteFabricProps.cpp`, with `core/engine/cpp/tests/js/image-payload.itest.ts` as its contract.
25
+ *
26
+ * WHAT IS LEFT IS A GATHER, NOT A FOLD, and the distinction is the whole change. This flattens a
27
+ * typed view object back into the bag an app authored — the `<image>` tag then receives those names
28
+ * verbatim and the engine resolves them at commit, once, for every adapter. Angular is the caller
29
+ * that kept this path alive (its `<Image>` component has typed `@Input()`s rather than a prop bag);
30
+ * it now hands the tag raw props like every other adapter does.
31
+ *
32
+ * So the aliases (`src`, `srcSet`, `alt`, `width`, `height`, `crossOrigin`, `referrerPolicy`) leave
33
+ * here UNRESOLVED on purpose. Resolving them would be the second implementation this file's own
34
+ * header spent four lines warning about.
35
+ */
167
36
  export function renderImage(view) {
168
37
  dlog('Image -> RCTImageView');
169
- return el('symbiote-image', mapImageProps(view));
38
+ const props = { ...view.passthrough };
39
+ for (const name of IMAGE_VIEW_PROP_NAMES) {
40
+ const value = view[name];
41
+ if (value !== undefined)
42
+ props[name] = value;
43
+ }
44
+ return el('image', props);
170
45
  }
@@ -1,11 +1,7 @@
1
- import { type IStyleProp, type IViewStyle } from '@symbiote-native/engine';
2
- import { type IDescriptor } from '../descriptor';
1
+ import type { IStyleProp, IViewStyle } from '@symbiote-native/engine';
3
2
  export type IInputAccessoryViewViewProps = {
4
3
  nativeID?: string;
5
4
  backgroundColor?: string;
6
5
  style?: IStyleProp<IViewStyle>;
7
6
  passthrough: Record<string, unknown>;
8
7
  };
9
- export declare const INPUT_ACCESSORY_VIEW_PROP_NAMES: readonly ["nativeID", "backgroundColor", "style"];
10
- export declare function mapInputAccessoryViewProps(view: IInputAccessoryViewViewProps): Record<string, unknown>;
11
- export declare function renderInputAccessoryView(view: IInputAccessoryViewViewProps): IDescriptor;