@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
package/README.md CHANGED
@@ -30,8 +30,7 @@ supplies only the third:
30
30
  bridges the `Descriptor` to `React.createElement`; Vue wires it through `ref`/`watch` and
31
31
  bridges to `h()`. This is the ONLY part a new adapter has to write.
32
32
 
33
- `Switch` is the canonical reference for a full three-layer component; `ActivityIndicator` is the
34
- canonical render-only reference (no state machine needed).
33
+ `Switch` is the canonical reference for a full three-layer component.
35
34
 
36
35
  ### Install
37
36
 
@@ -86,19 +85,33 @@ host node untouched — the render function never names a framework type.
86
85
  canonical `accessibility*` transform, shared so every adapter folds identically.
87
86
  - **Components with a full state + render split** — `Switch`, `Modal` (its reducer gates the iOS
88
87
  keep-alive frame).
89
- - **Render-only components** (no state machine) — `ActivityIndicator`, `Image`,
90
- `ImageBackground`, `InputAccessoryView`.
91
- - **Pure logic/plumbing without a `Descriptor`** — `Pressable`'s press state machine
92
- (`createPressHandlers` / `createPressRuntime`), the `Touchable*` timing constants,
93
- `Button`'s shared text-style fold, `TextInput`'s controlled-value/event-count handshake
94
- (`resolveTextInputProps`, `foldText`, `eventCountFromChange`, …), `KeyboardAvoidingView`'s inset
95
- math, `ScrollView`'s intrinsics/sticky-header math (no full 3-layer split — the adapter owns the
96
- element assembly).
88
+ - **Render-only components** (no state machine) — `Image`, `ImageBackground`,
89
+ `InputAccessoryView`.
90
+ - **Pure logic/plumbing without a full `Descriptor`** — `Pressable`'s press state machine
91
+ (`createPressHandlers` / `createPressRuntime` in `state/pressable`) plus its render-decision
92
+ helpers (`buildPressableListeners`, `resolveDisabledAccessibilityState`, `shouldClaimResponder`,
93
+ … in `view/render-pressable`), the `Touchable*` timing constants, `Button`'s shared text-style
94
+ fold, `TextInput`'s controlled-value/event-count handshake (`resolveTextInputProps`, `foldText`,
95
+ `eventCountFromChange`, …), `KeyboardAvoidingView`'s inset math, `ScrollView`'s
96
+ intrinsics/sticky-header math (no full 3-layer split — the adapter owns the element assembly).
97
+ - **Two more shared reducers, driven by every adapter's reactive glue**: the sticky-header effect
98
+ machine (`reduceSticky` / `createInitialStickyState`, the zero-swallow gate + debounce pick every
99
+ sticky-header consumer needs) and the list orchestration reducer (`reduceList` /
100
+ `createInitialListState`, the after-commit effect skeleton — window recompute → edge →
101
+ viewability → initial-scroll → MVCP — every list adapter runs).
97
102
  - **The `VirtualizedList` family's windowing engine** — `computeWindow`, `buildListPlan`,
98
103
  viewability tracking (`computeViewableSet`, `diffViewable`), and the `FlatList`/`SectionList`
