@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,186 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
|
2
|
+
import { DsnHeading, defineHeading } from './Heading';
|
|
3
|
+
|
|
4
|
+
// Register the custom element once for all tests
|
|
5
|
+
beforeAll(() => {
|
|
6
|
+
defineHeading();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
function createElement(attrs: Record<string, string> = {}): DsnHeading {
|
|
10
|
+
const el = document.createElement('dsn-heading') as DsnHeading;
|
|
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 getInternalHeading(el: DsnHeading): HTMLElement {
|
|
19
|
+
return el.shadowRoot!.querySelector('h1, h2, h3, h4, h5, h6')!;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('DsnHeading', () => {
|
|
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 heading element', () => {
|
|
34
|
+
const el = createElement();
|
|
35
|
+
const h = getInternalHeading(el);
|
|
36
|
+
expect(h).not.toBeNull();
|
|
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-heading');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('renders a slot for content projection', () => {
|
|
47
|
+
const el = createElement();
|
|
48
|
+
const slot = el.shadowRoot!.querySelector('slot');
|
|
49
|
+
expect(slot).not.toBeNull();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('level attribute', () => {
|
|
54
|
+
it('defaults to h2', () => {
|
|
55
|
+
const el = createElement();
|
|
56
|
+
const h = getInternalHeading(el);
|
|
57
|
+
expect(h.tagName).toBe('H2');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('renders h1 when level is 1', () => {
|
|
61
|
+
const el = createElement({ level: '1' });
|
|
62
|
+
const h = getInternalHeading(el);
|
|
63
|
+
expect(h.tagName).toBe('H1');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('renders h3 when level is 3', () => {
|
|
67
|
+
const el = createElement({ level: '3' });
|
|
68
|
+
const h = getInternalHeading(el);
|
|
69
|
+
expect(h.tagName).toBe('H3');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('renders h4 when level is 4', () => {
|
|
73
|
+
const el = createElement({ level: '4' });
|
|
74
|
+
const h = getInternalHeading(el);
|
|
75
|
+
expect(h.tagName).toBe('H4');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('renders h5 when level is 5', () => {
|
|
79
|
+
const el = createElement({ level: '5' });
|
|
80
|
+
const h = getInternalHeading(el);
|
|
81
|
+
expect(h.tagName).toBe('H5');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('renders h6 when level is 6', () => {
|
|
85
|
+
const el = createElement({ level: '6' });
|
|
86
|
+
const h = getInternalHeading(el);
|
|
87
|
+
expect(h.tagName).toBe('H6');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('updates element when level changes', () => {
|
|
91
|
+
const el = createElement({ level: '2' });
|
|
92
|
+
expect(getInternalHeading(el).tagName).toBe('H2');
|
|
93
|
+
|
|
94
|
+
el.setAttribute('level', '4');
|
|
95
|
+
expect(getInternalHeading(el).tagName).toBe('H4');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('appearance attribute', () => {
|
|
100
|
+
it('defaults appearance to match level', () => {
|
|
101
|
+
const el = createElement({ level: '3' });
|
|
102
|
+
const h = getInternalHeading(el);
|
|
103
|
+
expect(h.className).toContain('dsn-heading--heading-3');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('allows appearance independent of level', () => {
|
|
107
|
+
const el = createElement({ level: '2', appearance: 'heading-4' });
|
|
108
|
+
const h = getInternalHeading(el);
|
|
109
|
+
expect(h.tagName).toBe('H2');
|
|
110
|
+
expect(h.className).toContain('dsn-heading--heading-4');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('updates classes when appearance changes', () => {
|
|
114
|
+
const el = createElement({ appearance: 'heading-1' });
|
|
115
|
+
const h = getInternalHeading(el);
|
|
116
|
+
expect(h.className).toContain('dsn-heading--heading-1');
|
|
117
|
+
|
|
118
|
+
el.setAttribute('appearance', 'heading-5');
|
|
119
|
+
expect(h.className).toContain('dsn-heading--heading-5');
|
|
120
|
+
expect(h.className).not.toContain('dsn-heading--heading-1');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('level property', () => {
|
|
125
|
+
it('returns "2" when no attribute set', () => {
|
|
126
|
+
const el = createElement();
|
|
127
|
+
expect(el.level).toBe('2');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('reflects the attribute value', () => {
|
|
131
|
+
const el = createElement({ level: '3' });
|
|
132
|
+
expect(el.level).toBe('3');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('sets the attribute when property is set', () => {
|
|
136
|
+
const el = createElement();
|
|
137
|
+
el.level = '5';
|
|
138
|
+
expect(el.getAttribute('level')).toBe('5');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('appearance property', () => {
|
|
143
|
+
it('defaults to heading-2 when no attributes set', () => {
|
|
144
|
+
const el = createElement();
|
|
145
|
+
expect(el.appearance).toBe('heading-2');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('reflects the attribute value', () => {
|
|
149
|
+
const el = createElement({ appearance: 'heading-3' });
|
|
150
|
+
expect(el.appearance).toBe('heading-3');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('sets the attribute when property is set', () => {
|
|
154
|
+
const el = createElement();
|
|
155
|
+
el.appearance = 'heading-6';
|
|
156
|
+
expect(el.getAttribute('appearance')).toBe('heading-6');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('base class', () => {
|
|
161
|
+
it('always includes dsn-heading base class', () => {
|
|
162
|
+
const el = createElement();
|
|
163
|
+
const h = getInternalHeading(el);
|
|
164
|
+
expect(h.className).toContain('dsn-heading');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('includes base class with heading-1 appearance', () => {
|
|
168
|
+
const el = createElement({ appearance: 'heading-1' });
|
|
169
|
+
const h = getInternalHeading(el);
|
|
170
|
+
expect(h.className).toContain('dsn-heading');
|
|
171
|
+
expect(h.className).toContain('dsn-heading--heading-1');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('custom element registration', () => {
|
|
176
|
+
it('is defined as dsn-heading', () => {
|
|
177
|
+
expect(customElements.get('dsn-heading')).toBe(DsnHeading);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('does not re-register if already defined', () => {
|
|
181
|
+
// Should not throw
|
|
182
|
+
defineHeading();
|
|
183
|
+
expect(customElements.get('dsn-heading')).toBe(DsnHeading);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSN Heading Web Component
|
|
3
|
+
*
|
|
4
|
+
* A custom element that provides styled headings with independent
|
|
5
|
+
* semantic level and visual appearance.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```html
|
|
9
|
+
* <dsn-heading>Default h2 heading</dsn-heading>
|
|
10
|
+
* <dsn-heading level="1">Page Title</dsn-heading>
|
|
11
|
+
* <dsn-heading level="2" appearance="heading-4">Smaller h2</dsn-heading>
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type HeadingLevel = '1' | '2' | '3' | '4' | '5' | '6';
|
|
16
|
+
export type HeadingAppearance =
|
|
17
|
+
| 'heading-1'
|
|
18
|
+
| 'heading-2'
|
|
19
|
+
| 'heading-3'
|
|
20
|
+
| 'heading-4'
|
|
21
|
+
| 'heading-5'
|
|
22
|
+
| 'heading-6';
|
|
23
|
+
|
|
24
|
+
// Auto-generated CSS from shared source — run `node scripts/build-css.js` to regenerate
|
|
25
|
+
import { headingStyles } from './heading-styles.generated';
|
|
26
|
+
|
|
27
|
+
export class DsnHeading extends HTMLElement {
|
|
28
|
+
static get observedAttributes(): string[] {
|
|
29
|
+
return ['level', 'appearance'];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private _heading: HTMLElement;
|
|
33
|
+
|
|
34
|
+
constructor() {
|
|
35
|
+
super();
|
|
36
|
+
this.attachShadow({ mode: 'open' });
|
|
37
|
+
|
|
38
|
+
// Default to h2
|
|
39
|
+
this._heading = document.createElement('h2');
|
|
40
|
+
this._heading.innerHTML = '<slot></slot>';
|
|
41
|
+
|
|
42
|
+
// Create style element with embedded CSS
|
|
43
|
+
const style = document.createElement('style');
|
|
44
|
+
style.textContent = headingStyles;
|
|
45
|
+
|
|
46
|
+
this.shadowRoot!.appendChild(style);
|
|
47
|
+
this.shadowRoot!.appendChild(this._heading);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
connectedCallback(): void {
|
|
51
|
+
this._updateElement();
|
|
52
|
+
this._updateClasses();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
attributeChangedCallback(name: string): void {
|
|
56
|
+
if (name === 'level') {
|
|
57
|
+
this._updateElement();
|
|
58
|
+
}
|
|
59
|
+
this._updateClasses();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private _updateElement(): void {
|
|
63
|
+
const tag = `h${this.level}`;
|
|
64
|
+
if (this._heading.tagName.toLowerCase() !== tag) {
|
|
65
|
+
const newHeading = document.createElement(tag);
|
|
66
|
+
newHeading.innerHTML = '<slot></slot>';
|
|
67
|
+
newHeading.className = this._heading.className;
|
|
68
|
+
this.shadowRoot!.replaceChild(newHeading, this._heading);
|
|
69
|
+
this._heading = newHeading;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private _updateClasses(): void {
|
|
74
|
+
const appearance = this.appearance;
|
|
75
|
+
const classes = ['dsn-heading', `dsn-heading--${appearance}`];
|
|
76
|
+
this._heading.className = classes.join(' ');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Property: level
|
|
80
|
+
get level(): HeadingLevel {
|
|
81
|
+
return (this.getAttribute('level') as HeadingLevel) || '2';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
set level(value: HeadingLevel) {
|
|
85
|
+
this.setAttribute('level', value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Property: appearance
|
|
89
|
+
get appearance(): HeadingAppearance {
|
|
90
|
+
const attr = this.getAttribute('appearance') as HeadingAppearance;
|
|
91
|
+
return attr || (`heading-${this.level}` as HeadingAppearance);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
set appearance(value: HeadingAppearance) {
|
|
95
|
+
this.setAttribute('appearance', value);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Register the custom element
|
|
100
|
+
export function defineHeading(tagName = 'dsn-heading'): void {
|
|
101
|
+
if (!customElements.get(tagName)) {
|
|
102
|
+
customElements.define(tagName, DsnHeading);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Auto-generated — do not edit manually.
|
|
2
|
+
// Source: heading.shadow.css + components-html source
|
|
3
|
+
export const headingStyles = "/**\n * Shadow DOM wrapper styles for the Heading Web Component.\n * The core heading styles are imported from the shared HTML/CSS source.\n */\n\n:host {\n display: block;\n}\n\n/* Shared heading styles - single source of truth */\n/**\n * Heading Component\n * Uses semantic component tokens for consistent styling\n *\n * Each appearance level has its own complete set of tokens\n * (font-family, font-weight, color, font-size, line-height, margin).\n *\n * The `level` prop controls the semantic HTML element (h1–h6).\n * The `appearance` prop controls the visual style, independently of level.\n */\n\n.dsn-heading {\n margin-block-start: 0;\n text-wrap: balance;\n}\n\n/* Heading 1 Appearance */\n.dsn-heading--heading-1 {\n font-family: var(--dsn-heading-level-1-font-family);\n font-weight: var(--dsn-heading-level-1-font-weight);\n color: var(--dsn-heading-level-1-color);\n font-size: var(--dsn-heading-level-1-font-size);\n line-height: var(--dsn-heading-level-1-line-height);\n margin-block-end: var(--dsn-heading-level-1-margin-block-end);\n}\n\n/* Heading 2 Appearance */\n.dsn-heading--heading-2 {\n font-family: var(--dsn-heading-level-2-font-family);\n font-weight: var(--dsn-heading-level-2-font-weight);\n color: var(--dsn-heading-level-2-color);\n font-size: var(--dsn-heading-level-2-font-size);\n line-height: var(--dsn-heading-level-2-line-height);\n margin-block-end: var(--dsn-heading-level-2-margin-block-end);\n}\n\n/* Heading 3 Appearance */\n.dsn-heading--heading-3 {\n font-family: var(--dsn-heading-level-3-font-family);\n font-weight: var(--dsn-heading-level-3-font-weight);\n color: var(--dsn-heading-level-3-color);\n font-size: var(--dsn-heading-level-3-font-size);\n line-height: var(--dsn-heading-level-3-line-height);\n margin-block-end: var(--dsn-heading-level-3-margin-block-end);\n}\n\n/* Heading 4 Appearance */\n.dsn-heading--heading-4 {\n font-family: var(--dsn-heading-level-4-font-family);\n font-weight: var(--dsn-heading-level-4-font-weight);\n color: var(--dsn-heading-level-4-color);\n font-size: var(--dsn-heading-level-4-font-size);\n line-height: var(--dsn-heading-level-4-line-height);\n margin-block-end: var(--dsn-heading-level-4-margin-block-end);\n}\n\n/* Heading 5 Appearance */\n.dsn-heading--heading-5 {\n font-family: var(--dsn-heading-level-5-font-family);\n font-weight: var(--dsn-heading-level-5-font-weight);\n color: var(--dsn-heading-level-5-color);\n font-size: var(--dsn-heading-level-5-font-size);\n line-height: var(--dsn-heading-level-5-line-height);\n margin-block-end: var(--dsn-heading-level-5-margin-block-end);\n}\n\n/* Heading 6 Appearance */\n.dsn-heading--heading-6 {\n font-family: var(--dsn-heading-level-6-font-family);\n font-weight: var(--dsn-heading-level-6-font-weight);\n color: var(--dsn-heading-level-6-color);\n font-size: var(--dsn-heading-level-6-font-size);\n line-height: var(--dsn-heading-level-6-line-height);\n margin-block-end: var(--dsn-heading-level-6-margin-block-end);\n}\n\n";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow DOM wrapper styles for the Heading Web Component.
|
|
3
|
+
* The core heading styles are imported from the shared HTML/CSS source.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
:host {
|
|
7
|
+
display: block;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* Shared heading styles - single source of truth */
|
|
11
|
+
@import '../../../components-html/src/heading/heading.css';
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { DsnIcon, defineIcon } from './Icon';
|
|
3
|
+
|
|
4
|
+
// Register the custom element for tests
|
|
5
|
+
defineIcon();
|
|
6
|
+
|
|
7
|
+
function createElement(attrs: Record<string, string> = {}): DsnIcon {
|
|
8
|
+
const el = document.createElement('dsn-icon') as DsnIcon;
|
|
9
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
10
|
+
el.setAttribute(key, value);
|
|
11
|
+
}
|
|
12
|
+
document.body.appendChild(el);
|
|
13
|
+
return el;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('DsnIcon', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
document.body.innerHTML = '';
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('registration', () => {
|
|
22
|
+
it('should be registered as dsn-icon', () => {
|
|
23
|
+
expect(customElements.get('dsn-icon')).toBe(DsnIcon);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should be an instance of HTMLElement', () => {
|
|
27
|
+
const el = createElement({ name: 'check' });
|
|
28
|
+
expect(el).toBeInstanceOf(HTMLElement);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should have a shadow root', () => {
|
|
32
|
+
const el = createElement({ name: 'check' });
|
|
33
|
+
expect(el.shadowRoot).not.toBeNull();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('rendering', () => {
|
|
38
|
+
it('should render an SVG when name is set', () => {
|
|
39
|
+
const el = createElement({ name: 'check' });
|
|
40
|
+
const svg = el.shadowRoot!.querySelector('svg');
|
|
41
|
+
expect(svg).not.toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should not render an SVG when name is empty', () => {
|
|
45
|
+
const el = createElement();
|
|
46
|
+
const svg = el.shadowRoot!.querySelector('svg');
|
|
47
|
+
expect(svg).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should not render an SVG for unknown icon names', () => {
|
|
51
|
+
const el = createElement({ name: 'nonexistent-icon' });
|
|
52
|
+
const svg = el.shadowRoot!.querySelector('svg');
|
|
53
|
+
expect(svg).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should render path elements inside the SVG', () => {
|
|
57
|
+
const el = createElement({ name: 'check' });
|
|
58
|
+
const paths = el.shadowRoot!.querySelectorAll('svg path');
|
|
59
|
+
expect(paths.length).toBeGreaterThan(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should have correct SVG attributes', () => {
|
|
63
|
+
const el = createElement({ name: 'check' });
|
|
64
|
+
const svg = el.shadowRoot!.querySelector('svg')!;
|
|
65
|
+
expect(svg.getAttribute('viewBox')).toBe('0 0 24 24');
|
|
66
|
+
expect(svg.getAttribute('fill')).toBe('none');
|
|
67
|
+
expect(svg.getAttribute('stroke')).toBe('currentColor');
|
|
68
|
+
expect(svg.getAttribute('stroke-width')).toBe('2');
|
|
69
|
+
expect(svg.getAttribute('stroke-linecap')).toBe('round');
|
|
70
|
+
expect(svg.getAttribute('stroke-linejoin')).toBe('round');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should update SVG when name attribute changes', () => {
|
|
74
|
+
const el = createElement({ name: 'check' });
|
|
75
|
+
const pathsBefore = el.shadowRoot!.querySelectorAll('svg path');
|
|
76
|
+
|
|
77
|
+
el.setAttribute('name', 'plus');
|
|
78
|
+
const pathsAfter = el.shadowRoot!.querySelectorAll('svg path');
|
|
79
|
+
|
|
80
|
+
// plus has 2 paths, check has 1
|
|
81
|
+
expect(pathsAfter.length).toBe(2);
|
|
82
|
+
expect(pathsBefore.length).toBe(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should remove SVG when name is set to unknown', () => {
|
|
86
|
+
const el = createElement({ name: 'check' });
|
|
87
|
+
expect(el.shadowRoot!.querySelector('svg')).not.toBeNull();
|
|
88
|
+
|
|
89
|
+
el.setAttribute('name', 'invalid');
|
|
90
|
+
expect(el.shadowRoot!.querySelector('svg')).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('sizes', () => {
|
|
95
|
+
it('should default to md size', () => {
|
|
96
|
+
const el = createElement({ name: 'check' });
|
|
97
|
+
const svg = el.shadowRoot!.querySelector('svg')!;
|
|
98
|
+
expect(svg.classList.contains('dsn-icon')).toBe(true);
|
|
99
|
+
// md is default, no modifier class needed
|
|
100
|
+
expect(svg.classList.contains('dsn-icon--md')).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it.each(['sm', 'lg', 'xl', '2xl', '3xl', '4xl'] as const)(
|
|
104
|
+
'should apply dsn-icon--%s class for size="%s"',
|
|
105
|
+
(size) => {
|
|
106
|
+
const el = createElement({ name: 'check', size });
|
|
107
|
+
const svg = el.shadowRoot!.querySelector('svg')!;
|
|
108
|
+
expect(svg.classList.contains(`dsn-icon--${size}`)).toBe(true);
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
it('should update size class when size attribute changes', () => {
|
|
113
|
+
const el = createElement({ name: 'check', size: 'sm' });
|
|
114
|
+
let svg = el.shadowRoot!.querySelector('svg')!;
|
|
115
|
+
expect(svg.classList.contains('dsn-icon--sm')).toBe(true);
|
|
116
|
+
|
|
117
|
+
el.setAttribute('size', 'lg');
|
|
118
|
+
svg = el.shadowRoot!.querySelector('svg')!;
|
|
119
|
+
expect(svg.classList.contains('dsn-icon--lg')).toBe(true);
|
|
120
|
+
expect(svg.classList.contains('dsn-icon--sm')).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('accessibility', () => {
|
|
125
|
+
it('should be aria-hidden when no label is provided', () => {
|
|
126
|
+
const el = createElement({ name: 'check' });
|
|
127
|
+
const svg = el.shadowRoot!.querySelector('svg')!;
|
|
128
|
+
expect(svg.getAttribute('aria-hidden')).toBe('true');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should have role=img and aria-label when label is provided', () => {
|
|
132
|
+
const el = createElement({ name: 'check', label: 'Checkmark' });
|
|
133
|
+
const svg = el.shadowRoot!.querySelector('svg')!;
|
|
134
|
+
expect(svg.getAttribute('role')).toBe('img');
|
|
135
|
+
expect(svg.getAttribute('aria-label')).toBe('Checkmark');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should not have aria-hidden when label is provided', () => {
|
|
139
|
+
const el = createElement({ name: 'check', label: 'Checkmark' });
|
|
140
|
+
const svg = el.shadowRoot!.querySelector('svg')!;
|
|
141
|
+
expect(svg.hasAttribute('aria-hidden')).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should update accessibility when label changes', () => {
|
|
145
|
+
const el = createElement({ name: 'check' });
|
|
146
|
+
let svg = el.shadowRoot!.querySelector('svg')!;
|
|
147
|
+
expect(svg.getAttribute('aria-hidden')).toBe('true');
|
|
148
|
+
|
|
149
|
+
el.setAttribute('label', 'Done');
|
|
150
|
+
svg = el.shadowRoot!.querySelector('svg')!;
|
|
151
|
+
expect(svg.getAttribute('role')).toBe('img');
|
|
152
|
+
expect(svg.getAttribute('aria-label')).toBe('Done');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('properties', () => {
|
|
157
|
+
it('should reflect name property to attribute', () => {
|
|
158
|
+
const el = createElement();
|
|
159
|
+
el.name = 'star';
|
|
160
|
+
expect(el.getAttribute('name')).toBe('star');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should reflect size property to attribute', () => {
|
|
164
|
+
const el = createElement({ name: 'check' });
|
|
165
|
+
el.size = 'lg';
|
|
166
|
+
expect(el.getAttribute('size')).toBe('lg');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should reflect label property to attribute', () => {
|
|
170
|
+
const el = createElement({ name: 'check' });
|
|
171
|
+
el.label = 'Done';
|
|
172
|
+
expect(el.getAttribute('label')).toBe('Done');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should remove label attribute when set to empty string', () => {
|
|
176
|
+
const el = createElement({ name: 'check', label: 'Done' });
|
|
177
|
+
el.label = '';
|
|
178
|
+
expect(el.hasAttribute('label')).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should return correct default values for properties', () => {
|
|
182
|
+
const el = createElement({ name: 'check' });
|
|
183
|
+
expect(el.name).toBe('check');
|
|
184
|
+
expect(el.size).toBe('md');
|
|
185
|
+
expect(el.label).toBe('');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('icons', () => {
|
|
190
|
+
const iconNames = [
|
|
191
|
+
'arrow-left',
|
|
192
|
+
'arrow-right',
|
|
193
|
+
'check',
|
|
194
|
+
'chevron-down',
|
|
195
|
+
'chevron-left',
|
|
196
|
+
'chevron-right',
|
|
197
|
+
'chevron-up',
|
|
198
|
+
'download',
|
|
199
|
+
'edit',
|
|
200
|
+
'heart',
|
|
201
|
+
'home',
|
|
202
|
+
'menu',
|
|
203
|
+
'minus',
|
|
204
|
+
'plus',
|
|
205
|
+
'search',
|
|
206
|
+
'settings',
|
|
207
|
+
'star',
|
|
208
|
+
'trash',
|
|
209
|
+
'user',
|
|
210
|
+
'x',
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
it.each(iconNames)('should render icon "%s"', (name) => {
|
|
214
|
+
const el = createElement({ name });
|
|
215
|
+
const svg = el.shadowRoot!.querySelector('svg');
|
|
216
|
+
expect(svg).not.toBeNull();
|
|
217
|
+
const paths = svg!.querySelectorAll('path');
|
|
218
|
+
expect(paths.length).toBeGreaterThan(0);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('defineIcon', () => {
|
|
223
|
+
it('should not throw when called multiple times', () => {
|
|
224
|
+
expect(() => defineIcon()).not.toThrow();
|
|
225
|
+
expect(() => defineIcon()).not.toThrow();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|
package/src/icon/Icon.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSN Icon Web Component
|
|
3
|
+
*
|
|
4
|
+
* A custom element that renders SVG icons from the design system icon set.
|
|
5
|
+
* Icons are inlined as SVG markup for Shadow DOM encapsulation.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```html
|
|
9
|
+
* <dsn-icon name="check" size="md"></dsn-icon>
|
|
10
|
+
* <dsn-icon name="settings" size="lg" label="Open settings"></dsn-icon>
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { iconStyles } from './icon-styles.generated';
|
|
15
|
+
import { iconPaths } from './icon-paths.generated';
|
|
16
|
+
|
|
17
|
+
export type IconSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
|
|
18
|
+
export type IconName = keyof typeof iconPaths;
|
|
19
|
+
|
|
20
|
+
export class DsnIcon extends HTMLElement {
|
|
21
|
+
static get observedAttributes(): string[] {
|
|
22
|
+
return ['name', 'size', 'label'];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private _svg: SVGSVGElement | null = null;
|
|
26
|
+
|
|
27
|
+
constructor() {
|
|
28
|
+
super();
|
|
29
|
+
this.attachShadow({ mode: 'open' });
|
|
30
|
+
|
|
31
|
+
const style = document.createElement('style');
|
|
32
|
+
style.textContent = iconStyles;
|
|
33
|
+
this.shadowRoot!.appendChild(style);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
connectedCallback(): void {
|
|
37
|
+
this._render();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
attributeChangedCallback(): void {
|
|
41
|
+
this._render();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private _render(): void {
|
|
45
|
+
const name = this.name;
|
|
46
|
+
const size = this.size;
|
|
47
|
+
const label = this.label;
|
|
48
|
+
|
|
49
|
+
// Remove existing SVG if any
|
|
50
|
+
if (this._svg) {
|
|
51
|
+
this._svg.remove();
|
|
52
|
+
this._svg = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!name || !(name in iconPaths)) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const paths = iconPaths[name as keyof typeof iconPaths];
|
|
60
|
+
|
|
61
|
+
// Build SVG element
|
|
62
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
63
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
64
|
+
svg.setAttribute('fill', 'none');
|
|
65
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
66
|
+
svg.setAttribute('stroke-width', '2');
|
|
67
|
+
svg.setAttribute('stroke-linecap', 'round');
|
|
68
|
+
svg.setAttribute('stroke-linejoin', 'round');
|
|
69
|
+
|
|
70
|
+
// Apply CSS classes
|
|
71
|
+
const classes = ['dsn-icon'];
|
|
72
|
+
if (size !== 'md') {
|
|
73
|
+
classes.push(`dsn-icon--${size}`);
|
|
74
|
+
}
|
|
75
|
+
svg.setAttribute('class', classes.join(' '));
|
|
76
|
+
|
|
77
|
+
// Accessibility
|
|
78
|
+
if (label) {
|
|
79
|
+
svg.setAttribute('role', 'img');
|
|
80
|
+
svg.setAttribute('aria-label', label);
|
|
81
|
+
} else {
|
|
82
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Add paths
|
|
86
|
+
for (const d of paths) {
|
|
87
|
+
const path = document.createElementNS(
|
|
88
|
+
'http://www.w3.org/2000/svg',
|
|
89
|
+
'path'
|
|
90
|
+
);
|
|
91
|
+
path.setAttribute('d', d);
|
|
92
|
+
svg.appendChild(path);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this._svg = svg;
|
|
96
|
+
this.shadowRoot!.appendChild(svg);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Property: name
|
|
100
|
+
get name(): string {
|
|
101
|
+
return this.getAttribute('name') || '';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
set name(value: string) {
|
|
105
|
+
this.setAttribute('name', value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Property: size
|
|
109
|
+
get size(): IconSize {
|
|
110
|
+
return (this.getAttribute('size') as IconSize) || 'md';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
set size(value: IconSize) {
|
|
114
|
+
this.setAttribute('size', value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Property: label (accessible label)
|
|
118
|
+
get label(): string {
|
|
119
|
+
return this.getAttribute('label') || '';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
set label(value: string) {
|
|
123
|
+
if (value) {
|
|
124
|
+
this.setAttribute('label', value);
|
|
125
|
+
} else {
|
|
126
|
+
this.removeAttribute('label');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Register the custom element
|
|
132
|
+
export function defineIcon(tagName = 'dsn-icon'): void {
|
|
133
|
+
if (!customElements.get(tagName)) {
|
|
134
|
+
customElements.define(tagName, DsnIcon);
|
|
135
|
+
}
|
|
136
|
+
}
|