@tanishqkancharla/maui 0.0.61 → 0.0.63
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/dist/components/Banner.d.ts +32 -0
- package/dist/components/Banner.js +106 -0
- package/dist/components/Button.js +2 -6
- package/dist/components/Checkbox.js +3 -2
- package/dist/components/Radio.js +3 -2
- package/dist/components/Switch.js +2 -1
- package/dist/maui.d.ts +1 -0
- package/dist/maui.js +1 -0
- package/dist/tokens/shadow.d.ts +3 -0
- package/dist/tokens/shadow.js +5 -0
- package/package.json +1 -1
- package/skills/maui/SKILL.md +2 -3
- package/skills/maui/references/components/banner.md +42 -0
- package/src/apps/JsxEditor/catalog.ts +17 -0
- package/src/apps/JsxEditor/evaluate.test.ts +12 -1
- package/src/components/Banner.test.tsx +52 -0
- package/src/components/Banner.tsx +184 -0
- package/src/components/Button.tsx +2 -7
- package/src/components/Checkbox.tsx +6 -1
- package/src/components/Radio.tsx +6 -1
- package/src/components/Switch.tsx +5 -0
- package/src/maui.ts +7 -0
- package/src/pages/BannerPage.tsx +214 -0
- package/src/pages/Maui.tsx +2 -0
- package/src/tokens/shadow.ts +6 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
2
|
+
export type BannerTone = "neutral" | "info" | "success" | "warning" | "danger";
|
|
3
|
+
export type BannerLayout = "inline" | "stacked";
|
|
4
|
+
export type BannerProps = Omit<ComponentPropsWithoutRef<"div">, "title"> & {
|
|
5
|
+
tone?: BannerTone;
|
|
6
|
+
/** Use stacked for multi-sentence messages or a title with supporting text. */
|
|
7
|
+
layout?: BannerLayout;
|
|
8
|
+
title?: ReactNode;
|
|
9
|
+
/** A decorative icon. Defaults to the tone's icon; false hides it. */
|
|
10
|
+
icon?: ReactNode | false;
|
|
11
|
+
actions?: ReactNode;
|
|
12
|
+
/** Adds a close button. The caller owns visibility. */
|
|
13
|
+
onDismiss?: () => void;
|
|
14
|
+
dismissLabel?: string;
|
|
15
|
+
};
|
|
16
|
+
/** Plain div parts; icon is decorative. Layout changes action placement, not semantics. */
|
|
17
|
+
export declare const bannerStyles: {
|
|
18
|
+
readonly root: (args_0?: {
|
|
19
|
+
tone?: BannerTone;
|
|
20
|
+
} | undefined) => import("purse-styles").StyleElement;
|
|
21
|
+
readonly icon: import("purse-styles").StyleElement;
|
|
22
|
+
readonly body: (args_0?: {
|
|
23
|
+
layout?: BannerLayout;
|
|
24
|
+
} | undefined) => import("purse-styles").StyleElement;
|
|
25
|
+
readonly content: import("purse-styles").StyleElement;
|
|
26
|
+
readonly title: import("purse-styles").StyleElement;
|
|
27
|
+
readonly description: import("purse-styles").StyleElement;
|
|
28
|
+
readonly actions: import("purse-styles").StyleElement;
|
|
29
|
+
readonly dismiss: import("purse-styles").StyleElement;
|
|
30
|
+
};
|
|
31
|
+
/** Persistent in-flow message. Set role="status" or role="alert" explicitly to announce it. */
|
|
32
|
+
export declare function Banner({ tone, layout, title, children, icon, actions, onDismiss, dismissLabel, className, ...props }: BannerProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { style, useStyles } from "purse-styles";
|
|
3
|
+
import { Button } from "./Button";
|
|
4
|
+
import { Text } from "./Text";
|
|
5
|
+
import { CheckCircle } from "../icons/CheckCircle";
|
|
6
|
+
import { Close } from "../icons/Close";
|
|
7
|
+
import { CircleX } from "../icons/CircleX";
|
|
8
|
+
import { Information } from "../icons/Information";
|
|
9
|
+
import { WarningTriangle } from "../icons/WarningTriangle";
|
|
10
|
+
import { colors } from "../tokens/colors";
|
|
11
|
+
import { radius } from "../tokens/radius";
|
|
12
|
+
import { controlSize } from "../tokens/sizing";
|
|
13
|
+
import { spacing } from "../tokens/spacing";
|
|
14
|
+
import { text } from "../tokens/text";
|
|
15
|
+
import { cls } from "../utils/cls";
|
|
16
|
+
import { memoize } from "../utils/memoize";
|
|
17
|
+
const palettes = {
|
|
18
|
+
neutral: "gray",
|
|
19
|
+
info: "blue",
|
|
20
|
+
success: "green",
|
|
21
|
+
warning: "amber",
|
|
22
|
+
danger: "red",
|
|
23
|
+
};
|
|
24
|
+
const icons = {
|
|
25
|
+
neutral: Information,
|
|
26
|
+
info: Information,
|
|
27
|
+
success: CheckCircle,
|
|
28
|
+
warning: WarningTriangle,
|
|
29
|
+
danger: CircleX,
|
|
30
|
+
};
|
|
31
|
+
const root = memoize(({ tone = "neutral" } = {}) => style(text({ size: "sm" }), radius.lg, spacing.padding({ all: 3 }), {
|
|
32
|
+
display: "flex",
|
|
33
|
+
alignItems: "flex-start",
|
|
34
|
+
gap: spacing.value(3),
|
|
35
|
+
minWidth: 0,
|
|
36
|
+
backgroundColor: colors[palettes[tone]][2],
|
|
37
|
+
border: `1px solid ${colors[palettes[tone]][6]}`,
|
|
38
|
+
// Give text and icons the same inset beside standard-height controls.
|
|
39
|
+
"--banner-text-inset": `max(0px, calc((${controlSize.height} - 1lh) / 2))`,
|
|
40
|
+
"--banner-icon-color": colors[palettes[tone]][11],
|
|
41
|
+
}));
|
|
42
|
+
const icon = style({
|
|
43
|
+
flex: "0 0 auto",
|
|
44
|
+
width: "1lh",
|
|
45
|
+
height: "1lh",
|
|
46
|
+
marginBlockStart: "var(--banner-text-inset)",
|
|
47
|
+
color: "var(--banner-icon-color)",
|
|
48
|
+
display: "flex",
|
|
49
|
+
alignItems: "center",
|
|
50
|
+
justifyContent: "center",
|
|
51
|
+
"& > svg": { width: "100%", height: "100%" },
|
|
52
|
+
});
|
|
53
|
+
const body = memoize(({ layout = "inline" } = {}) => style({
|
|
54
|
+
display: "flex",
|
|
55
|
+
flex: "1 1 auto",
|
|
56
|
+
minWidth: 0,
|
|
57
|
+
flexDirection: layout === "stacked" ? "column" : "row",
|
|
58
|
+
flexWrap: "wrap",
|
|
59
|
+
alignItems: "flex-start",
|
|
60
|
+
gap: spacing.value(4),
|
|
61
|
+
"& > :first-child": {
|
|
62
|
+
flex: layout === "inline" ? "1 1 12rem" : "0 1 auto",
|
|
63
|
+
},
|
|
64
|
+
}));
|
|
65
|
+
const content = style({
|
|
66
|
+
minWidth: 0,
|
|
67
|
+
maxWidth: "100%",
|
|
68
|
+
paddingBlock: "var(--banner-text-inset)",
|
|
69
|
+
overflowWrap: "anywhere",
|
|
70
|
+
});
|
|
71
|
+
const title = style({ display: "block" });
|
|
72
|
+
const description = style({ display: "block" });
|
|
73
|
+
const actions = style({
|
|
74
|
+
display: "flex",
|
|
75
|
+
alignItems: "center",
|
|
76
|
+
justifyContent: "flex-end",
|
|
77
|
+
flexWrap: "wrap",
|
|
78
|
+
gap: spacing.value(4),
|
|
79
|
+
maxWidth: "100%",
|
|
80
|
+
marginInlineStart: "auto",
|
|
81
|
+
});
|
|
82
|
+
const dismiss = style({ flexShrink: 0 });
|
|
83
|
+
/** Plain div parts; icon is decorative. Layout changes action placement, not semantics. */
|
|
84
|
+
export const bannerStyles = {
|
|
85
|
+
root,
|
|
86
|
+
icon,
|
|
87
|
+
body,
|
|
88
|
+
content,
|
|
89
|
+
title,
|
|
90
|
+
description,
|
|
91
|
+
actions,
|
|
92
|
+
dismiss,
|
|
93
|
+
};
|
|
94
|
+
/** Persistent in-flow message. Set role="status" or role="alert" explicitly to announce it. */
|
|
95
|
+
export function Banner({ tone = "neutral", layout = "inline", title, children, icon, actions, onDismiss, dismissLabel = "Dismiss notification", className, ...props }) {
|
|
96
|
+
const rootClassName = useStyles(bannerStyles.root({ tone }));
|
|
97
|
+
const iconClassName = useStyles(bannerStyles.icon);
|
|
98
|
+
const bodyClassName = useStyles(bannerStyles.body({ layout }));
|
|
99
|
+
const contentClassName = useStyles(bannerStyles.content);
|
|
100
|
+
const titleClassName = useStyles(bannerStyles.title);
|
|
101
|
+
const descriptionClassName = useStyles(bannerStyles.description, title != null && style({ color: colors.gray[11] }));
|
|
102
|
+
const actionsClassName = useStyles(bannerStyles.actions);
|
|
103
|
+
const dismissClassName = useStyles(bannerStyles.dismiss);
|
|
104
|
+
const Icon = icons[tone];
|
|
105
|
+
return (_jsxs("div", { ...props, className: cls(rootClassName, className), children: [icon !== false && (_jsx("div", { className: iconClassName, "aria-hidden": "true", children: icon ?? _jsx(Icon, {}) })), _jsxs("div", { className: bodyClassName, children: [_jsxs("div", { className: contentClassName, children: [title != null && (_jsx(Text, { size: "sm", fontWeight: 500, className: titleClassName, children: title })), children != null && (_jsx("div", { className: descriptionClassName, children: children }))] }), actions != null && actions !== false && (_jsx("div", { className: actionsClassName, children: actions }))] }), onDismiss && (_jsx(Button, { variant: "quiet", variantColor: palettes[tone], "aria-label": dismissLabel, onPress: onDismiss, className: dismissClassName, children: _jsx(Close, { size: "sm" }) }))] }));
|
|
106
|
+
}
|
|
@@ -9,7 +9,7 @@ import { backgroundColor, surfaceMixPercent, surfaceWash, } from "../tokens/back
|
|
|
9
9
|
import { colors } from "../tokens/colors";
|
|
10
10
|
import { focusRing } from "../tokens/focusRing";
|
|
11
11
|
import { radius } from "../tokens/radius";
|
|
12
|
-
import { shadowVars } from "../tokens/shadow";
|
|
12
|
+
import { shadowVars, tintedControl } from "../tokens/shadow";
|
|
13
13
|
import { controlSize, iconSizeValues } from "../tokens/sizing";
|
|
14
14
|
import { spacing } from "../tokens/spacing";
|
|
15
15
|
import { text } from "../tokens/text";
|
|
@@ -146,10 +146,6 @@ function onSolidText(color) {
|
|
|
146
146
|
function fillShade(color, lightnessOffset, alpha = 1) {
|
|
147
147
|
return `oklch(from ${color} calc(l - ${lightnessOffset}) c h / ${alpha})`;
|
|
148
148
|
}
|
|
149
|
-
/** Dark-only top highlight, a fill-tinted lift (half strength in dark mode), and an edge painted beneath the lift: the fill's alpha step 6 in light mode, a near-black tint of the fill in dark mode. */
|
|
150
|
-
function primaryButtonShadow(color, lightEdge) {
|
|
151
|
-
return `inset 0 .5px light-dark(transparent, rgb(255 255 255 / 0.15)), 0 1px 2px light-dark(${fillShade(color, 0, 0.35)}, ${fillShade(color, 0, 0.18)}), 0 3px 8px -3px light-dark(${fillShade(color, 0, 0.6)}, ${fillShade(color, 0, 0.3)}), 0 0 0 .5px light-dark(${lightEdge}, oklch(from ${color} 0.18 calc(c * 0.5) h))`;
|
|
152
|
-
}
|
|
153
149
|
/**
|
|
154
150
|
* Diagonal from a slightly darker, more saturated, warmer-rotated fill to the
|
|
155
151
|
* fill. The offsets are the OKLCH delta between Campsite's #FB432C → #FF591E.
|
|
@@ -160,7 +156,7 @@ function primaryButtonGradient(color) {
|
|
|
160
156
|
/** Hover lightens the top of the gradient instead of replacing the fill. */
|
|
161
157
|
const primaryHoverSheen = `linear-gradient(to bottom, rgb(255 255 255 / 0.12), rgb(255 255 255 / 0))`;
|
|
162
158
|
const primaryClass = memoize((fill, textColor, lightEdge) => {
|
|
163
|
-
const primaryShadow =
|
|
159
|
+
const primaryShadow = tintedControl(fill, lightEdge);
|
|
164
160
|
const gradient = primaryButtonGradient(fill);
|
|
165
161
|
return style(buttonBaseClass, focusRing("&:focus-visible", primaryShadow), {
|
|
166
162
|
color: textColor,
|
|
@@ -27,11 +27,11 @@ const check = style(motion.standard("opacity", "transform"), {
|
|
|
27
27
|
height: checkboxSize.checkmark,
|
|
28
28
|
paddingTop: "0.5px",
|
|
29
29
|
paddingLeft: "0.5px",
|
|
30
|
-
fill:
|
|
30
|
+
fill: "white",
|
|
31
31
|
opacity: 0,
|
|
32
32
|
transform: "scale(0)",
|
|
33
33
|
});
|
|
34
|
-
const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-focus-visible] ${box}`, shadowVars.control), {
|
|
34
|
+
const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-focus-visible] ${box}`, shadowVars.control), focusRing(`&[data-selected][data-focus-visible] ${box}`, shadowVars.selectedControl), {
|
|
35
35
|
position: "relative",
|
|
36
36
|
width: "fit-content",
|
|
37
37
|
minHeight: controlSize.minTarget,
|
|
@@ -41,6 +41,7 @@ const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-foc
|
|
|
41
41
|
},
|
|
42
42
|
[`&[data-selected] ${box}`]: {
|
|
43
43
|
backgroundColor: colors.accent[9],
|
|
44
|
+
boxShadow: shadowVars.selectedControl,
|
|
44
45
|
},
|
|
45
46
|
[`&[data-selected][data-hovered] ${box}`]: {
|
|
46
47
|
backgroundColor: surfaceWash(colors.gray[12], surfaceMixPercent.hover, colors.accent[9]),
|
package/dist/components/Radio.js
CHANGED
|
@@ -22,10 +22,10 @@ const dot = style(radius.circle, motion.standard("transform"), {
|
|
|
22
22
|
left: "4px",
|
|
23
23
|
width: "6px",
|
|
24
24
|
height: "6px",
|
|
25
|
-
backgroundColor:
|
|
25
|
+
backgroundColor: "white",
|
|
26
26
|
transform: "scale(0)",
|
|
27
27
|
});
|
|
28
|
-
const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-focus-visible] ${circle}`, shadowVars.control), {
|
|
28
|
+
const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-focus-visible] ${circle}`, shadowVars.control), focusRing(`&[data-selected][data-focus-visible] ${circle}`, shadowVars.selectedControl), {
|
|
29
29
|
position: "relative",
|
|
30
30
|
width: "fit-content",
|
|
31
31
|
[`&[data-hovered] ${circle}`]: {
|
|
@@ -33,6 +33,7 @@ const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-foc
|
|
|
33
33
|
},
|
|
34
34
|
[`&[data-selected] ${circle}`]: {
|
|
35
35
|
backgroundColor: colors.accent[9],
|
|
36
|
+
boxShadow: shadowVars.selectedControl,
|
|
36
37
|
},
|
|
37
38
|
[`&[data-selected][data-hovered] ${circle}`]: {
|
|
38
39
|
backgroundColor: surfaceWash(colors.gray[12], surfaceMixPercent.hover, colors.accent[9]),
|
|
@@ -42,7 +42,7 @@ const thumb = style(radius.circle, motion.standard("transform"), {
|
|
|
42
42
|
backgroundColor: "#ffffff",
|
|
43
43
|
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.16)",
|
|
44
44
|
});
|
|
45
|
-
const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-focus-visible] ${track}`, shadowVars.control), {
|
|
45
|
+
const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-focus-visible] ${track}`, shadowVars.control), focusRing(`&[data-selected][data-focus-visible] ${track}`, shadowVars.selectedControl), {
|
|
46
46
|
position: "relative",
|
|
47
47
|
width: "fit-content",
|
|
48
48
|
minHeight: controlSize.minTarget,
|
|
@@ -51,6 +51,7 @@ const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-foc
|
|
|
51
51
|
},
|
|
52
52
|
[`&[data-selected] ${track}`]: {
|
|
53
53
|
backgroundColor: colors.accent[9],
|
|
54
|
+
boxShadow: shadowVars.selectedControl,
|
|
54
55
|
},
|
|
55
56
|
[`&[data-selected][data-hovered] ${track}`]: {
|
|
56
57
|
backgroundColor: switchOnHover,
|
package/dist/maui.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export { prose, proseRhythm, proseHtml, proseStreamingMarkers, type ProseSize, }
|
|
|
20
20
|
export { visuallyHidden } from "./tokens/visuallyHidden";
|
|
21
21
|
export { Avatar, avatarStyles } from "./components/Avatar";
|
|
22
22
|
export { Badge, badgeStyles } from "./components/Badge";
|
|
23
|
+
export { Banner, bannerStyles, type BannerProps, type BannerTone, type BannerLayout, } from "./components/Banner";
|
|
23
24
|
export { LogoIcon, type LogoIconProps } from "./components/LogoIcon";
|
|
24
25
|
export { Button, buttonStyles, useButton, type ButtonCssColor, type ButtonProps, type ButtonSize, type ButtonVariant, type ButtonVariantColor, } from "./components/Button";
|
|
25
26
|
export { TabBar, tabBarStyles, type TabBarItem, type TabBarProps, } from "./components/TabBar";
|
package/dist/maui.js
CHANGED
|
@@ -23,6 +23,7 @@ export { visuallyHidden } from "./tokens/visuallyHidden";
|
|
|
23
23
|
// Components
|
|
24
24
|
export { Avatar, avatarStyles } from "./components/Avatar";
|
|
25
25
|
export { Badge, badgeStyles } from "./components/Badge";
|
|
26
|
+
export { Banner, bannerStyles, } from "./components/Banner";
|
|
26
27
|
export { LogoIcon } from "./components/LogoIcon";
|
|
27
28
|
export { Button, buttonStyles, useButton, } from "./components/Button";
|
|
28
29
|
export { TabBar, tabBarStyles, } from "./components/TabBar";
|
package/dist/tokens/shadow.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
/** Dark-only top highlight, fill-tinted lift, and a 0.5px edge beneath the lift. */
|
|
2
|
+
export declare function tintedControl(color: string, lightEdge: string): string;
|
|
1
3
|
export declare const shadowVars: import("purse-styles").VariableGroup<{
|
|
2
4
|
readonly control: {
|
|
3
5
|
readonly default: `0 .5px 1px #00000018, 0 0 4px -1px #0001, 0 0 0 .5px ${string}`;
|
|
4
6
|
readonly ":root[data-theme=\"dark\"]": "inset 0 .5px rgb(255 255 255 / 0.09), 0 1px .5px #00000018, 0 0 3px -1px #0005, 0 0 0 .5px rgb(0 0 0 / 0.8)";
|
|
5
7
|
};
|
|
8
|
+
readonly selectedControl: string;
|
|
6
9
|
readonly subtle: `rgba(var(--${string}), 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, var(--${string})) 0px 1px 1px -0.5px, rgba(0, 0, 0, var(--${string})) 0px 3px 3px -1.5px`;
|
|
7
10
|
readonly medium: `rgba(var(--${string}), 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, var(--${string})) 0px 1px 1px -0.5px, rgba(0, 0, 0, var(--${string})) 0px 3px 3px -1.5px, rgba(0, 0, 0, var(--${string})) 0px 6px 6px -3px`;
|
|
8
11
|
readonly strong: `rgba(var(--${string}), 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, var(--${string})) 0px 1px 1px -0.5px, rgba(0, 0, 0, var(--${string})) 0px 3px 3px -1.5px, rgba(0, 0, 0, var(--${string})) 0px 6px 6px -3px, rgba(0, 0, 0, calc(var(--${string}) * 0.67)) 0px 12px 12px -6px, rgba(0, 0, 0, calc(var(--${string}) * 0.67)) 0px 24px 24px -12px`;
|
package/dist/tokens/shadow.js
CHANGED
|
@@ -13,6 +13,10 @@ const shadowParams = defineVars({
|
|
|
13
13
|
});
|
|
14
14
|
const rgb = shadowParams.foregroundRgb;
|
|
15
15
|
const blur = shadowParams.blurOpacity;
|
|
16
|
+
/** Dark-only top highlight, fill-tinted lift, and a 0.5px edge beneath the lift. */
|
|
17
|
+
export function tintedControl(color, lightEdge) {
|
|
18
|
+
return `inset 0 .5px light-dark(transparent, rgb(255 255 255 / 0.15)), 0 1px 2px light-dark(oklch(from ${color} l c h / 0.35), oklch(from ${color} l c h / 0.18)), 0 3px 8px -3px light-dark(oklch(from ${color} l c h / 0.6), oklch(from ${color} l c h / 0.3)), 0 0 0 .5px light-dark(${lightEdge}, oklch(from ${color} 0.18 calc(c * 0.5) h))`;
|
|
19
|
+
}
|
|
16
20
|
export const shadowVars = defineVars({
|
|
17
21
|
control: {
|
|
18
22
|
// 0.5px edge listed last, so it paints beneath the drop: light reads as a
|
|
@@ -20,6 +24,7 @@ export const shadowVars = defineVars({
|
|
|
20
24
|
default: `0 .5px 1px #00000018, 0 0 4px -1px #0001, 0 0 0 .5px ${colors.grayAlpha[6]}`,
|
|
21
25
|
[DARK_THEME]: `inset 0 .5px rgb(255 255 255 / 0.09), 0 1px .5px #00000018, 0 0 3px -1px #0005, 0 0 0 .5px rgb(0 0 0 / 0.8)`,
|
|
22
26
|
},
|
|
27
|
+
selectedControl: tintedControl(colors.accent[9], colors.accentAlpha[6]),
|
|
23
28
|
subtle: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px`,
|
|
24
29
|
medium: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px, rgba(0, 0, 0, ${blur}) 0px 6px 6px -3px`,
|
|
25
30
|
strong: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px, rgba(0, 0, 0, ${blur}) 0px 6px 6px -3px, rgba(0, 0, 0, calc(${blur} * 0.67)) 0px 12px 12px -6px, rgba(0, 0, 0, calc(${blur} * 0.67)) 0px 24px 24px -12px`,
|
package/package.json
CHANGED
package/skills/maui/SKILL.md
CHANGED
|
@@ -5,10 +5,8 @@ description: Conventions for installing, updating, and consuming the Maui design
|
|
|
5
5
|
|
|
6
6
|
# Maui
|
|
7
7
|
|
|
8
|
-
Read the reference for the work you are doing:
|
|
9
|
-
|
|
10
8
|
- When installing or updating the Maui package, read [installing_maui.md](references/installing_maui.md).
|
|
11
|
-
-
|
|
9
|
+
- For Maui UI work, read [design_conventions.md](references/design_conventions.md) and all matching references below.
|
|
12
10
|
|
|
13
11
|
Use Maui components. When one can't do what you need, build from the matching `react-aria-components` element with that component's exported styles (`buttonStyles`, `listBoxStyles`, …).
|
|
14
12
|
|
|
@@ -24,6 +22,7 @@ Use Maui components. When one can't do what you need, build from the matching `r
|
|
|
24
22
|
| ---------------- | -------------------------------------------------------------------------------------------- |
|
|
25
23
|
| Avatar | [avatar.md](references/components/avatar.md) |
|
|
26
24
|
| Badge | [badge.md](references/components/badge.md) |
|
|
25
|
+
| Banner | [banner.md](references/components/banner.md) |
|
|
27
26
|
| Buttons | [buttons.md](references/components/buttons.md) (`Button`, `Overlay`, `Dialog`) |
|
|
28
27
|
| Tab bar | [tab-bar.md](references/components/tab-bar.md) |
|
|
29
28
|
| Drawer | [drawer.md](references/components/drawer.md) |
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Banner
|
|
2
|
+
|
|
3
|
+
Gallery: `/components/banner`. Import `Banner` from `"maui"`.
|
|
4
|
+
|
|
5
|
+
Persistent in-flow message. Use `layout="inline"` (default) for one sentence,
|
|
6
|
+
even when it wraps. Always use `layout="stacked"` for multiple sentences or a
|
|
7
|
+
title with supporting text. Choose layout explicitly; Banner does not parse copy.
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
<Banner
|
|
11
|
+
tone="info"
|
|
12
|
+
actions={<Button variant="quiet" variantColor="blue" onPress={reload}>Reload</Button>}
|
|
13
|
+
>
|
|
14
|
+
A new version is available.
|
|
15
|
+
</Banner>
|
|
16
|
+
|
|
17
|
+
<Banner
|
|
18
|
+
tone="warning"
|
|
19
|
+
layout="stacked"
|
|
20
|
+
title="Your connection was interrupted."
|
|
21
|
+
actions={<Button variant="quiet" variantColor="amber" onPress={reconnect}>Reconnect</Button>}
|
|
22
|
+
>
|
|
23
|
+
Changes are saved on this device. Reconnect to sync.
|
|
24
|
+
</Banner>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- `tone`: `neutral` (default), `info`, `success`, `warning`, `danger`.
|
|
28
|
+
- Compose `actions` with quiet buttons in the matching palette: gray, blue, green,
|
|
29
|
+
amber, red respectively. Banner does not restyle supplied actions. Stacked and
|
|
30
|
+
wrapped actions align right.
|
|
31
|
+
- `title` and children accept React nodes. `icon` defaults to the tone's icon;
|
|
32
|
+
supply a decorative SVG or `false` to hide it. Icons match the first line height.
|
|
33
|
+
Icon-to-text gap and outer padding share spacing step 3 (6px; 8px at large scale).
|
|
34
|
+
- `onDismiss` adds a close button; caller owns visibility. `dismissLabel` defaults
|
|
35
|
+
to `"Dismiss notification"`. No timer or automatic dismissal.
|
|
36
|
+
- No automatic announcements. Set `role="status"` for live updates or
|
|
37
|
+
`role="alert"` for urgent failures; tone does not determine urgency.
|
|
38
|
+
|
|
39
|
+
## Styles
|
|
40
|
+
|
|
41
|
+
**Banner** — `bannerStyles`: `root({ tone })`, `icon`, `body({ layout })`, `content`,
|
|
42
|
+
`title`, `description`, `actions`, `dismiss`. Plain div parts; icon is decorative.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Avatar } from "../../components/Avatar"
|
|
2
2
|
import { Badge } from "../../components/Badge"
|
|
3
|
+
import { Banner } from "../../components/Banner"
|
|
3
4
|
import { Button } from "../../components/Button"
|
|
4
5
|
import { Checkbox } from "../../components/Checkbox"
|
|
5
6
|
import { Code, Kbd } from "../../components/Code"
|
|
@@ -337,6 +338,21 @@ export const catalog: CatalogComponent[] = [
|
|
|
337
338
|
html: true,
|
|
338
339
|
attributes: [],
|
|
339
340
|
},
|
|
341
|
+
{
|
|
342
|
+
name: "Banner",
|
|
343
|
+
info: "Persistent in-flow message. Inline or stacked actions; caller owns dismissal. Icons match the first line height.",
|
|
344
|
+
html: true,
|
|
345
|
+
attributes: [
|
|
346
|
+
{ name: "tone", values: ["neutral", "info", "success", "warning", "danger"] },
|
|
347
|
+
{ name: "layout", values: ["inline", "stacked"] },
|
|
348
|
+
{ name: "title" },
|
|
349
|
+
{ name: "icon" },
|
|
350
|
+
{ name: "actions" },
|
|
351
|
+
{ name: "onDismiss" },
|
|
352
|
+
{ name: "dismissLabel" },
|
|
353
|
+
{ name: "role", values: ["status", "alert"] },
|
|
354
|
+
],
|
|
355
|
+
},
|
|
340
356
|
{
|
|
341
357
|
name: "Avatar",
|
|
342
358
|
info: "Initials avatar from a name.",
|
|
@@ -569,6 +585,7 @@ export const catalog: CatalogComponent[] = [
|
|
|
569
585
|
export const previewScope: Record<string, unknown> = {
|
|
570
586
|
Avatar,
|
|
571
587
|
Badge,
|
|
588
|
+
Banner,
|
|
572
589
|
Button,
|
|
573
590
|
Checkbox,
|
|
574
591
|
Code,
|
|
@@ -3,10 +3,21 @@ import { evaluateJsx } from "./evaluate"
|
|
|
3
3
|
|
|
4
4
|
describe("evaluateJsx", () => {
|
|
5
5
|
test("evaluates Flex justifyContent and background", () => {
|
|
6
|
-
const result =
|
|
6
|
+
const result =
|
|
7
|
+
evaluateJsx(`<Flex row alignItems="center" justifyContent="between" background="element" p={4} radius="lg">
|
|
7
8
|
<Text>Left</Text>
|
|
8
9
|
<Text>Right</Text>
|
|
9
10
|
</Flex>`)
|
|
10
11
|
expect(result.ok).toBe(true)
|
|
11
12
|
})
|
|
13
|
+
|
|
14
|
+
test("evaluates Banner layouts with composed actions", () => {
|
|
15
|
+
for (const layout of ["inline", "stacked"]) {
|
|
16
|
+
const result =
|
|
17
|
+
evaluateJsx(`<Banner layout="${layout}" tone="warning" title="Connection interrupted" actions={<Button>Reconnect</Button>}>
|
|
18
|
+
Changes are saved on this device.
|
|
19
|
+
</Banner>`)
|
|
20
|
+
expect(result.ok).toBe(true)
|
|
21
|
+
}
|
|
22
|
+
})
|
|
12
23
|
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
2
|
+
import { PurseProvider } from "purse-styles"
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { afterAll, beforeAll, expect, it, vi } from "vitest"
|
|
5
|
+
import { Banner } from "./Banner"
|
|
6
|
+
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
vi.stubGlobal("document", { createElement: () => ({}) })
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterAll(() => vi.unstubAllGlobals())
|
|
12
|
+
|
|
13
|
+
function render(children: ReactNode) {
|
|
14
|
+
return renderToStaticMarkup(<PurseProvider>{children}</PurseProvider>)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
it("keeps tone separate from announcement urgency and makes the icon optional", () => {
|
|
18
|
+
const quiet = render(<Banner tone="danger">Sync failed.</Banner>)
|
|
19
|
+
expect(quiet).toContain("Sync failed.")
|
|
20
|
+
expect(quiet).not.toMatch(/role="(?:alert|status|banner)"/)
|
|
21
|
+
expect(quiet).not.toContain("<button")
|
|
22
|
+
expect(quiet).toContain('aria-hidden="true"')
|
|
23
|
+
|
|
24
|
+
for (const role of ["status", "alert"] as const) {
|
|
25
|
+
const announced = render(
|
|
26
|
+
<Banner role={role} icon={false}>
|
|
27
|
+
Sync failed.
|
|
28
|
+
</Banner>,
|
|
29
|
+
)
|
|
30
|
+
expect(announced).toContain(`role="${role}"`)
|
|
31
|
+
expect(announced).not.toContain("<svg")
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("preserves composed content, actions, and custom icons in both layouts", () => {
|
|
36
|
+
for (const layout of ["inline", "stacked"] as const) {
|
|
37
|
+
const html = render(
|
|
38
|
+
<Banner
|
|
39
|
+
layout={layout}
|
|
40
|
+
title="Connection interrupted"
|
|
41
|
+
icon={<svg data-custom-icon="bell" />}
|
|
42
|
+
actions={<a href="/connection">Reconnect</a>}
|
|
43
|
+
>
|
|
44
|
+
Your <strong>local changes</strong> are safe.
|
|
45
|
+
</Banner>,
|
|
46
|
+
)
|
|
47
|
+
expect(html).toContain("Connection interrupted")
|
|
48
|
+
expect(html).toContain("<strong>local changes</strong>")
|
|
49
|
+
expect(html).toContain('<a href="/connection">Reconnect</a>')
|
|
50
|
+
expect(html).toContain('data-custom-icon="bell"')
|
|
51
|
+
}
|
|
52
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react"
|
|
2
|
+
import { style, useStyles } from "purse-styles"
|
|
3
|
+
import { Button } from "./Button"
|
|
4
|
+
import { Text } from "./Text"
|
|
5
|
+
import { CheckCircle } from "../icons/CheckCircle"
|
|
6
|
+
import { Close } from "../icons/Close"
|
|
7
|
+
import { CircleX } from "../icons/CircleX"
|
|
8
|
+
import { Information } from "../icons/Information"
|
|
9
|
+
import { WarningTriangle } from "../icons/WarningTriangle"
|
|
10
|
+
import { colors } from "../tokens/colors"
|
|
11
|
+
import { radius } from "../tokens/radius"
|
|
12
|
+
import { controlSize } from "../tokens/sizing"
|
|
13
|
+
import { spacing } from "../tokens/spacing"
|
|
14
|
+
import { text } from "../tokens/text"
|
|
15
|
+
import { cls } from "../utils/cls"
|
|
16
|
+
import { memoize } from "../utils/memoize"
|
|
17
|
+
|
|
18
|
+
export type BannerTone = "neutral" | "info" | "success" | "warning" | "danger"
|
|
19
|
+
export type BannerLayout = "inline" | "stacked"
|
|
20
|
+
|
|
21
|
+
export type BannerProps = Omit<ComponentPropsWithoutRef<"div">, "title"> & {
|
|
22
|
+
tone?: BannerTone
|
|
23
|
+
/** Use stacked for multi-sentence messages or a title with supporting text. */
|
|
24
|
+
layout?: BannerLayout
|
|
25
|
+
title?: ReactNode
|
|
26
|
+
/** A decorative icon. Defaults to the tone's icon; false hides it. */
|
|
27
|
+
icon?: ReactNode | false
|
|
28
|
+
actions?: ReactNode
|
|
29
|
+
/** Adds a close button. The caller owns visibility. */
|
|
30
|
+
onDismiss?: () => void
|
|
31
|
+
dismissLabel?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const palettes = {
|
|
35
|
+
neutral: "gray",
|
|
36
|
+
info: "blue",
|
|
37
|
+
success: "green",
|
|
38
|
+
warning: "amber",
|
|
39
|
+
danger: "red",
|
|
40
|
+
} as const
|
|
41
|
+
|
|
42
|
+
const icons = {
|
|
43
|
+
neutral: Information,
|
|
44
|
+
info: Information,
|
|
45
|
+
success: CheckCircle,
|
|
46
|
+
warning: WarningTriangle,
|
|
47
|
+
danger: CircleX,
|
|
48
|
+
} as const
|
|
49
|
+
|
|
50
|
+
const root = memoize(({ tone = "neutral" }: { tone?: BannerTone } = {}) =>
|
|
51
|
+
style(text({ size: "sm" }), radius.lg, spacing.padding({ all: 3 }), {
|
|
52
|
+
display: "flex",
|
|
53
|
+
alignItems: "flex-start",
|
|
54
|
+
gap: spacing.value(3),
|
|
55
|
+
minWidth: 0,
|
|
56
|
+
backgroundColor: colors[palettes[tone]][2],
|
|
57
|
+
border: `1px solid ${colors[palettes[tone]][6]}`,
|
|
58
|
+
// Give text and icons the same inset beside standard-height controls.
|
|
59
|
+
"--banner-text-inset": `max(0px, calc((${controlSize.height} - 1lh) / 2))`,
|
|
60
|
+
"--banner-icon-color": colors[palettes[tone]][11],
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const icon = style({
|
|
65
|
+
flex: "0 0 auto",
|
|
66
|
+
width: "1lh",
|
|
67
|
+
height: "1lh",
|
|
68
|
+
marginBlockStart: "var(--banner-text-inset)",
|
|
69
|
+
color: "var(--banner-icon-color)",
|
|
70
|
+
display: "flex",
|
|
71
|
+
alignItems: "center",
|
|
72
|
+
justifyContent: "center",
|
|
73
|
+
"& > svg": { width: "100%", height: "100%" },
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const body = memoize(({ layout = "inline" }: { layout?: BannerLayout } = {}) =>
|
|
77
|
+
style({
|
|
78
|
+
display: "flex",
|
|
79
|
+
flex: "1 1 auto",
|
|
80
|
+
minWidth: 0,
|
|
81
|
+
flexDirection: layout === "stacked" ? "column" : "row",
|
|
82
|
+
flexWrap: "wrap",
|
|
83
|
+
alignItems: "flex-start",
|
|
84
|
+
gap: spacing.value(4),
|
|
85
|
+
"& > :first-child": {
|
|
86
|
+
flex: layout === "inline" ? "1 1 12rem" : "0 1 auto",
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const content = style({
|
|
92
|
+
minWidth: 0,
|
|
93
|
+
maxWidth: "100%",
|
|
94
|
+
paddingBlock: "var(--banner-text-inset)",
|
|
95
|
+
overflowWrap: "anywhere",
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const title = style({ display: "block" })
|
|
99
|
+
const description = style({ display: "block" })
|
|
100
|
+
const actions = style({
|
|
101
|
+
display: "flex",
|
|
102
|
+
alignItems: "center",
|
|
103
|
+
justifyContent: "flex-end",
|
|
104
|
+
flexWrap: "wrap",
|
|
105
|
+
gap: spacing.value(4),
|
|
106
|
+
maxWidth: "100%",
|
|
107
|
+
marginInlineStart: "auto",
|
|
108
|
+
})
|
|
109
|
+
const dismiss = style({ flexShrink: 0 })
|
|
110
|
+
|
|
111
|
+
/** Plain div parts; icon is decorative. Layout changes action placement, not semantics. */
|
|
112
|
+
export const bannerStyles = {
|
|
113
|
+
root,
|
|
114
|
+
icon,
|
|
115
|
+
body,
|
|
116
|
+
content,
|
|
117
|
+
title,
|
|
118
|
+
description,
|
|
119
|
+
actions,
|
|
120
|
+
dismiss,
|
|
121
|
+
} as const
|
|
122
|
+
|
|
123
|
+
/** Persistent in-flow message. Set role="status" or role="alert" explicitly to announce it. */
|
|
124
|
+
export function Banner({
|
|
125
|
+
tone = "neutral",
|
|
126
|
+
layout = "inline",
|
|
127
|
+
title,
|
|
128
|
+
children,
|
|
129
|
+
icon,
|
|
130
|
+
actions,
|
|
131
|
+
onDismiss,
|
|
132
|
+
dismissLabel = "Dismiss notification",
|
|
133
|
+
className,
|
|
134
|
+
...props
|
|
135
|
+
}: BannerProps) {
|
|
136
|
+
const rootClassName = useStyles(bannerStyles.root({ tone }))
|
|
137
|
+
const iconClassName = useStyles(bannerStyles.icon)
|
|
138
|
+
const bodyClassName = useStyles(bannerStyles.body({ layout }))
|
|
139
|
+
const contentClassName = useStyles(bannerStyles.content)
|
|
140
|
+
const titleClassName = useStyles(bannerStyles.title)
|
|
141
|
+
const descriptionClassName = useStyles(
|
|
142
|
+
bannerStyles.description,
|
|
143
|
+
title != null && style({ color: colors.gray[11] }),
|
|
144
|
+
)
|
|
145
|
+
const actionsClassName = useStyles(bannerStyles.actions)
|
|
146
|
+
const dismissClassName = useStyles(bannerStyles.dismiss)
|
|
147
|
+
const Icon = icons[tone]
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div {...props} className={cls(rootClassName, className)}>
|
|
151
|
+
{icon !== false && (
|
|
152
|
+
<div className={iconClassName} aria-hidden="true">
|
|
153
|
+
{icon ?? <Icon />}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
<div className={bodyClassName}>
|
|
157
|
+
<div className={contentClassName}>
|
|
158
|
+
{title != null && (
|
|
159
|
+
<Text size="sm" fontWeight={500} className={titleClassName}>
|
|
160
|
+
{title}
|
|
161
|
+
</Text>
|
|
162
|
+
)}
|
|
163
|
+
{children != null && (
|
|
164
|
+
<div className={descriptionClassName}>{children}</div>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
{actions != null && actions !== false && (
|
|
168
|
+
<div className={actionsClassName}>{actions}</div>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
{onDismiss && (
|
|
172
|
+
<Button
|
|
173
|
+
variant="quiet"
|
|
174
|
+
variantColor={palettes[tone]}
|
|
175
|
+
aria-label={dismissLabel}
|
|
176
|
+
onPress={onDismiss}
|
|
177
|
+
className={dismissClassName}
|
|
178
|
+
>
|
|
179
|
+
<Close size="sm" />
|
|
180
|
+
</Button>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
import { colors, type ColorName, type ColorScale } from "../tokens/colors"
|
|
16
16
|
import { focusRing } from "../tokens/focusRing"
|
|
17
17
|
import { radius } from "../tokens/radius"
|
|
18
|
-
import { shadowVars } from "../tokens/shadow"
|
|
18
|
+
import { shadowVars, tintedControl } from "../tokens/shadow"
|
|
19
19
|
import { controlSize, iconSizeValues } from "../tokens/sizing"
|
|
20
20
|
import { spacing } from "../tokens/spacing"
|
|
21
21
|
import { text } from "../tokens/text"
|
|
@@ -193,11 +193,6 @@ function fillShade(color: string, lightnessOffset: number, alpha = 1) {
|
|
|
193
193
|
return `oklch(from ${color} calc(l - ${lightnessOffset}) c h / ${alpha})`
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
/** Dark-only top highlight, a fill-tinted lift (half strength in dark mode), and an edge painted beneath the lift: the fill's alpha step 6 in light mode, a near-black tint of the fill in dark mode. */
|
|
197
|
-
function primaryButtonShadow(color: string, lightEdge: string) {
|
|
198
|
-
return `inset 0 .5px light-dark(transparent, rgb(255 255 255 / 0.15)), 0 1px 2px light-dark(${fillShade(color, 0, 0.35)}, ${fillShade(color, 0, 0.18)}), 0 3px 8px -3px light-dark(${fillShade(color, 0, 0.6)}, ${fillShade(color, 0, 0.3)}), 0 0 0 .5px light-dark(${lightEdge}, oklch(from ${color} 0.18 calc(c * 0.5) h))`
|
|
199
|
-
}
|
|
200
|
-
|
|
201
196
|
/**
|
|
202
197
|
* Diagonal from a slightly darker, more saturated, warmer-rotated fill to the
|
|
203
198
|
* fill. The offsets are the OKLCH delta between Campsite's #FB432C → #FF591E.
|
|
@@ -211,7 +206,7 @@ const primaryHoverSheen = `linear-gradient(to bottom, rgb(255 255 255 / 0.12), r
|
|
|
211
206
|
|
|
212
207
|
const primaryClass = memoize(
|
|
213
208
|
(fill: string, textColor: string, lightEdge: string) => {
|
|
214
|
-
const primaryShadow =
|
|
209
|
+
const primaryShadow = tintedControl(fill, lightEdge)
|
|
215
210
|
const gradient = primaryButtonGradient(fill)
|
|
216
211
|
return style(buttonBaseClass, focusRing("&:focus-visible", primaryShadow), {
|
|
217
212
|
color: textColor,
|
|
@@ -44,7 +44,7 @@ const check = style(motion.standard("opacity", "transform"), {
|
|
|
44
44
|
height: checkboxSize.checkmark,
|
|
45
45
|
paddingTop: "0.5px",
|
|
46
46
|
paddingLeft: "0.5px",
|
|
47
|
-
fill:
|
|
47
|
+
fill: "white",
|
|
48
48
|
opacity: 0,
|
|
49
49
|
transform: "scale(0)",
|
|
50
50
|
})
|
|
@@ -52,6 +52,10 @@ const check = style(motion.standard("opacity", "transform"), {
|
|
|
52
52
|
const root = style(
|
|
53
53
|
flex({ alignItems: "center", gap: 3 }),
|
|
54
54
|
focusRing(`&[data-focus-visible] ${box}`, shadowVars.control),
|
|
55
|
+
focusRing(
|
|
56
|
+
`&[data-selected][data-focus-visible] ${box}`,
|
|
57
|
+
shadowVars.selectedControl,
|
|
58
|
+
),
|
|
55
59
|
{
|
|
56
60
|
position: "relative",
|
|
57
61
|
width: "fit-content",
|
|
@@ -62,6 +66,7 @@ const root = style(
|
|
|
62
66
|
},
|
|
63
67
|
[`&[data-selected] ${box}`]: {
|
|
64
68
|
backgroundColor: colors.accent[9],
|
|
69
|
+
boxShadow: shadowVars.selectedControl,
|
|
65
70
|
},
|
|
66
71
|
[`&[data-selected][data-hovered] ${box}`]: {
|
|
67
72
|
backgroundColor: surfaceWash(
|
package/src/components/Radio.tsx
CHANGED
|
@@ -40,13 +40,17 @@ const dot = style(radius.circle, motion.standard("transform"), {
|
|
|
40
40
|
left: "4px",
|
|
41
41
|
width: "6px",
|
|
42
42
|
height: "6px",
|
|
43
|
-
backgroundColor:
|
|
43
|
+
backgroundColor: "white",
|
|
44
44
|
transform: "scale(0)",
|
|
45
45
|
})
|
|
46
46
|
|
|
47
47
|
const root = style(
|
|
48
48
|
flex({ alignItems: "center", gap: 3 }),
|
|
49
49
|
focusRing(`&[data-focus-visible] ${circle}`, shadowVars.control),
|
|
50
|
+
focusRing(
|
|
51
|
+
`&[data-selected][data-focus-visible] ${circle}`,
|
|
52
|
+
shadowVars.selectedControl,
|
|
53
|
+
),
|
|
50
54
|
{
|
|
51
55
|
position: "relative",
|
|
52
56
|
width: "fit-content",
|
|
@@ -55,6 +59,7 @@ const root = style(
|
|
|
55
59
|
},
|
|
56
60
|
[`&[data-selected] ${circle}`]: {
|
|
57
61
|
backgroundColor: colors.accent[9],
|
|
62
|
+
boxShadow: shadowVars.selectedControl,
|
|
58
63
|
},
|
|
59
64
|
[`&[data-selected][data-hovered] ${circle}`]: {
|
|
60
65
|
backgroundColor: surfaceWash(
|
|
@@ -58,6 +58,10 @@ const thumb = style(radius.circle, motion.standard("transform"), {
|
|
|
58
58
|
const root = style(
|
|
59
59
|
flex({ alignItems: "center", gap: 3 }),
|
|
60
60
|
focusRing(`&[data-focus-visible] ${track}`, shadowVars.control),
|
|
61
|
+
focusRing(
|
|
62
|
+
`&[data-selected][data-focus-visible] ${track}`,
|
|
63
|
+
shadowVars.selectedControl,
|
|
64
|
+
),
|
|
61
65
|
{
|
|
62
66
|
position: "relative",
|
|
63
67
|
width: "fit-content",
|
|
@@ -67,6 +71,7 @@ const root = style(
|
|
|
67
71
|
},
|
|
68
72
|
[`&[data-selected] ${track}`]: {
|
|
69
73
|
backgroundColor: colors.accent[9],
|
|
74
|
+
boxShadow: shadowVars.selectedControl,
|
|
70
75
|
},
|
|
71
76
|
[`&[data-selected][data-hovered] ${track}`]: {
|
|
72
77
|
backgroundColor: switchOnHover,
|
package/src/maui.ts
CHANGED
|
@@ -58,6 +58,13 @@ export { visuallyHidden } from "./tokens/visuallyHidden"
|
|
|
58
58
|
// Components
|
|
59
59
|
export { Avatar, avatarStyles } from "./components/Avatar"
|
|
60
60
|
export { Badge, badgeStyles } from "./components/Badge"
|
|
61
|
+
export {
|
|
62
|
+
Banner,
|
|
63
|
+
bannerStyles,
|
|
64
|
+
type BannerProps,
|
|
65
|
+
type BannerTone,
|
|
66
|
+
type BannerLayout,
|
|
67
|
+
} from "./components/Banner"
|
|
61
68
|
export { LogoIcon, type LogoIconProps } from "./components/LogoIcon"
|
|
62
69
|
export {
|
|
63
70
|
Button,
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
import {
|
|
3
|
+
Banner,
|
|
4
|
+
type BannerLayout,
|
|
5
|
+
type BannerTone,
|
|
6
|
+
} from "../components/Banner"
|
|
7
|
+
import { Button } from "../components/Button"
|
|
8
|
+
import { Code } from "../components/Code"
|
|
9
|
+
import { CodeBlock } from "../components/CodeBlock"
|
|
10
|
+
import { Prose } from "../components/Prose"
|
|
11
|
+
import { Text } from "../components/Text"
|
|
12
|
+
import { H2, H3, P } from "../components/Typography"
|
|
13
|
+
import { Flex } from "../components/Utils"
|
|
14
|
+
import { Bell } from "../icons/Bell"
|
|
15
|
+
import { Panel } from "./Panel"
|
|
16
|
+
|
|
17
|
+
const tones: { tone: BannerTone; message: string; layout?: BannerLayout }[] = [
|
|
18
|
+
{ tone: "neutral", message: "Maintenance is scheduled for Sunday." },
|
|
19
|
+
{ tone: "info", message: "A new version is available." },
|
|
20
|
+
{ tone: "success", message: "All changes are synced." },
|
|
21
|
+
{ tone: "warning", message: "You’re approaching your storage limit." },
|
|
22
|
+
{
|
|
23
|
+
tone: "danger",
|
|
24
|
+
message: "Sync is unavailable. Your changes are safe on this device.",
|
|
25
|
+
layout: "stacked",
|
|
26
|
+
},
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
export function BannerPage() {
|
|
30
|
+
const [showUpdate, setShowUpdate] = useState(true)
|
|
31
|
+
const [updated, setUpdated] = useState(false)
|
|
32
|
+
const [connected, setConnected] = useState(false)
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Prose style={{ paddingBottom: "32px" }}>
|
|
36
|
+
<H2>Banner</H2>
|
|
37
|
+
<P>
|
|
38
|
+
Persistent messages that stay in the page flow. Use inline for a single
|
|
39
|
+
sentence, even when it wraps. Always use stacked for multi-sentence
|
|
40
|
+
messages or a title with supporting text. Icons match the first line
|
|
41
|
+
height, including at the large platform scale.
|
|
42
|
+
</P>
|
|
43
|
+
|
|
44
|
+
<H3>Inline</H3>
|
|
45
|
+
<Panel data-example="inline">
|
|
46
|
+
<Flex column gap={8}>
|
|
47
|
+
{showUpdate ? (
|
|
48
|
+
<Banner
|
|
49
|
+
tone={updated ? "success" : "info"}
|
|
50
|
+
onDismiss={() => setShowUpdate(false)}
|
|
51
|
+
dismissLabel="Dismiss update notice"
|
|
52
|
+
actions={
|
|
53
|
+
!updated && (
|
|
54
|
+
<Button
|
|
55
|
+
variant="quiet"
|
|
56
|
+
variantColor="blue"
|
|
57
|
+
onPress={() => setUpdated(true)}
|
|
58
|
+
>
|
|
59
|
+
Reload
|
|
60
|
+
</Button>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
>
|
|
64
|
+
{updated
|
|
65
|
+
? "You’re on the latest version."
|
|
66
|
+
: "A new version is available."}
|
|
67
|
+
</Banner>
|
|
68
|
+
) : (
|
|
69
|
+
<Text size="sm" color="lowContrast">
|
|
70
|
+
Update notice dismissed.
|
|
71
|
+
</Text>
|
|
72
|
+
)}
|
|
73
|
+
<Button
|
|
74
|
+
variant="quiet"
|
|
75
|
+
onPress={() => {
|
|
76
|
+
setShowUpdate(true)
|
|
77
|
+
setUpdated(false)
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
Reset update example
|
|
81
|
+
</Button>
|
|
82
|
+
</Flex>
|
|
83
|
+
</Panel>
|
|
84
|
+
|
|
85
|
+
<H3>Stacked</H3>
|
|
86
|
+
<Panel data-example="stacked">
|
|
87
|
+
<Flex column gap={8}>
|
|
88
|
+
<Banner
|
|
89
|
+
layout="stacked"
|
|
90
|
+
tone={connected ? "success" : "warning"}
|
|
91
|
+
title={
|
|
92
|
+
connected
|
|
93
|
+
? "You’re connected."
|
|
94
|
+
: "Your connection was interrupted."
|
|
95
|
+
}
|
|
96
|
+
actions={
|
|
97
|
+
!connected && (
|
|
98
|
+
<Button
|
|
99
|
+
variant="quiet"
|
|
100
|
+
variantColor="amber"
|
|
101
|
+
onPress={() => setConnected(true)}
|
|
102
|
+
>
|
|
103
|
+
Reconnect
|
|
104
|
+
</Button>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
>
|
|
108
|
+
{connected
|
|
109
|
+
? "All changes are synced."
|
|
110
|
+
: "Changes are saved on this device. Reconnect to sync."}
|
|
111
|
+
</Banner>
|
|
112
|
+
<Button variant="quiet" onPress={() => setConnected(false)}>
|
|
113
|
+
Reset connection example
|
|
114
|
+
</Button>
|
|
115
|
+
</Flex>
|
|
116
|
+
</Panel>
|
|
117
|
+
|
|
118
|
+
<H3>Tones</H3>
|
|
119
|
+
<Panel data-example="tones">
|
|
120
|
+
<Flex column gap={6}>
|
|
121
|
+
{tones.map(({ tone, message, layout }) => (
|
|
122
|
+
<Banner key={tone} tone={tone} layout={layout}>
|
|
123
|
+
{message}
|
|
124
|
+
</Banner>
|
|
125
|
+
))}
|
|
126
|
+
</Flex>
|
|
127
|
+
</Panel>
|
|
128
|
+
|
|
129
|
+
<H3>Narrow layouts</H3>
|
|
130
|
+
<P>
|
|
131
|
+
Messages wrap without truncation, with stacked actions on the right.
|
|
132
|
+
</P>
|
|
133
|
+
<Panel data-example="narrow">
|
|
134
|
+
<Flex column gap={6} style={{ maxWidth: "320px" }}>
|
|
135
|
+
<Banner
|
|
136
|
+
layout="stacked"
|
|
137
|
+
tone="info"
|
|
138
|
+
actions={
|
|
139
|
+
<Button
|
|
140
|
+
variant="quiet"
|
|
141
|
+
variantColor="blue"
|
|
142
|
+
onPress={() => setUpdated(true)}
|
|
143
|
+
>
|
|
144
|
+
{updated ? "Up to date" : "Reload"}
|
|
145
|
+
</Button>
|
|
146
|
+
}
|
|
147
|
+
>
|
|
148
|
+
A new version is available. Reload to get the latest improvements.
|
|
149
|
+
</Banner>
|
|
150
|
+
<Banner
|
|
151
|
+
layout="stacked"
|
|
152
|
+
tone="warning"
|
|
153
|
+
title="Your connection was interrupted."
|
|
154
|
+
actions={
|
|
155
|
+
<Button
|
|
156
|
+
variant="quiet"
|
|
157
|
+
variantColor="amber"
|
|
158
|
+
onPress={() => setConnected(true)}
|
|
159
|
+
>
|
|
160
|
+
{connected ? "Connected" : "Reconnect"}
|
|
161
|
+
</Button>
|
|
162
|
+
}
|
|
163
|
+
>
|
|
164
|
+
Changes are saved on this device. Reconnect to sync.
|
|
165
|
+
</Banner>
|
|
166
|
+
</Flex>
|
|
167
|
+
</Panel>
|
|
168
|
+
|
|
169
|
+
<H3>Custom icon and no icon</H3>
|
|
170
|
+
<Panel data-example="icons">
|
|
171
|
+
<Flex column gap={6}>
|
|
172
|
+
<Banner icon={<Bell />}>
|
|
173
|
+
Notifications are paused until tomorrow.
|
|
174
|
+
</Banner>
|
|
175
|
+
<Banner icon={false}>Maintenance is scheduled for Sunday.</Banner>
|
|
176
|
+
</Flex>
|
|
177
|
+
</Panel>
|
|
178
|
+
|
|
179
|
+
<H3>Usage</H3>
|
|
180
|
+
<CodeBlock lang="tsx">{`<Banner
|
|
181
|
+
tone="info"
|
|
182
|
+
layout="inline"
|
|
183
|
+
actions={<Button variant="quiet" variantColor="blue" onPress={reload}>Reload</Button>}
|
|
184
|
+
onDismiss={() => setVisible(false)}
|
|
185
|
+
>
|
|
186
|
+
A new version is available.
|
|
187
|
+
</Banner>
|
|
188
|
+
|
|
189
|
+
<Banner
|
|
190
|
+
tone="warning"
|
|
191
|
+
layout="stacked"
|
|
192
|
+
title="Your connection was interrupted."
|
|
193
|
+
actions={<Button variant="quiet" variantColor="amber" onPress={reconnect}>Reconnect</Button>}
|
|
194
|
+
>
|
|
195
|
+
Changes are saved on this device. Reconnect to sync.
|
|
196
|
+
</Banner>`}</CodeBlock>
|
|
197
|
+
<P>
|
|
198
|
+
Neutral tone and inline layout are the defaults. Actions accept composed
|
|
199
|
+
buttons or links; use quiet buttons with a matching palette: gray for
|
|
200
|
+
neutral, blue for info, green for success, amber for warning, and red
|
|
201
|
+
for danger. Stacked and wrapped actions align to the right. The caller
|
|
202
|
+
controls visibility through <Code>onDismiss</Code>; there is no timer or
|
|
203
|
+
automatic dismissal. Customize the close button’s accessible name with{" "}
|
|
204
|
+
<Code>dismissLabel</Code>.
|
|
205
|
+
</P>
|
|
206
|
+
<P>
|
|
207
|
+
Banners do not announce themselves by default. Use{" "}
|
|
208
|
+
<Code>role="status"</Code> for live updates or <Code>role="alert"</Code>{" "}
|
|
209
|
+
for urgent failures. Tone alone does not determine urgency. Style parts
|
|
210
|
+
are available through <Code>bannerStyles</Code>.
|
|
211
|
+
</P>
|
|
212
|
+
</Prose>
|
|
213
|
+
)
|
|
214
|
+
}
|
package/src/pages/Maui.tsx
CHANGED
|
@@ -32,6 +32,7 @@ import { ReadmePage } from "./ReadmePage"
|
|
|
32
32
|
import { ActivityIndicatorPage } from "./ActivityIndicatorPage"
|
|
33
33
|
import { AvatarPage } from "./AvatarPage"
|
|
34
34
|
import { BadgePage } from "./BadgePage"
|
|
35
|
+
import { BannerPage } from "./BannerPage"
|
|
35
36
|
import { BackgroundColorTokenPage } from "./BackgroundColorTokenPage"
|
|
36
37
|
import { BordersTokenPage } from "./BordersTokenPage"
|
|
37
38
|
import { ButtonsPage } from "./ButtonsPage"
|
|
@@ -143,6 +144,7 @@ const navigation: NavEntry[] = [
|
|
|
143
144
|
children: [
|
|
144
145
|
{ label: "Avatar", path: "/components/avatar", page: AvatarPage },
|
|
145
146
|
{ label: "Badge", path: "/components/badge", page: BadgePage },
|
|
147
|
+
{ label: "Banner", path: "/components/banner", page: BannerPage },
|
|
146
148
|
{ label: "Buttons", path: "/components/buttons", page: ButtonsPage },
|
|
147
149
|
{ label: "Dialog", path: "/components/dialog", page: DialogPage },
|
|
148
150
|
{ label: "Tab bar", path: "/components/tab-bar", page: TabBarPage },
|
package/src/tokens/shadow.ts
CHANGED
|
@@ -16,6 +16,11 @@ const shadowParams = defineVars({
|
|
|
16
16
|
const rgb = shadowParams.foregroundRgb
|
|
17
17
|
const blur = shadowParams.blurOpacity
|
|
18
18
|
|
|
19
|
+
/** Dark-only top highlight, fill-tinted lift, and a 0.5px edge beneath the lift. */
|
|
20
|
+
export function tintedControl(color: string, lightEdge: string) {
|
|
21
|
+
return `inset 0 .5px light-dark(transparent, rgb(255 255 255 / 0.15)), 0 1px 2px light-dark(oklch(from ${color} l c h / 0.35), oklch(from ${color} l c h / 0.18)), 0 3px 8px -3px light-dark(oklch(from ${color} l c h / 0.6), oklch(from ${color} l c h / 0.3)), 0 0 0 .5px light-dark(${lightEdge}, oklch(from ${color} 0.18 calc(c * 0.5) h))`
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
export const shadowVars = defineVars({
|
|
20
25
|
control: {
|
|
21
26
|
// 0.5px edge listed last, so it paints beneath the drop: light reads as a
|
|
@@ -23,6 +28,7 @@ export const shadowVars = defineVars({
|
|
|
23
28
|
default: `0 .5px 1px #00000018, 0 0 4px -1px #0001, 0 0 0 .5px ${colors.grayAlpha[6]}`,
|
|
24
29
|
[DARK_THEME]: `inset 0 .5px rgb(255 255 255 / 0.09), 0 1px .5px #00000018, 0 0 3px -1px #0005, 0 0 0 .5px rgb(0 0 0 / 0.8)`,
|
|
25
30
|
},
|
|
31
|
+
selectedControl: tintedControl(colors.accent[9], colors.accentAlpha[6]),
|
|
26
32
|
subtle: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px`,
|
|
27
33
|
medium: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px, rgba(0, 0, 0, ${blur}) 0px 6px 6px -3px`,
|
|
28
34
|
strong: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px, rgba(0, 0, 0, ${blur}) 0px 6px 6px -3px, rgba(0, 0, 0, calc(${blur} * 0.67)) 0px 12px 12px -6px, rgba(0, 0, 0, calc(${blur} * 0.67)) 0px 24px 24px -12px`,
|