@symbiote-native/components 0.5.0 → 2.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 (88) hide show
  1. package/README.md +23 -10
  2. package/build/accessibility-props.d.ts +11 -0
  3. package/build/accessibility-props.js +30 -118
  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 +149 -0
  12. package/build/behaviors/button.d.ts +2 -0
  13. package/build/behaviors/button.js +328 -0
  14. package/build/behaviors/image-background.d.ts +2 -0
  15. package/build/behaviors/image-background.js +139 -0
  16. package/build/behaviors/image.d.ts +3 -0
  17. package/build/behaviors/image.js +123 -0
  18. package/build/behaviors/input-accessory-view.d.ts +3 -0
  19. package/build/behaviors/input-accessory-view.js +70 -0
  20. package/build/behaviors/pressable.d.ts +60 -0
  21. package/build/behaviors/pressable.js +385 -0
  22. package/build/behaviors/refresh-control.d.ts +2 -0
  23. package/build/behaviors/refresh-control.js +83 -0
  24. package/build/behaviors/scroll-view/index.android.d.ts +1 -0
  25. package/build/behaviors/scroll-view/index.android.js +52 -0
  26. package/build/behaviors/scroll-view/index.d.ts +2 -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 +5 -0
  30. package/build/behaviors/scroll-view/shared.d.ts +11 -0
  31. package/build/behaviors/scroll-view/shared.js +291 -0
  32. package/build/behaviors/scroll-view/sticky.d.ts +17 -0
  33. package/build/behaviors/scroll-view/sticky.js +568 -0
  34. package/build/behaviors/switch.d.ts +2 -0
  35. package/build/behaviors/switch.js +186 -0
  36. package/build/behaviors/text-input.d.ts +14 -0
  37. package/build/behaviors/text-input.js +319 -0
  38. package/build/behaviors/touchable-highlight.d.ts +9 -0
  39. package/build/behaviors/touchable-highlight.js +192 -0
  40. package/build/behaviors/touchable-native-feedback.d.ts +20 -0
  41. package/build/behaviors/touchable-native-feedback.js +333 -0
  42. package/build/behaviors/touchable-opacity.d.ts +12 -0
  43. package/build/behaviors/touchable-opacity.js +227 -0
  44. package/build/behaviors/touchable-without-feedback.d.ts +2 -0
  45. package/build/behaviors/touchable-without-feedback.js +296 -0
  46. package/build/component-names/index.android.js +47 -15
  47. package/build/component-names/index.ios.js +35 -15
  48. package/build/component-names/shared.d.ts +2 -1
  49. package/build/component-names/shared.js +49 -3
  50. package/build/descriptor.js +4 -4
  51. package/build/fold-host-bag.d.ts +15 -0
  52. package/build/fold-host-bag.js +99 -0
  53. package/build/index.d.ts +23 -12
  54. package/build/index.js +50 -9
  55. package/build/register.d.ts +1 -0
  56. package/build/register.js +55 -0
  57. package/build/resolve-intrinsic.d.ts +7 -0
  58. package/build/resolve-intrinsic.js +49 -0
  59. package/build/scroll-view-commands.d.ts +4 -0
  60. package/build/scroll-view-commands.js +30 -31
  61. package/build/state/pressable.d.ts +9 -0
  62. package/build/state/pressable.js +120 -34
  63. package/build/state/text-input.d.ts +10 -2
  64. package/build/state/text-input.js +11 -0
  65. package/build/text-props.js +2 -1
  66. package/build/view/render-button.d.ts +36 -1
  67. package/build/view/render-button.js +101 -12
  68. package/build/view/render-image/index.d.ts +2 -0
  69. package/build/view/render-image/index.js +29 -2
  70. package/build/view/render-input-accessory-view.d.ts +2 -0
  71. package/build/view/render-input-accessory-view.js +37 -5
  72. package/build/view/render-modal.js +3 -3
  73. package/build/view/render-pressable/index.d.ts +2 -0
  74. package/build/view/render-pressable/index.js +24 -0
  75. package/build/view/render-scroll-view.d.ts +1 -0
  76. package/build/view/render-scroll-view.js +18 -9
  77. package/build/view/render-switch.d.ts +4 -1
  78. package/build/view/render-switch.js +8 -2
  79. package/build/view/render-text-input.js +6 -2
  80. package/build/view/render-touchable-native-feedback.d.ts +19 -0
  81. package/build/view/render-touchable-native-feedback.js +19 -0
  82. package/host-primitives.cjs +432 -0
  83. package/host-primitives.d.cts +33 -0
  84. package/package.json +33 -5
  85. package/build/view/render-activity-indicator.d.ts +0 -25
  86. package/build/view/render-activity-indicator.js +0 -62
  87. package/build/view/render-image-background.d.ts +0 -9
  88. package/build/view/render-image-background.js +0 -48
