@symbiote-native/components 2.0.0 → 3.0.1

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 (79) hide show
  1. package/README.md +8 -9
  2. package/build/accessibility-props.d.ts +1 -1
  3. package/build/accessibility-props.js +2 -2
  4. package/build/behaviors/activity-indicator/shared.js +41 -70
  5. package/build/behaviors/button.d.ts +11 -0
  6. package/build/behaviors/button.js +101 -89
  7. package/build/behaviors/image-background.d.ts +1 -0
  8. package/build/behaviors/image-background.js +97 -81
  9. package/build/behaviors/image.d.ts +0 -1
  10. package/build/behaviors/image.js +24 -105
  11. package/build/behaviors/input-accessory-view.d.ts +0 -1
  12. package/build/behaviors/input-accessory-view.js +47 -53
  13. package/build/behaviors/pressable.d.ts +1 -1
  14. package/build/behaviors/pressable.js +89 -100
  15. package/build/behaviors/refresh-control.js +15 -2
  16. package/build/behaviors/scroll-view/index.android.js +25 -37
  17. package/build/behaviors/scroll-view/index.d.ts +1 -0
  18. package/build/behaviors/scroll-view/index.js +3 -0
  19. package/build/behaviors/scroll-view/responder.d.ts +4 -0
  20. package/build/behaviors/scroll-view/responder.js +202 -0
  21. package/build/behaviors/scroll-view/shared.d.ts +1 -3
  22. package/build/behaviors/scroll-view/shared.js +95 -95
  23. package/build/behaviors/scroll-view/sticky.d.ts +1 -0
  24. package/build/behaviors/scroll-view/sticky.js +62 -49
  25. package/build/behaviors/switch.js +43 -86
  26. package/build/behaviors/text-input.js +219 -107
  27. package/build/behaviors/touchable-highlight.js +74 -61
  28. package/build/behaviors/touchable-native-feedback.js +43 -122
  29. package/build/behaviors/touchable-opacity.js +71 -59
  30. package/build/behaviors/touchable-without-feedback.js +35 -100
  31. package/build/component-names/index.android.js +7 -11
  32. package/build/component-names/index.ios.js +0 -7
  33. package/build/component-names/shared.d.ts +1 -1
  34. package/build/index.d.ts +15 -21
  35. package/build/index.js +21 -28
  36. package/build/resolve-intrinsic.js +3 -9
  37. package/build/scroll-view-commands.d.ts +1 -9
  38. package/build/scroll-view-commands.js +13 -74
  39. package/build/state/flat-list.d.ts +2 -2
  40. package/build/state/flat-list.js +10 -2
  41. package/build/state/pressable.d.ts +6 -1
  42. package/build/state/pressable.js +63 -28
  43. package/build/state/section-list.d.ts +2 -0
  44. package/build/state/section-list.js +14 -7
  45. package/build/state/text-input.d.ts +7 -40
  46. package/build/state/text-input.js +17 -186
  47. package/build/state/touchable.d.ts +1 -0
  48. package/build/state/touchable.js +11 -8
  49. package/build/state/virtualized-list-reducer.d.ts +2 -2
  50. package/build/state/virtualized-list.d.ts +6 -6
  51. package/build/state/virtualized-list.js +71 -37
  52. package/build/text-props.d.ts +0 -8
  53. package/build/text-props.js +14 -25
  54. package/build/view/render-button.d.ts +1 -29
  55. package/build/view/render-button.js +44 -81
  56. package/build/view/render-image/index.d.ts +14 -1
  57. package/build/view/render-image/index.js +22 -147
  58. package/build/view/render-input-accessory-view.d.ts +1 -5
  59. package/build/view/render-input-accessory-view.js +26 -48
  60. package/build/view/render-keyboard-avoiding-view.d.ts +7 -1
  61. package/build/view/render-keyboard-avoiding-view.js +40 -1
  62. package/build/view/render-modal.d.ts +1 -1
  63. package/build/view/render-modal.js +15 -5
  64. package/build/view/render-pressable/index.d.ts +1 -0
  65. package/build/view/render-pressable/index.js +4 -0
  66. package/build/view/render-scroll-view.d.ts +0 -4
  67. package/build/view/render-scroll-view.js +3 -49
  68. package/build/view/render-switch.d.ts +0 -14
  69. package/build/view/render-switch.js +4 -42
  70. package/build/view/render-touchable-highlight.d.ts +1 -0
  71. package/build/view/render-touchable-native-feedback.d.ts +0 -1
  72. package/build/view/render-touchable-native-feedback.js +15 -9
  73. package/host-primitives.cjs +49 -203
  74. package/host-primitives.d.cts +0 -1
  75. package/package.json +3 -7
  76. package/build/fold-host-bag.d.ts +0 -15
  77. package/build/fold-host-bag.js +0 -99
  78. package/build/view/render-text-input.d.ts +0 -11
  79. package/build/view/render-text-input.js +0 -39
