@delacour/react-native-ui 0.4.0-alpha.20261020154302 → 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 +2 -2
- package/src/components/item/AGENTS.md +6 -2
- package/src/components/item/item.tsx +11 -30
- package/src/components/item/item.variants.test.ts +40 -0
- package/src/components/item/item.variants.ts +39 -0
- package/src/components/list-group/AGENTS.md +24 -9
- package/src/components/list-group/list-group-item.tsx +51 -13
- package/src/components/list-group/list-group.variants.test.ts +29 -1
- package/src/components/list-group/list-group.variants.ts +24 -0
- package/src/components/pressable/AGENTS.md +25 -1
- package/src/components/pressable/pressable.tsx +39 -2
- package/src/components/pressable/pressable.variants.test.ts +92 -0
- package/src/components/pressable/pressable.variants.ts +79 -0
- package/src/components/screen/AGENTS.md +16 -0
- package/src/components/screen/screen-flat-list.tsx +5 -1
- package/src/components/screen/screen-legend-list.tsx +5 -1
- package/src/components/screen/screen-scroll-area.tsx +5 -4
- package/src/components/screen/screen-scroll-handler.test.ts +23 -0
- package/src/components/screen/screen-scroll-handler.ts +33 -0
- package/src/components/screen/screen-section-list.tsx +5 -1
- package/src/components/screen/use-screen-scroll-insets.ts +40 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.4.0-alpha.
|
|
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.
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
*
|
|
@@ -46,14 +46,27 @@ five slots: `ItemPrefix`, `ItemContent`, `ItemTitle`, `ItemDescription`,
|
|
|
46
46
|
- **The root clips.** `overflow-hidden` is load-bearing: a pressed row fades to
|
|
47
47
|
the edge of its own box, and the first and last rows would square off the
|
|
48
48
|
group's corners without it.
|
|
49
|
-
- **A row is a `Pressable
|
|
50
|
-
`
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
- **A row is a `Pressable` only when it has a handler.**
|
|
50
|
+
`resolveListGroupItemRender` returns `pressable` for an `onPress` or
|
|
51
|
+
`onLongPress` — role `button`, children merged into one accessible element;
|
|
52
|
+
disabled, it keeps the role, reports `disabled` and dims through
|
|
53
|
+
`opacity-50`, which `Pressable` composes with its press animation — and
|
|
54
|
+
`static` for neither: a plain `Animated.View` with the same `item` slot, no
|
|
55
|
+
role and no press feedback. A Settings row showing a value is not a control,
|
|
56
|
+
and VoiceOver announcing it as a button on every swipe is the bug this
|
|
57
|
+
prevents. The rule is `Item`'s `resolveItemRender`, restated in this folder
|
|
58
|
+
rather than imported so a list group copied by the CLI does not drag `item`
|
|
59
|
+
along; the tests pin both. `Animated.View` rather than `View` keeps the `ref`
|
|
60
|
+
one type across both branches.
|
|
61
|
+
- **The pressable props are `Pressable`'s.** `ListGroupItemProps` extends
|
|
62
|
+
`PressableProps`, so `feedback`, `haptic`, `pressedScale` and the rest are
|
|
63
|
+
inherited rather than restated — the row owns no vocabulary of its own. A
|
|
64
|
+
static row ignores them. Only the default differs: `feedback` defaults to
|
|
65
|
+
`fade`, because a full-bleed row that scales reads as the whole card flexing
|
|
66
|
+
rather than as one row responding. A prop is only redeclared where it
|
|
67
|
+
genuinely changes, as [`Button`](../button/AGENTS.md) does with its narrower
|
|
68
|
+
union; redeclaring one unchanged just to hang a doc comment on it puts a
|
|
69
|
+
second definition in the tree that can drift.
|
|
57
70
|
- **Icons are composed, never passed as props.** `ItemPrefix` wraps its subtree
|
|
58
71
|
in an `IconDefaultsProvider` carrying the `prefixIcon` slot — a step on the
|
|
59
72
|
shared icon scale, like every other glyph in the library — and `foreground`,
|
|
@@ -62,6 +75,8 @@ five slots: `ItemPrefix`, `ItemContent`, `ItemTitle`, `ItemDescription`,
|
|
|
62
75
|
tunes that glyph and is ignored once it does.
|
|
63
76
|
- **String children** are wrapped in an `ItemContent` around an `ItemTitle`
|
|
64
77
|
automatically, consecutive strings collapsing into one — the same rule, and
|
|
65
|
-
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.
|
|
66
81
|
- **Title colour goes on the title.** The `item` slot carries no `text-*`; a row
|
|
67
82
|
is a `View` and cannot cascade colour to a `Text`. The tests assert this.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Children, type ReactElement, type ReactNode, useMemo } from "react";
|
|
2
|
+
import type { AccessibilityState } from "react-native";
|
|
3
|
+
import Animated from "react-native-reanimated";
|
|
2
4
|
import { Pressable, type PressableProps } from "../pressable";
|
|
3
5
|
import { useListGroupPart } from "./list-group.context";
|
|
4
|
-
import { listGroupVariants } from "./list-group.variants";
|
|
6
|
+
import { listGroupVariants, resolveListGroupItemRender } from "./list-group.variants";
|
|
5
7
|
import { ListGroupItemContent } from "./list-group-item-content";
|
|
6
8
|
import { ListGroupItemTitle } from "./list-group-item-title";
|
|
7
9
|
|
|
@@ -19,30 +21,63 @@ export type ListGroupItemProps = Omit<PressableProps, "children" | "disabled"> &
|
|
|
19
21
|
* not a convenience — without it `<ListGroup.Item>Wi-Fi</ListGroup.Item>` would
|
|
20
22
|
* crash.
|
|
21
23
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* row
|
|
24
|
+
* Give it an `onPress` or `onLongPress` and it is a `Pressable`, announced as a
|
|
25
|
+
* button — disabled too, when it keeps the role and reports `disabled`. Leave
|
|
26
|
+
* both off and it is a plain view with the same layout, no role and no press
|
|
27
|
+
* feedback, so a static Settings row never claims to be a control.
|
|
28
|
+
* `resolveListGroupItemRender` makes that call.
|
|
29
|
+
*
|
|
30
|
+
* `feedback`, `haptic` and the rest come from `Pressable` rather than being
|
|
31
|
+
* restated here. Only the default differs: `fade`, because a full-bleed row
|
|
32
|
+
* that scales reads as the whole card flexing rather than as one row
|
|
33
|
+
* responding.
|
|
26
34
|
*/
|
|
27
35
|
export function ListGroupItem({
|
|
28
36
|
feedback = "fade",
|
|
29
37
|
isDisabled = false,
|
|
38
|
+
haptic,
|
|
39
|
+
pressedScale,
|
|
40
|
+
pressedOpacity,
|
|
41
|
+
busy,
|
|
42
|
+
asChild,
|
|
43
|
+
onPress,
|
|
44
|
+
onLongPress,
|
|
45
|
+
accessibilityState,
|
|
30
46
|
className,
|
|
31
47
|
children,
|
|
32
48
|
...props
|
|
33
49
|
}: ListGroupItemProps): ReactElement {
|
|
34
50
|
const { size } = useListGroupPart("ListGroup.Item");
|
|
35
51
|
const content = useMemo(() => wrapTextChildren(children), [children]);
|
|
52
|
+
const itemClassName = listGroupVariants({ isDisabled, size }).item({ className });
|
|
53
|
+
|
|
54
|
+
if (resolveListGroupItemRender({ isDisabled, onLongPress, onPress }) === "pressable") {
|
|
55
|
+
return (
|
|
56
|
+
<Pressable
|
|
57
|
+
accessibilityState={accessibilityState}
|
|
58
|
+
asChild={asChild}
|
|
59
|
+
busy={busy}
|
|
60
|
+
className={itemClassName}
|
|
61
|
+
disabled={isDisabled}
|
|
62
|
+
feedback={feedback}
|
|
63
|
+
haptic={haptic}
|
|
64
|
+
onLongPress={onLongPress}
|
|
65
|
+
onPress={onPress}
|
|
66
|
+
pressedOpacity={pressedOpacity}
|
|
67
|
+
pressedScale={pressedScale}
|
|
68
|
+
{...props}
|
|
69
|
+
>
|
|
70
|
+
{content}
|
|
71
|
+
</Pressable>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const state: AccessibilityState = { ...accessibilityState, busy, disabled: isDisabled };
|
|
36
76
|
|
|
37
77
|
return (
|
|
38
|
-
<
|
|
39
|
-
className={listGroupVariants({ isDisabled, size }).item({ className })}
|
|
40
|
-
disabled={isDisabled}
|
|
41
|
-
feedback={feedback}
|
|
42
|
-
{...props}
|
|
43
|
-
>
|
|
78
|
+
<Animated.View accessibilityState={state} className={itemClassName} {...props}>
|
|
44
79
|
{content}
|
|
45
|
-
</
|
|
80
|
+
</Animated.View>
|
|
46
81
|
);
|
|
47
82
|
}
|
|
48
83
|
ListGroupItem.displayName = "DelacourUI.ListGroup.Item";
|
|
@@ -57,6 +92,9 @@ ListGroupItem.displayName = "DelacourUI.ListGroup.Item";
|
|
|
57
92
|
* Lives here rather than with the root: it is the row that wraps its own text,
|
|
58
93
|
* and importing it from `./list-group` would close a cycle back through the
|
|
59
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.
|
|
60
98
|
*/
|
|
61
99
|
function wrapTextChildren(children: ReactNode): ReactNode {
|
|
62
100
|
const items = Children.toArray(children);
|
|
@@ -83,5 +121,5 @@ function wrapTextChildren(children: ReactNode): ReactNode {
|
|
|
83
121
|
}
|
|
84
122
|
flushRun();
|
|
85
123
|
|
|
86
|
-
return output;
|
|
124
|
+
return output.length === 1 ? output[0] : output;
|
|
87
125
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { ICON_SIZE_TOKENS } from "../../styles/tokens";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
LIST_GROUP_SIZES,
|
|
5
|
+
LIST_GROUP_VARIANTS,
|
|
6
|
+
listGroupVariants,
|
|
7
|
+
resolveListGroupItemRender,
|
|
8
|
+
} from "./list-group.variants";
|
|
4
9
|
|
|
5
10
|
/** Pulls the horizontal padding step out of a class string — `px-4` yields 4. */
|
|
6
11
|
function paddingStep(cls: string): string | undefined {
|
|
@@ -221,3 +226,26 @@ describe("listGroupVariants icon slots", () => {
|
|
|
221
226
|
}
|
|
222
227
|
});
|
|
223
228
|
});
|
|
229
|
+
|
|
230
|
+
describe("resolveListGroupItemRender", () => {
|
|
231
|
+
const noop = () => {};
|
|
232
|
+
|
|
233
|
+
// A Settings row with only a value or a switch-for-show is not a control;
|
|
234
|
+
// announcing it as a button is a lie VoiceOver tells on every swipe.
|
|
235
|
+
test("is static without a press handler, disabled or not", () => {
|
|
236
|
+
expect(resolveListGroupItemRender({ isDisabled: false })).toBe("static");
|
|
237
|
+
expect(resolveListGroupItemRender({ isDisabled: true })).toBe("static");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("is a pressable with a handler", () => {
|
|
241
|
+
expect(resolveListGroupItemRender({ isDisabled: false, onPress: noop })).toBe("pressable");
|
|
242
|
+
expect(resolveListGroupItemRender({ isDisabled: false, onLongPress: noop })).toBe("pressable");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// A disabled action is still an action: it keeps the button role and
|
|
246
|
+
// reports `disabled`, rather than vanishing into a plain view.
|
|
247
|
+
test("stays a pressable when disabled", () => {
|
|
248
|
+
expect(resolveListGroupItemRender({ isDisabled: true, onPress: noop })).toBe("pressable");
|
|
249
|
+
expect(resolveListGroupItemRender({ isDisabled: true, onLongPress: noop })).toBe("pressable");
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -105,3 +105,27 @@ export const listGroupVariants = tv({
|
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
export type ListGroupVariantProps = VariantProps<typeof listGroupVariants>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What a list group row renders as.
|
|
111
|
+
*
|
|
112
|
+
* - `pressable` — it has a handler: a `Pressable`, announced as a button. A
|
|
113
|
+
* disabled one keeps the role, reports `disabled`, and dims through
|
|
114
|
+
* `opacity-50`, which `Pressable` composes with its press animation.
|
|
115
|
+
* - `static` — no handler: a plain view with no role and no press feedback. A
|
|
116
|
+
* static row announcing itself as a button is a lie VoiceOver tells on every
|
|
117
|
+
* swipe.
|
|
118
|
+
*
|
|
119
|
+
* The same rule as `resolveItemRender`, restated rather than imported so the
|
|
120
|
+
* list group does not take a registry dependency on `item`.
|
|
121
|
+
*/
|
|
122
|
+
export type ListGroupItemRender = "pressable" | "static";
|
|
123
|
+
|
|
124
|
+
export function resolveListGroupItemRender(options: {
|
|
125
|
+
onPress?: () => void;
|
|
126
|
+
onLongPress?: () => void;
|
|
127
|
+
isDisabled: boolean;
|
|
128
|
+
}): ListGroupItemRender {
|
|
129
|
+
const hasHandler = options.onPress !== undefined || options.onLongPress !== undefined;
|
|
130
|
+
return hasHandler ? "pressable" : "static";
|
|
131
|
+
}
|
|
@@ -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
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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 {
|