@symbiote-native/components 1.0.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 (83) hide show
  1. package/README.md +3 -4
  2. package/build/behaviors/activity-indicator/index.android.d.ts +1 -0
  3. package/build/behaviors/activity-indicator/index.android.js +16 -0
  4. package/build/behaviors/activity-indicator/index.d.ts +3 -0
  5. package/build/behaviors/activity-indicator/index.ios.d.ts +1 -0
  6. package/build/behaviors/activity-indicator/index.ios.js +14 -0
  7. package/build/behaviors/activity-indicator/index.js +5 -0
  8. package/build/behaviors/activity-indicator/shared.d.ts +18 -0
  9. package/build/behaviors/activity-indicator/shared.js +149 -0
  10. package/build/behaviors/button.d.ts +2 -0
  11. package/build/behaviors/button.js +328 -0
  12. package/build/behaviors/image-background.d.ts +2 -0
  13. package/build/behaviors/image-background.js +139 -0
  14. package/build/behaviors/image.d.ts +1 -1
  15. package/build/behaviors/image.js +2 -2
  16. package/build/behaviors/input-accessory-view.d.ts +1 -1
  17. package/build/behaviors/input-accessory-view.js +2 -2
  18. package/build/behaviors/pressable.d.ts +59 -1
  19. package/build/behaviors/pressable.js +93 -18
  20. package/build/behaviors/refresh-control.d.ts +2 -0
  21. package/build/behaviors/refresh-control.js +83 -0
  22. package/build/behaviors/scroll-view/index.android.d.ts +1 -0
  23. package/build/behaviors/scroll-view/index.android.js +52 -0
  24. package/build/behaviors/scroll-view/index.d.ts +2 -0
  25. package/build/behaviors/scroll-view/index.ios.d.ts +1 -0
  26. package/build/behaviors/scroll-view/index.ios.js +10 -0
  27. package/build/behaviors/scroll-view/index.js +5 -0
  28. package/build/behaviors/scroll-view/shared.d.ts +11 -0
  29. package/build/behaviors/scroll-view/shared.js +291 -0
  30. package/build/behaviors/scroll-view/sticky.d.ts +17 -0
  31. package/build/behaviors/scroll-view/sticky.js +568 -0
  32. package/build/behaviors/switch.d.ts +1 -1
  33. package/build/behaviors/switch.js +9 -5
  34. package/build/behaviors/text-input.d.ts +2 -2
  35. package/build/behaviors/text-input.js +43 -15
  36. package/build/behaviors/touchable-highlight.d.ts +9 -0
  37. package/build/behaviors/touchable-highlight.js +192 -0
  38. package/build/behaviors/touchable-native-feedback.d.ts +20 -0
  39. package/build/behaviors/touchable-native-feedback.js +333 -0
  40. package/build/behaviors/touchable-opacity.d.ts +12 -0
  41. package/build/behaviors/touchable-opacity.js +227 -0
  42. package/build/behaviors/touchable-without-feedback.d.ts +2 -0
  43. package/build/behaviors/touchable-without-feedback.js +296 -0
  44. package/build/component-names/index.android.js +29 -19
  45. package/build/component-names/index.ios.js +31 -19
  46. package/build/component-names/shared.d.ts +2 -1
  47. package/build/component-names/shared.js +16 -6
  48. package/build/descriptor.js +4 -4
  49. package/build/fold-host-bag.js +2 -2
  50. package/build/index.d.ts +17 -11
  51. package/build/index.js +44 -8
  52. package/build/register.d.ts +1 -0
  53. package/build/register.js +55 -0
  54. package/build/scroll-view-commands.d.ts +4 -0
  55. package/build/scroll-view-commands.js +30 -31
  56. package/build/state/text-input.d.ts +4 -1
  57. package/build/view/render-button.d.ts +36 -1
  58. package/build/view/render-button.js +101 -12
  59. package/build/view/render-image/index.js +2 -2
  60. package/build/view/render-input-accessory-view.js +1 -1
  61. package/build/view/render-modal.js +3 -3
  62. package/build/view/render-pressable/index.d.ts +2 -0
  63. package/build/view/render-pressable/index.js +24 -0
  64. package/build/view/render-scroll-view.d.ts +1 -0
  65. package/build/view/render-scroll-view.js +18 -9
  66. package/build/view/render-switch.d.ts +4 -1
  67. package/build/view/render-switch.js +2 -2
  68. package/build/view/render-text-input.js +2 -2
  69. package/build/view/render-touchable-native-feedback.d.ts +19 -0
  70. package/build/view/render-touchable-native-feedback.js +19 -0
  71. package/host-primitives.cjs +202 -150
  72. package/host-primitives.d.cts +0 -2
  73. package/package.json +8 -17
  74. package/build/state-style.d.ts +0 -15
  75. package/build/state-style.js +0 -47
  76. package/build/view/render-activity-indicator.d.ts +0 -25
  77. package/build/view/render-activity-indicator.js +0 -88
  78. package/build/view/render-image-background.d.ts +0 -9
  79. package/build/view/render-image-background.js +0 -48
  80. package/lowering-fixtures.cjs +0 -259
  81. package/lowering-fixtures.d.cts +0 -17
  82. package/specialize-state-style.cjs +0 -219
  83. package/specialize-state-style.d.cts +0 -15