@@ -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
- // `<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('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;
@@ -1,53 +1,31 @@
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.js';
9
- // The names this fold CONSUMES, exported so an adapter splitting props before calling the render fn
10
- // reads the list instead of copying it — the drift `render-image.ts` records paying for once.
11
- export const INPUT_ACCESSORY_VIEW_PROP_NAMES = [
12
- 'nativeID',
13
- 'backgroundColor',
14
- 'style',
15
- ];
16
- // The whole mapping, so the wrapper path and the lowered path share ONE implementation. There is no
17
- // aliasing here at all: every consumed name leaves under the same name, which is what makes the
18
- // fold idempotent by construction (asserted in `behaviors/input-accessory-view.test.ts`, not
19
- // assumed).
1
+ // InputAccessoryView: the framework-agnostic prop SHAPE (iOS). The tag commits a real Fabric host
2
+ // node, RCTInputAccessoryView, that docks its content above the keyboard; it is referenced by
3
+ // `nativeID`, which a TextInput points at through its `inputAccessoryViewID` prop, and native pairs
4
+ // the two by id. There is no JS-side translation — style / nativeID / backgroundColor map straight
5
+ // onto the intrinsic.
20
6
  //
21
- // The `undefined` guards on `nativeID` / `backgroundColor` are LOAD-BEARING, and the asymmetry with
22
- // the unguarded `style` above is not the tell it looks like.
7
+ // THE MAPPING FUNCTION IS GONE (2026-09-18) and only the type is left, which is the honest residue:
8
+ // `mapInputAccessoryViewProps` took the bag apart and put it back together unchanged, so the
9
+ // behavior stopped calling it and nothing else ever did. Angular still names the type for its
10
+ // `@Input()` declarations (`adapters/angular/src/elements.ts`), so the shape stays; the fold does
11
+ // not. Why it was never a rule, and the numeric `backgroundColor` it used to drop:
12
+ // `core/components/src/behaviors/input-accessory-view.ts`.
13
+ export {};
14
+ // WHAT THE DELETED FOLD KNEW THAT THIS FILE NO LONGER HAS TO, kept because the trap is a property
15
+ // of the engine and outlives the function that hit it. The `undefined` guards on `nativeID` /
16
+ // `backgroundColor` were LOAD-BEARING, and not for the reason they look it:
23
17
  //
24
- // They were removed on 2026-09-01 on the reasoning `.claude/rules/fabric-boolean-event-gates.md`
25
- // states — `setProp` collapses an undefined value to an absent key, so a conditional write in a
26
- // shared render fn is cosmetic — and that reasoning was checked against a payload carrying no `id`,
27
- // which is exactly the case where it holds. The equivalence oracle caught it on its first run:
28
- //
29
- // authored <InputAccessoryView id="p" testID="p" />
18
+ // authored <input-accessory-view id="p" testID="p">
30
19
  // guarded RCTInputAccessoryView{testID, nativeID:"p"}
31
20
  // unguarded RCTInputAccessoryView{testID} <- the alias result, deleted
32
21
  //