@@ -3,7 +3,7 @@
3
3
  // the Android RefreshControl wrap, the scroll-event guard/forwarder, and the native sticky
4
4
  // scroll-attach are all platform- and framework-invariant, so they live here. The adapter
5
5
  // supplies the lifecycle (the node getter, the effect) and re-exports these.
6
- import { attachNativeEvent, dispatchViewCommand, dlog, flattenStyle, isSymbioteEvent, } from '@symbiote-native/engine';
6
+ import { attachNativeEvent, dlog, flattenStyle, isSymbioteEvent, } from '@symbiote-native/engine';
7
7
  // RN's splitLayoutProps key partition (StyleSheet/splitLayoutProps.js): the LAYOUT keys
8
8
  // that belong on the OUTER box when a layout-affecting wrapper sits between the laid-out
9
9
  // frame and the visual content. Everything NOT in this set (background*, padding*, border*,
@@ -58,6 +58,24 @@ export function splitLayoutProps(style) {
58
58
  }
59
59
  return { outer, inner };
60
60
  }
61
+ // The whole Android wrap style decision: the layout/visual split, AND the axis base composed onto
62
+ // BOTH boxes. RN does the second half too (`StyleSheet.compose(baseStyle, outer)` beside
63
+ // `compose(baseStyle, inner)`, ScrollView.js:1856), and every adapter had dropped it from the
64
+ // wrapper — so an AndroidSwipeRefreshLayout with no explicit user layout style lost `flexGrow: 1`
65
+ // and collapsed to its content height inside a flex parent, where RN's grows.
66
+ //
67
+ // One function rather than five call sites composing `[base, outer]` by hand, because that is what
68
+ // the last one drifted into: a fold written inline is invisible to
69
+ // `tests/lowered-primitive-fold-parity.test.ts`, whose oracle is shared value imports.
70
+ // `style` is `unknown` rather than `IStyleProp`, because a `payloadFold` reads it off an untyped
71
+ // props bag and `flattenStyle` — the only thing that touches it here — already takes `unknown`.
72
+ // Narrowing it would buy a guard at every fold call site and no safety.
73
+ export function splitScrollViewStyle(base, style) {
74
+ const { outer, inner } = splitLayoutProps(flattenStyle(style));
75
+ // Base UNDER the split half on both, so an explicit user value still wins — the same order the
76
+ // unwrapped scroll view composes.
77
+ return { outer: [base, outer], inner: [base, inner] };
78
+ }
61
79
  // Re-exported so the package barrel (index.ts) can still export this guard to
62
80
  // '@symbiote-native/components' callers, now that it lives in the engine, next to ISymbioteEvent.
63
81
  export { isSymbioteEvent };
@@ -69,42 +87,23 @@ export function forwardScrollEvent(handler, args) {
69
87
  if (isSymbioteEvent(first))
70
88
  handler(first);
71
89
  }
72
- // The imperative handle is identical across platforms: every method dispatches a view
73
- // command on the SAME scroll-view node; only the surrounding element assembly diverges
74
- // (iOS sibling RefreshControl vs Android wrap). So it is built once here and both platform
75
- // files back it with their scroll node getter. Commands and arg order mirror RN's
76
- // ScrollViewCommands: scrollTo [x, y, animated], scrollToEnd [animated], flashScrollIndicators [].
90
+ // The imperative handle is identical across platforms — only the surrounding element assembly
91
+ // diverges (iOS sibling RefreshControl vs Android wrap) — so it is built once here and both
92
+ // platform files back it with their scroll node getter.
93
+ //
94
+ // It DELEGATES to the node's own methods rather than dispatching commands itself. The commands and
95
+ // their defaults live on `ISymbioteNode` because a LOWERED ScrollView hands the app its engine
96
+ // node directly, with no wrapper to build a handle: two implementations would let `scrollTo()` with
97
+ // no argument mean one thing through a ref and another through a tag, and nothing would report it.
77
98
  //
78
99
  // `getNode` is a LAZY getter (React `() => ref.current`, Vue `() => nodeRef.value`), read on
79
100
  // every call, NOT the node captured once. The node is null at mount and only set after the
80
101
  // element commits, so an eager capture would freeze `null` and every command would no-op.
