@adia-ai/web-modules 0.4.1 → 0.4.3

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.
@@ -0,0 +1,274 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import '../../../web-components/core/element.js';
3
+ // Stub primitives that theme-panel composes — they don't need to render
4
+ // anything visible for these behavior tests; we just need them to register
5
+ // so customElements.define() inside theme-panel sees its DOM children as
6
+ // known elements (otherwise happy-dom treats them as HTMLUnknownElement,
7
+ // which is fine for our assertions).
8
+ import './theme-panel.js';
9
+
10
+ const tick = () => new Promise((r) => queueMicrotask(r));
11
+
12
+ function mount(html) {
13
+ const wrap = document.createElement('div');
14
+ wrap.innerHTML = html;
15
+ document.body.appendChild(wrap);
16
+ return wrap.firstElementChild;
17
+ }
18
+
19
+ // happy-dom doesn't implement matchMedia; stub a non-dark default.
20
+ let mqlListeners = [];
21
+ function installMatchMedia({ dark = false } = {}) {
22
+ globalThis.matchMedia = (query) => ({
23
+ matches: query.includes('dark') ? dark : false,
24
+ media: query,
25
+ addEventListener: (_evt, fn) => { mqlListeners.push(fn); },
26
+ removeEventListener: (_evt, fn) => {
27
+ mqlListeners = mqlListeners.filter((f) => f !== fn);
28
+ },
29
+ addListener: (fn) => { mqlListeners.push(fn); },
30
+ removeListener: (fn) => {
31
+ mqlListeners = mqlListeners.filter((f) => f !== fn);
32
+ },
33
+ });
34
+ }
35
+
36
+ // happy-dom's requestAnimationFrame is sync via setTimeout; force it
37
+ // synchronous so #onThemeClick's slider sync runs in the test's tick().
38
+ beforeEach(() => {
39
+ document.body.innerHTML = '';
40
+ try { localStorage.clear(); } catch {}
41
+ mqlListeners = [];
42
+ installMatchMedia({ dark: false });
43
+ // Synchronous rAF for deterministic tests
44
+ globalThis.requestAnimationFrame = (cb) => { cb(); return 0; };
45
+ });
46
+
47
+ afterEach(() => {
48
+ // Always clean any html-level inline state so tests don't leak
49
+ document.documentElement.removeAttribute('data-theme');
50
+ document.documentElement.style.removeProperty('--a-density');
51
+ document.documentElement.style.removeProperty('--a-radius-k');
52
+ document.documentElement.style.removeProperty('color-scheme');
53
+ });
54
+
55
+ describe('theme-panel', () => {
56
+ it('registers theme-panel as a custom element', () => {
57
+ expect(customElements.get('theme-panel')).toBeDefined();
58
+ });
59
+
60
+ it('stamps internal DOM on connect (themes row)', async () => {
61
+ const tp = mount('<theme-panel></theme-panel>');
62
+ await tick();
63
+ const themes = tp.querySelector('[part="themes"]');
64
+ expect(themes).toBeTruthy();
65
+ // 8 default themes
66
+ expect(themes.querySelectorAll('button-ui').length).toBe(8);
67
+ });
68
+
69
+ it('renders only minimum surface by default (no sliders, presets, scheme)', async () => {
70
+ const tp = mount('<theme-panel></theme-panel>');
71
+ await tick();
72
+ expect(tp.querySelector('[part="presets"]')).toBeNull();
73
+ expect(tp.querySelector('[part="scheme"]')).toBeNull();
74
+ expect(tp.querySelector('slider-ui')).toBeNull();
75
+ });
76
+
77
+ it('[parametric] adds density + radius sliders', async () => {
78
+ const tp = mount('<theme-panel parametric></theme-panel>');
79
+ await tick();
80
+ expect(tp.querySelector('slider-ui[part="density"]')).toBeTruthy();
81
+ expect(tp.querySelector('slider-ui[part="radius"]')).toBeTruthy();
82
+ });
83
+
84
+ it('[presets] adds the preset row (compact/default/spacious)', async () => {
85
+ const tp = mount('<theme-panel presets></theme-panel>');
86
+ await tick();
87
+ const presets = tp.querySelector('[part="presets"]');
88
+ expect(presets).toBeTruthy();
89
+ const buttons = [...presets.querySelectorAll('button-ui')];
90
+ expect(buttons.map((b) => b.getAttribute('data-preset'))).toEqual(['compact', 'default', 'spacious']);
91
+ });
92
+
93
+ it('[scheme-toggle] adds the segmented scheme picker', async () => {
94
+ const tp = mount('<theme-panel scheme-toggle></theme-panel>');
95
+ await tick();
96
+ expect(tp.querySelector('[part="scheme"] segmented-ui')).toBeTruthy();
97
+ });
98
+
99
+ it('theme button click sets [data-theme] on the target (defaults to <html>)', async () => {
100
+ const tp = mount('<theme-panel></theme-panel>');
101
+ await tick();
102
+ const oceanBtn = tp.querySelector('button-ui[data-theme-slug="ocean"]');
103
+ oceanBtn.dispatchEvent(new Event('click', { bubbles: true }));
104
+ await tick();
105
+ expect(document.documentElement.getAttribute('data-theme')).toBe('ocean');
106
+ expect(tp.getAttribute('active-theme')).toBe('ocean');
107
+ });
108
+
109
+ it('theme button click for "default" clears [data-theme]', async () => {
110
+ document.documentElement.setAttribute('data-theme', 'ocean');
111
+ const tp = mount('<theme-panel></theme-panel>');
112
+ await tick();
113
+ const defaultBtn = tp.querySelector('button-ui[data-theme-slug="default"]');
114
+ defaultBtn.dispatchEvent(new Event('click', { bubbles: true }));
115
+ await tick();
116
+ expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
117
+ expect(tp.getAttribute('active-theme')).toBe('');
118
+ });
119
+
120
+ it('fires theme-change event with source: "theme" on theme click', async () => {
121
+ const tp = mount('<theme-panel></theme-panel>');
122
+ await tick();
123
+ const onChange = vi.fn();
124
+ tp.addEventListener('theme-change', onChange);
125
+ tp.querySelector('button-ui[data-theme-slug="forest"]').dispatchEvent(new Event('click', { bubbles: true }));
126
+ await tick();
127
+ expect(onChange).toHaveBeenCalled();
128
+ const detail = onChange.mock.calls[0][0].detail;
129
+ expect(detail.theme).toBe('forest');
130
+ expect(detail.source).toBe('theme');
131
+ });
132
+
133
+ it('sliders write --a-density / --a-radius-k on target', async () => {
134
+ const tp = mount('<theme-panel parametric></theme-panel>');
135
+ await tick();
136
+ const density = tp.querySelector('slider-ui[part="density"]');
137
+ density.value = '1.2';
138
+ density.dispatchEvent(new Event('input', { bubbles: true }));
139
+ await tick();
140
+ expect(document.documentElement.style.getPropertyValue('--a-density')).toBe('1.2');
141
+
142
+ const radius = tp.querySelector('slider-ui[part="radius"]');
143
+ radius.value = '0.6';
144
+ radius.dispatchEvent(new Event('input', { bubbles: true }));
145
+ await tick();
146
+ expect(document.documentElement.style.getPropertyValue('--a-radius-k')).toBe('0.6');
147
+ });
148
+
149
+ it('preset "compact" applies (0.85, 0.75)', async () => {
150
+ const tp = mount('<theme-panel parametric presets></theme-panel>');
151
+ await tick();
152
+ const compact = tp.querySelector('button-ui[data-preset="compact"]');
153
+ compact.dispatchEvent(new Event('click', { bubbles: true }));
154
+ await tick();
155
+ expect(document.documentElement.style.getPropertyValue('--a-density')).toBe('0.85');
156
+ expect(document.documentElement.style.getPropertyValue('--a-radius-k')).toBe('0.75');
157
+ });
158
+
159
+ it('preset "spacious" applies (1.15, 1.25)', async () => {
160
+ const tp = mount('<theme-panel parametric presets></theme-panel>');
161
+ await tick();
162
+ const spacious = tp.querySelector('button-ui[data-preset="spacious"]');
163
+ spacious.dispatchEvent(new Event('click', { bubbles: true }));
164
+ await tick();
165
+ expect(document.documentElement.style.getPropertyValue('--a-density')).toBe('1.15');
166
+ expect(document.documentElement.style.getPropertyValue('--a-radius-k')).toBe('1.25');
167
+ });
168
+
169
+ it('preset "default" (Reset) clears [data-theme]', async () => {
170
+ document.documentElement.setAttribute('data-theme', 'ocean');
171
+ const tp = mount('<theme-panel parametric presets></theme-panel>');
172
+ await tick();
173
+ const reset = tp.querySelector('button-ui[data-preset="default"]');
174
+ reset.dispatchEvent(new Event('click', { bubbles: true }));
175
+ await tick();
176
+ expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
177
+ });
178
+
179
+ it('[persist] writes localStorage keys on theme click', async () => {
180
+ const tp = mount('<theme-panel persist></theme-panel>');
181
+ await tick();
182
+ tp.querySelector('button-ui[data-theme-slug="ocean"]').dispatchEvent(new Event('click', { bubbles: true }));
183
+ await tick();
184
+ expect(localStorage.getItem('adia-theme-theme')).toBe('ocean');
185
+ });
186
+
187
+ it('absence of [persist] does NOT write localStorage', async () => {
188
+ const tp = mount('<theme-panel></theme-panel>');
189
+ await tick();
190
+ tp.querySelector('button-ui[data-theme-slug="ocean"]').dispatchEvent(new Event('click', { bubbles: true }));
191
+ await tick();
192
+ expect(localStorage.getItem('adia-theme-theme')).toBeNull();
193
+ });
194
+
195
+ it('reset() clears all state and emits source: "reset"', async () => {
196
+ document.documentElement.setAttribute('data-theme', 'ocean');
197
+ document.documentElement.style.setProperty('--a-density', '1.2');
198
+ const tp = mount('<theme-panel parametric persist></theme-panel>');
199
+ await tick();
200
+ const onChange = vi.fn();
201
+ tp.addEventListener('theme-change', onChange);
202
+ tp.reset();
203
+ await tick();
204
+ expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
205
+ expect(document.documentElement.style.getPropertyValue('--a-density')).toBe('');
206
+ expect(onChange).toHaveBeenCalled();
207
+ expect(onChange.mock.calls[0][0].detail.source).toBe('reset');
208
+ });
209
+
210
+ it('[storage-prefix] override changes LS key namespace', async () => {
211
+ const tp = mount('<theme-panel persist storage-prefix="my-app-"></theme-panel>');
212
+ await tick();
213
+ tp.querySelector('button-ui[data-theme-slug="ocean"]').dispatchEvent(new Event('click', { bubbles: true }));
214
+ await tick();
215
+ expect(localStorage.getItem('my-app-theme')).toBe('ocean');
216
+ expect(localStorage.getItem('adia-theme-theme')).toBeNull();
217
+ });
218
+
219
+ it('apply({theme}) is equivalent to a theme click and emits source: "programmatic"', async () => {
220
+ const tp = mount('<theme-panel></theme-panel>');
221
+ await tick();
222
+ const onChange = vi.fn();
223
+ tp.addEventListener('theme-change', onChange);
224
+ tp.apply({ theme: 'slate' });
225
+ await tick();
226
+ expect(document.documentElement.getAttribute('data-theme')).toBe('slate');
227
+ expect(onChange.mock.calls[0][0].detail.source).toBe('programmatic');
228
+ });
229
+
230
+ it('PCM listener attaches when scheme="auto" and no persisted scheme', async () => {
231
+ const tp = mount('<theme-panel scheme-toggle></theme-panel>');
232
+ await tick();
233
+ // mqlListeners is populated by our matchMedia stub on addEventListener('change', ...)
234
+ expect(mqlListeners.length).toBeGreaterThan(0);
235
+ expect(tp.getAttribute('active-scheme')).toBe('light'); // our stub returns dark=false
236
+ });
237
+
238
+ it('PCM listener does NOT attach when [persist] has a saved scheme', async () => {
239
+ try { localStorage.setItem('adia-theme-scheme', 'dark'); } catch {}
240
+ const tp = mount('<theme-panel persist scheme-toggle></theme-panel>');
241
+ await tick();
242
+ expect(mqlListeners.length).toBe(0);
243
+ expect(tp.getAttribute('active-scheme')).toBe('dark');
244
+ });
245
+
246
+ it('[target] scoped selector writes to that element, not <html>', async () => {
247
+ const preview = document.createElement('div');
248
+ preview.id = 'preview';
249
+ document.body.appendChild(preview);
250
+ const tp = mount('<theme-panel target="#preview"></theme-panel>');
251
+ await tick();
252
+ tp.querySelector('button-ui[data-theme-slug="ocean"]').dispatchEvent(new Event('click', { bubbles: true }));
253
+ await tick();
254
+ expect(preview.getAttribute('data-theme')).toBe('ocean');
255
+ expect(document.documentElement.getAttribute('data-theme')).toBeNull();
256
+ });
257
+
258
+ it('cleans up listeners on disconnect (no zombie listeners)', async () => {
259
+ const tp = mount('<theme-panel scheme-toggle></theme-panel>');
260
+ await tick();
261
+ expect(mqlListeners.length).toBeGreaterThan(0);
262
+ tp.remove();
263
+ await tick();
264
+ expect(mqlListeners.length).toBe(0);
265
+ });
266
+
267
+ it('restricted [themes] list renders only the listed slugs', async () => {
268
+ const tp = mount('<theme-panel themes="default ocean"></theme-panel>');
269
+ await tick();
270
+ const buttons = [...tp.querySelectorAll('[part="themes"] button-ui')];
271
+ expect(buttons.length).toBe(2);
272
+ expect(buttons.map((b) => b.getAttribute('data-theme-slug'))).toEqual(['default', 'ocean']);
273
+ });
274
+ });
@@ -0,0 +1,213 @@
1
+ # Edit this file; run `npm run build:components` to regenerate a2ui.json.
2
+ $schema: ../../../../scripts/schemas/component.yaml.schema.json
3
+ name: ThemePanel
4
+ tag: theme-panel
5
+ component: ThemePanel
6
+ category: layout
7
+ version: 1
8
+ description: |
9
+ Module-tier appearance-preferences control surface. Owns the three knobs
10
+ of the AdiaUI theming contract: [data-theme=<slug>] named themes,
11
+ --a-density / --a-radius-k parametric overrides, and color-scheme
12
+ light/dark switching, plus optional localStorage persistence behind a
13
+ small attribute API.
14
+
15
+ Drops into any consumer's <popover-ui slot="content"> (the canonical
16
+ composition) or directly into a sidebar section. Eight named themes
17
+ ship by default; section visibility flips on/off via boolean attributes
18
+ ([parametric], [presets], [scheme-toggle]).
19
+
20
+ Promoted from the duplicated <div id="theme-panel"> block in site/ and
21
+ playgrounds/admin-shell/ — see docs/specs/theme-panel-module.md.
22
+
23
+ props:
24
+ themes:
25
+ description: |
26
+ Space-separated list of theme slugs to render as buttons. Author
27
+ may restrict ([themes="default ocean"]) or extend. Tolerant —
28
+ unknown slugs render a button; clicking applies [data-theme=slug]
29
+ regardless of whether themes.css has a matching block.
30
+ type: string
31
+ default: "default ocean forest sunset lavender rose slate midnight"
32
+ reflect: true
33
+
34
+ parametric:
35
+ description: |
36
+ Renders the density + radius slider block. Sliders bind to
37
+ --a-density and --a-radius-k on the target element.
38
+ type: boolean
39
+ default: false
40
+ reflect: true
41
+
42
+ presets:
43
+ description: |
44
+ Renders the compact / reset / spacious preset row. Each preset
45
+ applies a (density, radius) pair; "default" also clears the
46
+ [data-theme] attribute.
47
+ type: boolean
48
+ default: false
49
+ reflect: true
50
+
51
+ scheme-toggle:
52
+ description: |
53
+ Renders an integrated light/dark <segmented-ui> at the top of the
54
+ panel. When absent, the panel omits scheme entirely — consumers
55
+ can still drive scheme via the .apply({scheme}) public method.
56
+ type: boolean
57
+ default: false
58
+ reflect: true
59
+ attribute: scheme-toggle
60
+
61
+ persist:
62
+ description: |
63
+ Persists selections to localStorage. Defaults to ephemeral so
64
+ playgrounds and embedded demos do not silently mutate a user's
65
+ docs-shell preferences.
66
+ type: boolean
67
+ default: false
68
+ reflect: true
69
+
70
+ storage-prefix:
71
+ description: |
72
+ Namespace for localStorage keys when [persist] is set. Four keys
73
+ are written: {prefix}theme, {prefix}scheme, {prefix}density,
74
+ {prefix}radius.
75
+ type: string
76
+ default: "adia-theme-"
77
+ reflect: true
78
+ attribute: storage-prefix
79
+
80
+ target:
81
+ description: |
82
+ CSS selector for the element that receives [data-theme] and
83
+ --a-density / --a-radius-k writes. Defaults to :root (the
84
+ <html> element). Scoped targets are useful for preview-pane
85
+ demos.
86
+ type: string
87
+ default: ":root"
88
+ reflect: true
89
+
90
+ scheme:
91
+ description: |
92
+ Initial color-scheme. "auto" follows prefers-color-scheme via a
93
+ matchMedia listener; user clicks on the scheme toggle promote to
94
+ an explicit light or dark choice.
95
+ type: string
96
+ default: "auto"
97
+ enum: ["light", "dark", "auto"]
98
+ reflect: true
99
+
100
+ active-theme:
101
+ description: |
102
+ Reflected — the currently selected theme slug. Empty string when
103
+ unset (default theme). Read-only externally; use .apply({theme})
104
+ to change.
105
+ type: string
106
+ default: ""
107
+ reflect: true
108
+ attribute: active-theme
109
+
110
+ active-scheme:
111
+ description: |
112
+ Reflected — the resolved color-scheme (auto collapsed to a
113
+ concrete value). Either "light" or "dark".
114
+ type: string
115
+ default: ""
116
+ reflect: true
117
+ attribute: active-scheme
118
+
119
+ active-density:
120
+ description: |
121
+ Reflected — mirrors the current --a-density value on the target
122
+ element. Stringified number.
123
+ type: string
124
+ default: ""
125
+ reflect: true
126
+ attribute: active-density
127
+
128
+ active-radius:
129
+ description: |
130
+ Reflected — mirrors the current --a-radius-k value on the target
131
+ element. Stringified number.
132
+ type: string
133
+ default: ""
134
+ reflect: true
135
+ attribute: active-radius
136
+
137
+ events:
138
+ theme-change:
139
+ description: |
140
+ Bubbles when any user-visible state changes (theme click, slider
141
+ input, preset click, scheme flip, reset). One event per change.
142
+ No event on auto-rehydrate-from-storage at boot.
143
+ detail:
144
+ theme: string
145
+ scheme: string
146
+ density: number
147
+ radius: number
148
+ source: string
149
+
150
+ slots: {}
151
+
152
+ states:
153
+ - name: idle
154
+ description: Default — panel rendered, awaiting input.
155
+ - name: persisted
156
+ description: |
157
+ [persist] is set; selections write to localStorage under
158
+ [storage-prefix].
159
+ attribute: persist
160
+ - name: scheme-auto
161
+ description: |
162
+ [scheme="auto"] and no user override this session; a
163
+ prefers-color-scheme listener reapplies on system flip.
164
+
165
+ traits: []
166
+
167
+ a2ui:
168
+ rules:
169
+ - >-
170
+ theme-panel is the canonical appearance-preferences popover.
171
+ Compose inside a <popover-ui slot="content"> in the topbar of a
172
+ shell (admin, chat, editor, simple) when the page exposes user
173
+ theming. Avoid hardcoding it as a shell child — placement is the
174
+ consumer's call.
175
+ - >-
176
+ Use [persist] for the docs surface or a real product app; omit
177
+ [persist] for playgrounds and embedded demos so state stays
178
+ ephemeral. The default is ephemeral.
179
+ - >-
180
+ Add boolean attributes [parametric], [presets], [scheme-toggle]
181
+ to enable sections; the minimum panel is the theme button grid.
182
+
183
+ keywords:
184
+ - theme
185
+ - theme-panel
186
+ - palette
187
+ - density
188
+ - radius
189
+ - scheme
190
+ - dark-mode
191
+ - light-mode
192
+ - color-scheme
193
+ - preferences
194
+ - settings
195
+ - color
196
+ - appearance
197
+ - skin
198
+
199
+ synonyms:
200
+ theme: [palette, skin, appearance]
201
+ scheme: [mode, color-scheme, dark-mode]
202
+ density: [compactness, spacing-scale]
203
+ radius: [corner-roundness, border-radius]
204
+
205
+ related:
206
+ - Popover
207
+ - Button
208
+ - Slider
209
+ - Field
210
+ - Divider
211
+ - Text
212
+ - Segmented
213
+ - AdminShell