@delacour/react-native-ui 0.1.0-alpha.20260925064741 → 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.
- package/package.json +3 -2
- package/src/components/item/AGENTS.md +92 -0
- package/src/components/item/index.ts +25 -0
- package/src/components/item/item-actions.tsx +28 -0
- package/src/components/item/item-content.tsx +15 -0
- package/src/components/item/item-description.tsx +12 -0
- package/src/components/item/item-footer.tsx +13 -0
- package/src/components/item/item-group.tsx +32 -0
- package/src/components/item/item-header.tsx +13 -0
- package/src/components/item/item-media.tsx +37 -0
- package/src/components/item/item-separator.tsx +13 -0
- package/src/components/item/item-title.tsx +17 -0
- package/src/components/item/item.context.tsx +66 -0
- package/src/components/item/item.tsx +218 -0
- package/src/components/item/item.types.ts +12 -0
- package/src/components/item/item.variants.test.ts +263 -0
- package/src/components/item/item.variants.ts +200 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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,7 @@
|
|
|
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",
|
|
51
52
|
"./kpi": "./src/components/kpi/index.ts",
|
|
52
53
|
"./label": "./src/components/label/index.ts",
|
|
53
54
|
"./list-group": "./src/components/list-group/index.ts",
|
|
@@ -97,7 +98,7 @@
|
|
|
97
98
|
},
|
|
98
99
|
"peerDependencies": {
|
|
99
100
|
"@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
|
|
100
|
-
"@delacour/react-native-charts": "0.1.0-alpha.
|
|
101
|
+
"@delacour/react-native-charts": "0.1.0-alpha.20260925065508",
|
|
101
102
|
"@gorhom/bottom-sheet": "^5.2.8",
|
|
102
103
|
"@legendapp/list": ">=3.3",
|
|
103
104
|
"expo-linear-gradient": ">=15",
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { TextProps, ViewProps } from "react-native";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The shape of an item's layout slots — `Content`, `Actions`, `Header` and
|
|
6
|
+
* `Footer` — and the base `Item.Media` extends. Shared by several parts, so it
|
|
7
|
+
* lives in a leaf rather than in one of them arbitrarily.
|
|
8
|
+
*/
|
|
9
|
+
export type ItemSlotProps = ViewProps & { className?: string; children?: ReactNode };
|
|
10
|
+
|
|
11
|
+
/** The shape of an item's two text lines, `Title` and `Description`. */
|
|
12
|
+
export type ItemTextProps = TextProps & { className?: string };
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ICON_SIZE_TOKENS } from "../../styles/tokens";
|
|
3
|
+
import { LIST_GROUP_SIZES, listGroupVariants } from "../list-group/list-group.variants";
|
|
4
|
+
import {
|
|
5
|
+
ITEM_MEDIA_VARIANTS,
|
|
6
|
+
ITEM_ORIENTATIONS,
|
|
7
|
+
ITEM_SIZES,
|
|
8
|
+
ITEM_VARIANTS,
|
|
9
|
+
itemVariants,
|
|
10
|
+
resolveItemFeedback,
|
|
11
|
+
resolveItemRender,
|
|
12
|
+
resolveItemSize,
|
|
13
|
+
resolveItemSurface,
|
|
14
|
+
} from "./item.variants";
|
|
15
|
+
|
|
16
|
+
/** Pulls the horizontal padding step out of a class string — `px-4` yields 4. */
|
|
17
|
+
function paddingStep(cls: string): string | undefined {
|
|
18
|
+
return cls.match(/\bpx-(\d+(?:\.\d+)?)\b/)?.[1];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Position of a class string's `size-icon-*` token on the shared icon scale. */
|
|
22
|
+
function iconStep(cls: string): number {
|
|
23
|
+
const token = cls.match(/\bsize-(icon-[\w-]+)\b/)?.[1];
|
|
24
|
+
return ICON_SIZE_TOKENS.indexOf(token as (typeof ICON_SIZE_TOKENS)[number]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("resolveItemSurface", () => {
|
|
28
|
+
test("a standalone item keeps its own variant", () => {
|
|
29
|
+
for (const variant of ITEM_VARIANTS) {
|
|
30
|
+
expect(resolveItemSurface(variant, false)).toBe(variant);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// The group owns the surface: a border or a fill on each row would draw a
|
|
35
|
+
// card inside a card.
|
|
36
|
+
test("an item inside a ListGroup is always grouped, whatever its variant", () => {
|
|
37
|
+
for (const variant of ITEM_VARIANTS) {
|
|
38
|
+
expect(resolveItemSurface(variant, true)).toBe("grouped");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("resolveItemSize", () => {
|
|
44
|
+
test("an explicit size wins over the group's", () => {
|
|
45
|
+
expect(resolveItemSize("lg", "sm")).toBe("lg");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("inherits the enclosing group's size when unset", () => {
|
|
49
|
+
for (const size of LIST_GROUP_SIZES) {
|
|
50
|
+
expect(resolveItemSize(undefined, size)).toBe(size);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("falls back to md outside a group", () => {
|
|
55
|
+
expect(resolveItemSize(undefined, undefined)).toBe("md");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("resolveItemFeedback", () => {
|
|
60
|
+
test("a caller's feedback always wins", () => {
|
|
61
|
+
expect(resolveItemFeedback("none", true)).toBe("none");
|
|
62
|
+
expect(resolveItemFeedback("scale-fade", false)).toBe("scale-fade");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// A full-bleed row that scales reads as the whole card flexing.
|
|
66
|
+
test("fades inside a group and scales standalone", () => {
|
|
67
|
+
expect(resolveItemFeedback(undefined, true)).toBe("fade");
|
|
68
|
+
expect(resolveItemFeedback(undefined, false)).toBe("scale");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("resolveItemRender", () => {
|
|
73
|
+
const noop = () => {};
|
|
74
|
+
|
|
75
|
+
test("is static without a press handler, disabled or not", () => {
|
|
76
|
+
expect(resolveItemRender({ isDisabled: false })).toBe("static");
|
|
77
|
+
expect(resolveItemRender({ isDisabled: true })).toBe("static");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("is a pressable with a handler", () => {
|
|
81
|
+
expect(resolveItemRender({ isDisabled: false, onPress: noop })).toBe("pressable");
|
|
82
|
+
expect(resolveItemRender({ isDisabled: false, onLongPress: noop })).toBe("pressable");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Pressable composes its resting opacity with the press animation, so a
|
|
86
|
+
// disabled Pressable dims through `opacity-50` like any other row.
|
|
87
|
+
test("stays a pressable when disabled", () => {
|
|
88
|
+
expect(resolveItemRender({ isDisabled: true, onPress: noop })).toBe("pressable");
|
|
89
|
+
expect(resolveItemRender({ isDisabled: true, onLongPress: noop })).toBe("pressable");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("itemVariants root slot", () => {
|
|
94
|
+
test("gives every surface a distinct class string", () => {
|
|
95
|
+
const surfaces = [...ITEM_VARIANTS, "grouped"] as const;
|
|
96
|
+
const seen = new Set(surfaces.map((surface) => itemVariants({ surface }).root()));
|
|
97
|
+
expect(seen.size).toBe(surfaces.length);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("maps each surface", () => {
|
|
101
|
+
expect(itemVariants({ surface: "default" }).root()).toContain("bg-transparent");
|
|
102
|
+
expect(itemVariants({ surface: "outline" }).root()).toContain("border-border");
|
|
103
|
+
expect(itemVariants({ surface: "muted" }).root()).toContain("bg-muted");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a standalone surface is rounded; a grouped one is not", () => {
|
|
107
|
+
for (const surface of ITEM_VARIANTS) {
|
|
108
|
+
expect(itemVariants({ surface }).root()).toMatch(/\brounded-/);
|
|
109
|
+
}
|
|
110
|
+
expect(itemVariants({ surface: "grouped" }).root()).not.toMatch(/\brounded-/);
|
|
111
|
+
expect(itemVariants({ surface: "grouped" }).root()).not.toContain("border-border");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Without it a short row leaves the press feedback ending mid-card.
|
|
115
|
+
test("a grouped row spans the group", () => {
|
|
116
|
+
expect(itemVariants({ surface: "grouped" }).root()).toContain("w-full");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("holds no text colour — a View cannot cascade one", () => {
|
|
120
|
+
for (const surface of [...ITEM_VARIANTS, "grouped"] as const) {
|
|
121
|
+
expect(itemVariants({ surface }).root()).not.toMatch(/\btext-/);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("orientation picks the main axis", () => {
|
|
126
|
+
expect(itemVariants({ orientation: "horizontal" }).root()).toContain("flex-row");
|
|
127
|
+
expect(itemVariants({ orientation: "vertical" }).root()).toContain("flex-col");
|
|
128
|
+
expect(itemVariants({ orientation: "vertical" }).root()).not.toContain("flex-row");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("disabled dims the row", () => {
|
|
132
|
+
expect(itemVariants({ isDisabled: true }).root()).toContain("opacity-50");
|
|
133
|
+
expect(itemVariants({ isDisabled: false }).root()).not.toContain("opacity-50");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("selected lays the accent over every surface, the muted fill included", () => {
|
|
137
|
+
for (const surface of [...ITEM_VARIANTS, "grouped"] as const) {
|
|
138
|
+
const cls = itemVariants({ isSelected: true, surface }).root();
|
|
139
|
+
expect(cls).toContain("bg-accent");
|
|
140
|
+
expect(cls).not.toContain("bg-muted");
|
|
141
|
+
expect(cls).not.toContain("bg-transparent");
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("itemVariants sizes", () => {
|
|
147
|
+
// This is what makes an Item drop into a ListGroup: the group insets its
|
|
148
|
+
// dividers by its own row padding, so the item's padding has to match it.
|
|
149
|
+
test("row padding matches ListGroup's divider inset at every size", () => {
|
|
150
|
+
for (const size of ITEM_SIZES) {
|
|
151
|
+
const itemPadding = paddingStep(itemVariants({ size }).root());
|
|
152
|
+
const dividerInset = listGroupVariants({ size })
|
|
153
|
+
.divider()
|
|
154
|
+
.match(/\bmx-(\d+(?:\.\d+)?)\b/)?.[1];
|
|
155
|
+
expect(itemPadding).toBeDefined();
|
|
156
|
+
expect(itemPadding).toBe(dividerInset);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("row metrics match a ListGroup row at every size", () => {
|
|
161
|
+
for (const size of ITEM_SIZES) {
|
|
162
|
+
const item = itemVariants({ size }).root();
|
|
163
|
+
for (const cls of listGroupVariants({ size }).item().split(" ")) {
|
|
164
|
+
if (/^(min-h|gap|px|py)-/.test(cls)) expect(item).toContain(cls);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("title and description step up with size", () => {
|
|
170
|
+
expect(itemVariants({ size: "sm" }).title()).toContain("text-sm");
|
|
171
|
+
expect(itemVariants({ size: "md" }).title()).toContain("text-base");
|
|
172
|
+
expect(itemVariants({ size: "lg" }).title()).toContain("text-lg");
|
|
173
|
+
expect(itemVariants({ size: "sm" }).description()).toContain("text-xs");
|
|
174
|
+
expect(itemVariants({ size: "md" }).description()).toContain("text-sm");
|
|
175
|
+
expect(itemVariants({ size: "lg" }).description()).toContain("text-base");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("text carries its own colour tokens", () => {
|
|
179
|
+
expect(itemVariants().title()).toContain("text-foreground");
|
|
180
|
+
expect(itemVariants().description()).toContain("text-muted-foreground");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("every media icon names an ascending step on the shared icon scale", () => {
|
|
184
|
+
for (const mediaVariant of ITEM_MEDIA_VARIANTS) {
|
|
185
|
+
if (mediaVariant === "image") continue;
|
|
186
|
+
const steps = ITEM_SIZES.map((size) => iconStep(itemVariants({ mediaVariant, size }).mediaIcon()));
|
|
187
|
+
for (const step of steps) expect(step).toBeGreaterThanOrEqual(0);
|
|
188
|
+
expect([...steps].sort((a, b) => a - b)).toEqual(steps);
|
|
189
|
+
expect(new Set(steps).size).toBe(steps.length);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("a bare media icon matches ListGroup's prefix icon", () => {
|
|
194
|
+
for (const size of ITEM_SIZES) {
|
|
195
|
+
expect(itemVariants({ mediaVariant: "default", size }).mediaIcon()).toBe(
|
|
196
|
+
listGroupVariants({ size }).prefixIcon()
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("itemVariants media slot", () => {
|
|
203
|
+
test("the icon tile has a fill and a corner", () => {
|
|
204
|
+
const cls = itemVariants({ mediaVariant: "icon" }).media();
|
|
205
|
+
expect(cls).toContain("bg-muted");
|
|
206
|
+
expect(cls).toMatch(/\brounded-/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("an image clips its child to the corner", () => {
|
|
210
|
+
expect(itemVariants({ mediaVariant: "image" }).media()).toContain("overflow-hidden");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("the icon tile and image are fixed squares that grow with size", () => {
|
|
214
|
+
for (const mediaVariant of ["icon", "image"] as const) {
|
|
215
|
+
const edges = ITEM_SIZES.map((size) => {
|
|
216
|
+
const edge = itemVariants({ mediaVariant, size })
|
|
217
|
+
.media()
|
|
218
|
+
.match(/\bsize-(\d+)\b/)?.[1];
|
|
219
|
+
return Number(edge);
|
|
220
|
+
});
|
|
221
|
+
for (const edge of edges) expect(edge).toBeGreaterThan(0);
|
|
222
|
+
expect([...edges].sort((a, b) => a - b)).toEqual(edges);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("a tile stays visible on the muted and the selected surface", () => {
|
|
227
|
+
for (const mediaVariant of ["icon", "image"] as const) {
|
|
228
|
+
for (const cls of [
|
|
229
|
+
itemVariants({ mediaVariant, surface: "muted" }).media(),
|
|
230
|
+
itemVariants({ isSelected: true, mediaVariant }).media(),
|
|
231
|
+
]) {
|
|
232
|
+
expect(cls).toContain("bg-background");
|
|
233
|
+
expect(cls).not.toContain("bg-muted");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("a bare media slot draws nothing of its own", () => {
|
|
239
|
+
const cls = itemVariants({ mediaVariant: "default" }).media();
|
|
240
|
+
expect(cls).not.toMatch(/\bbg-/);
|
|
241
|
+
expect(cls).not.toMatch(/\bsize-\d/);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("itemVariants orientation", () => {
|
|
246
|
+
// A `flex-1` column in an auto-height parent collapses to nothing in Yoga.
|
|
247
|
+
test("content only flexes along a row", () => {
|
|
248
|
+
expect(itemVariants({ orientation: "horizontal" }).content()).toContain("flex-1");
|
|
249
|
+
expect(itemVariants({ orientation: "vertical" }).content()).not.toContain("flex-1");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("header and footer are full-width strips on both axes", () => {
|
|
253
|
+
for (const orientation of ITEM_ORIENTATIONS) {
|
|
254
|
+
expect(itemVariants({ orientation }).header()).toContain("w-full");
|
|
255
|
+
expect(itemVariants({ orientation }).footer()).toContain("w-full");
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("a group runs down by default and across when horizontal", () => {
|
|
260
|
+
expect(itemVariants({ groupOrientation: "vertical" }).group()).toContain("flex-col");
|
|
261
|
+
expect(itemVariants({ groupOrientation: "horizontal" }).group()).toContain("flex-row");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { VariantProps } from "tailwind-variants";
|
|
2
|
+
import { tv } from "../../lib/tv";
|
|
3
|
+
import type { PressableFeedback } from "../pressable/pressable.variants";
|
|
4
|
+
|
|
5
|
+
export const ITEM_VARIANTS = ["default", "outline", "muted"] as const;
|
|
6
|
+
|
|
7
|
+
export const ITEM_SIZES = ["sm", "md", "lg"] as const;
|
|
8
|
+
|
|
9
|
+
export const ITEM_ORIENTATIONS = ["horizontal", "vertical"] as const;
|
|
10
|
+
|
|
11
|
+
export const ITEM_MEDIA_VARIANTS = ["default", "icon", "image"] as const;
|
|
12
|
+
|
|
13
|
+
export type ItemVariant = (typeof ITEM_VARIANTS)[number];
|
|
14
|
+
export type ItemSize = (typeof ITEM_SIZES)[number];
|
|
15
|
+
export type ItemOrientation = (typeof ITEM_ORIENTATIONS)[number];
|
|
16
|
+
export type ItemMediaVariant = (typeof ITEM_MEDIA_VARIANTS)[number];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The surface a row actually draws.
|
|
20
|
+
*
|
|
21
|
+
* One of the three variants a caller picks, or `grouped` — the surface an item
|
|
22
|
+
* takes inside a `ListGroup`, where the group already draws the card and the
|
|
23
|
+
* row must draw nothing but itself.
|
|
24
|
+
*/
|
|
25
|
+
export type ItemSurface = ItemVariant | "grouped";
|
|
26
|
+
|
|
27
|
+
/** Theme token an icon in `Item.Media` inherits. */
|
|
28
|
+
export const ITEM_MEDIA_ICON_TOKEN = "foreground";
|
|
29
|
+
|
|
30
|
+
/** Theme token an icon in `Item.Actions` inherits — a trailing hint, not the row's subject. */
|
|
31
|
+
export const ITEM_ACTIONS_ICON_TOKEN = "muted-foreground";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The surface an item draws, given its variant and whether a `ListGroup`
|
|
35
|
+
* encloses it.
|
|
36
|
+
*
|
|
37
|
+
* Inside a group the variant is ignored: the group owns the border, the fill
|
|
38
|
+
* and the corner, and a row repeating any of them would draw a card inside a
|
|
39
|
+
* card.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveItemSurface(variant: ItemVariant, isInGroup: boolean): ItemSurface {
|
|
42
|
+
return isInGroup ? "grouped" : variant;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* An item's size: its own when set, else the enclosing `ListGroup`'s, else
|
|
47
|
+
* `md`.
|
|
48
|
+
*
|
|
49
|
+
* The two share one scale on purpose, so an item dropped into a `sm` group
|
|
50
|
+
* comes out `sm` with nothing said at the call site.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveItemSize(size: ItemSize | undefined, groupSize: ItemSize | undefined): ItemSize {
|
|
53
|
+
return size ?? groupSize ?? "md";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The press feedback for an item, when the caller has not named one.
|
|
58
|
+
*
|
|
59
|
+
* A full-bleed row inside a group fades, because a row that scales reads as
|
|
60
|
+
* the whole card flexing. A standalone item is its own card, so it scales the
|
|
61
|
+
* way a card does.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveItemFeedback(feedback: PressableFeedback | undefined, isInGroup: boolean): PressableFeedback {
|
|
64
|
+
return feedback ?? (isInGroup ? "fade" : "scale");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What an item renders as.
|
|
69
|
+
*
|
|
70
|
+
* - `pressable` — it has a handler: a `Pressable`, announced as a button. A
|
|
71
|
+
* disabled one keeps the role, reports `disabled`, and dims through
|
|
72
|
+
* `opacity-50`, which `Pressable` composes with its press animation.
|
|
73
|
+
* - `static` — no handler: a plain view with no role. A static row announcing
|
|
74
|
+
* itself as a button is a lie VoiceOver tells on every swipe.
|
|
75
|
+
*/
|
|
76
|
+
export type ItemRender = "pressable" | "static";
|
|
77
|
+
|
|
78
|
+
export function resolveItemRender(options: {
|
|
79
|
+
onPress?: () => void;
|
|
80
|
+
onLongPress?: () => void;
|
|
81
|
+
isDisabled: boolean;
|
|
82
|
+
}): ItemRender {
|
|
83
|
+
const hasHandler = options.onPress !== undefined || options.onLongPress !== undefined;
|
|
84
|
+
return hasHandler ? "pressable" : "static";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Styling for every part of an item.
|
|
89
|
+
*
|
|
90
|
+
* Row metrics are the same numbers a `ListGroup` row uses, per size — asserted
|
|
91
|
+
* as a pair in the tests — because a group insets its dividers by its own row
|
|
92
|
+
* padding. An item whose padding drifted from that would sit in a group with
|
|
93
|
+
* its text and its divider out of line.
|
|
94
|
+
*
|
|
95
|
+
* `surface` rather than `variant` is the axis here, because inside a group the
|
|
96
|
+
* variant a caller passed does not decide what is drawn. `resolveItemSurface`
|
|
97
|
+
* is where that decision lives.
|
|
98
|
+
*
|
|
99
|
+
* The horizontal root wraps, and `header` and `footer` are `w-full`: a strip
|
|
100
|
+
* then takes a line of its own above or below the media, text and actions, on
|
|
101
|
+
* either orientation, with no extra prop. `content` only flexes along a row — a
|
|
102
|
+
* `flex-1` column in an auto-height parent collapses to nothing in Yoga.
|
|
103
|
+
*
|
|
104
|
+
* No slot but the two text slots holds a `text-*` colour — a React Native
|
|
105
|
+
* `View` does not cascade one to a `Text`.
|
|
106
|
+
*
|
|
107
|
+
* Free of React Native imports so it stays unit-testable — `bun test` cannot
|
|
108
|
+
* parse React Native's Flow-typed source. See AGENTS.md.
|
|
109
|
+
*/
|
|
110
|
+
export const itemVariants = tv({
|
|
111
|
+
slots: {
|
|
112
|
+
root: "",
|
|
113
|
+
media: "items-center justify-center",
|
|
114
|
+
/** Edge length an `Icon` in the media slot inherits. */
|
|
115
|
+
mediaIcon: "",
|
|
116
|
+
content: "justify-center gap-0.5",
|
|
117
|
+
title: "font-medium text-foreground",
|
|
118
|
+
description: "text-muted-foreground",
|
|
119
|
+
actions: "flex-row items-center gap-2",
|
|
120
|
+
/** Edge length an `Icon` in the actions slot inherits. */
|
|
121
|
+
actionsIcon: "",
|
|
122
|
+
header: "w-full flex-row items-center justify-between gap-2",
|
|
123
|
+
footer: "w-full flex-row items-center justify-between gap-2",
|
|
124
|
+
group: "",
|
|
125
|
+
},
|
|
126
|
+
variants: {
|
|
127
|
+
surface: {
|
|
128
|
+
default: { root: "rounded-lg border border-transparent bg-transparent" },
|
|
129
|
+
outline: { root: "rounded-lg border border-border bg-transparent" },
|
|
130
|
+
muted: { root: "rounded-lg border border-transparent bg-muted" },
|
|
131
|
+
grouped: { root: "w-full bg-transparent" },
|
|
132
|
+
},
|
|
133
|
+
size: {
|
|
134
|
+
sm: {
|
|
135
|
+
root: "min-h-12 gap-2.5 px-3 py-2",
|
|
136
|
+
title: "text-sm",
|
|
137
|
+
description: "text-xs",
|
|
138
|
+
actionsIcon: "size-icon-xs",
|
|
139
|
+
},
|
|
140
|
+
md: {
|
|
141
|
+
root: "min-h-14 gap-3 px-4 py-3",
|
|
142
|
+
title: "text-base",
|
|
143
|
+
description: "text-sm",
|
|
144
|
+
actionsIcon: "size-icon-sm",
|
|
145
|
+
},
|
|
146
|
+
lg: {
|
|
147
|
+
root: "min-h-16 gap-3.5 px-5 py-4",
|
|
148
|
+
title: "text-lg",
|
|
149
|
+
description: "text-base",
|
|
150
|
+
actionsIcon: "size-icon-md",
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
orientation: {
|
|
154
|
+
horizontal: { root: "flex-row flex-wrap items-center", content: "flex-1" },
|
|
155
|
+
vertical: { root: "flex-col items-start", content: "self-stretch" },
|
|
156
|
+
},
|
|
157
|
+
mediaVariant: {
|
|
158
|
+
default: {},
|
|
159
|
+
icon: { media: "rounded-md bg-muted" },
|
|
160
|
+
image: { media: "overflow-hidden rounded-md bg-muted" },
|
|
161
|
+
},
|
|
162
|
+
groupOrientation: {
|
|
163
|
+
vertical: { group: "flex-col gap-2" },
|
|
164
|
+
horizontal: { group: "flex-row gap-3" },
|
|
165
|
+
},
|
|
166
|
+
// The empty `false` branches type the props as `boolean` rather than
|
|
167
|
+
// `true`. See the note in button.variants.ts.
|
|
168
|
+
isDisabled: { true: { root: "opacity-50" }, false: {} },
|
|
169
|
+
isSelected: { true: { root: "bg-accent" }, false: {} },
|
|
170
|
+
},
|
|
171
|
+
compoundVariants: [
|
|
172
|
+
// A card-shaped surface is `rounded-lg`, stepping down to `rounded-md` at
|
|
173
|
+
// `sm` — the same one step `ListGroup` and `Accordion` take.
|
|
174
|
+
{ size: "sm", surface: ["default", "outline", "muted"], class: { root: "rounded-md" } },
|
|
175
|
+
{ mediaVariant: "default", size: "sm", class: { mediaIcon: "size-icon-md" } },
|
|
176
|
+
{ mediaVariant: "default", size: "md", class: { mediaIcon: "size-icon-lg" } },
|
|
177
|
+
{ mediaVariant: "default", size: "lg", class: { mediaIcon: "size-icon-xl" } },
|
|
178
|
+
{ mediaVariant: "icon", size: "sm", class: { media: "size-8", mediaIcon: "size-icon-sm" } },
|
|
179
|
+
{ mediaVariant: "icon", size: "md", class: { media: "size-10", mediaIcon: "size-icon-md" } },
|
|
180
|
+
{ mediaVariant: "icon", size: "lg", class: { media: "size-12", mediaIcon: "size-icon-lg" } },
|
|
181
|
+
{ mediaVariant: "image", size: "sm", class: { media: "size-10" } },
|
|
182
|
+
{ mediaVariant: "image", size: "md", class: { media: "size-12" } },
|
|
183
|
+
{ mediaVariant: "image", size: "lg", class: { media: "size-14" } },
|
|
184
|
+
// A tile is `bg-muted`, so on a muted or selected surface it would vanish
|
|
185
|
+
// into the row.
|
|
186
|
+
{ mediaVariant: ["icon", "image"], surface: "muted", class: { media: "bg-background" } },
|
|
187
|
+
{ mediaVariant: ["icon", "image"], isSelected: true, class: { media: "bg-background" } },
|
|
188
|
+
],
|
|
189
|
+
defaultVariants: {
|
|
190
|
+
surface: "default",
|
|
191
|
+
size: "md",
|
|
192
|
+
orientation: "horizontal",
|
|
193
|
+
mediaVariant: "default",
|
|
194
|
+
groupOrientation: "vertical",
|
|
195
|
+
isDisabled: false,
|
|
196
|
+
isSelected: false,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
export type ItemVariantProps = VariantProps<typeof itemVariants>;
|