@delacour/react-native-ui 0.1.0-alpha.20260925063807 → 0.1.0-alpha.20260925064136
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/alert/AGENTS.md +96 -0
- package/src/components/alert/alert-action.tsx +17 -0
- package/src/components/alert/alert-close-button.tsx +50 -0
- package/src/components/alert/alert-content.tsx +15 -0
- package/src/components/alert/alert-description.tsx +16 -0
- package/src/components/alert/alert-indicator.tsx +59 -0
- package/src/components/alert/alert-title.tsx +18 -0
- package/src/components/alert/alert.context.tsx +69 -0
- package/src/components/alert/alert.tsx +155 -0
- package/src/components/alert/alert.types.ts +10 -0
- package/src/components/alert/alert.variants.test.ts +218 -0
- package/src/components/alert/alert.variants.ts +180 -0
- package/src/components/alert/index.ts +23 -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.20260925064136",
|
|
4
4
|
"description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"./styles/tokens": "./src/styles/tokens.css",
|
|
35
35
|
"./styles/theme": "./src/styles/theme.css",
|
|
36
36
|
"./accordion": "./src/components/accordion/index.ts",
|
|
37
|
+
"./alert": "./src/components/alert/index.ts",
|
|
37
38
|
"./avatar": "./src/components/avatar/index.ts",
|
|
38
39
|
"./badge": "./src/components/badge/index.ts",
|
|
39
40
|
"./bottom-sheet": "./src/components/bottom-sheet/index.ts",
|
|
@@ -94,7 +95,7 @@
|
|
|
94
95
|
},
|
|
95
96
|
"peerDependencies": {
|
|
96
97
|
"@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
|
|
97
|
-
"@delacour/react-native-charts": "0.1.0-alpha.
|
|
98
|
+
"@delacour/react-native-charts": "0.1.0-alpha.20260925064136",
|
|
98
99
|
"@gorhom/bottom-sheet": "^5.2.8",
|
|
99
100
|
"@legendapp/list": ">=3.3",
|
|
100
101
|
"expo-linear-gradient": ">=15",
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Alert
|
|
2
|
+
|
|
3
|
+
A status message on a `Surface`: a glyph picked from its status, a title, a
|
|
4
|
+
description and, optionally, an action row and a dismiss control. Compound root
|
|
5
|
+
plus `Alert.Indicator`, `Alert.Content`, `Alert.Title`, `Alert.Description`,
|
|
6
|
+
`Alert.Action` and `Alert.CloseButton`.
|
|
7
|
+
|
|
8
|
+
`import { Alert } from "@delacour/react-native-ui/alert";`
|
|
9
|
+
|
|
10
|
+
## Files
|
|
11
|
+
|
|
12
|
+
| File | What it holds |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| `index.ts` | → `@delacour/react-native-ui/alert` |
|
|
15
|
+
| `alert.tsx` | Root + the `Object.assign` compound surface |
|
|
16
|
+
| `alert-indicator.tsx` | `Alert.Indicator`, and the status → glyph map |
|
|
17
|
+
| `alert-content.tsx` | `Alert.Content` |
|
|
18
|
+
| `alert-title.tsx` | `Alert.Title` |
|
|
19
|
+
| `alert-description.tsx` | `Alert.Description` |
|
|
20
|
+
| `alert-action.tsx` | `Alert.Action` |
|
|
21
|
+
| `alert-close-button.tsx` | `Alert.CloseButton`, the dismiss pressable |
|
|
22
|
+
| `alert.context.tsx` | `AlertProvider`, `useAlert()`, `useAlertContext()`, `useAlertPart()` |
|
|
23
|
+
| `alert.types.ts` | `AlertSlotProps`, shared by `Content` and `Action` |
|
|
24
|
+
| `alert.variants.ts` | The slotted `tv()`, `ALERT_FOREGROUND_TOKEN`, `ALERT_SURFACE_PADDING` and three pure resolvers — no RN imports |
|
|
25
|
+
| `alert.variants.test.ts` | |
|
|
26
|
+
|
|
27
|
+
## Design
|
|
28
|
+
|
|
29
|
+
- **Two axes: `status` and `variant`.** `status` says what the message means —
|
|
30
|
+
`default`, `info`, `success`, `warning`, `destructive` — and `variant` says
|
|
31
|
+
how loudly: `soft` (the default) washes the surface in the status's `-soft`
|
|
32
|
+
fill, `surface` keeps a neutral fill and lets the glyph and title carry the
|
|
33
|
+
colour. **Sizes**: `sm`, `md`, `lg`, which move padding, gap, type and glyph
|
|
34
|
+
together. `destructive` rather than `danger`, per the package's token rule.
|
|
35
|
+
- **It is built on `Surface`, and it adds only what a surface lacks.** The
|
|
36
|
+
corner, the continuous curve, the padding (`ALERT_SURFACE_PADDING` maps each
|
|
37
|
+
size onto the surface's own step) and the neutral fill all come from
|
|
38
|
+
[Surface](../surface/AGENTS.md). `alertVariants`' root carries the row and the
|
|
39
|
+
tint, and a test asserts it sets no padding of its own — a second one would
|
|
40
|
+
fight the surface's through the merge.
|
|
41
|
+
- **Only a tinted alert names a fill.** `resolveAlertTinted` is true for `soft`
|
|
42
|
+
plus a status, and those four cells swap `bg-card` for `bg-X-soft` and clear
|
|
43
|
+
the card's hairline: a grey rule around a red wash reads as two components
|
|
44
|
+
stacked. Every other alert leaves the surface's `variant` undefined
|
|
45
|
+
(`resolveAlertSurfaceVariant`), so it steps from the plane it sits on — an
|
|
46
|
+
alert inside a card lands on `secondary` rather than vanishing into the card.
|
|
47
|
+
A tinted alert pins the surface to `default` instead, so what nests in it
|
|
48
|
+
steps from a card like anything else.
|
|
49
|
+
- **One colour token per status, not per status and variant.**
|
|
50
|
+
`ALERT_FOREGROUND_TOKEN` names `X-soft-foreground` for each status and the
|
|
51
|
+
page's `foreground` for `default`; those are tuned to read on the soft fill
|
|
52
|
+
and on the neutral fills alike. The title slot emits the same token as a
|
|
53
|
+
class and the root publishes it through `IconDefaultsProvider`, so the glyph
|
|
54
|
+
and the title are always one shade. A test pins the pair and asserts every
|
|
55
|
+
token exists in both themes. The classes are written out literally —
|
|
56
|
+
Tailwind's scanner is static, so a class assembled from the map at runtime
|
|
57
|
+
would never be compiled.
|
|
58
|
+
- **The description is always muted.** A long explanation under a red title
|
|
59
|
+
must not shout; the title carries the status.
|
|
60
|
+
- **`items-start`, and the indicator is as tall as the title's line.** A
|
|
61
|
+
description that wraps to four lines must not drag the glyph to its middle.
|
|
62
|
+
The indicator's height is the title's line height at each size (`h-5`/`h-6`/
|
|
63
|
+
`h-7` against `text-sm`/`base`/`lg`), and it centres the glyph in that box, so
|
|
64
|
+
the glyph sits level with the first line. Nothing else is a fixed height:
|
|
65
|
+
`Text` respects OS font scaling, and a fixed box would clip a wrapped title.
|
|
66
|
+
- **Glyphs differ in shape, not just colour.** `warning` is a triangle and
|
|
67
|
+
`destructive` a circle, so the two stay apart for anyone who cannot tell amber
|
|
68
|
+
from red. `default` shares `info`'s circle. The map lives in
|
|
69
|
+
`alert-indicator.tsx` because the glyphs are RN SVG components and
|
|
70
|
+
`alert.variants.ts` must stay importable from `bun test`.
|
|
71
|
+
- **The indicator is hidden from assistive technology.** The title says what
|
|
72
|
+
happened; "image" announced before it adds nothing. Children replace the
|
|
73
|
+
glyph and inherit the alert's icon size and colour — a `Spinner` for a
|
|
74
|
+
pending state needs nothing else.
|
|
75
|
+
- **Announced as an `alert`, and as a live region on Android.**
|
|
76
|
+
`resolveAlertLiveRegion` makes a `warning` or `destructive` alert assertive
|
|
77
|
+
and anything else polite. The root is not made `accessible`: grouping it
|
|
78
|
+
would swallow the close control and any action into one element.
|
|
79
|
+
- **Dismissal is controllable.** `isDismissible` composes an
|
|
80
|
+
`Alert.CloseButton` in at the end. Uncontrolled, the alert hides itself when
|
|
81
|
+
dismissed (`defaultOpen`); pass `isOpen` with `onOpenChange` to own it.
|
|
82
|
+
`useAlert().dismiss` is on the context so an action ("Got it") can close the
|
|
83
|
+
alert without the caller threading a setter down. A dismissed alert fades out
|
|
84
|
+
over 150 ms rather than vanishing — the one `Animated.View` wrapper exists for
|
|
85
|
+
that `exiting`, and a caller's `className` still reaches the surface.
|
|
86
|
+
- **The close glyph is muted, not the status colour.** The control is about the
|
|
87
|
+
alert rather than part of what it says, and a red cross beside a red title
|
|
88
|
+
reads as a second warning. It presses with `fade` — a spring on a glyph that
|
|
89
|
+
small is a jitter — and carries `hitSlop={10}` to reach the 44-point target.
|
|
90
|
+
- **Actions are the caller's.** `Alert.Action` is a wrapping row and styles
|
|
91
|
+
nothing in it; a `Button` in there is sized and painted however the caller
|
|
92
|
+
says. It wraps rather than overflowing, because two buttons and a long label
|
|
93
|
+
outgrow a phone's width.
|
|
94
|
+
- **Parts are composed, not configured.** There is no `title` or `icon` prop:
|
|
95
|
+
omit `Alert.Indicator` for a text-only alert, omit `Alert.Description` for a
|
|
96
|
+
one-liner. The same reason `Button` and `Badge` compose their icons.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import type { AlertSlotProps } from "./alert.types";
|
|
4
|
+
import { alertVariants } from "./alert.variants";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A wrapping row for the alert's buttons, placed under the description inside
|
|
8
|
+
* `Alert.Content`.
|
|
9
|
+
*
|
|
10
|
+
* Takes any children — a `Button`, a `Text.Link` — and styles none of them: an
|
|
11
|
+
* action is the caller's control, sized and painted the way they want it. Read
|
|
12
|
+
* `useAlert().dismiss` from inside to close the alert from an action.
|
|
13
|
+
*/
|
|
14
|
+
export function AlertAction({ className, ...props }: AlertSlotProps): ReactElement {
|
|
15
|
+
return <View className={alertVariants().action({ className })} {...props} />;
|
|
16
|
+
}
|
|
17
|
+
AlertAction.displayName = "DelacourUI.Alert.Action";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { IconCrossSmall } from "../../icons/central";
|
|
3
|
+
import { Icon } from "../icon";
|
|
4
|
+
import { Pressable, type PressableProps } from "../pressable";
|
|
5
|
+
import { useAlertPart } from "./alert.context";
|
|
6
|
+
import { alertVariants } from "./alert.variants";
|
|
7
|
+
|
|
8
|
+
export type AlertCloseButtonProps = Omit<PressableProps, "asChild" | "busy" | "children">;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The alert's trailing dismiss control.
|
|
12
|
+
*
|
|
13
|
+
* The root composes one in whenever `isDismissible` is set, so reach for this
|
|
14
|
+
* by hand only to place it somewhere else. Pressing it calls the alert's
|
|
15
|
+
* `dismiss` and then the caller's own `onPress`.
|
|
16
|
+
*
|
|
17
|
+
* The glyph is drawn in `muted-foreground` rather than the status colour: the
|
|
18
|
+
* control is about the alert, not part of what it says, and a red cross beside
|
|
19
|
+
* a red title reads as a second warning. `fade` rather than `scale` — a spring
|
|
20
|
+
* on a glyph this small reads as a jitter. `hitSlop` lifts the target to the
|
|
21
|
+
* 44-point minimum the glyph alone falls short of.
|
|
22
|
+
*/
|
|
23
|
+
export function AlertCloseButton({
|
|
24
|
+
accessibilityLabel = "Dismiss",
|
|
25
|
+
className,
|
|
26
|
+
feedback = "fade",
|
|
27
|
+
hitSlop = 10,
|
|
28
|
+
onPress,
|
|
29
|
+
...props
|
|
30
|
+
}: AlertCloseButtonProps): ReactElement {
|
|
31
|
+
const { size, dismiss } = useAlertPart("Alert.CloseButton");
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Pressable
|
|
35
|
+
accessibilityLabel={accessibilityLabel}
|
|
36
|
+
accessibilityRole="button"
|
|
37
|
+
className={alertVariants({ size }).closeButton({ className })}
|
|
38
|
+
feedback={feedback}
|
|
39
|
+
hitSlop={hitSlop}
|
|
40
|
+
onPress={() => {
|
|
41
|
+
dismiss();
|
|
42
|
+
onPress?.();
|
|
43
|
+
}}
|
|
44
|
+
{...props}
|
|
45
|
+
>
|
|
46
|
+
<Icon color="muted-foreground" icon={IconCrossSmall} />
|
|
47
|
+
</Pressable>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
AlertCloseButton.displayName = "DelacourUI.Alert.CloseButton";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import type { AlertSlotProps } from "./alert.types";
|
|
4
|
+
import { alertVariants } from "./alert.variants";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The column holding the title, the description and any action.
|
|
8
|
+
*
|
|
9
|
+
* `flex-1` with `min-w-0` is what keeps a long description wrapping inside the
|
|
10
|
+
* alert instead of pushing the close control off its edge.
|
|
11
|
+
*/
|
|
12
|
+
export function AlertContent({ className, ...props }: AlertSlotProps): ReactElement {
|
|
13
|
+
return <View className={alertVariants().content({ className })} {...props} />;
|
|
14
|
+
}
|
|
15
|
+
AlertContent.displayName = "DelacourUI.Alert.Content";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { Text, type TextPresetProps } from "../text";
|
|
3
|
+
import { useAlertPart } from "./alert.context";
|
|
4
|
+
import { alertVariants } from "./alert.variants";
|
|
5
|
+
|
|
6
|
+
export type AlertDescriptionProps = TextPresetProps;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The alert's body text — always muted, whatever the status, so a long
|
|
10
|
+
* explanation under a red title never shouts.
|
|
11
|
+
*/
|
|
12
|
+
export function AlertDescription({ className, ...props }: AlertDescriptionProps): ReactElement {
|
|
13
|
+
const { size } = useAlertPart("Alert.Description");
|
|
14
|
+
return <Text className={alertVariants({ size }).description({ className })} {...props} />;
|
|
15
|
+
}
|
|
16
|
+
AlertDescription.displayName = "DelacourUI.Alert.Description";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { IconCircleCheck, IconCircleInfo, IconExclamationCircle, IconExclamationTriangle } from "../../icons/central";
|
|
4
|
+
import { Icon, type IconComponent } from "../icon";
|
|
5
|
+
import { useAlertPart } from "./alert.context";
|
|
6
|
+
import { type AlertStatus, alertVariants } from "./alert.variants";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The glyph each status draws.
|
|
10
|
+
*
|
|
11
|
+
* `warning` and `destructive` differ in shape as well as colour — a triangle
|
|
12
|
+
* against a circle — so the two stay distinguishable to anyone who cannot tell
|
|
13
|
+
* amber from red. `default` shares `info`'s glyph: a neutral note is still a
|
|
14
|
+
* note, and a status of its own would need a meaning it does not have.
|
|
15
|
+
*
|
|
16
|
+
* Lives here rather than in `alert.variants.ts` because the glyphs are React
|
|
17
|
+
* Native SVG components, and that file must stay importable from `bun test`.
|
|
18
|
+
*/
|
|
19
|
+
const ALERT_GLYPHS: Record<AlertStatus, IconComponent> = {
|
|
20
|
+
default: IconCircleInfo,
|
|
21
|
+
info: IconCircleInfo,
|
|
22
|
+
success: IconCircleCheck,
|
|
23
|
+
warning: IconExclamationTriangle,
|
|
24
|
+
destructive: IconExclamationCircle,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type AlertIndicatorProps = ViewProps & {
|
|
28
|
+
className?: string;
|
|
29
|
+
/** Replaces the status glyph — a `Spinner`, an `Icon` of your own. It inherits the alert's icon size and colour. */
|
|
30
|
+
children?: ReactNode;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The alert's leading glyph, picked from its status.
|
|
35
|
+
*
|
|
36
|
+
* Carries the title's line height as its own height and centres the glyph in
|
|
37
|
+
* it, so the glyph sits level with the title's first line however far the
|
|
38
|
+
* description wraps.
|
|
39
|
+
*
|
|
40
|
+
* Hidden from assistive technology. The title says what happened, and a screen
|
|
41
|
+
* reader announcing "image" before it adds nothing. Children replace the glyph
|
|
42
|
+
* and inherit the alert's icon size and colour, so `<Spinner />` or
|
|
43
|
+
* `<Icon icon={IconCloud} />` needs nothing else.
|
|
44
|
+
*/
|
|
45
|
+
export function AlertIndicator({ className, children, ...props }: AlertIndicatorProps): ReactElement {
|
|
46
|
+
const { status, size } = useAlertPart("Alert.Indicator");
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<View
|
|
50
|
+
accessibilityElementsHidden
|
|
51
|
+
className={alertVariants({ size }).indicator({ className })}
|
|
52
|
+
importantForAccessibility="no-hide-descendants"
|
|
53
|
+
{...props}
|
|
54
|
+
>
|
|
55
|
+
{children ?? <Icon icon={ALERT_GLYPHS[status]} />}
|
|
56
|
+
</View>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
AlertIndicator.displayName = "DelacourUI.Alert.Indicator";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { Text, type TextPresetProps } from "../text";
|
|
3
|
+
import { useAlertPart } from "./alert.context";
|
|
4
|
+
import { alertVariants } from "./alert.variants";
|
|
5
|
+
|
|
6
|
+
export type AlertTitleProps = TextPresetProps;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The alert's heading, coloured by its status.
|
|
10
|
+
*
|
|
11
|
+
* The colour is the same token the indicator's glyph reads, so the two are
|
|
12
|
+
* always one shade — a test pins the pair.
|
|
13
|
+
*/
|
|
14
|
+
export function AlertTitle({ className, ...props }: AlertTitleProps): ReactElement {
|
|
15
|
+
const { status, size } = useAlertPart("Alert.Title");
|
|
16
|
+
return <Text className={alertVariants({ size, status }).title({ className })} {...props} />;
|
|
17
|
+
}
|
|
18
|
+
AlertTitle.displayName = "DelacourUI.Alert.Title";
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createContext, type ReactElement, type ReactNode, use } from "react";
|
|
2
|
+
import type { AlertSize, AlertStatus, AlertVariant } from "./alert.variants";
|
|
3
|
+
|
|
4
|
+
export type AlertContextValue = {
|
|
5
|
+
/** What the alert says about the thing it describes. */
|
|
6
|
+
status: AlertStatus;
|
|
7
|
+
/** How the alert's surface is painted. */
|
|
8
|
+
variant: AlertVariant;
|
|
9
|
+
/** Size of the alert. */
|
|
10
|
+
size: AlertSize;
|
|
11
|
+
/**
|
|
12
|
+
* Closes the alert — hides it when uncontrolled, and reports `false` through
|
|
13
|
+
* `onOpenChange` either way. Lets an action inside the alert ("Got it")
|
|
14
|
+
* dismiss it without the caller threading a setter down.
|
|
15
|
+
*/
|
|
16
|
+
dismiss: () => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const AlertContext = createContext<AlertContextValue | null>(null);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Supplies the enclosing alert's status, variant, size and `dismiss` to its
|
|
23
|
+
* subtree.
|
|
24
|
+
*
|
|
25
|
+
* Lives in its own module, importing nothing but `alert.variants`, so a part
|
|
26
|
+
* can read it without importing `./alert`. That import would close a cycle, and
|
|
27
|
+
* Metro serves a partially initialised module for a cycle — leaving the context
|
|
28
|
+
* `undefined` at import time and red-boxing the app on a cold start.
|
|
29
|
+
*/
|
|
30
|
+
export function AlertProvider({ value, children }: { value: AlertContextValue; children: ReactNode }): ReactElement {
|
|
31
|
+
return <AlertContext value={value}>{children}</AlertContext>;
|
|
32
|
+
}
|
|
33
|
+
AlertProvider.displayName = "DelacourUI.Alert.Provider";
|
|
34
|
+
|
|
35
|
+
/** The enclosing alert's context, or null outside an `<Alert>`. */
|
|
36
|
+
export function useAlertContext(): AlertContextValue | null {
|
|
37
|
+
return use(AlertContext);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reads the enclosing alert's status, variant, size and `dismiss`.
|
|
42
|
+
*
|
|
43
|
+
* Lets a custom child match the alert — or close it — without the alert passing
|
|
44
|
+
* props down. Throws outside an `<Alert>`; use {@link useAlertContext} where the
|
|
45
|
+
* enclosing alert is optional.
|
|
46
|
+
*/
|
|
47
|
+
export function useAlert(): AlertContextValue {
|
|
48
|
+
const context = useAlertContext();
|
|
49
|
+
if (!context) {
|
|
50
|
+
throw new Error("useAlert must be called inside an <Alert>.");
|
|
51
|
+
}
|
|
52
|
+
return context;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The enclosing alert's context, for a compound part that cannot work without
|
|
57
|
+
* one.
|
|
58
|
+
*
|
|
59
|
+
* Internal: deliberately not re-exported from `index.ts`. A caller outside the
|
|
60
|
+
* library wants {@link useAlert}, whose error message names the hook rather than
|
|
61
|
+
* a part.
|
|
62
|
+
*/
|
|
63
|
+
export function useAlertPart(component: string): AlertContextValue {
|
|
64
|
+
const context = useAlertContext();
|
|
65
|
+
if (!context) {
|
|
66
|
+
throw new Error(`${component} must be rendered inside an <Alert>.`);
|
|
67
|
+
}
|
|
68
|
+
return context;
|
|
69
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode, useCallback, useMemo } from "react";
|
|
2
|
+
import Animated, { FadeOut } from "react-native-reanimated";
|
|
3
|
+
import { useControllableState } from "../../hooks/use-controllable-state";
|
|
4
|
+
import { IconDefaultsProvider } from "../icon";
|
|
5
|
+
import { Surface, type SurfaceProps } from "../surface";
|
|
6
|
+
import { type AlertContextValue, AlertProvider } from "./alert.context";
|
|
7
|
+
import {
|
|
8
|
+
ALERT_FOREGROUND_TOKEN,
|
|
9
|
+
ALERT_SURFACE_PADDING,
|
|
10
|
+
type AlertSize,
|
|
11
|
+
type AlertStatus,
|
|
12
|
+
type AlertVariant,
|
|
13
|
+
alertVariants,
|
|
14
|
+
resolveAlertLiveRegion,
|
|
15
|
+
resolveAlertSurfaceVariant,
|
|
16
|
+
} from "./alert.variants";
|
|
17
|
+
import { AlertAction } from "./alert-action";
|
|
18
|
+
import { AlertCloseButton } from "./alert-close-button";
|
|
19
|
+
import { AlertContent } from "./alert-content";
|
|
20
|
+
import { AlertDescription } from "./alert-description";
|
|
21
|
+
import { AlertIndicator } from "./alert-indicator";
|
|
22
|
+
import { AlertTitle } from "./alert-title";
|
|
23
|
+
|
|
24
|
+
/** A dismissed alert fades rather than vanishing, so the layout below it has a beat to follow. */
|
|
25
|
+
const EXITING = FadeOut.duration(150);
|
|
26
|
+
|
|
27
|
+
export type AlertProps = Omit<SurfaceProps, "variant" | "padding"> & {
|
|
28
|
+
/** What the alert says: `default`, `info`, `success`, `warning` or `destructive`. Picks the glyph and the colour. */
|
|
29
|
+
status?: AlertStatus;
|
|
30
|
+
/** `soft` washes the surface in the status's colour; `surface` keeps the neutral fill and colours only the glyph and title. */
|
|
31
|
+
variant?: AlertVariant;
|
|
32
|
+
/** Padding, gap, type scale and glyph size together. */
|
|
33
|
+
size?: AlertSize;
|
|
34
|
+
/** Composes a trailing dismiss control in. */
|
|
35
|
+
isDismissible?: boolean;
|
|
36
|
+
/** Whether the alert is shown. Pass it to control the alert; omit it and the alert hides itself when dismissed. */
|
|
37
|
+
isOpen?: boolean;
|
|
38
|
+
/** Whether an uncontrolled alert starts shown. */
|
|
39
|
+
defaultOpen?: boolean;
|
|
40
|
+
/** Called with `false` when the alert is dismissed, controlled or not. */
|
|
41
|
+
onOpenChange?: (isOpen: boolean) => void;
|
|
42
|
+
/** Name a screen reader gives the dismiss control. Defaults to `Dismiss`. */
|
|
43
|
+
closeAccessibilityLabel?: string;
|
|
44
|
+
children?: ReactNode;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function AlertRoot({
|
|
48
|
+
status = "default",
|
|
49
|
+
variant = "soft",
|
|
50
|
+
size = "md",
|
|
51
|
+
isDismissible = false,
|
|
52
|
+
isOpen,
|
|
53
|
+
defaultOpen = true,
|
|
54
|
+
onOpenChange,
|
|
55
|
+
closeAccessibilityLabel,
|
|
56
|
+
className,
|
|
57
|
+
children,
|
|
58
|
+
...props
|
|
59
|
+
}: AlertProps): ReactElement | null {
|
|
60
|
+
const [open, setOpen] = useControllableState({ defaultValue: defaultOpen, onChange: onOpenChange, value: isOpen });
|
|
61
|
+
const dismiss = useCallback(() => setOpen(false), [setOpen]);
|
|
62
|
+
|
|
63
|
+
const context = useMemo<AlertContextValue>(
|
|
64
|
+
() => ({ dismiss, size, status, variant }),
|
|
65
|
+
[dismiss, size, status, variant]
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const slots = alertVariants({ size, status, variant });
|
|
69
|
+
|
|
70
|
+
// A glyph composed anywhere inside — the indicator's own, a `Spinner`, an
|
|
71
|
+
// `Icon` in an action — adopts the alert's step and its status's colour.
|
|
72
|
+
const iconClassName = slots.icon();
|
|
73
|
+
const iconColor = ALERT_FOREGROUND_TOKEN[status];
|
|
74
|
+
const iconDefaults = useMemo(() => ({ className: iconClassName, color: iconColor }), [iconClassName, iconColor]);
|
|
75
|
+
|
|
76
|
+
if (!open) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<AlertProvider value={context}>
|
|
82
|
+
<Animated.View exiting={EXITING}>
|
|
83
|
+
<Surface
|
|
84
|
+
accessibilityLiveRegion={resolveAlertLiveRegion(status)}
|
|
85
|
+
accessibilityRole="alert"
|
|
86
|
+
className={slots.root({ className })}
|
|
87
|
+
padding={ALERT_SURFACE_PADDING[size]}
|
|
88
|
+
variant={resolveAlertSurfaceVariant({ status, variant })}
|
|
89
|
+
{...props}
|
|
90
|
+
>
|
|
91
|
+
<IconDefaultsProvider value={iconDefaults}>
|
|
92
|
+
{children}
|
|
93
|
+
{isDismissible ? <AlertCloseButton accessibilityLabel={closeAccessibilityLabel} /> : null}
|
|
94
|
+
</IconDefaultsProvider>
|
|
95
|
+
</Surface>
|
|
96
|
+
</Animated.View>
|
|
97
|
+
</AlertProvider>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A status message — a glyph, a title, a description and, optionally, an
|
|
103
|
+
* action — drawn on a `Surface`.
|
|
104
|
+
*
|
|
105
|
+
* `status` says what the message means: `default`, `info`, `success`,
|
|
106
|
+
* `warning` or `destructive`. It picks the indicator's glyph and colours the
|
|
107
|
+
* glyph and the title from one token; the description stays muted whatever the
|
|
108
|
+
* status. `variant` says how loudly: `soft` washes the surface in the status's
|
|
109
|
+
* soft fill, `surface` keeps the neutral fill and lets the glyph and title carry
|
|
110
|
+
* the colour. A neutral or `surface` alert takes its fill from the surface
|
|
111
|
+
* ladder, so one inside a card steps off the card instead of vanishing into it.
|
|
112
|
+
*
|
|
113
|
+
* `isDismissible` composes a close control in. Uncontrolled, the alert hides
|
|
114
|
+
* itself when dismissed; pass `isOpen` and `onOpenChange` to own that state.
|
|
115
|
+
* `useAlert().dismiss` closes it from inside, for a "Got it" action.
|
|
116
|
+
*
|
|
117
|
+
* Announced as an `alert`, and as a live region on Android — assertive for a
|
|
118
|
+
* warning or a failure, polite for anything else.
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* <Alert status="warning">
|
|
122
|
+
* <Alert.Indicator />
|
|
123
|
+
* <Alert.Content>
|
|
124
|
+
* <Alert.Title>Card expiring</Alert.Title>
|
|
125
|
+
* <Alert.Description>The card ending 4242 expires next month.</Alert.Description>
|
|
126
|
+
* </Alert.Content>
|
|
127
|
+
* </Alert>
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* <Alert isDismissible status="destructive">
|
|
131
|
+
* <Alert.Indicator />
|
|
132
|
+
* <Alert.Content>
|
|
133
|
+
* <Alert.Title>Payment failed</Alert.Title>
|
|
134
|
+
* <Alert.Description>Your bank declined the charge.</Alert.Description>
|
|
135
|
+
* <Alert.Action>
|
|
136
|
+
* <Button onPress={retry} size="sm">Try again</Button>
|
|
137
|
+
* </Alert.Action>
|
|
138
|
+
* </Alert.Content>
|
|
139
|
+
* </Alert>
|
|
140
|
+
*/
|
|
141
|
+
export const Alert = Object.assign(AlertRoot, {
|
|
142
|
+
/** The leading status glyph, picked from `status`. Children replace it. */
|
|
143
|
+
Indicator: AlertIndicator,
|
|
144
|
+
/** The column holding the title, description and action; takes the remaining width. */
|
|
145
|
+
Content: AlertContent,
|
|
146
|
+
/** The heading, coloured by the status. */
|
|
147
|
+
Title: AlertTitle,
|
|
148
|
+
/** The body text, always muted. */
|
|
149
|
+
Description: AlertDescription,
|
|
150
|
+
/** A wrapping row of buttons under the description. */
|
|
151
|
+
Action: AlertAction,
|
|
152
|
+
/** The trailing dismiss control. Composed in automatically whenever `isDismissible` is set. */
|
|
153
|
+
CloseButton: AlertCloseButton,
|
|
154
|
+
displayName: "DelacourUI.Alert",
|
|
155
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ViewProps } from "react-native";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shape of an alert's layout parts.
|
|
5
|
+
*
|
|
6
|
+
* Shared by `Alert.Content` and `Alert.Action`, which are both a styled `View`
|
|
7
|
+
* and nothing else, so it lives in a leaf rather than in one of the two files
|
|
8
|
+
* arbitrarily.
|
|
9
|
+
*/
|
|
10
|
+
export type AlertSlotProps = ViewProps & { className?: string };
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
ALERT_FOREGROUND_TOKEN,
|
|
6
|
+
ALERT_SIZES,
|
|
7
|
+
ALERT_STATUSES,
|
|
8
|
+
ALERT_SURFACE_PADDING,
|
|
9
|
+
ALERT_TINTED_STATUSES,
|
|
10
|
+
ALERT_VARIANTS,
|
|
11
|
+
alertVariants,
|
|
12
|
+
resolveAlertLiveRegion,
|
|
13
|
+
resolveAlertSurfaceVariant,
|
|
14
|
+
resolveAlertTinted,
|
|
15
|
+
} from "./alert.variants";
|
|
16
|
+
|
|
17
|
+
/** Every root class string the variant function can produce, one per combination. */
|
|
18
|
+
function everyRoot(): string[] {
|
|
19
|
+
return ALERT_VARIANTS.flatMap((variant) =>
|
|
20
|
+
ALERT_STATUSES.flatMap((status) => ALERT_SIZES.map((size) => alertVariants({ size, status, variant }).root()))
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("alertVariants root slot", () => {
|
|
25
|
+
test("lays the indicator, content and close control out in a row, aligned to the top", () => {
|
|
26
|
+
for (const cls of everyRoot()) {
|
|
27
|
+
expect(cls).toContain("flex-row");
|
|
28
|
+
expect(cls).toContain("items-start");
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("tints a soft alert with its status's soft fill", () => {
|
|
33
|
+
expect(alertVariants({ status: "info", variant: "soft" }).root()).toContain("bg-info-soft");
|
|
34
|
+
expect(alertVariants({ status: "success", variant: "soft" }).root()).toContain("bg-success-soft");
|
|
35
|
+
expect(alertVariants({ status: "warning", variant: "soft" }).root()).toContain("bg-warning-soft");
|
|
36
|
+
expect(alertVariants({ status: "destructive", variant: "soft" }).root()).toContain("bg-destructive-soft");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// A tinted fill is its own edge. A grey card hairline around a red wash
|
|
40
|
+
// reads as two components stacked, not one.
|
|
41
|
+
test("a tinted alert hides the surface's hairline", () => {
|
|
42
|
+
for (const status of ALERT_TINTED_STATUSES) {
|
|
43
|
+
expect(alertVariants({ status, variant: "soft" }).root()).toContain("border-transparent");
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// The fill is the Surface's: a neutral alert, or any alert on the surface
|
|
48
|
+
// variant, names no background of its own, so the ladder decides it.
|
|
49
|
+
test("paints no fill of its own where the surface ladder decides", () => {
|
|
50
|
+
for (const status of ALERT_STATUSES) {
|
|
51
|
+
expect(alertVariants({ status, variant: "surface" }).root()).not.toMatch(/\bbg-/);
|
|
52
|
+
}
|
|
53
|
+
expect(alertVariants({ status: "default", variant: "soft" }).root()).not.toMatch(/\bbg-/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("gives every tinted status a distinct surface", () => {
|
|
57
|
+
const seen = new Set(ALERT_TINTED_STATUSES.map((status) => alertVariants({ status, variant: "soft" }).root()));
|
|
58
|
+
expect(seen.size).toBe(ALERT_TINTED_STATUSES.length);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("steps its gap up with size", () => {
|
|
62
|
+
expect(alertVariants({ size: "sm" }).root()).toContain("gap-2");
|
|
63
|
+
expect(alertVariants({ size: "md" }).root()).toContain("gap-3");
|
|
64
|
+
expect(alertVariants({ size: "lg" }).root()).toContain("gap-3.5");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Rule 1: a React Native View does not cascade colour to a Text descendant.
|
|
68
|
+
test("carries no text colour on the root", () => {
|
|
69
|
+
for (const cls of everyRoot()) {
|
|
70
|
+
expect(cls).not.toMatch(/\btext-/);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Surface owns the padding; a second one here would fight it through the merge.
|
|
75
|
+
test("sets no padding of its own", () => {
|
|
76
|
+
for (const cls of everyRoot()) {
|
|
77
|
+
expect(cls).not.toMatch(/(^|\s)p[xy]?-/);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("merges an incoming className last", () => {
|
|
82
|
+
expect(alertVariants().root({ className: "mt-4" })).toContain("mt-4");
|
|
83
|
+
expect(alertVariants({ status: "info" }).root({ className: "bg-card" })).not.toContain("bg-info-soft");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("alertVariants text slots", () => {
|
|
88
|
+
test("the title is coloured by the status, from the same token the icon reads", () => {
|
|
89
|
+
for (const status of ALERT_STATUSES) {
|
|
90
|
+
expect(alertVariants({ status }).title()).toContain(`text-${ALERT_FOREGROUND_TOKEN[status]}`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("the description is always muted", () => {
|
|
95
|
+
for (const status of ALERT_STATUSES) {
|
|
96
|
+
expect(alertVariants({ status }).description()).toContain("text-muted-foreground");
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("the title is heavier than the description", () => {
|
|
101
|
+
expect(alertVariants().title()).toContain("font-semibold");
|
|
102
|
+
expect(alertVariants().description()).not.toContain("font-semibold");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("steps both text sizes up with the alert", () => {
|
|
106
|
+
expect(alertVariants({ size: "sm" }).title()).toContain("text-sm");
|
|
107
|
+
expect(alertVariants({ size: "md" }).title()).toContain("text-base");
|
|
108
|
+
expect(alertVariants({ size: "lg" }).title()).toContain("text-lg");
|
|
109
|
+
expect(alertVariants({ size: "sm" }).description()).toContain("text-xs");
|
|
110
|
+
expect(alertVariants({ size: "md" }).description()).toContain("text-sm");
|
|
111
|
+
expect(alertVariants({ size: "lg" }).description()).toContain("text-base");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// A fixed height clips wrapped text at a large accessibility step.
|
|
115
|
+
test("no slot carries a fixed height", () => {
|
|
116
|
+
for (const size of ALERT_SIZES) {
|
|
117
|
+
const slots = alertVariants({ size });
|
|
118
|
+
for (const cls of [slots.root(), slots.content(), slots.title(), slots.description(), slots.action()]) {
|
|
119
|
+
expect(cls).not.toMatch(/(^|\s)h-/);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("alertVariants layout slots", () => {
|
|
126
|
+
// Without flex-1 a long description pushes the close control off the edge.
|
|
127
|
+
test("the content takes the remaining width", () => {
|
|
128
|
+
expect(alertVariants().content()).toContain("flex-1");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("the action row wraps rather than overflowing", () => {
|
|
132
|
+
expect(alertVariants().action()).toContain("flex-row");
|
|
133
|
+
expect(alertVariants().action()).toContain("flex-wrap");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("the icon indexes the shared icon scale at every size", () => {
|
|
137
|
+
expect(alertVariants({ size: "sm" }).icon()).toBe("size-icon-sm");
|
|
138
|
+
expect(alertVariants({ size: "md" }).icon()).toBe("size-icon-md");
|
|
139
|
+
expect(alertVariants({ size: "lg" }).icon()).toBe("size-icon-lg");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// The glyph sits level with the title's first line rather than with the top
|
|
143
|
+
// of the box, so the indicator carries the line height as its own height.
|
|
144
|
+
test("the indicator is as tall as the title's first line", () => {
|
|
145
|
+
expect(alertVariants({ size: "sm" }).indicator()).toContain("h-5");
|
|
146
|
+
expect(alertVariants({ size: "md" }).indicator()).toContain("h-6");
|
|
147
|
+
expect(alertVariants({ size: "lg" }).indicator()).toContain("h-7");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("ALERT_FOREGROUND_TOKEN", () => {
|
|
152
|
+
test("a status names its soft foreground; the neutral alert names the page's", () => {
|
|
153
|
+
expect(ALERT_FOREGROUND_TOKEN).toEqual({
|
|
154
|
+
default: "foreground",
|
|
155
|
+
info: "info-soft-foreground",
|
|
156
|
+
success: "success-soft-foreground",
|
|
157
|
+
warning: "warning-soft-foreground",
|
|
158
|
+
destructive: "destructive-soft-foreground",
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// A token no theme declares compiles to nothing and the icon falls back to
|
|
163
|
+
// whatever colour it inherited.
|
|
164
|
+
test("every token is declared in both themes and aliased", () => {
|
|
165
|
+
const css = readFileSync(join(import.meta.dir, "../../styles/theme.css"), "utf8");
|
|
166
|
+
for (const token of Object.values(ALERT_FOREGROUND_TOKEN)) {
|
|
167
|
+
expect(css).toContain(`--color-${token}: var(--${token})`);
|
|
168
|
+
expect(css.split(`--${token}:`).length - 1).toBeGreaterThanOrEqual(2);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("ALERT_SURFACE_PADDING", () => {
|
|
174
|
+
test("maps each size onto the surface's own padding step", () => {
|
|
175
|
+
expect(ALERT_SURFACE_PADDING).toEqual({ sm: "sm", md: "md", lg: "lg" });
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("resolveAlertTinted", () => {
|
|
180
|
+
test("only a soft alert with a status is tinted", () => {
|
|
181
|
+
for (const status of ALERT_TINTED_STATUSES) {
|
|
182
|
+
expect(resolveAlertTinted({ status, variant: "soft" })).toBe(true);
|
|
183
|
+
expect(resolveAlertTinted({ status, variant: "surface" })).toBe(false);
|
|
184
|
+
}
|
|
185
|
+
expect(resolveAlertTinted({ status: "default", variant: "soft" })).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("resolveAlertSurfaceVariant", () => {
|
|
190
|
+
// A tinted alert paints over a card-shaped surface, so what nests in it
|
|
191
|
+
// steps from `default` like anything else on a card.
|
|
192
|
+
test("a tinted alert pins the surface to the card", () => {
|
|
193
|
+
for (const status of ALERT_TINTED_STATUSES) {
|
|
194
|
+
expect(resolveAlertSurfaceVariant({ status, variant: "soft" })).toBe("default");
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Left undefined, the surface steps from whatever it sits in, so an alert
|
|
199
|
+
// inside a card never vanishes into the card.
|
|
200
|
+
test("an untinted alert leaves the fill to the ladder", () => {
|
|
201
|
+
expect(resolveAlertSurfaceVariant({ status: "default", variant: "soft" })).toBeUndefined();
|
|
202
|
+
for (const status of ALERT_STATUSES) {
|
|
203
|
+
expect(resolveAlertSurfaceVariant({ status, variant: "surface" })).toBeUndefined();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("resolveAlertLiveRegion", () => {
|
|
209
|
+
// Android announces a live region when its content changes. A failure
|
|
210
|
+
// interrupts; news waits its turn.
|
|
211
|
+
test("a warning or a failure interrupts, anything else waits", () => {
|
|
212
|
+
expect(resolveAlertLiveRegion("destructive")).toBe("assertive");
|
|
213
|
+
expect(resolveAlertLiveRegion("warning")).toBe("assertive");
|
|
214
|
+
expect(resolveAlertLiveRegion("default")).toBe("polite");
|
|
215
|
+
expect(resolveAlertLiveRegion("info")).toBe("polite");
|
|
216
|
+
expect(resolveAlertLiveRegion("success")).toBe("polite");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { VariantProps } from "tailwind-variants";
|
|
2
|
+
import { tv } from "../../lib/tv";
|
|
3
|
+
import type { SurfacePadding, SurfaceVariant } from "../surface/surface.variants";
|
|
4
|
+
|
|
5
|
+
/** What the alert says about the thing it describes. */
|
|
6
|
+
export const ALERT_STATUSES = ["default", "info", "success", "warning", "destructive"] as const;
|
|
7
|
+
|
|
8
|
+
/** The statuses that carry a colour — every one but `default`. */
|
|
9
|
+
export const ALERT_TINTED_STATUSES = ["info", "success", "warning", "destructive"] as const;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How the surface is painted. `soft` washes it in the status's soft fill;
|
|
13
|
+
* `surface` keeps the neutral fill the surface ladder gives it and colours only
|
|
14
|
+
* the indicator and the title.
|
|
15
|
+
*/
|
|
16
|
+
export const ALERT_VARIANTS = ["soft", "surface"] as const;
|
|
17
|
+
|
|
18
|
+
export const ALERT_SIZES = ["sm", "md", "lg"] as const;
|
|
19
|
+
|
|
20
|
+
export type AlertStatus = (typeof ALERT_STATUSES)[number];
|
|
21
|
+
export type AlertVariant = (typeof ALERT_VARIANTS)[number];
|
|
22
|
+
export type AlertSize = (typeof ALERT_SIZES)[number];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Theme token for the indicator's glyph and the title's colour.
|
|
26
|
+
*
|
|
27
|
+
* One token per status, not per status and variant: `X-soft-foreground` is
|
|
28
|
+
* tuned to read on `X-soft` and on the neutral fills alike, so the same shade
|
|
29
|
+
* holds on both variants. A test pins each entry to the class the `title` slot
|
|
30
|
+
* emits, so the glyph and the words beside it cannot drift apart.
|
|
31
|
+
*/
|
|
32
|
+
export const ALERT_FOREGROUND_TOKEN = {
|
|
33
|
+
default: "foreground",
|
|
34
|
+
info: "info-soft-foreground",
|
|
35
|
+
success: "success-soft-foreground",
|
|
36
|
+
warning: "warning-soft-foreground",
|
|
37
|
+
destructive: "destructive-soft-foreground",
|
|
38
|
+
} as const satisfies Record<AlertStatus, string>;
|
|
39
|
+
|
|
40
|
+
/** The surface's padding step for each alert size. */
|
|
41
|
+
export const ALERT_SURFACE_PADDING = {
|
|
42
|
+
sm: "sm",
|
|
43
|
+
md: "md",
|
|
44
|
+
lg: "lg",
|
|
45
|
+
} as const satisfies Record<AlertSize, SurfacePadding>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Styling for every part of an alert.
|
|
49
|
+
*
|
|
50
|
+
* The root is laid over a `Surface`, which owns the corner, the padding and —
|
|
51
|
+
* unless the alert is tinted — the fill. This `tv()` adds only what an alert
|
|
52
|
+
* has and a surface does not: the row, the tint and the text treatment.
|
|
53
|
+
*
|
|
54
|
+
* A tinted alert (`soft`, with a status) swaps the card's fill for the status's
|
|
55
|
+
* soft fill and clears the card's hairline. A grey hairline around a red wash
|
|
56
|
+
* reads as two components stacked; the wash is its own edge. Everything else
|
|
57
|
+
* names no background, so the surface ladder picks one and an alert inside a
|
|
58
|
+
* card steps off the card rather than vanishing into it.
|
|
59
|
+
*
|
|
60
|
+
* `items-start`, not `items-center`: a description that wraps to four lines
|
|
61
|
+
* must not drag the glyph down to its middle. The indicator instead carries the
|
|
62
|
+
* title's line height as its own height and centres the glyph in it, which
|
|
63
|
+
* puts the glyph level with the first line at every size.
|
|
64
|
+
*
|
|
65
|
+
* No slot carries a fixed height apart from that indicator — `Text` respects OS
|
|
66
|
+
* font scaling, and a fixed box would clip a wrapped title.
|
|
67
|
+
*
|
|
68
|
+
* The root holds no `text-*` (rule 1). The title takes its status's colour,
|
|
69
|
+
* the description is always muted, so a long explanation never shouts.
|
|
70
|
+
*
|
|
71
|
+
* Free of React Native imports so it stays unit-testable — `bun test` cannot
|
|
72
|
+
* parse React Native's Flow-typed source. See AGENTS.md.
|
|
73
|
+
*/
|
|
74
|
+
export const alertVariants = tv({
|
|
75
|
+
slots: {
|
|
76
|
+
root: "flex-row items-start",
|
|
77
|
+
indicator: "items-center justify-center",
|
|
78
|
+
content: "min-w-0 flex-1 gap-1",
|
|
79
|
+
title: "font-semibold",
|
|
80
|
+
description: "text-muted-foreground",
|
|
81
|
+
action: "mt-2 flex-row flex-wrap items-center gap-2",
|
|
82
|
+
closeButton: "items-center justify-center rounded-full",
|
|
83
|
+
/** Edge length an `Icon` composed into the alert inherits. */
|
|
84
|
+
icon: "",
|
|
85
|
+
},
|
|
86
|
+
variants: {
|
|
87
|
+
variant: {
|
|
88
|
+
soft: {},
|
|
89
|
+
surface: {},
|
|
90
|
+
},
|
|
91
|
+
// Written out rather than built from `ALERT_FOREGROUND_TOKEN`: Tailwind's
|
|
92
|
+
// scanner is static, and a class assembled at runtime is never compiled.
|
|
93
|
+
status: {
|
|
94
|
+
default: { title: "text-foreground" },
|
|
95
|
+
info: { title: "text-info-soft-foreground" },
|
|
96
|
+
success: { title: "text-success-soft-foreground" },
|
|
97
|
+
warning: { title: "text-warning-soft-foreground" },
|
|
98
|
+
destructive: { title: "text-destructive-soft-foreground" },
|
|
99
|
+
},
|
|
100
|
+
size: {
|
|
101
|
+
sm: {
|
|
102
|
+
root: "gap-2",
|
|
103
|
+
indicator: "h-5",
|
|
104
|
+
title: "text-sm",
|
|
105
|
+
description: "text-xs",
|
|
106
|
+
closeButton: "size-5",
|
|
107
|
+
icon: "size-icon-sm",
|
|
108
|
+
},
|
|
109
|
+
md: {
|
|
110
|
+
root: "gap-3",
|
|
111
|
+
indicator: "h-6",
|
|
112
|
+
title: "text-base",
|
|
113
|
+
description: "text-sm",
|
|
114
|
+
closeButton: "size-6",
|
|
115
|
+
icon: "size-icon-md",
|
|
116
|
+
},
|
|
117
|
+
lg: {
|
|
118
|
+
root: "gap-3.5",
|
|
119
|
+
indicator: "h-7",
|
|
120
|
+
title: "text-lg",
|
|
121
|
+
description: "text-base",
|
|
122
|
+
closeButton: "size-7",
|
|
123
|
+
icon: "size-icon-lg",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
compoundVariants: [
|
|
128
|
+
{ variant: "soft", status: "info", class: { root: "border-transparent bg-info-soft" } },
|
|
129
|
+
{ variant: "soft", status: "success", class: { root: "border-transparent bg-success-soft" } },
|
|
130
|
+
{ variant: "soft", status: "warning", class: { root: "border-transparent bg-warning-soft" } },
|
|
131
|
+
{ variant: "soft", status: "destructive", class: { root: "border-transparent bg-destructive-soft" } },
|
|
132
|
+
],
|
|
133
|
+
defaultVariants: {
|
|
134
|
+
variant: "soft",
|
|
135
|
+
status: "default",
|
|
136
|
+
size: "md",
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export type AlertVariantProps = VariantProps<typeof alertVariants>;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether the alert paints its status's soft fill over the surface.
|
|
144
|
+
*
|
|
145
|
+
* Only a `soft` alert with a status is: a neutral alert has no tint to paint,
|
|
146
|
+
* and a `surface` alert keeps the ladder's fill on purpose.
|
|
147
|
+
*/
|
|
148
|
+
export function resolveAlertTinted({ status, variant }: { status: AlertStatus; variant: AlertVariant }): boolean {
|
|
149
|
+
return variant === "soft" && status !== "default";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The `variant` the alert hands its `Surface`.
|
|
154
|
+
*
|
|
155
|
+
* A tinted alert pins it to `default`: the tint replaces the fill, and what
|
|
156
|
+
* nests inside then steps from the card like anything else on one. Every other
|
|
157
|
+
* alert leaves it `undefined`, so the surface reads the plane it sits on and
|
|
158
|
+
* steps to the next fill — an alert inside a card is told apart from the card
|
|
159
|
+
* with nothing said at the call site.
|
|
160
|
+
*/
|
|
161
|
+
export function resolveAlertSurfaceVariant({
|
|
162
|
+
status,
|
|
163
|
+
variant,
|
|
164
|
+
}: {
|
|
165
|
+
status: AlertStatus;
|
|
166
|
+
variant: AlertVariant;
|
|
167
|
+
}): SurfaceVariant | undefined {
|
|
168
|
+
return resolveAlertTinted({ status, variant }) ? "default" : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* How urgently Android's TalkBack announces a change inside the alert.
|
|
173
|
+
*
|
|
174
|
+
* A warning or a failure interrupts whatever is being read; anything else waits
|
|
175
|
+
* its turn. iOS has no live regions — the root's `alert` role is what VoiceOver
|
|
176
|
+
* reads there.
|
|
177
|
+
*/
|
|
178
|
+
export function resolveAlertLiveRegion(status: AlertStatus): "polite" | "assertive" {
|
|
179
|
+
return status === "destructive" || status === "warning" ? "assertive" : "polite";
|
|
180
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { Alert, type AlertProps } from "./alert";
|
|
2
|
+
export { type AlertContextValue, AlertProvider, useAlert, useAlertContext } from "./alert.context";
|
|
3
|
+
export type { AlertSlotProps } from "./alert.types";
|
|
4
|
+
export {
|
|
5
|
+
ALERT_FOREGROUND_TOKEN,
|
|
6
|
+
ALERT_SIZES,
|
|
7
|
+
ALERT_STATUSES,
|
|
8
|
+
ALERT_SURFACE_PADDING,
|
|
9
|
+
ALERT_TINTED_STATUSES,
|
|
10
|
+
ALERT_VARIANTS,
|
|
11
|
+
type AlertSize,
|
|
12
|
+
type AlertStatus,
|
|
13
|
+
type AlertVariant,
|
|
14
|
+
type AlertVariantProps,
|
|
15
|
+
alertVariants,
|
|
16
|
+
resolveAlertLiveRegion,
|
|
17
|
+
resolveAlertSurfaceVariant,
|
|
18
|
+
resolveAlertTinted,
|
|
19
|
+
} from "./alert.variants";
|
|
20
|
+
export type { AlertCloseButtonProps } from "./alert-close-button";
|
|
21
|
+
export type { AlertDescriptionProps } from "./alert-description";
|
|
22
|
+
export type { AlertIndicatorProps } from "./alert-indicator";
|
|
23
|
+
export type { AlertTitleProps } from "./alert-title";
|