@dxlbnl/ui 0.1.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 (208) hide show
  1. package/README.md +94 -0
  2. package/dist/components/cards/Card.stories.svelte +82 -0
  3. package/dist/components/cards/Card.stories.svelte.d.ts +19 -0
  4. package/dist/components/cards/Card.svelte +28 -0
  5. package/dist/components/cards/Card.svelte.d.ts +12 -0
  6. package/dist/components/cards/NoteCard.stories.svelte +94 -0
  7. package/dist/components/cards/NoteCard.stories.svelte.d.ts +19 -0
  8. package/dist/components/cards/NoteCard.svelte +89 -0
  9. package/dist/components/cards/NoteCard.svelte.d.ts +18 -0
  10. package/dist/components/cards/ProductCard.stories.svelte +98 -0
  11. package/dist/components/cards/ProductCard.stories.svelte.d.ts +19 -0
  12. package/dist/components/cards/ProductCard.svelte +150 -0
  13. package/dist/components/cards/ProductCard.svelte.d.ts +22 -0
  14. package/dist/components/cards/ProjectCard.stories.svelte +88 -0
  15. package/dist/components/cards/ProjectCard.stories.svelte.d.ts +19 -0
  16. package/dist/components/cards/ProjectCard.svelte +109 -0
  17. package/dist/components/cards/ProjectCard.svelte.d.ts +20 -0
  18. package/dist/components/cards/index.d.ts +4 -0
  19. package/dist/components/cards/index.js +4 -0
  20. package/dist/components/data/Accordion.stories.svelte +316 -0
  21. package/dist/components/data/Accordion.stories.svelte.d.ts +19 -0
  22. package/dist/components/data/Accordion.svelte +23 -0
  23. package/dist/components/data/Accordion.svelte.d.ts +9 -0
  24. package/dist/components/data/AccordionItem.svelte +112 -0
  25. package/dist/components/data/AccordionItem.svelte.d.ts +11 -0
  26. package/dist/components/data/Table.composition.stories.svelte +67 -0
  27. package/dist/components/data/Table.composition.stories.svelte.d.ts +19 -0
  28. package/dist/components/data/Table.stories.svelte +137 -0
  29. package/dist/components/data/Table.stories.svelte.d.ts +19 -0
  30. package/dist/components/data/Table.svelte +83 -0
  31. package/dist/components/data/Table.svelte.d.ts +14 -0
  32. package/dist/components/data/Tabs.stories.svelte +386 -0
  33. package/dist/components/data/Tabs.stories.svelte.d.ts +19 -0
  34. package/dist/components/data/Tabs.svelte +142 -0
  35. package/dist/components/data/Tabs.svelte.d.ts +19 -0
  36. package/dist/components/data/index.d.ts +4 -0
  37. package/dist/components/data/index.js +4 -0
  38. package/dist/components/feedback/Modal.stories.svelte +192 -0
  39. package/dist/components/feedback/Modal.stories.svelte.d.ts +4 -0
  40. package/dist/components/feedback/Modal.svelte +185 -0
  41. package/dist/components/feedback/Modal.svelte.d.ts +19 -0
  42. package/dist/components/feedback/Toast.stories.svelte +203 -0
  43. package/dist/components/feedback/Toast.stories.svelte.d.ts +19 -0
  44. package/dist/components/feedback/Toast.svelte +109 -0
  45. package/dist/components/feedback/Toast.svelte.d.ts +15 -0
  46. package/dist/components/feedback/ToastRegion.stories.svelte +193 -0
  47. package/dist/components/feedback/ToastRegion.stories.svelte.d.ts +19 -0
  48. package/dist/components/feedback/ToastRegion.svelte +102 -0
  49. package/dist/components/feedback/ToastRegion.svelte.d.ts +9 -0
  50. package/dist/components/feedback/index.d.ts +3 -0
  51. package/dist/components/feedback/index.js +3 -0
  52. package/dist/components/forms/Checkbox.stories.svelte +103 -0
  53. package/dist/components/forms/Checkbox.stories.svelte.d.ts +19 -0
  54. package/dist/components/forms/Checkbox.svelte +150 -0
  55. package/dist/components/forms/Checkbox.svelte.d.ts +11 -0
  56. package/dist/components/forms/Field.stories.svelte +113 -0
  57. package/dist/components/forms/Field.stories.svelte.d.ts +19 -0
  58. package/dist/components/forms/Field.svelte +77 -0
  59. package/dist/components/forms/Field.svelte.d.ts +17 -0
  60. package/dist/components/forms/Input.stories.svelte +58 -0
  61. package/dist/components/forms/Input.stories.svelte.d.ts +19 -0
  62. package/dist/components/forms/Input.svelte +64 -0
  63. package/dist/components/forms/Input.svelte.d.ts +9 -0
  64. package/dist/components/forms/InputWrap.composition.stories.svelte +32 -0
  65. package/dist/components/forms/InputWrap.composition.stories.svelte.d.ts +19 -0
  66. package/dist/components/forms/InputWrap.stories.svelte +53 -0
  67. package/dist/components/forms/InputWrap.stories.svelte.d.ts +19 -0
  68. package/dist/components/forms/InputWrap.svelte +128 -0
  69. package/dist/components/forms/InputWrap.svelte.d.ts +21 -0
  70. package/dist/components/forms/Radio.stories.svelte +70 -0
  71. package/dist/components/forms/Radio.stories.svelte.d.ts +19 -0
  72. package/dist/components/forms/Radio.svelte +109 -0
  73. package/dist/components/forms/Radio.svelte.d.ts +9 -0
  74. package/dist/components/forms/RadioGroup.stories.svelte +115 -0
  75. package/dist/components/forms/RadioGroup.stories.svelte.d.ts +19 -0
  76. package/dist/components/forms/RadioGroup.svelte +116 -0
  77. package/dist/components/forms/RadioGroup.svelte.d.ts +24 -0
  78. package/dist/components/forms/Select.stories.svelte +168 -0
  79. package/dist/components/forms/Select.stories.svelte.d.ts +19 -0
  80. package/dist/components/forms/Select.svelte +262 -0
  81. package/dist/components/forms/Select.svelte.d.ts +23 -0
  82. package/dist/components/forms/Switch.stories.svelte +86 -0
  83. package/dist/components/forms/Switch.stories.svelte.d.ts +19 -0
  84. package/dist/components/forms/Switch.svelte +113 -0
  85. package/dist/components/forms/Switch.svelte.d.ts +11 -0
  86. package/dist/components/forms/Textarea.stories.svelte +40 -0
  87. package/dist/components/forms/Textarea.stories.svelte.d.ts +19 -0
  88. package/dist/components/forms/Textarea.svelte +66 -0
  89. package/dist/components/forms/Textarea.svelte.d.ts +9 -0
  90. package/dist/components/forms/field-context.d.ts +7 -0
  91. package/dist/components/forms/field-context.js +1 -0
  92. package/dist/components/forms/index.d.ts +9 -0
  93. package/dist/components/forms/index.js +9 -0
  94. package/dist/components/layout/Container.stories.svelte +67 -0
  95. package/dist/components/layout/Container.stories.svelte.d.ts +19 -0
  96. package/dist/components/layout/Container.svelte +52 -0
  97. package/dist/components/layout/Container.svelte.d.ts +14 -0
  98. package/dist/components/layout/Grid.stories.svelte +109 -0
  99. package/dist/components/layout/Grid.stories.svelte.d.ts +19 -0
  100. package/dist/components/layout/Grid.svelte +54 -0
  101. package/dist/components/layout/Grid.svelte.d.ts +19 -0
  102. package/dist/components/layout/Inline.stories.svelte +136 -0
  103. package/dist/components/layout/Inline.stories.svelte.d.ts +19 -0
  104. package/dist/components/layout/Inline.svelte +46 -0
  105. package/dist/components/layout/Inline.svelte.d.ts +19 -0
  106. package/dist/components/layout/Prose.stories.svelte +423 -0
  107. package/dist/components/layout/Prose.stories.svelte.d.ts +19 -0
  108. package/dist/components/layout/Prose.svelte +176 -0
  109. package/dist/components/layout/Prose.svelte.d.ts +12 -0
  110. package/dist/components/layout/Rule.stories.svelte +80 -0
  111. package/dist/components/layout/Rule.stories.svelte.d.ts +19 -0
  112. package/dist/components/layout/Rule.svelte +33 -0
  113. package/dist/components/layout/Rule.svelte.d.ts +9 -0
  114. package/dist/components/layout/Spread.stories.svelte +118 -0
  115. package/dist/components/layout/Spread.stories.svelte.d.ts +19 -0
  116. package/dist/components/layout/Spread.svelte +38 -0
  117. package/dist/components/layout/Spread.svelte.d.ts +16 -0
  118. package/dist/components/layout/Stack.stories.svelte +90 -0
  119. package/dist/components/layout/Stack.stories.svelte.d.ts +19 -0
  120. package/dist/components/layout/Stack.svelte +37 -0
  121. package/dist/components/layout/Stack.svelte.d.ts +16 -0
  122. package/dist/components/layout/index.d.ts +7 -0
  123. package/dist/components/layout/index.js +7 -0
  124. package/dist/components/navigation/Breadcrumb.stories.svelte +122 -0
  125. package/dist/components/navigation/Breadcrumb.stories.svelte.d.ts +19 -0
  126. package/dist/components/navigation/Breadcrumb.svelte +70 -0
  127. package/dist/components/navigation/Breadcrumb.svelte.d.ts +13 -0
  128. package/dist/components/navigation/Nav.stories.svelte +323 -0
  129. package/dist/components/navigation/Nav.stories.svelte.d.ts +19 -0
  130. package/dist/components/navigation/Nav.svelte +257 -0
  131. package/dist/components/navigation/Nav.svelte.d.ts +21 -0
  132. package/dist/components/navigation/index.d.ts +2 -0
  133. package/dist/components/navigation/index.js +2 -0
  134. package/dist/components/patterns/ActivityRow.stories.svelte +45 -0
  135. package/dist/components/patterns/ActivityRow.stories.svelte.d.ts +19 -0
  136. package/dist/components/patterns/ActivityRow.svelte +69 -0
  137. package/dist/components/patterns/ActivityRow.svelte.d.ts +16 -0
  138. package/dist/components/patterns/Alert.stories.svelte +63 -0
  139. package/dist/components/patterns/Alert.stories.svelte.d.ts +19 -0
  140. package/dist/components/patterns/Alert.svelte +91 -0
  141. package/dist/components/patterns/Alert.svelte.d.ts +16 -0
  142. package/dist/components/patterns/CtaBlock.stories.svelte +62 -0
  143. package/dist/components/patterns/CtaBlock.stories.svelte.d.ts +19 -0
  144. package/dist/components/patterns/CtaBlock.svelte +80 -0
  145. package/dist/components/patterns/CtaBlock.svelte.d.ts +16 -0
  146. package/dist/components/patterns/KvList.stories.svelte +48 -0
  147. package/dist/components/patterns/KvList.stories.svelte.d.ts +19 -0
  148. package/dist/components/patterns/KvList.svelte +65 -0
  149. package/dist/components/patterns/KvList.svelte.d.ts +15 -0
  150. package/dist/components/patterns/PageHero.stories.svelte +62 -0
  151. package/dist/components/patterns/PageHero.stories.svelte.d.ts +19 -0
  152. package/dist/components/patterns/PageHero.svelte +62 -0
  153. package/dist/components/patterns/PageHero.svelte.d.ts +14 -0
  154. package/dist/components/patterns/ProgressBar.stories.svelte +83 -0
  155. package/dist/components/patterns/ProgressBar.stories.svelte.d.ts +19 -0
  156. package/dist/components/patterns/ProgressBar.svelte +71 -0
  157. package/dist/components/patterns/ProgressBar.svelte.d.ts +13 -0
  158. package/dist/components/patterns/SectionFoot.stories.svelte +37 -0
  159. package/dist/components/patterns/SectionFoot.stories.svelte.d.ts +19 -0
  160. package/dist/components/patterns/SectionFoot.svelte +70 -0
  161. package/dist/components/patterns/SectionFoot.svelte.d.ts +15 -0
  162. package/dist/components/patterns/SectionHead.stories.svelte +67 -0
  163. package/dist/components/patterns/SectionHead.stories.svelte.d.ts +19 -0
  164. package/dist/components/patterns/SectionHead.svelte +54 -0
  165. package/dist/components/patterns/SectionHead.svelte.d.ts +14 -0
  166. package/dist/components/patterns/StatCard.stories.svelte +59 -0
  167. package/dist/components/patterns/StatCard.stories.svelte.d.ts +19 -0
  168. package/dist/components/patterns/StatCard.svelte +57 -0
  169. package/dist/components/patterns/StatCard.svelte.d.ts +15 -0
  170. package/dist/components/patterns/index.d.ts +9 -0
  171. package/dist/components/patterns/index.js +9 -0
  172. package/dist/components/primitives/Button.stories.svelte +132 -0
  173. package/dist/components/primitives/Button.stories.svelte.d.ts +19 -0
  174. package/dist/components/primitives/Button.svelte +142 -0
  175. package/dist/components/primitives/Button.svelte.d.ts +16 -0
  176. package/dist/components/primitives/Heading.stories.svelte +137 -0
  177. package/dist/components/primitives/Heading.stories.svelte.d.ts +19 -0
  178. package/dist/components/primitives/Heading.svelte +107 -0
  179. package/dist/components/primitives/Heading.svelte.d.ts +23 -0
  180. package/dist/components/primitives/Led.stories.svelte +63 -0
  181. package/dist/components/primitives/Led.stories.svelte.d.ts +19 -0
  182. package/dist/components/primitives/Led.svelte +65 -0
  183. package/dist/components/primitives/Led.svelte.d.ts +11 -0
  184. package/dist/components/primitives/TagPill.stories.svelte +90 -0
  185. package/dist/components/primitives/TagPill.stories.svelte.d.ts +19 -0
  186. package/dist/components/primitives/TagPill.svelte +44 -0
  187. package/dist/components/primitives/TagPill.svelte.d.ts +9 -0
  188. package/dist/components/primitives/Text.stories.svelte +252 -0
  189. package/dist/components/primitives/Text.stories.svelte.d.ts +19 -0
  190. package/dist/components/primitives/Text.svelte +101 -0
  191. package/dist/components/primitives/Text.svelte.d.ts +25 -0
  192. package/dist/components/primitives/index.d.ts +5 -0
  193. package/dist/components/primitives/index.js +5 -0
  194. package/dist/index.d.ts +10 -0
  195. package/dist/index.js +10 -0
  196. package/dist/stores/toast.d.ts +19 -0
  197. package/dist/stores/toast.js +22 -0
  198. package/dist/storybook-utils.d.ts +11 -0
  199. package/dist/storybook-utils.js +29 -0
  200. package/dist/tokens/ColorSwatch.svelte +73 -0
  201. package/dist/tokens/ColorSwatch.svelte.d.ts +10 -0
  202. package/dist/tokens/layout.css +144 -0
  203. package/dist/tokens/patterns.css +281 -0
  204. package/dist/tokens/tokens.css +96 -0
  205. package/dist/tokens/tokens.stories.svelte +107 -0
  206. package/dist/tokens/tokens.stories.svelte.d.ts +18 -0
  207. package/dist/tokens/typography.css +159 -0
  208. package/package.json +62 -0