33
- // `nativeID` has an ALIAS SOURCE. The wrapper leaves `id` in `passthrough`, the renderer's
34
- // PROP_ALIASES renames it to `nativeID`, and a `nativeID: undefined` written afterwards deletes what
35
- // the rename just produced — last write wins, and `undefined` collapsing to "absent" is precisely
36
- // what makes it destructive rather than inert. So the guard is cosmetic for a key nothing else can
37
- // produce, and required for a key an alias also targets.
38
- export function mapInputAccessoryViewProps(view) {
39
- const out = {
40
- ...view.passthrough,
41
- style: view.style,
42
- };
43
- if (view.nativeID !== undefined)
44
- out.nativeID = view.nativeID;
45
- if (view.backgroundColor !== undefined)
46
- out.backgroundColor = view.backgroundColor;
47
- return out;
48
- }
49
- export function renderInputAccessoryView(view) {
50
- dlog('InputAccessoryView -> RCTInputAccessoryView');
51
- // Empty structural children: the adapter appends the user children directly under the host.
52
- return el('input-accessory-view', mapInputAccessoryViewProps(view), []);
53
- }
22
+ // `setProp` collapses an undefined value to an absent key, so a conditional write is normally
23
+ // cosmetic (`.claude/rules/fabric-boolean-event-gates.md`) — it is destructive precisely when the
24
+ // key has an ALIAS SOURCE. `id` arrives, something renames it to `nativeID`, and a
25
+ // `nativeID: undefined` written afterwards deletes what the rename just produced. Removing the
26
+ // guards on that reasoning cost a day on 2026-09-01.
27
+ //
28
+ // It cannot recur here, and that is the point: the rename is `foldIdAlias` in
29
+ // `SymbioteFabricProps.cpp` now, one rule at the end of the payload build with nothing downstream
30
+ // of it to overwrite the result. Anything that reintroduces a JS-side alias for this tag
31
+ // reintroduces the trap with it.
@@ -1,4 +1,4 @@
1
- import type { IKeyboardEventName, IPlatformOSType, IStyleProp, IViewStyle } from '@symbiote-native/engine';
1
+ import type { IKeyboardEventName, ILayoutAnimationConfig, 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 {
@@ -17,6 +17,12 @@ export type IKeyboardAvoidingEventNames = {
17
17
  };
18
18
  export declare function keyboardAvoidingEventNamesFor(os: IPlatformOSType): IKeyboardAvoidingEventNames;
19
19
  export declare function readPrefersCrossFadeTransitions(query?: () => Promise<boolean>): Promise<boolean>;
20
+ export interface IKeyboardAnimationTiming {
21
+ duration: number;
22
+ easing: string;
23
+ }
24
+ export declare function readKeyboardAnimationTiming(payload: unknown): IKeyboardAnimationTiming | undefined;
25
+ export declare function configureKeyboardAvoidingAnimation(timing: IKeyboardAnimationTiming | undefined, enabled: boolean, configure?: (config: ILayoutAnimationConfig) => void): void;
20
26
  export type IComputeInsetOptions = {
21
27
  behavior?: IKeyboardAvoidingBehavior;
22
28
  previousInset?: number;
@@ -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 { AccessibilityInfo, dlog, isRecord, KEYBOARD_EVENT, Platform, } from '@symbiote-native/engine';
12
+ import { AccessibilityInfo, dlog, isRecord, KEYBOARD_EVENT, LayoutAnimation, 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.
@@ -60,6 +60,45 @@ export function readPrefersCrossFadeTransitions(query = () => AccessibilityInfo.
60
60
  return false;
61
61
  });
62
62
  }
63
+ // RCTLayoutAnimation.m's minimal accepted duration — RN's own comment at the call site
64
+ // (`KeyboardAvoidingView.js:172-173`): "We have to pass the duration equal to minimal accepted
65
+ // duration defined here".
66
+ const MIN_LAYOUT_ANIMATION_DURATION = 10;
67
+ // The keyboard event's own transition curve, read off the SAME raw payload `readKeyboardFrame`
68
+ // reads `endCoordinates` from — `duration`/`easing` sit one level up, beside it.
69
+ export function readKeyboardAnimationTiming(payload) {
70
+ if (!isRecord(payload))
71
+ return undefined;
72
+ const { duration, easing } = payload;
73
+ if (typeof duration !== 'number' || typeof easing !== 'string')
74
+ return undefined;
75
+ return { duration, easing };
76
+ }
77
+ // RN's `_updateBottomIfNecessary` (`:169-179`): once the inset actually changes, arm the NEXT
78
+ // commit to animate over the keyboard's own transition curve instead of snapping. Gated on
79
+ // `enabled` and on the event carrying BOTH a duration and an easing name — Android's
80
+ // DID_SHOW/DID_HIDE notifications carry neither, so this is correctly a no-op there, same as
81
+ // vendor's `if (enabled && duration && easing)`.
82
+ //
83
+ // `configure` is injectable purely for testability, the same shape `readPrefersCrossFadeTransitions`
84
+ // takes its `query`; callers pass nothing and get the real `LayoutAnimation.configureNext`.
85
+ export function configureKeyboardAvoidingAnimation(timing, enabled, configure = config => LayoutAnimation.configureNext(config)) {
86
+ if (!enabled || timing === undefined)
87
+ return;
88
+ const { duration, easing } = timing;
89
+ if (!duration || !easing)
90
+ return;
91
+ const flooredDuration = duration > MIN_LAYOUT_ANIMATION_DURATION
92
+ ? duration
93
+ : MIN_LAYOUT_ANIMATION_DURATION;
94
+ configure({
95
+ duration: flooredDuration,
96
+ update: {
97
+ duration: flooredDuration,
98
+ type: LayoutAnimation.coerceType(easing),
99
+ },
100
+ });
101
+ }
63
102
  // RN's _relativeKeyboardHeight: how far up the view must move so it no longer overlaps the
64
103
  // keyboard. keyboardY is the keyboard's top edge minus the caller's vertical offset; the inset
65
104
  // is the overlap of the view's bottom past that edge, clamped at 0.
@@ -20,4 +20,4 @@ export type IModalViewProps = {
20
20
  style?: IStyleProp<IViewStyle>;
21
21
  passthrough: Record<string, unknown>;
22
22
  };
23
- export declare function renderModal(view: IModalViewProps): IDescriptor;
23
+ export declare function renderModal(view: IModalViewProps, isRTL?: boolean): IDescriptor;
@@ -5,7 +5,7 @@
5
5
  // maps to the `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, I18nManager, } 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
@@ -13,8 +13,11 @@ import { el } from '../descriptor.js';
13
13
  // (ModalHostViewComponentDescriptor sets the node size to screenSize). An absolute container with
14
14
  // only top/left would collapse to its content instead. The backdrop color is layered on at render
15
15
  // time so transparent/backdropColor win.
16
- const CONTAINER_STYLE = {
17
- left: 0,
16
+ //
17
+ // `[side]` is NOT always 'left' — vendor computes it once at module load from
18
+ // `I18nManager.getConstants().isRTL` (`Modal.js:372`: `isRTL ? 'right' : 'left'`), so a modal in an
19
+ // RTL layout pins its container to the RIGHT edge. `renderModal`'s own `isRTL` parameter supplies it.
20
+ const CONTAINER_STYLE_BASE = {
18
21
  top: 0,
19
22
  flex: 1,
20
23
  };
@@ -30,7 +33,10 @@ const DEFAULT_ANIMATION_TYPE = 'none';
30
33
  // 'overFullScreen').
31
34
  const PRESENTATION_FULL_SCREEN = 'fullScreen';
32
35
  const PRESENTATION_OVER_FULL_SCREEN = 'overFullScreen';
33
- export function renderModal(view) {
36
+ // `isRTL` is injectable purely for testability, the same shape `computeInset`'s `os` option
37
+ // takes: `I18nManager`'s constants are resolved once at module load with no setter, so a test
38
+ // exercising the RTL branch cannot toggle the real module and must pass the value in.
39
+ export function renderModal(view, isRTL = I18nManager.isRTL) {
34
40
  // Only override backgroundColor when transparent or backdropColor are explicitly set, so these
35
41
  // Modal-specific props take precedence over the generic style prop (Modal.js: containerStyles
36
42
  // composed LAST in [styles.container, props.style, containerStyles]).
@@ -40,7 +46,11 @@ export function renderModal(view) {
40
46
  ? { backgroundColor: view.backdropColor }
41
47
  : {};
42
48
  const containerStyle = [
43
- { ...CONTAINER_STYLE, backgroundColor: OPAQUE_BACKDROP },
49
+ {
50
+ ...CONTAINER_STYLE_BASE,
51
+ ...(isRTL ? { right: 0 } : { left: 0 }),
52
+ backgroundColor: OPAQUE_BACKDROP,
53
+ },
44
54
  view.style,
45
55
  backdropOverride,
46
56
  ];
@@ -9,5 +9,6 @@ export declare function isTerminationAllowed(cancelable: boolean | undefined): b
9
9
  export declare function buildPressableListeners(handlers: IPressHandlers, options: {
10
10
  disabled?: boolean;
11
11
  cancelable?: boolean;
12
+ blockNativeResponder?: boolean;
12
13
  }): Record<string, unknown>;
13
14
  export declare function noteHoverNoop(onHoverIn: unknown, onHoverOut: unknown): void;
@@ -72,6 +72,10 @@ export function buildPressableListeners(handlers, options) {
72
72
  onPressOut: handlers.handlePressOut,
73
73
  onStartShouldSetResponder: () => shouldClaimResponder(options.disabled),
74
74
  onResponderMove: handlers.handleResponderMove,
75
+ // Pressability.js:479 — returned from onResponderGrant unconditionally, so native learns
76
+ // whether to stand down the moment the gesture is claimed. Unset resolves to false, matching
77
+ // the engine's own fallback for an absent listener (`events/index.ts`).
78
+ onResponderGrant: () => options.blockNativeResponder === true,
75
79
  };
76
80
  if (options.cancelable !== undefined) {
77
81
  listeners.onResponderTerminationRequest = () => isTerminationAllowed(options.cancelable);
@@ -1,13 +1,9 @@
1
1
  import type { IStyleProp, ISymbioteEvent, IViewStyle } from '@symbiote-native/engine';
2
2
  import type { ISymbioteIntrinsic } from '../component-names/shared';
3
3
  export declare function readLayoutDimension(event: ISymbioteEvent, key: 'width' | 'height'): number | undefined;
4
- export declare function resolveDecelerationRate(rate: 'normal' | 'fast' | number): number;
5
- export declare const SCROLL_VIEW_BASE_HORIZONTAL: IViewStyle;
6
- export declare const SCROLL_VIEW_BASE_VERTICAL: IViewStyle;
7
4
  export type IScrollIntrinsics = {
8
5
  scrollViewIntrinsic: ISymbioteIntrinsic;
9
6
  contentIntrinsic: ISymbioteIntrinsic;
10
- scrollViewBaseStyle: IViewStyle;
11
7
  contentStyle: IStyleProp<IViewStyle>;
12
8
  };
13
9
  export declare function selectScrollIntrinsics(isHorizontal: boolean, contentContainerStyle: IStyleProp<IViewStyle> | undefined): IScrollIntrinsics;
@@ -5,7 +5,6 @@
5
5
  // adapter owns the lifecycle (refs/state/effects) and the element assembly; it calls these pure
6
6
  // helpers from prepareScrollView. What diverges per platform, and how a RefreshControl
7
7
  // integrates, stays in the adapter's .ios/.android files.
8
- import { Platform } from '@symbiote-native/engine';
9
8
  import { readLayoutField } from './layout-event.js';
10
9
  // Thin re-export kept for the existing public surface (adapters import this name from
11
10
  // `@symbiote-native/components`); the actual field read is shared with render-scroll-sticky's
@@ -13,46 +12,6 @@ import { readLayoutField } from './layout-event.js';
13
12
  export function readLayoutDimension(event, key) {
14
13
  return readLayoutField(event, key);
15
14
  }
16
- // 'normal'/'fast' resolve to DIFFERENT friction constants per platform: RN's
17
- // processDecelerationRate.js Platform.select()s them: iOS glides longer (0.998/0.99),
18
- // Android sooner (0.985/0.9). Hardcoding the iOS pair made Android momentum scroll
19
- // glide far too long on 'fast'. This is the file's one Platform read: the header's
20
- // "no Platform.OS" rule governs component-intrinsic selection, not a value transform
21
- // RN itself platform-branches. `default` mirrors iOS so any non-ios/android host stays
22
- // defined (select would otherwise yield undefined). Numeric rates pass through unchanged.
23
- export function resolveDecelerationRate(rate) {
24
- if (typeof rate === 'number')
25
- return rate;
26
- // select() types as `number | undefined`; the always-present `default` makes the
27
- // `??` fallback unreachable, but it narrows the return to a plain `number` (no cast).
28
- if (rate === 'normal')
29
- return (Platform.select({ ios: 0.998, android: 0.985, default: 0.998 }) ?? 0.998);
30
- return Platform.select({ ios: 0.99, android: 0.9, default: 0.99 }) ?? 0.99;
31
- }
32
- // RN applies a base style to the scroll-view NODE itself, per axis (ScrollView.js
33
- // styles.baseHorizontal/baseVertical). Two parts carry weight:
34
- // - `overflow: 'scroll'`: clips content to the scroll view's frame. On iOS Fabric the
35
- // node only clips when this is set; without it a fixed-height ScrollView lets its
36
- // content bleed out over siblings (Android's native ViewGroup clips regardless, which
37
- // is why the bug showed only on iOS). RN sets it on BOTH axes, so we do too.
38
- // - `flexDirection: 'row'` (horizontal only): makes the single content child a MAIN-axis
39
- // item, so Yoga sizes it to its content width and the view overflows and scrolls.
40
- // Without it the content is a CROSS-axis item, stretched to the viewport, nothing to
41
- // scroll. Vertical keeps the default `column`.
42
- // Both axes match RN's baseHorizontal/baseVertical exactly. Composed UNDER the user style,
43
- // so an explicit value still wins.
44
- export const SCROLL_VIEW_BASE_HORIZONTAL = {
45
- flexGrow: 1,
46
- flexShrink: 1,
47
- flexDirection: 'row',
48
- overflow: 'scroll',
49
- };
50
- export const SCROLL_VIEW_BASE_VERTICAL = {
51
- flexGrow: 1,
52
- flexShrink: 1,
53
- flexDirection: 'column',
54
- overflow: 'scroll',
55
- };
56
15
  export function selectScrollIntrinsics(isHorizontal, contentContainerStyle) {
57
16
  // Horizontal scroll resolves to a different native component on Android (its own
58
17
  // ViewManager, not RCTScrollView+flag); on iOS both intrinsics map back to RCTScrollView.
@@ -63,16 +22,12 @@ export function selectScrollIntrinsics(isHorizontal, contentContainerStyle) {
63
22
  const contentIntrinsic = isHorizontal
64
23
  ? 'horizontal-scroll-content'
65
24
  : 'scroll-content';
66
- const scrollViewBaseStyle = isHorizontal
67
- ? SCROLL_VIEW_BASE_HORIZONTAL
68
- : SCROLL_VIEW_BASE_VERTICAL;
69
25
  const contentStyle = isHorizontal
70
26
  ? [contentContainerStyle, { flexDirection: 'row' }]
71
27
  : contentContainerStyle;
72
28
  return {
73
29
  scrollViewIntrinsic,
74
30
  contentIntrinsic,
75
- scrollViewBaseStyle,
76
31
  contentStyle,
77
32
  };
78
33
  }
@@ -87,10 +42,9 @@ const STICKY_JS_SCROLL_THROTTLE = 16;
87
42
  // collapsableChildren={false} on the content container (ScrollView.js:1731 `preserveChildren`).
88
43
  // No-op on iOS.
89
44
  //
90
- // Named and exported rather than inlined because the LOWERED path needs the same answer from a
91
- // different place — the host behavior's slot fold, which has the owner's raw props and none of the
92
- // sticky inputs `resolveScrollForwarding` also takes. A second inline copy would be invisible to
93
- // `tests/lowered-primitive-fold-parity.test.ts`, whose oracle is shared value IMPORTS.
45
+ // Named and exported rather than inlined because the host behavior's slot fold needs the same
46
+ // answer from a different place — it has the owner's raw props and none of the sticky inputs
47
+ // `resolveScrollForwarding` also takes. One exported function is what keeps the two from drifting.
94
48
  export function preservesContentChildren(maintainVisibleContentPosition, snapToAlignment) {
95
49
  return (maintainVisibleContentPosition !== undefined ||
96
50
  snapToAlignment !== undefined);
@@ -1,5 +1,4 @@
1
1
  import type { IStyleProp, IViewStyle, ISymbioteEvent } from '@symbiote-native/engine';
2
- import type { IDescriptor } from '../descriptor';
3
2
  import type { IAccessibilityProps, IAriaProps } from '../accessibility-props';
4
3
  export type ISwitchTrackColor = {
5
4
  false?: string;
@@ -17,16 +16,3 @@ export interface ISwitchProps extends IAccessibilityProps, IAriaProps {
17
16
  ios_backgroundColor?: string;
18
17
  style?: IStyleProp<IViewStyle>;
19
18
  }
20
- export type ISwitchPlatform = {
21
- trackColorProps: (value: boolean, trackColor?: ISwitchTrackColor) => Record<string, unknown>;
22
- };
23
- export type ISwitchViewProps = {
24
- value: boolean;
25
- disabled?: boolean;
26
- trackColor?: ISwitchTrackColor;
27
- thumbColor?: string;
28
- ios_backgroundColor?: string;
29
- style?: IStyleProp<IViewStyle>;
30
- passthrough: Record<string, unknown>;
31
- };
32
- export declare function renderSwitch(view: ISwitchViewProps, platform: ISwitchPlatform): IDescriptor;
@@ -1,42 +1,4 @@
1
- // Switch: the render half (framework-agnostic). Maps the resolved props onto the single
2
- // `switch` host node: the strict value fold lands as the `value` Fabric prop, the
3
- // track colors take platform-specific prop NAMES (iOS onTintColor/tintColor vs Android
4
- // trackColorFor*/trackTintColor, supplied via `platform`), thumbColor → thumbTintColor, and
5
- // ios_backgroundColor folds into the style as the pill that shows through the shrunken track.
6
- // Pure and prop-driven; no hooks, no events. The adapter owns those.
7
- import { dlog } from '@symbiote-native/engine';
8
- import { el } from '../descriptor.js';
9
- // RN rounds the iOS background pill to this radius when ios_backgroundColor is set.
10
- const IOS_BACKGROUND_BORDER_RADIUS = 16;
11
- // The `-managed` spelling, NOT the plain one the engine's Switch behavior registers under
12
- // (`core/components/src/behaviors/switch.ts`) — the wrapper still runs its own lifecycle
13
- // (lastNativeReport, the snap-back effect), so it may not share a tag the behavior registry would
14
- // also attach to; see that behavior's module header and `.claude/rules/host-primitive-tier.md`,
15
- // "A lowered element and its wrapper must not share an intrinsic tag" (the TextInput precedent).
16
- const SWITCH_MANAGED_INTRINSIC = 'switch-managed';
17
- // Fold ios_backgroundColor into the style, matching RN's iOS branch: it paints the
18
- // background that shows through the shrunken track. Untouched when unset, so a caller's own
19
- // backgroundColor wins by simply not passing ios_backgroundColor.
20
- function foldIosBackground(style, color) {
21
- if (color === undefined)
22
- return style;
23
- return [
24
- style,
25
- { backgroundColor: color, borderRadius: IOS_BACKGROUND_BORDER_RADIUS },
26
- ];
27
- }
28
- export function renderSwitch(view, platform) {
29
- dlog(`Switch render value=${view.value} disabled=${String(view.disabled)}`);
30
- // These color props reach Fabric as ordinary props: the shared ViewConfig declares
31
- // Switch's only event as `change`, so routeProp sends the on*-prefixed color names through
32
- // setProp rather than mistaking them for listeners.
33
- const props = {
34
- ...view.passthrough,
35
- value: view.value,
36
- disabled: view.disabled,
37
- ...platform.trackColorProps(view.value, view.trackColor),
38
- thumbTintColor: view.thumbColor,
39
- style: foldIosBackground(view.style, view.ios_backgroundColor),
40
- };
41
- return el(SWITCH_MANAGED_INTRINSIC, props);
42
- }
1
+ // Switch: the framework-agnostic prop surface. The painting moved to the `switch` tag's own
2
+ // behavior (`behaviors/switch.ts`) when the wrapper path was retired; what stays here is the
3
+ // public prop type every adapter re-exports and the event shape it fires with.
4
+ export {};
@@ -8,5 +8,6 @@ export interface ITouchableHighlightUnderlayView {
8
8
  hasPressHandler: boolean;
9
9
  underlayColor?: string;
10
10
  activeOpacity?: number;
11
+ testOnly_pressed?: boolean;
11
12
  }
12
13
  export declare function resolveHighlightExtraStyles(view: ITouchableHighlightUnderlayView): ITouchableHighlightExtraStyles | undefined;
@@ -14,7 +14,6 @@ export declare function canUseNativeForeground(): boolean;
14
14
  export declare function selectableBackground(rippleRadius?: number): IThemeAttrBackground;
15
15
  export declare function selectableBackgroundBorderless(rippleRadius?: number): IThemeAttrBackground;
16
16
  export declare function rippleBackground(color: string, borderless: boolean, rippleRadius?: number): IRippleBackground;
17
- export declare function backgroundProps(background: INativeFeedbackBackground, useForeground: boolean): Record<string, INativeFeedbackBackground>;
18
17
  /**
19
18
  * RN's four statics, under RN's own spelling — `TouchableNativeFeedback.Ripple(color, borderless)`.
20
19
  *