@@ -0,0 +1,139 @@
1
+ // ImageBackground's host behavior: the composition and the prop split the wrapper component did,
2
+ // moved below the framework so the primitive can be a bare tag.
3
+ //
4
+ // THE TWO-NODE SHAPE IS RN'S. `ImageBackground.js:74-103` opens a `<View>` carrying the app's
5
+ // `style`, puts an absolutely-filled `<Image>` inside it, and lays the app's `{children}` AFTER
6
+ // that image so they paint on top. The lowered form is the same two nodes — `image-background`
7
+ // (an RCTView, the tag an app writes) with an RCTImageView built under it.
8
+ //
9
+ // WHY THE SLOT TAKES NO CHILDREN, which is the one thing this primitive needed that ScrollView,
10
+ // ActivityIndicator and Button did not. `childHost` answers two questions at once — which node an
11
+ // owner prop redirects onto, and which node the app's children go under — and those had the same
12
+ // answer for every primitive until this one. Here they differ: the image takes `imageStyle` and the
13
+ // whole `...props` spread, while the children belong beside it. `slotTakesNoChildren` is what
14
+ // splits them (`IHostBehavior`, and it is not a JSX nicety upstream could have collapsed — an
15
+ // Android `<Image>` is an `ImageView`, not a `ViewGroup`).
16
+ //
17
+ // WHERE THE APP'S PROPS GO. RN destructures `children, style, imageStyle, imageRef,
18
+ // importantForAccessibility, ...props` and spreads `...props` onto the Image
19
+ // (`ImageBackground.js:62-81`), so the set that moves is OPEN — every event, every accessibility
20
+ // prop, `testID`, `id`, whatever an app writes next — and only a complement can express it.
21
+ // `IMAGE_BACKGROUND_HOST_PROPS` is the short list that stays behind, and it is shorter than RN's:
22
+ // `importantForAccessibility` rides to the image alone, which is what all five wrappers did, so
23
+ // the tag and the component it replaces commit the same payload. The divergence from RN predates
24
+ // lowering and is unchanged by it.
25
+ //
26
+ // WHAT THE IMAGE'S FOLD OWES. Everything on the image arrives as a real prop write, so its payload
27
+ // is built by the shared `foldImagePayload` like any other `image`. Two things cannot arrive that
28
+ // way and are folded here: the style, which is DERIVED from the owner's own `style` (RN proxies the
29
+ // wrapper's width/height onto the image so it fills the box rather than collapsing to the source's
30
+ // intrinsic size), and `id`, whose rename to `nativeID` is applied per adapter on the tag THEY
31
+ // create and so never reaches a node a behavior built.
32
+ import { appendChild, createElement, flattenStyle, registerHostBehavior, } from '@symbiote-native/engine';
33
+ import { descriptorFor } from '../component-names';
34
+ import { foldImagePayload, IMAGE_TAG } from './image.js';
35
+ export const IMAGE_BACKGROUND_TAG = 'image-background';
36
+ // The inner Image's positioning: absolute-fill behind the box's children.
37
+ const ABSOLUTE_FILL = {
38
+ position: 'absolute',
39
+ left: 0,
40
+ right: 0,
41
+ top: 0,
42
+ bottom: 0,
43
+ };
44
+ // The props RN keeps on the wrapper View (`ImageBackground.js:74-78`), plus the two spellings of a
45
+ // class name — `routeProp`'s slot redirect runs above its own class branch, so an unlisted `class`
46
+ // would style the image instead of the box.
47
+ const IMAGE_BACKGROUND_HOST_PROPS = ['style', 'class', 'className'];
48
+ // The owner props the image's payload is derived from. Only `style`, because a class name is
49
+ // published INTO `node.props.style` by `pushClassStyle` — so a `class` write arrives here spelled
50
+ // `style` and the proxied width/height follow a class-declared box as well as an inline one.
51
+ const IMAGE_BACKGROUND_SLOT_DERIVED = ['style'];
52
+ // `imageStyle` is the wrapper's own name for the image's `style`. A bare class NAME is a legal
53
+ // value for it — every adapter's wrapper resolved one — and `routeProp` routes a string landing on
54
+ // `style` as `class` instead, so the registry resolves it on the image with nothing needed here.
55
+ const IMAGE_BACKGROUND_SLOT_PROPS = { imageStyle: 'style' };
56
+ // A StyleProp is an object, an array of them, or a registered class array — all of which
57
+ // `flattenStyle` already handles one layer down. The only thing to exclude is a scalar.
58
+ function styleOf(value) {
59
+ if (typeof value !== 'object' || value === null)
60
+ return undefined;
61
+ if (Array.isArray(value))
62
+ return value;
63
+ return { ...value };
64
+ }
65
+ // RN opts the wrapper out of iOS's Smart Invert for its whole subtree (`ImageBackground.js:75`) —
66
+ // a photograph inverted by accessibility settings is the case that prop exists for. None of the
67
+ // five wrappers ever wrote it, so this closes a standing gap rather than reproducing one.
68
+ const hostFold = props => ({
69
+ ...props,
70
+ accessibilityIgnoresInvertColors: true,
71
+ });
72
+ // Read one explicit dimension off the (already-flattened) box style. A dp number or a percentage
73
+ // string is a valid IDimensionValue; anything else (auto / undefined) yields undefined.
74
+ function readDimension(style, key) {
75
+ const value = Object.hasOwn(style, key) ? Reflect.get(style, key) : undefined;
76
+ if (typeof value === 'number' || typeof value === 'string')
77
+ return value;
78
+ return undefined;
79
+ }
80
+ function imageFold(owner) {
81
+ return props => {
82
+ // RN's own workaround, and its comment is worth reading before "simplifying" this
83
+ // (`ImageBackground.js:86-96`): an RN Image overwrites its own width/height from the source's
84
+ // intrinsic size, which fights the box's explicit dimensions, so they are proxied back on.
85
+ // Reads the OWNER's live style — a class name lands there too, published by `pushClassStyle`.
86
+ const box = flattenStyle(styleOf(owner.props.style));
87
+ const next = {
88
+ ...props,
89
+ // `imageStyle` last, so a caller still wins over the fill and the proxy.
90
+ style: [
91
+ ABSOLUTE_FILL,
92
+ {
93
+ width: readDimension(box, 'width'),
94
+ height: readDimension(box, 'height'),
95
+ },
96
+ styleOf(props.style),
97
+ ],
98
+ };
99
+ // Unconditional priority when both are set, matching RN (`View.js:77-79`) and `foldHostBag`.
100
+ // A raw `id` is a key no ViewConfig declares, so Fabric drops it and the nativeID is lost.
101
+ if (Object.hasOwn(next, 'id')) {
102
+ next.nativeID = next.id;
103
+ delete next.id;
104
+ }
105
+ return foldImagePayload(next);
106
+ };
107
+ }
108
+ // Returns the image, so `slotProps` / `slotPropsExcept` / `slotDerived` all point at it — and the
109
+ // app's children stay on the owner because of `slotTakesNoChildren`, not because of what this
110
+ // returns. The image is appended FIRST and nothing else is ever placed in front of it, which is
111
+ // what makes the children paint over it.
112
+ //
113
+ // Built WITHOUT handing `IMAGE_TAG` to `createElement`, deliberately: that would attach Image's own
114
+ // behavior and set `payloadFold` to `foldImagePayload` alone, and this node's style has to be
115
+ // derived from the owner. `payloadFold` is a single slot, so the composition is spelled here — and
116
+ // it still calls the one shared mapping rather than restating it.
117
+ function buildBackgroundImage(node) {
118
+ const descriptor = descriptorFor(IMAGE_TAG);
119
+ const image = createElement(descriptor.component, descriptor.isText);
120
+ image.payloadFold = imageFold(node);
121
+ appendChild(node, image);
122
+ return image;
123
+ }
124
+ const imageBackgroundBehavior = {
125
+ slotProps: IMAGE_BACKGROUND_SLOT_PROPS,
126
+ slotPropsExcept: IMAGE_BACKGROUND_HOST_PROPS,
127
+ slotDerived: IMAGE_BACKGROUND_SLOT_DERIVED,
128
+ slotTakesNoChildren: true,
129
+ buildStructure: buildBackgroundImage,
130
+ foldPayload: hostFold,
131
+ // Required by the interface and deliberately empty: this primitive owns no timer, no listener
132
+ // and no native handshake. Written out rather than shared with a `noop` so the emptiness reads
133
+ // as a decision.
134
+ attach() { },
135
+ detach() { },
136
+ };
137
+ export function registerImageBackgroundBehavior() {
138
+ registerHostBehavior(IMAGE_BACKGROUND_TAG, imageBackgroundBehavior);
139
+ }
@@ -1,3 +1,3 @@
1
- export declare const IMAGE_TAG = "symbiote-image";
1
+ export declare const IMAGE_TAG = "image";
2
2
  export declare function foldImagePayload(props: Readonly<Record<string, unknown>>): Record<string, unknown>;
