@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.
- package/README.md +94 -0
- package/dist/components/cards/Card.stories.svelte +82 -0
- package/dist/components/cards/Card.stories.svelte.d.ts +19 -0
- package/dist/components/cards/Card.svelte +28 -0
- package/dist/components/cards/Card.svelte.d.ts +12 -0
- package/dist/components/cards/NoteCard.stories.svelte +94 -0
- package/dist/components/cards/NoteCard.stories.svelte.d.ts +19 -0
- package/dist/components/cards/NoteCard.svelte +89 -0
- package/dist/components/cards/NoteCard.svelte.d.ts +18 -0
- package/dist/components/cards/ProductCard.stories.svelte +98 -0
- package/dist/components/cards/ProductCard.stories.svelte.d.ts +19 -0
- package/dist/components/cards/ProductCard.svelte +150 -0
- package/dist/components/cards/ProductCard.svelte.d.ts +22 -0
- package/dist/components/cards/ProjectCard.stories.svelte +88 -0
- package/dist/components/cards/ProjectCard.stories.svelte.d.ts +19 -0
- package/dist/components/cards/ProjectCard.svelte +109 -0
- package/dist/components/cards/ProjectCard.svelte.d.ts +20 -0
- package/dist/components/cards/index.d.ts +4 -0
- package/dist/components/cards/index.js +4 -0
- package/dist/components/data/Accordion.stories.svelte +316 -0
- package/dist/components/data/Accordion.stories.svelte.d.ts +19 -0
- package/dist/components/data/Accordion.svelte +23 -0
- package/dist/components/data/Accordion.svelte.d.ts +9 -0
- package/dist/components/data/AccordionItem.svelte +112 -0
- package/dist/components/data/AccordionItem.svelte.d.ts +11 -0
- package/dist/components/data/Table.composition.stories.svelte +67 -0
- package/dist/components/data/Table.composition.stories.svelte.d.ts +19 -0
- package/dist/components/data/Table.stories.svelte +137 -0
- package/dist/components/data/Table.stories.svelte.d.ts +19 -0
- package/dist/components/data/Table.svelte +83 -0
- package/dist/components/data/Table.svelte.d.ts +14 -0
- package/dist/components/data/Tabs.stories.svelte +386 -0
- package/dist/components/data/Tabs.stories.svelte.d.ts +19 -0
- package/dist/components/data/Tabs.svelte +142 -0
- package/dist/components/data/Tabs.svelte.d.ts +19 -0
- package/dist/components/data/index.d.ts +4 -0
- package/dist/components/data/index.js +4 -0
- package/dist/components/feedback/Modal.stories.svelte +192 -0
- package/dist/components/feedback/Modal.stories.svelte.d.ts +4 -0
- package/dist/components/feedback/Modal.svelte +185 -0
- package/dist/components/feedback/Modal.svelte.d.ts +19 -0
- package/dist/components/feedback/Toast.stories.svelte +203 -0
- package/dist/components/feedback/Toast.stories.svelte.d.ts +19 -0
- package/dist/components/feedback/Toast.svelte +109 -0
- package/dist/components/feedback/Toast.svelte.d.ts +15 -0
- package/dist/components/feedback/ToastRegion.stories.svelte +193 -0
- package/dist/components/feedback/ToastRegion.stories.svelte.d.ts +19 -0
- package/dist/components/feedback/ToastRegion.svelte +102 -0
- package/dist/components/feedback/ToastRegion.svelte.d.ts +9 -0
- package/dist/components/feedback/index.d.ts +3 -0
- package/dist/components/feedback/index.js +3 -0
- package/dist/components/forms/Checkbox.stories.svelte +103 -0
- package/dist/components/forms/Checkbox.stories.svelte.d.ts +19 -0
- package/dist/components/forms/Checkbox.svelte +150 -0
- package/dist/components/forms/Checkbox.svelte.d.ts +11 -0
- package/dist/components/forms/Field.stories.svelte +113 -0
- package/dist/components/forms/Field.stories.svelte.d.ts +19 -0
- package/dist/components/forms/Field.svelte +77 -0
- package/dist/components/forms/Field.svelte.d.ts +17 -0
- package/dist/components/forms/Input.stories.svelte +58 -0
- package/dist/components/forms/Input.stories.svelte.d.ts +19 -0
- package/dist/components/forms/Input.svelte +64 -0
- package/dist/components/forms/Input.svelte.d.ts +9 -0
- package/dist/components/forms/InputWrap.composition.stories.svelte +32 -0
- package/dist/components/forms/InputWrap.composition.stories.svelte.d.ts +19 -0
- package/dist/components/forms/InputWrap.stories.svelte +53 -0
- package/dist/components/forms/InputWrap.stories.svelte.d.ts +19 -0
- package/dist/components/forms/InputWrap.svelte +128 -0
- package/dist/components/forms/InputWrap.svelte.d.ts +21 -0
- package/dist/components/forms/Radio.stories.svelte +70 -0
- package/dist/components/forms/Radio.stories.svelte.d.ts +19 -0
- package/dist/components/forms/Radio.svelte +109 -0
- package/dist/components/forms/Radio.svelte.d.ts +9 -0
- package/dist/components/forms/RadioGroup.stories.svelte +115 -0
- package/dist/components/forms/RadioGroup.stories.svelte.d.ts +19 -0
- package/dist/components/forms/RadioGroup.svelte +116 -0
- package/dist/components/forms/RadioGroup.svelte.d.ts +24 -0
- package/dist/components/forms/Select.stories.svelte +168 -0
- package/dist/components/forms/Select.stories.svelte.d.ts +19 -0
- package/dist/components/forms/Select.svelte +262 -0
- package/dist/components/forms/Select.svelte.d.ts +23 -0
- package/dist/components/forms/Switch.stories.svelte +86 -0
- package/dist/components/forms/Switch.stories.svelte.d.ts +19 -0
- package/dist/components/forms/Switch.svelte +113 -0
- package/dist/components/forms/Switch.svelte.d.ts +11 -0
- package/dist/components/forms/Textarea.stories.svelte +40 -0
- package/dist/components/forms/Textarea.stories.svelte.d.ts +19 -0
- package/dist/components/forms/Textarea.svelte +66 -0
- package/dist/components/forms/Textarea.svelte.d.ts +9 -0
- package/dist/components/forms/field-context.d.ts +7 -0
- package/dist/components/forms/field-context.js +1 -0
- package/dist/components/forms/index.d.ts +9 -0
- package/dist/components/forms/index.js +9 -0
- package/dist/components/layout/Container.stories.svelte +67 -0
- package/dist/components/layout/Container.stories.svelte.d.ts +19 -0
- package/dist/components/layout/Container.svelte +52 -0
- package/dist/components/layout/Container.svelte.d.ts +14 -0
- package/dist/components/layout/Grid.stories.svelte +109 -0
- package/dist/components/layout/Grid.stories.svelte.d.ts +19 -0
- package/dist/components/layout/Grid.svelte +54 -0
- package/dist/components/layout/Grid.svelte.d.ts +19 -0
- package/dist/components/layout/Inline.stories.svelte +136 -0
- package/dist/components/layout/Inline.stories.svelte.d.ts +19 -0
- package/dist/components/layout/Inline.svelte +46 -0
- package/dist/components/layout/Inline.svelte.d.ts +19 -0
- package/dist/components/layout/Prose.stories.svelte +423 -0
- package/dist/components/layout/Prose.stories.svelte.d.ts +19 -0
- package/dist/components/layout/Prose.svelte +176 -0
- package/dist/components/layout/Prose.svelte.d.ts +12 -0
- package/dist/components/layout/Rule.stories.svelte +80 -0
- package/dist/components/layout/Rule.stories.svelte.d.ts +19 -0
- package/dist/components/layout/Rule.svelte +33 -0
- package/dist/components/layout/Rule.svelte.d.ts +9 -0
- package/dist/components/layout/Spread.stories.svelte +118 -0
- package/dist/components/layout/Spread.stories.svelte.d.ts +19 -0
- package/dist/components/layout/Spread.svelte +38 -0
- package/dist/components/layout/Spread.svelte.d.ts +16 -0
- package/dist/components/layout/Stack.stories.svelte +90 -0
- package/dist/components/layout/Stack.stories.svelte.d.ts +19 -0
- package/dist/components/layout/Stack.svelte +37 -0
- package/dist/components/layout/Stack.svelte.d.ts +16 -0
- package/dist/components/layout/index.d.ts +7 -0
- package/dist/components/layout/index.js +7 -0
- package/dist/components/navigation/Breadcrumb.stories.svelte +122 -0
- package/dist/components/navigation/Breadcrumb.stories.svelte.d.ts +19 -0
- package/dist/components/navigation/Breadcrumb.svelte +70 -0
- package/dist/components/navigation/Breadcrumb.svelte.d.ts +13 -0
- package/dist/components/navigation/Nav.stories.svelte +323 -0
- package/dist/components/navigation/Nav.stories.svelte.d.ts +19 -0
- package/dist/components/navigation/Nav.svelte +257 -0
- package/dist/components/navigation/Nav.svelte.d.ts +21 -0
- package/dist/components/navigation/index.d.ts +2 -0
- package/dist/components/navigation/index.js +2 -0
- package/dist/components/patterns/ActivityRow.stories.svelte +45 -0
- package/dist/components/patterns/ActivityRow.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/ActivityRow.svelte +69 -0
- package/dist/components/patterns/ActivityRow.svelte.d.ts +16 -0
- package/dist/components/patterns/Alert.stories.svelte +63 -0
- package/dist/components/patterns/Alert.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/Alert.svelte +91 -0
- package/dist/components/patterns/Alert.svelte.d.ts +16 -0
- package/dist/components/patterns/CtaBlock.stories.svelte +62 -0
- package/dist/components/patterns/CtaBlock.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/CtaBlock.svelte +80 -0
- package/dist/components/patterns/CtaBlock.svelte.d.ts +16 -0
- package/dist/components/patterns/KvList.stories.svelte +48 -0
- package/dist/components/patterns/KvList.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/KvList.svelte +65 -0
- package/dist/components/patterns/KvList.svelte.d.ts +15 -0
- package/dist/components/patterns/PageHero.stories.svelte +62 -0
- package/dist/components/patterns/PageHero.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/PageHero.svelte +62 -0
- package/dist/components/patterns/PageHero.svelte.d.ts +14 -0
- package/dist/components/patterns/ProgressBar.stories.svelte +83 -0
- package/dist/components/patterns/ProgressBar.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/ProgressBar.svelte +71 -0
- package/dist/components/patterns/ProgressBar.svelte.d.ts +13 -0
- package/dist/components/patterns/SectionFoot.stories.svelte +37 -0
- package/dist/components/patterns/SectionFoot.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/SectionFoot.svelte +70 -0
- package/dist/components/patterns/SectionFoot.svelte.d.ts +15 -0
- package/dist/components/patterns/SectionHead.stories.svelte +67 -0
- package/dist/components/patterns/SectionHead.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/SectionHead.svelte +54 -0
- package/dist/components/patterns/SectionHead.svelte.d.ts +14 -0
- package/dist/components/patterns/StatCard.stories.svelte +59 -0
- package/dist/components/patterns/StatCard.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/StatCard.svelte +57 -0
- package/dist/components/patterns/StatCard.svelte.d.ts +15 -0
- package/dist/components/patterns/index.d.ts +9 -0
- package/dist/components/patterns/index.js +9 -0
- package/dist/components/primitives/Button.stories.svelte +132 -0
- package/dist/components/primitives/Button.stories.svelte.d.ts +19 -0
- package/dist/components/primitives/Button.svelte +142 -0
- package/dist/components/primitives/Button.svelte.d.ts +16 -0
- package/dist/components/primitives/Heading.stories.svelte +137 -0
- package/dist/components/primitives/Heading.stories.svelte.d.ts +19 -0
- package/dist/components/primitives/Heading.svelte +107 -0
- package/dist/components/primitives/Heading.svelte.d.ts +23 -0
- package/dist/components/primitives/Led.stories.svelte +63 -0
- package/dist/components/primitives/Led.stories.svelte.d.ts +19 -0
- package/dist/components/primitives/Led.svelte +65 -0
- package/dist/components/primitives/Led.svelte.d.ts +11 -0
- package/dist/components/primitives/TagPill.stories.svelte +90 -0
- package/dist/components/primitives/TagPill.stories.svelte.d.ts +19 -0
- package/dist/components/primitives/TagPill.svelte +44 -0
- package/dist/components/primitives/TagPill.svelte.d.ts +9 -0
- package/dist/components/primitives/Text.stories.svelte +252 -0
- package/dist/components/primitives/Text.stories.svelte.d.ts +19 -0
- package/dist/components/primitives/Text.svelte +101 -0
- package/dist/components/primitives/Text.svelte.d.ts +25 -0
- package/dist/components/primitives/index.d.ts +5 -0
- package/dist/components/primitives/index.js +5 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/stores/toast.d.ts +19 -0
- package/dist/stores/toast.js +22 -0
- package/dist/storybook-utils.d.ts +11 -0
- package/dist/storybook-utils.js +29 -0
- package/dist/tokens/ColorSwatch.svelte +73 -0
- package/dist/tokens/ColorSwatch.svelte.d.ts +10 -0
- package/dist/tokens/layout.css +144 -0
- package/dist/tokens/patterns.css +281 -0
- package/dist/tokens/tokens.css +96 -0
- package/dist/tokens/tokens.stories.svelte +107 -0
- package/dist/tokens/tokens.stories.svelte.d.ts +18 -0
- package/dist/tokens/typography.css +159 -0
- 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,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,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;
|