Skip to content

Commit 80ddb3b

Browse files
Yolijnermenmbartveneman
authored
Feat: component presets (#606)
Co-authored-by: Michelle <michelleermen@gmail.com> Co-authored-by: Bart Veneman <bartveneman@gmail.com>
1 parent 4e619e6 commit 80ddb3b

36 files changed

Lines changed: 3877 additions & 136 deletions

File tree

packages/theme-wizard-app/.stylelintrc.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export default {
66
'keyframes-name-pattern': '^(ams|basis|theme|utrecht|todo|--wizard)-[a-z0-9-]+$',
77
'scss/dollar-variable-pattern': '^(ams|basis|theme|utrecht|todo)-[a-z0-9-]+$',
88
'scss/percent-placeholder-pattern': '^(ams|basis|theme|utrecht|todo)-[a-z0-9-]+$',
9-
'selector-class-pattern': '^(ams|basis|theme|denhaag|utrecht|todo|wizard)-[a-z0-9_-]+$',
9+
'selector-class-pattern': '^(ams|basis|theme|denhaag|utrecht|todo|wizard|nl)-[a-z0-9_-]+$',
1010
},
1111
overrides: [
1212
{

packages/theme-wizard-app/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ import './src/components/wizard-style-guide-spacing';
2424
import './src/components/wizard-style-guide-components';
2525
import './src/components/wizard-table-scroller';
2626
import './src/components/wizard-theme-reset-button';
27+
import './src/components/wizard-token-presets';
2728
import './src/components/wizard-tokens-download';
2829
import './src/components/wizard-tokens-form';

packages/theme-wizard-app/src/components/app/app.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,23 @@ import { themeContext } from '../../contexts/theme';
88
import { getSiblingGroupsWithOnlyRefsTo } from '../../lib/ColorScale/siblings';
99
import PersistentStorage from '../../lib/PersistentStorage';
1010
import Theme from '../../lib/Theme';
11+
import { presetTokensToUpdateMany } from '../../lib/Theme/lib';
1112
import { EXTENSION_TOKEN_STAGED, StagedDesignToken } from '../../utils/types';
1213
import { WizardColorscaleInput, EXTENSION_COLORSCALE_SEED } from '../wizard-colorscale-input';
1314
import { WizardScraper } from '../wizard-scraper';
1415
import { WizardTokenCombobox } from '../wizard-token-combobox';
1516
import { WizardTokenInput } from '../wizard-token-input';
17+
import { WizardTokenPreset } from '../wizard-token-presets';
1618

1719
const tag = 'theme-wizard-app';
1820

21+
export type ThemeUpdateEvent = CustomEvent<{ theme: Theme }>;
22+
23+
declare global {
24+
interface DocumentEventMap {
25+
'theme-update': ThemeUpdateEvent;
26+
}
27+
}
1928
declare global {
2029
interface HTMLElementTagNameMap {
2130
[tag]: App;
@@ -82,6 +91,7 @@ export class App extends LitElement {
8291
readonly #forceUpdateTokens = () => {
8392
this.theme = this.theme.clone();
8493
this.requestUpdate();
94+
this.dispatchEvent(new CustomEvent('theme-update', { bubbles: true, detail: { theme: this.theme } }));
8595
};
8696

8797
readonly #handleScrapeDone = (event: Event) => {
@@ -107,7 +117,7 @@ export class App extends LitElement {
107117

108118
readonly #handleTokenChange = async (event: Event) => {
109119
const target = event.composedPath().shift(); // @see https://lit.dev/docs/components/events/#shadowdom-retargeting
110-
if (!(target instanceof WizardTokenInput || target instanceof WizardTokenCombobox)) return;
120+
111121
if (target instanceof WizardColorscaleInput) {
112122
const scaleColors = Object.values(target.value);
113123
const reversedScale = scaleColors.toReversed();
@@ -143,6 +153,11 @@ export class App extends LitElement {
143153
this.theme.updateAt(target.name, target.value?.$value);
144154
} else if (target instanceof WizardTokenInput) {
145155
this.theme.updateAt(target.name, target.value);
156+
} else if (target instanceof WizardTokenPreset) {
157+
const updates = presetTokensToUpdateMany(target.value);
158+
this.theme.updateMany(updates);
159+
} else {
160+
return;
146161
}
147162
this.#forceUpdateTokens();
148163
this.#themeStorage.setJSON(this.theme.tokens);

packages/theme-wizard-app/src/components/wizard-layout/styles.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,9 @@ export default css`
7474
7575
/* Hide all app descendants, except those that are needed to show the preview content */
7676
.wizard-layout *:not(
77-
:has(.wizard-layout__main), /* any parent of main */
78-
.wizard-layout__main, /* main itself */
79-
.wizard-layout__main * /* any child of main */
77+
:has(.wizard-layout__main),
78+
.wizard-layout__main,
79+
.wizard-layout__main *
8080
) {
8181
display: none;
8282
}

packages/theme-wizard-app/src/components/wizard-preview-theme/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export class PreviewTheme extends LitElement {
2323

2424
this.shadowRoot?.adoptedStyleSheets.push(this.theme.stylesheet);
2525
}
26+
2627
protected override render() {
2728
return html`<slot></slot>`;
2829
}

packages/theme-wizard-app/src/components/wizard-token-field/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export class WizardTokenField extends WizardTokenNavigator {
120120
}
121121

122122
get entries() {
123-
return WizardTokenField.entries(this.token);
123+
return this.token ? WizardTokenField.entries(this.token) : [];
124124
}
125125

126126
get type() {
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import '.';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import type { WizardTokenPreset } from '.';
4+
5+
const tag = 'wizard-token-preset';
6+
7+
const optionA = {
8+
name: 'Optie A',
9+
description: 'Beschrijving A',
10+
tokens: { color: { primary: { $value: 'blue' } } },
11+
};
12+
13+
const optionB = {
14+
name: 'Optie B',
15+
tokens: { color: { primary: { $value: 'red' } } },
16+
};
17+
18+
const mount = async (overrides: Partial<WizardTokenPreset> = {}): Promise<WizardTokenPreset> => {
19+
document.body.innerHTML = `<${tag}></${tag}>`;
20+
const el = document.querySelector<WizardTokenPreset>(tag)!;
21+
Object.assign(el, overrides);
22+
await el.updateComplete;
23+
return el;
24+
};
25+
26+
const getRadios = (el: WizardTokenPreset): HTMLInputElement[] =>
27+
Array.from(el.shadowRoot?.querySelectorAll('input[type="radio"]') ?? []);
28+
29+
const dispatchThemeUpdate = (tokens: Record<string, unknown>) => {
30+
const theme = {
31+
at: (path: string) =>
32+
path.split('.').reduce((obj: Record<string, unknown>, key) => obj?.[key] as Record<string, unknown>, tokens),
33+
};
34+
document.dispatchEvent(new CustomEvent('theme-update', { detail: { theme } }));
35+
};
36+
37+
describe(`<${tag}>`, () => {
38+
beforeEach(() => {
39+
document.body.innerHTML = '';
40+
});
41+
42+
describe('rendering', () => {
43+
it('renders a fieldset', async () => {
44+
const el = await mount();
45+
expect(el.shadowRoot?.querySelector('fieldset')).toBeTruthy();
46+
});
47+
48+
it('renders a legend with default text', async () => {
49+
const el = await mount();
50+
const legend = el.shadowRoot?.querySelector('legend');
51+
expect(legend?.textContent?.trim()).toBe('Preset opties');
52+
});
53+
54+
it('renders a legend with groupLabel when set', async () => {
55+
const el = await mount({ groupLabel: 'Kies een stijl' });
56+
const legend = el.shadowRoot?.querySelector('legend');
57+
expect(legend?.textContent?.trim()).toBe('Kies een stijl');
58+
});
59+
60+
it('renders no radio buttons when options is empty', async () => {
61+
const el = await mount();
62+
expect(getRadios(el)).toHaveLength(0);
63+
});
64+
65+
it('renders a radio button for each option', async () => {
66+
const el = await mount({ options: [optionA, optionB] });
67+
expect(getRadios(el)).toHaveLength(2);
68+
});
69+
70+
it('renders option names', async () => {
71+
const el = await mount({ options: [optionA, optionB] });
72+
const titles = el.shadowRoot?.querySelectorAll('.wizard-token-preset__option-title');
73+
const names = Array.from(titles ?? []).map((t) => t.textContent?.trim());
74+
expect(names).toContain('Optie A');
75+
expect(names).toContain('Optie B');
76+
});
77+
78+
it('renders option description when present', async () => {
79+
const el = await mount({ options: [optionA] });
80+
const description = el.shadowRoot?.querySelector('.wizard-token-preset__option-description');
81+
expect(description?.textContent?.trim()).toBe('Beschrijving A');
82+
});
83+
84+
it('does not render description element when absent', async () => {
85+
const el = await mount({ options: [optionB] });
86+
const description = el.shadowRoot?.querySelector('.wizard-token-preset__option-description');
87+
expect(description).toBeNull();
88+
});
89+
90+
it('all radio buttons share the same name attribute', async () => {
91+
const el = await mount({ options: [optionA, optionB] });
92+
const radios = getRadios(el);
93+
const names = radios.map((r) => r.getAttribute('name'));
94+
expect(new Set(names).size).toBe(1);
95+
});
96+
});
97+
98+
describe('initial state', () => {
99+
it('selectedIndex is -1 by default', async () => {
100+
const el = await mount({ options: [optionA] });
101+
expect(el.selectedIndex).toBe(-1);
102+
});
103+
104+
it('value returns empty object when nothing is selected', async () => {
105+
const el = await mount({ options: [optionA] });
106+
expect(el.value).toEqual({});
107+
});
108+
109+
it('selectedOption returns null when nothing is selected', async () => {
110+
const el = await mount({ options: [optionA] });
111+
expect(el.selectedOption).toBeNull();
112+
});
113+
114+
it('optionLabel returns empty string when nothing is selected', async () => {
115+
const el = await mount({ options: [optionA] });
116+
expect(el.optionLabel).toBe('');
117+
});
118+
119+
it('previewStyle returns empty string when nothing is selected', async () => {
120+
const el = await mount({ options: [optionA] });
121+
expect(el.previewStyle).toBe('');
122+
});
123+
124+
it('does not have selected attribute by default', async () => {
125+
const el = await mount({ options: [optionA] });
126+
expect(el.hasAttribute('selected')).toBe(false);
127+
});
128+
});
129+
130+
describe('selectIndex()', () => {
131+
it('sets selectedIndex to the given index', async () => {
132+
const el = await mount({ options: [optionA, optionB] });
133+
el.selectIndex(1);
134+
expect(el.selectedIndex).toBe(1);
135+
});
136+
137+
it('updates value to the tokens of the selected option', async () => {
138+
const el = await mount({ options: [optionA, optionB] });
139+
el.selectIndex(0);
140+
expect(el.value).toEqual(optionA.tokens);
141+
});
142+
143+
it('updates optionLabel to the name of the selected option', async () => {
144+
const el = await mount({ options: [optionA, optionB] });
145+
el.selectIndex(0);
146+
expect(el.optionLabel).toBe('Optie A');
147+
});
148+
149+
it('sets the selected attribute', async () => {
150+
const el = await mount({ options: [optionA] });
151+
el.selectIndex(0);
152+
expect(el.hasAttribute('selected')).toBe(true);
153+
});
154+
155+
it('dispatches a change event', async () => {
156+
const el = await mount({ options: [optionA, optionB] });
157+
const handler = vi.fn();
158+
el.addEventListener('change', handler);
159+
el.selectIndex(1);
160+
expect(handler).toHaveBeenCalledOnce();
161+
});
162+
163+
it('ignores negative indices', async () => {
164+
const el = await mount({ options: [optionA] });
165+
el.selectIndex(-1);
166+
expect(el.selectedIndex).toBe(-1);
167+
});
168+
169+
it('ignores out-of-bounds indices', async () => {
170+
const el = await mount({ options: [optionA] });
171+
el.selectIndex(5);
172+
expect(el.selectedIndex).toBe(-1);
173+
});
174+
});
175+
176+
describe('clearSelection()', () => {
177+
it('resets selectedIndex to -1', async () => {
178+
const el = await mount({ options: [optionA] });
179+
el.selectIndex(0);
180+
el.clearSelection();
181+
expect(el.selectedIndex).toBe(-1);
182+
});
183+
184+
it('resets value to empty object', async () => {
185+
const el = await mount({ options: [optionA] });
186+
el.selectIndex(0);
187+
el.clearSelection();
188+
expect(el.value).toEqual({});
189+
});
190+
191+
it('removes the selected attribute', async () => {
192+
const el = await mount({ options: [optionA] });
193+
el.selectIndex(0);
194+
el.clearSelection();
195+
expect(el.hasAttribute('selected')).toBe(false);
196+
});
197+
});
198+
199+
describe('radio button interaction', () => {
200+
it('selecting a radio button updates selectedIndex', async () => {
201+
const el = await mount({ options: [optionA, optionB] });
202+
const radios = getRadios(el);
203+
radios[1].dispatchEvent(new Event('change', { bubbles: true }));
204+
expect(el.selectedIndex).toBe(1);
205+
});
206+
207+
it('selecting a radio button dispatches a change event', async () => {
208+
const el = await mount({ options: [optionA, optionB] });
209+
const handler = vi.fn();
210+
el.addEventListener('change', handler);
211+
const radios = getRadios(el);
212+
radios[0].dispatchEvent(new Event('change', { bubbles: true }));
213+
expect(handler).toHaveBeenCalledOnce();
214+
});
215+
});
216+
217+
describe('token details rendering', () => {
218+
it('does not render token details when no option is selected', async () => {
219+
const el = await mount({ options: [optionA] });
220+
expect(el.shadowRoot?.querySelector('.wizard-token-preset__option-values')).toBeNull();
221+
});
222+
223+
it('renders token details for the selected option with a single token', async () => {
224+
const el = await mount({ options: [optionA] });
225+
el.selectIndex(0);
226+
await el.updateComplete;
227+
expect(el.shadowRoot?.querySelector('.wizard-token-preset__option-values')).toBeTruthy();
228+
});
229+
230+
it('renders a details element for options with multiple tokens', async () => {
231+
const multiTokenOption = {
232+
name: 'Multi',
233+
tokens: {
234+
color: { primary: { $value: 'blue' }, secondary: { $value: 'green' } },
235+
},
236+
};
237+
const el = await mount({ options: [multiTokenOption] });
238+
el.selectIndex(0);
239+
await el.updateComplete;
240+
expect(el.shadowRoot?.querySelector('details')).toBeTruthy();
241+
});
242+
});
243+
244+
describe('theme-update event', () => {
245+
it('selects the matching preset when theme-update fires', async () => {
246+
const el = await mount({ options: [optionA, optionB] });
247+
dispatchThemeUpdate({ color: { primary: { $value: 'red' } } });
248+
expect(el.selectedIndex).toBe(1);
249+
});
250+
251+
it('clears selection when no preset matches the theme', async () => {
252+
const el = await mount({ options: [optionA, optionB] });
253+
el.selectIndex(0);
254+
dispatchThemeUpdate({ color: { primary: { $value: 'green' } } });
255+
expect(el.selectedIndex).toBe(-1);
256+
});
257+
258+
it('does not listen for theme-update after disconnection', async () => {
259+
const el = await mount({ options: [optionA] });
260+
el.remove();
261+
dispatchThemeUpdate({ color: { primary: { $value: 'blue' } } });
262+
expect(el.selectedIndex).toBe(-1);
263+
});
264+
});
265+
266+
describe('previewStyle', () => {
267+
it('returns the previewStyle of the selected option', async () => {
268+
const optionWithStyle = { name: 'Stijl', previewStyle: 'background: red', tokens: {} };
269+
const el = await mount({ options: [optionWithStyle] });
270+
el.selectIndex(0);
271+
expect(el.previewStyle).toBe('background: red');
272+
});
273+
274+
it('returns empty string when selected option has no previewStyle', async () => {
275+
const el = await mount({ options: [optionA] });
276+
el.selectIndex(0);
277+
expect(el.previewStyle).toBe('');
278+
});
279+
});
280+
});

0 commit comments

Comments
 (0)