@delacour/react-native-ui 0.1.0-alpha.20260925064429 → 0.1.0-alpha.20260925065508

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.
Files changed (37) hide show
  1. package/package.json +4 -2
  2. package/src/components/chart/AGENTS.md +8 -0
  3. package/src/components/chart/chart.tsx +8 -1
  4. package/src/components/item/AGENTS.md +92 -0
  5. package/src/components/item/index.ts +25 -0
  6. package/src/components/item/item-actions.tsx +28 -0
  7. package/src/components/item/item-content.tsx +15 -0
  8. package/src/components/item/item-description.tsx +12 -0
  9. package/src/components/item/item-footer.tsx +13 -0
  10. package/src/components/item/item-group.tsx +32 -0
  11. package/src/components/item/item-header.tsx +13 -0
  12. package/src/components/item/item-media.tsx +37 -0
  13. package/src/components/item/item-separator.tsx +13 -0
  14. package/src/components/item/item-title.tsx +17 -0
  15. package/src/components/item/item.context.tsx +66 -0
  16. package/src/components/item/item.tsx +218 -0
  17. package/src/components/item/item.types.ts +12 -0
  18. package/src/components/item/item.variants.test.ts +263 -0
  19. package/src/components/item/item.variants.ts +200 -0
  20. package/src/components/kpi/AGENTS.md +125 -0
  21. package/src/components/kpi/index.ts +45 -0
  22. package/src/components/kpi/kpi-action.tsx +12 -0
  23. package/src/components/kpi/kpi-content.tsx +35 -0
  24. package/src/components/kpi/kpi-footer.tsx +52 -0
  25. package/src/components/kpi/kpi-group.tsx +70 -0
  26. package/src/components/kpi/kpi-header.tsx +25 -0
  27. package/src/components/kpi/kpi-icon.tsx +28 -0
  28. package/src/components/kpi/kpi-sparkline.tsx +145 -0
  29. package/src/components/kpi/kpi-stat.tsx +20 -0
  30. package/src/components/kpi/kpi-title.tsx +19 -0
  31. package/src/components/kpi/kpi-trend.tsx +105 -0
  32. package/src/components/kpi/kpi-value.tsx +32 -0
  33. package/src/components/kpi/kpi.context.tsx +111 -0
  34. package/src/components/kpi/kpi.tsx +154 -0
  35. package/src/components/kpi/kpi.types.ts +9 -0
  36. package/src/components/kpi/kpi.variants.test.ts +428 -0
  37. package/src/components/kpi/kpi.variants.ts +347 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20260925064429",
3
+ "version": "0.1.0-alpha.20260925065508",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,6 +48,8 @@
48
48
  "./field": "./src/components/field/index.ts",
49
49
  "./icon": "./src/components/icon/index.ts",
50
50
  "./input": "./src/components/input/index.ts",
51
+ "./item": "./src/components/item/index.ts",
52
+ "./kpi": "./src/components/kpi/index.ts",
51
53
  "./label": "./src/components/label/index.ts",
52
54
  "./list-group": "./src/components/list-group/index.ts",
53
55
  "./meter": "./src/components/meter/index.ts",
@@ -96,7 +98,7 @@
96
98
  },
