@delacour/react-native-ui 0.4.0-alpha.20261020154654 → 0.4.0-alpha.20261020155114

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.4.0-alpha.20261020154654",
3
+ "version": "0.4.0-alpha.20261020155114",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -181,7 +181,7 @@
181
181
  "expo-blur": ">=15",
182
182
  "@maplibre/maplibre-react-native": "^11.5.0",
183
183
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
184
- "@delacour/react-native-bottom-sheet": "0.2.0-alpha.20261020154654",
184
+ "@delacour/react-native-bottom-sheet": "0.2.0-alpha.20261020155114",
185
185
  "@delacour/react-native-charts": ">=0.1.0",
186
186
  "@legendapp/list": ">=3.3",
187
187
  "@shopify/react-native-skia": ">=2",
@@ -24,7 +24,7 @@ A row of media, text and actions, for lists and settings. Compound root plus
24
24
  | `item-separator.tsx` | `Item.Separator` |
25
25
  | `item.context.tsx` | `ItemProvider`, `useItem()`, `useItemContext()`, `useItemPart()` |
26
26
  | `item.types.ts` | Prop types shared by two or more parts |
27
- | `item.variants.ts` | Pure `tv()` slots and the four resolvers, no RN imports |
27
+ | `item.variants.ts` | Pure `tv()` slots, the four resolvers and `wrapItemTextChildren`, no RN imports |
28
28
  | `item.variants.test.ts` | |
29
29
 
30
30
  ## Design
@@ -87,6 +87,10 @@ A row of media, text and actions, for lists and settings. Compound root plus
87
87
  hand-placed rule in a group.
88
88
  - **String children** are wrapped in a `Content` around a `Title`, consecutive
89
89
  strings collapsing into one — the same rule, and the same reason, as
90
- [`ListGroup`](../list-group/AGENTS.md).
90
+ [`ListGroup`](../list-group/AGENTS.md). `wrapItemTextChildren` does it and
91
+ hands one node back as that node, never a one-item array: `Pressable` with
92
+ `asChild` passes its children to `Children.only`, which rejects any array,
93
+ so `<Item asChild onPress={…}><View /></Item>` threw until it did. The
94
+ tests hold that case.
91
95
  - **Text colour goes on the text.** No slot but `title` and `description` holds
92
96
  a `text-*` colour; the tests assert the root never does.
@@ -1,4 +1,4 @@
1
- import { Children, type ReactElement, type ReactNode, useMemo } from "react";
1
+ import { type ReactElement, type ReactNode, useMemo } from "react";
2
2
  import type { AccessibilityState } from "react-native";
3
3
  import Animated from "react-native-reanimated";
4
4
  import { useListGroupContext } from "../list-group/list-group.context";
@@ -13,6 +13,7 @@ import {
13
13
  resolveItemRender,
14
14
  resolveItemSize,
15
15
  resolveItemSurface,
16
+ wrapItemTextChildren,
16
17
  } from "./item.variants";
17
18
  import { ItemActions } from "./item-actions";
18
19
  import { ItemContent } from "./item-content";
@@ -115,40 +116,20 @@ function ItemRoot({
115
116
  /**
116
117
  * Wraps bare text children in a title inside a content column.
117
118
  *
118
- * Consecutive strings and numbers are collected into a single title rather
119
- * than one each — `Row {index}` is one piece of text. React Native cannot
120
- * render a string outside a `<Text>`, so without this `<Item>Wi-Fi</Item>`
121
- * would crash.
119
+ * React Native cannot render a string outside a `<Text>`, so without this
120
+ * `<Item>Wi-Fi</Item>` would crash. The run-collapsing and the single-child
121
+ * rule `asChild` depends on live in `wrapItemTextChildren`, where they are
122
+ * tested.
122
123
  *
123
124
  * Lives with the root because the root is what wraps its own text; a part
124
125
  * importing it from here would close a cycle. See AGENTS.md rule 3.
125
126
  */
126
127
  function wrapTextChildren(children: ReactNode): ReactNode {
127
- const items = Children.toArray(children);
128
- const output: ReactNode[] = [];
129
- let run: (string | number)[] = [];
130
-
131
- const flushRun = () => {
132
- if (run.length === 0) return;
133
- output.push(
134
- <ItemContent key={`content-${output.length}`}>
135
- <ItemTitle>{run.join("")}</ItemTitle>
136
- </ItemContent>
137
- );
138
- run = [];
139
- };
140
-
141
- for (const child of items) {
142
- if (typeof child === "string" || typeof child === "number") {
143
- run.push(child);
144
- continue;
145
- }
146
- flushRun();
147
- output.push(child);
148
- }
149
- flushRun();
150
-
151
- return output;
128
+ return wrapItemTextChildren(children, (text, key) => (
129
+ <ItemContent key={key}>
130
+ <ItemTitle>{text}</ItemTitle>
131
+ </ItemContent>
132
+ ));
152
133
  }
153
134
 
154
135
  /**
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { Children, createElement, isValidElement, type ReactElement } from "react";
2
3
  import { ICON_SIZE_TOKENS } from "../../styles/tokens";
3
4
  import { LIST_GROUP_SIZES, listGroupVariants } from "../list-group/list-group.variants";
4
5
  import {
@@ -11,6 +12,7 @@ import {
11
12
  resolveItemRender,
12
13
  resolveItemSize,
13
14
  resolveItemSurface,
15
+ wrapItemTextChildren,
14
16
  } from "./item.variants";
15
17
 
16
18
  /** Pulls the horizontal padding step out of a class string — `px-4` yields 4. */
@@ -261,3 +263,41 @@ describe("itemVariants orientation", () => {
261
263
  expect(itemVariants({ groupOrientation: "horizontal" }).group()).toContain("flex-row");
262
264
  });
263
265
  });