3
3
  export declare function registerImageBehavior(): void;
@@ -10,7 +10,7 @@
10
10
  // hand and says so in its own header, because nothing was exported to call.
11
11
  //
12
12
  // WHY THIS MAY SHARE THE WRAPPER'S TAG, where TextInput needed `-managed`. A behavior fold is keyed
13
- // on the tag, and `renderImage` emits `symbiote-image` too — so on a wrapper-built node this fold
13
+ // on the tag, and `renderImage` emits `image` too — so on a wrapper-built node this fold
14
14
  // runs on ALREADY-FOLDED props. That is safe here and only here, because the mapping is idempotent:
15
15
  // every alias it consumes (`src`, `srcSet`, `alt`, `width`, `height`, …) is absent from its own
16
16
  // output, `source` comes back in the array shape `normalizeSource` guarantees, and
@@ -20,7 +20,7 @@
20
20
  // so the property has to be pinned or it is an accident waiting to be broken.
21
21
  import { registerHostBehavior, } from '@symbiote-native/engine';
22
22
  import { IMAGE_VIEW_PROP_NAMES, mapImageProps, } from '../view/render-image/index.js';
23
- export const IMAGE_TAG = 'symbiote-image';
23
+ export const IMAGE_TAG = 'image';
24
24
  const RESIZE_MODES = new Set([
25
25
  'cover',
26
26
  'contain',
@@ -1,3 +1,3 @@
1
- export declare const INPUT_ACCESSORY_VIEW_TAG = "symbiote-input-accessory-view";
1
+ export declare const INPUT_ACCESSORY_VIEW_TAG = "input-accessory-view";
2
2
  export declare function foldInputAccessoryViewPayload(props: Readonly<Record<string, unknown>>): Record<string, unknown>;
3
3
  export declare function registerInputAccessoryViewBehavior(): void;
@@ -15,7 +15,7 @@
15
15
  // nothing can end up with two owners on one node. Idempotence is asserted rather than reasoned.
16
16
  //
17
17
  // PLATFORM. This is the only primitive in its group that is not platform-invariant in what it
18
- // COMMITS TO: `symbiote-input-accessory-view` resolves to `RCTInputAccessoryView` on iOS and to a
18
+ // COMMITS TO: `input-accessory-view` resolves to `RCTInputAccessoryView` on iOS and to a
19
19
  // plain `RCTView` on Android. The fold itself is platform-invariant on purpose — it reproduces the
20
20
  // wrapper's mapping exactly, on both platforms, so the lowered and wrapped paths cannot diverge
21
21
  // per-platform. What it does NOT do is fix the pre-existing Android divergence underneath it:
@@ -26,7 +26,7 @@
26
26
  // make the two paths disagree.
27
27
  import { registerHostBehavior, } from '@symbiote-native/engine';
28
28
  import { INPUT_ACCESSORY_VIEW_PROP_NAMES, mapInputAccessoryViewProps, } from '../view/render-input-accessory-view.js';
29
- export const INPUT_ACCESSORY_VIEW_TAG = 'symbiote-input-accessory-view';
29
+ export const INPUT_ACCESSORY_VIEW_TAG = 'input-accessory-view';
30
30
  const CONSUMED = new Set(INPUT_ACCESSORY_VIEW_PROP_NAMES);
31
31
  function stringOf(value) {
32
32
  return typeof value === 'string' ? value : undefined;
@@ -1,2 +1,60 @@
1
- export declare const PRESSABLE_TAG = "symbiote-pressable";
1
+ import { type IHostBehavior, type ISymbioteNode } from '@symbiote-native/engine';
2
+ import { type IPressMachineConfig } from '../state/pressable';
3
+ import type { IAccessibilityStateValue } from '../accessibility-props';
4
+ export declare const PRESSABLE_TAG = "pressable";
5
+ /**
6
+ * A last look at the machine's config before its handlers are built, for a tag that IS a pressable
7
+ * plus something — TouchableOpacity, whose fade has to run between the machine and the app's own
8
+ * `onPressIn`.
9
+ *
10
+ * Called from `rebuild`, so once per gesture rather than once per mount: it sees the config the
11
+ * props actually hold by the time a finger lands, and anything it captures is discarded with the
12
+ * gesture. Per-node state that must OUTLIVE a gesture belongs on the caller's own WeakMap.
13
+ */
14
+ export type IPressConfigRefinement = (node: ISymbioteNode, config: IPressMachineConfig) => IPressMachineConfig;
15
+ /**
16
+ * What the machine reads as `disabled`, for a tag whose spelling of it is not the raw prop.
17
+ *
18
+ * There is no resolver by default because RN's Pressable hands Pressability the RAW prop
19
+ * (`Pressable.js:266`) — `aria-disabled` there changes only what is ANNOUNCED. Button is the one
20
+ * primitive that differs: it resolves `disabled ?? aria-disabled ?? accessibilityState.disabled` in
21
+ * the component and passes the ANSWER down as the touchable's own prop (`Button.js:337` -> `:386`),
22
+ * which a single tag has no second node to pass to.
23
+ *
24
+ * Reads the bag and returns the answer; it must never write one back. `resolveButtonDisabled`
25
+ * short-circuits on an authored `disabled`, so a resolved value stored in `node.props.disabled`
26
+ * would answer the NEXT resolution as if the app had written it and the tag could never re-enable.
27
+ */
28
+ export type IDisabledResolver = (props: Readonly<Record<string, unknown>>) => boolean | undefined;
29
+ export declare function booleanOr(value: unknown): boolean | undefined;
30
+ export declare function asAccessibilityState(value: unknown): IAccessibilityStateValue | undefined;
31
+ export declare function accessibleUnlessOptedOut(props: Readonly<Record<string, unknown>>): boolean;
32
+ /**
33
+ * The machine on `node`, reading its props and the app's callbacks off `options.source` when that
34
+ * is a different node.
35
+ *
36
+ * Exported for a behavior whose responder is not its own node — `./touchable-native-feedback`,
37
+ * whose tag commits nothing and adopts the app's single child as the responder. Every other caller
38
+ * goes through `createPressBehavior`, where source and node are the same.
39
+ *
40
+ * Re-callable on the same node: a second call replaces the state and the dispatchers, which is what
41
+ * a re-arm after `detachPressMachine` needs.
42
+ */
43
+ export declare function attachPressMachine(node: ISymbioteNode, options?: {
44
+ readonly refine?: IPressConfigRefinement;
45
+ readonly disabledOf?: IDisabledResolver;
46
+ readonly source?: ISymbioteNode;
47
+ }): void;
48
+ /** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
49
+ export declare function detachPressMachine(node: ISymbioteNode): void;
50
+ /**
51
+ * The press machine as behavior parts, so a tag that is a pressable PLUS something can compose it
52
+ * instead of re-implementing it.
53
+ *
54
+ * Spread into the caller's own behavior and wrap `attach`/`detach` around these — the touchable
55
+ * family needs a per-node Animated value opened before the machine and closed after it. The
56
+ * WeakMap holding the machine's own state is keyed by node, so one node may hold exactly one of
57
+ * these; a tag composing it therefore must not also register the plain `pressable` behavior.
58
+ */
59
+ export declare function createPressBehavior(refine?: IPressConfigRefinement, disabledOf?: IDisabledResolver): Pick<IHostBehavior, 'attach' | 'detach' | 'foldPayload' | 'ownedListeners'>;
2
60
  export declare function registerPressableBehavior(): void;
@@ -15,9 +15,9 @@
15
15
  // does NOT re-export. A bare `import './register';` sitting NEXT TO such a re-export does not work
16
16
  // either: Babel merges the two imports of one specifier and the merged dependency stays lazy.
17
17
  import { appListenerFor, dlog, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, } from '@symbiote-native/engine';
18
- import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS, rippleProps, } from '../state/pressable.js';
19
- import { buildPressableListeners, resolveDisabledAccessibilityState, } from '../view/render-pressable/index.js';
20
- export const PRESSABLE_TAG = 'symbiote-pressable';
18
+ import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_MIN_PRESS_DURATION_MS, rippleProps, } from '../state/pressable.js';
19
+ import { buildPressableListeners, resolveDisabledAccessibilityState, resolvePressableFocusable, } from '../view/render-pressable/index.js';
20
+ export const PRESSABLE_TAG = 'pressable';
21
21
  const states = new WeakMap();
22
22
  function isRecord(value) {
23
23
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -25,6 +25,12 @@ function isRecord(value) {
25
25
  function numberOr(value, fallback) {
26
26
  return typeof value === 'number' ? value : fallback;
27
27
  }
28
+ // The bag arrives as `unknown` off `node.props`; every RN default below is written against
29
+ // `boolean | undefined`. Exported to the sibling behaviors folding the same bag, and deliberately
30
+ // NOT to the shared barrel — same reasoning as `asAccessibilityState`.
31
+ export function booleanOr(value) {
32
+ return typeof value === 'boolean' ? value : undefined;
33
+ }
28
34
  // A scalar offset or the per-edge object; anything else reads as "no offset", which the machine
29
35
  // turns into RN's defaults. Same narrowing the component path does — kept here rather than shared
30
36
  // because the component's version narrows Vue attrs, and this one narrows engine props.
@@ -61,16 +67,17 @@ const MACHINE_ONLY_KEYS = [
61
67
  'disabled',
62
68
  'cancelable',
63
69
  'delayLongPress',
70
+ 'minPressDuration',
64
71
  'unstable_pressDelay',
65
72
  'pressRetentionOffset',
66
73
  'delayHoverIn',
67
74
  'delayHoverOut',
68
75
  ];
69
76
  // Narrowed field by field rather than cast: the bag arrives as `unknown` off `node.props`. A local
70
- // twin of the guard each adapter keeps for its own attrs (Vue's `asAccessibilityState`) — not
71
- // hoisted to the shared barrel, because every adapter re-exports that barrel wholesale and a
72
- // narrowing helper is not API anyone should be able to import.
73
- function asAccessibilityState(value) {
77
+ // twin of the guard each adapter keeps for its own attrs (Vue's `asAccessibilityState`) — exported
78
+ // to the sibling behaviors that fold the same bag, and deliberately NOT to the shared barrel, which
79
+ // every adapter re-exports wholesale: a narrowing helper is not API anyone should be able to import.
80
+ export function asAccessibilityState(value) {
74
81
  if (!isRecord(value))
75
82
  return undefined;
76
83
  const state = {};
@@ -135,8 +142,26 @@ function foldPayload(props) {
135
142
  // skipping undefined is a coincidence to lean on, not a contract to rely on here.
136
143
  if (resolved !== undefined)
137
144
  out.accessibilityState = resolved;
145
+ out.accessible = accessibleUnlessOptedOut(props);
146
+ // Pressable.js:258 — the plain form, with no press-handler or disabled leg. A Touchable composing
147
+ // this tag has already resolved its own three-leg formula and passed the answer in as `focusable`,
148
+ // which `!== false` leaves alone.
149
+ out.focusable = resolvePressableFocusable(booleanOr(props.focusable));
138
150
  return out;
139
151
  }
152
+ // RN makes every pressable accessible unless the app opts OUT — `Pressable.js:252`
153
+ // (`accessible: accessible !== false`), and the whole Touchable family repeats it verbatim
154
+ // (`TouchableOpacity.js:303`, `TouchableHighlight.js:337`). `!== false` rather than `?? true`, so
155
+ // only a literal `false` opts out and an explicit `undefined` still reads as accessible.
156
+ //
157
+ // Nothing in this repo did it until 2026-09-09, on either path, so a Pressable reached a screen
158
+ // reader as a plain view unless the app wrote the prop. Landing it in the fold above alone reddens
159
+ // four equivalence arms — correctly, since those compare the wrapper against the lowered path — so
160
+ // the behavior and every adapter's wrapper have to move in ONE change. Exported for the wrappers
161
+ // that need to say it themselves, and for the tags whose behavior is their only path.
162
+ export function accessibleUnlessOptedOut(props) {
163
+ return props.accessible !== false;
164
+ }
140
165
  // From the STASH, not from `node.props`. Every name below is in `ownedListeners`, so `routeProp`
141
166
  // diverts the app's `onPress` away from `node.listeners` (where it would evict the behavior's own
142
167
  // dispatcher) and into the stash — which makes the stash the only place it exists. Reading
@@ -158,6 +183,11 @@ function configFor(node) {
158
183
  onLongPress: callbackAt(node, 'longPress'),
159
184
  delayLongPress: numberOr(node.props.delayLongPress, DEFAULT_DELAY_LONG_PRESS_MS),
160
185
  unstable_pressDelay: numberOr(node.props.unstable_pressDelay, 0),
186
+ // RN's Touchables own the deactivation floor in their OWN machine and hand Pressability
187
+ // `minPressDuration: 0` (TouchableOpacity.js:195). While they were wrappers they passed it as
188
+ // an internal input; on the tag there is nowhere else to say it, so the floor has to be a
189
+ // readable prop or every Touchable holds its fade for the machine's 130 ms default.
190
+ minPressDuration: numberOr(node.props.minPressDuration, DEFAULT_MIN_PRESS_DURATION_MS),
161
191
  hitSlop: asRectOffset(node.props.hitSlop),
162
192
  pressRetentionOffset: asRectOffset(node.props.pressRetentionOffset),
163
193
  };
@@ -170,12 +200,21 @@ function configFor(node) {
170
200
  // once the props exist. A gesture is one interaction, so a handful of closures per press is
171
201
  // invisible — unlike doing it per prop write, which is the cost this whole tier exists to remove.
172
202
  function rebuild(node, state) {
173
- const handlers = createPressHandlers(configFor(node), state.runtime, state.host);
203
+ // `state.source` for everything READ, `node` for the refinement, which acts on the responder
204
+ // (dispatching a view command needs the committed node, not the one holding the props).
205
+ const source = state.source;
206
+ const base = configFor(source);
207
+ const handlers = createPressHandlers(state.refine === undefined ? base : state.refine(node, base), state.runtime, state.host);
174
208
  state.isBuilt = true;
209
+ // Re-read every gesture, so a tag whose resolver looks past `disabled` — `./button`, at
210
+ // `aria-disabled` — re-enables on the next touch instead of latching at its first answer.
211
+ const disabled = state.disabledOf === undefined
212
+ ? source.props.disabled
213
+ : state.disabledOf(source.props);
175
214
  state.listeners = buildPressableListeners(handlers, {
176
- disabled: node.props.disabled === true ? true : undefined,
177
- cancelable: typeof node.props.cancelable === 'boolean'
178
- ? node.props.cancelable
215
+ disabled: disabled === true ? true : undefined,
216
+ cancelable: typeof source.props.cancelable === 'boolean'
217
+ ? source.props.cancelable
179
218
  : undefined,
180
219
  });
181
220
  }
@@ -228,7 +267,24 @@ function installListeners(node, state) {
228
267
  setBehaviorListener(node, event, symbioteEvent => dispatch(node, state, key, [symbioteEvent]));
229
268
  }
230
269
  }
231
- function attach(node) {
270
+ function attachWith(refine, disabledOf) {
271
+ return node => attach(node, { refine, disabledOf });
272
+ }
273
+ /**
274
+ * The machine on `node`, reading its props and the app's callbacks off `options.source` when that
275
+ * is a different node.
276
+ *
277
+ * Exported for a behavior whose responder is not its own node — `./touchable-native-feedback`,
278
+ * whose tag commits nothing and adopts the app's single child as the responder. Every other caller
279
+ * goes through `createPressBehavior`, where source and node are the same.
280
+ *
281
+ * Re-callable on the same node: a second call replaces the state and the dispatchers, which is what
282
+ * a re-arm after `detachPressMachine` needs.
283
+ */
284
+ export function attachPressMachine(node, options = {}) {
285
+ attach(node, options);
286
+ }
287
+ function attach(node, options) {
232
288
  const timers = new Set();
233
289
  const runtime = createPressRuntime();
234
290
  const host = {
@@ -264,6 +320,9 @@ function attach(node) {
264
320
  const state = {
265
321
  runtime,
266
322
  host,
323
+ refine: options.refine,
324
+ disabledOf: options.disabledOf,
325
+ source: options.source ?? node,
267
326
  timers,
268
327
  listeners: {},
269
328
  isBuilt: false,
@@ -271,6 +330,10 @@ function attach(node) {
271
330
  states.set(node, state);
272
331
  installListeners(node, state);
273
332
  }
333
+ /** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
334
+ export function detachPressMachine(node) {
335
+ detach(node);
336
+ }
274
337
  function detach(node) {
275
338
  const state = states.get(node);
276
339
  if (state === undefined)
@@ -286,11 +349,18 @@ function detach(node) {
286
349
  states.delete(node);
287
350
  dlog('pressable behavior detached');
288
351
  }
289
- // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
290
- // same tag with an equivalent behavior must not double-install anything.
291
- export function registerPressableBehavior() {
292
- registerHostBehavior(PRESSABLE_TAG, {
293
- attach,
352
+ /**
353
+ * The press machine as behavior parts, so a tag that is a pressable PLUS something can compose it
354
+ * instead of re-implementing it.
355
+ *
356
+ * Spread into the caller's own behavior and wrap `attach`/`detach` around these — the touchable
357
+ * family needs a per-node Animated value opened before the machine and closed after it. The
358
+ * WeakMap holding the machine's own state is keyed by node, so one node may hold exactly one of
359
+ * these; a tag composing it therefore must not also register the plain `pressable` behavior.
360
+ */
361
+ export function createPressBehavior(refine, disabledOf) {
362
+ return {
363
+ attach: attachWith(refine, disabledOf),
294
364
  detach,
295
365
  foldPayload,
296
366
  // Every name the machine needs as an INPUT. The responder pair is not optional — it is how a
@@ -306,5 +376,10 @@ export function registerPressableBehavior() {
306
376
  'responderMove',
307
377
  'responderTerminationRequest',
308
378
  ],
309
- });
379
+ };
380
+ }
381
+ // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
382
+ // same tag with an equivalent behavior must not double-install anything.
383
+ export function registerPressableBehavior() {
384
+ registerHostBehavior(PRESSABLE_TAG, createPressBehavior());
310
385
  }
@@ -0,0 +1,2 @@
1
+ export declare const REFRESH_CONTROL_TAG = "refresh-control";
2
+ export declare function registerRefreshControlBehavior(): void;
@@ -0,0 +1,83 @@
1
+ // RefreshControl's machine, on the engine node instead of inside a framework component.
2
+ //
3
+ // WHAT THE FIVE WRAPPERS ACTUALLY DO, counted before writing this — the audit rule's instruction to
4
+ // grep the fold's OUTPUT rather than trust one wrapper. Four of the five (react, vue, solid,
5
+ // svelte) fold exactly `resolveAccessibilityProps` and forward, and that fold ALREADY runs in the
6
+ // engine at `fabricProps` on every path (the aria fold `SafeAreaView`'s spec entry cites). So there
7
+ // is nothing left for a `foldPayload` here to do, and this behavior deliberately declares none.
8
+ //
9
+ // The fifth is Angular, and it is the whole reason this file exists: it alone reproduces RN's
10
+ // CONTROLLED handshake (`RefreshControl.js:145-166`) — mirror what native last reported, and when
11
+ // the app's `refreshing` disagrees, command native back with `setNativeRefreshing`. React, Vue,
12
+ // Solid and Svelte have never had it, so a pull whose handler leaves `refreshing` false spins
13
+ // forever on four of five adapters. Moving it here closes that as a P0 parity gap rather than
14
+ // porting it four more times.
15
+ //
16
+ // ONE COMMAND NAME ON BOTH PLATFORMS — no `Platform.OS` branch, unlike Switch's snap-back
17
+ // (`setValue` / `setNativeValue`). RN sends `setNativeRefreshing` through both
18
+ // `PullToRefreshCommands` and `AndroidSwipeRefreshLayoutCommands` (`RefreshControl.js:152,157`).
19
+ //
20
+ // PLACEMENT IS NOT THIS FILE'S PROBLEM, though the tier audit once filed the primitive as
21
+ // impossible over it. iOS puts the control BESIDE the scroll view's content and Android makes it
22
+ // the scroll view's PARENT, and that decision belongs to the ScrollView, which now states it as
23
+ // data — `claimedChildren: { [REFRESH_CONTROL]: platform.claimMode }` in
24
+ // `behaviors/scroll-view/shared.ts`, honoured by the engine's `appendChild`. A claim is keyed on
25
+ // the child's FABRIC name and needs nothing from the child's own behavior, so the two are
26
+ // independent; `refresh-control-placement.test.ts` pins that both ways round.
27
+ //
28
+ // WHY THE DIVERGENCE CHECK IS DEFERRED A MICROTASK, and why `afterCommit` alone is not enough:
29
+ // `behaviors/switch.ts`'s module header, verbatim. Same shape, same two triggers, same reasons —
30
+ // an ACCEPTING app's state reaches `node.props` only after its own reconciliation, and a REJECTING
31
+ // app writes no prop at all, so the commit that `afterCommit` waits for never comes.
32
+ import { appListenerFor, dispatchViewCommand, dlog, registerHostBehavior, setBehaviorListener, } from '@symbiote-native/engine';
33
+ export const REFRESH_CONTROL_TAG = 'refresh-control';
34
+ // RN's own name for the command, sent to whichever of the two native views the platform resolved.
35
+ const SET_NATIVE_REFRESHING = 'setNativeRefreshing';
36
+ // What native LAST reported, absent until it has reported at all. Absent is not `false`: native is
37
+ // optimistic — it spins on the gesture before JS approves — so only a report can make the mirror
38
+ // authoritative, and an app that drives `refreshing` on its own initiative must never be corrected
39
+ // against a value native never claimed.
40
+ const reported = new WeakMap();
41
+ // Shared by both triggers — see the module header for why there are two.
42
+ function evaluateSnapBack(node) {
43
+ const lastNativeReport = reported.get(node);
44
+ if (lastNativeReport === undefined)
45
+ return; // no report yet, nothing to disagree with
46
+ const refreshing = node.props.refreshing === true;
47
+ if (lastNativeReport === refreshing) {
48
+ dlog(`RefreshControl behavior snap-back no-op refreshing=${refreshing}`);
49
+ return;
50
+ }
51
+ dlog(`RefreshControl behavior ${SET_NATIVE_REFRESHING} reported=${lastNativeReport} refreshing=${refreshing}`);
52
+ dispatchViewCommand(node, SET_NATIVE_REFRESHING, [refreshing]);
53
+ reported.set(node, refreshing);
54
+ }
55
+ function onRefresh(node, event) {
56
+ // Native has already started spinning by the time this arrives (RefreshControl.js:180), so the
57
+ // mirror moves BEFORE the app's handler runs — a handler that flips `refreshing` to true then
58
+ // agrees with it, and one that does nothing is what the deferred check corrects.
59
+ reported.set(node, true);
60
+ const listener = appListenerFor(node, 'refresh');
61
+ if (typeof listener === 'function')
62
+ listener(event);
63
+ queueMicrotask(() => evaluateSnapBack(node));
64
+ }
65
+ function attach(node) {
66
+ setBehaviorListener(node, 'refresh', event => onRefresh(node, event));
67
+ }
68
+ function detach(node) {
69
+ reported.delete(node);
70
+ }
71
+ // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
72
+ // same tag with an equivalent behavior must not double-install anything.
73
+ export function registerRefreshControlBehavior() {
74
+ registerHostBehavior(REFRESH_CONTROL_TAG, {
75
+ attach,
76
+ // Closes the case a microtask scheduled from `onRefresh` cannot: the app moves `refreshing` on
77
+ // its own while a past report is still unresolved. Costs nothing extra — it fires only on a
78
+ // commit that already changed something.
79
+ afterCommit: evaluateSnapBack,
80
+ detach,
81
+ ownedListeners: ['refresh'],
82
+ });
83
+ }
@@ -0,0 +1 @@
1
+ export declare function registerScrollViewBehavior(): void;
@@ -0,0 +1,52 @@
1
+ // ScrollView's behavior on Android, where a RefreshControl is not a child at all.
2
+ //
3
+ // An Android ScrollView holds exactly ONE child, so a sibling refresh control is an `addViewAt`
4
+ // crash rather than a layout mistake. RN inverts the tree instead: `AndroidSwipeRefreshLayout`
5
+ // WRAPS the scroll view, and the scroll view's style is split across the two boxes — layout on the
6
+ // wrapper's frame, visual on the scroller (`ScrollView.js:1856`). `nestedScrollEnabled` goes on the
7
+ // inner view so it consumes the gesture before the refresh parent sees it.
8
+ //
9
+ // The two folds below are what neither node can work out alone: the wrapper is the APP's node, so
10
+ // it carries whatever the app wrote on `<RefreshControl>` and knows nothing about the scroll view's
11
+ // style. RN reaches the same place through `cloneElement`, which likewise OVERRIDES the refresh
12
+ // control's own `style` — so replacing it here is parity, not a liberty.
13
+ import {} from '@symbiote-native/engine';
14
+ import { splitScrollViewStyle } from '../../scroll-view-commands.js';
15
+ import { ownerFold, registerScrollViewBehaviors, } from './shared.js';
16
+ // The owner under a wrap: the ordinary fold with the VISUAL half of its own style in place of the
17
+ // composed one. Delegating rather than restating is what keeps `decelerationRate`, `horizontal` and
18
+ // `nestedScrollEnabled` in ONE place — none of the three has anything to do with the wrap, and the
19
+ // hand-written copy this replaced had already lost the first of them.
20
+ function wrappedOwnerFold(base, horizontal) {
21
+ const plain = ownerFold(base, horizontal);
22
+ return props => ({
23
+ ...plain(props),
24
+ style: splitScrollViewStyle(base, props.style).inner,
25
+ });
26
+ }
27
+ // The wrapper: the LAYOUT half of the OWNER's style, read off the owner because that is where the
28
+ // app wrote it. Kept in step by `slotDerived` naming `style`, which marks the wrapper dirty on an
29
+ // owner style write.
30
+ function wrapperFold(owner, base) {
31
+ return props => ({
32
+ ...props,
33
+ style: splitScrollViewStyle(base, owner.props.style).outer,
34
+ });
35
+ }
36
+ const android = {
37
+ claimMode: 'wrap',
38
+ slotDerived: ['style'],
39
+ onWrapChange: (base, horizontal) => (owner, wrapper) => {
40
+ // Back to the ordinary composition, not to `undefined` — the plain fold carries the axis and
41
+ // the gesture props, which have nothing to do with the wrap.
42
+ owner.payloadFold =
43
+ wrapper === undefined
44
+ ? ownerFold(base, horizontal)
45
+ : wrappedOwnerFold(base, horizontal);
46
+ if (wrapper !== undefined)
47
+ wrapper.payloadFold = wrapperFold(owner, base);
48
+ },
49
+ };
50
+ export function registerScrollViewBehavior() {
51
+ registerScrollViewBehaviors(android);
52
+ }
@@ -0,0 +1,2 @@
1
+ export { registerScrollViewBehavior } from './index.ios';
2
+ export { HORIZONTAL_SCROLL_VIEW_TAG, REFRESH_CONTROL, SCROLL_VIEW_TAG, } from './shared';
@@ -0,0 +1 @@
1
+ export declare function registerScrollViewBehavior(): void;
@@ -0,0 +1,10 @@
1
+ // ScrollView's behavior on iOS: a RefreshControl is a SIBLING of the content view, rendered before
2
+ // it (`ScrollView.js:1844`). That is the whole platform half — a claim in `beside` mode, and the
3
+ // engine's placement rule already puts a claimed child before the slot.
4
+ //
5
+ // This file is also the base the folder's `index.ts` re-exports for headless, matching
6
+ // `render-scroll-view`'s own choice to make iOS the default for anything platform-branched.
7
+ import { registerScrollViewBehaviors } from './shared.js';
8
+ export function registerScrollViewBehavior() {
9
+ registerScrollViewBehaviors({ claimMode: 'beside' });
10
+ }
@@ -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 for the same reason
3
+ // `render-scroll-view`'s own `Platform.select` defaults to it.
4
+ export { registerScrollViewBehavior } from './index.ios.js';
5
+ export { HORIZONTAL_SCROLL_VIEW_TAG, REFRESH_CONTROL, SCROLL_VIEW_TAG, } from './shared.js';
@@ -0,0 +1,11 @@
1
+ import { type IClaimMode, type IHostBehavior, type IPayloadFold, type IViewStyle } from '@symbiote-native/engine';
2
+ export declare const SCROLL_VIEW_TAG = "scroll-view";
3
+ export declare const HORIZONTAL_SCROLL_VIEW_TAG = "horizontal-scroll-view";
4
+ export declare const REFRESH_CONTROL: string;
5
+ export declare function ownerFold(base: IViewStyle, horizontal: boolean): IPayloadFold;
6
+ export interface IScrollPlatform {
7
+ claimMode: IClaimMode;
8
+ onWrapChange?: (base: IViewStyle, horizontal: boolean) => IHostBehavior['onWrapChange'];
9
+ slotDerived?: readonly string[];
10
+ }
11
+ export declare function registerScrollViewBehaviors(platform: IScrollPlatform): void;