@symbiote-native/components 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.d.ts CHANGED
@@ -12,12 +12,14 @@ export type { ISwitchProps, ISwitchViewProps, ISwitchPlatform, ISwitchTrackColor
12
12
  export type { IResponderProps } from './responder-props';
13
13
  export { switchReducer, createInitialSwitchState, shouldSnapBack, valueFromChange, } from './state/switch';
14
14
  export type { ISwitchState, ISwitchAction } from './state/switch';
15
- export { resolveDecelerationRate, selectScrollIntrinsics, readLayoutDimension, didContentSizeChange, SCROLL_VIEW_BASE_HORIZONTAL, SCROLL_VIEW_BASE_VERTICAL, } from './view/render-scroll-view';
16
- export type { IScrollIntrinsics, IContentSize } from './view/render-scroll-view';
15
+ export { resolveDecelerationRate, selectScrollIntrinsics, readLayoutDimension, didContentSizeChange, resolveScrollForwarding, SCROLL_VIEW_BASE_HORIZONTAL, SCROLL_VIEW_BASE_VERTICAL, } from './view/render-scroll-view';
16
+ export type { IScrollIntrinsics, IContentSize, IScrollForwarding, IScrollForwardingInputs, IScrollForwardMode, } from './view/render-scroll-view';
17
17
  export { buildScrollViewHandle, splitLayoutProps, attachStickyScroll, isSymbioteEvent, forwardScrollEvent, } from './scroll-view-commands';
18
18
  export type { IScrollViewHandle } from './scroll-view-commands';
19
19
  export { computeStickyInterpolation, nextStickyHeaderY, stickyDebounceMs, readLayoutNumber, STICKY_HEADER_Z_INDEX, } from './view/render-scroll-sticky';
20
20
  export type { IStickyHeaderProps, IStickyInterpolationParams } from './view/render-scroll-sticky';
21
+ export { reduceSticky, createInitialStickyState, stickyEffectSignature, } from './state/sticky-header-reducer';
22
+ export type { IStickyHeaderState, IStickyReducerInputs, IStickyAction, IStickyEffect, IStickyReduceResult, } from './state/sticky-header-reducer';
21
23
  export type { IImageSource, IImageSourceProp, IResizeMode, IImageProps, IImageViewProps, } from './view/render-image';
22
24
  export { renderImage } from './view/render-image';
23
25
  export type { IImageStatics, IImageSize, IImageCacheStatus } from '@symbiote-native/engine';
@@ -35,8 +37,9 @@ export type { IKeyboardAvoidingBehavior, IMeasuredFrame, IKeyboardFrame, IKeyboa
35
37
  export { createPressHandlers, createPressRuntime, normalizeRect, maxEdge, isTouchWithinRegion, readPoint, computeRegion, rippleProps, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_PRESS_RECT_OFFSETS, } from './state/pressable';
36
38
  export type { IPressState, IPressHandler, IPressHandlers, IPressHost, IPressRuntime, IPressMachineConfig, IFrameCallback, IRectOffset, IEdgeInsets, IResponderRegion, IPressableAndroidRippleConfig, IRippleBackground as IPressableRippleBackground, } from './state/pressable';
37
39
  export { buildPressableListeners, resolveDisabledAccessibilityState, noteHoverNoop, shouldSuppressPress, shouldClaimResponder, isTerminationAllowed, } from './view/render-pressable';
38
- export { computePressOutWait, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, DEFAULT_HIGHLIGHT_CHILD_OPACITY, DEFAULT_UNDERLAY_COLOR, DEFAULT_MIN_PRESS_DURATION_MS, } from './state/touchable';
39
- export type { IPressTimingProps, ITouchableHandler } from './state/touchable';
40
+ export { computePressOutWait, createTouchableFeedbackRuntime, createTouchableFeedbackHandlers, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, DEFAULT_HIGHLIGHT_CHILD_OPACITY, DEFAULT_UNDERLAY_COLOR, DEFAULT_MIN_PRESS_DURATION_MS, } from './state/touchable';
41
+ export type { IPressTimingProps, ITouchableHandler, ITouchableFeedbackRuntime, ITouchableFeedbackConfig, ITouchableFeedbackCallbacks, ITouchableFeedbackHandlers, } from './state/touchable';
42
+ export { highlightPressedStyle } from './view/render-touchable-highlight';
40
43
  export { backgroundProps, canUseNativeForeground, selectableBackground, selectableBackgroundBorderless, rippleBackground, } from './view/render-touchable-native-feedback';
41
44
  export type { INativeFeedbackBackground, IThemeAttrBackground, IRippleBackground, } from './view/render-touchable-native-feedback';
42
45
  export { BUTTON_ACCESSIBILITY_ROLE, buttonTextStyle, resolveButtonTextStyle, } from './view/render-button';
@@ -45,8 +48,10 @@ export { resolveTextInputProps, foldText, foldAutoComplete, foldSubmitBehavior,
45
48
  export type { ITextInputProps, ITextInputHandle, ITextInputSelection, ITextInputEventHandler, ITextInputFoldInput, IFoldedTextInputProps, IInputMode, IEnterKeyHint, ISubmitBehavior, } from './state/text-input';
46
49
  export { renderTextInput } from './view/render-text-input';
47
50
  export type { ITextInputViewProps } from './view/render-text-input';
48
- export { DEFAULT_WINDOW_SIZE, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_END_REACHED_THRESHOLD, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_VIEW_AREA_COVERAGE_PERCENT_THRESHOLD, DEFAULT_START_REACHED_THRESHOLD, FIRST_INDEX, EMPTY_OFFSET, NO_INDEX, NO_CONTENT_LENGTH_SENT, INVERTED_Y_STYLE, INVERTED_X_STYLE, readScrollOffset, readLayoutLength, buildOffsets, computeWindow, throttleWindow, visiblePercent, isCellViewable, offsetForIndex, averageMeasuredLength, highestMeasuredIndex, computeEndReached, computeStartReached, buildViewabilityPairs, computeViewableSet, diffViewable, maxMinimumViewTime, buildListPlan, } from './state/virtualized-list';
49
- export type { ICellLayout, ISeparators, ISeparatorProps, IViewToken, IViewableItemsChangedInfo, IViewabilityConfig, IViewabilityConfigCallbackPair, IVirtualizedListHandle, IViewableSetParams, IListCellPlan, IListPlan, IListPlanParams, } from './state/virtualized-list';
51
+ export { DEFAULT_WINDOW_SIZE, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_END_REACHED_THRESHOLD, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_VIEW_AREA_COVERAGE_PERCENT_THRESHOLD, DEFAULT_START_REACHED_THRESHOLD, FIRST_INDEX, EMPTY_OFFSET, NO_INDEX, NO_CONTENT_LENGTH_SENT, INVERTED_Y_STYLE, INVERTED_X_STYLE, readScrollOffset, readLayoutLength, buildOffsets, computeWindow, throttleWindow, visiblePercent, isCellViewable, offsetForIndex, averageMeasuredLength, highestMeasuredIndex, computeEndReached, computeStartReached, buildViewabilityPairs, computeViewableSet, diffViewable, maxMinimumViewTime, buildListPlan, computeMvcpAdjustment, resolveItemKey, indexOfItem, offsetForEnd, isSeparatorGapInRange, decideEdgeReached, resolveStickySectionHeaders, wrapFixedLayout, resolveAverageLength, } from './state/virtualized-list';
52
+ export type { IMvcpAction, IMvcpAdjustmentParams, IMvcpAdjustmentResult, ICellLayout, ISeparators, ISeparatorProps, IViewToken, IViewableItemsChangedInfo, IViewabilityConfig, IViewabilityConfigCallbackPair, IVirtualizedListHandle, IViewableSetParams, IListCellPlan, IListPlan, IListPlanParams, } from './state/virtualized-list';
53
+ export { reduceList, createInitialListState, listEffectSignature, } from './state/virtualized-list-reducer';
54
+ export type { IListState, IListMetrics, IListReducerInputs, IListAction, IListEffect, IListReduceResult, } from './state/virtualized-list-reducer';
50
55
  export { SINGLE_COLUMN, chunkIntoRows, rowKeyExtractor, expandRowToken, expandRowViewability, lastItemOfRow, firstItemOfRow, } from './state/flat-list';
51
56
  export type { IRow } from './state/flat-list';
52
57
  export { flattenSections, unwrapEntryItem, sectionEntryKey, scrollLocationToFlatIndex, } from './state/section-list';
package/build/index.js CHANGED
@@ -21,10 +21,15 @@ export { switchReducer, createInitialSwitchState, shouldSnapBack, valueFromChang
21
21
  // ScrollView: pure render/command helpers (no state machine, no 3-layer split). The adapter
22
22
  // owns the refs/effects/element assembly and the sticky-header component; these supply the
23
23
  // platform-invariant math and plumbing every adapter shares.
24
- export { resolveDecelerationRate, selectScrollIntrinsics, readLayoutDimension, didContentSizeChange, SCROLL_VIEW_BASE_HORIZONTAL, SCROLL_VIEW_BASE_VERTICAL, } from './view/render-scroll-view.js';
24
+ export { resolveDecelerationRate, selectScrollIntrinsics, readLayoutDimension, didContentSizeChange, resolveScrollForwarding, SCROLL_VIEW_BASE_HORIZONTAL, SCROLL_VIEW_BASE_VERTICAL, } from './view/render-scroll-view.js';
25
25
  export { buildScrollViewHandle, splitLayoutProps, attachStickyScroll, isSymbioteEvent, forwardScrollEvent, } from './scroll-view-commands.js';
26
26
  export { computeStickyInterpolation, nextStickyHeaderY, stickyDebounceMs, readLayoutNumber, STICKY_HEADER_Z_INDEX, } from './view/render-scroll-sticky.js';
27
- export { renderImage } from './view/render-image.js';
27
+ // The per-header sticky effect machine: the one pure state machine every sticky-header consumer drives
28
+ // (React/Vue header component, Angular header component, Angular projection wrapper), folding the
29
+ // zero-swallow gate + debounce-delay pick + rebuild-on-input-change decision out of each adapter's
30
+ // reactive glue. The adapter maps events to actions and executes the effects with its own primitives.
31
+ export { reduceSticky, createInitialStickyState, stickyEffectSignature, } from './state/sticky-header-reducer.js';
32
+ export { renderImage } from './view/render-image/index.js';
28
33
  export { imageStatics, setImageSourceResolver } from '@symbiote-native/engine';
29
34
  // ImageBackground: render-only composition (absolute-fill image behind, children on top).
30
35
  export { renderImageBackground } from './view/render-image-background.js';
@@ -43,7 +48,8 @@ export { createPressHandlers, createPressRuntime, normalizeRect, maxEdge, isTouc
43
48
  export { buildPressableListeners, resolveDisabledAccessibilityState, noteHoverNoop, shouldSuppressPress, shouldClaimResponder, isTerminationAllowed, } from './view/render-pressable/index.js';
44
49
  // Touchable*: shared press-timing constants + the deactivation-floor math (the Animated feedback
45
50
  // itself stays per-adapter), and TouchableNativeFeedback's pure static factories + background map.
46
- export { computePressOutWait, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, DEFAULT_HIGHLIGHT_CHILD_OPACITY, DEFAULT_UNDERLAY_COLOR, DEFAULT_MIN_PRESS_DURATION_MS, } from './state/touchable.js';
51
+ export { computePressOutWait, createTouchableFeedbackRuntime, createTouchableFeedbackHandlers, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, DEFAULT_HIGHLIGHT_CHILD_OPACITY, DEFAULT_UNDERLAY_COLOR, DEFAULT_MIN_PRESS_DURATION_MS, } from './state/touchable.js';
52
+ export { highlightPressedStyle } from './view/render-touchable-highlight.js';
47
53
  export { backgroundProps, canUseNativeForeground, selectableBackground, selectableBackgroundBorderless, rippleBackground, } from './view/render-touchable-native-feedback.js';
48
54
  // Button: shared base text style, role constant, and color fold (the adapter composes its own
49
55
  // TouchableOpacity + Text).
@@ -59,6 +65,10 @@ export { renderTextInput } from './view/render-text-input.js';
59
65
  // have NO view/render-*.ts (the cell content is the framework's own children, so there is
60
66
  // no Descriptor render fn) — the shared layer for lists is this STATE/logic, reused
61
67
  // verbatim by React and Vue.
62
- export { DEFAULT_WINDOW_SIZE, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_END_REACHED_THRESHOLD, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_VIEW_AREA_COVERAGE_PERCENT_THRESHOLD, DEFAULT_START_REACHED_THRESHOLD, FIRST_INDEX, EMPTY_OFFSET, NO_INDEX, NO_CONTENT_LENGTH_SENT, INVERTED_Y_STYLE, INVERTED_X_STYLE, readScrollOffset, readLayoutLength, buildOffsets, computeWindow, throttleWindow, visiblePercent, isCellViewable, offsetForIndex, averageMeasuredLength, highestMeasuredIndex, computeEndReached, computeStartReached, buildViewabilityPairs, computeViewableSet, diffViewable, maxMinimumViewTime, buildListPlan, } from './state/virtualized-list.js';
68
+ export { DEFAULT_WINDOW_SIZE, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_END_REACHED_THRESHOLD, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_VIEW_AREA_COVERAGE_PERCENT_THRESHOLD, DEFAULT_START_REACHED_THRESHOLD, FIRST_INDEX, EMPTY_OFFSET, NO_INDEX, NO_CONTENT_LENGTH_SENT, INVERTED_Y_STYLE, INVERTED_X_STYLE, readScrollOffset, readLayoutLength, buildOffsets, computeWindow, throttleWindow, visiblePercent, isCellViewable, offsetForIndex, averageMeasuredLength, highestMeasuredIndex, computeEndReached, computeStartReached, buildViewabilityPairs, computeViewableSet, diffViewable, maxMinimumViewTime, buildListPlan, computeMvcpAdjustment, resolveItemKey, indexOfItem, offsetForEnd, isSeparatorGapInRange, decideEdgeReached, resolveStickySectionHeaders, wrapFixedLayout, resolveAverageLength, } from './state/virtualized-list.js';
69
+ // The orchestration reducer: the one pure state machine every list adapter drives, folding the
70
+ // after-commit effect skeleton (window recompute -> edge -> viewability -> initial-scroll -> MVCP)
71
+ // out of each adapter's reactive glue. The adapter maps events to actions and runs the effects.
72
+ export { reduceList, createInitialListState, listEffectSignature, } from './state/virtualized-list-reducer.js';
63
73
  export { SINGLE_COLUMN, chunkIntoRows, rowKeyExtractor, expandRowToken, expandRowViewability, lastItemOfRow, firstItemOfRow, } from './state/flat-list.js';
64
74
  export { flattenSections, unwrapEntryItem, sectionEntryKey, scrollLocationToFlatIndex, } from './state/section-list.js';
@@ -0,0 +1,53 @@
1
+ export interface IStickyHeaderState {
2
+ measured: boolean;
3
+ layoutY: number;
4
+ layoutHeight: number;
5
+ translateY: number | null;
6
+ haveReceivedInitialZeroTranslateY: boolean;
7
+ inputRange: number[];
8
+ outputRange: number[];
9
+ }
10
+ export interface IStickyReducerInputs {
11
+ os: string;
12
+ inverted: boolean | undefined;
13
+ scrollViewHeight: number | undefined;
14
+ nextHeaderLayoutY: number | undefined;
15
+ index?: number;
16
+ }
17
+ export type IStickyAction = {
18
+ kind: 'layout';
19
+ y: number;
20
+ height: number;
21
+ } | {
22
+ kind: 'inputs-changed';
23
+ } | {
24
+ kind: 'animated-tick';
25
+ value: number;
26
+ } | {
27
+ kind: 'debounce-fired';
28
+ value: number;
29
+ };
30
+ export type IStickyEffect = {
31
+ kind: 'rebuild-interpolation';
32
+ inputRange: number[];
33
+ outputRange: number[];
34
+ } | {
35
+ kind: 'schedule-debounce';
36
+ delay: number;
37
+ value: number;
38
+ } | {
39
+ kind: 'apply-passthrough';
40
+ translateY: number;
41
+ } | {
42
+ kind: 'record-header-y';
43
+ index: number;
44
+ y: number;
45
+ };
46
+ export interface IStickyReduceResult {
47
+ state: IStickyHeaderState;
48
+ effects: IStickyEffect[];
49
+ changed: boolean;
50
+ }
51
+ export declare function createInitialStickyState(): IStickyHeaderState;
52
+ export declare function stickyEffectSignature(state: IStickyHeaderState): string;
53
+ export declare function reduceSticky(state: IStickyHeaderState, action: IStickyAction, inputs: IStickyReducerInputs): IStickyReduceResult;
@@ -0,0 +1,114 @@
1
+ // Sticky-header effect machine: the framework-agnostic STATE MACHINE that folds every per-adapter
2
+ // sticky-header effect skeleton into one place. Before this, each adapter (React useState/useEffect,
3
+ // Vue refs/watchEffect, Angular fields/ngOnChanges — and, in Angular, a SECOND copy inside the
4
+ // projection wrapper) re-wrote the same per-header sequence — gate the freshly-rebuilt
5
+ // interpolation's spurious zero, debounce the settled translateY, rebuild the top/inverted ranges on
6
+ // a layout/collision-input change — in its own reactive dialect, so the zero-swallow gate, the
7
+ // debounce-delay pick, and the rebuild decision lived FOUR times and quietly drifted.
8
+ //
9
+ // Here the whole decision half is one pure `reduceSticky(state, action, inputs) -> {state, effects}`,
10
+ // PER sticky header. The adapter keeps only what is genuinely framework-bound: translate a native
11
+ // event / animated tick / timer fire into an ACTION, hold ONE state cell, and EXECUTE the returned
12
+ // EFFECTS with its own primitives (build the interpolation node + wire addListener/removeListener,
13
+ // hold the debounce setTimeout, trigger its own re-render, record the cross-talk y). The math leaf
14
+ // (computeStickyInterpolation) and the debounce-window pick (stickyDebounceMs) still live in
15
+ // ./view/render-scroll-sticky; this module composes them into the ordered transition every adapter
16
+ // shares. Ported from ScrollViewStickyHeader.js's effect.
17
+ import { dlog } from '@symbiote-native/engine';
18
+ import { computeStickyInterpolation, stickyDebounceMs } from '../view/render-scroll-sticky.js';
19
+ // The un-measured identity interpolation (RN: a fresh AnimatedInterpolation before the header has
20
+ // measured its own y/height). Kept as the reset the initial state and every rebuild start from.
21
+ const IDENTITY_INPUT_RANGE = [-1, 0];
22
+ const IDENTITY_OUTPUT_RANGE = [0, 0];
23
+ const NO_TRANSLATE = null;
24
+ export function createInitialStickyState() {
25
+ return {
26
+ measured: false,
27
+ layoutY: 0,
28
+ layoutHeight: 0,
29
+ translateY: NO_TRANSLATE,
30
+ haveReceivedInitialZeroTranslateY: true,
31
+ inputRange: [...IDENTITY_INPUT_RANGE],
32
+ outputRange: [...IDENTITY_OUTPUT_RANGE],
33
+ };
34
+ }
35
+ // A cheap signature over the render-relevant state (the ranges + the committed translateY). The
36
+ // adapter can skip re-wiring when it is unchanged. Shared so the key CANNOT drift between adapters.
37
+ export function stickyEffectSignature(state) {
38
+ return `${state.inputRange.join(',')}|${state.outputRange.join(',')}|${state.translateY}`;
39
+ }
40
+ // Recompute the derived interpolation ranges off the current state + inputs (wrapping the load-bearing
41
+ // computeStickyInterpolation math), store them, and return them for the rebuild effect.
42
+ function deriveRanges(state, inputs) {
43
+ const { inputRange, outputRange } = computeStickyInterpolation({
44
+ measured: state.measured,
45
+ inverted: inputs.inverted,
46
+ scrollViewHeight: inputs.scrollViewHeight,
47
+ layoutY: state.layoutY,
48
+ layoutHeight: state.layoutHeight,
49
+ nextHeaderLayoutY: inputs.nextHeaderLayoutY,
50
+ });
51
+ state.inputRange = inputRange;
52
+ state.outputRange = outputRange;
53
+ return { inputRange, outputRange };
54
+ }
55
+ // The single transition every sticky-header adapter shares. The adapter maps a native event / animated
56
+ // tick / timer fire to an action, calls this, stores the returned state, and executes the effects.
57
+ export function reduceSticky(state, action, inputs) {
58
+ switch (action.kind) {
59
+ case 'layout': {
60
+ // Record own y/height, mark measured, rebuild the interpolation, and (when the reducer owns the
61
+ // cross-talk index) hand the parent this header's y so the PREVIOUS header learns its collision
62
+ // point. Matches RN ScrollViewStickyHeader.js._onLayout.
63
+ state.layoutY = action.y;
64
+ state.layoutHeight = action.height;
65
+ state.measured = true;
66
+ const { inputRange, outputRange } = deriveRanges(state, inputs);
67
+ const effects = [];
68
+ if (inputs.index !== undefined) {
69
+ effects.push({ kind: 'record-header-y', index: inputs.index, y: action.y });
70
+ }
71
+ effects.push({ kind: 'rebuild-interpolation', inputRange, outputRange });
72
+ return { state, effects, changed: true };
73
+ }
74
+ case 'inputs-changed': {
75
+ // A collision/viewport input changed (RN effect deps: inverted, scrollViewHeight,
76
+ // nextHeaderLayoutY): recompute the ranges and rebuild.
77
+ const { inputRange, outputRange } = deriveRanges(state, inputs);
78
+ return {
79
+ state,
80
+ effects: [{ kind: 'rebuild-interpolation', inputRange, outputRange }],
81
+ changed: true,
82
+ };
83
+ }
84
+ case 'animated-tick': {
85
+ // A freshly-rebuilt interpolation re-emits 0 to its listeners; swallow that first zero once a
86
+ // real value has committed (RN). Otherwise schedule the host-tuned debounce that pushes the
87
+ // settled value into the committed transform for hit-testing.
88
+ if (action.value === 0 && !state.haveReceivedInitialZeroTranslateY) {
89
+ state.haveReceivedInitialZeroTranslateY = true;
90
+ dlog('sticky-header swallowed re-emitted zero translateY');
91
+ return { state, effects: [], changed: false };
92
+ }
93
+ return {
94
+ state,
95
+ effects: [
96
+ { kind: 'schedule-debounce', delay: stickyDebounceMs(inputs.os), value: action.value },
97
+ ],
98
+ changed: false,
99
+ };
100
+ }
101
+ case 'debounce-fired': {
102
+ // The debounce completed: commit the settled translateY. Once a NON-zero value commits, re-arm
103
+ // the swallow gate so the next interpolation rebuild's spurious 0 is dropped (RN).
104
+ state.translateY = action.value;
105
+ if (action.value !== 0)
106
+ state.haveReceivedInitialZeroTranslateY = false;
107
+ return {
108
+ state,
109
+ effects: [{ kind: 'apply-passthrough', translateY: action.value }],
110
+ changed: true,
111
+ };
112
+ }
113
+ }
114
+ }
@@ -1,4 +1,4 @@
1
- import type { ISymbioteEvent } from '@symbiote-native/engine';
1
+ import { type ISymbioteEvent } from '@symbiote-native/engine';
2
2
  export declare const DEFAULT_ACTIVE_OPACITY = 0.2;
3
3
  export declare const OPACITY_ACTIVE_DURATION_MS = 150;
4
4
  export declare const OPACITY_INACTIVE_DURATION_MS = 250;
@@ -13,3 +13,24 @@ export interface IPressTimingProps {
13
13
  minPressDuration?: number;
14
14
  }
15
15
  export declare function computePressOutWait(heldFor: number, minPressDuration: number, delayPressOut: number): number;
16
+ export interface ITouchableFeedbackRuntime {
17
+ pressInTimerCancel: (() => void) | undefined;
18
+ activatedAt: number | undefined;
19
+ }
20
+ export declare function createTouchableFeedbackRuntime(): ITouchableFeedbackRuntime;
21
+ export interface ITouchableFeedbackCallbacks {
22
+ activate: (event: ISymbioteEvent) => void;
23
+ deactivate: (event: ISymbioteEvent) => void;
24
+ }
25
+ export interface ITouchableFeedbackConfig {
26
+ delayPressIn: number;
27
+ delayPressOut: number;
28
+ minPressDuration: number;
29
+ schedule: (callback: () => void, ms: number) => () => void;
30
+ now: () => number;
31
+ }
32
+ export interface ITouchableFeedbackHandlers {
33
+ handlePressIn: ITouchableHandler;
34
+ handlePressOut: ITouchableHandler;
35
+ }
36
+ export declare function createTouchableFeedbackHandlers(config: ITouchableFeedbackConfig, runtime: ITouchableFeedbackRuntime, callbacks: ITouchableFeedbackCallbacks): ITouchableFeedbackHandlers;
@@ -3,6 +3,7 @@
3
3
  // (delayPressIn/delayPressOut/minPressDuration) and the deactivation floor math. The Animated
4
4
  // feedback itself is framework (each adapter's Animated namespace), so it stays in the adapter;
5
5
  // only the timing constants + the pure wait computation live here.
6
+ import { dlog } from '@symbiote-native/engine';
6
7
  // TouchableOpacity.js: _opacityActive(150)/_opacityInactive(250), activeOpacity 0.2.
7
8
  export const DEFAULT_ACTIVE_OPACITY = 0.2;
8
9
  export const OPACITY_ACTIVE_DURATION_MS = 150;
@@ -20,3 +21,64 @@ export const DEFAULT_MIN_PRESS_DURATION_MS = 130;
20
21
  export function computePressOutWait(heldFor, minPressDuration, delayPressOut) {
21
22
  return Math.max(minPressDuration - heldFor, delayPressOut);
22
23
  }
24
+ export function createTouchableFeedbackRuntime() {
25
+ return { pressInTimerCancel: undefined, activatedAt: undefined };
26
+ }
27
+ // Build the two press handlers over config + runtime + the adapter's activate/deactivate callbacks.
28
+ // The whole TouchableOpacity press-scheduling dance (delayPressIn defer, flush-on-early-release,
29
+ // activatedAt tracking, the minPressDuration/delayPressOut hold) lives here, shared by every
30
+ // adapter. The adapter rebuilds/holds the callbacks (they capture live config + the Animated Value)
31
+ // while the runtime persists across renders. Twin of Pressable's createPressHandlers.
32
+ export function createTouchableFeedbackHandlers(config, runtime, callbacks) {
33
+ const { delayPressIn, delayPressOut, minPressDuration, schedule, now } = config;
34
+ const { activate, deactivate } = callbacks;
35
+ function clearPressInTimer() {
36
+ if (runtime.pressInTimerCancel !== undefined) {
37
+ runtime.pressInTimerCancel();
38
+ runtime.pressInTimerCancel = undefined;
39
+ }
40
+ }
41
+ // The real activation: stamp the activation clock (for the press-out floor) then run the adapter's
42
+ // opacity fade + onPressIn. Split out so delayPressIn can defer it behind a timer that an early
43
+ // release still flushes.
44
+ function doActivate(event) {
45
+ runtime.activatedAt = now();
46
+ activate(event);
47
+ }
48
+ function doDeactivate(event) {
49
+ runtime.activatedAt = undefined;
50
+ deactivate(event);
51
+ }
52
+ return {
53
+ // RN's _createPressabilityConfig forwards delayPressIn: defer the active visual and onPressIn
54
+ // behind the delay (a release before it elapses flushes it synchronously).
55
+ handlePressIn(event) {
56
+ if (delayPressIn > 0) {
57
+ dlog(`TouchableOpacity pressIn deferred ${delayPressIn}ms`);
58
+ runtime.pressInTimerCancel = schedule(() => {
59
+ runtime.pressInTimerCancel = undefined;
60
+ doActivate(event);
61
+ }, delayPressIn);
62
+ return;
63
+ }
64
+ doActivate(event);
65
+ },
66
+ // delayPressOut + minPressDuration (RN _deactivate): the press-out waits at least
67
+ // minPressDuration past activation (so a fast tap holds the active visual) and at least
68
+ // delayPressOut, whichever is longer.
69
+ handlePressOut(event) {
70
+ if (runtime.pressInTimerCancel !== undefined) {
71
+ clearPressInTimer();
72
+ doActivate(event);
73
+ }
74
+ const heldFor = runtime.activatedAt === undefined ? 0 : now() - runtime.activatedAt;
75
+ const wait = computePressOutWait(heldFor, minPressDuration, delayPressOut);
76
+ if (wait > 0) {
77
+ dlog(`TouchableOpacity pressOut deferred ${wait}ms`);
78
+ schedule(() => doDeactivate(event), wait);
79
+ return;
80
+ }
81
+ doDeactivate(event);
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,129 @@
1
+ import { type ICellLayout, type IViewToken, type IViewabilityConfigCallbackPair, type IViewableItemsChangedInfo } from './virtualized-list';
2
+ export interface IListMetrics {
3
+ count: number;
4
+ offsets: number[];
5
+ lengths: number[];
6
+ total: number;
7
+ first: number;
8
+ last: number;
9
+ target: {
10
+ first: number;
11
+ last: number;
12
+ };
13
+ averageLength: number;
14
+ fixedLayout: ((index: number) => ICellLayout) | undefined;
15
+ }
16
+ export interface IListState<ItemT> {
17
+ scrollOffset: number;
18
+ viewportLength: number;
19
+ measured: Map<number, number>;
20
+ committedWindow: {
21
+ first: number;
22
+ last: number;
23
+ };
24
+ sentEndForContentLength: number;
25
+ sentStartForContentLength: number;
26
+ lastViewable: Map<string, IViewToken<ItemT>>;
27
+ hasInteracted: boolean;
28
+ firstVisibleKey: string | null;
29
+ appliedInitialScroll: boolean;
30
+ metrics: IListMetrics;
31
+ }
32
+ export interface IListReducerInputs<ItemT> {
33
+ data: unknown;
34
+ getItem: (data: unknown, index: number) => ItemT;
35
+ getItemCount: (data: unknown) => number;
36
+ keyExtractor?: (item: ItemT, index: number) => string;
37
+ getItemLayout?: (data: unknown, index: number) => {
38
+ length: number;
39
+ offset: number;
40
+ index: number;
41
+ };
42
+ horizontal: boolean;
43
+ windowSize: number;
44
+ initialNumToRender: number;
45
+ maxToRenderPerBatch: number;
46
+ updateCellsBatchingPeriod: number;
47
+ onEndReachedThreshold: number;
48
+ onStartReachedThreshold: number;
49
+ onEndReachedActive: boolean;
50
+ onStartReachedActive: boolean;
51
+ viewabilityPairs: IViewabilityConfigCallbackPair<ItemT>[];
52
+ maintainVisibleContentPosition?: {
53
+ minIndexForVisible: number;
54
+ autoscrollToTopThreshold?: number;
55
+ };
56
+ initialScrollIndex?: number;
57
+ }
58
+ export type IListAction<ItemT> = {
59
+ kind: 'scroll';
60
+ offset: number;
61
+ } | {
62
+ kind: 'layout';
63
+ length: number;
64
+ } | {
65
+ kind: 'measure';
66
+ index: number;
67
+ length: number;
68
+ } | {
69
+ kind: 'refresh-metrics';
70
+ } | {
71
+ kind: 'batch-tick';
72
+ } | {
73
+ kind: 'record-interaction';
74
+ } | {
75
+ kind: 'viewable-fired';
76
+ map: Map<string, IViewToken<ItemT>>;
77
+ } | {
78
+ kind: 'commit';
79
+ } | {
80
+ kind: 'scroll-to-offset';
81
+ offset: number;
82
+ animated: boolean;
83
+ } | {
84
+ kind: 'scroll-to-index';
85
+ index: number;
86
+ animated: boolean;
87
+ viewPosition: number;
88
+ viewOffset: number;
89
+ } | {
90
+ kind: 'scroll-to-item';
91
+ item: unknown;
92
+ animated: boolean;
93
+ viewPosition: number;
94
+ } | {
95
+ kind: 'scroll-to-end';
96
+ animated: boolean;
97
+ };
98
+ export type IListEffect<ItemT> = {
99
+ kind: 'scroll-to';
100
+ offset: number;
101
+ animated: boolean;
102
+ } | {
103
+ kind: 'fire-end-reached';
104
+ distanceFromEnd: number;
105
+ } | {
106
+ kind: 'fire-start-reached';
107
+ distanceFromStart: number;
108
+ } | {
109
+ kind: 'fire-viewable';
110
+ info: IViewableItemsChangedInfo<ItemT>;
111
+ delay: number;
112
+ map: Map<string, IViewToken<ItemT>>;
113
+ } | {
114
+ kind: 'schedule-refill';
115
+ delay: number;
116
+ } | {
117
+ kind: 'fire-scroll-to-index-failed';
118
+ index: number;
119
+ highestMeasuredFrameIndex: number;
120
+ averageItemLength: number;
121
+ };
122
+ export interface IListReduceResult<ItemT> {
123
+ state: IListState<ItemT>;
124
+ effects: IListEffect<ItemT>[];
125
+ changed: boolean;
126
+ }
127
+ export declare function createInitialListState<ItemT>(): IListState<ItemT>;
128
+ export declare function listEffectSignature<ItemT>(state: IListState<ItemT>): string;
129
+ export declare function reduceList<ItemT>(state: IListState<ItemT>, action: IListAction<ItemT>, inputs: IListReducerInputs<ItemT>): IListReduceResult<ItemT>;
@@ -0,0 +1,292 @@
1
+ // VirtualizedList orchestration reducer: the framework-agnostic STATE MACHINE that folds every
2
+ // per-adapter effect skeleton into one place. Before this, each adapter (React useEffect, Vue
3
+ // watch, Angular ngAfterViewChecked) re-wrote the same sequence — recompute the window, gate
4
+ // onEndReached on `last === count - 1`, dedup by content length, run viewability, apply MVCP — in
5
+ // its own reactive dialect, and the predicates in that glue (`last === count - 1`, `first === 0`,
6
+ // the batch-fill catch-up test, the viewability guards) lived THREE times and quietly drifted.
7
+ //
8
+ // Here the whole decision half is one pure `reduceList(state, action, inputs) -> {state, effects}`.
9
+ // The adapter keeps only what is genuinely framework-bound: translate a native event into an
10
+ // ACTION, hold ONE state cell, and EXECUTE the returned EFFECTS with its own primitives (a native
11
+ // scrollTo, a callback/emit, a setTimeout, a re-render). The geometry leaves (buildOffsets /
12
+ // computeWindow / computeMvcpAdjustment / …) still live in ./virtualized-list; this module composes
13
+ // them into the ordered transition every adapter shares.
14
+ //
15
+ // Effect EXECUTION stays per-adapter by design: which framework hook fires the commit, how a
16
+ // native scrollTo is dispatched, how a debounce timer is held — an effect-list DESCRIBES the work,
17
+ // it does not run it. State TRANSITIONS (including the derived window metrics) are owned entirely
18
+ // here, so a windowing / edge / viewability / MVCP bug — and the drift between three copies of it —
19
+ // is fixed once for all adapters.
20
+ import { dlog } from '@symbiote-native/engine';
21
+ import { EMPTY_OFFSET, FIRST_INDEX, NO_CONTENT_LENGTH_SENT, NO_INDEX, buildOffsets, computeEndReached, computeMvcpAdjustment, computeStartReached, computeViewableSet, computeWindow, decideEdgeReached, diffViewable, highestMeasuredIndex, indexOfItem, maxMinimumViewTime, offsetForEnd, offsetForIndex, resolveAverageLength, resolveItemKey, throttleWindow, wrapFixedLayout, } from './virtualized-list.js';
22
+ export function createInitialListState() {
23
+ return {
24
+ scrollOffset: EMPTY_OFFSET,
25
+ viewportLength: EMPTY_OFFSET,
26
+ measured: new Map(),
27
+ committedWindow: { first: FIRST_INDEX, last: NO_INDEX },
28
+ sentEndForContentLength: NO_CONTENT_LENGTH_SENT,
29
+ sentStartForContentLength: NO_CONTENT_LENGTH_SENT,
30
+ lastViewable: new Map(),
31
+ hasInteracted: false,
32
+ firstVisibleKey: null,
33
+ appliedInitialScroll: false,
34
+ metrics: {
35
+ count: EMPTY_OFFSET,
36
+ offsets: [],
37
+ lengths: [],
38
+ total: EMPTY_OFFSET,
39
+ first: FIRST_INDEX,
40
+ last: NO_INDEX,
41
+ target: { first: FIRST_INDEX, last: NO_INDEX },
42
+ averageLength: EMPTY_OFFSET,
43
+ fixedLayout: undefined,
44
+ },
45
+ };
46
+ }
47
+ // A cheap signature over the render-relevant state. The adapter skips the after-commit pass when it
48
+ // is unchanged (the same dedup Angular's lastEffectSignature did), so the batch-fill timer is not
49
+ // thrashed by unrelated re-renders. Shared so the key CANNOT drift between adapters.
50
+ export function listEffectSignature(state) {
51
+ const m = state.metrics;
52
+ return `${state.scrollOffset}|${state.viewportLength}|${m.first}|${m.last}|${m.count}|${m.total}`;
53
+ }
54
+ // Recompute the derived window metrics off the current state + inputs. Owns the controlled
55
+ // committedWindow throttle (the side effect React ran during render): committedWindow is plain
56
+ // state, so growing it toward target one batch-step at a time triggers no reactivity loop.
57
+ function deriveMetrics(state, inputs) {
58
+ const count = inputs.getItemCount(inputs.data);
59
+ const fixedLayout = wrapFixedLayout(inputs.data, inputs.getItemLayout);
60
+ const averageLength = resolveAverageLength(fixedLayout, count, state.measured);
61
+ const { offsets, lengths, total } = buildOffsets(count, state.measured, fixedLayout, averageLength);
62
+ const target = computeWindow(count, offsets, lengths, state.scrollOffset, state.viewportLength, inputs.windowSize, inputs.initialNumToRender);
63
+ const throttled = throttleWindow(target, state.committedWindow, inputs.maxToRenderPerBatch);
64
+ state.committedWindow = throttled;
65
+ state.metrics = {
66
+ count,
67
+ offsets,
68
+ lengths,
69
+ total,
70
+ first: throttled.first,
71
+ last: throttled.last,
72
+ target,
73
+ averageLength,
74
+ fixedLayout,
75
+ };
76
+ return state;
77
+ }
78
+ function keyForOf(inputs) {
79
+ return (index) => resolveItemKey(inputs.getItem(inputs.data, index), index, inputs.keyExtractor);
80
+ }
81
+ // The after-render pass: every deferred effect, in the order 2 of 3 adapters already ran them
82
+ // (batch-fill -> end -> start -> viewability -> initial-scroll -> MVCP). Each is guarded by its own
83
+ // dedup state (sent*ForContentLength, lastViewable, appliedInitialScroll, firstVisibleKey), so
84
+ // running commit on every render is safe — the guards prevent a redundant fire.
85
+ function commitList(state, inputs) {
86
+ const effects = [];
87
+ const m = state.metrics;
88
+ // Batch fill: when the throttled window has not reached the target, ask for another render tick so
89
+ // it keeps filling toward target (RN's incremental fill).
90
+ if (!(m.first <= m.target.first && m.last >= m.target.last)) {
91
+ effects.push({ kind: 'schedule-refill', delay: inputs.updateCellsBatchingPeriod });
92
+ }
93
+ // onEndReached: fire only when the actual last cell is rendered AND within threshold; dedup by
94
+ // content length; re-arm on scroll away from the end (RN _maybeCallOnEdgeReached).
95
+ if (inputs.onEndReachedActive && state.viewportLength > EMPTY_OFFSET) {
96
+ const { distanceFromEnd, withinThreshold } = computeEndReached(m.total, state.scrollOffset, state.viewportLength, inputs.onEndReachedThreshold);
97
+ const decision = decideEdgeReached({
98
+ withinThreshold,
99
+ edgeCellRendered: m.last === m.count - 1,
100
+ total: m.total,
101
+ sentForContentLength: state.sentEndForContentLength,
102
+ });
103
+ state.sentEndForContentLength = decision.nextSentForContentLength;
104
+ if (decision.shouldFire) {
105
+ dlog(`VirtualizedList onEndReached distanceFromEnd=${distanceFromEnd} ` +
106
+ `(last=${m.last} of ${m.count}, contentLength=${m.total})`);
107
+ effects.push({ kind: 'fire-end-reached', distanceFromEnd });
108
+ }
109
+ }
110
+ // onStartReached: the top-edge twin of onEndReached.
111
+ if (inputs.onStartReachedActive && state.viewportLength > EMPTY_OFFSET) {
112
+ const { distanceFromStart, withinThreshold } = computeStartReached(state.scrollOffset, state.viewportLength, inputs.onStartReachedThreshold);
113
+ const decision = decideEdgeReached({
114
+ withinThreshold,
115
+ edgeCellRendered: m.first === FIRST_INDEX,
116
+ total: m.total,
117
+ sentForContentLength: state.sentStartForContentLength,
118
+ });
119
+ state.sentStartForContentLength = decision.nextSentForContentLength;
120
+ if (decision.shouldFire) {
121
+ dlog(`VirtualizedList onStartReached distanceFromStart=${distanceFromStart} ` +
122
+ `(first=${m.first}, contentLength=${m.total})`);
123
+ effects.push({ kind: 'fire-start-reached', distanceFromStart });
124
+ }
125
+ }
126
+ // Viewability: reclassify the rendered cells; if the viewable set changed, hand the adapter an
127
+ // info payload to fire (after minimumViewTime, if any). lastViewable is folded back only when the
128
+ // fire actually lands (the 'viewable-fired' action), so a debounce that is superseded mid-flight
129
+ // still diffs against the last COMMITTED set.
130
+ if (inputs.viewabilityPairs.length > EMPTY_OFFSET &&
131
+ state.viewportLength > EMPTY_OFFSET &&
132
+ m.count !== FIRST_INDEX) {
133
+ const { tokens, map } = computeViewableSet({
134
+ first: m.first,
135
+ last: m.last,
136
+ count: m.count,
137
+ offsets: m.offsets,
138
+ lengths: m.lengths,
139
+ scrollOffset: state.scrollOffset,
140
+ viewportLength: state.viewportLength,
141
+ data: inputs.data,
142
+ getItem: inputs.getItem,
143
+ keyExtractor: inputs.keyExtractor,
144
+ pairs: inputs.viewabilityPairs,
145
+ hasInteracted: state.hasInteracted,
146
+ });
147
+ const diff = diffViewable(state.lastViewable, map, tokens);
148
+ if (diff.hasChanged) {
149
+ dlog(`VirtualizedList viewable=${tokens.length} changed=${diff.changed.length} ` +
150
+ `(window [${m.first}, ${m.last}])`);
151
+ effects.push({
152
+ kind: 'fire-viewable',
153
+ info: { viewableItems: tokens, changed: diff.changed },
154
+ delay: maxMinimumViewTime(inputs.viewabilityPairs),
155
+ map,
156
+ });
157
+ }
158
+ }
159
+ // initialScrollIndex: once the first viewport is known, jump to that index a single time.
160
+ if (inputs.initialScrollIndex !== undefined &&
161
+ !state.appliedInitialScroll &&
162
+ state.viewportLength > EMPTY_OFFSET &&
163
+ m.count !== FIRST_INDEX) {
164
+ state.appliedInitialScroll = true;
165
+ const offset = offsetForIndex(inputs.initialScrollIndex, FIRST_INDEX, EMPTY_OFFSET, m.count, m.offsets, m.lengths, state.viewportLength);
166
+ // The initial jump is instant (RN does not animate initialScrollIndex).
167
+ effects.push({ kind: 'scroll-to', offset, animated: false });
168
+ }
169
+ // maintainVisibleContentPosition: shift for the prepended items collapsed into the leading spacer
170
+ // that native MVCP cannot see (RN getDerivedStateFromProps). computeMvcpAdjustment owns the pure
171
+ // decision; here it becomes a scroll-to effect.
172
+ const mvcp = computeMvcpAdjustment({
173
+ minIndexForVisible: inputs.maintainVisibleContentPosition?.minIndexForVisible,
174
+ autoscrollToTopThreshold: inputs.maintainVisibleContentPosition?.autoscrollToTopThreshold,
175
+ count: m.count,
176
+ committedFirst: state.committedWindow.first,
177
+ offsets: m.offsets,
178
+ scrollOffset: state.scrollOffset,
179
+ prevFirstVisibleKey: state.firstVisibleKey,
180
+ keyFor: keyForOf(inputs),
181
+ });
182
+ if (mvcp.action.kind === 'autoscroll-top') {
183
+ effects.push({ kind: 'scroll-to', offset: EMPTY_OFFSET, animated: true });
184
+ }
185
+ else if (mvcp.action.kind === 'shift') {
186
+ effects.push({ kind: 'scroll-to', offset: mvcp.action.offset, animated: false });
187
+ }
188
+ state.firstVisibleKey = mvcp.firstVisibleKey;
189
+ return { state, effects, changed: false };
190
+ }
191
+ // Resolve an imperative scrollToIndex: report the failure when there is no getItemLayout and the
192
+ // target is past the last measured cell (RN VirtualizedList.js), else scroll to the resolved offset.
193
+ function resolveScrollToIndex(state, inputs, action) {
194
+ const m = state.metrics;
195
+ const measuredCeiling = highestMeasuredIndex(state.measured);
196
+ if (inputs.getItemLayout === undefined && action.index > measuredCeiling) {
197
+ dlog(`VirtualizedList onScrollToIndexFailed index=${action.index} ` +
198
+ `highestMeasured=${measuredCeiling} (no getItemLayout)`);
199
+ return {
200
+ state,
201
+ effects: [
202
+ {
203
+ kind: 'fire-scroll-to-index-failed',
204
+ index: action.index,
205
+ highestMeasuredFrameIndex: measuredCeiling,
206
+ averageItemLength: m.averageLength,
207
+ },
208
+ ],
209
+ changed: false,
210
+ };
211
+ }
212
+ const offset = offsetForIndex(action.index, action.viewPosition, action.viewOffset, m.count, m.offsets, m.lengths, state.viewportLength);
213
+ return {
214
+ state,
215
+ effects: [{ kind: 'scroll-to', offset, animated: action.animated }],
216
+ changed: false,
217
+ };
218
+ }
219
+ // The single transition every adapter shares. The adapter maps a native event / imperative call to
220
+ // an action, calls this, stores the returned state, and executes the returned effects.
221
+ export function reduceList(state, action, inputs) {
222
+ switch (action.kind) {
223
+ // Scalar transitions never recompute the window — they set the input and ask for a render; the
224
+ // metrics derive runs exactly ONCE per render, in the 'refresh-metrics' the adapter fires from
225
+ // its render body. That single-derive-per-render invariant is what advances the throttled
226
+ // committedWindow one step per frame (deriving here too would advance it twice).
227
+ case 'scroll':
228
+ // First scroll is the interaction that ungates waitForInteraction viewability configs.
229
+ state.hasInteracted = true;
230
+ state.scrollOffset = action.offset;
231
+ return { state, effects: [], changed: true };
232
+ case 'layout':
233
+ state.viewportLength = action.length;
234
+ return { state, effects: [], changed: true };
235
+ case 'measure':
236
+ // A fixed getItemLayout owns cell sizes, so a measured length is ignored; a repeat of a known
237
+ // length changes nothing. Both guards keep an idle onLayout from forcing a render.
238
+ if (inputs.getItemLayout !== undefined)
239
+ return { state, effects: [], changed: false };
240
+ if (state.measured.get(action.index) === action.length) {
241
+ return { state, effects: [], changed: false };
242
+ }
243
+ state.measured.set(action.index, action.length);
244
+ return { state, effects: [], changed: true };
245
+ case 'batch-tick':
246
+ // The refill timer fired: ask for a render, whose refresh-metrics grows the window one step.
247
+ return { state, effects: [], changed: true };
248
+ case 'refresh-metrics':
249
+ return { state: deriveMetrics(state, inputs), effects: [], changed: true };
250
+ case 'record-interaction':
251
+ state.hasInteracted = true;
252
+ return { state, effects: [], changed: false };
253
+ case 'viewable-fired':
254
+ state.lastViewable = action.map;
255
+ return { state, effects: [], changed: false };
256
+ case 'commit':
257
+ return commitList(state, inputs);
258
+ case 'scroll-to-offset':
259
+ return {
260
+ state,
261
+ effects: [{ kind: 'scroll-to', offset: action.offset, animated: action.animated }],
262
+ changed: false,
263
+ };
264
+ case 'scroll-to-index':
265
+ return resolveScrollToIndex(state, inputs, action);
266
+ case 'scroll-to-item': {
267
+ const index = indexOfItem(inputs.data, inputs.getItem, state.metrics.count, action.item);
268
+ if (index === NO_INDEX) {
269
+ dlog('VirtualizedList scrollToItem: item not found');
270
+ return { state, effects: [], changed: false };
271
+ }
272
+ const offset = offsetForIndex(index, action.viewPosition, EMPTY_OFFSET, state.metrics.count, state.metrics.offsets, state.metrics.lengths, state.viewportLength);
273
+ return {
274
+ state,
275
+ effects: [{ kind: 'scroll-to', offset, animated: action.animated }],
276
+ changed: false,
277
+ };
278
+ }
279
+ case 'scroll-to-end':
280
+ return {
281
+ state,
282
+ effects: [
283
+ {
284
+ kind: 'scroll-to',
285
+ offset: offsetForEnd(state.metrics.total, state.viewportLength),
286
+ animated: action.animated,
287
+ },
288
+ ],
289
+ changed: false,
290
+ };
291
+ }
292
+ }
@@ -153,3 +153,46 @@ export interface IListPlanParams {
153
153
  hasSeparators: boolean;
154
154
  }
155
155
  export declare function buildListPlan(params: IListPlanParams): IListPlan;
156
+ export type IMvcpAction = {
157
+ kind: 'none';
158
+ } | {
159
+ kind: 'autoscroll-top';
160
+ } | {
161
+ kind: 'shift';
162
+ offset: number;
163
+ };
164
+ export interface IMvcpAdjustmentParams {
165
+ minIndexForVisible: number | undefined;
166
+ autoscrollToTopThreshold: number | undefined;
167
+ count: number;
168
+ committedFirst: number;
169
+ offsets: number[];
170
+ scrollOffset: number;
171
+ prevFirstVisibleKey: string | null;
172
+ keyFor: (index: number) => string;
173
+ }
174
+ export interface IMvcpAdjustmentResult {
175
+ firstVisibleKey: string | null;
176
+ action: IMvcpAction;
177
+ }
178
+ export declare function computeMvcpAdjustment(params: IMvcpAdjustmentParams): IMvcpAdjustmentResult;
179
+ export declare function resolveItemKey<ItemT>(item: ItemT, index: number, keyExtractor: ((item: ItemT, index: number) => string) | undefined): string;
180
+ export declare function indexOfItem(data: unknown, getItem: (data: unknown, index: number) => unknown, count: number, item: unknown): number;
181
+ export declare function offsetForEnd(total: number, viewportLength: number): number;
182
+ export declare function isSeparatorGapInRange(gapIndex: number, count: number): boolean;
183
+ export declare function decideEdgeReached(params: {
184
+ withinThreshold: boolean;
185
+ edgeCellRendered: boolean;
186
+ total: number;
187
+ sentForContentLength: number;
188
+ }): {
189
+ shouldFire: boolean;
190
+ nextSentForContentLength: number;
191
+ };
192
+ export declare function resolveStickySectionHeaders(enabled: boolean | undefined, headerIndices: number[], platformOS: string): number[] | undefined;
193
+ export declare function wrapFixedLayout(data: unknown, getItemLayout: ((data: unknown, index: number) => {
194
+ length: number;
195
+ offset: number;
196
+ index: number;
197
+ }) | undefined): ((index: number) => ICellLayout) | undefined;
198
+ export declare function resolveAverageLength(fixedLayout: ((index: number) => ICellLayout) | undefined, count: number, measured: Map<number, number>): number;
@@ -19,6 +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
23
  // Defaults match RN. windowSize is measured in viewport-lengths (21 => ten screens
23
24
  // of buffer on each side of the visible region). onEndReachedThreshold is a multiple
24
25
  // of the visible length (RN's onEndReachedThresholdOrDefault returns `?? 2`).
@@ -304,3 +305,111 @@ export function buildListPlan(params) {
304
305
  }
305
306
  return { leadingExtent, trailingExtent, cells, stickyChildPositions };
306
307
  }
308
+ export function computeMvcpAdjustment(params) {
309
+ const { minIndexForVisible, count, keyFor } = params;
310
+ if (minIndexForVisible === undefined || count === FIRST_INDEX) {
311
+ return { firstVisibleKey: null, action: { kind: 'none' } };
312
+ }
313
+ const newFirstVisibleKey = count > minIndexForVisible ? keyFor(minIndexForVisible) : null;
314
+ const prevKey = params.prevFirstVisibleKey;
315
+ const settled = {
316
+ firstVisibleKey: newFirstVisibleKey,
317
+ action: { kind: 'none' },
318
+ };
319
+ if (prevKey === null || newFirstVisibleKey === null || prevKey === newFirstVisibleKey) {
320
+ return settled;
321
+ }
322
+ let anchorIndex = NO_INDEX;
323
+ for (let index = minIndexForVisible; index < count; index += 1) {
324
+ if (keyFor(index) === prevKey) {
325
+ anchorIndex = index;
326
+ break;
327
+ }
328
+ }
329
+ if (anchorIndex <= minIndexForVisible)
330
+ return settled;
331
+ // Native MVCP shifts in-window cells itself; the JS shift covers ONLY the inserted items in the
332
+ // leading SPACER (above the first rendered index). Counting the full inserted extent would
333
+ // double-correct.
334
+ const spacerEnd = Math.min(anchorIndex, params.committedFirst);
335
+ const insertedExtent = spacerEnd > minIndexForVisible
336
+ ? params.offsets[spacerEnd] - params.offsets[minIndexForVisible]
337
+ : EMPTY_OFFSET;
338
+ if (insertedExtent <= EMPTY_OFFSET)
339
+ return settled;
340
+ const autoThreshold = params.autoscrollToTopThreshold;
341
+ const anchoredNearTop = autoThreshold !== undefined && params.scrollOffset <= autoThreshold;
342
+ if (anchoredNearTop) {
343
+ dlog(`VirtualizedList MVCP autoscroll-to-top (offset=${params.scrollOffset} <= ${autoThreshold})`);
344
+ return { firstVisibleKey: newFirstVisibleKey, action: { kind: 'autoscroll-top' } };
345
+ }
346
+ dlog(`VirtualizedList MVCP adjust +${insertedExtent}px ` +
347
+ `(anchor "${prevKey}" moved ${minIndexForVisible}->${anchorIndex})`);
348
+ return {
349
+ firstVisibleKey: newFirstVisibleKey,
350
+ action: { kind: 'shift', offset: params.scrollOffset + insertedExtent },
351
+ };
352
+ }
353
+ // The default key extractor: the caller's keyExtractor when set, else the index as a string (RN's
354
+ // default). Centralized so every adapter's keyForIndex resolves keys identically.
355
+ export function resolveItemKey(item, index, keyExtractor) {
356
+ return keyExtractor ? keyExtractor(item, index) : String(index);
357
+ }
358
+ // Linear item -> index lookup for scrollToItem (RN scans by reference identity). NO_INDEX when the
359
+ // item is not in data.
360
+ export function indexOfItem(data, getItem, count, item) {
361
+ for (let index = FIRST_INDEX; index < count; index += 1) {
362
+ if (getItem(data, index) === item)
363
+ return index;
364
+ }
365
+ return NO_INDEX;
366
+ }
367
+ // The pixel offset that scrolls the last content to the bottom edge (scrollToEnd). Never negative
368
+ // when the content is shorter than the viewport.
369
+ export function offsetForEnd(total, viewportLength) {
370
+ return Math.max(EMPTY_OFFSET, total - viewportLength);
371
+ }
372
+ // A gap index addresses a real separator only inside [0, count-2]; outside it there is no gap and
373
+ // the write is a no-op (RN bails on the same bounds).
374
+ export function isSeparatorGapInRange(gapIndex, count) {
375
+ return gapIndex >= FIRST_INDEX && gapIndex <= count - 2;
376
+ }
377
+ // onEndReached / onStartReached fire decision + content-length dedup (RN _maybeCallOnEdgeReached).
378
+ // The geometry (withinThreshold) comes from computeEndReached/computeStartReached; this folds in the
379
+ // "edge cell actually rendered" gate, the dedup against the last-fired content length, and the
380
+ // re-arm once scrolled away from the edge. Returns whether to fire plus the next dedup sentinel.
381
+ export function decideEdgeReached(params) {
382
+ const { withinThreshold, edgeCellRendered, total, sentForContentLength } = params;
383
+ if (withinThreshold && edgeCellRendered && sentForContentLength !== total) {
384
+ return { shouldFire: true, nextSentForContentLength: total };
385
+ }
386
+ // Re-arm once out of threshold so the next approach can fire again.
387
+ if (!withinThreshold) {
388
+ return { shouldFire: false, nextSentForContentLength: NO_CONTENT_LENGTH_SENT };
389
+ }
390
+ return { shouldFire: false, nextSentForContentLength: sentForContentLength };
391
+ }
392
+ // Section headers stick by default only on iOS (RN); the explicit prop overrides. Returns the
393
+ // header indices to stick, or undefined when sticking is off.
394
+ export function resolveStickySectionHeaders(enabled, headerIndices, platformOS) {
395
+ const stickyEnabled = enabled ?? platformOS === 'ios';
396
+ return stickyEnabled ? headerIndices : undefined;
397
+ }
398
+ // Wrap the user's getItemLayout into an (index) => ICellLayout resolver (dropping the `index` field
399
+ // RN's getItemLayout returns), or undefined when there is no getItemLayout.
400
+ export function wrapFixedLayout(data, getItemLayout) {
401
+ if (getItemLayout === undefined)
402
+ return undefined;
403
+ return (index) => {
404
+ const layout = getItemLayout(data, index);
405
+ return { length: layout.length, offset: layout.offset };
406
+ };
407
+ }
408
+ // The average cell length that sizes unmeasured cells and the trailing spacer: the fixed layout's
409
+ // first cell length when getItemLayout is set (guarded against an empty list so it never calls
410
+ // fixedLayout on a non-existent cell), else the running average of the measured cells.
411
+ export function resolveAverageLength(fixedLayout, count, measured) {
412
+ if (fixedLayout === undefined)
413
+ return averageMeasuredLength(measured);
414
+ return count > FIRST_INDEX ? fixedLayout(FIRST_INDEX).length : EMPTY_OFFSET;
415
+ }
@@ -10,7 +10,7 @@
10
10
  // the box. `imageStyle` wins last.
11
11
  import { dlog, flattenStyle, } from '@symbiote-native/engine';
12
12
  import { el } from '../descriptor.js';
13
- import { renderImage } from './render-image.js';
13
+ import { renderImage } from './render-image/index.js';
14
14
  // The inner Image's positioning: absolute-fill behind the wrapper's children.
15
15
  const IMAGE_BACKGROUND_ABSOLUTE_FILL = {
16
16
  position: 'absolute',
@@ -11,6 +11,22 @@ export type IScrollIntrinsics = {
11
11
  contentStyle: IStyleProp<IViewStyle>;
12
12
  };
13
13
  export declare function selectScrollIntrinsics(isHorizontal: boolean, contentContainerStyle: IStyleProp<IViewStyle> | undefined): IScrollIntrinsics;
14
+ export type IScrollForwardMode = 'plain' | 'sticky-native' | 'sticky-js';
15
+ export interface IScrollForwardingInputs {
16
+ hasStickyHeaders: boolean;
17
+ nativeStickyAvailable: boolean;
18
+ invertStickyHeaders: boolean | undefined;
19
+ scrollEventThrottle: number | undefined;
20
+ maintainVisibleContentPosition: unknown;
21
+ snapToAlignment: unknown;
22
+ }
23
+ export interface IScrollForwarding {
24
+ mode: IScrollForwardMode;
25
+ scrollEventThrottle: number | undefined;
26
+ capturesViewportHeight: boolean;
27
+ collapsableChildren: boolean;
28
+ }
29
+ export declare function resolveScrollForwarding(inputs: IScrollForwardingInputs): IScrollForwarding;
14
30
  export type IContentSize = {
15
31
  width: number;
16
32
  height: number;
@@ -71,6 +71,41 @@ export function selectScrollIntrinsics(isHorizontal, contentContainerStyle) {
71
71
  : contentContainerStyle;
72
72
  return { scrollViewIntrinsic, contentIntrinsic, scrollViewBaseStyle, contentStyle };
73
73
  }
74
+ // When sticky headers are active the scroll offset must reach the AnimatedValue; RN raises the scroll
75
+ // event rate for it (ScrollView.js:1798): throttle 1 on the native driver (it can afford every frame),
76
+ // 16 on the JS fallback (Animated.event drives the value in JS). Without sticky headers the user's
77
+ // throttle passes through untouched. These two magic numbers were copied into all three adapters.
78
+ const STICKY_NATIVE_SCROLL_THROTTLE = 1;
79
+ const STICKY_JS_SCROLL_THROTTLE = 16;
80
+ export function resolveScrollForwarding(inputs) {
81
+ // maintainVisibleContentPosition (and Android snapToAlignment) anchor against MOUNTED cell views;
82
+ // Android Fabric view-flattens layout-only cells away, so RN keeps them as real views with
83
+ // collapsableChildren={false} on the content container (ScrollView.js preserveChildren). No-op on iOS.
84
+ const collapsableChildren = inputs.maintainVisibleContentPosition !== undefined || inputs.snapToAlignment !== undefined;
85
+ if (!inputs.hasStickyHeaders) {
86
+ return {
87
+ mode: 'plain',
88
+ scrollEventThrottle: inputs.scrollEventThrottle,
89
+ capturesViewportHeight: false,
90
+ collapsableChildren,
91
+ };
92
+ }
93
+ const capturesViewportHeight = inputs.invertStickyHeaders === true;
94
+ if (inputs.nativeStickyAvailable) {
95
+ return {
96
+ mode: 'sticky-native',
97
+ scrollEventThrottle: inputs.scrollEventThrottle ?? STICKY_NATIVE_SCROLL_THROTTLE,
98
+ capturesViewportHeight,
99
+ collapsableChildren,
100
+ };
101
+ }
102
+ return {
103
+ mode: 'sticky-js',
104
+ scrollEventThrottle: inputs.scrollEventThrottle ?? STICKY_JS_SCROLL_THROTTLE,
105
+ capturesViewportHeight,
106
+ collapsableChildren,
107
+ };
108
+ }
74
109
  // Did the content view's measured size actually change since the last fire? RN synthesizes
75
110
  // onContentSizeChange from the inner content view's onLayout, but that fires on every layout
76
111
  // pass; RN dedupes so the user handler only sees real size changes (ScrollView.js). First
@@ -0,0 +1,2 @@
1
+ import type { IViewStyle } from '@symbiote-native/engine';
2
+ export declare function highlightPressedStyle<TStyle>(pressed: boolean, style: TStyle, underlayColor: string, activeOpacity: number): TStyle | [TStyle, IViewStyle];
@@ -0,0 +1,12 @@
1
+ // TouchableHighlight underlay: the shared render decision (framework-agnostic). RN drives the
2
+ // highlight with setState (not Animated) — while pressed it paints underlayColor + lowers the child
3
+ // opacity, at rest it is the bare style (TouchableHighlight.js). This 3-line gating was triplicated
4
+ // verbatim across adapters; it lives here once, prop-driven. Each adapter passes its own resolved
5
+ // style base (React the raw style, Angular the anchor+style array, Vue the untyped attrs style) plus
6
+ // the live `pressed`, so the base is generic — the overlay is layered on top when pressed.
7
+ export function highlightPressedStyle(pressed, style, underlayColor, activeOpacity) {
8
+ if (!pressed)
9
+ return style;
10
+ const overlay = { backgroundColor: underlayColor, opacity: activeOpacity };
11
+ return [style, overlay];
12
+ }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@symbiote-native/components",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "Framework-agnostic component logic (state machines + render functions) for SymbioteNative — written once, inherited by every adapter (React, Vue, Angular, ...).",
5
+ "license": "MIT",
5
6
  "repository": {
6
7
  "type": "git",
7
8
  "url": "git+https://github.com/OneEyed1366/symbiote-native.git",
@@ -33,12 +34,12 @@
33
34
  "access": "public"
34
35
  },
35
36
  "peerDependencies": {
36
- "@symbiote-native/engine": ">=0.1.6",
37
+ "@symbiote-native/engine": ">=0.1.7",
37
38
  "react-native": ">=0.86"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@types/node": "^26.0.0",
41
- "@symbiote-native/engine": "0.1.6"
42
+ "@symbiote-native/engine": "0.1.7"
42
43
  },
43
44
  "scripts": {
44
45
  "typecheck": "tsc --build",
@@ -1,56 +0,0 @@
1
- import { type IImageSource, type IImageSourceProp, type IStyleProp, type ISymbioteEvent, type IViewStyle } from '@symbiote-native/engine';
2
- import type { IAccessibilityProps, IAriaProps } from '../accessibility-props';
3
- import { type IDescriptor } from '../descriptor';
4
- export type { IImageSource, IImageSourceProp };
5
- type IImageEventHandler = (event: ISymbioteEvent) => void;
6
- export type IResizeMode = 'cover' | 'contain' | 'stretch' | 'repeat' | 'center';
7
- export type IImageCapInsets = {
8
- top: number;
9
- left: number;
10
- bottom: number;
11
- right: number;
12
- };
13
- export type IResizeMethod = 'auto' | 'resize' | 'scale' | 'none';
14
- export type IImageProps = IAccessibilityProps & IAriaProps & {
15
- source?: IImageSourceProp;
16
- defaultSource?: IImageSourceProp;
17
- loadingIndicatorSource?: IImageSourceProp;
18
- style?: IStyleProp<IViewStyle>;
19
- resizeMode?: IResizeMode;
20
- resizeMethod?: IResizeMethod;
21
- tintColor?: string;
22
- blurRadius?: number;
23
- capInsets?: IImageCapInsets;
24
- fadeDuration?: number;
25
- progressiveRenderingEnabled?: boolean;
26
- src?: string;
27
- srcSet?: string;
28
- alt?: string;
29
- width?: number;
30
- height?: number;
31
- crossOrigin?: 'anonymous' | 'use-credentials';
32
- referrerPolicy?: string;
33
- onLoadStart?: IImageEventHandler;
34
- onLoad?: IImageEventHandler;
35
- onLoadEnd?: IImageEventHandler;
36
- onError?: IImageEventHandler;
37
- onProgress?: IImageEventHandler;
38
- onPartialLoad?: IImageEventHandler;
39
- };
40
- export type IImageViewProps = {
41
- source?: IImageSourceProp;
42
- defaultSource?: IImageSourceProp;
43
- loadingIndicatorSource?: IImageSourceProp;
44
- style?: IStyleProp<IViewStyle>;
45
- resizeMode?: IResizeMode;
46
- tintColor?: string;
47
- src?: string;
48
- srcSet?: string;
49
- alt?: string;
50
- width?: number;
51
- height?: number;
52
- crossOrigin?: 'anonymous' | 'use-credentials';
53
- referrerPolicy?: string;
54
- passthrough: Record<string, unknown>;
55
- };
56
- export declare function renderImage(view: IImageViewProps): IDescriptor;
@@ -1,133 +0,0 @@
1
- import { dlog, flattenStyle, resolveImageSource, } from '@symbiote-native/engine';
2
- import { el } from '../descriptor.js';
3
- // Resolve the source, then normalize to the array shape native expects. A single
4
- // object/number becomes a one-element array; an already-array source passes through.
5
- function normalizeSource(source) {
6
- const resolved = resolveImageSource(source);
7
- const sources = Array.isArray(resolved) ? resolved : [resolved];
8
- dlog(`Image source resolved to ${JSON.stringify(sources)}`);
9
- return sources;
10
- }
11
- // The HTTP headers the W3C aliases (crossOrigin / referrerPolicy) contribute to
12
- // every folded source, mirroring ImageSourceUtils.js getImageSourcesFromImageProps:
13
- // 'use-credentials' adds the credentials header; referrerPolicy adds Referrer-Policy.
14
- function headersFromAliases(view) {
15
- const headers = {};
16
- if (view.crossOrigin === 'use-credentials') {
17
- headers['Access-Control-Allow-Credentials'] = 'true';
18
- }
19
- if (view.referrerPolicy !== undefined) {
20
- headers['Referrer-Policy'] = view.referrerPolicy;
21
- }
22
- return headers;
23
- }
24
- // Expand a `srcSet` descriptor list into scaled sources, falling back to `src` for
25
- // the 1x slot when srcSet omits it. Direct port of getImageSourcesFromImageProps'
26
- // srcSet branch (ImageSourceUtils.js:48). Invalid scale tokens are skipped, matching
27
- // RN's parse-and-warn behavior.
28
- function expandSrcSet(srcSet, view, headers) {
29
- const sources = [];
30
- let useSrcForDefaultScale = true;
31
- for (const entry of srcSet.split(', ')) {
32
- const [uri, xScale = '1x'] = entry.split(' ');
33
- if (!xScale.endsWith('x')) {
34
- dlog(`Image srcSet: unsupported scale token "${xScale}", skipping`);
35
- continue;
36
- }
37
- const scale = parseInt(xScale.slice(0, -1), 10);
38
- if (Number.isNaN(scale))
39
- continue;
40
- if (scale === 1)
41
- useSrcForDefaultScale = false;
42
- sources.push({ uri, scale, width: view.width, height: view.height, ...{ headers } });
43
- }
44
- if (useSrcForDefaultScale && view.src !== undefined) {
45
- sources.push({
46
- uri: view.src,
47
- scale: 1,
48
- width: view.width,
49
- height: view.height,
50
- ...{ headers },
51
- });
52
- }
53
- if (sources.length === 0)
54
- dlog('Image srcSet: produced no valid sources');
55
- return sources;
56
- }
57
- // Resolve the native `source` array from whichever of source / src / srcSet the
58
- // caller provided. Mirrors ImageSourceUtils.js getImageSourcesFromImageProps:
59
- // srcSet wins, then src, then a header-decorated source, then the plain source.
60
- // Always returns the array shape native expects (the same contract normalizeSource
61
- // guarantees), so the component never sends a bare object.
62
- function resolveSourceArray(view) {
63
- const headers = headersFromAliases(view);
64
- if (view.srcSet !== undefined) {
65
- return expandSrcSet(view.srcSet, view, headers);
66
- }
67
- if (view.src !== undefined) {
68
- return [{ uri: view.src, width: view.width, height: view.height, ...{ headers } }];
69
- }
70
- if (view.source === undefined) {
71
- dlog('Image: no source / src / srcSet provided');
72
- return [];
73
- }
74
- const sources = normalizeSource(view.source);
75
- // A header-decorated single object source gets the headers merged in, per RN's
76
- // `source.uri && headers` branch; the array/number shapes pass through untouched.
77
- if (Object.keys(headers).length > 0 && sources.length === 1) {
78
- const [only] = sources;
79
- if (typeof only === 'object' && only !== null && typeof Reflect.get(only, 'uri') === 'string') {
80
- return [{ ...only, headers }];
81
- }
82
- }
83
- return sources;
84
- }
85
- function readStyleString(style, key) {
86
- if (style === undefined)
87
- return undefined;
88
- // style is a StyleProp (possibly a nested array), so flatten before reading a key.
89
- const flat = flattenStyle(style);
90
- const value = Object.hasOwn(flat, key) ? flat[key] : undefined;
91
- return typeof value === 'string' ? value : undefined;
92
- }
93
- // Resolve an asset source and read its single uri. RN forwards the Android
94
- // loading indicator as a bare uri string (`loadingIndicatorSrc`), not the
95
- // array shape the main source uses, so we resolve and pluck the uri.
96
- function readSourceUri(source) {
97
- const [resolved] = normalizeSource(source);
98
- if (typeof resolved === 'object' && resolved !== null) {
99
- const uri = Reflect.get(resolved, 'uri');
100
- if (typeof uri === 'string')
101
- return uri;
102
- }
103
- return undefined;
104
- }
105
- export function renderImage(view) {
106
- // `width` / `height` aliases fold into style (ImageProps.js:195,202); explicit
107
- // style keys win, matching RN's `{width, height}, ...style` ordering.
108
- const foldedStyle = view.width === undefined && view.height === undefined
109
- ? view.style
110
- : [{ width: view.width, height: view.height }, view.style];
111
- const mapped = {
112
- ...view.passthrough,
113
- style: foldedStyle,
114
- source: resolveSourceArray(view),
115
- resizeMode: view.resizeMode ?? readStyleString(view.style, 'resizeMode'),
116
- tintColor: view.tintColor ?? readStyleString(view.style, 'tintColor'),
117
- };
118
- // `alt` is the accessibility text: it sets accessibilityLabel and marks the image
119
- // accessible (Image.ios.js / Image.android.js: alt -> accessibilityLabel + accessible).
120
- // An explicit accessibilityLabel (already folded into passthrough) still wins.
121
- if (view.alt !== undefined) {
122
- if (mapped.accessibilityLabel === undefined)
123
- mapped.accessibilityLabel = view.alt;
124
- mapped.accessible = true;
125
- }
126
- if (view.defaultSource !== undefined)
127
- mapped.defaultSource = normalizeSource(view.defaultSource);
128
- if (view.loadingIndicatorSource !== undefined) {
129
- mapped.loadingIndicatorSrc = readSourceUri(view.loadingIndicatorSource);
130
- }
131
- dlog('Image -> RCTImageView');
132
- return el('symbiote-image', mapped);
133
- }