266
+
267
+ describe("wrapItemTextChildren", () => {
268
+ const wrap = (text: string, key: string): ReactElement => createElement("title", { key }, text);
269
+
270
+ test("hands a single element back as that element, never a one-item array", () => {
271
+ const child = createElement("view");
272
+ const result = wrapItemTextChildren(child, wrap);
273
+ expect(Array.isArray(result)).toBe(false);
274
+ expect(isValidElement(result)).toBe(true);
275
+ });
276
+
277
+ test("survives Children.only — the check Pressable asChild makes", () => {
278
+ const result = wrapItemTextChildren(createElement("view"), wrap);
279
+ expect(() => Children.only(result)).not.toThrow();
280
+ });
281
+
282
+ test("hands a lone wrapped string back as one element", () => {
283
+ const result = wrapItemTextChildren("Wi-Fi", wrap);
284
+ expect(Array.isArray(result)).toBe(false);
285
+ expect(isValidElement(result) && result.type).toBe("title");
286
+ });
287
+
288
+ test("collapses consecutive strings and numbers into one run", () => {
289
+ const result = wrapItemTextChildren(["Row ", 3], wrap);
290
+ expect(isValidElement(result)).toBe(true);
291
+ expect(isValidElement(result) && (result.props as { children: string }).children).toBe("Row 3");
292
+ });
293
+
294
+ test("keeps an array when there is more than one node", () => {
295
+ const result = wrapItemTextChildren(["Wi-Fi", createElement("view", { key: "switch" }), "On"], wrap);
296
+ expect(Array.isArray(result)).toBe(true);
297
+ expect(Children.count(result)).toBe(3);
298
+ });
299
+
300
+ test("returns nothing for no children", () => {
301
+ expect(Children.count(wrapItemTextChildren(undefined, wrap))).toBe(0);
302
+ });
303
+ });
@@ -1,3 +1,4 @@
1
+ import { Children, type ReactElement, type ReactNode } from "react";
1
2
  import type { VariantProps } from "tailwind-variants";
2
3
  import { tv } from "../../lib/tv";
3
4
  import type { PressableFeedback } from "../pressable/pressable.variants";
