@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.
Files changed (41) hide show
  1. package/README.md +1 -1
  2. package/dist/components/cards/NoteCard.svelte +1 -1
  3. package/dist/components/cards/ProductCard.svelte +1 -1
  4. package/dist/components/cards/ProjectCard.svelte +1 -1
  5. package/dist/components/feedback/Alert.stories.svelte +131 -0
  6. package/dist/components/feedback/Alert.svelte +117 -0
  7. package/dist/components/{patterns → feedback}/Alert.svelte.d.ts +4 -2
  8. package/dist/components/feedback/Modal.stories.svelte +49 -117
  9. package/dist/components/feedback/Modal.svelte +47 -26
  10. package/dist/components/feedback/Toast.stories.svelte +89 -53
  11. package/dist/components/feedback/Toast.svelte +5 -64
  12. package/dist/components/feedback/Toast.svelte.d.ts +2 -0
  13. package/dist/components/feedback/ToastRegion.stories.svelte +11 -0
  14. package/dist/components/feedback/ToastRegion.svelte +1 -0
  15. package/dist/components/feedback/index.d.ts +1 -0
  16. package/dist/components/feedback/index.js +1 -0
  17. package/dist/components/forms/Input.stories.svelte +15 -0
  18. package/dist/components/forms/Input.svelte +11 -0
  19. package/dist/components/forms/InputWrap.composition.stories.svelte +11 -0
  20. package/dist/components/forms/InputWrap.stories.svelte +9 -0
  21. package/dist/components/forms/InputWrap.svelte +13 -11
  22. package/dist/components/forms/Switch.stories.svelte +55 -47
  23. package/dist/components/forms/Switch.svelte +4 -3
  24. package/dist/components/patterns/CtaBlock.svelte +1 -0
  25. package/dist/components/patterns/PageHero.stories.svelte +39 -0
  26. package/dist/components/patterns/PageHero.svelte +17 -3
  27. package/dist/components/patterns/PageHero.svelte.d.ts +5 -1
  28. package/dist/components/patterns/index.d.ts +0 -1
  29. package/dist/components/patterns/index.js +0 -1
  30. package/dist/components/primitives/Heading.stories.svelte +6 -30
  31. package/dist/components/primitives/Heading.svelte +2 -11
  32. package/dist/components/primitives/Heading.svelte.d.ts +0 -3
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.js +2 -2
  35. package/dist/stores/toast.d.ts +2 -0
  36. package/dist/stores/toast.js +1 -0
  37. package/dist/tokens/tokens.css +2 -0
  38. package/package.json +1 -1
  39. package/dist/components/patterns/Alert.stories.svelte +0 -63
  40. package/dist/components/patterns/Alert.svelte +0 -91
  41. /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: 'ok' })}>Save</button>
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} size="lg">{title}</Heading>
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} size="lg">{name}</Heading>
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>
@@ -49,7 +49,7 @@
49
49
  {/each}
50
50
  </Inline>
51
51
  {/if}
52
- <Heading level={3} size="lg">{title}</Heading>
52
+ <Heading level={3}>{title}</Heading>
53
53
  <p class="card-desc">{description}</p>
54
54
  </Stack>
55
55
  </div>
@@ -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 = 'ok' | 'amber' | 'danger' | 'info';
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: string;
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
- <!-- AC-5, AC-6, AC-7, AC-13, AC-14 -->
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
- const dialog = canvas.getByRole("dialog");
32
- await expect(dialog).toBeVisible();
33
- await expect(dialog.getAttribute("aria-modal")).toBe("true");
34
- await expect(dialog.getAttribute("aria-labelledby")).toBe("modal-title");
35
- const heading = canvas.getByRole("heading", { level: 2, name: /CONFIRM ACTION/i });
36
- await expect(heading).toBeVisible();
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).toBeVisible();
39
- await expect(closeBtn).toBeEnabled();
40
- await expect(closeBtn.getAttribute("type")).toBe("button");
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
- <!-- AC-5, AC-6, AC-8, AC-13, AC-14 -->
55
- <Story name="Confirm Variant"
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
- const dialog = canvas.getByRole("dialog");
61
- await expect(dialog).toBeVisible();
62
- const icon = canvasElement.querySelector(".modal-icon") as HTMLElement;
63
- await expect(icon).toBeVisible();
64
- await expect(icon.getAttribute("aria-hidden")).toBe("true");
65
- await expect(icon.textContent?.trim()).toBe("!");
66
- const amberColor = resolveTokenColor("--amber");
67
- await expect(getComputedStyle(icon).backgroundColor).toBe(amberColor);
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
- <!-- AC-5, AC-6, AC-9, AC-13, AC-14 -->
85
- <Story name="Destructive Variant"
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
- const dialog = canvas.getByRole("dialog");
91
- await expect(dialog).toBeVisible();
92
- const icon = canvasElement.querySelector(".modal-icon") as HTMLElement;
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
- await expect(icon.getAttribute("aria-hidden")).toBe("true");
95
- await expect(icon.textContent?.trim()).toBe("!");
96
- const dangerColor = resolveTokenColor("--danger");
97
- await expect(getComputedStyle(icon).backgroundColor).toBe(dangerColor);
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={() => { openWithFooter = false }}>Cancel</Button>
139
- <Button variant="primary">Confirm</Button>
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
- <Inline gap="xs">
81
- {#if variant === 'destructive' || variant === 'confirm'}
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
- {@render children?.()}
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
- <Spread>
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(--bg-elev);
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: space-between;
147
+ justify-content: flex-end;
148
148
  gap: var(--u2);
149
149
  }
150
150
 
151
- .modal-icon {
152
- display: inline-flex;
151
+ .modal-variant-icon {
152
+ display: flex;
153
153
  align-items: center;
154
154
  justify-content: center;
155
- width: 22px;
156
- height: 22px;
155
+ width: 40px;
156
+ height: 40px;
157
157
  border-radius: 50%;
158
158
  font-family: var(--mono);
159
- font-size: 13px;
159
+ font-size: 18px;
160
160
  font-weight: 700;
161
161
  flex-shrink: 0;
162
162
  }
163
163
 
164
- .modal--confirm .modal-icon {
165
- background: var(--amber);
166
- color: var(--bg);
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
- background: var(--danger);
171
- color: var(--bg);
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>