97
99
  "peerDependencies": {
98
100
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
99
- "@delacour/react-native-charts": "0.1.0-alpha.20260925064429",
101
+ "@delacour/react-native-charts": "0.1.0-alpha.20260925065508",
100
102
  "@gorhom/bottom-sheet": "^5.2.8",
101
103
  "@legendapp/list": ">=3.3",
102
104
  "expo-linear-gradient": ">=15",
@@ -87,6 +87,14 @@ knows nothing about tokens.
87
87
  tailwind-merge is what lets a caller's `className="aspect-video h-auto"`
88
88
  cleanly win when they want that instead.
89
89
 
90
+ - **`frameClassName` resizes the frame, not the root.** The canvas fills the
91
+ frame, and the frame carries the size's height, so a `className` on the root
92
+ cannot shorten the plot — the frame would overflow it. `frameClassName` is
93
+ merged after the size's `h-chart-*`, and the tokens are registered with
94
+ tailwind-merge, so `h-16` cleanly replaces it. `Kpi.Sparkline` is the reason
95
+ it exists: a sparkline is a chart with no axes a third the height of the
96
+ smallest one.
97
+
90
98
  - **The tooltip is a React Native view even though it floats over the canvas.**
91
99
  It wants `popover`, `border`, the radius scale and the type scale, none of
92
100
  which exist in Skia, and it is the part a caller most wants to restyle —
@@ -72,6 +72,12 @@ export type ChartProps = {
72
72
  /** How the scrub coexists with a scrolling parent. Defaults to holding. */
73
73
  scrubConfig?: ScrubConfig;
74
74
  className?: string;
75
+ /**
76
+ * Merged onto the frame — the view the canvas fills — after the size's
77
+ * height. `frameClassName="h-16"` is a sparkline; the three `size` heights
78
+ * are for a chart someone reads with axes.
79
+ */
80
+ frameClassName?: string;
75
81
  /** Named on the frame, so a capture flow or a test can find the plot. */
76
82
  testID?: string;
77
83
  children?: ReactNode;
@@ -106,6 +112,7 @@ function ChartRoot({
106
112
  orientation = "vertical",
107
113
  scrubConfig,
108
114
  className,
115
+ frameClassName,
109
116
  testID,
110
117
  children,
111
118
  }: ChartProps): ReactElement {
@@ -231,7 +238,7 @@ function ChartRoot({
231
238
  return (
232
239
  <ChartProvider value={value}>
233
240
  <View className={slots.root({ className })}>
234
- <View className={slots.frame()} onLayout={onLayout} testID={testID}>
241
+ <View className={slots.frame({ className: frameClassName })} onLayout={onLayout} testID={testID}>
235
242
  <CartesianChart
236
243
  curve={curve}
237
244
  data={data}
@@ -0,0 +1,92 @@
1
+ # Item
2
+
3
+ A row of media, text and actions, for lists and settings. Compound root plus
4
+ `Item.Media`, `Item.Content`, `Item.Title`, `Item.Description`, `Item.Actions`,
5
+ `Item.Header` and `Item.Footer`, and two layout parts, `Item.Group` and
6
+ `Item.Separator`.
7
+
8
+ `import { Item } from "@delacour/react-native-ui/item";`
9
+
10
+ ## Files
11
+
12
+ | File | What it holds |
13
+ | --- | --- |
14
+ | `index.ts` | → `@delacour/react-native-ui/item` |
15
+ | `item.tsx` | Root + the `Object.assign` compound surface, and the bare-text wrap it owns |
16
+ | `item-media.tsx` | `Item.Media` |
17
+ | `item-content.tsx` | `Item.Content` |
18
+ | `item-title.tsx` | `Item.Title` |
19
+ | `item-description.tsx` | `Item.Description` |
20
+ | `item-actions.tsx` | `Item.Actions` |
21
+ | `item-header.tsx` | `Item.Header` |
22
+ | `item-footer.tsx` | `Item.Footer` |
23
+ | `item-group.tsx` | `Item.Group` |
24
+ | `item-separator.tsx` | `Item.Separator` |
25
+ | `item.context.tsx` | `ItemProvider`, `useItem()`, `useItemContext()`, `useItemPart()` |
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 |
28
+ | `item.variants.test.ts` | |
29
+
30
+ ## Design
31
+
32
+ - **Axes**: `variant` — `default`, `outline`, `muted`; `size` — `sm`, `md`,
33
+ `lg`; `orientation` — `horizontal`, `vertical`. States: `isDisabled`,
34
+ `isSelected`, and `busy` inherited from `Pressable`. `Item.Media` has its own
35
+ `variant` — `default`, `icon`, `image`.
36
+ - **It drops into a `ListGroup` as a row, and that is the point of it.** An item
37
+ reads the group's context through the `list-group.context` leaf (rule 3 —
38
+ never `../list-group`) and changes three things when it finds one:
39
+ - **The surface becomes `grouped`.** `resolveItemSurface` ignores the variant:
40
+ the group draws the border, fill and corner, and a row repeating any of them
41
+ would draw a card inside a card. `surface` rather than `variant` is the
42
+ `tv()` axis for that reason.
43
+ - **Size defaults to the group's.** `resolveItemSize` — own, else group, else
44
+ `md`. The two components share the `sm`/`md`/`lg` scale on purpose.
45
+ - **Press feedback defaults to `fade`**, not `scale` — a full-bleed row that
46
+ scales reads as the whole card flexing. `resolveItemFeedback`.
47
+ - **Row metrics are `ListGroup`'s, number for number.** The group insets each
48
+ divider by its own row padding, so an item padded differently would sit with
49
+ its text and its divider out of line. The tests assert the item's `min-h`,
50
+ `gap`, `px` and `py` against `listGroupVariants().item()` at every size, and
51
+ the bare media icon against the group's `prefixIcon` — change one without the
52
+ other and `bun test` fails by name.
53
+ - **Pressable only with a handler.** `resolveItemRender` returns one of two:
54
+ `pressable` (a handler — a `Pressable`, role `button`, children merged into
55
+ one accessible element; disabled, it keeps the role, reports `disabled` and
56
+ dims through `opacity-50`, which `Pressable` composes with its press
57
+ animation) or `static` (no handler — a plain `Animated.View` with no role, so
58
+ a static row never announces itself as a button). The plain branch is
59
+ `Animated.View` rather than `View` so the `ref` type is one type.
60
+ - **A pressable item must not hold another control.** iOS merges an accessible
61
+ element's children into it, so a `Button` in the actions of a pressable item
62
+ is unreachable by VoiceOver. Either leave the item static and let the actions
63
+ be the controls, or make the row the control: a switch row takes
64
+ `accessibilityRole="switch"` and `accessibilityState={{ checked }}` on the
65
+ item, toggles from its `onPress`, and renders the `Switch` inside for show.
66
+ - **`isSelected` lays `bg-accent` over any surface**, the muted fill included —
67
+ declared after `surface` in the `tv()` so tailwind-merge drops the losing
68
+ `bg-*`. It also sets `accessibilityState.selected`. What marks the choice (a
69
+ check, a radio) is composed into `Item.Actions` by the caller.
70
+ - **Icons are composed, never passed as props.** `Item.Media` and
71
+ `Item.Actions` each wrap their subtree in an `IconDefaultsProvider`. Media
72
+ gets the foreground token at the size its variant calls for — a bare icon
73
+ matches a `ListGroup` prefix, an `icon` tile's glyph sits two steps smaller
74
+ inside it. Actions gets `muted-foreground` a step lower still, so a chevron
75
+ reads as a hint. A `Button` in the actions publishes its own defaults and is
76
+ unaffected.
77
+ - **`Header` and `Footer` work on both orientations.** The horizontal root is
78
+ `flex-wrap` and the strips are `w-full`, so a strip takes a line of its own
79
+ above or below media, text and actions without the item having to become a
80
+ column.
81
+ - **`Content` only flexes along a row.** Vertical, it is `self-stretch`: a
82
+ `flex-1` column in an auto-height parent collapses to zero in Yoga.
83
+ - **`Item.Group` is spaced, not divided.** It is for standalone items — outlined
84
+ cards, a carousel — announced with `accessibilityRole="list"`. Rows sharing
85
+ one card with dividers belong in a `ListGroup`, which already does both.
86
+ `Item.Separator` is a `Separator` under the item's name, for the occasional
87
+ hand-placed rule in a group.
88
+ - **String children** are wrapped in a `Content` around a `Title`, consecutive
89
+ strings collapsing into one — the same rule, and the same reason, as
90
+ [`ListGroup`](../list-group/AGENTS.md).
91
+ - **Text colour goes on the text.** No slot but `title` and `description` holds
92
+ a `text-*` colour; the tests assert the root never does.
@@ -0,0 +1,25 @@
1
+ export { Item, type ItemProps } from "./item";
2
+ export { type ItemContextValue, ItemProvider, useItem, useItemContext } from "./item.context";
3
+ export type { ItemSlotProps, ItemTextProps } from "./item.types";
4
+ export {
5
+ ITEM_ACTIONS_ICON_TOKEN,
6
+ ITEM_MEDIA_ICON_TOKEN,
7
+ ITEM_MEDIA_VARIANTS,
8
+ ITEM_ORIENTATIONS,
9
+ ITEM_SIZES,
10
+ ITEM_VARIANTS,
11
+ type ItemMediaVariant,
12
+ type ItemOrientation,
13
+ type ItemRender,
14
+ type ItemSize,
15
+ type ItemSurface,
16
+ type ItemVariant,
17
+ type ItemVariantProps,
18
+ itemVariants,
19
+ resolveItemFeedback,
20
+ resolveItemRender,
21
+ resolveItemSize,
22
+ resolveItemSurface,
23
+ } from "./item.variants";
24
+ export type { ItemGroupProps } from "./item-group";
25
+ export type { ItemMediaProps } from "./item-media";
@@ -0,0 +1,28 @@
1
+ import { type ReactElement, useMemo } from "react";
2
+ import { View } from "react-native";
3
+ import { IconDefaultsProvider } from "../icon";
4
+ import { useItemPart } from "./item.context";
5
+ import type { ItemSlotProps } from "./item.types";
6
+ import { ITEM_ACTIONS_ICON_TOKEN, itemVariants } from "./item.variants";
7
+
8
+ /**
9
+ * The trailing slot — buttons, a chevron, a switch, a value.
10
+ *
11
+ * A bare icon here inherits a step below the media's size and the muted token,
12
+ * so a chevron reads as a hint that the row leads somewhere rather than
13
+ * competing with the row's own subject. A `Button` inside sets its own icon
14
+ * defaults and is unaffected.
15
+ */
16
+ export function ItemActions({ className, children, ...props }: ItemSlotProps): ReactElement {
17
+ const { size } = useItemPart("Item.Actions");
18
+ const slots = itemVariants({ size });
19
+ const iconClassName = slots.actionsIcon();
20
+ const iconDefaults = useMemo(() => ({ className: iconClassName, color: ITEM_ACTIONS_ICON_TOKEN }), [iconClassName]);
21
+
22
+ return (
23
+ <View className={slots.actions({ className })} {...props}>
24
+ <IconDefaultsProvider value={iconDefaults}>{children}</IconDefaultsProvider>
25
+ </View>
26
+ );
27
+ }
28
+ ItemActions.displayName = "DelacourUI.Item.Actions";
@@ -0,0 +1,15 @@
1
+ import type { ReactElement } from "react";
2
+ import { View } from "react-native";
3
+ import { useItemPart } from "./item.context";
4
+ import type { ItemSlotProps } from "./item.types";
5
+ import { itemVariants } from "./item.variants";
6
+
7
+ /**
8
+ * The text column. Along a row it takes whatever width the media and actions
9
+ * leave, so the actions stay pinned to the trailing edge.
10
+ */
11
+ export function ItemContent({ className, ...props }: ItemSlotProps): ReactElement {
12
+ const { orientation } = useItemPart("Item.Content");
13
+ return <View className={itemVariants({ orientation }).content({ className })} {...props} />;
14
+ }
15
+ ItemContent.displayName = "DelacourUI.Item.Content";
@@ -0,0 +1,12 @@
1
+ import type { ReactElement } from "react";
2
+ import { Text } from "../text";
3
+ import { useItemPart } from "./item.context";
4
+ import type { ItemTextProps } from "./item.types";
5
+ import { itemVariants } from "./item.variants";
6
+
7
+ /** The item's secondary line, a step down in scale and on the muted token. */
8
+ export function ItemDescription({ className, ...props }: ItemTextProps): ReactElement {
9
+ const { size } = useItemPart("Item.Description");
10
+ return <Text className={itemVariants({ size }).description({ className })} {...props} />;
11
+ }
12
+ ItemDescription.displayName = "DelacourUI.Item.Description";
@@ -0,0 +1,13 @@
1
+ import type { ReactElement } from "react";
2
+ import { View } from "react-native";
3
+ import type { ItemSlotProps } from "./item.types";
4
+ import { itemVariants } from "./item.variants";
5
+
6
+ /**
7
+ * A full-width strip below the row's content — metadata, a progress bar, a row
8
+ * of buttons. Takes a line of its own on either orientation.
9
+ */
10
+ export function ItemFooter({ className, ...props }: ItemSlotProps): ReactElement {
11
+ return <View className={itemVariants().footer({ className })} {...props} />;
12
+ }
13
+ ItemFooter.displayName = "DelacourUI.Item.Footer";
@@ -0,0 +1,32 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { type ItemOrientation, itemVariants } from "./item.variants";
4
+
5
+ export type ItemGroupProps = ViewProps & {
6
+ /**
7
+ * `vertical` stacks the items down the screen. `horizontal` runs them across,
8
+ * for a carousel — put it in a horizontal `ScrollView` and give each item
9
+ * `orientation="vertical"` so every entry reads as a card.
10
+ */
11
+ orientation?: ItemOrientation;
12
+ className?: string;
13
+ children?: ReactNode;
14
+ };
15
+
16
+ /**
17
+ * A stack of standalone items, spaced rather than divided, announced as a
18
+ * list.
19
+ *
20
+ * For rows sharing one card with dividers between them, put the items in a
21
+ * `ListGroup` instead — they take the group's surface and size from there.
22
+ */
23
+ export function ItemGroup({ orientation = "vertical", className, ...props }: ItemGroupProps): ReactElement {
24
+ return (
25
+ <View
26
+ accessibilityRole="list"
27
+ className={itemVariants({ groupOrientation: orientation }).group({ className })}
28
+ {...props}
29
+ />
30
+ );
31
+ }
32
+ ItemGroup.displayName = "DelacourUI.Item.Group";
@@ -0,0 +1,13 @@
1
+ import type { ReactElement } from "react";
2
+ import { View } from "react-native";
3
+ import type { ItemSlotProps } from "./item.types";
4
+ import { itemVariants } from "./item.variants";
5
+
6
+ /**
7
+ * A full-width strip above the row's content — a cover image, a kicker, a
8
+ * timestamp. Takes a line of its own on either orientation.
9
+ */
10
+ export function ItemHeader({ className, ...props }: ItemSlotProps): ReactElement {
11
+ return <View className={itemVariants().header({ className })} {...props} />;
12
+ }
13
+ ItemHeader.displayName = "DelacourUI.Item.Header";
@@ -0,0 +1,37 @@
1
+ import { type ReactElement, useMemo } from "react";
2
+ import { View } from "react-native";
3
+ import { IconDefaultsProvider } from "../icon";
4
+ import { useItemPart } from "./item.context";
5
+ import type { ItemSlotProps } from "./item.types";
6
+ import { ITEM_MEDIA_ICON_TOKEN, type ItemMediaVariant, itemVariants } from "./item.variants";
7
+
8
+ export type ItemMediaProps = ItemSlotProps & {
9
+ /**
10
+ * `default` draws nothing and sizes a bare icon to match a `ListGroup` row's.
11
+ * `icon` sets the glyph on a filled tile. `image` is a fixed square that clips
12
+ * its child to the corner — give the image `className="size-full"`.
13
+ */
14
+ variant?: ItemMediaVariant;
15
+ };
16
+
17
+ /**
18
+ * The leading slot — a bare icon, an icon tile, a thumbnail, or an avatar
19
+ * passed straight through.
20
+ *
21
+ * Its subtree inherits an icon size read from the item's size and this slot's
22
+ * variant, and the foreground token, so a bare `<Icon icon={IconFile} />` comes
23
+ * out right with nothing said at the call site.
24
+ */
25
+ export function ItemMedia({ variant = "default", className, children, ...props }: ItemMediaProps): ReactElement {
26
+ const { isSelected, size, surface } = useItemPart("Item.Media");
27
+ const slots = itemVariants({ isSelected, mediaVariant: variant, size, surface });
28
+ const iconClassName = slots.mediaIcon();
29
+ const iconDefaults = useMemo(() => ({ className: iconClassName, color: ITEM_MEDIA_ICON_TOKEN }), [iconClassName]);
30
+
31
+ return (
32
+ <View className={slots.media({ className })} {...props}>
33
+ <IconDefaultsProvider value={iconDefaults}>{children}</IconDefaultsProvider>
34
+ </View>
35
+ );
36
+ }
37
+ ItemMedia.displayName = "DelacourUI.Item.Media";
@@ -0,0 +1,13 @@
1
+ import type { ReactElement } from "react";
2
+ import { Separator, type SeparatorProps } from "../separator";
3
+
4
+ /**
5
+ * A hairline between items in an `Item.Group`. Match the group's axis — a
6
+ * horizontal group needs `orientation="vertical"`.
7
+ *
8
+ * A `ListGroup` inserts its own dividers, so this is for `Item.Group` only.
9
+ */
10
+ export function ItemSeparator(props: SeparatorProps): ReactElement {
11
+ return <Separator {...props} />;
12
+ }
13
+ ItemSeparator.displayName = "DelacourUI.Item.Separator";
@@ -0,0 +1,17 @@
1
+ import type { ReactElement } from "react";
2
+ import { Text } from "../text";
3
+ import { useItemPart } from "./item.context";
4
+ import type { ItemTextProps } from "./item.types";
5
+ import { itemVariants } from "./item.variants";
6
+
7
+ /**
8
+ * The item's primary line.
9
+ *
10
+ * Carries its own colour and type scale, read from the item's context: a React
11
+ * Native `View` does not cascade colour to a `Text` descendant.
12
+ */
13
+ export function ItemTitle({ className, ...props }: ItemTextProps): ReactElement {
14
+ const { size } = useItemPart("Item.Title");
15
+ return <Text className={itemVariants({ size }).title({ className })} {...props} />;
16
+ }
17
+ ItemTitle.displayName = "DelacourUI.Item.Title";
@@ -0,0 +1,66 @@
1
+ import { createContext, type ReactElement, type ReactNode, use } from "react";
2
+ import type { ItemOrientation, ItemSize, ItemSurface } from "./item.variants";
3
+
4
+ export type ItemContextValue = {
5
+ /** Resolved size — the item's own, else its `ListGroup`'s, else `md`. */
6
+ size: ItemSize;
7
+ /** Whether the item's parts sit side by side or stack. */
8
+ orientation: ItemOrientation;
9
+ /** The surface actually drawn: `grouped` inside a `ListGroup`, else the variant. */
10
+ surface: ItemSurface;
11
+ /** Whether the item is disabled. */
12
+ isDisabled: boolean;
13
+ /** Whether the item is selected. */
14
+ isSelected: boolean;
15
+ };
16
+
17
+ const ItemContext = createContext<ItemContextValue | null>(null);
18
+
19
+ /**
20
+ * Supplies the enclosing item's resolved size, orientation and surface to its
21
+ * parts.
22
+ *
23
+ * Lives in its own module, importing nothing but `item.variants`, so a part can
24
+ * read it without importing `./item` and closing a cycle through the root. See
25
+ * AGENTS.md rule 3.
26
+ */
27
+ export function ItemProvider({ value, children }: { value: ItemContextValue; children: ReactNode }): ReactElement {
28
+ return <ItemContext value={value}>{children}</ItemContext>;
29
+ }
30
+ ItemProvider.displayName = "DelacourUI.Item.Provider";
31
+
32
+ /** The enclosing item's context, or null outside an `<Item>`. */
33
+ export function useItemContext(): ItemContextValue | null {
34
+ return use(ItemContext);
35
+ }
36
+
37
+ /**
38
+ * Reads the enclosing item's resolved size, orientation and surface.
39
+ *
40
+ * Lets a custom part style itself to match without props passed down through
41
+ * every slot. Throws outside an `<Item>` — use {@link useItemContext} where the
42
+ * enclosing item is optional.
43
+ */
44
+ export function useItem(): ItemContextValue {
45
+ const context = useItemContext();
46
+ if (!context) {
47
+ throw new Error("useItem must be called inside an <Item>.");
48
+ }
49
+ return context;
50
+ }
51
+
52
+ /**
53
+ * The enclosing item's context, for a compound part that cannot work without
54
+ * one.
55
+ *
56
+ * Internal: deliberately not re-exported from `index.ts`. A caller outside the
57
+ * library wants {@link useItem}, whose error message names the hook rather than
58
+ * a part.
59
+ */
60
+ export function useItemPart(component: string): ItemContextValue {
61
+ const context = useItemContext();
62
+ if (!context) {
63
+ throw new Error(`${component} must be rendered inside an <Item>.`);
64
+ }
65
+ return context;
66
+ }
@@ -0,0 +1,218 @@
1
+ import { Children, type ReactElement, type ReactNode, useMemo } from "react";
2
+ import type { AccessibilityState } from "react-native";
3
+ import Animated from "react-native-reanimated";
4
+ import { useListGroupContext } from "../list-group/list-group.context";
5
+ import { Pressable, type PressableProps } from "../pressable";
6
+ import { type ItemContextValue, ItemProvider } from "./item.context";
7
+ import {
8
+ type ItemOrientation,
9
+ type ItemSize,
10
+ type ItemVariant,
11
+ itemVariants,
12
+ resolveItemFeedback,
13
+ resolveItemRender,
14
+ resolveItemSize,
15
+ resolveItemSurface,
16
+ } from "./item.variants";
17
+ import { ItemActions } from "./item-actions";
18
+ import { ItemContent } from "./item-content";
19
+ import { ItemDescription } from "./item-description";
20
+ import { ItemFooter } from "./item-footer";
21
+ import { ItemGroup } from "./item-group";
22
+ import { ItemHeader } from "./item-header";
23
+ import { ItemMedia } from "./item-media";
24
+ import { ItemSeparator } from "./item-separator";
25
+ import { ItemTitle } from "./item-title";
26
+
27
+ export type ItemProps = Omit<PressableProps, "children" | "disabled"> & {
28
+ /**
29
+ * `default` draws no surface, `outline` a hairline border, `muted` a fill.
30
+ * Ignored inside a `ListGroup`, which draws the surface itself.
31
+ */
32
+ variant?: ItemVariant;
33
+ /**
34
+ * Row density. `Item.Media`, `Item.Title` and `Item.Description` follow it.
35
+ * Inside a `ListGroup` it defaults to the group's size.
36
+ */
37
+ size?: ItemSize;
38
+ /** `horizontal` is the list row; `vertical` stacks the parts into a card. */
39
+ orientation?: ItemOrientation;
40
+ isDisabled?: boolean;
41
+ /** Lays the accent fill over the surface and announces the item as selected. */
42
+ isSelected?: boolean;
43
+ children?: ReactNode;
44
+ };
45
+
46
+ function ItemRoot({
47
+ variant = "default",
48
+ size,
49
+ orientation = "horizontal",
50
+ isDisabled = false,
51
+ isSelected = false,
52
+ feedback,
53
+ haptic,
54
+ pressedScale,
55
+ pressedOpacity,
56
+ busy,
57
+ asChild,
58
+ onPress,
59
+ onLongPress,
60
+ accessibilityState,
61
+ className,
62
+ children,
63
+ ...props
64
+ }: ItemProps): ReactElement {
65
+ const group = useListGroupContext();
66
+ const isInGroup = group !== null;
67
+ const resolvedSize = resolveItemSize(size, group?.size);
68
+ const surface = resolveItemSurface(variant, isInGroup);
69
+
70
+ const context = useMemo<ItemContextValue>(
71
+ () => ({ isDisabled, isSelected, orientation, size: resolvedSize, surface }),
72
+ [isDisabled, isSelected, orientation, resolvedSize, surface]
73
+ );
74
+ const content = useMemo(() => wrapTextChildren(children), [children]);
75
+ const rootClassName = itemVariants({ isDisabled, isSelected, orientation, size: resolvedSize, surface }).root({
76
+ className,
77
+ });
78
+
79
+ const render = resolveItemRender({ isDisabled, onLongPress, onPress });
80
+
81
+ if (render === "pressable") {
82
+ return (
83
+ <ItemProvider value={context}>
84
+ <Pressable
85
+ accessibilityState={{ ...accessibilityState, selected: isSelected }}
86
+ asChild={asChild}
87
+ busy={busy}
88
+ className={rootClassName}
89
+ disabled={isDisabled}
90
+ feedback={resolveItemFeedback(feedback, isInGroup)}
91
+ haptic={haptic}
92
+ onLongPress={onLongPress}
93
+ onPress={onPress}
94
+ pressedOpacity={pressedOpacity}
95
+ pressedScale={pressedScale}
96
+ {...props}
97
+ >
98
+ {content}
99
+ </Pressable>
100
+ </ItemProvider>
101
+ );
102
+ }
103
+
104
+ const state: AccessibilityState = { ...accessibilityState, busy, disabled: isDisabled, selected: isSelected };
105
+
106
+ return (
107
+ <ItemProvider value={context}>
108
+ <Animated.View accessibilityState={state} className={rootClassName} {...props}>
109
+ {content}
110
+ </Animated.View>
111
+ </ItemProvider>
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Wraps bare text children in a title inside a content column.
117
+ *
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.
122
+ *
123
+ * Lives with the root because the root is what wraps its own text; a part
124
+ * importing it from here would close a cycle. See AGENTS.md rule 3.
125
+ */
126
+ 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;
152
+ }
153
+
154
+ /**
155
+ * A row of media, text and actions, for lists and settings.
156
+ *
157
+ * Give it an `onPress` and it renders as a `Pressable` announced as a button;
158
+ * leave it off and it is a plain view, so a static row never claims to be a
159
+ * control. `resolveItemRender` makes that call, and a disabled one too. `size` is set once here — the media, the title and the description
160
+ * read it from context.
161
+ *
162
+ * It drops straight into a `ListGroup` as a row: inside one it takes the
163
+ * group's size unless given its own, draws no surface of its own whatever its
164
+ * `variant`, fades rather than scales on press, and pads itself to the inset
165
+ * the group draws its dividers at.
166
+ *
167
+ * @example
168
+ * <Item variant="outline">
169
+ * <Item.Media variant="icon">
170
+ * <Icon icon={IconFileText} />
171
+ * </Item.Media>
172
+ * <Item.Content>
173
+ * <Item.Title>Invoice.pdf</Item.Title>
174
+ * <Item.Description>2.4 MB · Updated yesterday</Item.Description>
175
+ * </Item.Content>
176
+ * <Item.Actions>
177
+ * <Button size="sm" variant="outline">
178
+ * <Button.Label>Open</Button.Label>
179
+ * </Button>
180
+ * </Item.Actions>
181
+ * </Item>
182
+ *
183
+ * @example
184
+ * <ListGroup>
185
+ * <Item onPress={openWifi}>
186
+ * <Item.Media>
187
+ * <Icon icon={IconWifi} />
188
+ * </Item.Media>
189
+ * <Item.Content>
190
+ * <Item.Title>Wi-Fi</Item.Title>
191
+ * </Item.Content>
192
+ * <Item.Actions>
193
+ * <Icon icon={IconChevronRight} />
194
+ * </Item.Actions>
195
+ * </Item>
196
+ * </ListGroup>
197
+ */
198
+ export const Item = Object.assign(ItemRoot, {
199
+ /** A spaced stack of standalone items, announced as a list. */
200
+ Group: ItemGroup,
201
+ /** A hairline between items in an `Item.Group`. */
202
+ Separator: ItemSeparator,
203
+ /** The leading slot — a bare icon, an icon tile, or an image. Sized by the item. */
204
+ Media: ItemMedia,
205
+ /** The text column, taking the width the media and actions leave. */
206
+ Content: ItemContent,
207
+ /** The primary line. Carries its own colour — a `View` cannot cascade one to a `Text`. */
208
+ Title: ItemTitle,
209
+ /** The secondary line, a step down in scale and on the muted token. */
210
+ Description: ItemDescription,
211
+ /** The trailing slot — buttons, a chevron, a switch, a value. */
212
+ Actions: ItemActions,
213
+ /** A full-width strip above the row's content. */
214
+ Header: ItemHeader,
215
+ /** A full-width strip below the row's content. */
216
+ Footer: ItemFooter,
217
+ displayName: "DelacourUI.Item",
218
+ });