@@ -0,0 +1,142 @@
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
+ import Button from '../primitives/Button.svelte'
6
+
7
+ type TabsVariant = 'underline' | 'pill'
8
+
9
+ interface TabItem {
10
+ id: string
11
+ label: string
12
+ panel: Snippet
13
+ }
14
+
15
+ interface TabsProps extends HTMLAttributes<HTMLDivElement> {
16
+ /** Array of `{ id, label, panel }` tab descriptors. */
17
+ tabs: TabItem[]
18
+ /** `id` of the initially active tab — defaults to the first tab. */
19
+ active?: string
20
+ /** Tab indicator style. @default 'underline' */
21
+ variant?: TabsVariant
22
+ }
23
+
24
+ let { tabs, active = tabs[0]?.id, variant = 'underline', ...rest }: TabsProps = $props()
25
+
26
+ let activeId = $state(active)
27
+
28
+ function handleTabKeydown(e: KeyboardEvent, index: number) {
29
+ const count = tabs.length
30
+ let target = -1
31
+ switch (e.key) {
32
+ case 'ArrowRight': e.preventDefault(); target = (index + 1) % count; break
33
+ case 'ArrowLeft': e.preventDefault(); target = (index - 1 + count) % count; break
34
+ case 'Home': e.preventDefault(); target = 0; break
35
+ case 'End': e.preventDefault(); target = count - 1; break
36
+ }
37
+ if (target >= 0) {
38
+ activeId = tabs[target].id
39
+ document.getElementById(`tab-${tabs[target].id}`)?.focus()
40
+ }
41
+ }
42
+ </script>
43
+
44
+ <Stack class="tabs tabs--{variant}" {...rest}>
45
+ <div class="tab-bar" role="tablist">
46
+ {#each tabs as tab, i}
47
+ <Button
48
+ variant="ghost"
49
+ class={activeId === tab.id ? 'tab tab--active' : 'tab'}
50
+ role="tab"
51
+ id="tab-{tab.id}"
52
+ aria-selected={activeId === tab.id}
53
+ aria-controls="panel-{tab.id}"
54
+ onclick={() => (activeId = tab.id)}
55
+ onkeydown={(e) => handleTabKeydown(e, i)}
56
+ >
57
+ {tab.label}
58
+ </Button>
59
+ {/each}
60
+ </div>
61
+
62
+ {#each tabs as tab}
63
+ <div
64
+ class="tab-panel"
65
+ role="tabpanel"
66
+ id="panel-{tab.id}"
67
+ aria-labelledby="tab-{tab.id}"
68
+ hidden={activeId !== tab.id}
69
+ >
70
+ {@render tab.panel()}
71
+ </div>
72
+ {/each}
73
+ </Stack>
74
+
75
+ <style>
76
+ .tab-bar {
77
+ display: flex;
78
+ border-bottom: 1px solid var(--rule);
79
+ gap: 0;
80
+ }
81
+
82
+ /* Use .btn.tab (two classes) to beat .btn-ghost (one class) in specificity */
83
+ :global(.btn.tab) {
84
+ font-family: var(--mono);
85
+ font-size: 12px;
86
+ letter-spacing: 0.08em;
87
+ text-transform: uppercase;
88
+ padding: 10px 18px;
89
+ cursor: pointer;
90
+ color: var(--ink-faint);
91
+ border: none;
92
+ background: transparent;
93
+ border-bottom: 2px solid transparent;
94
+ margin-bottom: -1px;
95
+ transition:
96
+ color var(--transition),
97
+ border-color var(--transition);
98
+ white-space: nowrap;
99
+ }
100
+
101
+ :global(.btn.tab:hover) {
102
+ color: var(--ink);
103
+ }
104
+
105
+ :global(.btn.tab--active) {
106
+ color: var(--ink);
107
+ border-bottom-color: var(--amber);
108
+ }
109
+
110
+ .tab-panel {
111
+ padding: 16px;
112
+ background: var(--bg-rail);
113
+ border: 1px solid var(--rule);
114
+ border-top: none;
115
+ font-size: 13px;
116
+ line-height: 1.6;
117
+ color: var(--ink-dim);
118
+ }
119
+
120
+ /* Pill variant */
121
+ :global(.tabs--pill) .tab-bar {
122
+ gap: 4px;
123
+ padding: 4px;
124
+ background: var(--bg-rail);
125
+ border: 1px solid var(--rule);
126
+ width: fit-content;
127
+ border-bottom: 1px solid var(--rule);
128
+ }
129
+
130
+ :global(.tabs--pill .btn.tab) {
131
+ font-size: 11px;
132
+ padding: 5px 14px;
133
+ border-bottom: none;
134
+ margin-bottom: 0;
135
+ }
136
+
137
+ :global(.tabs--pill .btn.tab--active) {
138
+ background: var(--amber);
139
+ color: var(--bg);
140
+ border-bottom-color: transparent;
141
+ }
142
+ </style>
@@ -0,0 +1,19 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ import type { Snippet } from 'svelte';
3
+ type TabsVariant = 'underline' | 'pill';
4
+ interface TabItem {
5
+ id: string;
6
+ label: string;
7
+ panel: Snippet;
8
+ }
9
+ interface TabsProps extends HTMLAttributes<HTMLDivElement> {
10
+ /** Array of `{ id, label, panel }` tab descriptors. */
11
+ tabs: TabItem[];
12
+ /** `id` of the initially active tab — defaults to the first tab. */
13
+ active?: string;
14
+ /** Tab indicator style. @default 'underline' */
15
+ variant?: TabsVariant;
16
+ }
17
+ declare const Tabs: import("svelte").Component<TabsProps, {}, "">;
18
+ type Tabs = ReturnType<typeof Tabs>;
19
+ export default Tabs;
@@ -0,0 +1,4 @@
1
+ export { default as Accordion } from './Accordion.svelte';
2
+ export { default as AccordionItem } from './AccordionItem.svelte';
3
+ export { default as Tabs } from './Tabs.svelte';
4
+ export { default as Table } from './Table.svelte';
@@ -0,0 +1,4 @@
1
+ export { default as Accordion } from './Accordion.svelte';
2
+ export { default as AccordionItem } from './AccordionItem.svelte';
3
+ export { default as Tabs } from './Tabs.svelte';
4
+ export { default as Table } from './Table.svelte';
@@ -0,0 +1,192 @@
1
+ <script module lang="ts">
2
+ import { defineMeta } from "@storybook/addon-svelte-csf";
3
+ import { expect, within, waitFor } from "storybook/test";
4
+ import Modal from "./Modal.svelte";
5
+ import Button from "../primitives/Button.svelte";
6
+ import { resolveTokenColor } from "../../storybook-utils.js";
7
+
8
+ const { Story } = defineMeta({
9
+ title: "Feedback/Modal",
10
+ tags: ["autodocs"],
11
+ });
12
+ </script>
13
+
14
+ <script lang="ts">
15
+ let openDefault = $state(false);
16
+ let openConfirm = $state(false);
17
+ let openDestructive = $state(false);
18
+ let openWithFooter = $state(false);
19
+ let openBackdrop = $state(false);
20
+ let openNoFooter = $state(false);
21
+ </script>
22
+
23
+ <!-- AC-5, AC-6, AC-7, AC-13, AC-14 -->
24
+ <Story name="Default"
25
+ play={async ({ canvasElement, userEvent }) => {
26
+ const canvas = within(canvasElement);
27
+ 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
+ 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();
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");
41
+ await userEvent.click(closeBtn);
42
+ await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
43
+ }}>
44
+ <Button onclick={() => { openDefault = true }}>Open Modal</Button>
45
+ <Modal
46
+ open={openDefault}
47
+ title="// CONFIRM ACTION"
48
+ onclose={() => { openDefault = false }}
49
+ >
50
+ <p>This action cannot be undone. Are you sure you want to proceed?</p>
51
+ </Modal>
52
+ </Story>
53
+
54
+ <!-- AC-5, AC-6, AC-8, AC-13, AC-14 -->
55
+ <Story name="Confirm Variant"
56
+ play={async ({ canvasElement, userEvent }) => {
57
+ const canvas = within(canvasElement);
58
+ await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
59
+ 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);
68
+ const closeBtn = canvas.getByRole("button", { name: /Close dialog/i });
69
+ await expect(closeBtn.getAttribute("type")).toBe("button");
70
+ await userEvent.click(closeBtn);
71
+ await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
72
+ }}>
73
+ <Button onclick={() => { openConfirm = true }}>Open Modal</Button>
74
+ <Modal
75
+ open={openConfirm}
76
+ title="// CONFIRM PURCHASE"
77
+ variant="confirm"
78
+ onclose={() => { openConfirm = false }}
79
+ >
80
+ <p>You are about to place an order for 1× Conduit PDX-2 at €200. Proceed?</p>
81
+ </Modal>
82
+ </Story>
83
+
84
+ <!-- AC-5, AC-6, AC-9, AC-13, AC-14 -->
85
+ <Story name="Destructive Variant"
86
+ play={async ({ canvasElement, userEvent }) => {
87
+ const canvas = within(canvasElement);
88
+ await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
89
+ 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;
93
+ 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);
98
+ const closeBtn = canvas.getByRole("button", { name: /Close dialog/i });
99
+ await expect(closeBtn.getAttribute("type")).toBe("button");
100
+ await userEvent.click(closeBtn);
101
+ await expect(canvas.getByRole("dialog", { hidden: true })).not.toBeVisible();
102
+ }}>
103
+ <Button onclick={() => { openDestructive = true }}>Open Modal</Button>
104
+ <Modal
105
+ open={openDestructive}
106
+ title="// DELETE ITEM"
107
+ variant="destructive"
108
+ onclose={() => { openDestructive = false }}
109
+ >
110
+ <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
+ {#snippet footer()}
138
+ <Button variant="ghost" onclick={() => { openWithFooter = false }}>Cancel</Button>
139
+ <Button variant="primary">Confirm</Button>
140
+ {/snippet}
141
+ </Modal>
142
+ </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>
@@ -0,0 +1,4 @@
1
+ import Modal from "./Modal.svelte";
2
+ declare const Modal: import("svelte").Component<Record<string, never>, {}, "">;
3
+ type Modal = ReturnType<typeof Modal>;
4
+ export default Modal;
@@ -0,0 +1,185 @@
1
+ <script lang="ts">
2
+ import type { HTMLDialogAttributes } from 'svelte/elements'
3
+ import type { Snippet } from 'svelte'
4
+ import Inline from '../layout/Inline.svelte'
5
+ import Spread from '../layout/Spread.svelte'
6
+ import Text from '../primitives/Text.svelte'
7
+ import Button from '../primitives/Button.svelte'
8
+
9
+ type ModalVariant = 'default' | 'confirm' | 'destructive'
10
+
11
+ interface Props extends HTMLDialogAttributes {
12
+ /** Whether the dialog is currently open. @default false */
13
+ open?: boolean
14
+ /** Heading text shown in the modal header. */
15
+ title: string
16
+ /** Visual variant — adds a coloured icon to the header. @default 'default' */
17
+ variant?: ModalVariant
18
+ /** Called when the modal requests to close (close button, backdrop, or Escape). */
19
+ onclose?: () => void
20
+ children?: Snippet
21
+ footer?: Snippet
22
+ [key: string]: unknown
23
+ }
24
+
25
+ let {
26
+ open = false,
27
+ title,
28
+ variant = 'default',
29
+ onclose,
30
+ children,
31
+ footer,
32
+ ...rest
33
+ }: Props = $props()
34
+
35
+ let dialogElement: HTMLDialogElement | undefined = $state()
36
+
37
+ function handleClose() {
38
+ onclose?.()
39
+ }
40
+
41
+ function handleCancel(event: Event) {
42
+ event.preventDefault()
43
+ handleClose()
44
+ }
45
+
46
+ function handleDialogClick(event: MouseEvent) {
47
+ if (event.target === dialogElement) {
48
+ handleClose()
49
+ }
50
+ }
51
+
52
+ $effect(() => {
53
+ if (!dialogElement) return
54
+ if (open) {
55
+ dialogElement.showModal()
56
+ document.body.style.overflow = 'hidden'
57
+ return () => {
58
+ document.body.style.overflow = ''
59
+ }
60
+ } else {
61
+ if (dialogElement.open) {
62
+ dialogElement.close()
63
+ }
64
+ document.body.style.overflow = ''
65
+ }
66
+ })
67
+ </script>
68
+
69
+ <dialog
70
+ bind:this={dialogElement}
71
+ class="modal modal--{variant}"
72
+ aria-modal="true"
73
+ aria-labelledby="modal-title"
74
+ oncancel={handleCancel}
75
+ onclick={handleDialogClick}
76
+ {...rest}
77
+ >
78
+ <div class="modal-inner">
79
+ <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>
87
+ </header>
88
+
89
+ <div class="modal-body">
90
+ {@render children?.()}
91
+ </div>
92
+
93
+ {#if footer}
94
+ <footer class="modal-footer">
95
+ <Spread>
96
+ {@render footer()}
97
+ </Spread>
98
+ </footer>
99
+ {/if}
100
+ </div>
101
+ </dialog>
102
+
103
+ <style>
104
+ .modal {
105
+ position: fixed;
106
+ inset: 0;
107
+ border: none;
108
+ padding: 0;
109
+ background: transparent;
110
+ max-width: 100vw;
111
+ max-height: 100vh;
112
+ }
113
+
114
+ .modal[open] {
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ }
119
+
120
+ .modal::backdrop {
121
+ background: rgba(7, 9, 8, 0.85);
122
+ }
123
+
124
+ .modal-inner {
125
+ background: var(--bg-elev);
126
+ border: 1px solid var(--rule-strong);
127
+ width: 100%;
128
+ max-width: 480px;
129
+ max-height: 80vh;
130
+ overflow-y: auto;
131
+ display: flex;
132
+ flex-direction: column;
133
+ }
134
+
135
+ .modal-header {
136
+ padding: var(--u2) var(--u3);
137
+ border-bottom: 1px solid var(--rule);
138
+ display: flex;
139
+ align-items: center;
140
+ }
141
+
142
+ .modal-footer {
143
+ padding: var(--u2) var(--u3);
144
+ border-top: 1px solid var(--rule);
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: space-between;
148
+ gap: var(--u2);
149
+ }
150
+
151
+ .modal-icon {
152
+ display: inline-flex;
153
+ align-items: center;
154
+ justify-content: center;
155
+ width: 22px;
156
+ height: 22px;
157
+ border-radius: 50%;
158
+ font-family: var(--mono);
159
+ font-size: 13px;
160
+ font-weight: 700;
161
+ flex-shrink: 0;
162
+ }
163
+
164
+ .modal--confirm .modal-icon {
165
+ background: var(--amber);
166
+ color: var(--bg);
167
+ }
168
+
169
+ .modal--destructive .modal-icon {
170
+ background: var(--danger);
171
+ color: var(--bg);
172
+ }
173
+
174
+ .modal-body {
175
+ padding: var(--u3);
176
+ flex: 1;
177
+ font-size: var(--t-body);
178
+ color: var(--ink-dim);
179
+ line-height: 1.5;
180
+ }
181
+
182
+ .modal-header :global(.modal-title) {
183
+ flex: 1;
184
+ }
185
+ </style>
@@ -0,0 +1,19 @@
1
+ import type { HTMLDialogAttributes } from 'svelte/elements';
2
+ import type { Snippet } from 'svelte';
3
+ type ModalVariant = 'default' | 'confirm' | 'destructive';
4
+ interface Props extends HTMLDialogAttributes {
5
+ /** Whether the dialog is currently open. @default false */
6
+ open?: boolean;
7
+ /** Heading text shown in the modal header. */
8
+ title: string;
9
+ /** Visual variant — adds a coloured icon to the header. @default 'default' */
10
+ variant?: ModalVariant;
11
+ /** Called when the modal requests to close (close button, backdrop, or Escape). */
12
+ onclose?: () => void;
13
+ children?: Snippet;
14
+ footer?: Snippet;
15
+ [key: string]: unknown;
16
+ }
17
+ declare const Modal: import("svelte").Component<Props, {}, "">;
18
+ type Modal = ReturnType<typeof Modal>;
19
+ export default Modal;