@delacour/react-native-ui 0.1.0-alpha.20260925064136 → 0.1.0-alpha.20260925064741

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +4 -2
  2. package/src/components/card/AGENTS.md +88 -0
  3. package/src/components/card/card-action.tsx +16 -0
  4. package/src/components/card/card-content.tsx +12 -0
  5. package/src/components/card/card-description.tsx +12 -0
  6. package/src/components/card/card-footer.tsx +30 -0
  7. package/src/components/card/card-header.tsx +45 -0
  8. package/src/components/card/card-title.tsx +25 -0
  9. package/src/components/card/card.context.tsx +64 -0
  10. package/src/components/card/card.tsx +94 -0
  11. package/src/components/card/card.types.ts +13 -0
  12. package/src/components/card/card.variants.test.ts +240 -0
  13. package/src/components/card/card.variants.ts +177 -0
  14. package/src/components/card/index.ts +15 -0
  15. package/src/components/chart/AGENTS.md +8 -0
  16. package/src/components/chart/chart.tsx +8 -1
  17. package/src/components/kpi/AGENTS.md +125 -0
  18. package/src/components/kpi/index.ts +45 -0
  19. package/src/components/kpi/kpi-action.tsx +12 -0
  20. package/src/components/kpi/kpi-content.tsx +35 -0
  21. package/src/components/kpi/kpi-footer.tsx +52 -0
  22. package/src/components/kpi/kpi-group.tsx +70 -0
  23. package/src/components/kpi/kpi-header.tsx +25 -0
  24. package/src/components/kpi/kpi-icon.tsx +28 -0
  25. package/src/components/kpi/kpi-sparkline.tsx +145 -0
  26. package/src/components/kpi/kpi-stat.tsx +20 -0
  27. package/src/components/kpi/kpi-title.tsx +19 -0
  28. package/src/components/kpi/kpi-trend.tsx +105 -0
  29. package/src/components/kpi/kpi-value.tsx +32 -0
  30. package/src/components/kpi/kpi.context.tsx +111 -0
  31. package/src/components/kpi/kpi.tsx +154 -0
  32. package/src/components/kpi/kpi.types.ts +9 -0
  33. package/src/components/kpi/kpi.variants.test.ts +428 -0
  34. package/src/components/kpi/kpi.variants.ts +347 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20260925064136",
3
+ "version": "0.1.0-alpha.20260925064741",
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",
@@ -47,6 +48,7 @@
47
48
  "./field": "./src/components/field/index.ts",
48
49
  "./icon": "./src/components/icon/index.ts",
49
50
  "./input": "./src/components/input/index.ts",
51
+ "./kpi": "./src/components/kpi/index.ts",
50
52
  "./label": "./src/components/label/index.ts",
51
53
  "./list-group": "./src/components/list-group/index.ts",
52
54
  "./meter": "./src/components/meter/index.ts",
@@ -95,7 +97,7 @@
95
97
  },
96
98
  "peerDependencies": {
97
99
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
98
- "@delacour/react-native-charts": "0.1.0-alpha.20260925064136",
100
+ "@delacour/react-native-charts": "0.1.0-alpha.20260925064741",
99
101
  "@gorhom/bottom-sheet": "^5.2.8",
100
102
  "@legendapp/list": ">=3.3",
101
103
  "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;