@dxlbnl/ui 0.1.0 → 1.0.0
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/README.md +1 -1
- package/dist/components/cards/NoteCard.svelte +1 -1
- package/dist/components/cards/ProductCard.svelte +1 -1
- package/dist/components/cards/ProjectCard.svelte +1 -1
- package/dist/components/feedback/Alert.stories.svelte +131 -0
- package/dist/components/feedback/Alert.svelte +117 -0
- package/dist/components/{patterns → feedback}/Alert.svelte.d.ts +4 -2
- package/dist/components/feedback/Modal.stories.svelte +49 -117
- package/dist/components/feedback/Modal.svelte +47 -26
- package/dist/components/feedback/Toast.stories.svelte +89 -53
- package/dist/components/feedback/Toast.svelte +5 -64
- package/dist/components/feedback/Toast.svelte.d.ts +2 -0
- package/dist/components/feedback/ToastRegion.stories.svelte +11 -0
- package/dist/components/feedback/ToastRegion.svelte +1 -0
- package/dist/components/feedback/index.d.ts +1 -0
- package/dist/components/feedback/index.js +1 -0
- package/dist/components/forms/Input.stories.svelte +15 -0
- package/dist/components/forms/Input.svelte +11 -0
- package/dist/components/forms/InputWrap.composition.stories.svelte +11 -0
- package/dist/components/forms/InputWrap.stories.svelte +9 -0
- package/dist/components/forms/InputWrap.svelte +13 -11
- package/dist/components/forms/Switch.stories.svelte +55 -47
- package/dist/components/forms/Switch.svelte +4 -3
- package/dist/components/patterns/CtaBlock.svelte +1 -0
- package/dist/components/patterns/PageHero.stories.svelte +39 -0
- package/dist/components/patterns/PageHero.svelte +17 -3
- package/dist/components/patterns/PageHero.svelte.d.ts +5 -1
- package/dist/components/patterns/index.d.ts +0 -1
- package/dist/components/patterns/index.js +0 -1
- package/dist/components/primitives/Heading.stories.svelte +6 -30
- package/dist/components/primitives/Heading.svelte +2 -11
- package/dist/components/primitives/Heading.svelte.d.ts +0 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/stores/toast.d.ts +2 -0
- package/dist/stores/toast.js +1 -0
- package/dist/tokens/tokens.css +2 -0
- package/package.json +1 -1
- package/dist/components/patterns/Alert.stories.svelte +0 -63
- package/dist/components/patterns/Alert.svelte +0 -91
- /package/dist/components/{patterns → feedback}/Alert.stories.svelte.d.ts +0 -0
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ Both palettes use the same CSS custom property names (`--ink`, `--bg`, `--amber`
|
|
|
82
82
|
<ToastRegion />
|
|
83
83
|
|
|
84
84
|
<!-- Push a notification from anywhere -->
|
|
85
|
-
<button onclick={() => toast.push('Saved', { variant: '
|
|
85
|
+
<button onclick={() => toast.push('Saved', { title: 'Done', variant: 'success' })}>Save</button>
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
## Storybook
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
<Text variant="mono" color="faint" size="xs">{hexId}</Text>
|
|
42
42
|
<Text variant="mono" color="cyan" size="xs">{kind.toUpperCase()}</Text>
|
|
43
43
|
</Spread>
|
|
44
|
-
<Heading level={3}
|
|
44
|
+
<Heading level={3}>{title}</Heading>
|
|
45
45
|
{#if lede}
|
|
46
46
|
<Text variant="body" class="note-lede">{lede}</Text>
|
|
47
47
|
{/if}
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
<div class="card-body">
|
|
71
71
|
<Stack gap="xs">
|
|
72
72
|
<Text variant="eyebrow">{sku}</Text>
|
|
73
|
-
<Heading level={3}
|
|
73
|
+
<Heading level={3}>{name}</Heading>
|
|
74
74
|
<Text variant="mono" case="none" color="dim" class="card-desc">{description}</Text>
|
|
75
75
|
<div class="card-footer-row">
|
|
76
76
|
<Spread>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import { defineMeta } from "@storybook/addon-svelte-csf";
|
|
3
|
+
import { expect, within } from "storybook/test";
|
|
4
|
+
import Alert from "./Alert.svelte";
|
|
5
|
+
import { resolveTokenFgColor } from "../../storybook-utils.js";
|
|
6
|
+
|
|
7
|
+
const { Story } = defineMeta({
|
|
8
|
+
title: "Feedback/Alert",
|
|
9
|
+
component: Alert,
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
});
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<!-- AC-5: success variant — borderLeftColor matches --ok token -->
|
|
15
|
+
<Story
|
|
16
|
+
name="Ok"
|
|
17
|
+
args={{ variant: "success", title: "Build successful", message: "All rails nominal." }}
|
|
18
|
+
play={async ({ canvasElement }) => {
|
|
19
|
+
const canvas = within(canvasElement);
|
|
20
|
+
|
|
21
|
+
// AC-9: root element has NO role attribute
|
|
22
|
+
const alertEl = canvasElement.querySelector(".alert");
|
|
23
|
+
await expect(alertEl).not.toBeNull();
|
|
24
|
+
await expect(alertEl!.getAttribute("role")).toBeNull();
|
|
25
|
+
|
|
26
|
+
// title and message visible
|
|
27
|
+
await expect(canvas.getByText("Build successful")).toBeVisible();
|
|
28
|
+
await expect(canvas.getByText("All rails nominal.")).toBeVisible();
|
|
29
|
+
|
|
30
|
+
// .alert-tag glyph present with aria-hidden
|
|
31
|
+
const tag = canvas.getByText("ok");
|
|
32
|
+
await expect(tag).toHaveAttribute("aria-hidden", "true");
|
|
33
|
+
|
|
34
|
+
// AC-5: borderLeftColor matches --ok
|
|
35
|
+
const okColor = resolveTokenFgColor("--ok");
|
|
36
|
+
await expect(getComputedStyle(alertEl!).borderLeftColor).toBe(okColor);
|
|
37
|
+
}}
|
|
38
|
+
/>
|
|
39
|
+
|
|
40
|
+
<!-- AC-6: warning variant — borderLeftColor matches --amber token -->
|
|
41
|
+
<Story
|
|
42
|
+
name="Amber"
|
|
43
|
+
args={{ variant: "warning", title: "High load", message: "+12V rail at 88%." }}
|
|
44
|
+
play={async ({ canvasElement }) => {
|
|
45
|
+
const canvas = within(canvasElement);
|
|
46
|
+
|
|
47
|
+
// AC-9: no role attribute
|
|
48
|
+
const alertEl = canvasElement.querySelector(".alert");
|
|
49
|
+
await expect(alertEl).not.toBeNull();
|
|
50
|
+
await expect(alertEl!.getAttribute("role")).toBeNull();
|
|
51
|
+
|
|
52
|
+
await expect(canvas.getByText("High load")).toBeVisible();
|
|
53
|
+
|
|
54
|
+
// .alert-tag glyph for warning
|
|
55
|
+
const tag = canvas.getByText("!!");
|
|
56
|
+
await expect(tag).toHaveAttribute("aria-hidden", "true");
|
|
57
|
+
|
|
58
|
+
// AC-6: borderLeftColor matches --amber
|
|
59
|
+
const amberColor = resolveTokenFgColor("--amber");
|
|
60
|
+
await expect(getComputedStyle(alertEl!).borderLeftColor).toBe(amberColor);
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
<!-- AC-7: error variant — borderLeftColor matches --danger token -->
|
|
65
|
+
<Story
|
|
66
|
+
name="Danger"
|
|
67
|
+
args={{ variant: "error", title: "Thermal fault", message: "Over-temperature protection triggered." }}
|
|
68
|
+
play={async ({ canvasElement }) => {
|
|
69
|
+
const canvas = within(canvasElement);
|
|
70
|
+
|
|
71
|
+
// AC-9: no role attribute
|
|
72
|
+
const alertEl = canvasElement.querySelector(".alert");
|
|
73
|
+
await expect(alertEl).not.toBeNull();
|
|
74
|
+
await expect(alertEl!.getAttribute("role")).toBeNull();
|
|
75
|
+
|
|
76
|
+
await expect(canvas.getByText("Thermal fault")).toBeVisible();
|
|
77
|
+
|
|
78
|
+
// .alert-tag glyph for error
|
|
79
|
+
const tag = canvas.getByText("err");
|
|
80
|
+
await expect(tag).toHaveAttribute("aria-hidden", "true");
|
|
81
|
+
|
|
82
|
+
// AC-7: borderLeftColor matches --danger
|
|
83
|
+
const dangerColor = resolveTokenFgColor("--danger");
|
|
84
|
+
await expect(getComputedStyle(alertEl!).borderLeftColor).toBe(dangerColor);
|
|
85
|
+
}}
|
|
86
|
+
/>
|
|
87
|
+
|
|
88
|
+
<!-- AC-8: info variant — borderLeftColor matches --cyan token -->
|
|
89
|
+
<Story
|
|
90
|
+
name="Info"
|
|
91
|
+
args={{ variant: "info", title: "Firmware update available", message: "v2.1.0 → v2.2.0." }}
|
|
92
|
+
play={async ({ canvasElement }) => {
|
|
93
|
+
const canvas = within(canvasElement);
|
|
94
|
+
|
|
95
|
+
// AC-9: no role attribute
|
|
96
|
+
const alertEl = canvasElement.querySelector(".alert");
|
|
97
|
+
await expect(alertEl).not.toBeNull();
|
|
98
|
+
await expect(alertEl!.getAttribute("role")).toBeNull();
|
|
99
|
+
|
|
100
|
+
await expect(canvas.getByText("Firmware update available")).toBeVisible();
|
|
101
|
+
|
|
102
|
+
// .alert-tag glyph for info
|
|
103
|
+
const tag = canvas.getByText("inf");
|
|
104
|
+
await expect(tag).toHaveAttribute("aria-hidden", "true");
|
|
105
|
+
|
|
106
|
+
// AC-8: borderLeftColor matches --cyan
|
|
107
|
+
const cyanColor = resolveTokenFgColor("--cyan");
|
|
108
|
+
await expect(getComputedStyle(alertEl!).borderLeftColor).toBe(cyanColor);
|
|
109
|
+
}}
|
|
110
|
+
/>
|
|
111
|
+
|
|
112
|
+
<!-- AC-11: when title is absent/empty, .alert-title is not visible -->
|
|
113
|
+
<Story
|
|
114
|
+
name="No Title"
|
|
115
|
+
args={{ variant: "success", message: "Status OK — no title provided." }}
|
|
116
|
+
play={async ({ canvasElement }) => {
|
|
117
|
+
const canvas = within(canvasElement);
|
|
118
|
+
|
|
119
|
+
const alertEl = canvasElement.querySelector(".alert");
|
|
120
|
+
await expect(alertEl).not.toBeNull();
|
|
121
|
+
|
|
122
|
+
// AC-11: no .alert-title text visible when title is absent
|
|
123
|
+
const titleEl = canvasElement.querySelector(".alert-title");
|
|
124
|
+
// Either the element is absent, or it has no visible text
|
|
125
|
+
if (titleEl) {
|
|
126
|
+
await expect(titleEl.textContent?.trim()).toBeFalsy();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await expect(canvas.getByText("Status OK — no title provided.")).toBeVisible();
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements'
|
|
3
|
+
import type { Snippet } from 'svelte'
|
|
4
|
+
import Stack from '../layout/Stack.svelte'
|
|
5
|
+
|
|
6
|
+
type AlertVariant = 'success' | 'warning' | 'error' | 'info'
|
|
7
|
+
|
|
8
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
/** Colour variant — drives the tag glyph and border accent. @default 'info' */
|
|
10
|
+
variant?: AlertVariant
|
|
11
|
+
/** Bold title text shown in the alert header. */
|
|
12
|
+
title?: string
|
|
13
|
+
/** Body message text. */
|
|
14
|
+
message?: string
|
|
15
|
+
/** When provided, renders a dismiss × button inside the alert. */
|
|
16
|
+
ondismiss?: () => void
|
|
17
|
+
children?: Snippet
|
|
18
|
+
[key: string]: unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let {
|
|
22
|
+
variant = 'info',
|
|
23
|
+
title,
|
|
24
|
+
message,
|
|
25
|
+
ondismiss,
|
|
26
|
+
children,
|
|
27
|
+
...rest
|
|
28
|
+
}: Props = $props()
|
|
29
|
+
|
|
30
|
+
const TAG_GLYPHS: Record<AlertVariant, string> = {
|
|
31
|
+
success: 'ok',
|
|
32
|
+
warning: '!!',
|
|
33
|
+
error: 'err',
|
|
34
|
+
info: 'inf',
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<div class="alert alert--{variant}" {...rest}>
|
|
39
|
+
<span class="alert-tag" aria-hidden="true">{TAG_GLYPHS[variant]}</span>
|
|
40
|
+
<Stack gap="sm">
|
|
41
|
+
{#if title}
|
|
42
|
+
<span class="alert-title">{title}</span>
|
|
43
|
+
{/if}
|
|
44
|
+
{#if message}
|
|
45
|
+
<span class="alert-msg">{message}</span>
|
|
46
|
+
{/if}
|
|
47
|
+
{@render children?.()}
|
|
48
|
+
</Stack>
|
|
49
|
+
{#if ondismiss}
|
|
50
|
+
<button class="alert-dismiss" type="button" aria-label="Dismiss notification" onclick={ondismiss}>×</button>
|
|
51
|
+
{/if}
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<style>
|
|
55
|
+
.alert {
|
|
56
|
+
display: flex;
|
|
57
|
+
align-items: flex-start;
|
|
58
|
+
gap: 12px;
|
|
59
|
+
padding: 12px 16px;
|
|
60
|
+
border-left: 2px solid;
|
|
61
|
+
font-size: var(--t-body);
|
|
62
|
+
line-height: 1.5;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.alert--success { border-color: var(--ok); background: color-mix(in srgb, var(--ok) 8%, transparent); }
|
|
66
|
+
.alert--warning { border-color: var(--amber); background: color-mix(in srgb, var(--amber) 8%, transparent); }
|
|
67
|
+
.alert--error { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 8%, transparent); }
|
|
68
|
+
.alert--info { border-color: var(--cyan); background: color-mix(in srgb, var(--cyan) 8%, transparent); }
|
|
69
|
+
|
|
70
|
+
.alert-tag {
|
|
71
|
+
font-family: var(--mono);
|
|
72
|
+
font-size: var(--t-micro);
|
|
73
|
+
letter-spacing: 0.08em;
|
|
74
|
+
text-transform: uppercase;
|
|
75
|
+
flex-shrink: 0;
|
|
76
|
+
margin-top: 2px;
|
|
77
|
+
}
|
|
78
|
+
.alert--success .alert-tag { color: var(--ok); }
|
|
79
|
+
.alert--warning .alert-tag { color: var(--amber); }
|
|
80
|
+
.alert--error .alert-tag { color: var(--danger); }
|
|
81
|
+
.alert--info .alert-tag { color: var(--cyan); }
|
|
82
|
+
|
|
83
|
+
.alert-title {
|
|
84
|
+
font-family: var(--mono);
|
|
85
|
+
font-size: var(--t-micro);
|
|
86
|
+
letter-spacing: 0.08em;
|
|
87
|
+
text-transform: uppercase;
|
|
88
|
+
}
|
|
89
|
+
.alert--success .alert-title { color: var(--ok); }
|
|
90
|
+
.alert--warning .alert-title { color: var(--amber); }
|
|
91
|
+
.alert--error .alert-title { color: var(--danger); }
|
|
92
|
+
.alert--info .alert-title { color: var(--cyan); }
|
|
93
|
+
|
|
94
|
+
.alert-msg {
|
|
95
|
+
font-size: var(--t-mono);
|
|
96
|
+
color: var(--ink-dim);
|
|
97
|
+
line-height: 1.5;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.alert-dismiss {
|
|
101
|
+
margin-left: auto;
|
|
102
|
+
flex-shrink: 0;
|
|
103
|
+
align-self: center;
|
|
104
|
+
background: none;
|
|
105
|
+
border: none;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
color: var(--ink-faint);
|
|
108
|
+
font-size: 18px;
|
|
109
|
+
line-height: 1;
|
|
110
|
+
padding: 0;
|
|
111
|
+
width: 28px;
|
|
112
|
+
height: 28px;
|
|
113
|
+
display: inline-flex;
|
|
114
|
+
align-items: center;
|
|
115
|
+
justify-content: center;
|
|
116
|
+
}
|
|
117
|
+
</style>
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
-
type AlertVariant = '
|
|
3
|
+
type AlertVariant = 'success' | 'warning' | 'error' | 'info';
|
|
4
4
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
5
5
|
/** Colour variant — drives the tag glyph and border accent. @default 'info' */
|
|
6
6
|
variant?: AlertVariant;
|
|
7
7
|
/** Bold title text shown in the alert header. */
|
|
8
|
-
title
|
|
8
|
+
title?: string;
|
|
9
9
|
/** Body message text. */
|
|
10
10
|
message?: string;
|
|
11
|
+
/** When provided, renders a dismiss × button inside the alert. */
|
|
12
|
+
ondismiss?: () => void;
|
|
11
13
|
children?: Snippet;
|
|
12
14
|
[key: string]: unknown;
|
|
13
15
|
}
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { expect, within, waitFor } from "storybook/test";
|
|
4
4
|
import Modal from "./Modal.svelte";
|
|
5
5
|
import Button from "../primitives/Button.svelte";
|
|
6
|
-
import { resolveTokenColor } from "../../storybook-utils.js";
|
|
7
6
|
|
|
8
7
|
const { Story } = defineMeta({
|
|
9
8
|
title: "Feedback/Modal",
|
|
@@ -15,29 +14,33 @@
|
|
|
15
14
|
let openDefault = $state(false);
|
|
16
15
|
let openConfirm = $state(false);
|
|
17
16
|
let openDestructive = $state(false);
|
|
18
|
-
let openWithFooter = $state(false);
|
|
19
|
-
let openBackdrop = $state(false);
|
|
20
|
-
let openNoFooter = $state(false);
|
|
21
17
|
</script>
|
|
22
18
|
|
|
23
|
-
<!--
|
|
19
|
+
<!-- Default story: 12px mono uppercase title, close button right of title -->
|
|
24
20
|
<Story name="Default"
|
|
25
21
|
play={async ({ canvasElement, userEvent }) => {
|
|
26
22
|
const canvas = within(canvasElement);
|
|
27
23
|
const trigger = canvas.getByRole("button", { name: "Open Modal" });
|
|
28
|
-
// dialog not visible before trigger
|
|
29
|
-
await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
|
|
30
24
|
await userEvent.click(trigger);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
await expect(
|
|
34
|
-
await expect(
|
|
35
|
-
|
|
36
|
-
await expect(
|
|
25
|
+
// title is h2#modal-title, visible
|
|
26
|
+
const title = canvasElement.querySelector("h2#modal-title") as HTMLElement;
|
|
27
|
+
await expect(title).not.toBeNull();
|
|
28
|
+
await expect(title).toBeVisible();
|
|
29
|
+
// 12px
|
|
30
|
+
await expect(getComputedStyle(title).fontSize).toBe("12px");
|
|
31
|
+
// uppercase
|
|
32
|
+
await expect(getComputedStyle(title).textTransform).toBe("uppercase");
|
|
33
|
+
// header justify-content space-between
|
|
34
|
+
const header = canvasElement.querySelector(".modal-header") as HTMLElement;
|
|
35
|
+
await expect(getComputedStyle(header).justifyContent).toBe("space-between");
|
|
36
|
+
// close button flex-shrink 0
|
|
37
37
|
const closeBtn = canvas.getByRole("button", { name: /Close dialog/i });
|
|
38
|
-
await expect(closeBtn).
|
|
39
|
-
|
|
40
|
-
await expect(closeBtn.
|
|
38
|
+
await expect(getComputedStyle(closeBtn).flexShrink).toBe("0");
|
|
39
|
+
// close button is to the right of title
|
|
40
|
+
await expect(closeBtn.getBoundingClientRect().left).toBeGreaterThan(
|
|
41
|
+
title.getBoundingClientRect().right
|
|
42
|
+
);
|
|
43
|
+
// close modal
|
|
41
44
|
await userEvent.click(closeBtn);
|
|
42
45
|
await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
|
|
43
46
|
}}>
|
|
@@ -48,25 +51,27 @@
|
|
|
48
51
|
onclose={() => { openDefault = false }}
|
|
49
52
|
>
|
|
50
53
|
<p>This action cannot be undone. Are you sure you want to proceed?</p>
|
|
54
|
+
{#snippet footer()}
|
|
55
|
+
<Button variant="ghost" onclick={() => { openDefault = false }}>Cancel</Button>
|
|
56
|
+
<Button variant="primary">OK</Button>
|
|
57
|
+
{/snippet}
|
|
51
58
|
</Modal>
|
|
52
59
|
</Story>
|
|
53
60
|
|
|
54
|
-
<!--
|
|
55
|
-
<Story name="Confirm
|
|
61
|
+
<!-- Confirm story: icon in body, no icon in header -->
|
|
62
|
+
<Story name="Confirm"
|
|
56
63
|
play={async ({ canvasElement, userEvent }) => {
|
|
57
64
|
const canvas = within(canvasElement);
|
|
58
|
-
await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
|
|
59
65
|
await userEvent.click(canvas.getByRole("button", { name: "Open Modal" }));
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
await expect(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
// .modal-variant-icon exists in body
|
|
67
|
+
const body = canvasElement.querySelector(".modal-body") as HTMLElement;
|
|
68
|
+
const confirmIcon = body.querySelector(".modal-variant-icon") as HTMLElement;
|
|
69
|
+
await expect(confirmIcon).not.toBeNull();
|
|
70
|
+
// title visible
|
|
71
|
+
const title = canvasElement.querySelector("h2#modal-title") as HTMLElement;
|
|
72
|
+
await expect(title).toBeVisible();
|
|
73
|
+
// close modal
|
|
68
74
|
const closeBtn = canvas.getByRole("button", { name: /Close dialog/i });
|
|
69
|
-
await expect(closeBtn.getAttribute("type")).toBe("button");
|
|
70
75
|
await userEvent.click(closeBtn);
|
|
71
76
|
await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
|
|
72
77
|
}}>
|
|
@@ -78,25 +83,28 @@
|
|
|
78
83
|
onclose={() => { openConfirm = false }}
|
|
79
84
|
>
|
|
80
85
|
<p>You are about to place an order for 1× Conduit PDX-2 at €200. Proceed?</p>
|
|
86
|
+
{#snippet footer()}
|
|
87
|
+
<Button variant="ghost" onclick={() => { openConfirm = false }}>Cancel</Button>
|
|
88
|
+
<Button variant="primary">Confirm</Button>
|
|
89
|
+
{/snippet}
|
|
81
90
|
</Modal>
|
|
82
91
|
</Story>
|
|
83
92
|
|
|
84
|
-
<!--
|
|
85
|
-
<Story name="Destructive
|
|
93
|
+
<!-- Destructive story: icon in body, title visible -->
|
|
94
|
+
<Story name="Destructive"
|
|
86
95
|
play={async ({ canvasElement, userEvent }) => {
|
|
87
96
|
const canvas = within(canvasElement);
|
|
88
|
-
await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
|
|
89
97
|
await userEvent.click(canvas.getByRole("button", { name: "Open Modal" }));
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const icon =
|
|
98
|
+
// .modal-variant-icon exists in body
|
|
99
|
+
const body = canvasElement.querySelector(".modal-body") as HTMLElement;
|
|
100
|
+
const icon = body.querySelector(".modal-variant-icon") as HTMLElement;
|
|
101
|
+
await expect(icon).not.toBeNull();
|
|
93
102
|
await expect(icon).toBeVisible();
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
103
|
+
// title visible
|
|
104
|
+
const title = canvasElement.querySelector("h2#modal-title") as HTMLElement;
|
|
105
|
+
await expect(title).toBeVisible();
|
|
106
|
+
// close modal
|
|
98
107
|
const closeBtn = canvas.getByRole("button", { name: /Close dialog/i });
|
|
99
|
-
await expect(closeBtn.getAttribute("type")).toBe("button");
|
|
100
108
|
await userEvent.click(closeBtn);
|
|
101
109
|
await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
|
|
102
110
|
}}>
|
|
@@ -108,85 +116,9 @@
|
|
|
108
116
|
onclose={() => { openDestructive = false }}
|
|
109
117
|
>
|
|
110
118
|
<p>This will permanently delete the item. This action cannot be undone.</p>
|
|
111
|
-
</Modal>
|
|
112
|
-
</Story>
|
|
113
|
-
|
|
114
|
-
<!-- AC-5, AC-6, AC-10, AC-13, AC-14 -->
|
|
115
|
-
<Story name="With Footer"
|
|
116
|
-
play={async ({ canvasElement, userEvent }) => {
|
|
117
|
-
const canvas = within(canvasElement);
|
|
118
|
-
await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
|
|
119
|
-
await userEvent.click(canvas.getByRole("button", { name: "Open Modal" }));
|
|
120
|
-
await expect(canvas.getByRole("dialog")).toBeVisible();
|
|
121
|
-
const footer = canvasElement.querySelector("footer");
|
|
122
|
-
await expect(footer).toBeInTheDocument();
|
|
123
|
-
const cancelBtn = canvas.getByRole("button", { name: /Cancel/i });
|
|
124
|
-
await expect(cancelBtn).toBeVisible();
|
|
125
|
-
const closeBtn = canvas.getByRole("button", { name: /Close dialog/i });
|
|
126
|
-
await expect(closeBtn.getAttribute("type")).toBe("button");
|
|
127
|
-
await userEvent.click(closeBtn);
|
|
128
|
-
await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
|
|
129
|
-
}}>
|
|
130
|
-
<Button onclick={() => { openWithFooter = true }}>Open Modal</Button>
|
|
131
|
-
<Modal
|
|
132
|
-
open={openWithFooter}
|
|
133
|
-
title="// WITH FOOTER"
|
|
134
|
-
onclose={() => { openWithFooter = false }}
|
|
135
|
-
>
|
|
136
|
-
<p>This modal includes a footer with action buttons.</p>
|
|
137
119
|
{#snippet footer()}
|
|
138
|
-
<Button variant="ghost" onclick={() => {
|
|
139
|
-
<Button variant="primary">
|
|
120
|
+
<Button variant="ghost" onclick={() => { openDestructive = false }}>Cancel</Button>
|
|
121
|
+
<Button variant="primary">Delete</Button>
|
|
140
122
|
{/snippet}
|
|
141
123
|
</Modal>
|
|
142
124
|
</Story>
|
|
143
|
-
|
|
144
|
-
<!-- AC-5, AC-6, AC-11, AC-13, AC-14 -->
|
|
145
|
-
<Story name="Backdrop Close"
|
|
146
|
-
play={async ({ canvasElement, userEvent }) => {
|
|
147
|
-
const canvas = within(canvasElement);
|
|
148
|
-
await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
|
|
149
|
-
await userEvent.click(canvas.getByRole("button", { name: "Open Modal" }));
|
|
150
|
-
const dialog = canvas.getByRole("dialog");
|
|
151
|
-
await expect(dialog).toBeVisible();
|
|
152
|
-
const closeBtn = canvas.getByRole("button", { name: /Close dialog/i });
|
|
153
|
-
await expect(closeBtn.getAttribute("type")).toBe("button");
|
|
154
|
-
// native .click() dispatches with event.target === dialog, triggering handleDialogClick
|
|
155
|
-
dialog.click();
|
|
156
|
-
// waitFor: Svelte $effect that closes the dialog is async
|
|
157
|
-
await waitFor(() => expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible());
|
|
158
|
-
}}>
|
|
159
|
-
<Button onclick={() => { openBackdrop = true }}>Open Modal</Button>
|
|
160
|
-
<Modal
|
|
161
|
-
open={openBackdrop}
|
|
162
|
-
title="// BACKDROP TEST"
|
|
163
|
-
onclose={() => { openBackdrop = false }}
|
|
164
|
-
>
|
|
165
|
-
<p>Click outside this panel on the backdrop to close.</p>
|
|
166
|
-
</Modal>
|
|
167
|
-
</Story>
|
|
168
|
-
|
|
169
|
-
<!-- AC-5, AC-6, AC-12, AC-13, AC-14 -->
|
|
170
|
-
<Story name="No Footer"
|
|
171
|
-
play={async ({ canvasElement, userEvent }) => {
|
|
172
|
-
const canvas = within(canvasElement);
|
|
173
|
-
await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
|
|
174
|
-
await userEvent.click(canvas.getByRole("button", { name: "Open Modal" }));
|
|
175
|
-
await expect(canvas.getByRole("dialog")).toBeVisible();
|
|
176
|
-
await expect(canvasElement.querySelector("footer")).toBeNull();
|
|
177
|
-
const closeBtn = canvas.getByRole("button", { name: /Close dialog/i });
|
|
178
|
-
await expect(closeBtn).toBeVisible();
|
|
179
|
-
await expect(closeBtn).toBeEnabled();
|
|
180
|
-
await expect(closeBtn.getAttribute("type")).toBe("button");
|
|
181
|
-
await userEvent.click(closeBtn);
|
|
182
|
-
await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
|
|
183
|
-
}}>
|
|
184
|
-
<Button onclick={() => { openNoFooter = true }}>Open Modal</Button>
|
|
185
|
-
<Modal
|
|
186
|
-
open={openNoFooter}
|
|
187
|
-
title="// NO FOOTER"
|
|
188
|
-
onclose={() => { openNoFooter = false }}
|
|
189
|
-
>
|
|
190
|
-
<p>Informational content shown without a footer action bar.</p>
|
|
191
|
-
</Modal>
|
|
192
|
-
</Story>
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { HTMLDialogAttributes } from 'svelte/elements'
|
|
3
3
|
import type { Snippet } from 'svelte'
|
|
4
|
-
import Inline from '../layout/Inline.svelte'
|
|
5
|
-
import Spread from '../layout/Spread.svelte'
|
|
6
4
|
import Text from '../primitives/Text.svelte'
|
|
7
5
|
import Button from '../primitives/Button.svelte'
|
|
8
6
|
|
|
@@ -77,24 +75,24 @@
|
|
|
77
75
|
>
|
|
78
76
|
<div class="modal-inner">
|
|
79
77
|
<header class="modal-header">
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
<span class="modal-icon" aria-hidden="true">!</span>
|
|
83
|
-
{/if}
|
|
84
|
-
<Text variant="mono" as="h2" id="modal-title" size="lg" class="modal-title">{title}</Text>
|
|
85
|
-
<Button variant="ghost" type="button" aria-label="Close dialog" onclick={handleClose}>×</Button>
|
|
86
|
-
</Inline>
|
|
78
|
+
<Text variant="mono" size="xs" as="h2" id="modal-title" class="modal-title">{title}</Text>
|
|
79
|
+
<Button variant="ghost" type="button" aria-label="Close dialog" onclick={handleClose} class="modal-close">×</Button>
|
|
87
80
|
</header>
|
|
88
81
|
|
|
89
82
|
<div class="modal-body">
|
|
90
|
-
{
|
|
83
|
+
{#if variant === 'confirm' || variant === 'destructive'}
|
|
84
|
+
<div class="modal-body-row">
|
|
85
|
+
<div class="modal-variant-icon" aria-hidden="true">!</div>
|
|
86
|
+
<div>{@render children?.()}</div>
|
|
87
|
+
</div>
|
|
88
|
+
{:else}
|
|
89
|
+
{@render children?.()}
|
|
90
|
+
{/if}
|
|
91
91
|
</div>
|
|
92
92
|
|
|
93
93
|
{#if footer}
|
|
94
94
|
<footer class="modal-footer">
|
|
95
|
-
|
|
96
|
-
{@render footer()}
|
|
97
|
-
</Spread>
|
|
95
|
+
{@render footer()}
|
|
98
96
|
</footer>
|
|
99
97
|
{/if}
|
|
100
98
|
</div>
|
|
@@ -122,7 +120,7 @@
|
|
|
122
120
|
}
|
|
123
121
|
|
|
124
122
|
.modal-inner {
|
|
125
|
-
background: var(--
|
|
123
|
+
background: var(--overlay);
|
|
126
124
|
border: 1px solid var(--rule-strong);
|
|
127
125
|
width: 100%;
|
|
128
126
|
max-width: 480px;
|
|
@@ -137,6 +135,8 @@
|
|
|
137
135
|
border-bottom: 1px solid var(--rule);
|
|
138
136
|
display: flex;
|
|
139
137
|
align-items: center;
|
|
138
|
+
justify-content: space-between;
|
|
139
|
+
gap: var(--u);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
.modal-footer {
|
|
@@ -144,31 +144,38 @@
|
|
|
144
144
|
border-top: 1px solid var(--rule);
|
|
145
145
|
display: flex;
|
|
146
146
|
align-items: center;
|
|
147
|
-
justify-content:
|
|
147
|
+
justify-content: flex-end;
|
|
148
148
|
gap: var(--u2);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
.modal-icon {
|
|
152
|
-
display:
|
|
151
|
+
.modal-variant-icon {
|
|
152
|
+
display: flex;
|
|
153
153
|
align-items: center;
|
|
154
154
|
justify-content: center;
|
|
155
|
-
width:
|
|
156
|
-
height:
|
|
155
|
+
width: 40px;
|
|
156
|
+
height: 40px;
|
|
157
157
|
border-radius: 50%;
|
|
158
158
|
font-family: var(--mono);
|
|
159
|
-
font-size:
|
|
159
|
+
font-size: 18px;
|
|
160
160
|
font-weight: 700;
|
|
161
161
|
flex-shrink: 0;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
.modal--confirm .modal-icon {
|
|
165
|
-
|
|
166
|
-
color: var(--
|
|
164
|
+
.modal--confirm .modal-variant-icon {
|
|
165
|
+
border: 2px solid var(--amber);
|
|
166
|
+
color: var(--amber);
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
.modal--destructive .modal-icon {
|
|
170
|
-
|
|
171
|
-
color: var(--
|
|
169
|
+
.modal--destructive .modal-variant-icon {
|
|
170
|
+
border: 2px solid var(--danger);
|
|
171
|
+
color: var(--danger);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.modal-body-row {
|
|
175
|
+
display: flex;
|
|
176
|
+
flex-direction: row;
|
|
177
|
+
align-items: flex-start;
|
|
178
|
+
gap: var(--u2);
|
|
172
179
|
}
|
|
173
180
|
|
|
174
181
|
.modal-body {
|
|
@@ -181,5 +188,19 @@
|
|
|
181
188
|
|
|
182
189
|
.modal-header :global(.modal-title) {
|
|
183
190
|
flex: 1;
|
|
191
|
+
color: var(--ink);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.modal-header :global(.modal-close) {
|
|
195
|
+
flex-shrink: 0;
|
|
196
|
+
font-size: 18px;
|
|
197
|
+
line-height: 1;
|
|
198
|
+
width: 28px;
|
|
199
|
+
height: 28px;
|
|
200
|
+
display: inline-flex;
|
|
201
|
+
align-items: center;
|
|
202
|
+
justify-content: center;
|
|
203
|
+
padding: 0;
|
|
204
|
+
color: var(--ink-faint);
|
|
184
205
|
}
|
|
185
206
|
</style>
|