@@ -84,6 +85,44 @@ export function resolveItemRender(options: {
84
85
  return hasHandler ? "pressable" : "static";
85
86
  }
86
87
 
88
+ /**
89
+ * Wraps each run of bare text children through `wrap`, leaving every other
90
+ * child as it is.
91
+ *
92
+ * Consecutive strings and numbers are collected into a single run rather than
93
+ * one each — `Row {index}` is one piece of text.
94
+ *
95
+ * One node comes back as that node, never as a one-item array: `Pressable`
96
+ * with `asChild` hands its children to `Children.only`, which rejects an
97
+ * array whatever its length, so `<Item asChild onPress={…}><View /></Item>`
98
+ * threw before this.
99
+ */
100
+ export function wrapItemTextChildren(
101
+ children: ReactNode,
102
+ wrap: (text: string, key: string) => ReactElement
103
+ ): ReactNode {
104
+ const output: ReactNode[] = [];
105
+ let run: (string | number)[] = [];
106
+
107
+ const flushRun = () => {
108
+ if (run.length === 0) return;
109
+ output.push(wrap(run.join(""), `content-${output.length}`));
110
+ run = [];
111
+ };
112
+
113
+ for (const child of Children.toArray(children)) {
114
+ if (typeof child === "string" || typeof child === "number") {
115
+ run.push(child);
116
+ continue;
117
+ }
118
+ flushRun();
119
+ output.push(child);
120
+ }
121
+ flushRun();
122
+
123
+ return output.length === 1 ? output[0] : output;
124
+ }
125
+
87
126
  /**
88
127
  * Styling for every part of an item.
89
128
  *
@@ -75,6 +75,8 @@ five slots: `ItemPrefix`, `ItemContent`, `ItemTitle`, `ItemDescription`,
75
75
  tunes that glyph and is ignored once it does.
76
76
  - **String children** are wrapped in an `ItemContent` around an `ItemTitle`
77
77
  automatically, consecutive strings collapsing into one — the same rule, and
78
- the same reason, as [`Button`](../button/AGENTS.md).
78
+ the same reason, as [`Button`](../button/AGENTS.md). A single node comes
79
+ back unwrapped, not as a one-item array, so `asChild` (which calls
80
+ `Children.only`) takes it.
79
81
  - **Title colour goes on the title.** The `item` slot carries no `text-*`; a row
80
82
  is a `View` and cannot cascade colour to a `Text`. The tests assert this.
@@ -92,6 +92,9 @@ ListGroupItem.displayName = "DelacourUI.ListGroup.Item";
92
92
  * Lives here rather than with the root: it is the row that wraps its own text,
93
93
  * and importing it from `./list-group` would close a cycle back through the
94
94
  * root. See AGENTS.md rule 3.
95
+ *
96
+ * One node comes back as that node, never a one-item array: `Pressable` with
97
+ * `asChild` hands its children to `Children.only`, which rejects any array.
95
98
  */
96
99
  function wrapTextChildren(children: ReactNode): ReactNode {
97
100
  const items = Children.toArray(children);
@@ -118,5 +121,5 @@ function wrapTextChildren(children: ReactNode): ReactNode {
118
121
  }
119
122
  flushRun();
120
123
 
121
- return output;
124
+ return output.length === 1 ? output[0] : output;
122
125
  }
@@ -13,7 +13,7 @@ and only `onPress` and `onLongPress` cross back to JS.
13
13
  | `index.ts` | → `@delacour/react-native-ui/pressable` |
14
14
  | `pressable.tsx` | The Gesture API primitive |
15
15
  | `pressable.variants.ts` | Shared feedback vocabulary, no RN imports |
16
- | `pressable.variants.test.ts` | |
16
+ | `pressable.variants.test.ts` | Feedback, rest opacity, accessibility state and activation matrices |
17
17
 
18
18
  ## Design
19
19
 
@@ -52,6 +52,30 @@ and only `onPress` and `onLongPress` cross back to JS.
52
52
  [`Marker`](../marker/AGENTS.md), a running step that must stay tappable,
53
53
  is the case that found it. Neither applies any opacity — that is the
54
54
  caller's variant's job, and the point below is what lets it do that job.
55
+ - **A screen reader's activation is wired to `onPress`, not left to the
56
+ gesture.** A Gesture Handler tap only ever sees touches. VoiceOver's
57
+ double-tap calls `accessibilityActivate`, which React Native answers only
58
+ when `onAccessibilityTap` is set; without it UIKit synthesises a touch at the
59
+ element's centre and the gesture happens to catch it. TalkBack's double-tap
60
+ performs `ACTION_CLICK`, which React Native routes only to a listed
61
+ `activate` action and otherwise drops into `View.performClick`, with no
62
+ listener behind it. `resolvePressableActivation` decides per platform:
63
+ `onAccessibilityTap` on iOS (an `activate` entry there would become a literal
64
+ "activate" in every button's rotor, since Fabric lists every entry as a custom
65
+ action), an appended `activate` action on Android. Both go through
66
+ `isPressableInteractive`, the same guard the gesture uses, so a disabled or
67
+ busy control consumes the activation and does nothing. Returning `YES` from
68
+ `accessibilityActivate` also stops UIKit synthesising the touch, so one
69
+ double-tap is one `onPress`. A caller that lists `activate` itself — the
70
+ `NumberInput` stepper, `Message`, `FlipCard`, `SectionRail.Trigger`,
71
+ `ProgressButton` — owns it on both platforms and the pressable wires nothing;
72
+ a caller's own `onAccessibilityTap` wins the same way. A caller's other
73
+ actions and its `onAccessibilityAction` are merged in, never replaced.
74
+ `Button`, `ListGroup.Item`, `Screen.Navbar.BackButton`, `Dialog.Close`,
75
+ `BottomSheet.Close` and every other `Pressable` consumer inherit it.
76
+ Verified on an iOS simulator with `AXPress` through Simulator.app's
77
+ accessibility bridge, which calls `accessibilityActivate` — `warden ui press`
78
+ does not, it synthesises a touch, so it cannot tell the two paths apart.
55
79
  - **A className's opacity is multiplied into the press, never overwritten.**
56
80
  The press feedback is an animated style, and an animated style owns every key
57
81
  it writes on the view — so its resting `opacity: 1` silently replaced a
@@ -9,7 +9,7 @@ import {
9
9
  type Ref,
10
10
  useMemo,
11
11
  } from "react";
12
- import type { AccessibilityState, ViewProps } from "react-native";
12
+ import { type AccessibilityActionEvent, type AccessibilityState, Platform, type ViewProps } from "react-native";
13
13
  import { Gesture, GestureDetector } from "react-native-gesture-handler";
14
14
  import { Presets } from "react-native-pulsar";
15
15
  import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated";
@@ -18,8 +18,10 @@ import { useResolveClassNames } from "uniwind";
18
18
  import { composeRefs } from "../../lib/compose-refs";
19
19
  import { mergeProps } from "../../lib/merge-props";
20
20
  import {
21
+ isPressableInteractive,
21
22
  type PressableFeedback,
22
23
  resolvePressableAccessibilityState,
24
+ resolvePressableActivation,
23
25
  resolvePressedState,
24
26
  resolveRestOpacity,
25
27
  } from "./pressable.variants";
@@ -137,6 +139,11 @@ export type PressableProps = Omit<ViewProps, "style"> & {
137
139
  * Built on the Gesture API rather than a ready-made pressable so the animation,
138
140
  * the haptic and the gesture stay on one thread, and so nothing depends on
139
141
  * Gesture Handler's own `Pressable`, which was renamed in its v3.
142
+ *
143
+ * A gesture only ever sees touches, so a screen reader's activation is wired
144
+ * to `onPress` separately — `onAccessibilityTap` on iOS, an `activate` action on
145
+ * Android — behind the same `disabled` / `busy` guard. See
146
+ * `resolvePressableActivation`.
140
147
  */
141
148
  export function Pressable({
142
149
  children,
@@ -151,11 +158,14 @@ export function Pressable({
151
158
  pressedOpacity,
152
159
  asChild = false,
153
160
  accessibilityState,
161
+ accessibilityActions,
162
+ onAccessibilityAction,
163
+ onAccessibilityTap,
154
164
  ref,
155
165
  ...props
156
166
  }: PressableProps): ReactElement {
157
167
  const pressed = useSharedValue(0);
158
- const interactive = !disabled && !busy;
168
+ const interactive = isPressableInteractive({ busy, disabled });
159
169
  // Destructured to primitives on purpose. Reanimated's plugin collects what
160
170
  // the worklet closes over, and a fresh object each render would rebuild the
161
171
  // animated style every render.
@@ -207,6 +217,31 @@ export function Pressable({
207
217
  // the prop's default — see `resolvePressableAccessibilityState`.
208
218
  const state: AccessibilityState = resolvePressableAccessibilityState({ accessibilityState, busy, disabled });
209
219
 
220
+ const activation = resolvePressableActivation({
221
+ accessibilityActions,
222
+ hasAccessibilityTap: onAccessibilityTap !== undefined,
223
+ hasPress: onPress !== undefined,
224
+ platform: Platform.OS,
225
+ });
226
+ const activate = () => {
227
+ if (interactive) onPress?.();
228
+ };
229
+ // Built once and spread last at both render sites, after `{...props}`, so a
230
+ // caller's actions and handlers are merged in here rather than replacing the
231
+ // activation the pressable adds. `activate` is consumed only when the
232
+ // pressable added it; every other action reaches the caller's handler.
233
+ const accessibilityWiring = {
234
+ accessibilityActions: activation.accessibilityActions,
235
+ onAccessibilityAction: (event: AccessibilityActionEvent) => {
236
+ if (activation.routesActivate && event.nativeEvent.actionName === "activate") {
237
+ activate();
238
+ return;
239
+ }
240
+ onAccessibilityAction?.(event);
241
+ },
242
+ onAccessibilityTap: activation.wiresAccessibilityTap ? activate : onAccessibilityTap,
243
+ };
244
+
210
245
  const content = asChild ? (
211
246
  renderAsChild(children, {
212
247
  accessibilityState: state,
@@ -215,6 +250,7 @@ export function Pressable({
215
250
  ref,
216
251
  style: animatedStyle,
217
252
  ...props,
253
+ ...accessibilityWiring,
218
254
  })
219
255
  ) : (
220
256
  <Animated.View
@@ -229,6 +265,7 @@ export function Pressable({
229
265
  ref={ref}
230
266
  style={animatedStyle}
231
267
  {...props}
268
+ {...accessibilityWiring}
232
269
  >
233
270
  {children}
234
271
  </Animated.View>
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
+ isPressableInteractive,
3
4
  PRESSABLE_FEEDBACK,
4
5
  PRESSABLE_FEEDBACK_FALLBACK,
5
6
  PRESSABLE_FEEDBACKS,
6
7
  resolvePressableAccessibilityState,
8
+ resolvePressableActivation,
7
9
  resolvePressedState,
8
10
  resolveRestOpacity,
9
11
  } from "./pressable.variants";
@@ -170,3 +172,93 @@ describe("resolvePressableAccessibilityState", () => {
170
172
  expect(state.expanded).toBe(true);
171
173
  });
172
174
  });
175
+
176
+ describe("isPressableInteractive", () => {
177
+ test("only a control that is neither disabled nor busy takes a press", () => {
178
+ expect(isPressableInteractive({ busy: false, disabled: false })).toBe(true);
179
+ expect(isPressableInteractive({ busy: true, disabled: false })).toBe(false);
180
+ expect(isPressableInteractive({ busy: false, disabled: true })).toBe(false);
181
+ expect(isPressableInteractive({ busy: true, disabled: true })).toBe(false);
182
+ });
183
+ });
184
+
185
+ describe("resolvePressableActivation", () => {
186
+ test("iOS wires onAccessibilityTap and adds no custom action", () => {
187
+ expect(resolvePressableActivation({ hasAccessibilityTap: false, hasPress: true, platform: "ios" })).toEqual({
188
+ accessibilityActions: undefined,
189
+ routesActivate: false,
190
+ wiresAccessibilityTap: true,
191
+ });
192
+ });
193
+
194
+ test("iOS leaves a caller's own actions untouched, so the rotor gains no 'activate' entry", () => {
195
+ const actions = [{ label: "Remove", name: "remove" }];
196
+ const resolved = resolvePressableActivation({
197
+ accessibilityActions: actions,
198
+ hasAccessibilityTap: false,
199
+ hasPress: true,
200
+ platform: "ios",
201
+ });
202
+ expect(resolved.accessibilityActions).toBe(actions);
203
+ expect(resolved.wiresAccessibilityTap).toBe(true);
204
+ });
205
+
206
+ test("Android appends 'activate' after a caller's actions and routes it", () => {
207
+ const resolved = resolvePressableActivation({
208
+ accessibilityActions: [{ label: "Remove", name: "remove" }],
209
+ hasAccessibilityTap: false,
210
+ hasPress: true,
211
+ platform: "android",
212
+ });
213
+ expect(resolved).toEqual({
214
+ accessibilityActions: [{ label: "Remove", name: "remove" }, { name: "activate" }],
215
+ routesActivate: true,
216
+ wiresAccessibilityTap: false,
217
+ });
218
+ });
219
+
220
+ test("Android with no caller actions gets exactly one 'activate'", () => {
221
+ expect(
222
+ resolvePressableActivation({ hasAccessibilityTap: false, hasPress: true, platform: "android" })
223
+ .accessibilityActions
224
+ ).toEqual([{ name: "activate" }]);
225
+ });
226
+
227
+ test("a caller that lists 'activate' owns it on both platforms, so it never fires twice", () => {
228
+ const actions = [{ name: "activate" }, { name: "increment" }];
229
+ for (const platform of ["ios", "android"] as const) {
230
+ expect(
231
+ resolvePressableActivation({
232
+ accessibilityActions: actions,
233
+ hasAccessibilityTap: false,
234
+ hasPress: true,
235
+ platform,
236
+ })
237
+ ).toEqual({ accessibilityActions: actions, routesActivate: false, wiresAccessibilityTap: false });
238
+ }
239
+ });
240
+
241
+ test("a caller's own onAccessibilityTap wins on iOS", () => {
242
+ expect(
243
+ resolvePressableActivation({ hasAccessibilityTap: true, hasPress: true, platform: "ios" }).wiresAccessibilityTap
244
+ ).toBe(false);
245
+ });
246
+
247
+ test("nothing is wired without an onPress to reach", () => {
248
+ for (const platform of ["ios", "android"] as const) {
249
+ expect(resolvePressableActivation({ hasAccessibilityTap: false, hasPress: false, platform })).toEqual({
250
+ accessibilityActions: undefined,
251
+ routesActivate: false,
252
+ wiresAccessibilityTap: false,
253
+ });
254
+ }
255
+ });
256
+
257
+ test("any other platform is left alone", () => {
258
+ expect(resolvePressableActivation({ hasAccessibilityTap: false, hasPress: true, platform: "web" })).toEqual({
259
+ accessibilityActions: undefined,
260
+ routesActivate: false,
261
+ wiresAccessibilityTap: false,
262
+ });
263
+ });
264
+ });
@@ -111,3 +111,82 @@ export function resolvePressableAccessibilityState<S extends GatedAccessibilityS
111
111
  disabled: disabled || accessibilityState?.disabled === true,
112
112
  };
113
113
  }
114
+
115
+ /**
116
+ * Whether a pressable takes a press at all — the one guard the tap gesture, the
117
+ * long press and screen-reader activation all go through, so a gated control
118
+ * cannot be reached by one of them and not the others.
119
+ */
120
+ export function isPressableInteractive({ busy, disabled }: { busy: boolean; disabled: boolean }): boolean {
121
+ return !disabled && !busy;
122
+ }
123
+
124
+ /** The shape of an entry in React Native's `accessibilityActions`. Structural, so no RN import. */
125
+ type PressableAccessibilityAction = { name: string; label?: string };
126
+
127
+ export type PressableActivation<A extends PressableAccessibilityAction> = {
128
+ /** What the view's `accessibilityActions` becomes — the caller's own, plus `activate` on Android. */
129
+ accessibilityActions: ReadonlyArray<A | { name: "activate" }> | undefined;
130
+ /** The pressable consumes `activate` in `onAccessibilityAction` and calls `onPress` itself. */
131
+ routesActivate: boolean;
132
+ /** The pressable sets `onAccessibilityTap`, which is what VoiceOver's double-tap reaches. */
133
+ wiresAccessibilityTap: boolean;
134
+ };
135
+
136
+ /**
137
+ * How a screen reader's activation reaches `onPress`.
138
+ *
139
+ * The press is a Gesture Handler tap, which only ever sees touches. Each
140
+ * platform's activation is something else:
141
+ *
142
+ * - **iOS.** A VoiceOver double-tap calls `accessibilityActivate`, which React
143
+ * Native answers only when `onAccessibilityTap` is set. Without it UIKit falls
144
+ * back to synthesising a touch at the element's centre, which the gesture
145
+ * happens to recognise — so it worked, but by accident. Setting it makes the
146
+ * call return `YES`, which also stops UIKit synthesising the touch: one
147
+ * activation, one `onPress`. An `activate` entry in `accessibilityActions` is
148
+ * no use here: Fabric lists every entry as a rotor custom action, so it would
149
+ * add a literal "activate" to every button's rotor.
150
+ * - **Android.** A TalkBack double-tap performs `ACTION_CLICK`, which React
151
+ * Native routes to `onAccessibilityAction` only when `activate` is listed.
152
+ * Without it the click falls to `View.performClick`, with no listener behind
153
+ * it. React Native ignores `onAccessibilityTap` on Android.
154
+ *
155
+ * A caller that lists `activate` itself owns it on both platforms — a
156
+ * `NumberInput` stepper, a `Message`, a `FlipCard` — and the pressable wires
157
+ * nothing, so the press cannot fire twice. A caller's own `onAccessibilityTap`
158
+ * wins the same way. With no `onPress` there is nothing to reach.
159
+ *
160
+ * The `disabled` / `busy` guard is not decided here: activation stays wired on
161
+ * a gated control and the handler checks `isPressableInteractive`, so a gated
162
+ * control's double-tap is consumed and does nothing rather than falling back
163
+ * to a synthesised touch.
164
+ */
165
+ export function resolvePressableActivation<A extends PressableAccessibilityAction>({
166
+ accessibilityActions,
167
+ hasAccessibilityTap,
168
+ hasPress,
169
+ platform,
170
+ }: {
171
+ accessibilityActions?: ReadonlyArray<A>;
172
+ hasAccessibilityTap: boolean;
173
+ hasPress: boolean;
174
+ platform: string;
175
+ }): PressableActivation<A> {
176
+ const ownedByCaller = accessibilityActions?.some((action) => action.name === "activate") === true;
177
+ const wires = hasPress && !ownedByCaller;
178
+
179
+ if (wires && platform === "android") {
180
+ return {
181
+ accessibilityActions: [...(accessibilityActions ?? []), { name: "activate" }],
182
+ routesActivate: true,
183
+ wiresAccessibilityTap: false,
184
+ };
185
+ }
186
+
187
+ return {
188
+ accessibilityActions,
189
+ routesActivate: false,
190
+ wiresAccessibilityTap: wires && platform === "ios" && !hasAccessibilityTap,
191
+ };
192
+ }
@@ -34,6 +34,8 @@ whole-screen states.
34
34
  | `screen-loading.tsx` | `Screen.Loading` |
35
35
  | `screen-error.tsx` | `Screen.Error` |
36
36
  | `screen-list-component.tsx` | A list header/footer prop, as a node — internal |
37
+ | `screen-scroll-handler.ts` | `classifyScrollHandler` — sorts a caller's `onScroll` into JS or worklet, no RN imports |
38
+ | `screen-scroll-handler.test.ts` | |
37
39
  | `screen-debug.ts` | `SCREEN_DEBUG_COLORS` |
38
40
  | `screen.context.tsx` | `ScreenProvider`, `useScreen()`, `useScreenContext()`, `useScreenPart()`, `useScreenDebug()` |
39
41
  | `screen.types.ts` | Prop types shared by two or more parts |
@@ -122,6 +124,20 @@ whole-screen states.
122
124
 
123
125
  ## Lists
124
126
 
127
+ - **A caller's `onScroll` is composed with the screen's tracking, never
128
+ swapped for it.** Every scrollable but `ChatList` takes one, and hands it to
129
+ `useScreenScrollInsets` rather than spreading it. Spread before the screen's
130
+ handler it never ran; spread after, it replaced the handler and froze
131
+ `scrollY`, `contentHeight` and `layoutHeight` — so `ScrollShadow`, the
132
+ navbar's scroll border and every `ScrollSource` effect stopped moving the
133
+ moment a consumer listened to the scroll. A `useAnimatedScrollHandler` result
134
+ runs beside the tracking on the UI thread through `useComposedEventHandler`; a
135
+ plain function is called back on the JS thread with `{ nativeEvent }` only,
136
+ because a worklet holds the native payload, not React Native's synthetic
137
+ wrapper. Prefer the worklet form for anything frame-bound.
138
+ - **`Screen.ChatList` takes no `onScroll`.** Its engines own the scroll for
139
+ anchoring and the keyboard lift, so the prop is omitted from its types rather
140
+ than half-supported.
125
141
  - **`Screen.ChatList` is a discriminated union on `variant`.** `legend`
126
142
  (default) takes chronological oldest-first data and LegendList's own
127
143
  `renderItem`; `flat` is an inverted `FlatList` with newest-first data and RN's.
@@ -46,11 +46,15 @@ export const ScreenFlatList = forwardRef(function ScreenFlatListRender<ItemT>(
46
46
  contentContainerClassName,
47
47
  ListHeaderComponent: ListHeaderComponentProp,
48
48
  ListFooterComponent: ListFooterComponentProp,
49
+ onScroll,
49
50
  ...props
50
51
  }: ScreenFlatListProps<ItemT>,
51
52
  ref: ForwardedRef<FlatList<ItemT>>
52
53
  ): ReactElement {
53
- const { scrollHandler, insetTopAnimatedStyle, insetBottomAnimatedStyle } = useScreenScrollInsets("standard");
54
+ const { scrollHandler, insetTopAnimatedStyle, insetBottomAnimatedStyle } = useScreenScrollInsets(
55
+ "standard",
56
+ onScroll
57
+ );
54
58
 
55
59
  const ListHeaderComponent = useMemo<ReactElement>(
56
60
  () => (
@@ -63,11 +63,15 @@ export const ScreenLegendList = forwardRef(function ScreenLegendListRender<ItemT
63
63
  contentContainerClassName,
64
64
  ListHeaderComponent: ListHeaderComponentProp,
65
65
  ListFooterComponent: ListFooterComponentProp,
66
+ onScroll,
66
67
  ...props
67
68
  }: ScreenLegendListProps<ItemT>,
68
69
  ref: ForwardedRef<LegendListRef>
69
70
  ): ReactElement {
70
- const { scrollHandler, insetTopAnimatedStyle, insetBottomAnimatedStyle } = useScreenScrollInsets("standard");
71
+ const { scrollHandler, insetTopAnimatedStyle, insetBottomAnimatedStyle } = useScreenScrollInsets(
72
+ "standard",
73
+ onScroll
74
+ );
71
75
 
72
76
  const ListHeaderComponent = useMemo<ReactElement>(
73
77
  () => (
@@ -48,11 +48,12 @@ export type ScreenScrollAreaProps = ScrollViewProps &
48
48
  * </Screen.ScrollArea>
49
49
  */
50
50
  export const ScreenScrollArea = forwardRef<ScreenScrollViewRef, ScreenScrollAreaProps>(function ScreenScrollAreaRender(
51
- { header, className, contentContainerClassName, keyboardAware = false, children, ...props },
51
+ { header, className, contentContainerClassName, keyboardAware = false, onScroll, children, ...props },
52
52
  ref
53
53
  ): ReactElement {
54
54
  const { scrollHandler, insetTopAnimatedStyle, insetBottomAnimatedStyle } = useScreenScrollInsets(
55
- keyboardAware ? "keyboard-aware" : "standard"
55
+ keyboardAware ? "keyboard-aware" : "standard",
56
+ onScroll
56
57
  );
57
58
  // A sticky footer rides the keyboard, so the focused field has to clear the
58
59
  // footer too — not just the keyboard. Deliberately not the chat list's
@@ -85,11 +86,11 @@ export const ScreenScrollArea = forwardRef<ScreenScrollViewRef, ScreenScrollArea
85
86
  <StyledKeyboardAwareScrollView
86
87
  className={className}
87
88
  keyboardShouldPersistTaps="handled"
88
- onScroll={scrollHandler}
89
89
  showsHorizontalScrollIndicator={false}
90
90
  showsVerticalScrollIndicator={false}
91
91
  bottomOffset={footerClearance}
92
92
  {...props}
93
+ onScroll={scrollHandler}
93
94
  ref={ref as Ref<KeyboardAwareScrollViewRef>}
94
95
  contentContainerClassName={contentContainer}
95
96
  >
@@ -102,10 +103,10 @@ export const ScreenScrollArea = forwardRef<ScreenScrollViewRef, ScreenScrollArea
102
103
  <Animated.ScrollView
103
104
  className={className}
104
105
  keyboardShouldPersistTaps="handled"
105
- onScroll={scrollHandler}
106
106
  showsHorizontalScrollIndicator={false}
107
107
  showsVerticalScrollIndicator={false}
108
108
  {...props}
109
+ onScroll={scrollHandler}
109
110
  ref={ref}
110
111
  contentContainerClassName={contentContainer}
111
112
  >
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { classifyScrollHandler } from "./screen-scroll-handler";
3
+
4
+ describe("classifyScrollHandler", () => {
5
+ test("nothing supplied is none", () => {
6
+ expect(classifyScrollHandler(undefined)).toEqual({ kind: "none" });
7
+ expect(classifyScrollHandler(null)).toEqual({ kind: "none" });
8
+ });
9
+
10
+ test("a plain function is a JS callback", () => {
11
+ const onScroll = (): void => {};
12
+ expect(classifyScrollHandler(onScroll)).toEqual({ kind: "js", handler: onScroll });
13
+ });
14
+
15
+ test("a useAnimatedScrollHandler result is a worklet handler", () => {
16
+ const processed = { workletEventHandler: { eventNames: ["onScroll"] } };
17
+ expect(classifyScrollHandler(processed)).toEqual({ kind: "worklet", handler: processed });
18
+ });
19
+
20
+ test("an object without a worklet handler is none", () => {
21
+ expect(classifyScrollHandler({})).toEqual({ kind: "none" });
22
+ });
23
+ });
@@ -0,0 +1,33 @@
1
+ import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native";
2
+
3
+ /** A caller's `onScroll`, as React Native types it. */
4
+ export type ScreenScrollCallback = (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
5
+
6
+ /**
7
+ * What a caller handed a Screen scrollable as `onScroll`.
8
+ *
9
+ * Two shapes reach the same prop and need different plumbing. A
10
+ * `useAnimatedScrollHandler` result is typed as a function but is really an
11
+ * object carrying a `workletEventHandler`, and it composes with the screen's own
12
+ * tracking on the UI thread. A plain function has to be called back on the JS
13
+ * thread instead — Reanimated replaces the JS listener with a no-op once a
14
+ * worklet handler owns the prop, so passing it through would drop it.
15
+ */
16
+ export type ScreenScrollHandler =
17
+ | { kind: "none" }
18
+ | { kind: "js"; handler: ScreenScrollCallback }
19
+ | { kind: "worklet"; handler: object };
20
+
21
+ /**
22
+ * Sorts a caller's `onScroll` into the shape the scroll hook can compose.
23
+ *
24
+ * Structural rather than `instanceof WorkletEventHandler`: the class is
25
+ * Reanimated-internal, and the property is what `useComposedEventHandler` reads.
26
+ */
27
+ export function classifyScrollHandler(value: unknown): ScreenScrollHandler {
28
+ if (typeof value === "function") return { handler: value as ScreenScrollCallback, kind: "js" };
29
+ if (typeof value === "object" && value !== null && "workletEventHandler" in value) {
30
+ return { handler: value, kind: "worklet" };
31
+ }
32
+ return { kind: "none" };
33
+ }
@@ -42,11 +42,15 @@ export const ScreenSectionList = forwardRef(function ScreenSectionListRender<Ite
42
42
  contentContainerClassName,
43
43
  ListHeaderComponent: ListHeaderComponentProp,
44
44
  ListFooterComponent: ListFooterComponentProp,
45
+ onScroll,
45
46
  ...props
46
47
  }: ScreenSectionListProps<ItemT, SectionT>,
47
48
  ref: ForwardedRef<SectionList<ItemT, SectionT>>
48
49
  ): ReactElement {
49
- const { scrollHandler, insetTopAnimatedStyle, insetBottomAnimatedStyle } = useScreenScrollInsets("standard");
50
+ const { scrollHandler, insetTopAnimatedStyle, insetBottomAnimatedStyle } = useScreenScrollInsets(
51
+ "standard",
52
+ onScroll
53
+ );
50
54
 
51
55
  const ListHeaderComponent = useMemo<ReactElement>(
52
56
  () => (
@@ -1,4 +1,5 @@
1
1
  import { useState } from "react";
2
+ import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native";
2
3
  import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
3
4
  import {
4
5
  type DerivedValue,
@@ -6,6 +7,7 @@ import {
6
7
  useAnimatedReaction,
7
8
  useAnimatedScrollHandler,
8
9
  useAnimatedStyle,
10
+ useComposedEventHandler,
9
11
  useDerivedValue,
10
12
  } from "react-native-reanimated";
11
13
  import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -19,6 +21,7 @@ import {
19
21
  resolveScrollTopInset,
20
22
  type ScreenScrollInsetMode,
21
23
  } from "./screen.variants";
24
+ import { classifyScrollHandler, type ScreenScrollCallback } from "./screen-scroll-handler";
22
25
 
23
26
  /**
24
27
  * The footer's collapsed baseline height, as JS state.
@@ -162,25 +165,48 @@ export function useScreenFooterKeyboardClearance(): number {
162
165
  * container cannot animate on the UI thread, and the navbar's height is only
163
166
  * known after it lays out — so a spacer view whose height is an animated style
164
167
  * is what lets the reserve appear in the same frame as the measurement.
168
+ *
169
+ * `onScroll` is the caller's own, and it is composed with the screen's tracking
170
+ * rather than competing with it for the one prop. Pass either a plain callback
171
+ * or a `useAnimatedScrollHandler` result; the hook sorts them.
165
172
  */
166
- export function useScreenScrollInsets(mode: ScreenScrollInsetMode) {
173
+ export function useScreenScrollInsets(mode: ScreenScrollInsetMode, onScroll?: ScreenScrollCallback | null) {
167
174
  const { navbar, footer, scrollY, contentHeight, layoutHeight } = useScreenPart("a Screen scrollable");
168
175
  const { bottom, top } = useSafeAreaInsets();
169
176
  const keyboard = useReanimatedKeyboardAnimation();
170
-
171
- const scrollHandler = useAnimatedScrollHandler({
172
- onScroll: (event) => {
173
- scrollY.value = event.contentOffset.y;
174
-
175
- if (event.contentSize.height !== contentHeight.value) {
176
- contentHeight.value = event.contentSize.height;
177
- }
178
-
179
- if (event.layoutMeasurement.height !== layoutHeight.value) {
180
- layoutHeight.value = event.layoutMeasurement.height;
181
- }
177
+ const caller = classifyScrollHandler(onScroll);
178
+ const callerCallback = caller.kind === "js" ? caller.handler : undefined;
179
+
180
+ const trackingHandler = useAnimatedScrollHandler(
181
+ {
182
+ onScroll: (event) => {
183
+ scrollY.value = event.contentOffset.y;
184
+
185
+ if (event.contentSize.height !== contentHeight.value) {
186
+ contentHeight.value = event.contentSize.height;
187
+ }
188
+
189
+ if (event.layoutMeasurement.height !== layoutHeight.value) {
190
+ layoutHeight.value = event.layoutMeasurement.height;
191
+ }
192
+
193
+ // Only `nativeEvent` is real: a worklet sees the native payload, not
194
+ // React Native's synthetic wrapper, so `persist()` and the rest of the
195
+ // wrapper do not exist. A caller needing more passes a worklet handler.
196
+ if (callerCallback) {
197
+ scheduleOnRN(callerCallback, { nativeEvent: event } as unknown as NativeSyntheticEvent<NativeScrollEvent>);
198
+ }
199
+ },
182
200
  },
183
- });
201
+ [callerCallback]
202
+ );
203
+
204
+ // A caller's `useAnimatedScrollHandler` runs beside the tracking on the UI
205
+ // thread rather than replacing it — one `onScroll` prop, two worklets.
206
+ const scrollHandler = useComposedEventHandler([
207
+ trackingHandler,
208
+ caller.kind === "worklet" ? (caller.handler as typeof trackingHandler) : null,
209
+ ]);
184
210
 
185
211
  const insetTopAnimatedStyle = useAnimatedStyle(() => {
186
212
  return {