@symbiote-native/components 3.1.0 → 3.1.2
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.
- package/README.md +17 -18
- package/build/behaviors/activity-indicator/shared.js +29 -71
- package/build/behaviors/button.d.ts +0 -9
- package/build/behaviors/button.js +48 -234
- package/build/behaviors/image-background.js +21 -81
- package/build/behaviors/image.js +3 -10
- package/build/behaviors/input-accessory-view.js +10 -51
- package/build/behaviors/pressable.d.ts +0 -48
- package/build/behaviors/pressable.js +59 -149
- package/build/behaviors/scroll-view/index.android.js +10 -30
- package/build/behaviors/scroll-view/shared.js +55 -186
- package/build/behaviors/scroll-view/sticky.d.ts +0 -8
- package/build/behaviors/scroll-view/sticky.js +54 -142
- package/build/behaviors/text-input.d.ts +0 -8
- package/build/behaviors/text-input.js +57 -156
- package/build/behaviors/touchable-highlight.js +14 -54
- package/build/behaviors/touchable-native-feedback.js +9 -32
- package/build/behaviors/touchable-opacity.js +3 -18
- package/build/behaviors/touchable-without-feedback.js +6 -24
- package/build/bootstrap/index.d.ts +1 -0
- package/build/bootstrap/index.js +2 -1
- package/build/component-names/index.android.js +6 -8
- package/build/component-names/shared.js +12 -42
- package/build/index.js +13 -19
- package/build/scroll-view-commands.js +9 -17
- package/build/state/pressable.js +18 -43
- package/build/state/sticky-header-reducer.js +103 -149
- package/build/state/touchable.js +3 -5
- package/build/state/virtualized-list-reducer.js +21 -48
- package/build/state/virtualized-list.js +63 -148
- package/build/text-props.js +3 -13
- package/build/view/render-button.js +13 -53
- package/build/view/render-input-accessory-view.js +8 -24
- package/build/view/render-pressable/index.js +3 -4
- package/build/view/render-scroll-view.js +13 -23
- package/build/view/render-touchable-native-feedback.js +5 -14
- package/host-primitives.cjs +33 -207
- package/package.json +3 -3
|
@@ -1,35 +1,11 @@
|
|
|
1
|
-
// ImageBackground's host behavior
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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 that complement, and `importantForAccessibility` is IN it despite
|
|
22
|
-
// never being part of the spread: RN reapplies it explicitly to both nodes (`:76,82`), so it stays on
|
|
23
|
-
// the owner here too and the image gets its own copy derived from `ownerProps` (FIXED 2026-09-20 —
|
|
24
|
-
// it used to redirect to the image alone and never reach the owner at all, which the module's own
|
|
25
|
-
// comment rationalized as "the divergence from RN predates all of it").
|
|
26
|
-
//
|
|
27
|
-
// WHAT THE IMAGE'S FOLD OWES. Everything on the image arrives as a real prop write, so its payload
|
|
28
|
-
// is built by the shared `foldImagePayload` like any other `image`. Two things cannot arrive that
|
|
29
|
-
// way and are folded here: the style, which is DERIVED from the owner's own `style` (RN proxies the
|
|
30
|
-
// wrapper's width/height onto the image so it fills the box rather than collapsing to the source's
|
|
31
|
-
// intrinsic size), and `id`, whose rename to `nativeID` is applied per adapter on the tag THEY
|
|
32
|
-
// create and so never reaches a node a behavior built.
|
|
1
|
+
// ImageBackground's host behavior. RN's `ImageBackground.js:74-103` opens a `<View>` with the
|
|
2
|
+
// app's `style`, an absolutely-filled `<Image>` inside, children painted AFTER on top — this tag
|
|
3
|
+
// commits the same two nodes (`image-background` + its inner image).
|
|
4
|
+
// `childHost` normally answers both "where do owner props redirect" and "where do children go" —
|
|
5
|
+
// here they differ (image takes the spread, children stay on the owner), so
|
|
6
|
+
// `slotTakesNoChildren` splits them; not a JSX nicety, an Android `<Image>` is not a ViewGroup.
|
|
7
|
+
// RN spreads an OPEN prop set onto the Image (`ImageBackground.js:62-81`);
|
|
8
|
+
// `IMAGE_BACKGROUND_HOST_PROPS` below is the complement RN keeps on the wrapper instead.
|
|
33
9
|
import { appendChild, createElement, registerHostBehavior, } from '@symbiote-native/engine';
|
|
34
10
|
import { descriptorFor } from '../component-names';
|
|
35
11
|
import { IMAGE_TAG, registerImageBehavior } from './image.js';
|
|
@@ -37,12 +13,9 @@ export const IMAGE_BACKGROUND_TAG = 'image-background';
|
|
|
37
13
|
// The inner image's own tag. Distinct from `image` because the absolute fill must NOT reach a bare
|
|
38
14
|
// `<image>`, and a tag is the only thing a per-node rule can branch on.
|
|
39
15
|
export const IMAGE_BACKGROUND_IMAGE_TAG = 'image-background-image';
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// it out of `...props` and reapplies it explicitly to BOTH the wrapper (:76) and the image (:82), so
|
|
44
|
-
// it is never part of the spread — the engine derives the image's own copy from this node
|
|
45
|
-
// (`foldImageBackgroundImageProps` in `SymbioteFabricProps.cpp`), the same seam the box proxy uses.
|
|
16
|
+
// Kept on the wrapper (`ImageBackground.js:74-78`), plus both class spellings — the slot redirect
|
|
17
|
+
// runs above the class branch, so an unlisted `class` would style the image instead. RN reapplies
|
|
18
|
+
// `importantForAccessibility` to BOTH nodes (:76,82); the image gets its own derived copy.
|
|
46
19
|
const IMAGE_BACKGROUND_HOST_PROPS = [
|
|
47
20
|
'style',
|
|
48
21
|
'class',
|
|
@@ -56,49 +29,16 @@ const IMAGE_BACKGROUND_SLOT_DERIVED = ['style', 'importantForAccessibility'];
|
|
|
56
29
|
// value for it — every adapter's wrapper resolved one — and `routeProp` routes a string landing on
|
|
57
30
|
// `style` as `class` instead, so the registry resolves it on the image with nothing needed here.
|
|
58
31
|
const IMAGE_BACKGROUND_SLOT_PROPS = { imageStyle: 'style' };
|
|
59
|
-
// The owner's fold is GONE
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
// app writes `style` on the `<image-background>` and `IMAGE_BACKGROUND_HOST_PROPS` keeps it there.
|
|
70
|
-
// "A per-node rule cannot reach another node" is what this file used to say, and it was a fact about
|
|
71
|
-
// the JS FOLD rather than the engine — the tree is in C++, so `fabricProps` takes `ownerProps` from
|
|
72
|
-
// `node.parent` and the proxy reads it there.
|
|
73
|
-
//
|
|
74
|
-
// The `id -> nativeID` half went earlier still and was DEAD before this port: `foldIdAlias` applies
|
|
75
|
-
// to every tagged node and runs ahead of any fold, so by the time this ran the key was already
|
|
76
|
-
// renamed. Worth naming, because a fold that still spells a rule someone else now applies reads as
|
|
77
|
-
// load-bearing and is not.
|
|
78
|
-
//
|
|
79
|
-
// Contract: `core/engine/cpp/tests/js/image-background-image-payload.itest.ts`.
|
|
80
|
-
// Returns the image, so `slotProps` / `slotPropsExcept` / `slotDerived` all point at it — and the
|
|
81
|
-
// app's children stay on the owner because of `slotTakesNoChildren`, not because of what this
|
|
82
|
-
// returns. The image is appended FIRST and nothing else is ever placed in front of it, which is
|
|
83
|
-
// what makes the children paint over it.
|
|
84
|
-
//
|
|
85
|
-
// Built WITH `IMAGE_TAG` since 2026-09-18, which is the reverse of what it used to do and for a
|
|
86
|
-
// reason that reversed with it. It used to withhold the tag so the node would not get Image's
|
|
87
|
-
// `payloadFold` — a single slot this primitive needed for its own derived style — and call the
|
|
88
|
-
// shared mapping by hand at the end. The mapping is the ENGINE's now, reached off the tag, so the
|
|
89
|
-
// tag is how this node gets the platform half at all; the JS slot is free for the composition.
|
|
90
|
-
//
|
|
91
|
-
// ONE ORDERING DIFFERENCE FALLS OUT, and it is deliberate rather than overlooked. The image rule
|
|
92
|
-
// folds the image's own `width`/`height` PROPS under its style, and the background rule then layers
|
|
93
|
-
// the box's dimensions over that — where RN nests it the other way (`ImageBackground.js:83-98` puts
|
|
94
|
-
// the props under the proxied box size). So when an app sets BOTH a `width` prop on the
|
|
95
|
-
// ImageBackground and a conflicting width in its `style`, RN gives the style's and we give the
|
|
96
|
-
// prop's.
|
|
97
|
-
//
|
|
98
|
-
// It is left this way rather than reproduced: RN's own comment calls that nesting a "Temporary
|
|
99
|
-
// Workaround" for an Image that overwrites its own dimensions, and an explicit prop winning over an
|
|
100
|
-
// inherited box is the less surprising of the two. Pinned in
|
|
101
|
-
// `core/components/src/behaviors/image-background.test.ts` so it stays a decision.
|
|
32
|
+
// The owner's fold is GONE: its one job (`accessibilityIgnoresInvertColors: true`) is
|
|
33
|
+
// `foldImageBackgroundProps` in C++ now (`image-background-payload.itest.ts`).
|
|
34
|
+
// The image's fold is GONE too — `foldImageBackgroundImageProps` in C++, reading `ownerProps`
|
|
35
|
+
// from `node.parent` for the style proxy (`image-background-image-payload.itest.ts`).
|
|
36
|
+
// Returns the image, so `slotProps`/`slotPropsExcept`/`slotDerived` point at it; children stay on
|
|
37
|
+
// the owner via `slotTakesNoChildren`. Built WITH `IMAGE_TAG` so the engine's Image mapping still
|
|
38
|
+
// applies to it — the JS slot is free for composition only.
|
|
39
|
+
// ONE ORDERING DIFFERENCE, deliberate: the image rule folds its own `width`/`height` under its
|
|
40
|
+
// style, then the box's dimensions layer over that — RN nests it the other way
|
|
41
|
+
// (`ImageBackground.js:83-98`). Pinned in `image-background.test.ts` so it stays a decision.
|
|
102
42
|
//
|
|
103
43
|
// THE TAG IS ITS OWN, and that is what the C++ port needed. The node used to carry plain `image`,
|
|
104
44
|
// which is right for everything the ordinary image rule does and wrong for the fill — a bare
|
package/build/behaviors/image.js
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
// Image's host behavior
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// WHERE THE FOLD WENT: `foldImageProps` in `SymbioteFabricProps.cpp`, with
|
|
6
|
-
// `core/engine/cpp/tests/js/image-payload.itest.ts` as its contract. The `srcSet`/`src`/`source`
|
|
7
|
-
// precedence, the W3C header decoration, the `width`/`height` fold into style, `alt` becoming
|
|
8
|
-
// `accessibilityLabel` + `accessible`, `resizeMode`/`tintColor` falling back to style keys, and
|
|
9
|
-
// `loadingIndicatorSource` being plucked down to a bare uri — all of it is a function of the tag,
|
|
10
|
-
// which is what makes it the platform's.
|
|
1
|
+
// Image's host behavior carries no runtime — no listeners, no timers, no fold. `foldImageProps`
|
|
2
|
+
// in C++ resolves the `srcSet`/`src`/`source` precedence, style folds and a11y mapping, asserted
|
|
3
|
+
// in `core/engine/cpp/tests/js/image-payload.itest.ts`.
|
|
11
4
|
//
|
|
12
5
|
// WHY THIS ONE DID NOT MOVE WHOLE, and it is the first that did not. `resolveAssetSource` turns the
|
|
13
6
|
// number `require('./logo.png')` returns into a `{uri, width, height, scale}` by asking METRO'S
|
|
@@ -1,54 +1,13 @@
|
|
|
1
|
-
// InputAccessoryView's host behavior
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// A REAL RULE WAS FOUND WHILE READING VENDOR FOR THE PORT (2026-09-20):
|
|
12
|
-
// `InputAccessoryView.js`'s `styles.container = {position: 'absolute'}`, composed AFTER the app's
|
|
13
|
-
// own style (`[props.style, styles.container]`) so it wins over any `position` an app authors.
|
|
14
|
-
// Every InputAccessoryView is positioned absolutely; nothing about it is per-instance, so it is
|
|
15
|
-
// `foldInputAccessoryViewProps` now rather than anything here. Contract:
|
|
16
|
-
// `core/engine/cpp/tests/js/touchable-payload.itest.ts`.
|
|
17
|
-
//
|
|
18
|
-
// NOT YET PORTED, and recorded honestly rather than silently skipped: vendor also wraps the app's
|
|
19
|
-
// children in an inner `SafeAreaView` sized to `useWindowDimensions().width` (`InputAccessoryView.js`
|
|
20
|
-
// render body) and returns nothing at all when it has zero children. Both are real structural gaps
|
|
21
|
-
// — a composed child node plus a live window-dimension binding — genuinely larger than a tag-only
|
|
22
|
-
// rule, and are NOT covered by this pass.
|
|
23
|
-
//
|
|
24
|
-
// TODO(rn-parity): port both. Needs (a) a `buildStructure` composing an inner wrapper node styled
|
|
25
|
-
// `{flex: 1, width}` around the app's children, with `width` read from a live window-dimensions
|
|
26
|
-
// subscription (no `SafeAreaView` primitive exists in this codebase yet — it would need building),
|
|
27
|
-
// and (b) suppressing the whole node (both platforms, not just Android's void case already fixed
|
|
28
|
-
// here) when it has zero children, matching `React.Children.count(props.children) === 0`.
|
|
29
|
-
//
|
|
30
|
-
// The `id -> nativeID` alias — is `foldIdAlias` in `SymbioteFabricProps.cpp`, applied to every
|
|
31
|
-
// tagged node rather than per primitive.
|
|
32
|
-
//
|
|
33
|
-
// PLATFORM. This is the only primitive in its group that is not platform-invariant in what it
|
|
34
|
-
// COMMITS TO: `input-accessory-view` resolves to `RCTInputAccessoryView` on iOS and to the void
|
|
35
|
-
// component on Android. The fold itself is platform-invariant on purpose.
|
|
36
|
-
//
|
|
37
|
-
// FIXED (2026-09-20). `InputAccessoryView.js` on Android does `console.warn('<InputAccessoryView>
|
|
38
|
-
// is only supported on iOS.'); return null` — the WHOLE component, children included, renders
|
|
39
|
-
// NOTHING. We used to commit a real `RCTView` and its whole children subtree (the toolbar content
|
|
40
|
-
// an app wrapped in it) — an extra, laid-out, potentially visible view where a real device shows
|
|
41
|
-
// none. `backgroundColor` being a declared iOS prop vs an Android style key is the SAME already-
|
|
42
|
-
// narrow gap, unaffected by this.
|
|
43
|
-
//
|
|
44
|
-
// The fix needed a new engine primitive, because every existing "commits no node of its own"
|
|
45
|
-
// tier-2 primitive (`touchable-native-feedback`, `touchable-without-feedback`) is an ANCHOR — it
|
|
46
|
-
// hoists its single child up in its own place, which is the opposite of what this tag needs: its
|
|
47
|
-
// whole subtree must vanish. `VOID_COMPONENT` (`core/engine/src/node.ts`, `OP_CREATE_VOID` in
|
|
48
|
-
// `mutation-buffer.ts`) is that primitive — a node the commit walk stops at, recursively,
|
|
49
|
-
// contributing neither itself nor its children. Wired purely through
|
|
50
|
-
// `core/components/src/component-names/index.android.ts`'s per-platform table, the same seam
|
|
51
|
-
// `ANCHOR_COMPONENT` already used — this behavior file needed no change at all.
|
|
1
|
+
// InputAccessoryView's host behavior owns no per-node runtime — the rule is
|
|
2
|
+
// `foldInputAccessoryViewProps` in C++, including the container's absolute-position style RN
|
|
3
|
+
// always composes over the app's own (`core/engine/cpp/tests/js/touchable-payload.itest.ts`).
|
|
4
|
+
// NOT YET PORTED: vendor also wraps children in an inner SafeAreaView sized to
|
|
5
|
+
// `useWindowDimensions().width` and renders nothing with zero children.
|
|
6
|
+
// TODO(rn-parity): needs a `buildStructure` wrapper node, a live window-dimensions subscription,
|
|
7
|
+
// and suppressing the whole node on both platforms when childless.
|
|
8
|
+
// Android resolves to `VOID_COMPONENT` (`core/engine/src/node.ts`, `OP_CREATE_VOID`): the commit
|
|
9
|
+
// walk stops there recursively, contributing neither the node nor its children — wired through
|
|
10
|
+
// `component-names/index.android.ts`, this behavior file needed no change.
|
|
52
11
|
import { Platform, registerHostBehavior, } from '@symbiote-native/engine';
|
|
53
12
|
export const INPUT_ACCESSORY_VIEW_TAG = 'input-accessory-view';
|
|
54
13
|
// `InputAccessoryView.js:110-113` — off iOS it warns and renders nothing (the void component).
|
|
@@ -2,52 +2,13 @@ import { type IHostBehavior, type ISymbioteNode } from '@symbiote-native/engine'
|
|
|
2
2
|
import { type IPressMachineConfig } from '../state/pressable';
|
|
3
3
|
import type { IAccessibilityStateValue } from '../accessibility-props';
|
|
4
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
5
|
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
6
|
export type IDisabledResolver = (props: Readonly<Record<string, unknown>>) => boolean | undefined;
|
|
29
7
|
export type ICancelableResolver = (source: ISymbioteNode) => boolean | undefined;
|
|
30
8
|
export declare function booleanOr(value: unknown): boolean | undefined;
|
|
31
9
|
export declare function asAccessibilityState(value: unknown): IAccessibilityStateValue | undefined;
|
|
32
10
|
export declare function accessibleUnlessOptedOut(props: Readonly<Record<string, unknown>>): boolean;
|
|
33
|
-
/**
|
|
34
|
-
* The Android ripple's three view commands around the app's callbacks (TNF :230-252,
|
|
35
|
-
* useAndroidRippleForView.js:77-104). The JS responder takes the touch before Android's own
|
|
36
|
-
* pressed handling, so without them the ripple never animates. Hotspot first: it starts under
|
|
37
|
-
* the finger.
|
|
38
|
-
*/
|
|
39
11
|
export declare function withNativeFeedbackCommands(node: ISymbioteNode, config: IPressMachineConfig): IPressMachineConfig;
|
|
40
|
-
/**
|
|
41
|
-
* The machine on `node`, reading its props and the app's callbacks off `options.source` when that
|
|
42
|
-
* is a different node.
|
|
43
|
-
*
|
|
44
|
-
* Exported for a behavior whose responder is not its own node — `./touchable-native-feedback`,
|
|
45
|
-
* whose tag commits nothing and adopts the app's single child as the responder. Every other caller
|
|
46
|
-
* goes through `createPressBehavior`, where source and node are the same.
|
|
47
|
-
*
|
|
48
|
-
* Re-callable on the same node: a second call replaces the state and the dispatchers, which is what
|
|
49
|
-
* a re-arm after `detachPressMachine` needs.
|
|
50
|
-
*/
|
|
51
12
|
export declare function attachPressMachine(node: ISymbioteNode, options?: {
|
|
52
13
|
readonly refine?: IPressConfigRefinement;
|
|
53
14
|
readonly disabledOf?: IDisabledResolver;
|
|
@@ -56,14 +17,5 @@ export declare function attachPressMachine(node: ISymbioteNode, options?: {
|
|
|
56
17
|
}): void;
|
|
57
18
|
/** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
|
|
58
19
|
export declare function detachPressMachine(node: ISymbioteNode): void;
|
|
59
|
-
/**
|
|
60
|
-
* The press machine as behavior parts, so a tag that is a pressable PLUS something can compose it
|
|
61
|
-
* instead of re-implementing it.
|
|
62
|
-
*
|
|
63
|
-
* Spread into the caller's own behavior and wrap `attach`/`detach` around these — the touchable
|
|
64
|
-
* family needs a per-node Animated value opened before the machine and closed after it. The
|
|
65
|
-
* WeakMap holding the machine's own state is keyed by node, so one node may hold exactly one of
|
|
66
|
-
* these; a tag composing it therefore must not also register the plain `pressable` behavior.
|
|
67
|
-
*/
|
|
68
20
|
export declare function createPressBehavior(refine?: IPressConfigRefinement, disabledOf?: IDisabledResolver): Pick<IHostBehavior, 'attach' | 'detach' | 'ownedListeners'>;
|
|
69
21
|
export declare function registerPressableBehavior(): void;
|
|
@@ -1,19 +1,9 @@
|
|
|
1
1
|
// The press machine as an ENGINE-NODE behavior, so a pressable can be an intrinsic tag instead of
|
|
2
|
-
// a framework component
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// /
|
|
7
|
-
// on the engine node rather than in a component instance.
|
|
8
|
-
//
|
|
9
|
-
// REGISTRATION IS THE HAZARD, not the machine. Metro enables `inlineRequires` in production only,
|
|
10
|
-
// moving a `require` to the first place its binding is used as a VALUE, and a barrel's
|
|
11
|
-
// `export { X } from './x'` compiles to a lazy getter. A module whose only job is a side effect is
|
|
12
|
-
// never named as a value, so re-exporting it means it NEVER RUNS in a release build — dev perfect,
|
|
13
|
-
// release silently pressless. Each adapter therefore keeps its own `src/register.ts` calling
|
|
14
|
-
// `registerPressableBehavior()`, and its entry does a bare `import './register';` that the barrel
|
|
15
|
-
// does NOT re-export. A bare `import './register';` sitting NEXT TO such a re-export does not work
|
|
16
|
-
// either: Babel merges the two imports of one specifier and the merged dependency stays lazy.
|
|
2
|
+
// a framework component. Written once; every adapter inherits it by registering, none re-implements
|
|
3
|
+
// it. The machine itself is unchanged and still shared with the component path.
|
|
4
|
+
// Registration, not the machine, is the hazard: Metro's inlineRequires makes a barrel re-export of
|
|
5
|
+
// a side-effect-only module never run in a release build. Each adapter keeps its own
|
|
6
|
+
// `src/register.ts` calling registerPressableBehavior(), reached only by a bare side-effect import.
|
|
17
7
|
import { appListenerFor, dispatchViewCommand, dlog, Platform, propOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, propsOf, } from '@symbiote-native/engine';
|
|
18
8
|
import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_MIN_PRESS_DURATION_MS, } from '../state/pressable.js';
|
|
19
9
|
import { buildPressableListeners } from '../view/render-pressable/index.js';
|
|
@@ -56,10 +46,9 @@ function asRectOffset(value) {
|
|
|
56
46
|
function isPressHandler(value) {
|
|
57
47
|
return typeof value === 'function';
|
|
58
48
|
}
|
|
59
|
-
// Narrowed field by field rather than cast: the bag arrives as `unknown` off
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// every adapter re-exports wholesale: a narrowing helper is not API anyone should be able to import.
|
|
49
|
+
// Narrowed field by field rather than cast: the bag arrives as `unknown` off propOf. Exported to
|
|
50
|
+
// sibling behaviors folding the same bag, deliberately NOT to the shared barrel — a narrowing
|
|
51
|
+
// helper isn't API anyone should import.
|
|
63
52
|
export function asAccessibilityState(value) {
|
|
64
53
|
if (!isRecord(value))
|
|
65
54
|
return undefined;
|
|
@@ -76,36 +65,19 @@ export function asAccessibilityState(value) {
|
|
|
76
65
|
state.expanded = value.expanded;
|
|
77
66
|
return state;
|
|
78
67
|
}
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// It cost a trip: a `payloadFold` marshals the whole bag out and the whole bag back, ~17 us per
|
|
87
|
-
// pressable per commit, and a benchmark row carries two.
|
|
88
|
-
//
|
|
89
|
-
// What is still here is the MACHINE, which is where a browser keeps it too: timers, the responder
|
|
90
|
-
// claim, hit-slop retention, and the callbacks into app code.
|
|
91
|
-
//
|
|
92
|
-
// The one thing that did NOT move with it is `hitSlop`, and that is deliberate — it is a real
|
|
93
|
-
// native View prop, so it never was part of the fold.
|
|
94
|
-
// RN makes every pressable accessible unless the app opts OUT — `Pressable.js:252`
|
|
95
|
-
// (`accessible: accessible !== false`), and the whole Touchable family repeats it verbatim
|
|
96
|
-
// (`TouchableOpacity.js:303`, `TouchableHighlight.js:337`). `!== false` rather than `?? true`, so
|
|
97
|
-
// only a literal `false` opts out and an explicit `undefined` still reads as accessible.
|
|
98
|
-
//
|
|
99
|
-
// Nothing in this repo did it until 2026-09-09, so a Pressable reached a screen reader as a plain
|
|
100
|
-
// view unless the app wrote the prop. Exported so anything composing this tag can say it too.
|
|
68
|
+
// The payload fold moved to the engine (foldPressableProps in SymbioteFabricProps.cpp), not
|
|
69
|
+
// re-implemented here: disabled -> accessibilityState, accessible/focusable defaulting on, the
|
|
70
|
+
// ripple config and the machine-only keys are all functions of the TAG alone, user-agent behavior.
|
|
71
|
+
// What's still here is the MACHINE: timers, the responder claim, hit-slop retention, callbacks
|
|
72
|
+
// into app code. `hitSlop` did NOT move with the fold — it's a real native View prop.
|
|
73
|
+
// RN makes every pressable accessible unless the app opts OUT (`accessible !== false`), and the
|
|
74
|
+
// whole Touchable family repeats it. `!== false` not `?? true`, so only a literal `false` opts out.
|
|
101
75
|
export function accessibleUnlessOptedOut(props) {
|
|
102
76
|
return props.accessible !== false;
|
|
103
77
|
}
|
|
104
|
-
// From the STASH, not
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// `propOf` here returns undefined for every callback and every press silently does nothing:
|
|
108
|
-
// the behavior runs, the machine runs, and it calls nobody.
|
|
78
|
+
// From the STASH, not the props: every name below is in ownedListeners, so routeProp diverts the
|
|
79
|
+
// app's onPress away from node.listeners into the stash. Reading propOf here would return
|
|
80
|
+
// undefined for every callback and every press would silently call nobody.
|
|
109
81
|
function callbackAt(node, event) {
|
|
110
82
|
const value = appListenerFor(node, event);
|
|
111
83
|
return isPressHandler(value) ? value : undefined;
|
|
@@ -121,29 +93,19 @@ function configFor(node) {
|
|
|
121
93
|
onPressOut: callbackAt(node, 'pressOut'),
|
|
122
94
|
onPressMove: callbackAt(node, 'pressMove'),
|
|
123
95
|
onLongPress: callbackAt(node, 'longPress'),
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
// `unstable_pressDelay`, lands at a constant 500ms from touch-down by default, not
|
|
128
|
-
// 500ms + unstable_pressDelay. See `createPressHandlers`'s `handlePressIn` for the other half
|
|
129
|
-
// (the timer must be ARMED at grant, not after the pressDelay fires, or this compensation
|
|
130
|
-
// does nothing).
|
|
96
|
+
// The subtraction applies only to the FALLBACK, never an authored value — it keeps the
|
|
97
|
+
// long-press threshold at a constant 500ms from touch-down by default, not
|
|
98
|
+
// 500ms + unstable_pressDelay (see createPressHandlers.handlePressIn for the other half).
|
|
131
99
|
delayLongPress: Math.max(10, numberOr(propOf(node, 'delayLongPress'), DEFAULT_DELAY_LONG_PRESS_MS - unstablePressDelay)),
|
|
132
100
|
unstable_pressDelay: unstablePressDelay,
|
|
133
|
-
// RN's Touchables own the deactivation floor in their
|
|
134
|
-
// `minPressDuration: 0
|
|
135
|
-
// an internal input; on the tag there is nowhere else to say it, so the floor has to be a
|
|
136
|
-
// readable prop or every Touchable holds its fade for the machine's 130 ms default.
|
|
101
|
+
// RN's Touchables own the deactivation floor in their own machine, handing Pressability
|
|
102
|
+
// `minPressDuration: 0`. On the tag there's nowhere else to say it, so it's a readable prop.
|
|
137
103
|
minPressDuration: numberOr(propOf(node, 'minPressDuration'), DEFAULT_MIN_PRESS_DURATION_MS),
|
|
138
104
|
hitSlop: asRectOffset(propOf(node, 'hitSlop')),
|
|
139
105
|
pressRetentionOffset: asRectOffset(propOf(node, 'pressRetentionOffset')),
|
|
140
|
-
// Pressable
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
// `TouchableNativeFeedback.js:228`). `node` here is whichever tag AUTHORS the prop — itself for
|
|
144
|
-
// a plain pressable/highlight/button, the owner for a clone-onto-child touchable, since
|
|
145
|
-
// `configFor` is always called with that source — so reading both names off it resolves
|
|
146
|
-
// correctly for every composition without a per-tag override.
|
|
106
|
+
// Pressable's own name is `android_disableSound`; every composed touchable instead forwards
|
|
107
|
+
// `touchSoundDisabled` to this same field. `node` here is whichever tag AUTHORS the prop, so
|
|
108
|
+
// reading both names resolves correctly for every composition without a per-tag override.
|
|
147
109
|
android_disableSound: booleanOr(propOf(node, 'android_disableSound')) ??
|
|
148
110
|
booleanOr(propOf(node, 'touchSoundDisabled')),
|
|
149
111
|
};
|
|
@@ -164,12 +126,8 @@ function hotspotAt(nativeEvent, key) {
|
|
|
164
126
|
const value = nativeEvent[key];
|
|
165
127
|
return typeof value === 'number' ? value : 0;
|
|
166
128
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
* useAndroidRippleForView.js:77-104). The JS responder takes the touch before Android's own
|
|
170
|
-
* pressed handling, so without them the ripple never animates. Hotspot first: it starts under
|
|
171
|
-
* the finger.
|
|
172
|
-
*/
|
|
129
|
+
// The Android ripple's three view commands around the app's callbacks. The JS responder takes
|
|
130
|
+
// the touch before Android's own pressed handling, so without them the ripple never animates.
|
|
173
131
|
export function withNativeFeedbackCommands(node, config) {
|
|
174
132
|
const hotspot = (event) => {
|
|
175
133
|
dispatchViewCommand(node, 'hotspotUpdate', [
|
|
@@ -194,13 +152,9 @@ export function withNativeFeedbackCommands(node, config) {
|
|
|
194
152
|
},
|
|
195
153
|
};
|
|
196
154
|
}
|
|
197
|
-
// Rebuilding at GESTURE START is the whole reason for the dispatcher indirection
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
// capture no `onPress` at all and every press would silently do nothing. `createPressHandlers`
|
|
201
|
-
// destructures its config eagerly, so it cannot be handed a live view either; it has to be re-made
|
|
202
|
-
// once the props exist. A gesture is one interaction, so a handful of closures per press is
|
|
203
|
-
// invisible — unlike doing it per prop write, which is the cost this whole tier exists to remove.
|
|
155
|
+
// Rebuilding at GESTURE START is the whole reason for the dispatcher indirection: `attach` runs
|
|
156
|
+
// inside createElement before a single prop is routed, so a machine built at attach would capture
|
|
157
|
+
// no onPress at all. A gesture is one interaction, so a handful of closures per press is invisible.
|
|
204
158
|
function rebuild(node, state) {
|
|
205
159
|
// `state.source` for everything READ, `node` for the refinement, which acts on the responder
|
|
206
160
|
// (dispatching a view command needs the committed node, not the one holding the props).
|
|
@@ -223,13 +177,9 @@ function rebuild(node, state) {
|
|
|
223
177
|
blockNativeResponder: propOf(source, 'blockNativeResponder') === true,
|
|
224
178
|
});
|
|
225
179
|
}
|
|
226
|
-
// Pressable is the only member
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
// (`TouchableOpacity.js:186`, `TouchableHighlight.js:194`, `TouchableNativeFeedback.js:217`) — every
|
|
230
|
-
// composed touchable shares this `rebuild()`, so without this the authored name never reached the
|
|
231
|
-
// machine and every Touchable silently kept the RN native default (yield the responder) regardless of
|
|
232
|
-
// what the app asked for. An explicit `cancelable` still wins, matching Pressable's own precedence.
|
|
180
|
+
// Pressable is the only family member with its own `cancelable` prop; the Touchables instead
|
|
181
|
+
// expose `rejectResponderTermination` and derive cancelable internally. Without this the authored
|
|
182
|
+
// name never reaches the machine. An explicit `cancelable` still wins, matching Pressable's rule.
|
|
233
183
|
function resolveCancelable(source) {
|
|
234
184
|
const cancelable = propOf(source, 'cancelable');
|
|
235
185
|
if (typeof cancelable === 'boolean')
|
|
@@ -237,19 +187,11 @@ function resolveCancelable(source) {
|
|
|
237
187
|
const reject = propOf(source, 'rejectResponderTermination');
|
|
238
188
|
return typeof reject === 'boolean' ? !reject : undefined;
|
|
239
189
|
}
|
|
240
|
-
// WHICHEVER EVENT OPENS THE GESTURE REBUILDS
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
// Fabric, and `onPress` still fired because by then the machine existed. That is exactly the
|
|
246
|
-
// device report: the callback works, the button does not light up.
|
|
247
|
-
//
|
|
248
|
-
// So the trigger is a FLAG, not a name: build if this gesture has not built yet, and clear it when
|
|
249
|
-
// the gesture ends. Order-independent, and it survives the engine reordering its own events.
|
|
250
|
-
//
|
|
251
|
-
// A key `buildPressableListeners` omitted — every one of them when `disabled` is true — resolves to
|
|
252
|
-
// undefined here and the dispatcher returns undefined, which is what an absent listener would do.
|
|
190
|
+
// WHICHEVER EVENT OPENS THE GESTURE REBUILDS: `onStartShouldSetResponder` looks like the opener
|
|
191
|
+
// and isn't, since the engine bubbles PRESS_IN and only then negotiates the responder, so
|
|
192
|
+
// `pressIn` arrives first. Rebuilding only on the responder claim handed it an empty listener bag.
|
|
193
|
+
// The trigger is a FLAG, not a name: build if this gesture hasn't built yet, clear on gesture end
|
|
194
|
+
// — order-independent, survives the engine reordering its own events.
|
|
253
195
|
const GESTURE_END_KEYS = new Set([
|
|
254
196
|
'onPressOut',
|
|
255
197
|
'onResponderTerminationRequest',
|
|
@@ -267,30 +209,14 @@ function dispatch(node, state, key, args) {
|
|
|
267
209
|
state.isBuilt = false;
|
|
268
210
|
return result;
|
|
269
211
|
}
|
|
270
|
-
// Installed straight into the listener slot
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
*
|
|
279
|
-
* AN ARRAY OF PAIRS RATHER THAN A `Map`, and the reason is measured rather than stylistic.
|
|
280
|
-
* `installListeners` below is the only reader, and it runs once per node that carries a press
|
|
281
|
-
* machine — which is every `<TextInput>` on the screen, not just every `<Pressable>`. A `for…of` over
|
|
282
|
-
* a `Map` builds a fresh two-element array per entry for the destructuring to read back: seven
|
|
283
|
-
* allocations per node, for seven fixed pairs that never change. The tuples here already exist, so
|
|
284
|
-
* the same loop allocates nothing but its iterator.
|
|
285
|
-
*
|
|
286
|
-
* Priced on `-O` Hermes by `text-input-attach-ladder.itest.ts`, one pass over the seven pairs:
|
|
287
|
-
* `Map` 1.04 us, this 0.39, two parallel arrays 0.29. The parallel arrays are cheapest and give up
|
|
288
|
-
* the pairing, which is not worth 0.1 us on a table that a drift would silently unwire.
|
|
289
|
-
*
|
|
290
|
-
* NOT the same list as `createPressBehavior`'s `ownedListeners`, and they must not be merged: that
|
|
291
|
-
* one is every name the machine takes as an INPUT (`pressMove` and `longPress` included), this one
|
|
292
|
-
* is only the names it installs a dispatcher for.
|
|
293
|
-
*/
|
|
212
|
+
// Installed straight into the listener slot, not through routeProp: the behavior OWNS these
|
|
213
|
+
// names, and setEventListener diverts an owned name into the app stash — routing the dispatcher
|
|
214
|
+
// through there would stash it and leave the slot empty.
|
|
215
|
+
// Engine event name -> the app-facing callback key its dispatcher routes to. An array of pairs
|
|
216
|
+
// rather than a Map: installListeners runs once per node carrying a press machine, and a `for…of`
|
|
217
|
+
// over a Map allocates a fresh pair per entry where these tuples already exist.
|
|
218
|
+
// NOT the same list as createPressBehavior's ownedListeners — that's every name the machine takes
|
|
219
|
+
// as an INPUT, this is only the names it installs a dispatcher for.
|
|
294
220
|
const EVENT_KEY_PAIRS = [
|
|
295
221
|
['press', 'onPress'],
|
|
296
222
|
['pressIn', 'onPressIn'],
|
|
@@ -308,17 +234,11 @@ function installListeners(node, state) {
|
|
|
308
234
|
function attachWith(refine, disabledOf) {
|
|
309
235
|
return node => attach(node, { refine, disabledOf });
|
|
310
236
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
* whose tag commits nothing and adopts the app's single child as the responder. Every other caller
|
|
317
|
-
* goes through `createPressBehavior`, where source and node are the same.
|
|
318
|
-
*
|
|
319
|
-
* Re-callable on the same node: a second call replaces the state and the dispatchers, which is what
|
|
320
|
-
* a re-arm after `detachPressMachine` needs.
|
|
321
|
-
*/
|
|
237
|
+
// The machine on `node`, reading props and callbacks off `options.source` when different.
|
|
238
|
+
// Exported for touchable-native-feedback, whose tag commits nothing and adopts the app's single
|
|
239
|
+
// child as the responder; every other caller goes through createPressBehavior where they're equal.
|
|
240
|
+
// Re-callable on the same node: a second call replaces the state and dispatchers, which is what
|
|
241
|
+
// re-arming after detachPressMachine needs.
|
|
322
242
|
export function attachPressMachine(node, options = {}) {
|
|
323
243
|
attach(node, options);
|
|
324
244
|
}
|
|
@@ -330,10 +250,8 @@ function attach(node, options) {
|
|
|
330
250
|
// through the style registry's `:active` variant, and never crosses into it.
|
|
331
251
|
setPressed: pressed => {
|
|
332
252
|
setNodePressed(node, pressed);
|
|
333
|
-
// Dirtying is not publishing
|
|
334
|
-
//
|
|
335
|
-
// adapter does either. `setNodeHidden`'s React twin never hit this because the reconciler is
|
|
336
|
-
// already in its commit phase when it calls.
|
|
253
|
+
// Dirtying is not publishing: a press arrives outside every renderer mutation path, so
|
|
254
|
+
// nothing else schedules a commit.
|
|
337
255
|
requestCommitFor(node);
|
|
338
256
|
},
|
|
339
257
|
// `measure` needs a committed Fabric tag, which a node has by the time a human can touch it.
|
|
@@ -377,10 +295,8 @@ function detach(node) {
|
|
|
377
295
|
const state = states.get(node);
|
|
378
296
|
if (state === undefined)
|
|
379
297
|
return;
|
|
380
|
-
// The machine's own teardown
|
|
381
|
-
//
|
|
382
|
-
// `state.timers` — the 130ms floor's deferred `pressOut` included — so the loop below already
|
|
383
|
-
// cancels them. Kept as the contract, and for a timer armed by some future route.
|
|
298
|
+
// The machine's own teardown. Not load-bearing here: host.schedule puts every timer the machine
|
|
299
|
+
// arms into state.timers, so the loop below already cancels them — kept as the contract.
|
|
384
300
|
disposePressRuntime(state.runtime);
|
|
385
301
|
for (const id of state.timers)
|
|
386
302
|
clearTimeout(id);
|
|
@@ -388,15 +304,9 @@ function detach(node) {
|
|
|
388
304
|
states.delete(node);
|
|
389
305
|
dlog('pressable behavior detached');
|
|
390
306
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
*
|
|
395
|
-
* Spread into the caller's own behavior and wrap `attach`/`detach` around these — the touchable
|
|
396
|
-
* family needs a per-node Animated value opened before the machine and closed after it. The
|
|
397
|
-
* WeakMap holding the machine's own state is keyed by node, so one node may hold exactly one of
|
|
398
|
-
* these; a tag composing it therefore must not also register the plain `pressable` behavior.
|
|
399
|
-
*/
|
|
307
|
+
// The press machine as behavior parts, so a tag that is a pressable PLUS something can compose
|
|
308
|
+
// it instead of re-implementing it — the touchable family wraps attach/detach for its own
|
|
309
|
+
// per-node Animated value. One node may hold exactly one of these; don't also register `pressable`.
|
|
400
310
|
export function createPressBehavior(refine, disabledOf) {
|
|
401
311
|
return {
|
|
402
312
|
attach: attachWith(refine, disabledOf),
|