81
102
  export function buildScrollViewHandle(getNode) {
82
103
  return {
83
- scrollTo: (options) => {
84
- const node = getNode();
85
- if (node === null)
86
- return;
87
- const x = options?.x ?? 0;
88
- const y = options?.y ?? 0;
89
- const animated = options?.animated ?? true;
90
- dlog(`ScrollView.scrollTo x=${x} y=${y} animated=${animated}`);
91
- dispatchViewCommand(node, 'scrollTo', [x, y, animated]);
92
- },
93
- scrollToEnd: (options) => {
94
- const node = getNode();
95
- if (node === null)
96
- return;
97
- const animated = options?.animated ?? true;
98
- dlog(`ScrollView.scrollToEnd animated=${animated}`);
99
- dispatchViewCommand(node, 'scrollToEnd', [animated]);
100
- },
101
- flashScrollIndicators: () => {
102
- const node = getNode();
103
- if (node === null)
104
- return;
105
- dlog('ScrollView.flashScrollIndicators');
106
- dispatchViewCommand(node, 'flashScrollIndicators', []);
107
- },
104
+ scrollTo: (options) => getNode()?.scrollTo(options),
105
+ scrollToEnd: (options) => getNode()?.scrollToEnd(options),
106
+ flashScrollIndicators: () => getNode()?.flashScrollIndicators(),
108
107
  getScrollNode: () => getNode(),
109
108
  };
110
109
  }
@@ -1,5 +1,6 @@
1
1
  import { type ISymbioteEvent } from '@symbiote-native/engine';
2
2
  export declare const DEFAULT_DELAY_LONG_PRESS_MS = 500;
