@symbiote-native/components 3.1.2 → 3.1.3
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/build/behaviors/pressable.d.ts +5 -3
- package/build/behaviors/pressable.js +50 -20
- package/build/behaviors/refresh-control.js +8 -2
- package/build/behaviors/scroll-view/responder.d.ts +3 -2
- package/build/behaviors/scroll-view/responder.js +19 -21
- package/build/behaviors/scroll-view/shared.js +15 -4
- package/build/behaviors/switch.js +18 -4
- package/build/behaviors/text-input.js +20 -6
- package/build/behaviors/touchable-opacity.d.ts +0 -7
- package/build/behaviors/touchable-opacity.js +62 -63
- package/build/behaviors/touchable-without-feedback.js +19 -17
- package/package.json +3 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type IHostBehavior, type ISymbioteNode } from '@symbiote-native/engine';
|
|
1
|
+
import { type IEventDispatch, 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";
|
|
@@ -9,12 +9,14 @@ export declare function booleanOr(value: unknown): boolean | undefined;
|
|
|
9
9
|
export declare function asAccessibilityState(value: unknown): IAccessibilityStateValue | undefined;
|
|
10
10
|
export declare function accessibleUnlessOptedOut(props: Readonly<Record<string, unknown>>): boolean;
|
|
11
11
|
export declare function withNativeFeedbackCommands(node: ISymbioteNode, config: IPressMachineConfig): IPressMachineConfig;
|
|
12
|
-
export declare
|
|
12
|
+
export declare const PRESS_DISPATCH: IEventDispatch;
|
|
13
|
+
export type IAttachOptions = {
|
|
13
14
|
readonly refine?: IPressConfigRefinement;
|
|
14
15
|
readonly disabledOf?: IDisabledResolver;
|
|
15
16
|
readonly cancelableOf?: ICancelableResolver;
|
|
16
17
|
readonly source?: ISymbioteNode;
|
|
17
|
-
}
|
|
18
|
+
};
|
|
19
|
+
export declare function attachPressMachine(node: ISymbioteNode, options?: IAttachOptions): void;
|
|
18
20
|
/** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
|
|
19
21
|
export declare function detachPressMachine(node: ISymbioteNode): void;
|
|
20
22
|
export declare function createPressBehavior(refine?: IPressConfigRefinement, disabledOf?: IDisabledResolver): Pick<IHostBehavior, 'attach' | 'detach' | 'ownedListeners'>;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Registration, not the machine, is the hazard: Metro's inlineRequires makes a barrel re-export of
|
|
5
5
|
// a side-effect-only module never run in a release build. Each adapter keeps its own
|
|
6
6
|
// `src/register.ts` calling registerPressableBehavior(), reached only by a bare side-effect import.
|
|
7
|
-
import { appListenerFor, dispatchViewCommand, dlog, Platform, propOf, registerHostBehavior, requestCommitFor,
|
|
7
|
+
import { appListenerFor, dispatchViewCommand, dlog, Platform, propOf, registerHostBehavior, requestCommitFor, setNodeDispatch, setNodePressed, propsOf, } from '@symbiote-native/engine';
|
|
8
8
|
import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_MIN_PRESS_DURATION_MS, } from '../state/pressable.js';
|
|
9
9
|
import { buildPressableListeners } from '../view/render-pressable/index.js';
|
|
10
10
|
export const PRESSABLE_TAG = 'pressable';
|
|
@@ -196,7 +196,8 @@ const GESTURE_END_KEYS = new Set([
|
|
|
196
196
|
'onPressOut',
|
|
197
197
|
'onResponderTerminationRequest',
|
|
198
198
|
]);
|
|
199
|
-
function dispatch(node,
|
|
199
|
+
function dispatch(node, options, key, args) {
|
|
200
|
+
const state = stateOf(node, options);
|
|
200
201
|
if (!state.isBuilt)
|
|
201
202
|
rebuild(node, state);
|
|
202
203
|
const listener = state.listeners[key];
|
|
@@ -209,15 +210,10 @@ function dispatch(node, state, key, args) {
|
|
|
209
210
|
state.isBuilt = false;
|
|
210
211
|
return result;
|
|
211
212
|
}
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
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.
|
|
220
|
-
const EVENT_KEY_PAIRS = [
|
|
213
|
+
// Engine event name -> the app-facing callback key it routes to. NOT the same list as
|
|
214
|
+
// `createPressBehavior`'s `ownedListeners`, which is every name the machine takes as an INPUT;
|
|
215
|
+
// this is only what it answers when Fabric fires
|
|
216
|
+
const DISPATCH_KEYS = new Map([
|
|
221
217
|
['press', 'onPress'],
|
|
222
218
|
['pressIn', 'onPressIn'],
|
|
223
219
|
['pressOut', 'onPressOut'],
|
|
@@ -225,24 +221,55 @@ const EVENT_KEY_PAIRS = [
|
|
|
225
221
|
['responderMove', 'onResponderMove'],
|
|
226
222
|
['responderTerminationRequest', 'onResponderTerminationRequest'],
|
|
227
223
|
['responderGrant', 'onResponderGrant'],
|
|
228
|
-
];
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
224
|
+
]);
|
|
225
|
+
// The per-node half of what the seven dispatcher closures used to capture. A behavior that
|
|
226
|
+
// registered no refinement leaves its nodes out of here entirely
|
|
227
|
+
const attached = new WeakMap();
|
|
228
|
+
// ONE object for every pressable in the app, where seven closures plus the `Map` holding them used
|
|
229
|
+
// to sit per node. `attach` runs inside `createElement`, so what it allocates every list item pays
|
|
230
|
+
// Exported for a tag that is a pressable PLUS something with names of its own (`./text-input`): it
|
|
231
|
+
// composes a union and delegates here, since a node holds exactly one dispatch
|
|
232
|
+
export const PRESS_DISPATCH = {
|
|
233
|
+
names: new Set(DISPATCH_KEYS.keys()),
|
|
234
|
+
deliver(node, name, event) {
|
|
235
|
+
const key = DISPATCH_KEYS.get(name);
|
|
236
|
+
if (key === undefined)
|
|
237
|
+
return undefined;
|
|
238
|
+
return dispatch(node, attached.get(node) ?? NO_OPTIONS, key, [event]);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
234
241
|
function attachWith(refine, disabledOf) {
|
|
235
|
-
|
|
242
|
+
// One bag per BEHAVIOR, not per node: `refine` and `disabledOf` are fixed at registration, and a
|
|
243
|
+
// behavior with neither shares the empty one so a plain `<pressable>` stores nothing
|
|
244
|
+
const options = refine === undefined && disabledOf === undefined
|
|
245
|
+
? NO_OPTIONS
|
|
246
|
+
: { refine, disabledOf };
|
|
247
|
+
return node => attach(node, options);
|
|
236
248
|
}
|
|
249
|
+
const NO_OPTIONS = {};
|
|
237
250
|
// The machine on `node`, reading props and callbacks off `options.source` when different.
|
|
238
251
|
// Exported for touchable-native-feedback, whose tag commits nothing and adopts the app's single
|
|
239
252
|
// child as the responder; every other caller goes through createPressBehavior where they're equal.
|
|
240
253
|
// Re-callable on the same node: a second call replaces the state and dispatchers, which is what
|
|
241
254
|
// re-arming after detachPressMachine needs.
|
|
242
|
-
export function attachPressMachine(node, options =
|
|
255
|
+
export function attachPressMachine(node, options = NO_OPTIONS) {
|
|
243
256
|
attach(node, options);
|
|
244
257
|
}
|
|
258
|
+
// One pointer, plus one map slot for a refined behavior: everything below is read from `dispatch`
|
|
259
|
+
// alone, so a pressable nobody touches never builds a gesture runtime
|
|
260
|
+
// A re-attach drops the old state so the new options take effect on the next gesture
|
|
245
261
|
function attach(node, options) {
|
|
262
|
+
states.delete(node);
|
|
263
|
+
if (options === NO_OPTIONS)
|
|
264
|
+
attached.delete(node);
|
|
265
|
+
else
|
|
266
|
+
attached.set(node, options);
|
|
267
|
+
setNodeDispatch(node, PRESS_DISPATCH);
|
|
268
|
+
}
|
|
269
|
+
function stateOf(node, options) {
|
|
270
|
+
const existing = states.get(node);
|
|
271
|
+
if (existing !== undefined)
|
|
272
|
+
return existing;
|
|
246
273
|
const timers = new Set();
|
|
247
274
|
const runtime = createPressRuntime();
|
|
248
275
|
const host = {
|
|
@@ -285,14 +312,17 @@ function attach(node, options) {
|
|
|
285
312
|
isBuilt: false,
|
|
286
313
|
};
|
|
287
314
|
states.set(node, state);
|
|
288
|
-
|
|
315
|
+
return state;
|
|
289
316
|
}
|
|
290
317
|
/** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
|
|
291
318
|
export function detachPressMachine(node) {
|
|
292
319
|
detach(node);
|
|
293
320
|
}
|
|
294
321
|
function detach(node) {
|
|
322
|
+
setNodeDispatch(node, undefined);
|
|
323
|
+
attached.delete(node);
|
|
295
324
|
const state = states.get(node);
|
|
325
|
+
// A pressable nobody touched has no state at all, and the two releases above are all it owes
|
|
296
326
|
if (state === undefined)
|
|
297
327
|
return;
|
|
298
328
|
// The machine's own teardown. Not load-bearing here: host.schedule puts every timer the machine
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
// `behaviors/switch.ts`'s module header, verbatim. Same shape, same two triggers, same reasons —
|
|
30
30
|
// an ACCEPTING app's state reaches `node.props` only after its own reconciliation, and a REJECTING
|
|
31
31
|
// app writes no prop at all, so the commit that `afterCommit` waits for never comes.
|
|
32
|
-
import { appListenerFor, dispatchViewCommand, dlog, registerHostBehavior,
|
|
32
|
+
import { appListenerFor, dispatchViewCommand, dlog, registerHostBehavior, setNodeDispatch, propOf, } from '@symbiote-native/engine';
|
|
33
33
|
export const REFRESH_CONTROL_TAG = 'refresh-control';
|
|
34
34
|
// RN's own name for the command, sent to whichever of the two native views the platform resolved.
|
|
35
35
|
const SET_NATIVE_REFRESHING = 'setNativeRefreshing';
|
|
@@ -75,10 +75,16 @@ function onRefresh(node, event) {
|
|
|
75
75
|
listener(event);
|
|
76
76
|
queueMicrotask(() => evaluateSnapBack(node));
|
|
77
77
|
}
|
|
78
|
+
// ONE object for every RefreshControl in the app, where a closure per node used to be
|
|
79
|
+
const REFRESH_DISPATCH = {
|
|
80
|
+
names: new Set(['refresh']),
|
|
81
|
+
deliver: (node, _name, event) => onRefresh(node, event),
|
|
82
|
+
};
|
|
78
83
|
function attach(node) {
|
|
79
|
-
|
|
84
|
+
setNodeDispatch(node, REFRESH_DISPATCH);
|
|
80
85
|
}
|
|
81
86
|
function detach(node) {
|
|
87
|
+
setNodeDispatch(node, undefined);
|
|
82
88
|
reported.delete(node);
|
|
83
89
|
}
|
|
84
90
|
// Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type ISymbioteNode } from '@symbiote-native/engine';
|
|
1
|
+
import { type ISymbioteEvent, type ISymbioteNode } from '@symbiote-native/engine';
|
|
2
2
|
export declare function markScrollObserved(owner: ISymbioteNode): void;
|
|
3
3
|
export declare const RESPONDER_OWNED_LISTENERS: readonly string[];
|
|
4
|
-
export declare
|
|
4
|
+
export declare const RESPONDER_DISPATCHED_NAMES: ReadonlySet<string>;
|
|
5
|
+
export declare function deliverResponderEvent(owner: ISymbioteNode, name: string, event: ISymbioteEvent): unknown;
|
|
@@ -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, dlog, isDebug, isRecord, isSymbioteNode, Keyboard, Platform, propOf,
|
|
8
|
+
import { appListenerFor, currentlyFocusedInput, blurTextInput, dlog, isDebug, isRecord, isSymbioteNode, Keyboard, Platform, propOf, } 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
|
|
@@ -208,24 +208,22 @@ export const RESPONDER_OWNED_LISTENERS = [
|
|
|
208
208
|
'momentumScrollBegin',
|
|
209
209
|
'momentumScrollEnd',
|
|
210
210
|
];
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
handleMomentumScrollEnd(owner, event);
|
|
230
|
-
});
|
|
211
|
+
// The eight names an owner answers, as ONE module-level table where eight closures per node used to
|
|
212
|
+
// be. `./shared` composes it with its own `scroll` into the owner's `IEventDispatch`
|
|
213
|
+
const HANDLERS = new Map([
|
|
214
|
+
['startShouldSetResponderCapture', startShouldSetResponderCapture],
|
|
215
|
+
['startShouldSetResponder', startShouldSetResponder],
|
|
216
|
+
[
|
|
217
|
+
'responderTerminationRequest',
|
|
218
|
+
(owner) => responderTerminationRequest(owner),
|
|
219
|
+
],
|
|
220
|
+
['responderGrant', handleResponderGrant],
|
|
221
|
+
['responderRelease', handleResponderRelease],
|
|
222
|
+
['momentumScrollBegin', handleMomentumScrollBegin],
|
|
223
|
+
['scrollBeginDrag', handleScrollBeginDrag],
|
|
224
|
+
['momentumScrollEnd', handleMomentumScrollEnd],
|
|
225
|
+
]);
|
|
226
|
+
export const RESPONDER_DISPATCHED_NAMES = new Set(HANDLERS.keys());
|
|
227
|
+
export function deliverResponderEvent(owner, name, event) {
|
|
228
|
+
return HANDLERS.get(name)?.(owner, event);
|
|
231
229
|
}
|
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
// `contentContainerStyle` travels through `slotProps` as a pure RENAME onto the slot's `style`;
|
|
21
21
|
// the row-direction CONSTANT is a `payloadFold` instead, assigned to the slot node inside
|
|
22
22
|
// `buildStructure` since `IHostBehavior.foldPayload` is only ever the OWNER's.
|
|
23
|
-
import { appendChild, appListenerFor, createElement, dlog, registerHostBehavior,
|
|
23
|
+
import { appendChild, appListenerFor, createElement, dlog, registerHostBehavior, setNodeDispatch, setEventListener, } from '@symbiote-native/engine';
|
|
24
24
|
import { descriptorFor } from '../../component-names';
|
|
25
25
|
import { didContentSizeChange, readLayoutDimension, } from '../../view/render-scroll-view.js';
|
|
26
|
-
import {
|
|
26
|
+
import { deliverResponderEvent, RESPONDER_DISPATCHED_NAMES, RESPONDER_OWNED_LISTENERS, } from './responder.js';
|
|
27
27
|
import { handleOwnerScroll, markScrollOwner, reconcileStickyIndices, releaseStickyOwner, stickyHeaderBehavior, STICKY_HEADER_TAG, syncOwnerLayout, } from './sticky.js';
|
|
28
28
|
export const SCROLL_VIEW_TAG = 'scroll-view';
|
|
29
29
|
export const HORIZONTAL_SCROLL_VIEW_TAG = 'horizontal-scroll-view';
|
|
@@ -105,6 +105,17 @@ function syncContentSizeWiring(owner, wired) {
|
|
|
105
105
|
setEventListener(slot, 'layout', undefined);
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
+
// The nine names an owner answers, as ONE object for every ScrollView in the app. `scroll` is in
|
|
109
|
+
// here unconditionally т.к. it drives the sticky `AnimatedValue`, and a header may register long
|
|
110
|
+
// after the node was created
|
|
111
|
+
const OWNER_DISPATCH = {
|
|
112
|
+
names: new Set([...RESPONDER_DISPATCHED_NAMES, 'scroll']),
|
|
113
|
+
deliver(node, name, event) {
|
|
114
|
+
if (name === 'scroll')
|
|
115
|
+
return handleOwnerScroll(node, event);
|
|
116
|
+
return deliverResponderEvent(node, name, event);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
108
119
|
// Two owned names answer to a flip, and they answer on DIFFERENT nodes: `contentSizeChange` wires
|
|
109
120
|
// the SLOT's layout, `layout` wires the owner's own — which an inverted sticky header also wants,
|
|
110
121
|
// so the two claims are resolved in one place (`syncOwnerLayout`) rather than by whoever wrote last.
|
|
@@ -133,14 +144,14 @@ function scrollBehavior(contentIntrinsic, rowStyle, platform) {
|
|
|
133
144
|
// a header can register long after this node was created.
|
|
134
145
|
attach(node) {
|
|
135
146
|
markScrollOwner(node);
|
|
136
|
-
|
|
137
|
-
installResponderPredicates(node);
|
|
147
|
+
setNodeDispatch(node, OWNER_DISPATCH);
|
|
138
148
|
},
|
|
139
149
|
onOwnedListenerChange: syncOwnedListener,
|
|
140
150
|
// The one beat where the app's children are all present — no hook reports a children CHANGE,
|
|
141
151
|
// and reconcileStickyIndices returns on a WeakSet miss for any that never used the prop.
|
|
142
152
|
afterCommit: reconcileStickyIndices,
|
|
143
153
|
detach(node) {
|
|
154
|
+
setNodeDispatch(node, undefined);
|
|
144
155
|
lastContentSize.delete(node);
|
|
145
156
|
releaseStickyOwner(node);
|
|
146
157
|
},
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
// unresolved. `afterCommit` costs nothing extra (it fires only on a commit that already changed
|
|
37
37
|
// something) and closes that one case; the microtask path is what the no-op-handler case actually
|
|
38
38
|
// needs.
|
|
39
|
-
import { appListenerFor, dispatchViewCommand, dlog, Platform, propOf, registerHostBehavior,
|
|
39
|
+
import { appListenerFor, dispatchViewCommand, dlog, Platform, propOf, registerHostBehavior, setNodeDispatch, } from '@symbiote-native/engine';
|
|
40
40
|
import { createInitialSwitchState, shouldSnapBack, switchReducer, valueFromChange, } from '../state/switch.js';
|
|
41
41
|
export const SWITCH_TAG = 'switch';
|
|
42
42
|
const states = new WeakMap();
|
|
@@ -120,13 +120,27 @@ function alwaysClaimsResponder() {
|
|
|
120
120
|
function neverYieldsResponder() {
|
|
121
121
|
return false;
|
|
122
122
|
}
|
|
123
|
+
// ONE object for every Switch in the app, where three closures per node used to be
|
|
124
|
+
const SWITCH_DISPATCH = {
|
|
125
|
+
names: new Set([
|
|
126
|
+
'change',
|
|
127
|
+
'startShouldSetResponder',
|
|
128
|
+
'responderTerminationRequest',
|
|
129
|
+
]),
|
|
130
|
+
deliver(node, name, event) {
|
|
131
|
+
if (name === 'change')
|
|
132
|
+
return onChange(node, event);
|
|
133
|
+
return name === 'startShouldSetResponder'
|
|
134
|
+
? alwaysClaimsResponder()
|
|
135
|
+
: neverYieldsResponder();
|
|
136
|
+
},
|
|
137
|
+
};
|
|
123
138
|
function attach(node) {
|
|
124
139
|
states.set(node, createInitialSwitchState());
|
|
125
|
-
|
|
126
|
-
setBehaviorListener(node, 'startShouldSetResponder', alwaysClaimsResponder);
|
|
127
|
-
setBehaviorListener(node, 'responderTerminationRequest', neverYieldsResponder);
|
|
140
|
+
setNodeDispatch(node, SWITCH_DISPATCH);
|
|
128
141
|
}
|
|
129
142
|
function detach(node) {
|
|
143
|
+
setNodeDispatch(node, undefined);
|
|
130
144
|
states.delete(node);
|
|
131
145
|
}
|
|
132
146
|
// Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// The order of the two commit hooks is load-bearing, pinned by a test: attachAfterCommit seeds
|
|
7
7
|
// lastNativeText from mount-time props, afterCommit compares against that seed. Reversed, the
|
|
8
8
|
// first beat would see an empty mirror and command a redundant write on every input.
|
|
9
|
-
import { appListenerFor, blurTextInput, dispatchViewCommand, dlog, focusTextInput, Platform, propOf, propsOf, registerHostBehavior, requestCommitFor,
|
|
10
|
-
import { attachPressMachine, detachPressMachine, } from './pressable.js';
|
|
9
|
+
import { appListenerFor, blurTextInput, dispatchViewCommand, dlog, focusTextInput, Platform, propOf, propsOf, registerHostBehavior, requestCommitFor, setInputBlurred, setInputFocused, setNodeDispatch, setProp, } from '@symbiote-native/engine';
|
|
10
|
+
import { attachPressMachine, detachPressMachine, PRESS_DISPATCH, } from './pressable.js';
|
|
11
11
|
import { eventCountFromChange, foldText, INITIAL_EVENT_COUNT, SELECTION_NONE, shouldCommandText, textFromChange, } from '../state/text-input.js';
|
|
12
12
|
// Both spellings, because `multiline` picks between two Fabric views and the TAG is what decides.
|
|
13
13
|
// The behavior is registered for both so it does not care which one the app wrote.
|
|
@@ -186,15 +186,29 @@ function attach(node) {
|
|
|
186
186
|
// No `requestCommitFor` here: at create the renderer commits anyway, and on a re-attach the key
|
|
187
187
|
// is already at this value, so `setProp`'s identity guard makes the write a no-op.
|
|
188
188
|
setProp(node, 'mostRecentEventCount', INITIAL_EVENT_COUNT);
|
|
189
|
-
setBehaviorListener(node, 'change', event => onChange(node, event));
|
|
190
|
-
setBehaviorListener(node, 'focus', event => onFocus(node, event));
|
|
191
|
-
setBehaviorListener(node, 'blur', event => onBlur(node, event));
|
|
192
|
-
setBehaviorListener(node, 'selectionChange', event => onSelectionChange(node, event));
|
|
193
189
|
attachPressMachine(node, {
|
|
194
190
|
refine: focusOnPress,
|
|
195
191
|
cancelableOf: textInputCancelable,
|
|
196
192
|
});
|
|
193
|
+
// AFTER the machine, which points the node at its own: a node holds exactly one dispatch, and
|
|
194
|
+
// `TEXT_INPUT_DISPATCH` is the union that delegates the machine's seven back to it
|
|
195
|
+
setNodeDispatch(node, TEXT_INPUT_DISPATCH);
|
|
197
196
|
}
|
|
197
|
+
const OWN_HANDLERS = new Map([
|
|
198
|
+
['change', onChange],
|
|
199
|
+
['focus', onFocus],
|
|
200
|
+
['blur', onBlur],
|
|
201
|
+
['selectionChange', onSelectionChange],
|
|
202
|
+
]);
|
|
203
|
+
const TEXT_INPUT_DISPATCH = {
|
|
204
|
+
names: new Set([...OWN_HANDLERS.keys(), ...PRESS_DISPATCH.names]),
|
|
205
|
+
deliver(node, name, event) {
|
|
206
|
+
const own = OWN_HANDLERS.get(name);
|
|
207
|
+
if (own !== undefined)
|
|
208
|
+
return own(node, event);
|
|
209
|
+
return PRESS_DISPATCH.deliver(node, name, event);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
198
212
|
// TextInput.js:597 with its `rejectResponderTermination = true` default (:905): iOS keeps the
|
|
199
213
|
// gesture unless the app opts in; Android hands Pressability `null`, i.e. its own default (yield).
|
|
200
214
|
function textInputCancelable(source) {
|
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
import { type IHostBehavior } from '@symbiote-native/engine';
|
|
2
2
|
import { type IDisabledResolver } from './pressable';
|
|
3
3
|
export declare const TOUCHABLE_OPACITY_TAG = "touchable-opacity";
|
|
4
|
-
/**
|
|
5
|
-
* The behavior as PARTS, so a tag that is a TouchableOpacity plus something — `button`, which RN
|
|
6
|
-
* builds as exactly that (Button.js:283) — composes the fade instead of re-implementing it.
|
|
7
|
-
*
|
|
8
|
-
* Safe to hand to two tags: every piece of runtime is keyed by NODE (`states`), and `press` is
|
|
9
|
-
* itself already shared that way.
|
|
10
|
-
*/
|
|
11
4
|
export declare function createTouchableOpacityBehavior(disabledOf?: IDisabledResolver): IHostBehavior;
|
|
12
5
|
export declare function registerTouchableOpacityBehavior(): void;
|
|
@@ -25,12 +25,32 @@
|
|
|
25
25
|
// event props. Deliberately not implemented — dead on every device this project targets (no tvOS
|
|
26
26
|
// build, no example app; iOS/Android are the only targets). Audit skill, "Found, NOT fixed: TV
|
|
27
27
|
// (Platform.isTV) focus/blur feedback on TouchableOpacity/Highlight".
|
|
28
|
-
import { AnimatedMock, AnimatedValue, Easing, Platform, dlog, getExplicitStyle, markPropsDirty, registerHostBehavior, requestCommitFor, setAnimatedBehaviorStyle, timing, propOf, propsOf, } from '@symbiote-native/engine';
|
|
28
|
+
import { AnimatedMock, AnimatedValue, Easing, Platform, dlog, getExplicitStyle, markPropsDirty, registerHostBehavior, requestCommitFor, setAnimatedBehaviorStyle, setProp, timing, propOf, propsOf, } from '@symbiote-native/engine';
|
|
29
29
|
import { resolveButtonDisabled } from '../view/render-button.js';
|
|
30
30
|
import { asAccessibilityState, booleanOr, createPressBehavior, } from './pressable.js';
|
|
31
|
-
import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS, OPACITY_ACTIVE_GRANT_DURATION_MS, OPACITY_INACTIVE_DURATION_MS,
|
|
31
|
+
import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS, OPACITY_ACTIVE_GRANT_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, restingOpacityFromStyle, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '../state/touchable.js';
|
|
32
32
|
export const TOUCHABLE_OPACITY_TAG = 'touchable-opacity';
|
|
33
33
|
const states = new WeakMap();
|
|
34
|
+
// Built on the FIRST FADE, not at attach: the three together read 1 343 B per node
|
|
35
|
+
// (`touchable-attach-cost.itest.ts`) and nothing reads them until a finger lands
|
|
36
|
+
function stateOf(node) {
|
|
37
|
+
const existing = states.get(node);
|
|
38
|
+
if (existing !== undefined)
|
|
39
|
+
return existing;
|
|
40
|
+
// Seeded at the CURRENT resting opacity, which the first `afterCommit` used to set
|
|
41
|
+
const state = {
|
|
42
|
+
opacity: new AnimatedValue(restingOpacityOf(node)),
|
|
43
|
+
runtime: createTouchableFeedbackRuntime(),
|
|
44
|
+
timers: new Set(),
|
|
45
|
+
isBound: false,
|
|
46
|
+
};
|
|
47
|
+
states.set(node, state);
|
|
48
|
+
return state;
|
|
49
|
+
}
|
|
50
|
+
// What the last commit settled at, apart from the state above т.к. `afterCommit` runs for every
|
|
51
|
+
// mounted node while that state is owed only by one that actually fades
|
|
52
|
+
// `undefined` is the mount, where RN re-settles nothing (it is `componentDidUpdate` only)
|
|
53
|
+
const settledAt = new WeakMap();
|
|
34
54
|
// `modules/animated`'s barrel swaps the whole driver namespace for the mock when the host reports
|
|
35
55
|
// reduced motion; the one driver used here is swapped on the same flag.
|
|
36
56
|
const startTiming = Platform.isDisableAnimations ? AnimatedMock.timing : timing;
|
|
@@ -46,7 +66,16 @@ function numberOr(value, fallback) {
|
|
|
46
66
|
function restingOpacityOf(node) {
|
|
47
67
|
return restingOpacityFromStyle(getExplicitStyle(node));
|
|
48
68
|
}
|
|
49
|
-
|
|
69
|
+
// Registered on FIRST FADE, not at attach: the leaf costs 9.8 us and 5.9 KB per node
|
|
70
|
+
// (`touchable-attach-cost.itest.ts`) and a list nobody presses pays it for a value that never moves
|
|
71
|
+
function ensureLayer(node, state) {
|
|
72
|
+
if (state.isBound)
|
|
73
|
+
return;
|
|
74
|
+
state.isBound = true;
|
|
75
|
+
setAnimatedBehaviorStyle(node, { opacity: state.opacity });
|
|
76
|
+
}
|
|
77
|
+
function fadeTo(node, state, toValue, ms) {
|
|
78
|
+
ensureLayer(node, state);
|
|
50
79
|
dlog(`touchable-opacity opacity -> ${toValue} over ${ms}ms`);
|
|
51
80
|
// useNativeDriver is RN's own (TouchableOpacity.js:242): opacity is natively drivable, so the
|
|
52
81
|
// fade survives a busy JS thread, which is the whole point of press feedback.
|
|
@@ -61,9 +90,7 @@ function fadeTo(state, toValue, ms) {
|
|
|
61
90
|
// hold when a finger lands, and the handlers it builds are discarded with the gesture. The runtime
|
|
62
91
|
// that must outlive one — an in-flight delayPressIn timer, the activation clock — is on the node.
|
|
63
92
|
const refine = (node, config) => {
|
|
64
|
-
const state =
|
|
65
|
-
if (state === undefined)
|
|
66
|
-
return config;
|
|
93
|
+
const state = stateOf(node);
|
|
67
94
|
const resting = restingOpacityOf(node);
|
|
68
95
|
const activeOpacity = numberOr(propOf(node, 'activeOpacity'), DEFAULT_ACTIVE_OPACITY);
|
|
69
96
|
const handlers = createTouchableFeedbackHandlers({
|
|
@@ -86,18 +113,16 @@ const refine = (node, config) => {
|
|
|
86
113
|
now: Date.now,
|
|
87
114
|
}, state.runtime, {
|
|
88
115
|
activate(event) {
|
|
89
|
-
// TouchableOpacity.js:215-220 picks the duration from WHERE the press-in came from:
|
|
90
|
-
//
|
|
91
|
-
// a drift-out/drift-back-in reactivation arrives as 'responderMove'
|
|
92
|
-
// (`state/pressable.ts`'s `handleResponderMove`) and gets the 150ms ease-back-in instead.
|
|
116
|
+
// TouchableOpacity.js:215-220 picks the duration from WHERE the press-in came from: a
|
|
117
|
+
// plain grant fades instantly, a drift-back-in arrives as `responderMove` and eases
|
|
93
118
|
const duration = event.type === 'responderMove'
|
|
94
119
|
? OPACITY_ACTIVE_DURATION_MS
|
|
95
120
|
: OPACITY_ACTIVE_GRANT_DURATION_MS;
|
|
96
|
-
fadeTo(state, activeOpacity, duration);
|
|
121
|
+
fadeTo(node, state, activeOpacity, duration);
|
|
97
122
|
config.onPressIn?.(event);
|
|
98
123
|
},
|
|
99
124
|
deactivate(event) {
|
|
100
|
-
fadeTo(state, resting, OPACITY_INACTIVE_DURATION_MS);
|
|
125
|
+
fadeTo(node, state, resting, OPACITY_INACTIVE_DURATION_MS);
|
|
101
126
|
config.onPressOut?.(event);
|
|
102
127
|
},
|
|
103
128
|
});
|
|
@@ -115,13 +140,9 @@ const refine = (node, config) => {
|
|
|
115
140
|
// Without it, a caller who sets only `accessibilityState={{disabled: true}}` (no `disabled` prop)
|
|
116
141
|
// gets the greyed-out RN look but a press that still fires here — RN suppresses it.
|
|
117
142
|
const touchableOpacityDisabled = props => resolveButtonDisabled(booleanOr(props.disabled), booleanOr(props['aria-disabled']), asAccessibilityState(props.accessibilityState));
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
*
|
|
122
|
-
* Safe to hand to two tags: every piece of runtime is keyed by NODE (`states`), and `press` is
|
|
123
|
-
* itself already shared that way.
|
|
124
|
-
*/
|
|
143
|
+
// The behavior as PARTS, so a tag that is a TouchableOpacity plus something composes the fade
|
|
144
|
+
// instead of re-implementing it (`button` is exactly that, Button.js:283)
|
|
145
|
+
// Safe to hand to two tags: every piece of runtime is keyed by NODE, `press` included
|
|
125
146
|
export function createTouchableOpacityBehavior(disabledOf) {
|
|
126
147
|
// Its own machine rather than the module-level `press`, so a composing tag can say what
|
|
127
148
|
// `disabled` MEANS to it — the answer feeds the machine, not a payload: the props half of that
|
|
@@ -130,72 +151,50 @@ export function createTouchableOpacityBehavior(disabledOf) {
|
|
|
130
151
|
return {
|
|
131
152
|
...machine,
|
|
132
153
|
attach(node) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
timers: new Set(),
|
|
137
|
-
settled: undefined,
|
|
138
|
-
};
|
|
139
|
-
states.set(node, state);
|
|
140
|
-
// Bound at ATTACH rather than at the first press, matching RN: the binding is what forces
|
|
141
|
-
// `collapsable: false`, and a view Fabric un-flattens mid-gesture would lose the responder.
|
|
142
|
-
// Nothing is published until a fade runs — the leaf writes only when its value moves.
|
|
143
|
-
setAnimatedBehaviorStyle(node, { opacity: state.opacity });
|
|
154
|
+
// The half of the binding that CANNOT wait for the first press: a view Fabric flattens
|
|
155
|
+
// mid-gesture loses the tag the responder lands on. Everything else follows in `stateOf`
|
|
156
|
+
setProp(node, 'collapsable', false);
|
|
144
157
|
machine.attach(node);
|
|
145
158
|
},
|
|
146
159
|
// RN's componentDidUpdate: a changed `disabled` or a changed style opacity re-settles the view,
|
|
147
160
|
// so a Touchable disabled mid-press does not stay stuck at its active opacity.
|
|
148
161
|
afterCommit(node) {
|
|
149
|
-
const state = states.get(node);
|
|
150
|
-
if (state === undefined)
|
|
151
|
-
return;
|
|
152
162
|
const resting = restingOpacityOf(node);
|
|
153
|
-
// Through the SAME resolver the press machine uses, not the raw prop: on
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
// stayed at its active opacity while the press was already suppressed. RN's Button has no
|
|
157
|
-
// such gap because it passes the resolved value DOWN as its touchable's prop.
|
|
163
|
+
// Through the SAME resolver the press machine uses, not the raw prop: on `button` `disabled`
|
|
164
|
+
// also answers to `aria-disabled` / `accessibilityState` (Button.js:331,337), and reading the
|
|
165
|
+
// prop alone left an aria-only flip un-settled at its active opacity
|
|
158
166
|
const props = propsOf(node);
|
|
159
167
|
const disabled = disabledOf?.(props) ?? props.disabled;
|
|
160
|
-
const previous =
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
// TouchableOpacity commits no `opacity` key at all. Vendor's native-animated leaf is a
|
|
166
|
-
// separate channel from the ordinary style diff, so forcing non-flattening never implies
|
|
167
|
-
// publishing a value. Ours publishes anyway: `setAnimatedBehaviorStyle`/`bindAnimatedValue`
|
|
168
|
-
// (`host-binding.ts`) force `collapsable: false` and resolve+write the leaf's value in the
|
|
169
|
-
// SAME call. Untangling that is a change to shared animation infrastructure, not a one-line
|
|
170
|
-
// fix here — deferred, see the audit skill. Set rather than animate: this is the mount, and
|
|
171
|
-
// RN re-settles on componentDidUpdate only.
|
|
172
|
-
//
|
|
173
|
-
// TODO(rn-parity): skip this initial publish when `resting === RESTING_OPACITY` (1, Fabric's
|
|
174
|
-
// own opacity default) and no authored `style.opacity` needs another route to Fabric, so a
|
|
175
|
-
// resting default TouchableOpacity commits no `opacity` key — matching vendor. Needs
|
|
176
|
-
// `bindAnimatedValue`/`host-binding.ts` to separate "force collapsable:false" from "publish
|
|
177
|
-
// the leaf's value" (shared by every animated-behavior consumer, not just this tag), plus a
|
|
178
|
-
// new test for the untouched-default case (no existing case in touchable-opacity.test.ts
|
|
179
|
-
// covers it — every case authors an explicit style.opacity first).
|
|
180
|
-
state.opacity.setValue(resting);
|
|
168
|
+
const previous = settledAt.get(node);
|
|
169
|
+
settledAt.set(node, { disabled, resting });
|
|
170
|
+
// The mount, where RN re-settles nothing. An untouched node holds no `AnimatedValue` yet, and
|
|
171
|
+
// `stateOf` seeds the one it eventually builds at whatever `resting` is by then
|
|
172
|
+
if (previous === undefined)
|
|
181
173
|
return;
|
|
182
|
-
}
|
|
183
174
|
if (previous.disabled === disabled && previous.resting === resting)
|
|
184
175
|
return;
|
|
185
|
-
fadeTo(
|
|
176
|
+
fadeTo(node, stateOf(node), resting, OPACITY_INACTIVE_DURATION_MS);
|
|
186
177
|
},
|
|
187
178
|
detach(node) {
|
|
188
179
|
machine.detach(node);
|
|
180
|
+
settledAt.delete(node);
|
|
189
181
|
const state = states.get(node);
|
|
190
|
-
|
|
182
|
+
// Never faded, so the flatten guard `attach` wrote is the only thing owed back
|
|
183
|
+
if (state === undefined) {
|
|
184
|
+
setProp(node, 'collapsable', undefined);
|
|
191
185
|
return;
|
|
186
|
+
}
|
|
192
187
|
for (const id of state.timers)
|
|
193
188
|
clearTimeout(id);
|
|
194
189
|
state.timers.clear();
|
|
195
190
|
// RN's componentWillUnmount: stop the animation so a teardown mid-fade leaves no driver
|
|
196
191
|
// running against a node that is gone.
|
|
197
192
|
state.opacity.resetAnimation();
|
|
198
|
-
|
|
193
|
+
// Dropping the layer clears `collapsable` on its way out, so only an unbound node still owes
|
|
194
|
+
if (state.isBound)
|
|
195
|
+
setAnimatedBehaviorStyle(node, undefined);
|
|
196
|
+
else
|
|
197
|
+
setProp(node, 'collapsable', undefined);
|
|
199
198
|
states.delete(node);
|
|
200
199
|
dlog('touchable-opacity behavior detached');
|
|
201
200
|
},
|
|
@@ -71,15 +71,22 @@ function numberOr(value, fallback) {
|
|
|
71
71
|
// The clone is `foldCloneOntoChild` in C++, keyed on the PARENT'S tag; TWF's passthrough half
|
|
72
72
|
// copies only when SET, unlike TNF's unconditional list. One upstream quirk it reproduces:
|
|
73
73
|
// `nativeID` wins over `id` here (`node.nativeIdWinsOverId`, `clone-onto-child-payload.itest.ts`).
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
// Built on the FIRST GESTURE, not at arm: nothing reads the runtime or the timer set until a
|
|
75
|
+
// finger lands, and a list of touchables nobody presses paid ~500 B per item for them
|
|
76
|
+
function stateOf(owner) {
|
|
77
|
+
const existing = states.get(owner);
|
|
78
|
+
if (existing !== undefined)
|
|
79
|
+
return existing;
|
|
80
|
+
const state = {
|
|
81
|
+
runtime: createTouchableFeedbackRuntime(),
|
|
82
|
+
timers: new Set(),
|
|
83
|
+
};
|
|
84
|
+
states.set(owner, state);
|
|
85
|
+
return state;
|
|
86
|
+
}
|
|
87
|
+
function refinementFor(owner) {
|
|
82
88
|
return (_node, config) => {
|
|
89
|
+
const state = stateOf(owner);
|
|
83
90
|
const handlers = createTouchableFeedbackHandlers({
|
|
84
91
|
delayPressIn: numberOr(propOf(owner, 'delayPressIn'), 0),
|
|
85
92
|
delayPressOut: numberOr(propOf(owner, 'delayPressOut'), 0),
|
|
@@ -127,18 +134,13 @@ function trampolineFor(owner, name) {
|
|
|
127
134
|
function forwardListener(owner, child, name, wired) {
|
|
128
135
|
setBehaviorListener(child, name, wired ? trampolineFor(owner, name) : undefined);
|
|
129
136
|
}
|
|
130
|
-
// The machine plus the forwarded listeners. Split from adoption
|
|
131
|
-
// with its child and its fold intact but its machine torn down
|
|
132
|
-
// NOT re-chain the fold
|
|
137
|
+
// The machine plus the forwarded listeners. Split from adoption т.к. a parked subtree comes back
|
|
138
|
+
// with its child and its fold intact but its machine torn down, so `attach` re-runs this and must
|
|
139
|
+
// NOT re-chain the fold
|
|
133
140
|
function arm(owner, child) {
|
|
134
|
-
let state = states.get(owner);
|
|
135
|
-
if (state === undefined) {
|
|
136
|
-
state = { runtime: createTouchableFeedbackRuntime(), timers: new Set() };
|
|
137
|
-
states.set(owner, state);
|
|
138
|
-
}
|
|
139
141
|
attachPressMachine(child, {
|
|
140
142
|
source: owner,
|
|
141
|
-
refine: refinementFor(owner
|
|
143
|
+
refine: refinementFor(owner),
|
|
142
144
|
});
|
|
143
145
|
for (const name of FORWARDED_LISTENERS) {
|
|
144
146
|
forwardListener(owner, child, name, appListenerFor(owner, name) !== undefined);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@symbiote-native/components",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.3",
|
|
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.4.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@types/node": "^26.0.0",
|
|
66
|
-
"@symbiote-native/engine": "1.
|
|
66
|
+
"@symbiote-native/engine": "1.4.0"
|
|
67
67
|
},
|
|
68
68
|
"scripts": {
|
|
69
69
|
"typecheck": "tsc --build",
|