@symbiote-native/components 0.4.0 → 1.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 (67) hide show
  1. package/README.md +30 -8
  2. package/build/accessibility-props.d.ts +11 -0
  3. package/build/accessibility-props.js +30 -117
  4. package/build/behaviors/image.d.ts +3 -0
  5. package/build/behaviors/image.js +123 -0
  6. package/build/behaviors/input-accessory-view.d.ts +3 -0
  7. package/build/behaviors/input-accessory-view.js +70 -0
  8. package/build/behaviors/pressable.d.ts +2 -0
  9. package/build/behaviors/pressable.js +310 -0
  10. package/build/behaviors/switch.d.ts +2 -0
  11. package/build/behaviors/switch.js +182 -0
  12. package/build/behaviors/text-input.d.ts +14 -0
  13. package/build/behaviors/text-input.js +291 -0
  14. package/build/bootstrap/index.js +1 -1
  15. package/build/component-names/index.android.js +23 -1
  16. package/build/component-names/index.ios.js +9 -1
  17. package/build/component-names/shared.d.ts +1 -1
  18. package/build/component-names/shared.js +40 -1
  19. package/build/descriptor.d.ts +8 -0
  20. package/build/descriptor.js +27 -0
  21. package/build/fold-host-bag.d.ts +15 -0
  22. package/build/fold-host-bag.js +99 -0
  23. package/build/index.d.ts +25 -12
  24. package/build/index.js +18 -6
  25. package/build/resolve-intrinsic.d.ts +7 -0
  26. package/build/resolve-intrinsic.js +49 -0
  27. package/build/state/pressable.d.ts +9 -0
  28. package/build/state/pressable.js +133 -37
  29. package/build/state/sticky-header-reducer.js +28 -5
  30. package/build/state/switch.js +1 -1
  31. package/build/state/text-input.d.ts +7 -1
  32. package/build/state/text-input.js +46 -8
  33. package/build/state/touchable.d.ts +30 -1
  34. package/build/state/touchable.js +94 -5
  35. package/build/state/virtualized-list-diagnostics.d.ts +31 -0
  36. package/build/state/virtualized-list-diagnostics.js +33 -0
  37. package/build/state/virtualized-list-reducer.d.ts +14 -0
  38. package/build/state/virtualized-list-reducer.js +179 -45
  39. package/build/state/virtualized-list.d.ts +5 -2
  40. package/build/state/virtualized-list.js +143 -34
  41. package/build/state-style.d.ts +15 -0
  42. package/build/state-style.js +47 -0
  43. package/build/text-props.d.ts +9 -0
  44. package/build/text-props.js +25 -0
  45. package/build/view/render-activity-indicator.js +37 -3
  46. package/build/view/render-image/index.d.ts +2 -0
  47. package/build/view/render-image/index.js +42 -5
  48. package/build/view/render-input-accessory-view.d.ts +2 -0
  49. package/build/view/render-input-accessory-view.js +41 -6
  50. package/build/view/render-keyboard-avoiding-view.d.ts +14 -2
  51. package/build/view/render-keyboard-avoiding-view.js +52 -6
  52. package/build/view/render-modal.js +4 -2
  53. package/build/view/render-pressable/index.js +3 -1
  54. package/build/view/render-scroll-sticky.js +1 -1
  55. package/build/view/render-scroll-view.js +9 -3
  56. package/build/view/render-switch.js +11 -2
  57. package/build/view/render-text-input.js +6 -2
  58. package/build/view/render-touchable-highlight.d.ts +11 -1
  59. package/build/view/render-touchable-highlight.js +11 -10
  60. package/build/view/render-touchable-native-feedback.js +5 -1
  61. package/host-primitives.cjs +380 -0
  62. package/host-primitives.d.cts +35 -0
  63. package/lowering-fixtures.cjs +259 -0
  64. package/lowering-fixtures.d.cts +17 -0
  65. package/package.json +42 -5
  66. package/specialize-state-style.cjs +219 -0
  67. package/specialize-state-style.d.cts +15 -0
@@ -9,7 +9,7 @@
9
9
  // carry. Instead we return a layout DESCRIPTION (which styles to apply and whether to nest)
10
10
  // and the adapter builds its own element tree with its children. Mirrors RN's
