@symbiote-native/components 3.0.1 → 3.1.1
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/button.js +13 -11
- package/build/behaviors/input-accessory-view.js +8 -6
- package/build/behaviors/pressable.d.ts +9 -0
- package/build/behaviors/pressable.js +53 -3
- package/build/behaviors/scroll-view/index.android.js +3 -1
- package/build/behaviors/scroll-view/responder.js +37 -8
- package/build/behaviors/scroll-view/shared.js +9 -10
- package/build/behaviors/text-input.js +13 -2
- package/build/behaviors/touchable-native-feedback.js +4 -35
- package/build/bootstrap/index.d.ts +1 -0
- package/build/bootstrap/index.js +4 -1
- package/build/component-names/index.android.js +3 -14
- package/build/index.d.ts +2 -2
- package/build/index.js +2 -2
- package/build/scroll-view-commands.js +6 -6
- package/build/state/flat-list.d.ts +1 -0
- package/build/state/flat-list.js +5 -0
- package/build/state/modal.d.ts +3 -1
- package/build/state/modal.js +18 -12
- package/build/state/virtualized-list-reducer.js +2 -2
- package/build/state/virtualized-list.d.ts +9 -1
- package/build/state/virtualized-list.js +23 -5
- package/build/view/render-button.js +0 -9
- package/build/view/render-modal.js +3 -2
- package/build/view/render-touchable-native-feedback.js +5 -11
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -20,17 +20,19 @@ supplies only the third:
|
|
|
20
20
|
|
|
21
21
|
1. **Logic — `src/state/*.ts`.** A pure reducer `(state, action) => state`, a
|
|
22
22
|
`createInitial*State` factory, and pure predicates. Zero framework, zero render —
|
|
23
|
-
`
|
|
24
|
-
the `virtualized-list` windowing math for the list family.
|
|
23
|
+
`modalReducer` for `Modal`, `shouldSnapBack` / `valueFromChange` for `Pressable`'s press
|
|
24
|
+
machine, the `virtualized-list` windowing math for the list family.
|
|
25
25
|
2. **View — `src/view/render-*.ts`.** A pure function `render*(viewState, platform) => Descriptor`.
|
|
26
26
|
State and visuals enter **only through arguments**; out comes a tree of `Descriptor` nodes
|
|
27
27
|
(`{ type, props, children, key }`, built with `el()` / `txt()`) over the intrinsic primitives
|
|
28
|
-
(`symbiote-view`, `symbiote-
|
|
28
|
+
(`symbiote-view`, `symbiote-scroll-view`, …). No framework, no state, no events.
|
|
29
29
|
3. **Lifecycle — the adapter.** React wires the reducer through `useReducer`/`useLayoutEffect` and
|
|
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
|
-
`
|
|
33
|
+
`Modal` is the canonical reference for a full three-layer component. `Switch` used to be — its
|
|
34
|
+
`render*` function is gone now that the painting moved to the `switch` tag's own host behavior
|
|
35
|
+
(see "Host behaviors" below), so only its reducer and prop type still live at this layer.
|
|
34
36
|
|
|
35
37
|
### Install
|
|
36
38
|
|
|
@@ -45,28 +47,25 @@ npm install @symbiote-native/components
|
|
|
45
47
|
## Usage
|
|
46
48
|
|
|
47
49
|
Nearly every consumer reaches this package **through an adapter**, not directly — an app imports
|
|
48
|
-
`
|
|
50
|
+
`Modal` from `@symbiote-native/react` (or `@symbiote-native/vue`), and that adapter re-exports the prop
|
|
49
51
|
types and wires the reducer/render pair from here. Calling the render function directly is what an
|
|
50
52
|
adapter itself does, to build its lifecycle wrapper:
|
|
51
53
|
|
|
52
54
|
```ts
|
|
53
55
|
import {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
renderModal,
|
|
57
|
+
createInitialModalState,
|
|
58
|
+
modalReducer,
|
|
57
59
|
} from '@symbiote-native/components';
|
|
58
60
|
|
|
59
61
|
// inside an adapter's own hook/composable:
|
|
60
|
-
const state =
|
|
61
|
-
const next =
|
|
62
|
-
const descriptor =
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}),
|
|
68
|
-
},
|
|
69
|
-
);
|
|
62
|
+
const state = createInitialModalState(visible);
|
|
63
|
+
const next = modalReducer(state, { type: visible ? 'show' : 'hide' });
|
|
64
|
+
const descriptor = renderModal({
|
|
65
|
+
visible,
|
|
66
|
+
transparent: false,
|
|
67
|
+
passthrough: { onDismiss, onRequestClose, ref },
|
|
68
|
+
});
|
|
70
69
|
// descriptor is then handed to the adapter's own descriptorToReact / descriptorToVue bridge
|
|
71
70
|
```
|
|
72
71
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// iOS button RCTView TouchableOpacity's Animated.View — the responder + the fade
|
|
15
15
|
// └ view RCTView no style at all, and no fold
|
|
16
16
|
// └ text RCTText `button-label-text` — foldButtonLabelStyle + RN's Text defaults
|
|
17
|
-
// └ raw RCTRawText `button-label` —
|
|
17
|
+
// └ raw RCTRawText `button-label` — uppercaseTitle (Android) FOUR nodes
|
|
18
18
|
//
|
|
19
19
|
// Android button RCTView the styled button view, CLONED onto: the responder, the ripple
|
|
20
20
|
// │ background, the whole a11y fold. No fade, no wrapper.
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
// since 2026-09-09, in the same commit that deleted the five wrappers, which is what makes it safe:
|
|
112
112
|
// while a wrapper still built its own view and text under this tag, registering would have given
|
|
113
113
|
// every Button a second copy of the subtree.
|
|
114
|
-
import { addDerivedNode, appendChild, createElement, createRawText, markPropsDirty, Platform, registerHostBehavior, requestCommitFor,
|
|
114
|
+
import { addDerivedNode, appendChild, createElement, createRawText, markPropsDirty, Platform, registerHostBehavior, requestCommitFor, } from '@symbiote-native/engine';
|
|
115
115
|
import { descriptorFor } from '../component-names';
|
|
116
116
|
import { resolveButtonDisabled } from '../view/render-button.js';
|
|
117
117
|
import { booleanOr, createPressBehavior, } from './pressable.js';
|
|
@@ -192,15 +192,8 @@ const buttonDisabled = props => projectionOf(props).disabled;
|
|
|
192
192
|
// was the thing this fold was waiting for: `ownerProps` answers "my parent" and this node's parent
|
|
193
193
|
// is the wrapping view, which knows none of it.
|
|
194
194
|
export const BUTTON_LABEL_TEXT_TAG = 'button-label-text';
|
|
195
|
-
// The label's own tag
|
|
196
|
-
//
|
|
197
|
-
// Android and verbatim elsewhere (`Button.js:352-353`), which is a user-agent choice about a control
|
|
198
|
-
// rather than anything the app asked for.
|
|
199
|
-
//
|
|
200
|
-
// That is what the fold here used to do, and it is `foldButtonLabel` in `SymbioteFabricProps.cpp`
|
|
201
|
-
// now, reached off this tag. `button-payload.itest.ts` recorded "a raw text carries no tag at all,
|
|
202
|
-
// so there is nothing for a tag-keyed rule to key on" — true of `createRawText`'s old signature, not
|
|
203
|
-
// of raw texts, and it takes a tag now for exactly this.
|
|
195
|
+
// The label's own tag, on the raw text that holds the title. Its Android uppercase (`Button.js:352`)
|
|
196
|
+
// is `uppercaseTitle` below, in JS: the C++ tag rule it briefly was could only do ASCII.
|
|
204
197
|
export const BUTTON_LABEL_TAG = 'button-label';
|
|
205
198
|
// ---- the Android touchable -------------------------------------------------------------------
|
|
206
199
|
// Button.js:281-284. Not two variants of one component: see the tree diagram at the top for what
|
|
@@ -313,6 +306,14 @@ function onOwnedListenerChange(node, name) {
|
|
|
313
306
|
markPropsDirty(node);
|
|
314
307
|
requestCommitFor(node);
|
|
315
308
|
}
|
|
309
|
+
// Button.js:352-353 — Android renders `title.toUpperCase()`. Here in JS rather than in the C++ tag
|
|
310
|
+
// rules because only JavaScript's uppercase is full Unicode (Cyrillic, `ß` -> `SS`); the host has
|
|
311
|
+
// no ICU, and an ASCII port left every non-Latin title in its authored case.
|
|
312
|
+
function uppercaseTitle(slotKey, value) {
|
|
313
|
+
return slotKey === 'text' && typeof value === 'string'
|
|
314
|
+
? value.toUpperCase()
|
|
315
|
+
: value;
|
|
316
|
+
}
|
|
316
317
|
// Idempotent: an adapter entry may be imported more than once in a bundle.
|
|
317
318
|
export function registerButtonBehavior() {
|
|
318
319
|
// `attach`/`detach` come from the touchable unwrapped: the internal nodes are ordinary children
|
|
@@ -325,6 +326,7 @@ export function registerButtonBehavior() {
|
|
|
325
326
|
onOwnedListenerChange,
|
|
326
327
|
slotProps: SLOT_PROPS,
|
|
327
328
|
slotDerived: SLOT_DERIVED,
|
|
329
|
+
...(IS_ANDROID ? { slotValueFor: uppercaseTitle } : {}),
|
|
328
330
|
};
|
|
329
331
|
// The two DERIVED nodes' tags, registered with no runtime at all. A tag reaches C++ only through
|
|
330
332
|
// `recordSetTag`, which `attachHostBehavior` emits, so a tag nobody registered carries an empty
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
// tagged node rather than per primitive.
|
|
32
32
|
//
|
|
33
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
|
|
35
|
-
//
|
|
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
36
|
//
|
|
37
37
|
// FIXED (2026-09-20). `InputAccessoryView.js` on Android does `console.warn('<InputAccessoryView>
|
|
38
38
|
// is only supported on iOS.'); return null` — the WHOLE component, children included, renders
|
|
@@ -49,12 +49,14 @@
|
|
|
49
49
|
// contributing neither itself nor its children. Wired purely through
|
|
50
50
|
// `core/components/src/component-names/index.android.ts`'s per-platform table, the same seam
|
|
51
51
|
// `ANCHOR_COMPONENT` already used — this behavior file needed no change at all.
|
|
52
|
-
import { registerHostBehavior, } from '@symbiote-native/engine';
|
|
52
|
+
import { Platform, registerHostBehavior, } from '@symbiote-native/engine';
|
|
53
53
|
export const INPUT_ACCESSORY_VIEW_TAG = 'input-accessory-view';
|
|
54
|
-
//
|
|
55
|
-
//
|
|
54
|
+
// `InputAccessoryView.js:110-113` — off iOS it warns and renders nothing (the void component).
|
|
55
|
+
// RN warns per render; once per node is the nearest beat a tag has.
|
|
56
56
|
function attach(_node) {
|
|
57
|
-
|
|
57
|
+
if (Platform.OS !== 'ios') {
|
|
58
|
+
console.warn('<InputAccessoryView> is only supported on iOS.');
|
|
59
|
+
}
|
|
58
60
|
}
|
|
59
61
|
function detach(_node) {
|
|
60
62
|
// nothing to release
|
|
@@ -26,9 +26,17 @@ export type IPressConfigRefinement = (node: ISymbioteNode, config: IPressMachine
|
|
|
26
26
|
* would answer the NEXT resolution as if the app had written it and the tag could never re-enable.
|
|
27
27
|
*/
|
|
28
28
|
export type IDisabledResolver = (props: Readonly<Record<string, unknown>>) => boolean | undefined;
|
|
29
|
+
export type ICancelableResolver = (source: ISymbioteNode) => boolean | undefined;
|
|
29
30
|
export declare function booleanOr(value: unknown): boolean | undefined;
|
|
30
31
|
export declare function asAccessibilityState(value: unknown): IAccessibilityStateValue | undefined;
|
|
31
32
|
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
|
+
export declare function withNativeFeedbackCommands(node: ISymbioteNode, config: IPressMachineConfig): IPressMachineConfig;
|
|
32
40
|
/**
|
|
33
41
|
* The machine on `node`, reading its props and the app's callbacks off `options.source` when that
|
|
34
42
|
* is a different node.
|
|
@@ -43,6 +51,7 @@ export declare function accessibleUnlessOptedOut(props: Readonly<Record<string,
|
|
|
43
51
|
export declare function attachPressMachine(node: ISymbioteNode, options?: {
|
|
44
52
|
readonly refine?: IPressConfigRefinement;
|
|
45
53
|
readonly disabledOf?: IDisabledResolver;
|
|
54
|
+
readonly cancelableOf?: ICancelableResolver;
|
|
46
55
|
readonly source?: ISymbioteNode;
|
|
47
56
|
}): void;
|
|
48
57
|
/** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// `registerPressableBehavior()`, and its entry does a bare `import './register';` that the barrel
|
|
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
|
-
import { appListenerFor, dlog, propOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, propsOf, } from '@symbiote-native/engine';
|
|
17
|
+
import { appListenerFor, dispatchViewCommand, dlog, Platform, propOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, propsOf, } from '@symbiote-native/engine';
|
|
18
18
|
import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_MIN_PRESS_DURATION_MS, } from '../state/pressable.js';
|
|
19
19
|
import { buildPressableListeners } from '../view/render-pressable/index.js';
|
|
20
20
|
export const PRESSABLE_TAG = 'pressable';
|
|
@@ -148,6 +148,52 @@ function configFor(node) {
|
|
|
148
148
|
booleanOr(propOf(node, 'touchSoundDisabled')),
|
|
149
149
|
};
|
|
150
150
|
}
|
|
151
|
+
// Read once: the platform cannot change under a running app.
|
|
152
|
+
const IS_ANDROID = Platform.OS === 'android';
|
|
153
|
+
// useAndroidRippleForView.js:57 — a ripple exists only when color, borderless or radius is set.
|
|
154
|
+
function hasAndroidRipple(config) {
|
|
155
|
+
if (typeof config !== 'object' || config === null)
|
|
156
|
+
return false;
|
|
157
|
+
return (Reflect.get(config, 'color') != null ||
|
|
158
|
+
Reflect.get(config, 'borderless') != null ||
|
|
159
|
+
Reflect.get(config, 'radius') != null);
|
|
160
|
+
}
|
|
161
|
+
// `locationX ?? 0` (TouchableNativeFeedback.js:280, useAndroidRippleForView.js:83). The bag is raw
|
|
162
|
+
// Fabric payload, so guard.
|
|
163
|
+
function hotspotAt(nativeEvent, key) {
|
|
164
|
+
const value = nativeEvent[key];
|
|
165
|
+
return typeof value === 'number' ? value : 0;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The Android ripple's three view commands around the app's callbacks (TNF :230-252,
|
|
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
|
+
*/
|
|
173
|
+
export function withNativeFeedbackCommands(node, config) {
|
|
174
|
+
const hotspot = (event) => {
|
|
175
|
+
dispatchViewCommand(node, 'hotspotUpdate', [
|
|
176
|
+
hotspotAt(event.nativeEvent, 'locationX'),
|
|
177
|
+
hotspotAt(event.nativeEvent, 'locationY'),
|
|
178
|
+
]);
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
...config,
|
|
182
|
+
onPressIn(event) {
|
|
183
|
+
hotspot(event);
|
|
184
|
+
dispatchViewCommand(node, 'setPressed', [true]);
|
|
185
|
+
config.onPressIn?.(event);
|
|
186
|
+
},
|
|
187
|
+
onPressMove(event) {
|
|
188
|
+
hotspot(event);
|
|
189
|
+
config.onPressMove?.(event);
|
|
190
|
+
},
|
|
191
|
+
onPressOut(event) {
|
|
192
|
+
dispatchViewCommand(node, 'setPressed', [false]);
|
|
193
|
+
config.onPressOut?.(event);
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
151
197
|
// Rebuilding at GESTURE START is the whole reason for the dispatcher indirection, and skipping it
|
|
152
198
|
// is a bug that looks like working code. `attach` runs inside `createElement`, before a single
|
|
153
199
|
// prop has been routed — the node holds nothing at all there — so a machine built at attach would
|
|
@@ -160,7 +206,10 @@ function rebuild(node, state) {
|
|
|
160
206
|
// (dispatching a view command needs the committed node, not the one holding the props).
|
|
161
207
|
const source = state.source;
|
|
162
208
|
const base = configFor(source);
|
|
163
|
-
const
|
|
209
|
+
const refined = state.refine === undefined ? base : state.refine(node, base);
|
|
210
|
+
const handlers = createPressHandlers(IS_ANDROID && hasAndroidRipple(propOf(source, 'android_ripple'))
|
|
211
|
+
? withNativeFeedbackCommands(node, refined)
|
|
212
|
+
: refined, state.runtime, state.host);
|
|
164
213
|
state.isBuilt = true;
|
|
165
214
|
// Re-read every gesture, so a tag whose resolver looks past `disabled` — `./button`, at
|
|
166
215
|
// `aria-disabled` — re-enables on the next touch instead of latching at its first answer.
|
|
@@ -170,7 +219,7 @@ function rebuild(node, state) {
|
|
|
170
219
|
: state.disabledOf(sourceProps);
|
|
171
220
|
state.listeners = buildPressableListeners(handlers, {
|
|
172
221
|
disabled: disabled === true ? true : undefined,
|
|
173
|
-
cancelable: resolveCancelable(source),
|
|
222
|
+
cancelable: (state.cancelableOf ?? resolveCancelable)(source),
|
|
174
223
|
blockNativeResponder: propOf(source, 'blockNativeResponder') === true,
|
|
175
224
|
});
|
|
176
225
|
}
|
|
@@ -311,6 +360,7 @@ function attach(node, options) {
|
|
|
311
360
|
host,
|
|
312
361
|
refine: options.refine,
|
|
313
362
|
disabledOf: options.disabledOf,
|
|
363
|
+
cancelableOf: options.cancelableOf,
|
|
314
364
|
source: options.source ?? node,
|
|
315
365
|
timers,
|
|
316
366
|
listeners: {},
|
|
@@ -33,7 +33,9 @@
|
|
|
33
33
|
import { registerScrollViewBehaviors } from './shared.js';
|
|
34
34
|
const android = {
|
|
35
35
|
claimMode: 'wrap',
|
|
36
|
-
|
|
36
|
+
// `stickyHeaderIndices`: the content view's clipping is forced off under sticky headers on
|
|
37
|
+
// Android only (`ScrollView.js:1740-1745`), so only here does it dirty the slot.
|
|
38
|
+
slotDerived: ['style', 'stickyHeaderIndices'],
|
|
37
39
|
};
|
|
38
40
|
export function registerScrollViewBehavior() {
|
|
39
41
|
registerScrollViewBehaviors(android);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Ported line-for-line from `ScrollView.js:1263-1546` — the negotiation engine itself and the
|
|
6
6
|
// `IResponderProps` prop surface already exist and needed no change; this file is ScrollView opting
|
|
7
7
|
// into the same mechanism Pressable already uses (`../pressable.ts:329-384`).
|
|
8
|
-
import { appListenerFor, currentlyFocusedInput, blurTextInput, isRecord, isSymbioteNode, Keyboard, propOf, setBehaviorListener, } from '@symbiote-native/engine';
|
|
8
|
+
import { appListenerFor, currentlyFocusedInput, blurTextInput, dlog, isDebug, isRecord, isSymbioteNode, Keyboard, Platform, propOf, setBehaviorListener, } from '@symbiote-native/engine';
|
|
9
9
|
import { descriptorFor } from '../../component-names';
|
|
10
10
|
import { TEXT_INPUT_MULTILINE_TAG, TEXT_INPUT_TAG } from '../text-input.js';
|
|
11
11
|
// RN's `IS_ANIMATING_TOUCH_START_THRESHOLD_MS` (ScrollView.js:695) — a momentum end inside this
|
|
@@ -45,14 +45,20 @@ function isTextInputNode(node) {
|
|
|
45
45
|
return (node.component === descriptorFor(TEXT_INPUT_TAG).component ||
|
|
46
46
|
node.component === descriptorFor(TEXT_INPUT_MULTILINE_TAG).component);
|
|
47
47
|
}
|
|
48
|
-
// RN's `
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
// RN's `_keyboardEventsAreUnreliable()` (ScrollView.js:1558-1561): before API 30 Android reports
|
|
49
|
+
// the keyboard only through layout, so its events cannot be trusted.
|
|
50
|
+
const ANDROID_RELIABLE_KEYBOARD_API = 30;
|
|
51
|
+
function keyboardEventsAreUnreliable() {
|
|
52
|
+
return (Platform.OS === 'android' &&
|
|
53
|
+
typeof Platform.Version === 'number' &&
|
|
54
|
+
Platform.Version < ANDROID_RELIABLE_KEYBOARD_API);
|
|
55
|
+
}
|
|
56
|
+
// RN's `_keyboardIsDismissible()` (ScrollView.js:1527-1541).
|
|
52
57
|
function keyboardIsDismissible() {
|
|
53
58
|
const focused = currentlyFocusedInput();
|
|
54
59
|
const hasFocusedTextInput = focused !== null && isTextInputNode(focused);
|
|
55
|
-
|
|
60
|
+
const softKeyboardMayBeOpen = Keyboard.metrics() !== undefined || keyboardEventsAreUnreliable();
|
|
61
|
+
return hasFocusedTextInput && softKeyboardMayBeOpen;
|
|
56
62
|
}
|
|
57
63
|
// RN's `_softKeyboardIsDetached()` (ScrollView.js:1543-1546).
|
|
58
64
|
function softKeyboardIsDetached() {
|
|
@@ -88,8 +94,13 @@ function keyboardNeverPersistsTaps(persist) {
|
|
|
88
94
|
// keyboard is up, and the touch did not land on the focused input itself.
|
|
89
95
|
function startShouldSetResponderCapture(owner, event) {
|
|
90
96
|
const state = stateFor(owner);
|
|
91
|
-
|
|
97
|
+
// Both claims below take the tap away from the button under the finger, so each one says so.
|
|
98
|
+
if (isAnimating(state)) {
|
|
99
|
+
if (isDebug()) {
|
|
100
|
+
dlog(`ScrollView claims the tap (animating: momentum begin=${state.lastMomentumScrollBeginTime} end=${state.lastMomentumScrollEndTime})`);
|
|
101
|
+
}
|
|
92
102
|
return true;
|
|
103
|
+
}
|
|
93
104
|
if (propOf(owner, 'disableScrollViewPanResponder') === true)
|
|
94
105
|
return false;
|
|
95
106
|
if (softKeyboardIsDetached())
|
|
@@ -97,8 +108,11 @@ function startShouldSetResponderCapture(owner, event) {
|
|
|
97
108
|
if (keyboardNeverPersistsTaps(propOf(owner, 'keyboardShouldPersistTaps')) &&
|
|
98
109
|
keyboardIsDismissible()) {
|
|
99
110
|
const target = touchTargetOf(event.nativeEvent);
|
|
100
|
-
if (target !== undefined && !isTextInputNode(target))
|
|
111
|
+
if (target !== undefined && !isTextInputNode(target)) {
|
|
112
|
+
if (isDebug())
|
|
113
|
+
dlog('ScrollView claims the tap (keyboard up, dismissing it)');
|
|
101
114
|
return true;
|
|
115
|
+
}
|
|
102
116
|
}
|
|
103
117
|
return false;
|
|
104
118
|
}
|
|
@@ -173,7 +187,19 @@ function handleMomentumScrollEnd(owner, event) {
|
|
|
173
187
|
if (typeof app === 'function')
|
|
174
188
|
app(event);
|
|
175
189
|
}
|
|
190
|
+
// `ScrollView.js:1284-1295`. Android's native scroller has no `keyboardDismissMode`, so `on-drag`
|
|
191
|
+
// is JS dismissing the keyboard (`dismissKeyboard()`) before the app's own handler runs.
|
|
192
|
+
function handleScrollBeginDrag(owner, event) {
|
|
193
|
+
if (Platform.OS === 'android' &&
|
|
194
|
+
propOf(owner, 'keyboardDismissMode') === 'on-drag') {
|
|
195
|
+
blurTextInput(currentlyFocusedInput());
|
|
196
|
+
}
|
|
197
|
+
const app = appListenerFor(owner, 'scrollBeginDrag');
|
|
198
|
+
if (typeof app === 'function')
|
|
199
|
+
app(event);
|
|
200
|
+
}
|
|
176
201
|
export const RESPONDER_OWNED_LISTENERS = [
|
|
202
|
+
'scrollBeginDrag',
|
|
177
203
|
'startShouldSetResponderCapture',
|
|
178
204
|
'startShouldSetResponder',
|
|
179
205
|
'responderTerminationRequest',
|
|
@@ -196,6 +222,9 @@ export function installResponderPredicates(owner) {
|
|
|
196
222
|
setBehaviorListener(owner, 'momentumScrollBegin', event => {
|
|
197
223
|
handleMomentumScrollBegin(owner, event);
|
|
198
224
|
});
|
|
225
|
+
setBehaviorListener(owner, 'scrollBeginDrag', event => {
|
|
226
|
+
handleScrollBeginDrag(owner, event);
|
|
227
|
+
});
|
|
199
228
|
setBehaviorListener(owner, 'momentumScrollEnd', event => {
|
|
200
229
|
handleMomentumScrollEnd(owner, event);
|
|
201
230
|
});
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
// The slot's fold is assigned to the node inside `buildStructure`, not declared on the behavior:
|
|
82
82
|
// `IHostBehavior.foldPayload` is the OWNER's, wired by `attachHostBehavior`, and a behavior that
|
|
83
83
|
// builds a node owns what that node carries.
|
|
84
|
-
import { appendChild, appListenerFor, createElement, dlog, registerHostBehavior, setBehaviorListener, setEventListener,
|
|
84
|
+
import { appendChild, appListenerFor, createElement, dlog, registerHostBehavior, setBehaviorListener, setEventListener, } from '@symbiote-native/engine';
|
|
85
85
|
import { descriptorFor } from '../../component-names';
|
|
86
86
|
import { didContentSizeChange, readLayoutDimension, } from '../../view/render-scroll-view.js';
|
|
87
87
|
import { installResponderPredicates, RESPONDER_OWNED_LISTENERS, } from './responder.js';
|
|
@@ -95,7 +95,11 @@ const SLOT_PROPS = {
|
|
|
95
95
|
};
|
|
96
96
|
// Owner props the SLOT's payload reads. Declared so a write to one dirties the slot — see
|
|
97
97
|
// `IHostBehavior.slotDerived` for why nothing else makes that happen.
|
|
98
|
-
const SLOT_DERIVED = [
|
|
98
|
+
const SLOT_DERIVED = [
|
|
99
|
+
'maintainVisibleContentPosition',
|
|
100
|
+
'snapToAlignment',
|
|
101
|
+
'removeClippedSubviews',
|
|
102
|
+
];
|
|
99
103
|
// A `<RefreshControl>` written among the app's children is claimed, and WHAT the owner does with
|
|
100
104
|
// it is the one thing that genuinely differs per platform — see the platform files. Resolved
|
|
101
105
|
// through `descriptorFor`, so this is `PullToRefreshView` on iOS and `AndroidSwipeRefreshLayout`
|
|
@@ -113,9 +117,8 @@ export const REFRESH_CONTROL = descriptorFor('refresh-control').component;
|
|
|
113
117
|
// intrinsic from; an app that also writes `horizontal` on the vertical tag is contradicting the
|
|
114
118
|
// element it chose, and the tag wins.
|
|
115
119
|
//
|
|
116
|
-
// `nestedScrollEnabled` defaults ON
|
|
117
|
-
//
|
|
118
|
-
// (`ScrollView.js:1862`) — parity here is with the wrapper this behavior replaced.
|
|
120
|
+
// `nestedScrollEnabled` defaults ON only on the Android RefreshControl WRAP path, as RN does
|
|
121
|
+
// (`ScrollView.js:1862`); elsewhere only the authored value is sent.
|
|
119
122
|
// THE OWNER'S FOLD IS GONE (2026-09-18) — `foldScrollViewProps` in `SymbioteFabricProps.cpp`. Every
|
|
120
123
|
// input it had was the node's own bag plus the AXIS, and the axis is the tag (`scroll-view` vs
|
|
121
124
|
// `horizontal-scroll-view`), so it was a tag rule by every criterion: the base style composition,
|
|
@@ -148,8 +151,7 @@ export const REFRESH_CONTROL = descriptorFor('refresh-control').component;
|
|
|
148
151
|
// `core/engine/cpp/tests/js/scroll-content-payload.itest.ts`.
|
|
149
152
|
// `rowStyle` USED TO BE A PARAMETER HERE and is not one any more: the content node's row direction
|
|
150
153
|
// is decided in the engine from that node's OWN tag (`horizontal-scroll-content`), so this builder
|
|
151
|
-
// no longer needs to know the axis to build it.
|
|
152
|
-
// row style for the things that DO still need it in JS.
|
|
154
|
+
// no longer needs to know the axis to build it.
|
|
153
155
|
function buildContent(contentIntrinsic) {
|
|
154
156
|
return (node) => {
|
|
155
157
|
const descriptor = descriptorFor(contentIntrinsic);
|
|
@@ -230,9 +232,6 @@ function syncOwnedListener(owner, name, wired) {
|
|
|
230
232
|
syncOwnerLayout(owner);
|
|
231
233
|
}
|
|
232
234
|
function scrollBehavior(contentIntrinsic, rowStyle, platform) {
|
|
233
|
-
// The row style is the horizontal tag's constant and nothing else carries it, so it IS the axis —
|
|
234
|
-
// deriving keeps the two from ever disagreeing about which behavior this is.
|
|
235
|
-
const horizontal = rowStyle !== undefined;
|
|
236
235
|
return {
|
|
237
236
|
// `scroll` and `layout` are owned for the collision reason rather than because the behavior
|
|
238
237
|
// consumes them: RN's ScrollView installs `_handleScroll` and `_handleLayout` on the native
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// compares against that seed. Reversed, the very first beat would see an empty mirror, decide the
|
|
22
22
|
// app's value had diverged, and command a redundant `setTextAndSelection` down to native on every
|
|
23
23
|
// input in the tree.
|
|
24
|
-
import { appListenerFor, blurTextInput, dispatchViewCommand, dlog, focusTextInput, propOf, propsOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setInputBlurred, setInputFocused, setProp, } from '@symbiote-native/engine';
|
|
24
|
+
import { appListenerFor, blurTextInput, dispatchViewCommand, dlog, focusTextInput, Platform, propOf, propsOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setInputBlurred, setInputFocused, setProp, } from '@symbiote-native/engine';
|
|
25
25
|
import { attachPressMachine, detachPressMachine, } from './pressable.js';
|
|
26
26
|
import { eventCountFromChange, foldText, INITIAL_EVENT_COUNT, SELECTION_NONE, shouldCommandText, textFromChange, } from '../state/text-input.js';
|
|
27
27
|
// Both spellings, because `multiline` picks between two Fabric views and the TAG is what decides.
|
|
@@ -265,7 +265,18 @@ function attach(node) {
|
|
|
265
265
|
setBehaviorListener(node, 'focus', event => onFocus(node, event));
|
|
266
266
|
setBehaviorListener(node, 'blur', event => onBlur(node, event));
|
|
267
267
|
setBehaviorListener(node, 'selectionChange', event => onSelectionChange(node, event));
|
|
268
|
-
attachPressMachine(node, {
|
|
268
|
+
attachPressMachine(node, {
|
|
269
|
+
refine: focusOnPress,
|
|
270
|
+
cancelableOf: textInputCancelable,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
// TextInput.js:597 with its `rejectResponderTermination = true` default (:905): iOS keeps the
|
|
274
|
+
// gesture unless the app opts in; Android hands Pressability `null`, i.e. its own default (yield).
|
|
275
|
+
function textInputCancelable(source) {
|
|
276
|
+
if (Platform.OS !== 'ios')
|
|
277
|
+
return undefined;
|
|
278
|
+
const reject = propOf(source, 'rejectResponderTermination');
|
|
279
|
+
return !(reject === undefined || Boolean(reject));
|
|
269
280
|
}
|
|
270
281
|
// The first commit is the earliest point where the node has BOTH its props and a Fabric tag. The
|
|
271
282
|
// mirror needs the first, `autoFocus` needs the second.
|
|
@@ -54,9 +54,9 @@
|
|
|
54
54
|
// since 2026-09-09, in the same commit that deleted the five wrappers, which is what makes it safe:
|
|
55
55
|
// while a wrapper still built its own Pressable + feedback View, registering would have left every
|
|
56
56
|
// TouchableNativeFeedback with two responders.
|
|
57
|
-
import { SLOT_DERIVED_ALL, appListenerFor,
|
|
57
|
+
import { SLOT_DERIVED_ALL, appListenerFor, markPropsDirty, Platform, registerHostBehavior, requestCommitFor, setBehaviorListener, } from '@symbiote-native/engine';
|
|
58
58
|
import { resolveButtonDisabled } from '../view/render-button.js';
|
|
59
|
-
import { asAccessibilityState, attachPressMachine, booleanOr, detachPressMachine, } from './pressable.js';
|
|
59
|
+
import { asAccessibilityState, attachPressMachine, booleanOr, detachPressMachine, withNativeFeedbackCommands, } from './pressable.js';
|
|
60
60
|
export const TOUCHABLE_NATIVE_FEEDBACK_TAG = 'touchable-native-feedback';
|
|
61
61
|
// TouchableNativeFeedback.js:214-221 — `disabled != null ? disabled : (aria-disabled ??
|
|
62
62
|
// accessibilityState.disabled)`, read by the OWNER's Pressability config (:213), same three-way
|
|
@@ -106,11 +106,6 @@ const PRESS_LISTENERS = [
|
|
|
106
106
|
// discriminant; the C++ rule asks only whether the value is an OBJECT and copies it into the slot,
|
|
107
107
|
// because the four factories that produce it (`render-touchable-native-feedback.ts`) are ours and
|
|
108
108
|
// the payload is not a place to re-validate what a typed factory already built.
|
|
109
|
-
// TouchableNativeFeedback.js:280 — `locationX ?? 0`. The bag is raw Fabric payload, so guard.
|
|
110
|
-
function hotspotAt(nativeEvent, key) {
|
|
111
|
-
const value = nativeEvent[key];
|
|
112
|
-
return typeof value === 'number' ? value : 0;
|
|
113
|
-
}
|
|
114
109
|
/**
|
|
115
110
|
* TNF's own Pressability config, applied to whatever node carries the responder.
|
|
116
111
|
*
|
|
@@ -128,34 +123,8 @@ function hotspotAt(nativeEvent, key) {
|
|
|
128
123
|
* moves.
|
|
129
124
|
*/
|
|
130
125
|
export const nativeFeedbackRefinement = (node, config) => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const hotspot = (event) => {
|
|
134
|
-
dispatchViewCommand(node, 'hotspotUpdate', [
|
|
135
|
-
hotspotAt(event.nativeEvent, 'locationX'),
|
|
136
|
-
hotspotAt(event.nativeEvent, 'locationY'),
|
|
137
|
-
]);
|
|
138
|
-
};
|
|
139
|
-
return {
|
|
140
|
-
...config,
|
|
141
|
-
minPressDuration: 0,
|
|
142
|
-
onPressIn(event) {
|
|
143
|
-
// RN's order: hotspot first, so the ripple starts where the finger is rather than at centre.
|
|
144
|
-
hotspot(event);
|
|
145
|
-
dispatchViewCommand(node, 'setPressed', [true]);
|
|
146
|
-
config.onPressIn?.(event);
|
|
147
|
-
},
|
|
148
|
-
onPressMove(event) {
|
|
149
|
-
hotspot(event);
|
|
150
|
-
// RN drops the app's own onPressMove here; ours forwards it, because on a tag that callback
|
|
151
|
-
// is the app's and iOS already delivers it.
|
|
152
|
-
config.onPressMove?.(event);
|
|
153
|
-
},
|
|
154
|
-
onPressOut(event) {
|
|
155
|
-
dispatchViewCommand(node, 'setPressed', [false]);
|
|
156
|
-
config.onPressOut?.(event);
|
|
157
|
-
},
|
|
158
|
-
};
|
|
126
|
+
const floorless = { ...config, minPressDuration: 0 };
|
|
127
|
+
return IS_ANDROID ? withNativeFeedbackCommands(node, floorless) : floorless;
|
|
159
128
|
};
|
|
160
129
|
// `cloneFold` LEFT THIS FILE ON 2026-09-18 — it is `foldCloneOntoChild` in
|
|
161
130
|
// `SymbioteFabricProps.cpp`, and the seam it needed is the first rule keyed on the PARENT'S tag
|
|
@@ -2,6 +2,7 @@ import { type IColorValue, type IDeviceEventSource, type INativeViewConfigSource
|
|
|
2
2
|
export type IBootstrapHostOptions = {
|
|
3
3
|
colorProcessor?: (value: IColorValue) => unknown;
|
|
4
4
|
imageSourceResolver?: (source: unknown) => unknown;
|
|
5
|
+
assetSourceResolver?: (source: unknown) => unknown;
|
|
5
6
|
deviceEventSource?: IDeviceEventSource;
|
|
6
7
|
nativeViewConfigSource?: INativeViewConfigSource;
|
|
7
8
|
debug?: boolean;
|
package/build/bootstrap/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// export): react-native's own source is Flow syntax Vitest's transform can't parse, so anything
|
|
6
6
|
// importing it directly must stay unreachable from the tested main index.ts.
|
|
7
7
|
import { processColor, DeviceEventEmitter, Image, } from 'react-native';
|
|
8
|
-
import { setColorProcessor, setDeviceEventSource, setImageSourceResolver, setNativeViewConfigSource, } from '@symbiote-native/engine';
|
|
8
|
+
import { setAssetSourceResolver, installBackHandler, setColorProcessor, setDeviceEventSource, setImageSourceResolver, setNativeViewConfigSource, } from '@symbiote-native/engine';
|
|
9
9
|
// @ts-expect-error react-native ships no types for this internal path (plain .js) - the
|
|
10
10
|
// try/catch below is what actually proves the shape, not TS.
|
|
11
11
|
import * as ReactNativeViewConfigRegistry from 'react-native/Libraries/Renderer/shims/ReactNativeViewConfigRegistry';
|
|
@@ -37,6 +37,9 @@ export function bootstrapHost(options = {}) {
|
|
|
37
37
|
globalThis.__SYMBIOTE_DEBUG__ = options.debug ?? process.env.DEBUG === '1';
|
|
38
38
|
setColorProcessor(options.colorProcessor ?? defaultColorProcessor);
|
|
39
39
|
setImageSourceResolver(options.imageSourceResolver ?? defaultImageSourceResolver);
|
|
40
|
+
setAssetSourceResolver(options.assetSourceResolver ?? defaultImageSourceResolver);
|
|
40
41
|
setDeviceEventSource(options.deviceEventSource ?? DeviceEventEmitter);
|
|
42
|
+
// After the event source: the back button subscribes through it (see installBackHandler).
|
|
43
|
+
installBackHandler();
|
|
41
44
|
setNativeViewConfigSource(options.nativeViewConfigSource ?? defaultNativeViewConfigSource);
|
|
42
45
|
}
|
|
@@ -31,20 +31,9 @@ const ANDROID_NAMES = {
|
|
|
31
31
|
switch: 'AndroidSwitch',
|
|
32
32
|
'activity-indicator': 'RCTView',
|
|
33
33
|
'activity-indicator-spinner': 'AndroidProgressBar',
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// renders a plain View on Android and applies NO insets. `RCTSafeAreaView` does exist and is
|
|
38
|
-
// registered (`ReactSafeAreaViewManager.REACT_CLASS`, wired in `MainReactPackage.kt:149`) and
|
|
39
|
-
// does real window-inset math — so we inset where RN does not, and an app ported from RN gets
|
|
40
|
-
// different Android layout with nothing to explain why.
|
|
41
|
-
//
|
|
42
|
-
// This table's rule is "the name is the ViewManager's REACT_CLASS", and by that rule the entry is
|
|
43
|
-
// correct. What nobody checked is whether RN's JS ROUTES there. Left as-is deliberately: insetting
|
|
44
|
-
// is the better behaviour and RN has deprecated its own component in favour of
|
|
45
|
-
// react-native-safe-area-context. Invisible to every audit we have, because both of our paths
|
|
46
|
-
// agree with each other and only disagree with RN — the boundary
|
|
47
|
-
// `.claude/rules/adapter-parity-audit.md` states about itself.
|
|
34
|
+
// DELIBERATE DIVERGENCE FROM RN (2026-09-23). RN's `SafeAreaView.js` is a plain View on Android,
|
|
35
|
+
// with no insets. `RCTSafeAreaView` is registered there too (`ReactSafeAreaViewManager`) and
|
|
36
|
+
// applies the window insets, so one screen is safe on both platforms.
|
|
48
37
|
'safe-area-view': 'RCTSafeAreaView',
|
|
49
38
|
modal: 'RCTModalHostView',
|
|
50
39
|
'refresh-control': 'AndroidSwipeRefreshLayout',
|
package/build/index.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export { imageStatics, setImageSourceResolver } from '@symbiote-native/engine';
|
|
|
27
27
|
export type { IInputAccessoryViewViewProps } from './view/render-input-accessory-view';
|
|
28
28
|
export { renderModal } from './view/render-modal';
|
|
29
29
|
export type { IModalViewProps, IModalAnimationType, IModalPresentationStyle, IModalOrientation, IModalOrientationChangeEvent, } from './view/render-modal';
|
|
30
|
-
export { modalReducer, createInitialModalState, shouldRenderModal, } from './state/modal';
|
|
30
|
+
export { modalReducer, createInitialModalState, isModalVisible, modalVisibilityAction, shouldRenderModal, } from './state/modal';
|
|
31
31
|
export type { IModalState, IModalAction } from './state/modal';
|
|
32
32
|
export { computeInset, configureKeyboardAvoidingAnimation, keyboardAvoidingEventNamesFor, readKeyboardAnimationTiming, readPrefersCrossFadeTransitions, readKeyboardFrame, readLayoutFrame, resolveKeyboardAvoidingLayout, DEFAULT_VERTICAL_OFFSET, } from './view/render-keyboard-avoiding-view';
|
|
33
33
|
export type { IComputeInsetOptions, IKeyboardAnimationTiming, IKeyboardAvoidingBehavior, IKeyboardAvoidingEventNames, IMeasuredFrame, IKeyboardFrame, IKeyboardAvoidingLayout, IResolveKeyboardAvoidingLayoutParams, } from './view/render-keyboard-avoiding-view';
|
|
@@ -50,7 +50,7 @@ export { reduceList, createInitialListState, listEffectSignature, } from './stat
|
|
|
50
50
|
export type { IListState, IListMetrics, IListReducerInputs, IListAction, IListEffect, IListReduceResult, } from './state/virtualized-list-reducer';
|
|
51
51
|
export { subscribeListDiagnostics } from './state/virtualized-list-diagnostics';
|
|
52
52
|
export type { IListDiagnosticFrame, IListDiagnosticMove, } from './state/virtualized-list-diagnostics';
|
|
53
|
-
export { SINGLE_COLUMN, chunkIntoRows, rowKeyExtractor, expandRowToken, expandRowViewability, lastItemOfRow, firstItemOfRow, } from './state/flat-list';
|
|
53
|
+
export { SINGLE_COLUMN, chunkIntoRows, rowKeyExtractor, expandRowToken, expandRowViewability, lastItemOfRow, firstItemOfRow, removeClippedSubviewsOrDefault, } from './state/flat-list';
|
|
54
54
|
export type { IRow } from './state/flat-list';
|
|
55
55
|
export { flattenSections, unwrapEntryItem, sectionEntryKey, scrollLocationToFlatIndex, } from './state/section-list';
|
|
56
56
|
export type { ISection, ISectionEntry, IVirtualizedSectionListHandle, } from './state/section-list';
|
package/build/index.js
CHANGED
|
@@ -35,7 +35,7 @@ export { renderImage } from './view/render-image/index.js';
|
|
|
35
35
|
export { imageStatics, setImageSourceResolver } from '@symbiote-native/engine';
|
|
36
36
|
// Modal: full 3-layer split (state machine gates the iOS keep-alive frame).
|
|
37
37
|
export { renderModal } from './view/render-modal.js';
|
|
38
|
-
export { modalReducer, createInitialModalState, shouldRenderModal, } from './state/modal.js';
|
|
38
|
+
export { modalReducer, createInitialModalState, isModalVisible, modalVisibilityAction, shouldRenderModal, } from './state/modal.js';
|
|
39
39
|
// KeyboardAvoidingView: pure inset/behavior math; the adapter owns the Keyboard subscription.
|
|
40
40
|
export { computeInset, configureKeyboardAvoidingAnimation, keyboardAvoidingEventNamesFor, readKeyboardAnimationTiming, readPrefersCrossFadeTransitions, readKeyboardFrame, readLayoutFrame, resolveKeyboardAvoidingLayout, DEFAULT_VERTICAL_OFFSET, } from './view/render-keyboard-avoiding-view.js';
|
|
41
41
|
// Pressable: the press state machine (logic) + render decisions. The pure lifecycle (timers,
|
|
@@ -74,7 +74,7 @@ export { DEFAULT_WINDOW_SIZE, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_REND
|
|
|
74
74
|
// out of each adapter's reactive glue. The adapter maps events to actions and runs the effects.
|
|
75
75
|
export { reduceList, createInitialListState, listEffectSignature, } from './state/virtualized-list-reducer.js';
|
|
76
76
|
export { subscribeListDiagnostics } from './state/virtualized-list-diagnostics.js';
|
|
77
|
-
export { SINGLE_COLUMN, chunkIntoRows, rowKeyExtractor, expandRowToken, expandRowViewability, lastItemOfRow, firstItemOfRow, } from './state/flat-list.js';
|
|
77
|
+
export { SINGLE_COLUMN, chunkIntoRows, rowKeyExtractor, expandRowToken, expandRowViewability, lastItemOfRow, firstItemOfRow, removeClippedSubviewsOrDefault, } from './state/flat-list.js';
|
|
78
78
|
export { flattenSections, unwrapEntryItem, sectionEntryKey, scrollLocationToFlatIndex, } from './state/section-list.js';
|
|
79
79
|
export { registerPressableBehavior, PRESSABLE_TAG, } from './behaviors/pressable.js';
|
|
80
80
|
export { registerTouchableOpacityBehavior, TOUCHABLE_OPACITY_TAG, } from './behaviors/touchable-opacity.js';
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// ScrollView: the imperative +
|
|
2
|
-
// ScrollView has no state machine). The imperative handle, the
|
|
3
|
-
// the
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
import { attachNativeEvent, dlog,
|
|
1
|
+
// ScrollView: the imperative + event-routing module (framework-agnostic, no 3-layer split:
|
|
2
|
+
// ScrollView has no state machine). The imperative handle, the scroll-event guard/forwarder, and
|
|
3
|
+
// the native sticky scroll-attach are platform- and framework-invariant, so they live here. The
|
|
4
|
+
// Android RefreshControl wrap's style split moved out — see the note below. The adapter supplies
|
|
5
|
+
// the lifecycle (the node getter, the effect) and re-exports these.
|
|
6
|
+
import { attachNativeEvent, dlog, isSymbioteEvent, } from '@symbiote-native/engine';
|
|
7
7
|
// THE ANDROID WRAP SPLIT LEFT THIS FILE ON 2026-09-18, and with it the last copy of RN's
|
|
8
8
|
// `splitLayoutProps` key partition. Both halves are `SymbioteFabricProps.cpp` now
|
|
9
9
|
// (`splitScrollViewStyle`, `foldRefreshWrapperProps`), reached off the tag and the tree rather than
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type IViewableItemsChangedInfo, type IViewToken } from './virtualized-list';
|
|
2
2
|
export declare const SINGLE_COLUMN = 1;
|
|
3
|
+
export declare function removeClippedSubviewsOrDefault<TValue>(removeClippedSubviews: TValue | null | undefined, os: string): TValue | boolean;
|
|
3
4
|
export interface IRow<ItemT> {
|
|
4
5
|
items: ItemT[];
|
|
5
6
|
startIndex: number;
|
package/build/state/flat-list.js
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
// only the element creation (createElement / h) for a row and the ref wiring.
|
|
7
7
|
import { defaultKeyExtractor, } from './virtualized-list.js';
|
|
8
8
|
export const SINGLE_COLUMN = 1;
|
|
9
|
+
// FlatList.js's `removeClippedSubviewsOrDefault`: off-screen rows are clipped by default on
|
|
10
|
+
// Android only (RN's `shouldUseRemoveClippedSubviewsAsDefaultOnIOS` is off in 0.86).
|
|
11
|
+
export function removeClippedSubviewsOrDefault(removeClippedSubviews, os) {
|
|
12
|
+
return removeClippedSubviews ?? os === 'android';
|
|
13
|
+
}
|
|
9
14
|
export function chunkIntoRows(data, columns) {
|
|
10
15
|
const rows = [];
|
|
11
16
|
for (let start = 0; start < data.length; start += columns) {
|
package/build/state/modal.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare function isModalVisible(visible: unknown): boolean;
|
|
1
2
|
export type IModalState = {
|
|
2
3
|
isRendered: boolean;
|
|
3
4
|
};
|
|
@@ -8,4 +9,5 @@ export type IModalAction = {
|
|
|
8
9
|
type: 'hide';
|
|
9
10
|
};
|
|
10
11
|
export declare function modalReducer(state: IModalState, action: IModalAction): IModalState;
|
|
11
|
-
export declare function shouldRenderModal(isVisible: boolean, state: IModalState): boolean;
|
|
12
|
+
export declare function shouldRenderModal(isVisible: boolean, state: IModalState, os?: string): boolean;
|
|
13
|
+
export declare function modalVisibilityAction(isVisible: boolean): IModalAction | undefined;
|
package/build/state/modal.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
// Modal
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
1
|
+
// Modal logic, framework-agnostic. On iOS RN keeps the modal mounted through its exit animation
|
|
2
|
+
// until native onDismiss arrives (Modal.js _shouldShowModal); only that event drops the
|
|
3
|
+
// keep-alive, then the app's onDismiss runs. Android has no dismiss event: `visible` alone.
|
|
4
|
+
import { Platform } from '@symbiote-native/engine';
|
|
5
|
+
// Modal.js `defaultProps.visible = true`: defaultProps fill only `undefined`, so an unset
|
|
6
|
+
// `visible` shows and an explicit `false`/`null` hides.
|
|
7
|
+
export function isModalVisible(visible) {
|
|
8
|
+
return visible === undefined || visible === true;
|
|
9
|
+
}
|
|
8
10
|
// On first render the keep-alive matches `visible`: a modal that starts visible is rendered,
|
|
9
11
|
// one that starts hidden contributes no node (the render gate returns null).
|
|
10
12
|
export function createInitialModalState(isVisible) {
|
|
@@ -20,9 +22,13 @@ export function modalReducer(state, action) {
|
|
|
20
22
|
return state.isRendered ? { isRendered: false } : state;
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// Modal.js `_shouldShowModal`: iOS keeps the node through its exit animation until the native
|
|
26
|
+
// dismiss drops the keep-alive; Android shows on `visible` alone.
|
|
27
|
+
export function shouldRenderModal(isVisible, state, os = Platform.OS) {
|
|
28
|
+
return os === 'ios' ? isVisible || state.isRendered : isVisible;
|
|
29
|
+
}
|
|
30
|
+
// Modal.js componentDidUpdate: false->true arms the keep-alive; true->false does NOTHING — only the
|
|
31
|
+
// native `onDismiss` (iOS) drops it, via `{type: 'hide'}` from the adapter's dismiss handler.
|
|
32
|
+
export function modalVisibilityAction(isVisible) {
|
|
33
|
+
return isVisible ? { type: 'show' } : undefined;
|
|
28
34
|
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// here, so a windowing / edge / viewability / MVCP bug — and the drift between three copies of it —
|
|
19
19
|
// is fixed once for all adapters.
|
|
20
20
|
import { dlog } from '@symbiote-native/engine';
|
|
21
|
-
import { EMPTY_OFFSET, FIRST_INDEX, NO_CONTENT_LENGTH_SENT, NO_INDEX, averageMeasuredStride, buildOffsets, computeEndReached, computeMvcpAdjustment, computeStartReached, computeViewableSet, computeWindow, decideEdgeReached, diffViewable, highestMeasuredIndex, indexOfItem, isSettledLayout, maxMinimumViewTime, offsetForEnd, offsetForIndex, resolveAverageLength, resolveItemKey, throttleWindow, wrapFixedLayout, } from './virtualized-list.js';
|
|
21
|
+
import { EMPTY_OFFSET, FIRST_INDEX, NO_CONTENT_LENGTH_SENT, NO_INDEX, averageMeasuredStride, buildOffsets, computeEndReached, computeMvcpAdjustment, computeStartReached, computeViewableSet, computeWindow, decideEdgeReached, diffViewable, highestMeasuredIndex, indexOfItem, isSettledLayout, maxMinimumViewTime, offsetForEnd, offsetForIndex, resolveAverageLength, resolveItemKey, throttleWindow, initialRenderRegion, wrapFixedLayout, } from './virtualized-list.js';
|
|
22
22
|
import { recordCellMove, recordListFrame, } from './virtualized-list-diagnostics.js';
|
|
23
23
|
export function createInitialListState() {
|
|
24
24
|
return {
|
|
@@ -91,7 +91,7 @@ function deriveMetrics(state, inputs) {
|
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
93
|
const target = computeWindow(count, offsets, lengths, state.scrollOffset, state.viewportLength, inputs.windowSize, inputs.initialNumToRender);
|
|
94
|
-
const throttled = throttleWindow(target, state.committedWindow, inputs.maxToRenderPerBatch);
|
|
94
|
+
const throttled = throttleWindow(target, state.committedWindow, inputs.maxToRenderPerBatch, initialRenderRegion(count, inputs.initialScrollIndex, inputs.initialNumToRender));
|
|
95
95
|
state.committedWindow = throttled;
|
|
96
96
|
state.metrics = {
|
|
97
97
|
count,
|
|
@@ -13,6 +13,7 @@ export declare const NO_INDEX = -1;
|
|
|
13
13
|
export declare const FULLY_VISIBLE_PERCENT = 100;
|
|
14
14
|
export declare const ON_EDGE_REACHED_EPSILON = 0.001;
|
|
15
15
|
export declare const NO_CONTENT_LENGTH_SENT = -1;
|
|
16
|
+
export declare function invertedYStyleFor(os: string): IViewStyle;
|
|
16
17
|
export declare const INVERTED_Y_STYLE: IViewStyle;
|
|
17
18
|
export declare const INVERTED_X_STYLE: IViewStyle;
|
|
18
19
|
export interface ICellLayout {
|
|
@@ -84,13 +85,20 @@ export declare function computeWindow(count: number, offsets: number[], lengths:
|
|
|
84
85
|
first: number;
|
|
85
86
|
last: number;
|
|
86
87
|
};
|
|
88
|
+
export declare function initialRenderRegion(count: number, initialScrollIndex: number | undefined, initialNumToRender: number): {
|
|
89
|
+
first: number;
|
|
90
|
+
last: number;
|
|
91
|
+
};
|
|
87
92
|
export declare function throttleWindow(target: {
|
|
88
93
|
first: number;
|
|
89
94
|
last: number;
|
|
90
95
|
}, previous: {
|
|
91
96
|
first: number;
|
|
92
97
|
last: number;
|
|
93
|
-
}, maxToRenderPerBatch: number
|
|
98
|
+
}, maxToRenderPerBatch: number, initialRegion: {
|
|
99
|
+
first: number;
|
|
100
|
+
last: number;
|
|
101
|
+
}): {
|
|
94
102
|
first: number;
|
|
95
103
|
last: number;
|
|
96
104
|
};
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// framework's own children (renderItem -> ReactNode / VNode), so there is no
|
|
20
20
|
// Descriptor render fn for a list - the shared layer for lists is this STATE/logic
|
|
21
21
|
// module, not a view/render-*.ts.
|
|
22
|
-
import { dlog } from '@symbiote-native/engine';
|
|
22
|
+
import { dlog, Platform } from '@symbiote-native/engine';
|
|
23
23
|
// Defaults match RN. windowSize is measured in viewport-lengths (21 => ten screens
|
|
24
24
|
// of buffer on each side of the visible region). initialNumToRender bounds the first
|
|
25
25
|
// paint before any layout is measured. maxToRenderPerBatch / batching period mirror
|
|
@@ -46,8 +46,15 @@ export const ON_EDGE_REACHED_EPSILON = 0.001;
|
|
|
46
46
|
// yet". Real content lengths are >= 0, so -1 can never collide with one.
|
|
47
47
|
export const NO_CONTENT_LENGTH_SENT = -1;
|
|
48
48
|
// Inversion flips the content container along the scroll axis; each cell re-flips so
|
|
49
|
-
// its own content stays upright
|
|
50
|
-
|
|
49
|
+
// its own content stays upright. VirtualizedList.js `styles.verticallyInverted`: Android flips
|
|
50
|
+
// with `scale: -1` because `scaleY: -1` can ANR on API 33+ (react-native#35350); the native side
|
|
51
|
+
// then moves the scrollbar back via `isInvertedVirtualizedList`.
|
|
52
|
+
export function invertedYStyleFor(os) {
|
|
53
|
+
return os === 'android'
|
|
54
|
+
? { transform: [{ scale: -1 }] }
|
|
55
|
+
: { transform: [{ scaleY: -1 }] };
|
|
56
|
+
}
|
|
57
|
+
export const INVERTED_Y_STYLE = invertedYStyleFor(Platform.OS);
|
|
51
58
|
export const INVERTED_X_STYLE = { transform: [{ scaleX: -1 }] };
|
|
52
59
|
// nativeEvent payload guards. The payloads arrive as `unknown` off the wire, so they
|
|
53
60
|
// are narrowed with runtime checks rather than cast.
|
|
@@ -166,13 +173,24 @@ export function computeWindow(count, offsets, lengths, scrollOffset, viewportLen
|
|
|
166
173
|
}
|
|
167
174
|
return { first, last };
|
|
168
175
|
}
|
|
176
|
+
// RN's `VirtualizedList._initialRenderRegion`: `initialNumToRender` cells from
|
|
177
|
+
// `initialScrollIndex`, clamped to the data. What a list paints before it has a window of its own.
|
|
178
|
+
export function initialRenderRegion(count, initialScrollIndex, initialNumToRender) {
|
|
179
|
+
const first = Math.max(FIRST_INDEX, Math.min(count - 1, Math.floor(initialScrollIndex ?? FIRST_INDEX)));
|
|
180
|
+
return { first, last: Math.min(count, first + initialNumToRender) - 1 };
|
|
181
|
+
}
|
|
169
182
|
// Clamp a freshly computed window against the previously-committed one so at most
|
|
170
183
|
// maxToRenderPerBatch new cells are added on each side per tick (RN's incremental fill).
|
|
171
184
|
// The window grows toward the target over successive batch ticks rather than snapping in
|
|
172
185
|
// one render: cheaper first paint on a big jump.
|
|
173
|
-
|
|
186
|
+
//
|
|
187
|
+
// With NO previous window - the list just received data - RN paints its initial region and grows
|
|
188
|
+
// from there (`_createRenderMask` adds `_initialRenderRegion` to a window constrained from empty),
|
|
189
|
+
// even when the viewport is already known. Snapping to the target instead mounted ~125 rows where
|
|
190
|
+
// RN mounts 10 on a 420pt viewport (`stock-virtualized-suite.itest.tsx`).
|
|
191
|
+
export function throttleWindow(target, previous, maxToRenderPerBatch, initialRegion) {
|
|
174
192
|
if (previous.last < previous.first)
|
|
175
|
-
return target;
|
|
193
|
+
return initialRegion.last < initialRegion.first ? target : initialRegion;
|
|
176
194
|
const first = Math.max(target.first, previous.first - maxToRenderPerBatch);
|
|
177
195
|
const last = Math.min(target.last, previous.last + maxToRenderPerBatch);
|
|
178
196
|
// Never present an empty window when the target is non-empty.
|
|
@@ -11,15 +11,6 @@
|
|
|
11
11
|
// What is NOT here, because a layer below already does it: the `aria-*` -> `accessibilityState`
|
|
12
12
|
// fold RN performs in `Button.js:326-331`. The engine folds those for every node
|
|
13
13
|
// (`core/engine/src/accessibility-props.ts`), so repeating it here would fold twice.
|
|
14
|
-
import { Platform } from '@symbiote-native/engine';
|
|
15
|
-
// Button.js:394-437, one constant per literal so a value cannot drift silently. The LABEL's seven
|
|
16
|
-
// went with `resolveButtonTextStyle` on 2026-09-18 — they live in `SymbioteFabricProps.cpp` beside
|
|
17
|
-
// the rule that reads them and are deliberately not duplicated here.
|
|
18
|
-
const ANDROID_BUTTON_BLUE = '#2196F3';
|
|
19
|
-
const ANDROID_DISABLED_BACKGROUND = '#dfdfdf';
|
|
20
|
-
const ANDROID_ELEVATION = 4;
|
|
21
|
-
const ANDROID_DISABLED_ELEVATION = 0;
|
|
22
|
-
const ANDROID_BORDER_RADIUS = 2;
|
|
23
14
|
// `BUTTON_ACCESSIBILITY_ROLE` and `resolveButtonImportantForAccessibility` WERE HERE and are gone
|
|
24
15
|
// (2026-09-18). Both are `foldButtonProps` in `SymbioteFabricProps.cpp` now, and neither had a
|
|
25
16
|
// caller left afterwards — only its own unit test, which is the shape this project calls a mirror:
|
|
@@ -73,10 +73,11 @@ export function renderModal(view, isRTL = I18nManager.isRTL) {
|
|
|
73
73
|
// supportedOrientations/allowSwipeDismissal, Android hardwareAccelerated/
|
|
74
74
|
// statusBarTranslucent/navigationBarTranslucent.
|
|
75
75
|
supportedOrientations: view.supportedOrientations,
|
|
76
|
-
hardwareAccelerated:
|
|
76
|
+
// Modal.js `defaultProps = {hardwareAccelerated: false, visible: true}`.
|
|
77
|
+
hardwareAccelerated: view.hardwareAccelerated ?? false,
|
|
77
78
|
statusBarTranslucent: view.statusBarTranslucent,
|
|
78
79
|
navigationBarTranslucent: view.navigationBarTranslucent,
|
|
79
80
|
allowSwipeDismissal: view.allowSwipeDismissal,
|
|
80
|
-
visible: view.visible,
|
|
81
|
+
visible: view.visible ?? true,
|
|
81
82
|
}, [container]);
|
|
82
83
|
}
|
|
@@ -4,14 +4,9 @@
|
|
|
4
4
|
// both live here so every adapter inherits the exact same surface. The adapter only attaches these
|
|
5
5
|
// statics onto its component value and nests the feedback View under its Pressable.
|
|
6
6
|
import { Platform } from '@symbiote-native/engine';
|
|
7
|
-
//
|
|
8
|
-
// && Platform.Version >= 23. Version is a string on iOS (where the gate is irrelevant) and a
|
|
9
|
-
// number on Android, so guard the type at runtime before the numeric compare, no cast.
|
|
10
|
-
const ANDROID_FOREGROUND_MIN_VERSION = 23;
|
|
7
|
+
// TouchableNativeFeedback.js:202 — the OS alone; RN's minSdk is past the old API-23 gate.
|
|
11
8
|
export function canUseNativeForeground() {
|
|
12
|
-
return
|
|
13
|
-
typeof Platform.Version === 'number' &&
|
|
14
|
-
Platform.Version >= ANDROID_FOREGROUND_MIN_VERSION);
|
|
9
|
+
return Platform.OS === 'android';
|
|
15
10
|
}
|
|
16
11
|
export function selectableBackground(rippleRadius) {
|
|
17
12
|
return {
|
|
@@ -27,15 +22,14 @@ export function selectableBackgroundBorderless(rippleRadius) {
|
|
|
27
22
|
rippleRadius,
|
|
28
23
|
};
|
|
29
24
|
}
|
|
30
|
-
// RN runs the color
|
|
31
|
-
//
|
|
25
|
+
// RN runs the color through processColor here; we keep the string and the engine's Android rule
|
|
26
|
+
// converts it (Java reads it with getInt, a string would fail). A null color is "no tint".
|
|
32
27
|
export function rippleBackground(color, borderless, rippleRadius) {
|
|
33
28
|
return { type: 'RippleAndroid', color, borderless, rippleRadius };
|
|
34
29
|
}
|
|
35
30
|
// `backgroundProps` WAS HERE AND IS GONE (2026-09-18). It mapped the resolved background plus
|
|
36
31
|
// `useForeground` onto the native slot Android reads, and that is the `#ifdef ANDROID` tail of
|
|
37
|
-
// `foldCloneOntoChild` in `SymbioteFabricProps.cpp
|
|
38
|
-
// `Platform.Version` on Android IS the api level and `androidApiLevel()` reads the same number.
|
|
32
|
+
// `foldCloneOntoChild` in `SymbioteFabricProps.cpp`.
|
|
39
33
|
//
|
|
40
34
|
// A JS COPY OF A RULE THAT RUNS IN C++, with no runtime caller and no test, reachable only through
|
|
41
35
|
// the package barrel. Found by asking what each `Platform.OS` branch still left in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@symbiote-native/components",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"description": "Framework-agnostic component logic (state machines + render functions) for SymbioteNative — written once, inherited by every adapter (React, Vue, Angular, ...).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -59,11 +59,11 @@
|
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"react-native": ">=0.86",
|
|
62
|
-
"@symbiote-native/engine": "^1.
|
|
62
|
+
"@symbiote-native/engine": "^1.3.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@types/node": "^26.0.0",
|
|
66
|
-
"@symbiote-native/engine": "1.
|
|
66
|
+
"@symbiote-native/engine": "1.3.0"
|
|
67
67
|
},
|
|
68
68
|
"scripts": {
|
|
69
69
|
"typecheck": "tsc --build",
|