3
+ export declare const DEFAULT_MIN_PRESS_DURATION_MS = 130;
3
4
  export declare const DEFAULT_PRESS_RECT_OFFSETS: {
4
5
  top: number;
5
6
  left: number;
@@ -56,19 +57,26 @@ export interface IPressRuntime {
56
57
  longPressCancel: (() => void) | undefined;
57
58
  longPressFired: boolean;
58
59
  pressDelayCancel: (() => void) | undefined;
60
+ pressOutCancel: (() => void) | undefined;
59
61
  pressOrigin: {
60
62
  x: number;
61
63
  y: number;
62
64
  } | undefined;
63
65
  driftedOut: boolean;
64
66
  region: IResponderRegion | undefined;
67
+ active: boolean;
68
+ activatedAt: number | undefined;
69
+ delayElapsed: boolean;
70
+ disposed: boolean;
65
71
  }
66
72
  export declare function createPressRuntime(): IPressRuntime;
73
+ export declare function disposePressRuntime(runtime: IPressRuntime): void;
67
74
  export type IFrameCallback = (x: number, y: number, width: number, height: number, pageX: number, pageY: number) => void;
68
75
  export interface IPressHost {
69
76
  setPressed: (pressed: boolean) => void;
70
77
  getMeasureFn: () => ((callback: IFrameCallback) => void) | undefined;
71
78
  schedule: (callback: () => void, ms: number) => () => void;
79
+ now: () => number;
72
80
  }
73
81
  export interface IPressMachineConfig {
74
82
  onPress?: IPressHandler;
@@ -78,6 +86,7 @@ export interface IPressMachineConfig {
78
86
  onLongPress?: IPressHandler;
79
87
  delayLongPress: number;
80
88
  unstable_pressDelay: number;
89
+ minPressDuration?: number;
81
90
  hitSlop?: IRectOffset;
82
91
  pressRetentionOffset?: IRectOffset;
83
92
  }
@@ -11,6 +11,8 @@
11
11
  // the decision of when each callback fires) is here, shared by every adapter.
12
12
  import { dlog, Platform } from '@symbiote-native/engine';
13
13
  export const DEFAULT_DELAY_LONG_PRESS_MS = 500;
14
+ // Pressability's default active-visual floor for a plain Pressable. Touchable* overrides this to 0.
15
+ export const DEFAULT_MIN_PRESS_DURATION_MS = 130;
14
16
  // RN's default extra slop kept around a press once it is active, before a drift fires pressOut.
15
17
  // The PressRect extension (Pressability.js DEFAULT_PRESS_RECT_OFFSETS). Per-edge, deeper bottom.
16
18
  export const DEFAULT_PRESS_RECT_OFFSETS = {
@@ -91,11 +93,40 @@ export function createPressRuntime() {
91
93
  longPressCancel: undefined,
92
94
  longPressFired: false,
93
95
  pressDelayCancel: undefined,
96
+ pressOutCancel: undefined,
94
97
  pressOrigin: undefined,
95
98
  driftedOut: false,
96
99
  region: undefined,
100
+ active: false,
101
+ activatedAt: undefined,
102
+ delayElapsed: false,
103
+ disposed: false,
97
104
  };
98
105
  }
106
+ function cancelRuntimeTimer(runtime, key) {
107
+ const cancel = runtime[key];
108
+ if (cancel === undefined)
109
+ return;
110
+ cancel();
111
+ runtime[key] = undefined;
112
+ }
113
+ // RN Pressability.reset(): cancel every timer and make any already-queued callback inert. The
114
+ // adapter invokes this exactly once from its own unmount/destroy hook.
115
+ export function disposePressRuntime(runtime) {
116
+ if (runtime.disposed)
117
+ return;
118
+ runtime.disposed = true;
119
+ cancelRuntimeTimer(runtime, 'longPressCancel');
120
+ cancelRuntimeTimer(runtime, 'pressDelayCancel');
121
+ cancelRuntimeTimer(runtime, 'pressOutCancel');
122
+ runtime.longPressFired = false;
123
+ runtime.pressOrigin = undefined;
124
+ runtime.driftedOut = false;
125
+ runtime.region = undefined;
126
+ runtime.active = false;
127
+ runtime.activatedAt = undefined;
128
+ runtime.delayElapsed = false;
129
+ }
99
130
  // Measure the responder's on-screen frame and cache it as the retention region for the life of
100
131
  // the press (RN measures on responder grant, _measureResponderRegion). When measure is
101
132
  // unavailable (no node yet, an uncommitted node, or a host slot without a measure method,
@@ -123,7 +154,7 @@ function measureRegion(runtime, measureFn) {
123
154
  // (the React adapter's useMemo body) lives here, shared by every adapter. The adapter rebuilds
124
155
  // these per render (the closures capture the live config) while the runtime persists across them.
125
156
  export function createPressHandlers(config, runtime, host) {
126
- const { onPress, onPressIn, onPressOut, onPressMove, onLongPress, delayLongPress, unstable_pressDelay, } = config;
157
+ const { onPress, onPressIn, onPressOut, onPressMove, onLongPress, delayLongPress, unstable_pressDelay, minPressDuration = DEFAULT_MIN_PRESS_DURATION_MS, } = config;
127
158
  // Per-edge offsets for the measured-rect retention test (RN's hitSlop + pressRectOffset).
128
159
  // pressRetentionOffset defaults to RN's DEFAULT_PRESS_RECT_OFFSETS when unset; hitSlop to zero.
129
160
  const hitSlopRect = normalizeRect(config.hitSlop);
@@ -145,20 +176,28 @@ export function createPressHandlers(config, runtime, host) {
145
176
  return (Math.hypot(point.x - origin.x, point.y - origin.y) <= fallbackThreshold);
146
177
  }
147
178
  function clearLongPress() {
148
- if (runtime.longPressCancel !== undefined) {
149
- runtime.longPressCancel();
150
- runtime.longPressCancel = undefined;
151
- }
179
+ cancelRuntimeTimer(runtime, 'longPressCancel');
180
+ }
181
+ function clearPressDelay() {
182
+ cancelRuntimeTimer(runtime, 'pressDelayCancel');
183
+ }
184
+ function clearPressOut() {
185
+ cancelRuntimeTimer(runtime, 'pressOutCancel');
152
186
  }
153
- // The real activation: flip pressed-state on, arm the long-press timer, fire onPressIn. Split
154
- // out so unstable_pressDelay can defer it behind a timer while an early release flushes it.
155
187
  function activate(event) {
188
+ if (runtime.disposed || runtime.active || runtime.driftedOut)
189
+ return;
190
+ // A new grant or drift-back-in supersedes a delayed out from the previous active interval.
191
+ clearPressOut();
192
+ runtime.active = true;
193
+ runtime.activatedAt = host.now();
156
194
  dlog('Pressable pressIn');
157
195
  host.setPressed(true);
158
- runtime.longPressFired = false;
159
- if (onLongPress) {
196
+ if (!runtime.longPressFired && onLongPress) {
160
197
  runtime.longPressCancel = host.schedule(() => {
161
198
  runtime.longPressCancel = undefined;
199
+ if (runtime.disposed || !runtime.active || runtime.driftedOut)
200
+ return;
162
201
  runtime.longPressFired = true;
163
202
  dlog('Pressable longPress timer fired');
164
203
  onLongPress(event);
@@ -166,51 +205,99 @@ export function createPressHandlers(config, runtime, host) {
166
205
  }
167
206
  onPressIn?.(event);
168
207
  }
169
- // Flush a still-pending pressDelay timer immediately, so a pressOut/press that arrives before
170
- // the delay elapsed still sees an activated press.
171
- function flushPressDelay(event) {
172
- if (runtime.pressDelayCancel !== undefined) {
173
- runtime.pressDelayCancel();
174
- runtime.pressDelayCancel = undefined;
175
- activate(event);
208
+ function deactivate(event) {
209
+ if (runtime.disposed || !runtime.active)
210
+ return;
211
+ runtime.active = false;
212
+ clearLongPress();
213
+ const activatedAt = runtime.activatedAt ?? host.now();
214
+ runtime.activatedAt = undefined;
215
+ const heldFor = Math.max(0, host.now() - activatedAt);
216
+ const wait = Math.max(minPressDuration - heldFor, 0);
217
+ const finish = () => {
218
+ runtime.pressOutCancel = undefined;
219
+ if (runtime.disposed)
220
+ return;
221
+ host.setPressed(false);
222
+ onPressOut?.(event);
223
+ };
224
+ if (wait > 0) {
225
+ dlog(`Pressable pressOut deferred ${wait}ms`);
226
+ runtime.pressOutCancel = host.schedule(finish, wait);
227
+ }
228
+ else {
229
+ finish();
176
230
  }
177
231
  }
232
+ // An honest release arrives as press then pressOut. If it beat delayPressIn, activate now so the
233
+ // tap still flashes and calls onPressIn; a cancelled/out-of-bounds gesture never calls this path.
234
+ function completePressDelay(event) {
235
+ if (runtime.pressDelayCancel !== undefined)
236
+ clearPressDelay();
237
+ if (!runtime.delayElapsed)
238
+ runtime.delayElapsed = true;
239
+ if (!runtime.driftedOut)
240
+ activate(event);
241
+ }
242
+ function resetGesture() {
243
+ runtime.pressOrigin = undefined;
244
+ runtime.region = undefined;
245
+ runtime.driftedOut = false;
246
+ runtime.delayElapsed = false;
247
+ runtime.longPressFired = false;
248
+ }
178
249
  return {
179
250
  handlePressIn(event) {
251
+ if (runtime.disposed)
252
+ return;
253
+ // A new grant cancels a delayed out from the prior gesture, matching Pressability's
254
+ // onResponderGrant -> _cancelPressOutDelayTimeout.
255
+ clearPressDelay();
256
+ clearLongPress();
257
+ clearPressOut();
258
+ runtime.active = false;
259
+ runtime.activatedAt = undefined;
260
+ runtime.longPressFired = false;
261
+ runtime.delayElapsed = false;
180
262
  runtime.pressOrigin = readPoint(event);
181
263
  runtime.driftedOut = false;
182
- // Measure now so the move stream tests against the real frame, not a press-start radius.
183
264
  measureRegion(runtime, host.getMeasureFn());
184
265
  if (unstable_pressDelay > 0) {
185
266
  dlog(`Pressable pressIn deferred ${unstable_pressDelay}ms`);
186
267
  runtime.pressDelayCancel = host.schedule(() => {
187
268
  runtime.pressDelayCancel = undefined;
188
- activate(event);
269
+ if (runtime.disposed)
270
+ return;
271
+ runtime.delayElapsed = true;
272
+ if (!runtime.driftedOut)
273
+ activate(event);
189
274
  }, unstable_pressDelay);
190
275
  return;
191
276
  }
277
+ runtime.delayElapsed = true;
192
278
  activate(event);
193
279
  },
194
280
  handlePressOut(event) {
281
+ if (runtime.disposed)
282
+ return;
195
283
  dlog('Pressable pressOut');
196
- flushPressDelay(event);
284
+ // A cancellation before delayPressIn must stay inert. An honest release already flushed the
285
+ // delay in handlePress, because the engine emits press immediately before pressOut.
286
+ clearPressDelay();
197
287
  clearLongPress();
198
- runtime.pressOrigin = undefined;
199
- runtime.region = undefined;
200
- host.setPressed(false);
201
- onPressOut?.(event);
288
+ deactivate(event);
289
+ resetGesture();
202
290
  },
203
291
  handlePress(event) {
292
+ if (runtime.disposed)
293
+ return;
204
294
  dlog('Pressable press');
205
- flushPressDelay(event);
295
+ completePressDelay(event);
206
296
  clearLongPress();
207
- // A drift past the retention region cancels the tap (RN); reset and suppress.
208
297
  if (runtime.driftedOut) {
209
- runtime.driftedOut = false;
210
298
  dlog('Pressable press suppressed by drift past retention region');
211
299
  return;
212
300
  }
213
- // A fired long press cancels the tap: reset the flag and suppress onPress.
214
301
  if (runtime.longPressFired) {
215
302
  runtime.longPressFired = false;
216
303
  dlog('Pressable press suppressed by prior longPress');
@@ -218,11 +305,9 @@ export function createPressHandlers(config, runtime, host) {
218
305
  }
219
306
  onPress?.(event);
220
307
  },
221
- // Responder-move stream of the Pressable's own View. onPressMove fires on every move while
222
- // the press is live (RN). Then the retention test: a move outside the measured region
223
- // (expanded by hitSlop+pressRectOffset) fires an early pressOut and marks the tap suppressed;
224
- // a move back inside re-activates.
225
308
  handleResponderMove(event) {
309
+ if (runtime.disposed)
310
+ return;
226
311
  onPressMove?.(event);
227
312
  const here = readPoint(event);
228
313
  if (!here)
@@ -232,14 +317,15 @@ export function createPressHandlers(config, runtime, host) {
232
317
  dlog('Pressable drifted past retention region — deactivating');
233
318
  runtime.driftedOut = true;
234
319
  clearLongPress();
235
- host.setPressed(false);
236
- onPressOut?.(event);
320
+ // Before delayPressIn there was no activation, so there is no onPressOut to emit.
321
+ deactivate(event);
237
322
  }
238
323
  }
239
324
  else if (runtime.driftedOut) {
240
325
  dlog('Pressable returned inside retention region — reactivating');
241
326
  runtime.driftedOut = false;
242
- activate(event);
327
+ if (runtime.delayElapsed)
328
+ activate(event);
243
329
  }
244
330
  },
245
331
  };
@@ -1,4 +1,4 @@
1
- import type { IPlatformOSType, ISymbioteEvent, ITextStyle } from '@symbiote-native/engine';
1
+ import type { IMeasureOnSuccess, IMeasureInWindowOnSuccess, IMeasureLayoutOnSuccess, ISymbioteNode, IPlatformOSType, ISymbioteEvent, ITextStyle } from '@symbiote-native/engine';
2
2
  import type { IAccessibilityProps, IAriaProps } from '../accessibility-props';
3
3
  export type IInputMode = 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
4
4
  export type IEnterKeyHint = 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send';
@@ -53,6 +53,9 @@ export type IFoldedTextInputProps = {
53
53
  showSoftInputOnFocus: boolean | undefined;
54
54
  };
55
55
  export declare function resolveTextInputProps(input: ITextInputFoldInput): IFoldedTextInputProps;
56
+ export type ITextInputChangeEvent = ISymbioteEvent & {
57
+ text: string;
58
+ };
56
59
  export type ITextInputProps = IAccessibilityProps & IAriaProps & {
57
60
  value?: string;
58
61
  defaultValue?: string;
@@ -86,7 +89,7 @@ export type ITextInputProps = IAccessibilityProps & IAriaProps & {
86
89
  underlineColorAndroid?: string;
87
90
  inputAccessoryViewID?: string;
88
91
  style?: ITextStyle;
89
- onValueChange?: (text: string, event: ISymbioteEvent) => void;
92
+ onValueChange?: (event: ITextInputChangeEvent) => void;
90
93
  onFocus?: ITextInputEventHandler;
91
94
  onBlur?: ITextInputEventHandler;
92
95
  onEndEditing?: ITextInputEventHandler;
@@ -95,10 +98,15 @@ export type ITextInputProps = IAccessibilityProps & IAriaProps & {
95
98
  onSelectionChange?: ITextInputEventHandler;
96
99
  onContentSizeChange?: ITextInputEventHandler;
97
100
  };
101
+ export declare const TEXT_INPUT_CALLBACK_NAMES: readonly string[];
98
102
  export type ITextInputHandle = {
99
103
  focus(): void;
100
104
  blur(): void;
101
105
  clear(): void;
102
106
  isFocused(): boolean;
103
107
  setSelection(start: number, end: number): void;
108
+ measure(callback: IMeasureOnSuccess): void;
109
+ measureInWindow(callback: IMeasureInWindowOnSuccess): void;
110
+ measureLayout(relativeToNativeNode: ISymbioteNode | number, onSuccess: IMeasureLayoutOnSuccess, onFail?: () => void): void;
111
+ setNativeProps(nativeProps: Record<string, unknown>): void;
104
112
  };
@@ -230,3 +230,14 @@ export function resolveTextInputProps(input) {
230
230
  : input.showSoftInputOnFocus,
231
231
  };
232
232
  }
233
+ const TEXT_INPUT_CALLBACKS = {
234
+ onValueChange: true,
235
+ onFocus: true,
236
+ onBlur: true,
237
+ onEndEditing: true,
238
+ onSubmitEditing: true,
239
+ onKeyPress: true,
240
+ onSelectionChange: true,
241
+ onContentSizeChange: true,
242
+ };
243
+ export const TEXT_INPUT_CALLBACK_NAMES = Object.keys(TEXT_INPUT_CALLBACKS);
@@ -1,6 +1,7 @@
1
1
  // The defaults RN's Text.js applies before handing props to native, folded once for every adapter.
2
2
  //
3
- // RN does this unconditionally on the outer (non-virtual) path, Text.js:288-291:
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):
4
5
  //
5
6
  // processedProps.allowFontScaling = allowFontScaling !== false;
6
7
  // processedProps.ellipsizeMode = ellipsizeMode ?? 'tail';
@@ -1,4 +1,4 @@
1
- import type { ITextStyle, ISymbioteEvent } from '@symbiote-native/engine';
1
+ import type { ITextStyle, IViewStyle, 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;
@@ -16,4 +16,39 @@ export interface IButtonProps extends IAccessibilityProps, IAriaProps {
16
16
  }
17
17
  export declare const BUTTON_ACCESSIBILITY_ROLE = "button";
18
18
  export declare const buttonTextStyle: ITextStyle;
19
+ export declare const buttonViewStyle: IViewStyle;
20
+ /**
21
+ * The label style with `color` and `disabled` folded in.
22
+ *
23
+ * `color` tints the TEXT on iOS and the BUTTON on Android (Button.js:318-324), so on Android this
24
+ * ignores it — see `resolveButtonViewStyle`, which is where it lands there. `disabled` wins over
25
+ * `color` on both, because RN pushes `textDisabled` after the tint.
26
+ */
19
27
  export declare function resolveButtonTextStyle(color: string | undefined, disabled: boolean | undefined): ITextStyle;
28
+ /**
29
+ * The inner view's style with `color` and `disabled` folded in — `{}` on iOS in every combination,
30
+ * which is RN's own answer there and the reason the node looked droppable.
31
+ */
32
+ export declare function resolveButtonViewStyle(color: string | undefined, disabled: boolean | undefined): IViewStyle;
33
+ /**
34
+ * The label as it is rendered: UPPERCASE on Android (Button.js:352-353).
35
+ *
36
+ * RN also asserts the title is a string one line above. Not reproduced: the assert is a dev-only
37
+ * `invariant`, and every adapter here types `title: string`.
38
+ */
39
+ export declare function resolveButtonTitle(title: string): string;
40
+ /**
41
+ * Whether the button is disabled, which `aria-disabled` may decide on its own.
42
+ *
43
+ * Button.js:337-338 — `props.disabled != null ? props.disabled : accessibilityState.disabled`. The
44
+ * engine already folds `aria-disabled` into the committed `accessibilityState`, but that fold is a
45
+ * PAYLOAD fold: it cannot suppress the press or grey the label, which is what this is for.
46
+ */
47
+ export declare function resolveButtonDisabled(disabled: boolean | undefined, ariaDisabled: boolean | undefined, accessibilityState: {
48
+ disabled?: boolean;
49
+ } | undefined): boolean | undefined;
50
+ /**
51
+ * Button.js:356-359 — `'no'` becomes `'no-hide-descendants'`, so the label inside cannot take
52
+ * focus separately from the button that contains it.
53
+ */
54
+ export declare function resolveButtonImportantForAccessibility(value: IAccessibilityProps['importantForAccessibility']): IAccessibilityProps['importantForAccessibility'];
@@ -1,23 +1,112 @@
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.
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.
4
16
  const IOS_BUTTON_BLUE = '#007AFF';
5
- const IOS_DISABLED_GREY = '#cdcdcd';
17
+ const IOS_DISABLED_TEXT = '#cdcdcd';
18
+ const ANDROID_BUTTON_BLUE = '#2196F3';
19
+ const ANDROID_DISABLED_BACKGROUND = '#dfdfdf';
20
+ const ANDROID_DISABLED_TEXT = '#a1a1a1';
21
+ const ANDROID_TEXT = 'white';
22
+ const ANDROID_ELEVATION = 4;
23
+ const ANDROID_DISABLED_ELEVATION = 0;
24
+ const ANDROID_BORDER_RADIUS = 2;
25
+ const TEXT_MARGIN = 8;
26
+ const IOS_FONT_SIZE = 18;
27
+ const ANDROID_FONT_WEIGHT = '500';
6
28
  // RN's Button is accessibilityRole="button"; the role string is a native accessibility enum value.
7
29
  export const BUTTON_ACCESSIBILITY_ROLE = 'button';
30
+ // `styles.text` — the platform-invariant half plus the platform's own. RN spells the margin as
31
+ // MARGIN, not padding: the label pushes the button's edges outward rather than insetting itself,
32
+ // so a background (Android) or a tap target (both) is 16pt taller than the glyphs.
8
33
  export const buttonTextStyle = {
9
- color: IOS_BUTTON_BLUE,
10
34
  textAlign: 'center',
11
- padding: 8,
12
- fontSize: 18,
35
+ margin: TEXT_MARGIN,
36
+ ...Platform.select({
37
+ ios: { color: IOS_BUTTON_BLUE, fontSize: IOS_FONT_SIZE },
38
+ android: { color: ANDROID_TEXT, fontWeight: ANDROID_FONT_WEIGHT },
39
+ default: { color: IOS_BUTTON_BLUE, fontSize: IOS_FONT_SIZE },
40
+ }),
13
41
  };
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).
42
+ // `styles.button` — empty on iOS, the whole Material look on Android.
43
+ export const buttonViewStyle = Platform.select({
44
+ ios: {},
45
+ android: {
46
+ elevation: ANDROID_ELEVATION,
47
+ backgroundColor: ANDROID_BUTTON_BLUE,
48
+ borderRadius: ANDROID_BORDER_RADIUS,
49
+ },
50
+ default: {},
51
+ }) ?? {};
52
+ /**
53
+ * The label style with `color` and `disabled` folded in.
54
+ *
55
+ * `color` tints the TEXT on iOS and the BUTTON on Android (Button.js:318-324), so on Android this
56
+ * ignores it — see `resolveButtonViewStyle`, which is where it lands there. `disabled` wins over
57
+ * `color` on both, because RN pushes `textDisabled` after the tint.
58
+ */
16
59
  export function resolveButtonTextStyle(color, disabled) {
17
60
  const style = { ...buttonTextStyle };
18
- if (color !== undefined)
61
+ if (color !== undefined && Platform.OS !== 'android')
19
62
  style.color = color;
20
- if (disabled === true)
21
- style.color = IOS_DISABLED_GREY;
63
+ if (disabled === true) {
64
+ style.color =
65
+ Platform.OS === 'android' ? ANDROID_DISABLED_TEXT : IOS_DISABLED_TEXT;
66
+ }
67
+ return style;
68
+ }
69
+ /**
70
+ * The inner view's style with `color` and `disabled` folded in — `{}` on iOS in every combination,
71
+ * which is RN's own answer there and the reason the node looked droppable.
72
+ */
73
+ export function resolveButtonViewStyle(color, disabled) {
74
+ if (Platform.OS !== 'android')
75
+ return buttonViewStyle;
76
+ const style = { ...buttonViewStyle };
77
+ if (color !== undefined)
78
+ style.backgroundColor = color;
79
+ if (disabled === true) {
80
+ style.elevation = ANDROID_DISABLED_ELEVATION;
81
+ style.backgroundColor = ANDROID_DISABLED_BACKGROUND;
82
+ }
22
83
  return style;
23
84
  }
85
+ /**
86
+ * The label as it is rendered: UPPERCASE on Android (Button.js:352-353).
87
+ *
88
+ * RN also asserts the title is a string one line above. Not reproduced: the assert is a dev-only
89
+ * `invariant`, and every adapter here types `title: string`.
90
+ */
91
+ export function resolveButtonTitle(title) {
92
+ return Platform.OS === 'android' ? title.toUpperCase() : title;
93
+ }
94
+ /**
95
+ * Whether the button is disabled, which `aria-disabled` may decide on its own.
96
+ *
97
+ * Button.js:337-338 — `props.disabled != null ? props.disabled : accessibilityState.disabled`. The
98
+ * engine already folds `aria-disabled` into the committed `accessibilityState`, but that fold is a
99
+ * PAYLOAD fold: it cannot suppress the press or grey the label, which is what this is for.
100
+ */
101
+ export function resolveButtonDisabled(disabled, ariaDisabled, accessibilityState) {
102
+ if (disabled !== undefined)
103
+ return disabled;
104
+ return ariaDisabled ?? accessibilityState?.disabled;
105
+ }
106
+ /**
107
+ * Button.js:356-359 — `'no'` becomes `'no-hide-descendants'`, so the label inside cannot take
108
+ * focus separately from the button that contains it.
109
+ */
110
+ export function resolveButtonImportantForAccessibility(value) {
111
+ return value === 'no' ? 'no-hide-descendants' : value;
112
+ }
@@ -53,4 +53,6 @@ export type IImageViewProps = {
53
53
  referrerPolicy?: string;
54
54
  passthrough: Record<string, unknown>;
55
55
  };
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>;
56
58
  export declare function renderImage(view: IImageViewProps): IDescriptor;