11
11
  // Libraries/Components/Keyboard/KeyboardAvoidingView.js inset/behavior logic.
12
- import { isRecord } from '@symbiote-native/engine';
12
+ import { AccessibilityInfo, dlog, isRecord, KEYBOARD_EVENT, Platform, } from '@symbiote-native/engine';
13
13
  // RN's default keyboardVerticalOffset (KeyboardAvoidingView.js).
14
14
  export const DEFAULT_VERTICAL_OFFSET = 0;
15
15
  // RN rounds nothing here; 'height' mode collapses flex so the shrunk height holds.
@@ -38,13 +38,51 @@ export function readLayoutFrame(layout) {
38
38
  return undefined;
39
39
  return { y, height };
40
40
  }
41
+ export function keyboardAvoidingEventNamesFor(os) {
42
+ if (os === 'ios')
43
+ return { show: KEYBOARD_EVENT.willShow, hide: KEYBOARD_EVENT.willHide };
44
+ return { show: KEYBOARD_EVENT.didShow, hide: KEYBOARD_EVENT.didHide };
45
+ }
46
+ // The iOS "Prefer Cross-Fade Transitions" setting, read once per mount and fed to computeInset.
47
+ //
48
+ // AccessibilityInfo's iOS getters REJECT when the native error callback fires. That is deliberate
49
+ // RN parity and the engine must keep it — but this caller runs at mount and nobody awaits it, so a
50
+ // rejection would surface as an unhandled promise. Five adapters would each need the same `.catch`,
51
+ // which is five chances to forget one, so the fallback lives here instead. A failed read and "the
52
+ // user does not prefer cross-fade" are indistinguishable to every caller, so false is the honest
53
+ // answer, not a swallowed error.
54
+ //
55
+ // The query is injectable purely so both branches are unit-testable without a native-module fake;
56
+ // adapters call it with no arguments.
57
+ export function readPrefersCrossFadeTransitions(query = () => AccessibilityInfo.prefersCrossFadeTransitions()) {
58
+ return query().catch((error) => {
59
+ dlog(`KeyboardAvoidingView: prefersCrossFadeTransitions read failed -> false (${String(error)})`);
60
+ return false;
61
+ });
62
+ }
41
63
  // RN's _relativeKeyboardHeight: how far up the view must move so it no longer overlaps the
42
64
  // keyboard. keyboardY is the keyboard's top edge minus the caller's vertical offset; the inset
43
65
  // is the overlap of the view's bottom past that edge, clamped at 0.
