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

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.20261020143428",
3
+ "version": "0.4.0-alpha.20261020154654",
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.20261020143428",
184
+ "@delacour/react-native-bottom-sheet": "0.2.0-alpha.20261020154654",
185
185
  "@delacour/react-native-charts": ">=0.1.0",
186
186
  "@legendapp/list": ">=3.3",
187
187
  "@shopify/react-native-skia": ">=2",
@@ -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`.** `ListGroupItemProps` extends `PressableProps`, so
50
- `feedback`, `haptic`, `pressedScale` and the rest are inherited rather than
51
- restated — the row owns no vocabulary of its own. Only the default differs:
52
- `feedback` defaults to `fade`, because a full-bleed row that scales reads as
53
- the whole card flexing rather than as one row responding. A prop is only
54
- redeclared where it genuinely changes, as [`Button`](../button/AGENTS.md) does
55
- with its narrower union; redeclaring one unchanged just to hang a doc comment
56
- on it puts a second definition in the tree that can drift.
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`,
@@ -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
- * A row is a `Pressable`, so `feedback`, `haptic` and the rest come from there
23
- * rather than being restated here. Only the default differs: `fade`, because a
24
- * full-bleed row that scales reads as the whole card flexing rather than as one
25
- * row responding.
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
- <Pressable
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
- </Pressable>
80
+ </Animated.View>
46
81
  );
47
82
  }
48
83
  ListGroupItem.displayName = "DelacourUI.ListGroup.Item";
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { ICON_SIZE_TOKENS } from "../../styles/tokens";
3
- import { LIST_GROUP_SIZES, LIST_GROUP_VARIANTS, listGroupVariants } from "./list-group.variants";
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
+ }