@dsn-starter-kit/components-web 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/button/Button.d.ts +50 -0
- package/dist/button/Button.d.ts.map +1 -0
- package/dist/button/Button.js +247 -0
- package/dist/button/Button.js.map +1 -0
- package/dist/button/Button.test.d.ts +2 -0
- package/dist/button/Button.test.d.ts.map +1 -0
- package/dist/button/Button.test.js +299 -0
- package/dist/button/Button.test.js.map +1 -0
- package/dist/button/button-styles.generated.d.ts +2 -0
- package/dist/button/button-styles.generated.d.ts.map +1 -0
- package/dist/button/button-styles.generated.js +4 -0
- package/dist/button/button-styles.generated.js.map +1 -0
- package/dist/button/index.d.ts +3 -0
- package/dist/button/index.d.ts.map +1 -0
- package/dist/button/index.js +2 -0
- package/dist/button/index.js.map +1 -0
- package/dist/heading/Heading.d.ts +30 -0
- package/dist/heading/Heading.d.ts.map +1 -0
- package/dist/heading/Heading.js +79 -0
- package/dist/heading/Heading.js.map +1 -0
- package/dist/heading/Heading.test.d.ts +2 -0
- package/dist/heading/Heading.test.d.ts.map +1 -0
- package/dist/heading/Heading.test.js +157 -0
- package/dist/heading/Heading.test.js.map +1 -0
- package/dist/heading/heading-styles.generated.d.ts +2 -0
- package/dist/heading/heading-styles.generated.d.ts.map +1 -0
- package/dist/heading/heading-styles.generated.js +4 -0
- package/dist/heading/heading-styles.generated.js.map +1 -0
- package/dist/heading/index.d.ts +3 -0
- package/dist/heading/index.d.ts.map +1 -0
- package/dist/heading/index.js +2 -0
- package/dist/heading/index.js.map +1 -0
- package/dist/icon/Icon.d.ts +31 -0
- package/dist/icon/Icon.d.ts.map +1 -0
- package/dist/icon/Icon.js +110 -0
- package/dist/icon/Icon.js.map +1 -0
- package/dist/icon/Icon.test.d.ts +2 -0
- package/dist/icon/Icon.test.d.ts.map +1 -0
- package/dist/icon/Icon.test.js +193 -0
- package/dist/icon/Icon.test.js.map +1 -0
- package/dist/icon/icon-paths.generated.d.ts +2 -0
- package/dist/icon/icon-paths.generated.d.ts.map +1 -0
- package/dist/icon/icon-paths.generated.js +56 -0
- package/dist/icon/icon-paths.generated.js.map +1 -0
- package/dist/icon/icon-styles.generated.d.ts +2 -0
- package/dist/icon/icon-styles.generated.d.ts.map +1 -0
- package/dist/icon/icon-styles.generated.js +4 -0
- package/dist/icon/icon-styles.generated.js.map +1 -0
- package/dist/icon/index.d.ts +3 -0
- package/dist/icon/index.d.ts.map +1 -0
- package/dist/icon/index.js +2 -0
- package/dist/icon/index.js.map +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +71 -0
- package/dist/index.js.map +1 -0
- package/dist/link/Link.d.ts +52 -0
- package/dist/link/Link.d.ts.map +1 -0
- package/dist/link/Link.js +260 -0
- package/dist/link/Link.js.map +1 -0
- package/dist/link/Link.test.d.ts +2 -0
- package/dist/link/Link.test.d.ts.map +1 -0
- package/dist/link/Link.test.js +256 -0
- package/dist/link/Link.test.js.map +1 -0
- package/dist/link/index.d.ts +3 -0
- package/dist/link/index.d.ts.map +1 -0
- package/dist/link/index.js +2 -0
- package/dist/link/index.js.map +1 -0
- package/dist/link/link-styles.generated.d.ts +2 -0
- package/dist/link/link-styles.generated.d.ts.map +1 -0
- package/dist/link/link-styles.generated.js +4 -0
- package/dist/link/link-styles.generated.js.map +1 -0
- package/dist/ordered-list/OrderedList.d.ts +32 -0
- package/dist/ordered-list/OrderedList.d.ts.map +1 -0
- package/dist/ordered-list/OrderedList.js +72 -0
- package/dist/ordered-list/OrderedList.js.map +1 -0
- package/dist/ordered-list/OrderedList.test.d.ts +2 -0
- package/dist/ordered-list/OrderedList.test.d.ts.map +1 -0
- package/dist/ordered-list/OrderedList.test.js +82 -0
- package/dist/ordered-list/OrderedList.test.js.map +1 -0
- package/dist/ordered-list/index.d.ts +2 -0
- package/dist/ordered-list/index.d.ts.map +1 -0
- package/dist/ordered-list/index.js +2 -0
- package/dist/ordered-list/index.js.map +1 -0
- package/dist/ordered-list/ordered-list-styles.generated.d.ts +2 -0
- package/dist/ordered-list/ordered-list-styles.generated.d.ts.map +1 -0
- package/dist/ordered-list/ordered-list-styles.generated.js +4 -0
- package/dist/ordered-list/ordered-list-styles.generated.js.map +1 -0
- package/dist/paragraph/Paragraph.d.ts +26 -0
- package/dist/paragraph/Paragraph.d.ts.map +1 -0
- package/dist/paragraph/Paragraph.js +60 -0
- package/dist/paragraph/Paragraph.js.map +1 -0
- package/dist/paragraph/Paragraph.test.d.ts +2 -0
- package/dist/paragraph/Paragraph.test.d.ts.map +1 -0
- package/dist/paragraph/Paragraph.test.js +121 -0
- package/dist/paragraph/Paragraph.test.js.map +1 -0
- package/dist/paragraph/index.d.ts +3 -0
- package/dist/paragraph/index.d.ts.map +1 -0
- package/dist/paragraph/index.js +2 -0
- package/dist/paragraph/index.js.map +1 -0
- package/dist/paragraph/paragraph-styles.generated.d.ts +2 -0
- package/dist/paragraph/paragraph-styles.generated.d.ts.map +1 -0
- package/dist/paragraph/paragraph-styles.generated.js +4 -0
- package/dist/paragraph/paragraph-styles.generated.js.map +1 -0
- package/dist/unordered-list/UnorderedList.d.ts +22 -0
- package/dist/unordered-list/UnorderedList.d.ts.map +1 -0
- package/dist/unordered-list/UnorderedList.js +42 -0
- package/dist/unordered-list/UnorderedList.js.map +1 -0
- package/dist/unordered-list/UnorderedList.test.d.ts +2 -0
- package/dist/unordered-list/UnorderedList.test.d.ts.map +1 -0
- package/dist/unordered-list/UnorderedList.test.js +56 -0
- package/dist/unordered-list/UnorderedList.test.js.map +1 -0
- package/dist/unordered-list/index.d.ts +2 -0
- package/dist/unordered-list/index.d.ts.map +1 -0
- package/dist/unordered-list/index.js +2 -0
- package/dist/unordered-list/index.js.map +1 -0
- package/dist/unordered-list/unordered-list-styles.generated.d.ts +2 -0
- package/dist/unordered-list/unordered-list-styles.generated.d.ts.map +1 -0
- package/dist/unordered-list/unordered-list-styles.generated.js +4 -0
- package/dist/unordered-list/unordered-list-styles.generated.js.map +1 -0
- package/package.json +42 -0
- package/src/button/Button.test.ts +360 -0
- package/src/button/Button.ts +299 -0
- package/src/button/button-demo.html +477 -0
- package/src/button/button-styles.generated.ts +3 -0
- package/src/button/button.shadow.css +20 -0
- package/src/button/index.ts +2 -0
- package/src/heading/Heading.test.ts +186 -0
- package/src/heading/Heading.ts +104 -0
- package/src/heading/heading-styles.generated.ts +3 -0
- package/src/heading/heading.shadow.css +11 -0
- package/src/heading/index.ts +2 -0
- package/src/icon/Icon.test.ts +228 -0
- package/src/icon/Icon.ts +136 -0
- package/src/icon/icon-paths.generated.ts +55 -0
- package/src/icon/icon-styles.generated.ts +3 -0
- package/src/icon/icon.shadow.css +13 -0
- package/src/icon/index.ts +2 -0
- package/src/index.ts +83 -0
- package/src/link/Link.test.ts +309 -0
- package/src/link/Link.ts +284 -0
- package/src/link/index.ts +2 -0
- package/src/link/link-styles.generated.ts +3 -0
- package/src/link/link.shadow.css +15 -0
- package/src/ordered-list/OrderedList.test.ts +97 -0
- package/src/ordered-list/OrderedList.ts +84 -0
- package/src/ordered-list/index.ts +1 -0
- package/src/ordered-list/ordered-list-styles.generated.ts +3 -0
- package/src/ordered-list/ordered-list.shadow.css +11 -0
- package/src/paragraph/Paragraph.test.ts +143 -0
- package/src/paragraph/Paragraph.ts +77 -0
- package/src/paragraph/index.ts +2 -0
- package/src/paragraph/paragraph-styles.generated.ts +3 -0
- package/src/paragraph/paragraph.shadow.css +11 -0
- package/src/unordered-list/UnorderedList.test.ts +66 -0
- package/src/unordered-list/UnorderedList.ts +50 -0
- package/src/unordered-list/index.ts +1 -0
- package/src/unordered-list/unordered-list-styles.generated.ts +3 -0
- package/src/unordered-list/unordered-list.shadow.css +11 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { DsnButton, defineButton } from './Button';
|
|
3
|
+
|
|
4
|
+
// Register the custom element once for all tests
|
|
5
|
+
beforeAll(() => {
|
|
6
|
+
defineButton();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
function createElement(attrs: Record<string, string> = {}): DsnButton {
|
|
10
|
+
const el = document.createElement('dsn-button') as DsnButton;
|
|
11
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
12
|
+
el.setAttribute(key, value);
|
|
13
|
+
}
|
|
14
|
+
document.body.appendChild(el);
|
|
15
|
+
return el;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getInternalButton(el: DsnButton): HTMLButtonElement {
|
|
19
|
+
return el.shadowRoot!.querySelector('button')!;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('DsnButton', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
document.body.innerHTML = '';
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('rendering', () => {
|
|
28
|
+
it('creates a shadow root', () => {
|
|
29
|
+
const el = createElement();
|
|
30
|
+
expect(el.shadowRoot).not.toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('renders an internal button element', () => {
|
|
34
|
+
const el = createElement();
|
|
35
|
+
const btn = getInternalButton(el);
|
|
36
|
+
expect(btn).toBeInstanceOf(HTMLButtonElement);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders a style element in shadow DOM', () => {
|
|
40
|
+
const el = createElement();
|
|
41
|
+
const style = el.shadowRoot!.querySelector('style');
|
|
42
|
+
expect(style).not.toBeNull();
|
|
43
|
+
expect(style!.textContent).toContain('.dsn-button');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('renders a default slot for content projection', () => {
|
|
47
|
+
const el = createElement();
|
|
48
|
+
const slot = el.shadowRoot!.querySelector('slot:not([name])');
|
|
49
|
+
expect(slot).not.toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('renders an icon-start named slot', () => {
|
|
53
|
+
const el = createElement();
|
|
54
|
+
const slot = el.shadowRoot!.querySelector('slot[name="icon-start"]');
|
|
55
|
+
expect(slot).not.toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('renders an icon-end named slot', () => {
|
|
59
|
+
const el = createElement();
|
|
60
|
+
const slot = el.shadowRoot!.querySelector('slot[name="icon-end"]');
|
|
61
|
+
expect(slot).not.toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('renders slots in correct order: icon-start, default, icon-end', () => {
|
|
65
|
+
const el = createElement();
|
|
66
|
+
const btn = getInternalButton(el);
|
|
67
|
+
const slots = btn.querySelectorAll('slot');
|
|
68
|
+
expect(slots).toHaveLength(3);
|
|
69
|
+
expect(slots[0].getAttribute('name')).toBe('icon-start');
|
|
70
|
+
expect(slots[1].getAttribute('name')).toBeNull();
|
|
71
|
+
expect(slots[2].getAttribute('name')).toBe('icon-end');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('default values', () => {
|
|
76
|
+
it('defaults variant to "strong"', () => {
|
|
77
|
+
const el = createElement();
|
|
78
|
+
expect(el.variant).toBe('strong');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('defaults size to "default"', () => {
|
|
82
|
+
const el = createElement();
|
|
83
|
+
expect(el.size).toBe('default');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('defaults type to "button"', () => {
|
|
87
|
+
const el = createElement();
|
|
88
|
+
expect(el.type).toBe('button');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('defaults disabled to false', () => {
|
|
92
|
+
const el = createElement();
|
|
93
|
+
expect(el.disabled).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('defaults loading to false', () => {
|
|
97
|
+
const el = createElement();
|
|
98
|
+
expect(el.loading).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('defaults fullWidth to false', () => {
|
|
102
|
+
const el = createElement();
|
|
103
|
+
expect(el.fullWidth).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('defaults iconOnly to false', () => {
|
|
107
|
+
const el = createElement();
|
|
108
|
+
expect(el.iconOnly).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('CSS classes', () => {
|
|
113
|
+
it('applies default classes', () => {
|
|
114
|
+
const el = createElement();
|
|
115
|
+
const btn = getInternalButton(el);
|
|
116
|
+
expect(btn.className).toBe(
|
|
117
|
+
'dsn-button dsn-button--strong dsn-button--size-default'
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('applies variant class from attribute', () => {
|
|
122
|
+
const el = createElement({ variant: 'subtle' });
|
|
123
|
+
const btn = getInternalButton(el);
|
|
124
|
+
expect(btn.className).toContain('dsn-button--subtle');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('applies size class from attribute', () => {
|
|
128
|
+
const el = createElement({ size: 'large' });
|
|
129
|
+
const btn = getInternalButton(el);
|
|
130
|
+
expect(btn.className).toContain('dsn-button--size-large');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('applies loading class', () => {
|
|
134
|
+
const el = createElement({ loading: '' });
|
|
135
|
+
const btn = getInternalButton(el);
|
|
136
|
+
expect(btn.className).toContain('dsn-button--loading');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('applies full-width class', () => {
|
|
140
|
+
const el = createElement({ 'full-width': '' });
|
|
141
|
+
const btn = getInternalButton(el);
|
|
142
|
+
expect(btn.className).toContain('dsn-button--full-width');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('applies icon-only class', () => {
|
|
146
|
+
const el = createElement({ 'icon-only': '' });
|
|
147
|
+
const btn = getInternalButton(el);
|
|
148
|
+
expect(btn.className).toContain('dsn-button--icon-only');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('updates classes when variant changes', () => {
|
|
152
|
+
const el = createElement({ variant: 'strong' });
|
|
153
|
+
const btn = getInternalButton(el);
|
|
154
|
+
expect(btn.className).toContain('dsn-button--strong');
|
|
155
|
+
|
|
156
|
+
el.variant = 'default';
|
|
157
|
+
expect(btn.className).toContain('dsn-button--default');
|
|
158
|
+
expect(btn.className).not.toContain('dsn-button--strong');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('updates classes when size changes', () => {
|
|
162
|
+
const el = createElement({ size: 'small' });
|
|
163
|
+
const btn = getInternalButton(el);
|
|
164
|
+
expect(btn.className).toContain('dsn-button--size-small');
|
|
165
|
+
|
|
166
|
+
el.size = 'large';
|
|
167
|
+
expect(btn.className).toContain('dsn-button--size-large');
|
|
168
|
+
expect(btn.className).not.toContain('dsn-button--size-small');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('disabled state', () => {
|
|
173
|
+
it('disables internal button when disabled attribute is set', () => {
|
|
174
|
+
const el = createElement({ disabled: '' });
|
|
175
|
+
const btn = getInternalButton(el);
|
|
176
|
+
expect(btn.disabled).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('enables internal button when disabled is removed', () => {
|
|
180
|
+
const el = createElement({ disabled: '' });
|
|
181
|
+
const btn = getInternalButton(el);
|
|
182
|
+
expect(btn.disabled).toBe(true);
|
|
183
|
+
|
|
184
|
+
el.disabled = false;
|
|
185
|
+
expect(btn.disabled).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('reflects disabled property to attribute', () => {
|
|
189
|
+
const el = createElement();
|
|
190
|
+
el.disabled = true;
|
|
191
|
+
expect(el.hasAttribute('disabled')).toBe(true);
|
|
192
|
+
|
|
193
|
+
el.disabled = false;
|
|
194
|
+
expect(el.hasAttribute('disabled')).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('loading state', () => {
|
|
199
|
+
it('disables internal button when loading', () => {
|
|
200
|
+
const el = createElement({ loading: '' });
|
|
201
|
+
const btn = getInternalButton(el);
|
|
202
|
+
expect(btn.disabled).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('sets aria-busy when loading', () => {
|
|
206
|
+
const el = createElement({ loading: '' });
|
|
207
|
+
const btn = getInternalButton(el);
|
|
208
|
+
expect(btn.getAttribute('aria-busy')).toBe('true');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('removes aria-busy when loading is removed', () => {
|
|
212
|
+
const el = createElement({ loading: '' });
|
|
213
|
+
const btn = getInternalButton(el);
|
|
214
|
+
expect(btn.getAttribute('aria-busy')).toBe('true');
|
|
215
|
+
|
|
216
|
+
el.loading = false;
|
|
217
|
+
expect(btn.hasAttribute('aria-busy')).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('adds loading class and disables button', () => {
|
|
221
|
+
const el = createElement();
|
|
222
|
+
el.loading = true;
|
|
223
|
+
const btn = getInternalButton(el);
|
|
224
|
+
|
|
225
|
+
expect(btn.className).toContain('dsn-button--loading');
|
|
226
|
+
expect(btn.disabled).toBe(true);
|
|
227
|
+
expect(btn.getAttribute('aria-busy')).toBe('true');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('reflects loading property to attribute', () => {
|
|
231
|
+
const el = createElement();
|
|
232
|
+
el.loading = true;
|
|
233
|
+
expect(el.hasAttribute('loading')).toBe(true);
|
|
234
|
+
|
|
235
|
+
el.loading = false;
|
|
236
|
+
expect(el.hasAttribute('loading')).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('full-width', () => {
|
|
241
|
+
it('reflects fullWidth property to full-width attribute', () => {
|
|
242
|
+
const el = createElement();
|
|
243
|
+
el.fullWidth = true;
|
|
244
|
+
expect(el.hasAttribute('full-width')).toBe(true);
|
|
245
|
+
|
|
246
|
+
el.fullWidth = false;
|
|
247
|
+
expect(el.hasAttribute('full-width')).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('icon-only', () => {
|
|
252
|
+
it('reflects iconOnly property to icon-only attribute', () => {
|
|
253
|
+
const el = createElement();
|
|
254
|
+
el.iconOnly = true;
|
|
255
|
+
expect(el.hasAttribute('icon-only')).toBe(true);
|
|
256
|
+
|
|
257
|
+
el.iconOnly = false;
|
|
258
|
+
expect(el.hasAttribute('icon-only')).toBe(false);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('type attribute', () => {
|
|
263
|
+
it('sets internal button type', () => {
|
|
264
|
+
const el = createElement({ type: 'submit' });
|
|
265
|
+
const btn = getInternalButton(el);
|
|
266
|
+
expect(btn.getAttribute('type')).toBe('submit');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('defaults internal button type to "button"', () => {
|
|
270
|
+
const el = createElement();
|
|
271
|
+
const btn = getInternalButton(el);
|
|
272
|
+
expect(btn.getAttribute('type')).toBe('button');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('updates internal button type when property changes', () => {
|
|
276
|
+
const el = createElement();
|
|
277
|
+
el.type = 'reset';
|
|
278
|
+
const btn = getInternalButton(el);
|
|
279
|
+
expect(btn.getAttribute('type')).toBe('reset');
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('focus delegation', () => {
|
|
284
|
+
it('delegates focus to internal button', () => {
|
|
285
|
+
const el = createElement();
|
|
286
|
+
const btn = getInternalButton(el);
|
|
287
|
+
const focusSpy = vi.spyOn(btn, 'focus');
|
|
288
|
+
|
|
289
|
+
el.focus();
|
|
290
|
+
expect(focusSpy).toHaveBeenCalled();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('delegates blur to internal button', () => {
|
|
294
|
+
const el = createElement();
|
|
295
|
+
const btn = getInternalButton(el);
|
|
296
|
+
const blurSpy = vi.spyOn(btn, 'blur');
|
|
297
|
+
|
|
298
|
+
el.blur();
|
|
299
|
+
expect(blurSpy).toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('delegates click to internal button', () => {
|
|
303
|
+
const el = createElement();
|
|
304
|
+
const btn = getInternalButton(el);
|
|
305
|
+
const clickSpy = vi.spyOn(btn, 'click');
|
|
306
|
+
|
|
307
|
+
el.click();
|
|
308
|
+
expect(clickSpy).toHaveBeenCalled();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('all variants', () => {
|
|
313
|
+
const variants = [
|
|
314
|
+
'strong',
|
|
315
|
+
'strong-negative',
|
|
316
|
+
'strong-positive',
|
|
317
|
+
'default',
|
|
318
|
+
'default-negative',
|
|
319
|
+
'default-positive',
|
|
320
|
+
'subtle',
|
|
321
|
+
'subtle-negative',
|
|
322
|
+
'subtle-positive',
|
|
323
|
+
'link',
|
|
324
|
+
] as const;
|
|
325
|
+
|
|
326
|
+
variants.forEach((variant) => {
|
|
327
|
+
it(`applies class for variant "${variant}"`, () => {
|
|
328
|
+
const el = createElement({ variant });
|
|
329
|
+
const btn = getInternalButton(el);
|
|
330
|
+
expect(btn.className).toContain(`dsn-button--${variant}`);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe('all sizes', () => {
|
|
336
|
+
const sizes = ['small', 'default', 'large'] as const;
|
|
337
|
+
|
|
338
|
+
sizes.forEach((size) => {
|
|
339
|
+
it(`applies class for size "${size}"`, () => {
|
|
340
|
+
const el = createElement({ size });
|
|
341
|
+
const btn = getInternalButton(el);
|
|
342
|
+
expect(btn.className).toContain(`dsn-button--size-${size}`);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe('defineButton', () => {
|
|
349
|
+
it('does not throw when called with an already-registered tag', () => {
|
|
350
|
+
expect(() => defineButton('dsn-button')).not.toThrow();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('registers a custom element with a custom tag name', () => {
|
|
354
|
+
// DsnButton constructor is already registered as 'dsn-button',
|
|
355
|
+
// so re-registering under another name is not allowed by the spec.
|
|
356
|
+
// Instead, verify that the existing registration works.
|
|
357
|
+
const el = document.createElement('dsn-button');
|
|
358
|
+
expect(el).toBeInstanceOf(DsnButton);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSN Button Web Component
|
|
3
|
+
*
|
|
4
|
+
* A custom element that provides a styled button with multiple variants and sizes.
|
|
5
|
+
* Uses the same CSS classes and design tokens as the HTML and React button components.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```html
|
|
9
|
+
* <dsn-button variant="strong" size="default">Save</dsn-button>
|
|
10
|
+
* <dsn-button variant="strong-negative" size="small">Delete</dsn-button>
|
|
11
|
+
* <dsn-button variant="default" loading>Loading...</dsn-button>
|
|
12
|
+
* <dsn-button variant="subtle" icon-only aria-label="Close">×</dsn-button>
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type ButtonVariant =
|
|
17
|
+
| 'strong'
|
|
18
|
+
| 'strong-negative'
|
|
19
|
+
| 'strong-positive'
|
|
20
|
+
| 'default'
|
|
21
|
+
| 'default-negative'
|
|
22
|
+
| 'default-positive'
|
|
23
|
+
| 'subtle'
|
|
24
|
+
| 'subtle-negative'
|
|
25
|
+
| 'subtle-positive'
|
|
26
|
+
| 'link';
|
|
27
|
+
|
|
28
|
+
export type ButtonSize = 'small' | 'default' | 'large';
|
|
29
|
+
|
|
30
|
+
// Auto-generated CSS from shared source — run `node scripts/build-css.js` to regenerate
|
|
31
|
+
import { buttonStyles } from './button-styles.generated';
|
|
32
|
+
import { iconPaths } from '../icon/icon-paths.generated';
|
|
33
|
+
|
|
34
|
+
export class DsnButton extends HTMLElement {
|
|
35
|
+
static get observedAttributes(): string[] {
|
|
36
|
+
return [
|
|
37
|
+
'variant',
|
|
38
|
+
'size',
|
|
39
|
+
'disabled',
|
|
40
|
+
'loading',
|
|
41
|
+
'full-width',
|
|
42
|
+
'icon-only',
|
|
43
|
+
'type',
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private _button: HTMLButtonElement;
|
|
48
|
+
private _iconStartSlot: HTMLSlotElement;
|
|
49
|
+
private _loaderEl: SVGElement | null = null;
|
|
50
|
+
|
|
51
|
+
constructor() {
|
|
52
|
+
super();
|
|
53
|
+
this.attachShadow({ mode: 'open' });
|
|
54
|
+
|
|
55
|
+
// Create the internal button element
|
|
56
|
+
this._button = document.createElement('button');
|
|
57
|
+
this._button.setAttribute('type', 'button');
|
|
58
|
+
this._iconStartSlot = document.createElement('slot');
|
|
59
|
+
this._iconStartSlot.setAttribute('name', 'icon-start');
|
|
60
|
+
this._button.appendChild(this._iconStartSlot);
|
|
61
|
+
this._button.appendChild(document.createElement('slot'));
|
|
62
|
+
const iconEndSlot = document.createElement('slot');
|
|
63
|
+
iconEndSlot.setAttribute('name', 'icon-end');
|
|
64
|
+
this._button.appendChild(iconEndSlot);
|
|
65
|
+
|
|
66
|
+
// Create style element with embedded CSS
|
|
67
|
+
const style = document.createElement('style');
|
|
68
|
+
style.textContent = buttonStyles;
|
|
69
|
+
|
|
70
|
+
this.shadowRoot!.appendChild(style);
|
|
71
|
+
this.shadowRoot!.appendChild(this._button);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
connectedCallback(): void {
|
|
75
|
+
this._updateClasses();
|
|
76
|
+
this._updateDisabled();
|
|
77
|
+
this._updateType();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
attributeChangedCallback(
|
|
81
|
+
name: string,
|
|
82
|
+
_oldValue: string | null,
|
|
83
|
+
_newValue: string | null
|
|
84
|
+
): void {
|
|
85
|
+
switch (name) {
|
|
86
|
+
case 'variant':
|
|
87
|
+
case 'size':
|
|
88
|
+
case 'loading':
|
|
89
|
+
case 'full-width':
|
|
90
|
+
case 'icon-only':
|
|
91
|
+
this._updateClasses();
|
|
92
|
+
break;
|
|
93
|
+
case 'disabled':
|
|
94
|
+
this._updateDisabled();
|
|
95
|
+
break;
|
|
96
|
+
case 'type':
|
|
97
|
+
this._updateType();
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Loading affects classes, disabled state, and loader icon
|
|
102
|
+
if (name === 'loading') {
|
|
103
|
+
this._updateDisabled();
|
|
104
|
+
this._updateLoader();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private _updateLoader(): void {
|
|
109
|
+
if (this.loading) {
|
|
110
|
+
if (!this._loaderEl) {
|
|
111
|
+
this._loaderEl = this._createLoaderSvg();
|
|
112
|
+
}
|
|
113
|
+
// Hide the icon-start slot and show loader instead
|
|
114
|
+
this._iconStartSlot.style.display = 'none';
|
|
115
|
+
this._button.insertBefore(this._loaderEl, this._iconStartSlot);
|
|
116
|
+
} else {
|
|
117
|
+
if (this._loaderEl && this._loaderEl.parentNode) {
|
|
118
|
+
this._loaderEl.remove();
|
|
119
|
+
}
|
|
120
|
+
this._iconStartSlot.style.display = '';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private _createLoaderSvg(): SVGElement {
|
|
125
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
126
|
+
const svg = document.createElementNS(ns, 'svg');
|
|
127
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
128
|
+
svg.setAttribute('fill', 'none');
|
|
129
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
130
|
+
svg.setAttribute('stroke-width', '2');
|
|
131
|
+
svg.setAttribute('stroke-linecap', 'round');
|
|
132
|
+
svg.setAttribute('stroke-linejoin', 'round');
|
|
133
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
134
|
+
svg.classList.add('dsn-icon', 'dsn-button__loader');
|
|
135
|
+
|
|
136
|
+
const paths = iconPaths['loader'] || [];
|
|
137
|
+
paths.forEach((d) => {
|
|
138
|
+
const path = document.createElementNS(ns, 'path');
|
|
139
|
+
path.setAttribute('d', d);
|
|
140
|
+
// Skip the transparent fill path (bounding box)
|
|
141
|
+
if (d === 'M0 0h24v24H0z') {
|
|
142
|
+
path.setAttribute('stroke', 'none');
|
|
143
|
+
path.setAttribute('fill', 'none');
|
|
144
|
+
}
|
|
145
|
+
svg.appendChild(path);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return svg;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private _updateClasses(): void {
|
|
152
|
+
const variant = this.variant;
|
|
153
|
+
const size = this.size;
|
|
154
|
+
const loading = this.loading;
|
|
155
|
+
const fullWidth = this.fullWidth;
|
|
156
|
+
const iconOnly = this.iconOnly;
|
|
157
|
+
|
|
158
|
+
const classes = [
|
|
159
|
+
'dsn-button',
|
|
160
|
+
`dsn-button--${variant}`,
|
|
161
|
+
`dsn-button--size-${size}`,
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
if (loading) {
|
|
165
|
+
classes.push('dsn-button--loading');
|
|
166
|
+
}
|
|
167
|
+
if (fullWidth) {
|
|
168
|
+
classes.push('dsn-button--full-width');
|
|
169
|
+
}
|
|
170
|
+
if (iconOnly) {
|
|
171
|
+
classes.push('dsn-button--icon-only');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
this._button.className = classes.join(' ');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private _updateDisabled(): void {
|
|
178
|
+
const isDisabled = this.disabled || this.loading;
|
|
179
|
+
if (isDisabled) {
|
|
180
|
+
this._button.setAttribute('disabled', '');
|
|
181
|
+
} else {
|
|
182
|
+
this._button.removeAttribute('disabled');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (this.loading) {
|
|
186
|
+
this._button.setAttribute('aria-busy', 'true');
|
|
187
|
+
} else {
|
|
188
|
+
this._button.removeAttribute('aria-busy');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private _updateType(): void {
|
|
193
|
+
this._button.setAttribute('type', this.type);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Property: variant
|
|
197
|
+
get variant(): ButtonVariant {
|
|
198
|
+
return (this.getAttribute('variant') as ButtonVariant) || 'strong';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
set variant(value: ButtonVariant) {
|
|
202
|
+
this.setAttribute('variant', value);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Property: size
|
|
206
|
+
get size(): ButtonSize {
|
|
207
|
+
return (this.getAttribute('size') as ButtonSize) || 'default';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
set size(value: ButtonSize) {
|
|
211
|
+
this.setAttribute('size', value);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Property: disabled
|
|
215
|
+
get disabled(): boolean {
|
|
216
|
+
return this.hasAttribute('disabled');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
set disabled(value: boolean) {
|
|
220
|
+
if (value) {
|
|
221
|
+
this.setAttribute('disabled', '');
|
|
222
|
+
} else {
|
|
223
|
+
this.removeAttribute('disabled');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Property: loading
|
|
228
|
+
get loading(): boolean {
|
|
229
|
+
return this.hasAttribute('loading');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
set loading(value: boolean) {
|
|
233
|
+
if (value) {
|
|
234
|
+
this.setAttribute('loading', '');
|
|
235
|
+
} else {
|
|
236
|
+
this.removeAttribute('loading');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Property: fullWidth (maps to full-width attribute)
|
|
241
|
+
get fullWidth(): boolean {
|
|
242
|
+
return this.hasAttribute('full-width');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
set fullWidth(value: boolean) {
|
|
246
|
+
if (value) {
|
|
247
|
+
this.setAttribute('full-width', '');
|
|
248
|
+
} else {
|
|
249
|
+
this.removeAttribute('full-width');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Property: iconOnly (maps to icon-only attribute)
|
|
254
|
+
get iconOnly(): boolean {
|
|
255
|
+
return this.hasAttribute('icon-only');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
set iconOnly(value: boolean) {
|
|
259
|
+
if (value) {
|
|
260
|
+
this.setAttribute('icon-only', '');
|
|
261
|
+
} else {
|
|
262
|
+
this.removeAttribute('icon-only');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Property: type
|
|
267
|
+
get type(): string {
|
|
268
|
+
return this.getAttribute('type') || 'button';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
set type(value: string) {
|
|
272
|
+
this.setAttribute('type', value);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Expose the internal button for form submission
|
|
276
|
+
get form(): HTMLFormElement | null {
|
|
277
|
+
return this._button.form;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Focus delegation
|
|
281
|
+
focus(options?: FocusOptions): void {
|
|
282
|
+
this._button.focus(options);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
blur(): void {
|
|
286
|
+
this._button.blur();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
click(): void {
|
|
290
|
+
this._button.click();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Register the custom element
|
|
295
|
+
export function defineButton(tagName = 'dsn-button'): void {
|
|
296
|
+
if (!customElements.get(tagName)) {
|
|
297
|
+
customElements.define(tagName, DsnButton);
|
|
298
|
+
}
|
|
299
|
+
}
|