44
- export function computeInset(frame, keyboard, verticalOffset) {
66
+ export function computeInset(frame, keyboard, verticalOffset, options = {}) {
45
67
  if (frame === undefined || keyboard === undefined)
46
68
  return 0;
69
+ // With the iOS Prefer Cross-Fade Transitions setting on, the keyboard reports screenY as 0
70
+ // rather than its real top edge (RN:88-96). Run through the ordinary math that makes the overlap
71
+ // the view's ENTIRE y + height and pushes the content clean off screen, so RN answers 0.
72
+ const os = options.os ?? Platform.OS;
73
+ if (os === 'ios' &&
74
+ keyboard.screenY === 0 &&
75
+ options.prefersCrossFadeTransitions === true) {
76
+ return 0;
77
+ }
47
78
  const keyboardY = keyboard.screenY - verticalOffset;
79
+ // The 'height' fixpoint (RN:100-105). In this mode the wrapper is SHRUNK by the inset, so its
80
+ // next onLayout reports a frame shorter by exactly that much. Adding the previous inset back
81
+ // cancels the shrink; without it each subsequent keyboard event computes a smaller overlap and
82
+ // the view walks back down under the keyboard. A correction, not an accumulation.
83
+ if (options.behavior === 'height') {
84
+ return Math.max((options.previousInset ?? 0) + frame.y + frame.height - keyboardY, 0);
85
+ }
48
86
  return Math.max(frame.y + frame.height - keyboardY, 0);
49
87
  }
50
88
  // Map the behavior + effective inset onto the wrapper/inner styles and the nesting decision:
@@ -52,7 +90,7 @@ export function computeInset(frame, keyboard, verticalOffset) {
52
90
  // wrapper directly. 'height' shrinks the wrapper from its initial measured height (only while
53
91
  // the keyboard is up, matching RN).
54
92
  export function resolveKeyboardAvoidingLayout(params) {
55
- const { behavior, effectiveInset, initialHeight, style, contentContainerStyle } = params;
93
+ const { behavior, effectiveInset, initialHeight, style, contentContainerStyle, } = params;
56
94
  if (behavior === 'position') {
57
95
  return {
58
96
  kind: 'nested',
@@ -61,12 +99,20 @@ export function resolveKeyboardAvoidingLayout(params) {
61
99
  };
62
100
  }
63
101
  if (behavior === 'padding') {
64
- return { kind: 'wrapper', wrapperStyle: [style, { paddingBottom: effectiveInset }] };
102
+ return {
103
+ kind: 'wrapper',
104
+ wrapperStyle: [style, { paddingBottom: effectiveInset }],
105
+ };
65
106
  }
66
- if (behavior === 'height' && effectiveInset > 0 && initialHeight !== undefined) {
107
+ if (behavior === 'height' &&
108
+ effectiveInset > 0 &&
109
+ initialHeight !== undefined) {
67
110
  return {
68
111
  kind: 'wrapper',
69
- wrapperStyle: [style, { height: initialHeight - effectiveInset, flex: COLLAPSED_FLEX }],
112
+ wrapperStyle: [
113
+ style,
114
+ { height: initialHeight - effectiveInset, flex: COLLAPSED_FLEX },
115
+ ],
70
116
  };
71
117
  }
72
118
  return { kind: 'wrapper', wrapperStyle: style };
@@ -5,7 +5,7 @@
5
5
  // maps to the `symbiote-modal` intrinsic the host config routes to ModalHostView, wrapping a
6
6
  // full-screen container View that holds the user children (injected by the adapter). Shared
7
7
  // verbatim: React and Vue both bridge this Descriptor; the keep-alive state lives in state/modal.ts.
8
- import { dlog } from '@symbiote-native/engine';
8
+ import { dlog, } from '@symbiote-native/engine';
9
9
  import { el } from '../descriptor.js';
10
10
  // The full-screen box RN anchors the modal content in (Modal.js styles.container: [side]:0,
11
11
  // top:0, flex:1, backgroundColor:'white'). It is NOT position:absolute, it is a flex child that
@@ -45,7 +45,9 @@ export function renderModal(view) {
45
45
  backdropOverride,
46
46
  ];
47
47
  const resolvedPresentationStyle = view.presentationStyle ??
48
- (view.transparent === true ? PRESENTATION_OVER_FULL_SCREEN : PRESENTATION_FULL_SCREEN);
48
+ (view.transparent === true
49
+ ? PRESENTATION_OVER_FULL_SCREEN
50
+ : PRESENTATION_FULL_SCREEN);
49
51
  dlog('Modal visible -> committing ModalHostView(container View)');
50
52
  // collapsable:false keeps the container as a real shadow node (RN sets this so the wrapper is
51
53
  // never flattened away under the host). Empty structural children: the adapter injects the
@@ -8,7 +8,9 @@ import { dlog } from '@symbiote-native/engine';
8
8
  // disabled state even if the caller passed none (Pressable.js: disabled != null ? {...state,
9
9
  // disabled} : state). Untouched when disabled is unset.
10
10
  export function resolveDisabledAccessibilityState(accessibilityState, disabled) {
11
- return disabled !== undefined ? { ...accessibilityState, disabled } : accessibilityState;
11
+ return disabled !== undefined
12
+ ? { ...accessibilityState, disabled }
13
+ : accessibilityState;
12
14
  }
13
15
  // The 3 agnostic gating predicates behind the listener bag below. Angular has no bag to spread -
14
16
  // it binds these directly onto template event outputs (see adapters/angular/src/components/
@@ -32,7 +32,7 @@ export function readLayoutNumber(event, key) {
32
32
  // layoutY and tracks 1:1 until the next header pushes it off, while the inverted branch pins
33
33
  // at the viewport bottom (stickStartPoint) and tracks up to the collision point.
34
34
  export function computeStickyInterpolation(params) {
35
- const { measured, inverted, scrollViewHeight, layoutY, layoutHeight, nextHeaderLayoutY } = params;
35
+ const { measured, inverted, scrollViewHeight, layoutY, layoutHeight, nextHeaderLayoutY, } = params;
36
36
  const inputRange = [-1, 0];
37
37
  const outputRange = [0, 0];
38
38
  if (measured) {
@@ -26,7 +26,7 @@ export function resolveDecelerationRate(rate) {
26
26
  // select() types as `number | undefined`; the always-present `default` makes the
27
27
  // `??` fallback unreachable, but it narrows the return to a plain `number` (no cast).
28
28
  if (rate === 'normal')
29
- return Platform.select({ ios: 0.998, android: 0.985, default: 0.998 }) ?? 0.998;
29
+ return (Platform.select({ ios: 0.998, android: 0.985, default: 0.998 }) ?? 0.998);
30
30
  return Platform.select({ ios: 0.99, android: 0.9, default: 0.99 }) ?? 0.99;
31
31
  }
32
32
  // RN applies a base style to the scroll-view NODE itself, per axis (ScrollView.js
@@ -69,7 +69,12 @@ export function selectScrollIntrinsics(isHorizontal, contentContainerStyle) {
69
69
  const contentStyle = isHorizontal
70
70
  ? [contentContainerStyle, { flexDirection: 'row' }]
71
71
  : contentContainerStyle;
72
- return { scrollViewIntrinsic, contentIntrinsic, scrollViewBaseStyle, contentStyle };
72
+ return {
73
+ scrollViewIntrinsic,
74
+ contentIntrinsic,
75
+ scrollViewBaseStyle,
76
+ contentStyle,
77
+ };
73
78
  }
74
79
  // When sticky headers are active the scroll offset must reach the AnimatedValue; RN raises the scroll
75
80
  // event rate for it (ScrollView.js:1798): throttle 1 on the native driver (it can afford every frame),
@@ -81,7 +86,8 @@ export function resolveScrollForwarding(inputs) {
81
86
  // maintainVisibleContentPosition (and Android snapToAlignment) anchor against MOUNTED cell views;
82
87
  // Android Fabric view-flattens layout-only cells away, so RN keeps them as real views with
83
88
  // collapsableChildren={false} on the content container (ScrollView.js preserveChildren). No-op on iOS.
84
- const collapsableChildren = inputs.maintainVisibleContentPosition !== undefined || inputs.snapToAlignment !== undefined;
89
+ const collapsableChildren = inputs.maintainVisibleContentPosition !== undefined ||
90
+ inputs.snapToAlignment !== undefined;
85
91
  if (!inputs.hasStickyHeaders) {
86
92
  return {
87
93
  mode: 'plain',
@@ -8,13 +8,22 @@ import { dlog } from '@symbiote-native/engine';
8
8
  import { el } from '../descriptor.js';
9
9
  // RN rounds the iOS background pill to this radius when ios_backgroundColor is set.
10
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 = 'symbiote-switch-managed';
11
17
  // Fold ios_backgroundColor into the style, matching RN's iOS branch: it paints the
12
18
  // background that shows through the shrunken track. Untouched when unset, so a caller's own
13
19
  // backgroundColor wins by simply not passing ios_backgroundColor.
14
20
  function foldIosBackground(style, color) {
15
21
  if (color === undefined)
16
22
  return style;
17
- return [style, { backgroundColor: color, borderRadius: IOS_BACKGROUND_BORDER_RADIUS }];
23
+ return [
24
+ style,
25
+ { backgroundColor: color, borderRadius: IOS_BACKGROUND_BORDER_RADIUS },
26
+ ];
18
27
  }
19
28
  export function renderSwitch(view, platform) {
20
29
  dlog(`Switch render value=${view.value} disabled=${String(view.disabled)}`);
@@ -29,5 +38,5 @@ export function renderSwitch(view, platform) {
29
38
  thumbTintColor: view.thumbColor,
30
39
  style: foldIosBackground(view.style, view.ios_backgroundColor),
31
40
  };
32
- return el('symbiote-switch', props);
41
+ return el(SWITCH_MANAGED_INTRINSIC, props);
33
42
  }
@@ -8,8 +8,12 @@ import { dlog } from '@symbiote-native/engine';
8
8
  import { el } from '../descriptor.js';
9
9
  // One host element per native input class. Text carries the only non-trivial nesting elsewhere;
10
10
  // here the choice is binary and runtime (the `multiline` prop), so the module stays flat.
11
- const SINGLELINE_INTRINSIC = 'symbiote-text-input';
12
- const MULTILINE_INTRINSIC = 'symbiote-text-input-multiline';
11
+ // The `-managed` spelling, NOT the plain one the lowering transforms emit. Both resolve to the
12
+ // same native view; the tag is what decides whether the engine's TextInput machine attaches, and
13
+ // on this path the adapter's own lifecycle already owns that state. Sharing the tag would run both
14
+ // copies at once. Full reasoning in `component-names/shared.ts`.
15
+ const SINGLELINE_INTRINSIC = 'symbiote-text-input-managed';
16
+ const MULTILINE_INTRINSIC = 'symbiote-text-input-multiline-managed';
13
17
  export function renderTextInput(view) {
14
18
  const intrinsic = view.multiline ? MULTILINE_INTRINSIC : SINGLELINE_INTRINSIC;
15
19
  dlog(`TextInput render multiline=${String(view.multiline)} ` +
@@ -1,2 +1,12 @@
1
1
  import type { IViewStyle } from '@symbiote-native/engine';
2
- export declare function highlightPressedStyle<TStyle>(pressed: boolean, style: TStyle, underlayColor: string, activeOpacity: number): TStyle | [TStyle, IViewStyle];
2
+ export interface ITouchableHighlightExtraStyles {
3
+ underlay: IViewStyle;
4
+ child: IViewStyle;
5
+ }
6
+ export interface ITouchableHighlightUnderlayView {
7
+ shown: boolean;
8
+ hasPressHandler: boolean;
9
+ underlayColor?: string;
10
+ activeOpacity?: number;
11
+ }
12
+ export declare function resolveHighlightExtraStyles(view: ITouchableHighlightUnderlayView): ITouchableHighlightExtraStyles | undefined;
@@ -1,12 +1,13 @@
1
1
  // TouchableHighlight underlay: the shared render decision (framework-agnostic). RN drives the
2
- // highlight with setState (not Animated) — while pressed it paints underlayColor + lowers the child
3
- // opacity, at rest it is the bare style (TouchableHighlight.js). This 3-line gating was triplicated
4
- // verbatim across adapters; it lives here once, prop-driven. Each adapter passes its own resolved
5
- // style base (React the raw style, Angular the anchor+style array, Vue the untyped attrs style) plus
6
- // the live `pressed`, so the base is generic — the overlay is layered on top when pressed.
7
- export function highlightPressedStyle(pressed, style, underlayColor, activeOpacity) {
8
- if (!pressed)
9
- return style;
10
- const overlay = { backgroundColor: underlayColor, opacity: activeOpacity };
11
- return [style, overlay];
2
+ // highlight with setState, not Animated — while shown it paints underlayColor and lowers the child
3
+ // opacity; at rest it is the bare style (TouchableHighlight.js).
4
+ import { DEFAULT_HIGHLIGHT_CHILD_OPACITY, DEFAULT_UNDERLAY_COLOR, } from '../state/touchable.js';
5
+ // undefined = paint nothing extra, which is RN's `extraStyles: null` state.
6
+ export function resolveHighlightExtraStyles(view) {
7
+ if (!view.shown || !view.hasPressHandler)
8
+ return undefined;
9
+ return {
10
+ underlay: { backgroundColor: view.underlayColor ?? DEFAULT_UNDERLAY_COLOR },
11
+ child: { opacity: view.activeOpacity ?? DEFAULT_HIGHLIGHT_CHILD_OPACITY },
12
+ };
12
13
  }
@@ -14,7 +14,11 @@ export function canUseNativeForeground() {
14
14
  Platform.Version >= ANDROID_FOREGROUND_MIN_VERSION);
15
15
  }
16
16
  export function selectableBackground(rippleRadius) {
17
- return { type: 'ThemeAttrAndroid', attribute: 'selectableItemBackground', rippleRadius };
17
+ return {
18
+ type: 'ThemeAttrAndroid',
19
+ attribute: 'selectableItemBackground',
20
+ rippleRadius,
21
+ };
18
22
  }
19
23
  export function selectableBackgroundBorderless(rippleRadius) {
20
24
  return {