@tanishqkancharla/maui 0.0.62 → 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/maui.d.ts +1 -0
- package/dist/maui.js +1 -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/maui.ts +7 -0
- package/src/pages/BannerPage.tsx +214 -0
- package/src/pages/Maui.tsx +2 -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
|
+
}
|
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/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
|
+
}
|
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 },
|