99
104
  row/section folding helpers. Lists have no `view/render-*.ts` (a cell's content is the
100
105
  framework's own children) — the shared layer here is pure state/logic, reused verbatim by every
101
106
  adapter.
107
+ - **Host behaviors (`src/behaviors/*.ts`)** — tier-2 host-primitive lowering: a primitive's state
108
+ machine and prop folds registered directly on the engine node (`registerPressableBehavior`,
109
+ `registerSwitchBehavior`, `registerImageBehavior`, `registerTextInputBehavior`,
110
+ `registerInputAccessoryViewBehavior`, plus the folds a lowered element still needs —
111
+ `foldImagePayload`, `foldInputAccessoryViewPayload`, `buildTextInputHandle`) so a
112
+ `Pressable`/`Switch`/`TextInput`/`Image`/`InputAccessoryView` can compile to a bare intrinsic tag
113
+ instead of a framework component. Built on `@symbiote-native/engine`'s `registerHostBehavior`
114
+ seam; see `.claude/rules/host-primitive-tier.md`.
102
115
 
103
116
  ## What it does NOT do
104
117
 
@@ -61,4 +61,15 @@ export interface IAriaProps {
61
61
  'aria-valuenow'?: number;
62
62
  'aria-valuetext'?: string;
63
63
  }
64
+ /**
65
+ * The typed entry point adapters import. The FOLD itself now lives in `@symbiote-native/engine`
66
+ * (`core/engine/src/accessibility-props.ts`) so it runs at the layer every path goes through —
67
+ * including a LOWERED element, which has no component wrapper to run it. This function stays here
68
+ * because the public types do, and because it keeps the typed gate: `hasAnyAriaKey` is checked
69
+ * before anything is allocated, so the ~99% of nodes carrying no alias cost the same as before.
70
+ *
71
+ * Idempotent by construction, and that is load-bearing now that the engine folds too: pass 1 blanks
72
+ * every alias, so a wrapper still calling this after the engine has run finds nothing and returns
73
+ * by identity.
74
+ */
64
75
  export declare function resolveAccessibilityProps<T extends IAccessibilityProps & IAriaProps>(props: T): T;
@@ -8,43 +8,11 @@
8
8
  //
9
9
  // Framework-agnostic (imports only @symbiote-native/engine), so every adapter (React,
10
10
  // Vue, and the next) folds aria/role into accessibility* identically.
11
- import { dlog } from '@symbiote-native/engine';
12
- // RN's web `role` → native `accessibilityRole`. Where the web role has no native
13
- // counterpart it is forwarded unchanged (the AccessibilityRole union stays open),
14
- // so the map only lists the values that actually differ.
15
- const ROLE_TO_ACCESSIBILITY_ROLE = {
16
- alert: 'alert',
17
- button: 'button',
18
- checkbox: 'checkbox',
19
- combobox: 'combobox',
20
- grid: 'grid',
21
- heading: 'header',
22
- img: 'image',
23
- link: 'link',
24
- list: 'list',
25
- listitem: 'list',
26
- menu: 'menu',
27
- menubar: 'menubar',
28
- menuitem: 'menuitem',
29
- none: 'none',
30
- presentation: 'none',
31
- progressbar: 'progressbar',
32
- radio: 'radio',
33
- radiogroup: 'radiogroup',
34
- scrollbar: 'scrollbar',
35
- searchbox: 'search',
36
- slider: 'adjustable',
37
- spinbutton: 'spinbutton',
38
- summary: 'summary',
39
- switch: 'switch',
40
- tab: 'tab',
41
- tablist: 'tablist',
42
- timer: 'timer',
43
- toolbar: 'toolbar',
44
- };
45
- function accessibilityRoleFromRole(role) {
46
- return ROLE_TO_ACCESSIBILITY_ROLE[role] ?? role;
47
- }
11
+ import { foldAriaProps } from '@symbiote-native/engine';
12
+ // The gate's key list, typed against `IAriaProps` so a new alias added to that interface and
13
+ // forgotten here is a type error. The engine carries its own untyped copy for the lowered path;
14
+ // `core/engine/src/accessibility-props.ts` is the single implementation of the FOLD, this is only
15
+ // the cheap probe that decides whether to call it.
48
16
  const ARIA_KEYS = [
49
17
  'role',
50
18
  'aria-label',
@@ -62,90 +30,34 @@ const ARIA_KEYS = [
62
30
  'aria-valuenow',
63
31
  'aria-valuetext',
64
32
  ];
33
+ // An indexed loop rather than `.some(key => …)`: the callback captures `props`, so the closure is
34
+ // allocated on every call, and this runs once per accessibility-bearing component instance — 8 000
35
+ // of them on one benchmark create. The fifteen property reads it replaces the closure with are
36
+ // cheaper than the allocation.
65
37
  function hasAnyAriaKey(props) {
66
- return ARIA_KEYS.some(key => props[key] !== undefined);
38
+ for (let index = 0; index < ARIA_KEYS.length; index += 1) {
39
+ if (props[ARIA_KEYS[index]] !== undefined)
40
+ return true;
41
+ }
42
+ return false;
67
43
  }
68
- // Fold the web-alias `aria-*` / `role` props into the canonical `accessibility*`
69
- // props, mirroring RN's View.js transform. Canonical props take precedence per
70
- // View.js: each aria value fills in via `??` only where the canonical field is
71
- // still empty. The alias keys are blanked to `undefined` in the result so they
72
- // never reach native (the commit layer drops undefined props); the returned
73
- // object keeps type `T`, spreadable straight into createElement. When no alias
74
- // is present the input passes through untouched (cheap fast path).
44
+ /**
45
+ * The typed entry point adapters import. The FOLD itself now lives in `@symbiote-native/engine`
46
+ * (`core/engine/src/accessibility-props.ts`) so it runs at the layer every path goes through —
47
+ * including a LOWERED element, which has no component wrapper to run it. This function stays here
48
+ * because the public types do, and because it keeps the typed gate: `hasAnyAriaKey` is checked
49
+ * before anything is allocated, so the ~99% of nodes carrying no alias cost the same as before.
50
+ *
51
+ * Idempotent by construction, and that is load-bearing now that the engine folds too: pass 1 blanks
52
+ * every alias, so a wrapper still calling this after the engine has run finds nothing and returns
53
+ * by identity.
54
+ */
75
55
  export function resolveAccessibilityProps(props) {
76
56
  if (!hasAnyAriaKey(props))
77
57
  return props;
78
- dlog('resolveAccessibilityProps: folding aria/role aliases into accessibility* props');
79
- const { role, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-live': ariaLive, 'aria-hidden': ariaHidden, 'aria-busy': ariaBusy, 'aria-checked': ariaChecked, 'aria-disabled': ariaDisabled, 'aria-expanded': ariaExpanded, 'aria-selected': ariaSelected, 'aria-modal': ariaModal, 'aria-valuemax': ariaValueMax, 'aria-valuemin': ariaValueMin, 'aria-valuenow': ariaValueNow, 'aria-valuetext': ariaValueText, } = props;
80
- const next = {
81
- ...props,
82
- role: undefined,
83
- 'aria-label': undefined,
84
- 'aria-labelledby': undefined,
85
- 'aria-live': undefined,
86
- 'aria-hidden': undefined,
87
- 'aria-busy': undefined,
88
- 'aria-checked': undefined,
89
- 'aria-disabled': undefined,
90
- 'aria-expanded': undefined,
91
- 'aria-selected': undefined,
92
- 'aria-modal': undefined,
93
- 'aria-valuemax': undefined,
94
- 'aria-valuemin': undefined,
95
- 'aria-valuenow': undefined,
96
- 'aria-valuetext': undefined,
97
- };
98
- if (ariaLabelledBy !== undefined &&
99
- next.accessibilityLabelledBy === undefined) {
100
- next.accessibilityLabelledBy = ariaLabelledBy.split(/\s*,\s*/g);
101
- }
102
- if (ariaLabel !== undefined && next.accessibilityLabel === undefined) {
103
- next.accessibilityLabel = ariaLabel;
104
- }
105
- if (ariaLive !== undefined && next.accessibilityLiveRegion === undefined) {
106
- next.accessibilityLiveRegion = ariaLive === 'off' ? 'none' : ariaLive;
107
- }
108
- if (ariaHidden !== undefined) {
109
- if (next.accessibilityElementsHidden === undefined) {
110
- next.accessibilityElementsHidden = ariaHidden;
111
- }
112
- if (ariaHidden === true && next.importantForAccessibility === undefined) {
113
- next.importantForAccessibility = 'no-hide-descendants';
114
- }
115
- }
116
- if (ariaModal !== undefined && next.accessibilityViewIsModal === undefined) {
117
- next.accessibilityViewIsModal = ariaModal;
118
- }
119
- if (role !== undefined && next.accessibilityRole === undefined) {
120
- next.accessibilityRole = accessibilityRoleFromRole(role);
121
- }
122
- const existingState = props.accessibilityState;
123
- if (existingState !== undefined ||
124
- ariaBusy !== undefined ||
125
- ariaChecked !== undefined ||
126
- ariaDisabled !== undefined ||
127
- ariaExpanded !== undefined ||
128
- ariaSelected !== undefined) {
129
- next.accessibilityState = {
130
- busy: ariaBusy ?? existingState?.busy,
131
- checked: ariaChecked ?? existingState?.checked,
132
- disabled: ariaDisabled ?? existingState?.disabled,
133
- expanded: ariaExpanded ?? existingState?.expanded,
134
- selected: ariaSelected ?? existingState?.selected,
135
- };
136
- }
137
- const existingValue = props.accessibilityValue;
138
- if (existingValue !== undefined ||
139
- ariaValueMax !== undefined ||
140
- ariaValueMin !== undefined ||
141
- ariaValueNow !== undefined ||
142
- ariaValueText !== undefined) {
143
- next.accessibilityValue = {
144
- max: ariaValueMax ?? existingValue?.max,
145
- min: ariaValueMin ?? existingValue?.min,
146
- now: ariaValueNow ?? existingValue?.now,
147
- text: ariaValueText ?? existingValue?.text,
148
- };
149
- }
150
- return next;
58
+ // `Object.entries` rather than a spread: an interface has no index signature, so a spread of `T`
59
+ // is not assignable to `Record<string, unknown>` and the alternative would be a cast. Paid only
60
+ // on the folding path, never on the gate.
61
+ const folded = foldAriaProps(Object.fromEntries(Object.entries(props)));
62
+ return Object.assign({}, props, folded);
151
63
  }
@@ -0,0 +1 @@
1
+ export declare function registerActivityIndicatorBehavior(): void;
@@ -0,0 +1,16 @@
1
+ // ActivityIndicator's behavior on Android, where the spinner is `AndroidProgressBar` and needs two
2
+ // props RN's iOS branch never sends (`ActivityIndicator.js:106`, spread only at `:118`):
3
+ //
4
+ // styleAttr drives ProgressBar.setStyle(); without it the view throws "setStyle() not called"
5
+ // indeterminate the spinner has no determinate mode here
6
+ //
7
+ // and where the default colour is the THEME — expressed as null so the fold omits the key entirely
8
+ // rather than handing Fabric's colour parser a null it rejects.
9
+ import { registerActivityIndicatorBehaviors } from './shared.js';
10
+ const ANDROID_STYLE_ATTR = 'Normal';
11
+ export function registerActivityIndicatorBehavior() {
12
+ registerActivityIndicatorBehaviors({
13
+ defaultColor: null,
14
+ nativeExtras: { styleAttr: ANDROID_STYLE_ATTR, indeterminate: true },
15
+ });
16
+ }
@@ -0,0 +1,3 @@
1
+ export { registerActivityIndicatorBehavior } from './index.ios';
2
+ export { ACTIVITY_INDICATOR_SPINNER_TAG, ACTIVITY_INDICATOR_TAG, } from './shared';
3
+ export type { IActivityIndicatorProps, IActivityIndicatorSize } from './shared';
@@ -0,0 +1 @@
1
+ export declare function registerActivityIndicatorBehavior(): void;
@@ -0,0 +1,14 @@
1
+ // ActivityIndicator's behavior on iOS: `ActivityIndicatorView` takes the size enum and a GRAY
2
+ // default colour, and needs no extra native props. That is the entire platform half.
3
+ //
4
+ // This file is also the base the folder's `index.ts` re-exports for headless, matching every other
5
+ // platform-split module in this tree.
6
+ import { registerActivityIndicatorBehaviors } from './shared.js';
7
+ // RN's iOS default spinner colour (`ActivityIndicator.js:25`, GRAY).
8
+ const IOS_DEFAULT_COLOR = '#999999';
9
+ export function registerActivityIndicatorBehavior() {
10
+ registerActivityIndicatorBehaviors({
11
+ defaultColor: IOS_DEFAULT_COLOR,
12
+ nativeExtras: {},
13
+ });
14
+ }
@@ -0,0 +1,5 @@
1
+ // The base of the folder-as-module group: Metro picks `index.ios` / `index.android` per platform,
2
+ // and everything else (tsx, vitest, headless) lands here. iOS is the default, matching every other
3
+ // platform-split module in this tree.
4
+ export { registerActivityIndicatorBehavior } from './index.ios.js';
5
+ export { ACTIVITY_INDICATOR_SPINNER_TAG, ACTIVITY_INDICATOR_TAG, } from './shared.js';
@@ -0,0 +1,18 @@
1
+ import { type IStyleProp, type ISymbioteEvent, type IViewStyle } from '@symbiote-native/engine';
2
+ import type { IAccessibilityProps, IAriaProps } from '../../accessibility-props';
3
+ export declare const ACTIVITY_INDICATOR_TAG = "activity-indicator";
4
+ export declare const ACTIVITY_INDICATOR_SPINNER_TAG = "activity-indicator-spinner";
5
+ export type IActivityIndicatorSize = 'small' | 'large' | number;
6
+ export interface IActivityIndicatorProps extends IAccessibilityProps, IAriaProps {
7
+ animating?: boolean;
8
+ color?: string;
9
+ size?: IActivityIndicatorSize;
10
+ hidesWhenStopped?: boolean;
11
+ style?: IStyleProp<IViewStyle>;
12
+ onLayout?: (event: ISymbioteEvent) => void;
13
+ }
14
+ export type IActivityIndicatorPlatform = {
15
+ defaultColor: string | null;
16
+ nativeExtras: Readonly<Record<string, unknown>>;
17
+ };
18
+ export declare function registerActivityIndicatorBehaviors(platform: IActivityIndicatorPlatform): void;
@@ -0,0 +1,149 @@
1
+ // ActivityIndicator's host behavior: the composition and the prop fold, below the framework, so the
2
+ // primitive is a bare `activity-indicator` tag and not five wrapper components.
3
+ //
4
+ // THE TWO-NODE SHAPE IS RN'S, not ours to collapse. `ActivityIndicator.js:112` opens a centering
5
+ // `<View>` around the native spinner, so the tag is that View and `buildStructure` builds
6
+ // `activity-indicator-spinner` under it. The container cannot be folded INTO the spinner either: it
7
+ // carries `alignItems`/`justifyContent`, which centre the spinner inside the space it was given,
8
+ // and moved onto the spinner they would centre its children, of which it has none.
9
+ //
10
+ // THE PLATFORM HALF IS THE SPINNER'S DEFAULTS AND NOTHING ELSE. iOS defaults the colour to RN's GRAY
11
+ // and needs no extra native props; Android's default is the theme, which means OMITTING the key
12
+ // rather than sending null (Fabric's colour parser rejects a null), plus `styleAttr` and
13
+ // `indeterminate` — without the first, AndroidProgressBar throws "setStyle() not called".
14
+ // `index.ios` / `index.android` supply them, the same file split `behaviors/scroll-view` uses.
15
+ //
16
+ // WHERE THE APP'S PROPS GO. `slotPropsExcept` is the COMPLEMENT of a rename map: everything an app
17
+ // writes on the tag routes to the spinner under its own name except `ACTIVITY_INDICATOR_HOST_PROPS`,
18
+ // which is RN's own split (`ActivityIndicator.js:99` spreads `...restProps` onto the spinner; `:113`
19
+ // keeps `onLayout` and `style` on the View). The set that moves is OPEN — every aria alias, every
20
+ // accessibility prop, whatever an app writes next — so a name map cannot express it.
21
+ //
22
+ // THE SIZE TRANSLATION IS PLATFORM-INVARIANT and lives here beside the fold that applies it: RN maps
23
+ // 'small'/'large' to a native size enum AND a fixed box style, while a NUMBER never reaches native
24
+ // at all (it sizes the spinner through style alone).
25
+ //
26
+ // Registered by all five adapters since 2026-09-09, in the same commit that deleted the five
27
+ // wrappers — the registry is keyed by TAG, so registering while a wrapper still painted its own
28
+ // spinner would have given every indicator two.
29
+ import { appendChild, createElement, registerHostBehavior, } from '@symbiote-native/engine';
30
+ import { descriptorFor } from '../../component-names';
31
+ export const ACTIVITY_INDICATOR_TAG = 'activity-indicator';
32
+ // The NATIVE spinner — `ActivityIndicatorView` on iOS, `AndroidProgressBar` on Android. Built by
33
+ // `buildStructure` below and by nothing else; no app writes it.
34
+ export const ACTIVITY_INDICATOR_SPINNER_TAG = 'activity-indicator-spinner';
35
+ // Fixed pixel boxes RN gives the two named sizes (styles.sizeSmall/sizeLarge).
36
+ const SIZE_SMALL_PX = 20;
37
+ const SIZE_LARGE_PX = 36;
38
+ // RN's own default when the app writes no `size` (ActivityIndicator.js:72).
39
+ const DEFAULT_SIZE = 'small';
40
+ // Centering wrapper RN puts around the spinner (styles.container).
41
+ const CONTAINER_STYLE = {
42
+ alignItems: 'center',
43
+ justifyContent: 'center',
44
+ };
45
+ // The props that stay on the centering host instead of travelling to the spinner: RN's own two
46
+ // (`ActivityIndicator.js:113`) plus the spellings the ENGINE resolves against a node's own style.
47
+ const ACTIVITY_INDICATOR_HOST_PROPS = [
48
+ // A layout callback measures the box the spinner is centred IN, which is this node.
49
+ 'onLayout',
50
+ // The composed `StyleSheet.compose(styles.container, style)` array (`:114`).
51
+ 'style',
52
+ // A class NAME resolves to a style (`routeProp`'s class branch), so a class written on the tag has
53
+ // to reach the centering view — the node `style` lands on — or the app's rule paints a spinner it
54
+ // was never written for.
55
+ 'class',
56
+ 'className',
57
+ ];
58
+ function resolveSize(size) {
59
+ if (size === 'small') {
60
+ return {
61
+ sizeStyle: { width: SIZE_SMALL_PX, height: SIZE_SMALL_PX },
62
+ sizeProp: 'small',
63
+ };
64
+ }
65
+ if (size === 'large') {
66
+ return {
67
+ sizeStyle: { width: SIZE_LARGE_PX, height: SIZE_LARGE_PX },
68
+ sizeProp: 'large',
69
+ };
70
+ }
71
+ return { sizeStyle: { width: size, height: size } };
72
+ }
73
+ // The HOST's fold: RN's `StyleSheet.compose(styles.container, style)` (ActivityIndicator.js:114).
74
+ // Base first, so an app style still wins.
75
+ const hostFold = props => ({
76
+ ...props,
77
+ style: [CONTAINER_STYLE, props.style],
78
+ });
79
+ function isActivityIndicatorSize(value) {
80
+ return value === 'small' || value === 'large' || typeof value === 'number';
81
+ }
82
+ // The SPINNER's fold — RN's own body (`ActivityIndicator.js:99-118`) applied to the node the app
83
+ // never names.
84
+ function spinnerFold(platform) {
85
+ return props => {
86
+ const size = isActivityIndicatorSize(props.size)
87
+ ? props.size
88
+ : DEFAULT_SIZE;
89
+ const { sizeStyle, sizeProp } = resolveSize(size);
90
+ const next = {
91
+ ...props,
92
+ // RN defaults both to true and every wrapper spelled that `!== false`. A tag has no
93
+ // destructuring default, so the fold is where the default has to live.
94
+ animating: props.animating !== false,
95
+ hidesWhenStopped: props.hidesWhenStopped !== false,
96
+ style: sizeStyle,
97
+ };
98
+ // A NUMBER never reaches native: it sizes the spinner through style alone, and the native enum
99
+ // takes 'small'/'large' only. So the key has to leave, not merely go unwritten.
100
+ if (sizeProp === undefined)
101
+ delete next.size;
102
+ else
103
+ next.size = sizeProp;
104
+ const color = typeof props.color === 'string' ? props.color : platform.defaultColor;
105
+ // Omitted rather than sent as null — Android's theme default is null and Fabric's colour parser
106
+ // rejects one.
107
+ if (color === null)
108
+ delete next.color;
109
+ else
110
+ next.color = color;
111
+ return next;
112
+ };
113
+ }
114
+ // The composition. Returns the spinner as the slot because the prop redirect is gated on
115
+ // `childHost` being set — the redirect is what this slot is FOR, and NOT where children go: RN's
116
+ // ActivityIndicator renders only the spinner (`ActivityIndicator.js:112-118`) and takes no children
117
+ // at all. Hence `slotTakesNoChildren` below; without it a stray child would mount INSIDE the native
118
+ // spinner, which on Android is a `ProgressBar` and not a `ViewGroup` — the `addView` crash
119
+ // `IHostBehavior.slotTakesNoChildren` records for ImageBackground's Image.
120
+ function buildSpinner(platform) {
121
+ return (node) => {
122
+ const descriptor = descriptorFor(ACTIVITY_INDICATOR_SPINNER_TAG);
123
+ const spinner = createElement(descriptor.component, descriptor.isText, ACTIVITY_INDICATOR_SPINNER_TAG);
124
+ // Constants of the platform, never a function of a prop, so they are seeded at build time the
125
+ // way ScrollView seeds `collapsable: false` — empty on iOS, AndroidProgressBar's two
126
+ // requirements on Android.
127
+ spinner.props = { ...platform.nativeExtras };
128
+ spinner.payloadFold = spinnerFold(platform);
129
+ appendChild(node, spinner);
130
+ return spinner;
131
+ };
132
+ }
133
+ function activityIndicatorBehavior(platform) {
134
+ return {
135
+ slotPropsExcept: ACTIVITY_INDICATOR_HOST_PROPS,
136
+ slotTakesNoChildren: true,
137
+ buildStructure: buildSpinner(platform),
138
+ foldPayload: hostFold,
139
+ // Required by the interface and deliberately empty: this primitive owns no timer, no listener
140
+ // and no native handshake. Written out rather than shared with a `noop` so the emptiness reads
141
+ // as a decision.
142
+ attach() { },
143
+ detach() { },
144
+ };
145
+ }
146
+ // Called by the platform files; nothing else should.
147
+ export function registerActivityIndicatorBehaviors(platform) {
148
+ registerHostBehavior(ACTIVITY_INDICATOR_TAG, activityIndicatorBehavior(platform));
149
+ }
@@ -0,0 +1,2 @@
1
+ export declare const BUTTON_TAG = "button";
2
+ export declare function registerButtonBehavior(): void;