@delacour/react-native-ui 0.1.0-alpha.20260925064136 → 0.1.0-alpha.20260925064429
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/card/AGENTS.md +88 -0
- package/src/components/card/card-action.tsx +16 -0
- package/src/components/card/card-content.tsx +12 -0
- package/src/components/card/card-description.tsx +12 -0
- package/src/components/card/card-footer.tsx +30 -0
- package/src/components/card/card-header.tsx +45 -0
- package/src/components/card/card-title.tsx +25 -0
- package/src/components/card/card.context.tsx +64 -0
- package/src/components/card/card.tsx +94 -0
- package/src/components/card/card.types.ts +13 -0
- package/src/components/card/card.variants.test.ts +240 -0
- package/src/components/card/card.variants.ts +177 -0
- package/src/components/card/index.ts +15 -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.20260925064429",
|
|
4
4
|
"description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"./badge": "./src/components/badge/index.ts",
|
|
40
40
|
"./bottom-sheet": "./src/components/bottom-sheet/index.ts",
|
|
41
41
|
"./button": "./src/components/button/index.ts",
|
|
42
|
+
"./card": "./src/components/card/index.ts",
|
|
42
43
|
"./chart": "./src/components/chart/index.ts",
|
|
43
44
|
"./checkbox": "./src/components/checkbox/index.ts",
|
|
44
45
|
"./chip": "./src/components/chip/index.ts",
|
|
@@ -95,7 +96,7 @@
|
|
|
95
96
|
},
|
|
96
97
|
"peerDependencies": {
|
|
97
98
|
"@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
|
|
98
|
-
"@delacour/react-native-charts": "0.1.0-alpha.
|
|
99
|
+
"@delacour/react-native-charts": "0.1.0-alpha.20260925064429",
|
|
99
100
|
"@gorhom/bottom-sheet": "^5.2.8",
|
|
100
101
|
"@legendapp/list": ">=3.3",
|
|
101
102
|
"expo-linear-gradient": ">=15",
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Card
|
|
2
|
+
|
|
3
|
+
A content surface with a header, a body and a footer, built on
|
|
4
|
+
[`Surface`](../surface/AGENTS.md). Compound root plus six parts: `Header`,
|
|
5
|
+
`Title`, `Description`, `Action`, `Content`, `Footer`.
|
|
6
|
+
|
|
7
|
+
`import { Card } from "@delacour/react-native-ui/card";`
|
|
8
|
+
|
|
9
|
+
## Files
|
|
10
|
+
|
|
11
|
+
| File | What it holds |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `index.ts` | → `@delacour/react-native-ui/card` |
|
|
14
|
+
| `card.tsx` | Root + the `Object.assign` compound surface |
|
|
15
|
+
| `card-header.tsx` | `Card.Header`, and the bare-text wrap it owns |
|
|
16
|
+
| `card-title.tsx` | `Card.Title` |
|
|
17
|
+
| `card-description.tsx` | `Card.Description` |
|
|
18
|
+
| `card-action.tsx` | `Card.Action` |
|
|
19
|
+
| `card-content.tsx` | `Card.Content` |
|
|
20
|
+
| `card-footer.tsx` | `Card.Footer` |
|
|
21
|
+
| `card.context.tsx` | `CardProvider`, `useCard()`, `useCardContext()`, `useCardPart()` |
|
|
22
|
+
| `card.types.ts` | Prop types shared by two or more parts |
|
|
23
|
+
| `card.variants.ts` | The slotted `tv()`, `resolveCardFooterFill`, `splitCardHeaderChildren` — no RN imports |
|
|
24
|
+
| `card.variants.test.ts` | |
|
|
25
|
+
|
|
26
|
+
## Design
|
|
27
|
+
|
|
28
|
+
- **The surface is `Surface`'s; the card adds only the rhythm between its
|
|
29
|
+
parts.** Fill, hairline, corner and clip all come from `Surface` at
|
|
30
|
+
`padding="none"`, so `variant` is `Surface`'s four fills with the same
|
|
31
|
+
nesting rule — a card that names none is the hairlined `default` on a screen
|
|
32
|
+
and steps to the next fill inside another surface. A card is not a second
|
|
33
|
+
definition of what a card-shaped surface looks like.
|
|
34
|
+
- **The root resolves the fill itself as well as passing it down.** The parts
|
|
35
|
+
need the plane the card landed on — the title's colour and a band footer's
|
|
36
|
+
fill key off it — and `Surface` does not publish a prop it was never given.
|
|
37
|
+
So `Card` runs the same two pure resolvers and hands `Surface` the explicit
|
|
38
|
+
result; the two cannot disagree, because it is one function called twice.
|
|
39
|
+
- **Padding lives on the parts, not the root.** The root carries `pt`/`pb`
|
|
40
|
+
and a `gap` of the same step; `Header`, `Content` and `Footer` carry that step as
|
|
41
|
+
`px`. An image written straight into the card therefore reaches both side
|
|
42
|
+
edges with nothing to undo, and `className="pt-0"` on the card bleeds it to
|
|
43
|
+
the top. Content that must not be clipped does not belong in a card: the root
|
|
44
|
+
is `padding="none"`, which is `Surface`'s one clipping step.
|
|
45
|
+
- **The root's vertical padding is `pt`/`pb`, never `py`.** The root is the
|
|
46
|
+
surface's view, which carries `p-0` at `padding="none"`, and Uniwind resolves
|
|
47
|
+
that shorthand over a `py-*` on the same view — the first build on a device
|
|
48
|
+
drew the title flush against the top hairline. The longhands win, and a test
|
|
49
|
+
forbids `py-` on the root so it cannot come back.
|
|
50
|
+
- **Sizes**: `sm`, `md`, `lg` — insets of 3, 4 and 6, which are `Surface`'s own
|
|
51
|
+
padding steps, so a card and a surface side by side at one size hold their
|
|
52
|
+
content at one distance from the edge. Size is one axis for five numbers —
|
|
53
|
+
vertical padding, gap, horizontal inset, title scale, description scale —
|
|
54
|
+
and the test pins the three insets to each other rather than to numbers.
|
|
55
|
+
- **The title is on its plane's foreground token.** `text-card-foreground` on
|
|
56
|
+
the card fill, `text-secondary-foreground` on a secondary card, and
|
|
57
|
+
`text-foreground` for a transparent card on the page. Written out per plane
|
|
58
|
+
in the `tv()`, because Tailwind's scanner cannot see a class built at
|
|
59
|
+
runtime. The description is `muted-foreground` everywhere.
|
|
60
|
+
- **`Card.Title` is announced as a header**, so a screen reader's rotor can
|
|
61
|
+
step card to card through a list of them.
|
|
62
|
+
- **`Card.Action` is lifted out of the header's text column, wherever it is
|
|
63
|
+
written.** React Native has no grid to place a child by type, so the header
|
|
64
|
+
walks its children with `splitCardHeaderChildren` and renders the text column
|
|
65
|
+
as `flex-1` with the actions after it. The title wraps in the width an action
|
|
66
|
+
leaves instead of pushing it off the edge. The helper takes its predicate as
|
|
67
|
+
an argument so it stays in `card.variants.ts`, free of the part and of React
|
|
68
|
+
Native, and reachable from `bun test`.
|
|
69
|
+
- **Bare text in a header is its title.** A raw string inside a `View` is a
|
|
70
|
+
red box, and a header's text is its heading — the same wrap, for the same
|
|
71
|
+
reason, as [`ListGroup.Item`](../list-group/AGENTS.md).
|
|
72
|
+
- **Footer variants**: `default`, a wrapping row of actions; `band`, a strip
|
|
73
|
+
set into the card — `border-t`, the next fill down, and the card's bottom
|
|
74
|
+
corners. The band pulls itself down over the root's bottom padding with a
|
|
75
|
+
`-mb-*` of exactly that step and restates it as `py-*`, so it meets the edge
|
|
76
|
+
and the root's clip gives it the corners. That only works as the last child,
|
|
77
|
+
which the doc comment says.
|
|
78
|
+
- **A band's fill is a step on the ladder, not a colour.**
|
|
79
|
+
`resolveCardFooterFill` is `resolveSurfaceVariant` for the card's plane — the
|
|
80
|
+
same step a surface nested in the card would take — so a band never matches
|
|
81
|
+
the card it sits in, at any depth. A transparent card on the page has no
|
|
82
|
+
plane, and its band takes the card fill.
|
|
83
|
+
- **No interaction on the card.** A card is layout. A card that selects or
|
|
84
|
+
navigates is a `Pressable` around it — `<Pressable asChild>` or a wrapper —
|
|
85
|
+
which keeps `feedback`, `haptic` and the accessibility role at the call site
|
|
86
|
+
that knows what the press means. The playground's plan picker is the example.
|
|
87
|
+
- **No text treatment on the root, header, content or footer** (rule 1). The
|
|
88
|
+
tests assert it across every combination.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { useCardPart } from "./card.context";
|
|
4
|
+
import type { CardSlotProps } from "./card.types";
|
|
5
|
+
import { cardVariants } from "./card.variants";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A control pinned to the header's top-right corner — a menu button, a badge, a
|
|
9
|
+
* link. `Card.Header` lifts it out of the text column wherever it is written, so
|
|
10
|
+
* the title and description keep the width it leaves.
|
|
11
|
+
*/
|
|
12
|
+
export function CardAction({ className, ...props }: CardSlotProps): ReactElement {
|
|
13
|
+
const { size } = useCardPart("Card.Action");
|
|
14
|
+
return <View className={cardVariants({ size }).action({ className })} {...props} />;
|
|
15
|
+
}
|
|
16
|
+
CardAction.displayName = "DelacourUI.Card.Action";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { useCardPart } from "./card.context";
|
|
4
|
+
import type { CardSlotProps } from "./card.types";
|
|
5
|
+
import { cardVariants } from "./card.variants";
|
|
6
|
+
|
|
7
|
+
/** The card's body, inset to line up with the header and footer and spaced by the card's size. */
|
|
8
|
+
export function CardContent({ className, ...props }: CardSlotProps): ReactElement {
|
|
9
|
+
const { size } = useCardPart("Card.Content");
|
|
10
|
+
return <View className={cardVariants({ size }).content({ className })} {...props} />;
|
|
11
|
+
}
|
|
12
|
+
CardContent.displayName = "DelacourUI.Card.Content";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { Text } from "../text";
|
|
3
|
+
import { useCardPart } from "./card.context";
|
|
4
|
+
import type { CardTextProps } from "./card.types";
|
|
5
|
+
import { cardVariants } from "./card.variants";
|
|
6
|
+
|
|
7
|
+
/** The muted supporting line under the title, a step down the type scale from it. */
|
|
8
|
+
export function CardDescription({ className, ...props }: CardTextProps): ReactElement {
|
|
9
|
+
const { size } = useCardPart("Card.Description");
|
|
10
|
+
return <Text className={cardVariants({ size }).description({ className })} {...props} />;
|
|
11
|
+
}
|
|
12
|
+
CardDescription.displayName = "DelacourUI.Card.Description";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { useCardPart } from "./card.context";
|
|
4
|
+
import type { CardSlotProps } from "./card.types";
|
|
5
|
+
import { type CardFooterVariant, cardVariants, resolveCardFooterFill } from "./card.variants";
|
|
6
|
+
|
|
7
|
+
export type CardFooterProps = CardSlotProps & {
|
|
8
|
+
/**
|
|
9
|
+
* `default` is a row of actions under the content. `band` sets it into the
|
|
10
|
+
* card as a strip of its own — a rule across the top, the next fill down, and
|
|
11
|
+
* the card's bottom corners — for a footer that is what someone does with the
|
|
12
|
+
* card rather than more of what it says.
|
|
13
|
+
*/
|
|
14
|
+
variant?: CardFooterVariant;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The card's last row — its actions, or a status line.
|
|
19
|
+
*
|
|
20
|
+
* A `band` footer steps its fill from the card's own plane rather than naming a
|
|
21
|
+
* colour, so it stays a step apart from the card however deep it is nested.
|
|
22
|
+
* It should be the card's last child: it pulls itself down over the card's
|
|
23
|
+
* bottom padding to meet the edge.
|
|
24
|
+
*/
|
|
25
|
+
export function CardFooter({ variant = "default", className, ...props }: CardFooterProps): ReactElement {
|
|
26
|
+
const { size, plane } = useCardPart("Card.Footer");
|
|
27
|
+
const slots = cardVariants({ footer: variant, footerFill: resolveCardFooterFill(plane), size });
|
|
28
|
+
return <View className={slots.footer({ className })} {...props} />;
|
|
29
|
+
}
|
|
30
|
+
CardFooter.displayName = "DelacourUI.Card.Footer";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { useCardPart } from "./card.context";
|
|
4
|
+
import type { CardSlotProps } from "./card.types";
|
|
5
|
+
import { cardVariants, splitCardHeaderChildren } from "./card.variants";
|
|
6
|
+
import { CardAction } from "./card-action";
|
|
7
|
+
import { CardTitle } from "./card-title";
|
|
8
|
+
|
|
9
|
+
function isCardAction(node: ReactNode): boolean {
|
|
10
|
+
return isValidElement(node) && node.type === CardAction;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wraps a bare string or number in a `Card.Title`. A raw string inside a `View`
|
|
15
|
+
* is a red box in React Native, and a header's bare text is its title.
|
|
16
|
+
*/
|
|
17
|
+
function wrapBareText(node: ReactNode, index: number): ReactNode {
|
|
18
|
+
return typeof node === "string" || typeof node === "number" ? (
|
|
19
|
+
<CardTitle key={`title-${index}`}>{node}</CardTitle>
|
|
20
|
+
) : (
|
|
21
|
+
node
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The card's opening block: a title and description stacked on the left, and
|
|
27
|
+
* any `Card.Action` pinned to the top right.
|
|
28
|
+
*
|
|
29
|
+
* The header lifts every action out of the column wherever it is written, so
|
|
30
|
+
* the order of children does not matter and the text wraps in the width the
|
|
31
|
+
* action leaves. Bare text becomes a title.
|
|
32
|
+
*/
|
|
33
|
+
export function CardHeader({ className, children, ...props }: CardSlotProps): ReactElement {
|
|
34
|
+
const { size } = useCardPart("Card.Header");
|
|
35
|
+
const slots = cardVariants({ size });
|
|
36
|
+
const { text, actions } = splitCardHeaderChildren(children, isCardAction);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<View className={slots.header({ className })} {...props}>
|
|
40
|
+
<View className={slots.headerText()}>{text.map(wrapBareText)}</View>
|
|
41
|
+
{actions}
|
|
42
|
+
</View>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
CardHeader.displayName = "DelacourUI.Card.Header";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { Text } from "../text";
|
|
3
|
+
import { useCardPart } from "./card.context";
|
|
4
|
+
import type { CardTextProps } from "./card.types";
|
|
5
|
+
import { cardVariants } from "./card.variants";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The card's heading.
|
|
9
|
+
*
|
|
10
|
+
* Carries its own colour — the foreground token of the plane the card resolved
|
|
11
|
+
* to — and its own step on the type scale, read from the card's context: a
|
|
12
|
+
* React Native `View` does not cascade colour to a `Text` descendant. Announced
|
|
13
|
+
* as a header, so a screen reader's rotor stops on each card in a list.
|
|
14
|
+
*/
|
|
15
|
+
export function CardTitle({ className, ...props }: CardTextProps): ReactElement {
|
|
16
|
+
const { size, plane } = useCardPart("Card.Title");
|
|
17
|
+
return (
|
|
18
|
+
<Text
|
|
19
|
+
accessibilityRole="header"
|
|
20
|
+
className={cardVariants({ plane: plane ?? "none", size }).title({ className })}
|
|
21
|
+
{...props}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
CardTitle.displayName = "DelacourUI.Card.Title";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createContext, type ReactElement, type ReactNode, use } from "react";
|
|
2
|
+
import type { SurfacePlane, SurfaceVariant } from "../surface/surface.variants";
|
|
3
|
+
import type { CardSize } from "./card.variants";
|
|
4
|
+
|
|
5
|
+
export type CardContextValue = {
|
|
6
|
+
/** Size of the card — the padding, the gaps and the title and description scale. */
|
|
7
|
+
size: CardSize;
|
|
8
|
+
/** The fill the card resolved to — explicit, or stepped from the surface it sits in. */
|
|
9
|
+
variant: SurfaceVariant;
|
|
10
|
+
/**
|
|
11
|
+
* The fill the card's content sits on. The same as `variant` unless that is
|
|
12
|
+
* `transparent`, which passes the plane beneath it through — `null` on the page.
|
|
13
|
+
*/
|
|
14
|
+
plane: SurfacePlane | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const CardContext = createContext<CardContextValue | null>(null);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Supplies the enclosing card's size and fill to its subtree.
|
|
21
|
+
*
|
|
22
|
+
* Lives in its own module, importing nothing but types, so a part the card
|
|
23
|
+
* renders can read it without importing `./card` and closing a cycle through
|
|
24
|
+
* `card.tsx`. See AGENTS.md rule 3.
|
|
25
|
+
*/
|
|
26
|
+
export function CardProvider({ value, children }: { value: CardContextValue; children: ReactNode }): ReactElement {
|
|
27
|
+
return <CardContext value={value}>{children}</CardContext>;
|
|
28
|
+
}
|
|
29
|
+
CardProvider.displayName = "DelacourUI.Card.Provider";
|
|
30
|
+
|
|
31
|
+
/** The enclosing card's context, or null outside a `<Card>`. */
|
|
32
|
+
export function useCardContext(): CardContextValue | null {
|
|
33
|
+
return use(CardContext);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reads the enclosing card's size and fill.
|
|
38
|
+
*
|
|
39
|
+
* Lets a custom part match the card — the same inset, a colour that reads on
|
|
40
|
+
* its plane — without the card passing props down. Throws outside a `<Card>`;
|
|
41
|
+
* use {@link useCardContext} where the enclosing card is optional.
|
|
42
|
+
*/
|
|
43
|
+
export function useCard(): CardContextValue {
|
|
44
|
+
const context = useCardContext();
|
|
45
|
+
if (!context) {
|
|
46
|
+
throw new Error("useCard must be called inside a <Card>.");
|
|
47
|
+
}
|
|
48
|
+
return context;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The enclosing card's context, for a compound part that cannot work without one.
|
|
53
|
+
*
|
|
54
|
+
* Internal: deliberately not re-exported from `index.ts`. A caller outside the
|
|
55
|
+
* library wants {@link useCard}, whose error message names the hook rather than
|
|
56
|
+
* a part.
|
|
57
|
+
*/
|
|
58
|
+
export function useCardPart(component: string): CardContextValue {
|
|
59
|
+
const context = useCardContext();
|
|
60
|
+
if (!context) {
|
|
61
|
+
throw new Error(`${component} must be rendered inside a <Card>.`);
|
|
62
|
+
}
|
|
63
|
+
return context;
|
|
64
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { type ReactElement, useMemo } from "react";
|
|
2
|
+
import { Surface, type SurfaceProps, useSurfaceContext } from "../surface";
|
|
3
|
+
import { resolveSurfacePlane, resolveSurfaceVariant } from "../surface/surface.variants";
|
|
4
|
+
import { type CardContextValue, CardProvider } from "./card.context";
|
|
5
|
+
import { type CardSize, cardVariants } from "./card.variants";
|
|
6
|
+
import { CardAction } from "./card-action";
|
|
7
|
+
import { CardContent } from "./card-content";
|
|
8
|
+
import { CardDescription } from "./card-description";
|
|
9
|
+
import { CardFooter } from "./card-footer";
|
|
10
|
+
import { CardHeader } from "./card-header";
|
|
11
|
+
import { CardTitle } from "./card-title";
|
|
12
|
+
|
|
13
|
+
export type CardProps = Omit<SurfaceProps, "padding"> & {
|
|
14
|
+
/** The inset, the gaps between the parts, and the title and description scale. */
|
|
15
|
+
size?: CardSize;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function CardRoot({ variant, size = "md", className, children, ...props }: CardProps): ReactElement {
|
|
19
|
+
const parentPlane = useSurfaceContext()?.plane ?? null;
|
|
20
|
+
|
|
21
|
+
// Resolved here as well as inside `Surface`, because the parts need the plane
|
|
22
|
+
// the card landed on — the title's colour and a band footer's fill key off it.
|
|
23
|
+
const resolved = resolveSurfaceVariant({ parentPlane, variant });
|
|
24
|
+
const plane = resolveSurfacePlane({ parentPlane, variant: resolved });
|
|
25
|
+
|
|
26
|
+
const context = useMemo<CardContextValue>(() => ({ plane, size, variant: resolved }), [plane, size, resolved]);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<CardProvider value={context}>
|
|
30
|
+
<Surface className={cardVariants({ size }).root({ className })} padding="none" variant={resolved} {...props}>
|
|
31
|
+
{children}
|
|
32
|
+
</Surface>
|
|
33
|
+
</CardProvider>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A content surface with a header, a body and a footer.
|
|
39
|
+
*
|
|
40
|
+
* Built on `Surface`, so it takes the same four fills and steps the same way:
|
|
41
|
+
* a card that names no variant is the hairlined `default` at the top of a
|
|
42
|
+
* screen and the next fill down inside another surface. The root is
|
|
43
|
+
* `padding="none"`, which clips — the padding lives on the parts, so an image
|
|
44
|
+
* placed straight in the card reaches its side edges, and `className="pt-0"`
|
|
45
|
+
* bleeds it to the top.
|
|
46
|
+
*
|
|
47
|
+
* `size` reaches every part through context, so the header, the body and the
|
|
48
|
+
* footer line up on one inset and the title picks its own scale and colour —
|
|
49
|
+
* a React Native `View` cannot cascade either to a `Text`.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* <Card>
|
|
53
|
+
* <Card.Header>
|
|
54
|
+
* <Card.Title>Monthly report</Card.Title>
|
|
55
|
+
* <Card.Description>Revenue and retention for October.</Card.Description>
|
|
56
|
+
* <Card.Action>
|
|
57
|
+
* <Badge>New</Badge>
|
|
58
|
+
* </Card.Action>
|
|
59
|
+
* </Card.Header>
|
|
60
|
+
* <Card.Content>
|
|
61
|
+
* <Text.Title>$48,120</Text.Title>
|
|
62
|
+
* </Card.Content>
|
|
63
|
+
* <Card.Footer>
|
|
64
|
+
* <Button size="sm" variant="outline">Export</Button>
|
|
65
|
+
* <Button size="sm">Open</Button>
|
|
66
|
+
* </Card.Footer>
|
|
67
|
+
* </Card>
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* <Card className="pt-0">
|
|
71
|
+
* <Image className="h-40 w-full" source={cover} />
|
|
72
|
+
* <Card.Header>
|
|
73
|
+
* <Card.Title>Lake Tekapo</Card.Title>
|
|
74
|
+
* </Card.Header>
|
|
75
|
+
* <Card.Footer variant="band">
|
|
76
|
+
* <Button size="sm">Book</Button>
|
|
77
|
+
* </Card.Footer>
|
|
78
|
+
* </Card>
|
|
79
|
+
*/
|
|
80
|
+
export const Card = Object.assign(CardRoot, {
|
|
81
|
+
/** The opening block. Stacks a title and description, and pins any `Card.Action` to the top right. */
|
|
82
|
+
Header: CardHeader,
|
|
83
|
+
/** The card's heading, on its plane's foreground token. Announced as a header. */
|
|
84
|
+
Title: CardTitle,
|
|
85
|
+
/** The muted supporting line under the title. */
|
|
86
|
+
Description: CardDescription,
|
|
87
|
+
/** A control pinned to the header's top-right corner, wherever in the header it is written. */
|
|
88
|
+
Action: CardAction,
|
|
89
|
+
/** The card's body, inset to line up with the header and footer. */
|
|
90
|
+
Content: CardContent,
|
|
91
|
+
/** The last row — a row of actions, or with `variant="band"` a strip set into the card. */
|
|
92
|
+
Footer: CardFooter,
|
|
93
|
+
displayName: "DelacourUI.Card",
|
|
94
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { ViewProps } from "react-native";
|
|
3
|
+
import type { TextPresetProps } from "../text";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The shape of a card's layout parts — `Header`, `Action` and `Content`, and
|
|
7
|
+
* extended by `Footer` — so it lives in a leaf rather than in one of them
|
|
8
|
+
* arbitrarily.
|
|
9
|
+
*/
|
|
10
|
+
export type CardSlotProps = ViewProps & { className?: string; children?: ReactNode };
|
|
11
|
+
|
|
12
|
+
/** The shape of a card's two text parts, `Title` and `Description`. */
|
|
13
|
+
export type CardTextProps = TextPresetProps;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createElement, Fragment, type ReactNode } from "react";
|
|
3
|
+
import { SURFACE_FILLED_VARIANTS } from "../surface/surface.variants";
|
|
4
|
+
import {
|
|
5
|
+
CARD_FOOTER_VARIANTS,
|
|
6
|
+
CARD_PLANES,
|
|
7
|
+
CARD_SIZES,
|
|
8
|
+
cardVariants,
|
|
9
|
+
resolveCardFooterFill,
|
|
10
|
+
splitCardHeaderChildren,
|
|
11
|
+
} from "./card.variants";
|
|
12
|
+
|
|
13
|
+
/** Every slot the variant function can produce, one call per combination. */
|
|
14
|
+
function everyCombination(): ReturnType<typeof cardVariants>[] {
|
|
15
|
+
return CARD_SIZES.flatMap((size) =>
|
|
16
|
+
CARD_PLANES.flatMap((plane) =>
|
|
17
|
+
CARD_FOOTER_VARIANTS.flatMap((footer) =>
|
|
18
|
+
SURFACE_FILLED_VARIANTS.map((footerFill) => cardVariants({ footer, footerFill, plane, size }))
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The numeric step of the first `${prefix}-N` utility in a class string. */
|
|
25
|
+
function step(cls: string, prefix: string): string | undefined {
|
|
26
|
+
return cls.match(new RegExp(`(?:^|\\s)${prefix}-(\\d+(?:\\.5)?)(?:\\s|$)`))?.[1];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("cardVariants root slot", () => {
|
|
30
|
+
// The padding lives on the parts, so the root carries only the vertical
|
|
31
|
+
// rhythm between them — horizontal padding here would stop a bled image
|
|
32
|
+
// reaching the card's side edges.
|
|
33
|
+
test("carries no horizontal padding at any size", () => {
|
|
34
|
+
for (const size of CARD_SIZES) {
|
|
35
|
+
const cls = cardVariants({ size }).root();
|
|
36
|
+
expect(cls).not.toMatch(/(^|\s)(p|px|pl|pr)-/);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("spaces its parts by the same step it pads its top and bottom", () => {
|
|
41
|
+
for (const size of CARD_SIZES) {
|
|
42
|
+
const cls = cardVariants({ size }).root();
|
|
43
|
+
expect(step(cls, "pt")).toBeDefined();
|
|
44
|
+
expect(step(cls, "pb")).toBe(step(cls, "pt"));
|
|
45
|
+
expect(step(cls, "gap")).toBe(step(cls, "pt"));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// The card sits on a `padding="none"` surface, whose root carries `p-0`.
|
|
50
|
+
// Uniwind resolves that shorthand over a `py-*` on the same view, so the
|
|
51
|
+
// card's vertical padding vanished on a device; the longhands hold.
|
|
52
|
+
test("pads top and bottom with longhands, never the py shorthand", () => {
|
|
53
|
+
for (const size of CARD_SIZES) {
|
|
54
|
+
expect(cardVariants({ size }).root()).not.toMatch(/(^|\s)py-/);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Rule 1: a React Native View does not cascade colour to a Text descendant.
|
|
59
|
+
test("carries no text treatment on the root, header, content or footer", () => {
|
|
60
|
+
for (const slots of everyCombination()) {
|
|
61
|
+
for (const cls of [slots.root(), slots.header(), slots.content(), slots.footer()]) {
|
|
62
|
+
expect(cls).not.toMatch(/(^|\s)text-/);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("merges an incoming className last", () => {
|
|
68
|
+
expect(cardVariants().root({ className: "pt-0" })).toContain("pt-0");
|
|
69
|
+
expect(cardVariants().root({ className: "gap-8" })).not.toMatch(/(^|\s)gap-4(\s|$)/);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("cardVariants size", () => {
|
|
74
|
+
test("defaults to md", () => {
|
|
75
|
+
expect(cardVariants().root()).toBe(cardVariants({ size: "md" }).root());
|
|
76
|
+
expect(cardVariants().title()).toBe(cardVariants({ size: "md" }).title());
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("matches Surface's padding steps: 3, 4 and 6", () => {
|
|
80
|
+
expect(step(cardVariants({ size: "sm" }).root(), "pt")).toBe("3");
|
|
81
|
+
expect(step(cardVariants({ size: "md" }).root(), "pt")).toBe("4");
|
|
82
|
+
expect(step(cardVariants({ size: "lg" }).root(), "pt")).toBe("6");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// The header, the body and the footer have to line up on one inset, or the
|
|
86
|
+
// title sits a few points off the button beneath it.
|
|
87
|
+
test("header, content and footer share one horizontal inset, equal to the vertical one", () => {
|
|
88
|
+
for (const size of CARD_SIZES) {
|
|
89
|
+
const slots = cardVariants({ size });
|
|
90
|
+
const inset = step(slots.root(), "pt");
|
|
91
|
+
expect(step(slots.header(), "px")).toBe(inset);
|
|
92
|
+
expect(step(slots.content(), "px")).toBe(inset);
|
|
93
|
+
expect(step(slots.footer(), "px")).toBe(inset);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("steps the title and description type scale with the size", () => {
|
|
98
|
+
expect(cardVariants({ size: "sm" }).title()).toContain("text-sm");
|
|
99
|
+
expect(cardVariants({ size: "md" }).title()).toContain("text-base");
|
|
100
|
+
expect(cardVariants({ size: "lg" }).title()).toContain("text-lg");
|
|
101
|
+
expect(cardVariants({ size: "sm" }).description()).toContain("text-xs");
|
|
102
|
+
expect(cardVariants({ size: "md" }).description()).toContain("text-sm");
|
|
103
|
+
expect(cardVariants({ size: "lg" }).description()).toContain("text-base");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("gives every size a distinct root", () => {
|
|
107
|
+
const seen = new Set(CARD_SIZES.map((size) => cardVariants({ size }).root()));
|
|
108
|
+
expect(seen.size).toBe(CARD_SIZES.length);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("cardVariants title and description", () => {
|
|
113
|
+
test("the title is the X-foreground token of the plane it sits on", () => {
|
|
114
|
+
expect(cardVariants({ plane: "default" }).title()).toContain("text-card-foreground");
|
|
115
|
+
expect(cardVariants({ plane: "secondary" }).title()).toContain("text-secondary-foreground");
|
|
116
|
+
expect(cardVariants({ plane: "tertiary" }).title()).toContain("text-tertiary-foreground");
|
|
117
|
+
expect(cardVariants({ plane: "none" }).title()).toContain("text-foreground");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("the title is semibold at every size", () => {
|
|
121
|
+
for (const size of CARD_SIZES) {
|
|
122
|
+
expect(cardVariants({ size }).title()).toContain("font-semibold");
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("the description sits on the muted token on every plane", () => {
|
|
127
|
+
for (const plane of CARD_PLANES) {
|
|
128
|
+
expect(cardVariants({ plane }).description()).toContain("text-muted-foreground");
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Two colour classes on one Text and tailwind-merge keeps only the last —
|
|
133
|
+
// which would be a page colour, not the plane's.
|
|
134
|
+
test("the title carries exactly one text colour", () => {
|
|
135
|
+
for (const slots of everyCombination()) {
|
|
136
|
+
const colours = slots
|
|
137
|
+
.title()
|
|
138
|
+
.split(/\s+/)
|
|
139
|
+
.filter((name) => /^text-(card|secondary|tertiary|foreground|muted)/.test(name));
|
|
140
|
+
expect(colours).toHaveLength(1);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("cardVariants header", () => {
|
|
146
|
+
test("lays the text column and the action out in a row", () => {
|
|
147
|
+
const slots = cardVariants();
|
|
148
|
+
expect(slots.header()).toContain("flex-row");
|
|
149
|
+
expect(slots.headerText()).toContain("flex-1");
|
|
150
|
+
expect(slots.action()).toContain("shrink-0");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("cardVariants footer", () => {
|
|
155
|
+
test("defaults to the plain row of actions", () => {
|
|
156
|
+
expect(cardVariants().footer()).toBe(cardVariants({ footer: "default" }).footer());
|
|
157
|
+
expect(cardVariants().footer()).toContain("flex-row");
|
|
158
|
+
expect(cardVariants().footer()).not.toContain("border-t");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a band draws a rule across its top and a fill of its own", () => {
|
|
162
|
+
for (const footerFill of SURFACE_FILLED_VARIANTS) {
|
|
163
|
+
const cls = cardVariants({ footer: "band", footerFill }).footer();
|
|
164
|
+
expect(cls).toContain("border-t");
|
|
165
|
+
expect(cls).toContain("border-border");
|
|
166
|
+
expect(cls).toMatch(/(^|\s)bg-/);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("only a band paints a fill", () => {
|
|
171
|
+
for (const footerFill of SURFACE_FILLED_VARIANTS) {
|
|
172
|
+
expect(cardVariants({ footer: "default", footerFill }).footer()).not.toMatch(/(^|\s)bg-/);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("maps each band fill to its surface token", () => {
|
|
177
|
+
expect(cardVariants({ footer: "band", footerFill: "default" }).footer()).toContain("bg-card");
|
|
178
|
+
expect(cardVariants({ footer: "band", footerFill: "secondary" }).footer()).toContain("bg-secondary");
|
|
179
|
+
expect(cardVariants({ footer: "band", footerFill: "tertiary" }).footer()).toContain("bg-tertiary");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// A band is set INTO the card: it pulls down over the root's bottom padding
|
|
183
|
+
// by exactly that padding, and restates it inside, so it meets the card's
|
|
184
|
+
// bottom edge and its own content keeps the same breathing room.
|
|
185
|
+
test("a band cancels the root's bottom padding and restates it inside", () => {
|
|
186
|
+
for (const size of CARD_SIZES) {
|
|
187
|
+
const inset = step(cardVariants({ size }).root(), "pb");
|
|
188
|
+
const cls = cardVariants({ footer: "band", size }).footer();
|
|
189
|
+
expect(cls).toMatch(new RegExp(`(^|\\s)-mb-${inset?.replace(".", "\\.")}(\\s|$)`));
|
|
190
|
+
expect(step(cls, "py")).toBe(inset);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("resolveCardFooterFill", () => {
|
|
196
|
+
test("a band is a step from the card's own fill, never the same one", () => {
|
|
197
|
+
for (const plane of SURFACE_FILLED_VARIANTS) {
|
|
198
|
+
expect(resolveCardFooterFill(plane)).not.toBe(plane);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a card on the default fill gets a secondary band", () => {
|
|
203
|
+
expect(resolveCardFooterFill("default")).toBe("secondary");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// A transparent card on the page paints nothing, so its band steps from the
|
|
207
|
+
// page itself — the card fill, a step above it.
|
|
208
|
+
test("a card with no plane of its own gets a default band", () => {
|
|
209
|
+
expect(resolveCardFooterFill(null)).toBe("default");
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("splitCardHeaderChildren", () => {
|
|
214
|
+
function Action(): null {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
const isAction = (node: ReactNode): boolean =>
|
|
218
|
+
typeof node === "object" && node !== null && "type" in node && node.type === Action;
|
|
219
|
+
|
|
220
|
+
test("puts everything in the text column when there is no action", () => {
|
|
221
|
+
const { text, actions } = splitCardHeaderChildren(["Title", createElement(Fragment, null, "x")], isAction);
|
|
222
|
+
expect(text).toHaveLength(2);
|
|
223
|
+
expect(actions).toHaveLength(0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("lifts every action out of the text column, keeping order", () => {
|
|
227
|
+
const first = createElement(Action, { key: "a" });
|
|
228
|
+
const second = createElement(Action, { key: "b" });
|
|
229
|
+
const { text, actions } = splitCardHeaderChildren([first, "Title", second, "Description"], isAction);
|
|
230
|
+
expect(text).toEqual(["Title", "Description"]);
|
|
231
|
+
expect(actions).toHaveLength(2);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// A conditional action leaves `false` behind; it must not count as text.
|
|
235
|
+
test("drops the nulls and booleans a conditional child leaves behind", () => {
|
|
236
|
+
const { text, actions } = splitCardHeaderChildren(["Title", false, null, undefined], isAction);
|
|
237
|
+
expect(text).toEqual(["Title"]);
|
|
238
|
+
expect(actions).toHaveLength(0);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Children, type ReactNode } from "react";
|
|
2
|
+
import type { VariantProps } from "tailwind-variants";
|
|
3
|
+
import { tv } from "../../lib/tv";
|
|
4
|
+
import { resolveSurfaceVariant, type SurfacePlane } from "../surface/surface.variants";
|
|
5
|
+
|
|
6
|
+
export const CARD_SIZES = ["sm", "md", "lg"] as const;
|
|
7
|
+
|
|
8
|
+
/** `default` is a row of actions; `band` sets the footer into the card as a strip of its own. */
|
|
9
|
+
export const CARD_FOOTER_VARIANTS = ["default", "band"] as const;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The planes a card's title can sit on: the three surface fills, plus `none`
|
|
13
|
+
* for a transparent card on the page, which has no fill of its own.
|
|
14
|
+
*/
|
|
15
|
+
export const CARD_PLANES = ["default", "secondary", "tertiary", "none"] as const;
|
|
16
|
+
|
|
17
|
+
export type CardSize = (typeof CARD_SIZES)[number];
|
|
18
|
+
export type CardFooterVariant = (typeof CARD_FOOTER_VARIANTS)[number];
|
|
19
|
+
export type CardPlane = (typeof CARD_PLANES)[number];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Styling for every part of a card.
|
|
23
|
+
*
|
|
24
|
+
* The card's own surface — fill, hairline, corner, clip — is `Surface`'s, at
|
|
25
|
+
* `padding="none"`. What this adds is the rhythm between the parts, and it
|
|
26
|
+
* puts the padding on the PARTS rather than on the root. The root holds only
|
|
27
|
+
* `pt`/`pb` and a `gap` of the same step; the header, content and footer each
|
|
28
|
+
* carry the same step as `px`. So an image placed straight in the card reaches
|
|
29
|
+
* both side edges with nothing to undo, and `className="pt-0"` on the card is
|
|
30
|
+
* all it takes to bleed one to the top as well.
|
|
31
|
+
*
|
|
32
|
+
* `pt`/`pb` rather than `py`, and that is load-bearing: the root is also the
|
|
33
|
+
* surface's, which carries `p-0` at `padding="none"`, and Uniwind resolves that
|
|
34
|
+
* shorthand over a `py-*` on the same view. The card rendered with no vertical
|
|
35
|
+
* padding at all until the longhands replaced it.
|
|
36
|
+
*
|
|
37
|
+
* Size is one axis for five numbers — the vertical padding, the gap, the
|
|
38
|
+
* horizontal inset and the title and description type scale — and the steps
|
|
39
|
+
* are Surface's own `sm`/`md`/`lg` (3, 4, 6), so a card and a surface beside
|
|
40
|
+
* it at the same size hold their content at the same distance from the edge.
|
|
41
|
+
*
|
|
42
|
+
* The title colour is keyed on `plane`, the fill the card resolved to, so a
|
|
43
|
+
* card nested in another reads `text-secondary-foreground` on the secondary
|
|
44
|
+
* fill rather than the card's token on something else. The classes are written
|
|
45
|
+
* out per plane because Tailwind's scanner cannot see a `text-${token}` built at
|
|
46
|
+
* runtime.
|
|
47
|
+
*
|
|
48
|
+
* A `band` footer is set INTO the card: `-mb-*` pulls it down over the root's
|
|
49
|
+
* bottom padding by exactly that padding and `py-*` restates it inside, so it
|
|
50
|
+
* meets the card's bottom edge — the root clips, so it takes the card's own
|
|
51
|
+
* corners — while its content keeps the same breathing room. Its fill is the
|
|
52
|
+
* next rung of the surface ladder from the card's, see
|
|
53
|
+
* {@link resolveCardFooterFill}.
|
|
54
|
+
*
|
|
55
|
+
* The root, header, content and footer carry no `text-*` (rule 1): a `View`
|
|
56
|
+
* does not cascade colour to a `Text`, so the tokens live on the title and
|
|
57
|
+
* description slots.
|
|
58
|
+
*
|
|
59
|
+
* Free of React Native imports so it stays unit-testable — `bun test` cannot
|
|
60
|
+
* parse React Native's Flow-typed source. See AGENTS.md.
|
|
61
|
+
*/
|
|
62
|
+
export const cardVariants = tv({
|
|
63
|
+
slots: {
|
|
64
|
+
root: "",
|
|
65
|
+
header: "flex-row items-start gap-3",
|
|
66
|
+
/** The column holding the title and description, beside any action. */
|
|
67
|
+
headerText: "flex-1",
|
|
68
|
+
action: "shrink-0 flex-row items-center self-start",
|
|
69
|
+
title: "font-semibold",
|
|
70
|
+
description: "text-muted-foreground",
|
|
71
|
+
content: "",
|
|
72
|
+
footer: "flex-row flex-wrap items-center gap-2",
|
|
73
|
+
},
|
|
74
|
+
variants: {
|
|
75
|
+
size: {
|
|
76
|
+
sm: {
|
|
77
|
+
root: "gap-3 pt-3 pb-3",
|
|
78
|
+
header: "px-3",
|
|
79
|
+
headerText: "gap-0.5",
|
|
80
|
+
content: "gap-2 px-3",
|
|
81
|
+
footer: "px-3",
|
|
82
|
+
title: "text-sm",
|
|
83
|
+
description: "text-xs",
|
|
84
|
+
},
|
|
85
|
+
md: {
|
|
86
|
+
root: "gap-4 pt-4 pb-4",
|
|
87
|
+
header: "px-4",
|
|
88
|
+
headerText: "gap-1",
|
|
89
|
+
content: "gap-3 px-4",
|
|
90
|
+
footer: "px-4",
|
|
91
|
+
title: "text-base",
|
|
92
|
+
description: "text-sm",
|
|
93
|
+
},
|
|
94
|
+
lg: {
|
|
95
|
+
root: "gap-6 pt-6 pb-6",
|
|
96
|
+
header: "px-6",
|
|
97
|
+
headerText: "gap-1.5",
|
|
98
|
+
content: "gap-4 px-6",
|
|
99
|
+
footer: "px-6",
|
|
100
|
+
title: "text-lg",
|
|
101
|
+
description: "text-base",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
plane: {
|
|
105
|
+
default: { title: "text-card-foreground" },
|
|
106
|
+
secondary: { title: "text-secondary-foreground" },
|
|
107
|
+
tertiary: { title: "text-tertiary-foreground" },
|
|
108
|
+
none: { title: "text-foreground" },
|
|
109
|
+
},
|
|
110
|
+
footer: {
|
|
111
|
+
default: {},
|
|
112
|
+
band: { footer: "border-t border-border" },
|
|
113
|
+
},
|
|
114
|
+
// Read only by a band; the plain footer paints nothing whatever this is.
|
|
115
|
+
footerFill: {
|
|
116
|
+
default: {},
|
|
117
|
+
secondary: {},
|
|
118
|
+
tertiary: {},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
compoundVariants: [
|
|
122
|
+
{ footer: "band", size: "sm", class: { footer: "-mb-3 py-3" } },
|
|
123
|
+
{ footer: "band", size: "md", class: { footer: "-mb-4 py-4" } },
|
|
124
|
+
{ footer: "band", size: "lg", class: { footer: "-mb-6 py-6" } },
|
|
125
|
+
{ footer: "band", footerFill: "default", class: { footer: "bg-card" } },
|
|
126
|
+
{ footer: "band", footerFill: "secondary", class: { footer: "bg-secondary" } },
|
|
127
|
+
{ footer: "band", footerFill: "tertiary", class: { footer: "bg-tertiary" } },
|
|
128
|
+
],
|
|
129
|
+
defaultVariants: {
|
|
130
|
+
size: "md",
|
|
131
|
+
plane: "default",
|
|
132
|
+
footer: "default",
|
|
133
|
+
footerFill: "secondary",
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
export type CardVariantProps = VariantProps<typeof cardVariants>;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The fill a `band` footer paints: the next rung of the surface ladder from the
|
|
141
|
+
* card's own, so the band is always told apart from the card it is set into.
|
|
142
|
+
*
|
|
143
|
+
* It is the same step a surface nested in the card would take, which is the
|
|
144
|
+
* point — a band is the card's plane stepped once, not a colour of its own. A
|
|
145
|
+
* transparent card on the page has no plane, so its band takes the card fill.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveCardFooterFill(plane: SurfacePlane | null): SurfacePlane {
|
|
148
|
+
const next = resolveSurfaceVariant({ parentPlane: plane });
|
|
149
|
+
return next === "transparent" ? "default" : next;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Splits a header's children into its text column and its trailing actions.
|
|
154
|
+
*
|
|
155
|
+
* A header is a row — the title and description stacked on the left, an action
|
|
156
|
+
* pinned to the top right — and React Native has no grid to place a child by
|
|
157
|
+
* type. So the header walks its children and lifts every action out, keeping
|
|
158
|
+
* the rest in order for the column. `Children.toArray` drops the nulls and
|
|
159
|
+
* booleans a conditional child leaves behind, so an action rendered only some
|
|
160
|
+
* of the time does not leave a hole in either list.
|
|
161
|
+
*
|
|
162
|
+
* `isAction` is passed in rather than imported so this stays free of the part
|
|
163
|
+
* it looks for, and of React Native with it.
|
|
164
|
+
*/
|
|
165
|
+
export function splitCardHeaderChildren(
|
|
166
|
+
children: ReactNode,
|
|
167
|
+
isAction: (node: ReactNode) => boolean
|
|
168
|
+
): { text: ReactNode[]; actions: ReactNode[] } {
|
|
169
|
+
const text: ReactNode[] = [];
|
|
170
|
+
const actions: ReactNode[] = [];
|
|
171
|
+
|
|
172
|
+
for (const child of Children.toArray(children)) {
|
|
173
|
+
(isAction(child) ? actions : text).push(child);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { actions, text };
|
|
177
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { Card, type CardProps } from "./card";
|
|
2
|
+
export { type CardContextValue, CardProvider, useCard, useCardContext } from "./card.context";
|
|
3
|
+
export type { CardSlotProps, CardTextProps } from "./card.types";
|
|
4
|
+
export {
|
|
5
|
+
CARD_FOOTER_VARIANTS,
|
|
6
|
+
CARD_PLANES,
|
|
7
|
+
CARD_SIZES,
|
|
8
|
+
type CardFooterVariant,
|
|
9
|
+
type CardPlane,
|
|
10
|
+
type CardSize,
|
|
11
|
+
type CardVariantProps,
|
|
12
|
+
cardVariants,
|
|
13
|
+
resolveCardFooterFill,
|
|
14
|
+
} from "./card.variants";
|
|
15
|
+
export type { CardFooterProps } from "./card-footer";
|