morethanui 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.
@@ -0,0 +1,321 @@
1
+ /* MTUI <x-datepicker> — tier: Enhanced over Basic native <input type="date">.
2
+ Single date only — no ranges, no times (kept out of v1 scope).
3
+ Basic (no JS): the native date input renders and works normally.
4
+ <x-datepicker>
5
+ <label class="field">
6
+ <span class="t-label">Start date</span>
7
+ <input type="date" min="2026-01-01" max="2026-12-31" value="2026-07-07">
8
+ </label>
9
+ </x-datepicker>
10
+ Dispatches a bubbling `change` CustomEvent (detail: { value }) on the
11
+ underlying input, value in the native YYYY-MM-DD format.
12
+ Tap the header to zoom out: day grid -> month grid -> scrollable year
13
+ list -> back to day grid. Prev/next step by month/year/scroll-page
14
+ depending on the current level — faster than repeated month clicks
15
+ when jumping further than a month or two. */
16
+ (function () {
17
+ 'use strict';
18
+ if (customElements.get('x-datepicker')) return;
19
+
20
+ let seq = 0;
21
+ const iso = (d) => {
22
+ const y = d.getFullYear();
23
+ const m = String(d.getMonth() + 1).padStart(2, '0');
24
+ const day = String(d.getDate()).padStart(2, '0');
25
+ return `${y}-${m}-${day}`;
26
+ };
27
+ const parseIso = (s) => {
28
+ if (!s) return null;
29
+ const [y, m, d] = s.split('-').map(Number);
30
+ return new Date(y, m - 1, d);
31
+ };
32
+ const sameDay = (a, b) => a && b
33
+ && a.getFullYear() === b.getFullYear()
34
+ && a.getMonth() === b.getMonth()
35
+ && a.getDate() === b.getDate();
36
+
37
+ class XDatepicker extends HTMLElement {
38
+ connectedCallback() {
39
+ if (this._built) return;
40
+ const input = this.querySelector('input[type="date"]');
41
+ if (!input) return;
42
+ this._built = true;
43
+ this._build(input);
44
+ }
45
+
46
+ _build(input) {
47
+ const panelId = `x-datepicker-panel-${++seq}`;
48
+ const labelText = this.querySelector('.t-label')?.textContent.trim();
49
+ const lang = document.documentElement.lang || undefined;
50
+ const monthFmt = new Intl.DateTimeFormat(lang, { month: 'long', year: 'numeric' });
51
+ const monthShortFmt = new Intl.DateTimeFormat(lang, { month: 'short' });
52
+ const weekdayFmt = new Intl.DateTimeFormat(lang, { weekday: 'short' });
53
+ const min = parseIso(input.min);
54
+ const max = parseIso(input.max);
55
+
56
+ input.setAttribute('tabindex', '-1');
57
+ input.setAttribute('aria-hidden', 'true');
58
+ input.style.position = 'absolute';
59
+ input.style.opacity = '0';
60
+ input.style.pointerEvents = 'none';
61
+ input.style.inlineSize = '1px';
62
+ input.style.blockSize = '1px';
63
+
64
+ const trigger = document.createElement('button');
65
+ trigger.type = 'button';
66
+ trigger.className = 'select-trigger';
67
+ trigger.setAttribute('aria-haspopup', 'dialog');
68
+ trigger.setAttribute('aria-expanded', 'false');
69
+ trigger.setAttribute('popovertarget', panelId);
70
+ if (labelText) trigger.setAttribute('aria-label', labelText);
71
+ const triggerLabel = document.createElement('span');
72
+ trigger.append(triggerLabel);
73
+
74
+ const panel = document.createElement('div');
75
+ panel.className = 'datepicker-panel menu';
76
+ panel.id = panelId;
77
+ panel.setAttribute('popover', '');
78
+ if (labelText) panel.setAttribute('aria-label', labelText);
79
+
80
+ const header = document.createElement('div');
81
+ header.className = 'datepicker-header';
82
+ const prev = document.createElement('button');
83
+ prev.type = 'button';
84
+ prev.className = 'datepicker-nav';
85
+ prev.dataset.dir = 'prev';
86
+ const headerLabel = document.createElement('button');
87
+ headerLabel.type = 'button';
88
+ headerLabel.className = 'datepicker-header-label';
89
+ const next = document.createElement('button');
90
+ next.type = 'button';
91
+ next.className = 'datepicker-nav';
92
+ next.dataset.dir = 'next';
93
+ header.append(prev, headerLabel, next);
94
+
95
+ const weekdays = document.createElement('div');
96
+ weekdays.className = 'datepicker-weekdays';
97
+ const weekStart = new Date(2024, 0, 7); // a Sunday
98
+ for (let i = 0; i < 7; i++) {
99
+ const d = document.createElement('span');
100
+ const day = new Date(weekStart);
101
+ day.setDate(day.getDate() + i);
102
+ d.textContent = weekdayFmt.format(day);
103
+ weekdays.append(d);
104
+ }
105
+
106
+ const grid = document.createElement('div');
107
+ grid.className = 'datepicker-grid';
108
+ grid.setAttribute('role', 'grid');
109
+
110
+ const footer = document.createElement('div');
111
+ footer.className = 'datepicker-footer';
112
+ const todayBtn = document.createElement('button');
113
+ todayBtn.type = 'button';
114
+ todayBtn.className = 'btn';
115
+ todayBtn.setAttribute('data-variant', 'ghost');
116
+ todayBtn.textContent = 'Today';
117
+ footer.append(todayBtn);
118
+
119
+ panel.append(header, weekdays, grid, footer);
120
+ input.before(trigger);
121
+ document.body.append(panel);
122
+
123
+ let selected = parseIso(input.value);
124
+ let viewYear = (selected || new Date()).getFullYear();
125
+ let viewMonth = (selected || new Date()).getMonth();
126
+ let viewMode = 'days'; // 'days' | 'months'
127
+
128
+ const syncTrigger = () => {
129
+ triggerLabel.textContent = selected
130
+ ? new Intl.DateTimeFormat(lang, { dateStyle: 'medium' }).format(selected)
131
+ : 'Select date';
132
+ };
133
+
134
+ function commit(date) {
135
+ selected = date;
136
+ input.value = iso(date);
137
+ syncTrigger();
138
+ panel.hidePopover();
139
+ trigger.focus();
140
+ input.dispatchEvent(new CustomEvent('change', {
141
+ bubbles: true,
142
+ detail: { value: input.value },
143
+ }));
144
+ }
145
+
146
+ function render() {
147
+ grid.innerHTML = '';
148
+ if (viewMode === 'months') {
149
+ renderMonths();
150
+ } else if (viewMode === 'years') {
151
+ renderYears();
152
+ } else {
153
+ renderDays();
154
+ }
155
+ }
156
+
157
+ function renderDays() {
158
+ weekdays.hidden = false;
159
+ grid.setAttribute('data-view', 'days');
160
+ headerLabel.textContent = monthFmt.format(new Date(viewYear, viewMonth, 1));
161
+ prev.setAttribute('aria-label', 'Previous month');
162
+ next.setAttribute('aria-label', 'Next month');
163
+
164
+ const firstOfMonth = new Date(viewYear, viewMonth, 1);
165
+ const startOffset = firstOfMonth.getDay(); // Sunday-start grid (v1)
166
+ const gridStart = new Date(viewYear, viewMonth, 1 - startOffset);
167
+ const today = new Date();
168
+
169
+ for (let i = 0; i < 42; i++) {
170
+ const day = new Date(gridStart);
171
+ day.setDate(day.getDate() + i);
172
+ const btn = document.createElement('button');
173
+ btn.type = 'button';
174
+ btn.className = 'datepicker-day';
175
+ btn.setAttribute('role', 'gridcell');
176
+ btn.textContent = String(day.getDate());
177
+ btn.dataset.iso = iso(day);
178
+ if (day.getMonth() !== viewMonth) btn.dataset.outside = '';
179
+ if (sameDay(day, today)) btn.setAttribute('data-today', '');
180
+ if (sameDay(day, selected)) btn.setAttribute('aria-selected', 'true');
181
+ if ((min && day < min) || (max && day > max)) btn.disabled = true;
182
+ btn.tabIndex = sameDay(day, selected) || (!selected && sameDay(day, today)) ? 0 : -1;
183
+ btn.addEventListener('click', () => commit(new Date(day)));
184
+ grid.append(btn);
185
+ }
186
+ }
187
+
188
+ function renderMonths() {
189
+ weekdays.hidden = true;
190
+ grid.setAttribute('data-view', 'months');
191
+ headerLabel.textContent = String(viewYear);
192
+ prev.setAttribute('aria-label', 'Previous year');
193
+ next.setAttribute('aria-label', 'Next year');
194
+
195
+ for (let m = 0; m < 12; m++) {
196
+ const btn = document.createElement('button');
197
+ btn.type = 'button';
198
+ btn.className = 'datepicker-day datepicker-month';
199
+ btn.setAttribute('role', 'gridcell');
200
+ btn.textContent = monthShortFmt.format(new Date(viewYear, m, 1));
201
+ if (m === viewMonth) btn.setAttribute('aria-selected', 'true');
202
+ btn.tabIndex = m === viewMonth ? 0 : -1;
203
+ btn.addEventListener('click', () => {
204
+ viewMonth = m;
205
+ viewMode = 'days';
206
+ render();
207
+ grid.querySelector('[tabindex="0"]')?.focus();
208
+ });
209
+ grid.append(btn);
210
+ }
211
+ }
212
+
213
+ function renderYears() {
214
+ weekdays.hidden = true;
215
+ grid.setAttribute('data-view', 'years');
216
+ headerLabel.textContent = 'Select year';
217
+ prev.setAttribute('aria-label', 'Scroll up');
218
+ next.setAttribute('aria-label', 'Scroll down');
219
+
220
+ const rangeStart = min ? min.getFullYear() : viewYear - 100;
221
+ const rangeEnd = max ? max.getFullYear() : viewYear + 100;
222
+ for (let y = rangeStart; y <= rangeEnd; y++) {
223
+ const btn = document.createElement('button');
224
+ btn.type = 'button';
225
+ btn.className = 'datepicker-day datepicker-year';
226
+ btn.setAttribute('role', 'gridcell');
227
+ btn.textContent = String(y);
228
+ if (y === viewYear) btn.setAttribute('aria-selected', 'true');
229
+ btn.tabIndex = y === viewYear ? 0 : -1;
230
+ btn.addEventListener('click', () => {
231
+ viewYear = y;
232
+ viewMode = 'months';
233
+ render();
234
+ grid.querySelector('[tabindex="0"]')?.focus();
235
+ });
236
+ grid.append(btn);
237
+ }
238
+ // bring the current year into view without an initial janky jump —
239
+ // reduced motion collapses --t-lift to 0ms, so this stays instant.
240
+ const smooth = (parseFloat(getComputedStyle(document.documentElement)
241
+ .getPropertyValue('--t-lift')) || 0) > 0;
242
+ requestAnimationFrame(() => {
243
+ grid.querySelector('[aria-selected="true"]')
244
+ ?.scrollIntoView({ block: 'center', behavior: smooth ? 'smooth' : 'auto' });
245
+ });
246
+ }
247
+
248
+ headerLabel.addEventListener('click', () => {
249
+ viewMode = viewMode === 'days' ? 'months' : viewMode === 'months' ? 'years' : 'days';
250
+ render();
251
+ grid.querySelector('[tabindex="0"]')?.focus();
252
+ });
253
+
254
+ prev.addEventListener('click', () => {
255
+ if (viewMode === 'years') {
256
+ grid.scrollBy({ top: -grid.clientHeight * 0.9 });
257
+ } else if (viewMode === 'months') {
258
+ viewYear -= 1;
259
+ render();
260
+ } else {
261
+ viewMonth -= 1;
262
+ if (viewMonth < 0) { viewMonth = 11; viewYear -= 1; }
263
+ render();
264
+ }
265
+ });
266
+ next.addEventListener('click', () => {
267
+ if (viewMode === 'years') {
268
+ grid.scrollBy({ top: grid.clientHeight * 0.9 });
269
+ } else if (viewMode === 'months') {
270
+ viewYear += 1;
271
+ render();
272
+ } else {
273
+ viewMonth += 1;
274
+ if (viewMonth > 11) { viewMonth = 0; viewYear += 1; }
275
+ render();
276
+ }
277
+ });
278
+ todayBtn.addEventListener('click', () => {
279
+ const t = new Date();
280
+ viewYear = t.getFullYear();
281
+ viewMonth = t.getMonth();
282
+ viewMode = 'days';
283
+ render();
284
+ commit(t);
285
+ });
286
+
287
+ grid.addEventListener('keydown', (e) => {
288
+ const current = document.activeElement.closest('.datepicker-day');
289
+ if (!current) return;
290
+ const cells = [...grid.children];
291
+ const idx = cells.indexOf(current);
292
+ const rowSpan = viewMode === 'months' ? 4 : viewMode === 'years' ? 1 : 7;
293
+ const moves = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: rowSpan, ArrowUp: -rowSpan };
294
+ if (e.key in moves) {
295
+ e.preventDefault();
296
+ const target = cells[idx + moves[e.key]];
297
+ if (target) { target.tabIndex = 0; current.tabIndex = -1; target.focus(); }
298
+ } else if (e.key === 'Enter' || e.key === ' ') {
299
+ e.preventDefault();
300
+ if (!current.disabled) current.click();
301
+ }
302
+ });
303
+
304
+ panel.addEventListener('toggle', (e) => {
305
+ const open = e.newState === 'open';
306
+ trigger.setAttribute('aria-expanded', String(open));
307
+ if (open) {
308
+ viewYear = (selected || new Date()).getFullYear();
309
+ viewMonth = (selected || new Date()).getMonth();
310
+ viewMode = 'days';
311
+ render();
312
+ grid.querySelector('[tabindex="0"]')?.focus();
313
+ }
314
+ });
315
+
316
+ syncTrigger();
317
+ }
318
+ }
319
+
320
+ customElements.define('x-datepicker', XDatepicker);
321
+ })();
package/js/x-select.js ADDED
@@ -0,0 +1,139 @@
1
+ /* MTUI <x-select> — tier: Enhanced over Basic .select (native <select>).
2
+ Wraps existing Basic field-select markup, hides the native select from
3
+ sight (kept as the value holder + no-JS fallback + form participant),
4
+ and draws an MTUI trigger + popover-ring listbox panel in its place.
5
+ Basic (no JS): the native select renders and works normally.
6
+ <x-select>
7
+ <label class="field">
8
+ <span class="t-label">Role</span>
9
+ <span class="select"><select>
10
+ <option value="designer">Designer</option>
11
+ <option value="engineer" selected>Engineer</option>
12
+ </select></span>
13
+ </label>
14
+ </x-select>
15
+ Dispatches a bubbling `change` CustomEvent (detail: { value }) on the
16
+ underlying <select> whenever the value changes via the panel. */
17
+ (function () {
18
+ 'use strict';
19
+ if (customElements.get('x-select')) return;
20
+
21
+ let seq = 0;
22
+
23
+ class XSelect extends HTMLElement {
24
+ connectedCallback() {
25
+ if (this._built) return;
26
+ const select = this.querySelector('select');
27
+ if (!select) return; // nothing to enhance
28
+ this._built = true;
29
+ this._build(select);
30
+ }
31
+
32
+ _build(select) {
33
+ const wrap = select.closest('.select') || select;
34
+ const panelId = `x-select-panel-${++seq}`;
35
+ const labelText = this.querySelector('.t-label')?.textContent.trim();
36
+
37
+ wrap.style.display = 'none';
38
+ select.setAttribute('tabindex', '-1');
39
+ select.setAttribute('aria-hidden', 'true');
40
+
41
+ const trigger = document.createElement('button');
42
+ trigger.type = 'button';
43
+ trigger.className = 'select-trigger';
44
+ trigger.setAttribute('aria-haspopup', 'listbox');
45
+ trigger.setAttribute('aria-expanded', 'false');
46
+ trigger.setAttribute('popovertarget', panelId);
47
+ if (labelText) trigger.setAttribute('aria-label', labelText);
48
+ if (select.disabled) trigger.disabled = true;
49
+ const label = document.createElement('span');
50
+ trigger.append(label);
51
+
52
+ const panel = document.createElement('div');
53
+ panel.className = 'select-panel menu';
54
+ panel.id = panelId;
55
+ panel.setAttribute('popover', '');
56
+ panel.setAttribute('role', 'listbox');
57
+ if (labelText) panel.setAttribute('aria-label', labelText);
58
+
59
+ const options = [...select.options].map((opt, i) => {
60
+ const row = document.createElement('button');
61
+ row.type = 'button';
62
+ row.className = 'menu-item select-option';
63
+ row.textContent = opt.textContent;
64
+ row.setAttribute('role', 'option');
65
+ row.dataset.index = String(i);
66
+ if (opt.disabled) {
67
+ row.disabled = true;
68
+ row.setAttribute('aria-disabled', 'true');
69
+ }
70
+ row.addEventListener('click', () => commit(i));
71
+ panel.append(row);
72
+ return row;
73
+ });
74
+
75
+ wrap.before(trigger);
76
+ document.body.append(panel); // top layer via popover; anchored by js/menu.js
77
+
78
+ const syncFace = () => {
79
+ label.textContent = select.options[select.selectedIndex]?.textContent || '';
80
+ options.forEach((row, i) => {
81
+ const selected = i === select.selectedIndex;
82
+ row.setAttribute('aria-selected', String(selected));
83
+ });
84
+ };
85
+ syncFace();
86
+
87
+ function commit(index) {
88
+ const changed = select.selectedIndex !== index;
89
+ select.selectedIndex = index;
90
+ syncFace();
91
+ panel.hidePopover();
92
+ trigger.focus();
93
+ if (changed) {
94
+ select.dispatchEvent(new CustomEvent('change', {
95
+ bubbles: true,
96
+ detail: { value: select.value },
97
+ }));
98
+ }
99
+ }
100
+
101
+ panel.addEventListener('keydown', (e) => {
102
+ const enabled = options.filter((r) => !r.disabled);
103
+ const current = document.activeElement.closest('.select-option');
104
+ const idx = enabled.indexOf(current);
105
+ if (e.key === 'ArrowDown') {
106
+ e.preventDefault();
107
+ (enabled[idx + 1] || enabled[0])?.focus();
108
+ } else if (e.key === 'ArrowUp') {
109
+ e.preventDefault();
110
+ (enabled[idx - 1] || enabled[enabled.length - 1])?.focus();
111
+ } else if (e.key === 'Home') {
112
+ e.preventDefault();
113
+ enabled[0]?.focus();
114
+ } else if (e.key === 'End') {
115
+ e.preventDefault();
116
+ enabled[enabled.length - 1]?.focus();
117
+ } else if (e.key === 'Enter' || e.key === ' ') {
118
+ e.preventDefault();
119
+ if (current) commit(Number(current.dataset.index));
120
+ } else if (e.key.length === 1) {
121
+ const match = enabled.find((r) =>
122
+ r.textContent.trim().toLowerCase().startsWith(e.key.toLowerCase()));
123
+ match?.focus();
124
+ }
125
+ });
126
+
127
+ panel.addEventListener('toggle', (e) => {
128
+ const open = e.newState === 'open';
129
+ trigger.setAttribute('aria-expanded', String(open));
130
+ if (open) {
131
+ panel.style.minWidth = `${trigger.getBoundingClientRect().width}px`;
132
+ (options[select.selectedIndex] || options[0])?.focus();
133
+ }
134
+ });
135
+ }
136
+ }
137
+
138
+ customElements.define('x-select', XSelect);
139
+ })();
package/js/x-tabs.js ADDED
@@ -0,0 +1,48 @@
1
+ /* MTUI <x-tabs> — tier: Enhanced over Basic segmented.
2
+ Upgrades a .segmented radio group + [data-panel] siblings in place:
3
+ only the panel matching the checked radio's value shows.
4
+ Basic (JS off): all panels render, radios still select — functional.
5
+ Keyboard: native radio arrow-key behavior is the tab navigation.
6
+ Events: bubbling `change` CustomEvent with detail { value } (the native
7
+ radio change also bubbles through; listeners can read detail?.value ??
8
+ el.value). */
9
+ (function () {
10
+ 'use strict';
11
+ if (customElements.get('x-tabs')) return;
12
+
13
+ class XTabs extends HTMLElement {
14
+ connectedCallback() {
15
+ this.addEventListener('change', this);
16
+ this._sync(false);
17
+ }
18
+
19
+ disconnectedCallback() {
20
+ this.removeEventListener('change', this);
21
+ }
22
+
23
+ get value() {
24
+ const checked = this.querySelector('.segmented input:checked');
25
+ return checked ? checked.value : null;
26
+ }
27
+
28
+ handleEvent(e) {
29
+ if (e.target === this || !e.target.matches('.segmented input')) return;
30
+ this._sync(true);
31
+ }
32
+
33
+ _sync(emit) {
34
+ const value = this.value;
35
+ this.querySelectorAll('[data-panel]').forEach((panel) => {
36
+ panel.hidden = panel.dataset.panel !== value;
37
+ });
38
+ if (emit) {
39
+ this.dispatchEvent(new CustomEvent('change', {
40
+ bubbles: true,
41
+ detail: { value },
42
+ }));
43
+ }
44
+ }
45
+ }
46
+
47
+ customElements.define('x-tabs', XTabs);
48
+ })();
package/js/x-theme.js ADDED
@@ -0,0 +1,154 @@
1
+ /* MTUI <x-theme> — tier: JS-only (an in-app theme switcher inherently needs
2
+ JS; without it the element is empty and the app keeps its current/persisted
3
+ theme, still functional and on-language).
4
+
5
+ The Telegram "Appearance" panel, MTUI-native: flip theme, tap an accent,
6
+ set corners + density. Writes ONLY to the <html> knobs (root-only rule,
7
+ DESIGN.md) and persists each choice to localStorage so it survives reloads.
8
+ Built from canonical .segmented groups + a row of accent dots; accent
9
+ colors are sampled live from tokens.css (never hard-coded).
10
+
11
+ Canonical markup — just the empty element; it renders its own controls:
12
+ <x-theme></x-theme>
13
+
14
+ Emits a bubbling CustomEvent on every change:
15
+ 'themechange', detail: { key, value } // key: theme|accent|radius|density|tint
16
+
17
+ localStorage keys: mtui-theme, mtui-accent, mtui-radius, mtui-density, mtui-tint.
18
+ Precedence on load: a stored value (only if it's a valid option) > current
19
+ <html> attribute > default. */
20
+ (function () {
21
+ 'use strict';
22
+
23
+ const KNOBS = [
24
+ { key: 'theme', attr: 'data-theme', label: 'Appearance',
25
+ opts: [['light', 'Light'], ['dark', 'Dark']] },
26
+ { key: 'accent', attr: 'data-accent', label: 'Accent', swatch: true,
27
+ opts: [['clay', 'Clay'], ['cobalt', 'Cobalt'], ['fern', 'Fern'], ['plum', 'Plum']] },
28
+ { key: 'radius', attr: 'data-radius', label: 'Corners',
29
+ opts: [['round', 'Round'], ['soft', 'Soft'], ['sharp', 'Sharp']] },
30
+ { key: 'density', attr: 'data-density', label: 'Density',
31
+ opts: [['comfortable', 'Comfortable'], ['compact', 'Compact']] },
32
+ { key: 'tint', attr: 'data-tint', label: 'Surface tint',
33
+ opts: [['accent', 'Accent'], ['warm', 'Warm']] },
34
+ ];
35
+ const byKey = (k) => KNOBS.find((x) => x.key === k);
36
+
37
+ const store = {
38
+ get(key) { try { return localStorage.getItem('mtui-' + key); } catch (e) { return null; } },
39
+ set(key, val) { try { localStorage.setItem('mtui-' + key, val); } catch (e) { /* storage off */ } },
40
+ };
41
+
42
+ // Read each accent's solid --accent from the stylesheet by briefly swapping
43
+ // the root knob (root-only; all swaps + restore happen in one sync turn, so
44
+ // nothing paints in between — no flash). Never invents a hex value.
45
+ function sampleAccents(root, values) {
46
+ const prev = root.getAttribute('data-accent');
47
+ const cs = getComputedStyle(root);
48
+ const out = {};
49
+ try {
50
+ values.forEach((v) => {
51
+ root.setAttribute('data-accent', v);
52
+ out[v] = cs.getPropertyValue('--accent').trim();
53
+ });
54
+ } finally {
55
+ // Always restore, even if reading a value throws — never strand the
56
+ // root on a sampled accent.
57
+ if (prev === null) root.removeAttribute('data-accent');
58
+ else root.setAttribute('data-accent', prev);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ let uid = 0;
64
+
65
+ class ThemeControl extends HTMLElement {
66
+ connectedCallback() {
67
+ if (this._built) return; // guard re-entry on move/reparent
68
+ this._built = true;
69
+ const root = document.documentElement;
70
+ const id = ++uid;
71
+
72
+ const accentColors = sampleAccents(root, byKey('accent').opts.map((o) => o[0]));
73
+
74
+ // Resolve + apply each knob (stored pref wins) before reflecting state.
75
+ const current = {};
76
+ KNOBS.forEach((k) => {
77
+ // Prefer a stored pref only if it's a real option (guards corrupt/stale
78
+ // localStorage); otherwise keep the current attribute — which may be a
79
+ // legit non-preset like data-accent="custom" from the generator/Telegram
80
+ // — and never clobber it with the default.
81
+ const stored = store.get(k.key);
82
+ const valid = stored && k.opts.some(([v]) => v === stored);
83
+ const val = (valid ? stored : root.getAttribute(k.attr)) || k.opts[0][0];
84
+ root.setAttribute(k.attr, val);
85
+ current[k.key] = val;
86
+ });
87
+
88
+ const panel = document.createElement('div');
89
+ panel.className = 'theme-panel';
90
+
91
+ KNOBS.forEach((k) => {
92
+ const group = document.createElement('div');
93
+ group.className = 'theme-group';
94
+
95
+ const label = document.createElement('span');
96
+ label.className = 't-label';
97
+ label.textContent = k.label;
98
+ group.appendChild(label);
99
+
100
+ const name = `mtui-${k.key}-${id}`;
101
+ const container = document.createElement(k.swatch ? 'div' : 'fieldset');
102
+ container.dataset.knob = k.key;
103
+ container.className = k.swatch ? 'theme-accents' : 'segmented';
104
+ if (k.swatch) {
105
+ container.setAttribute('role', 'radiogroup');
106
+ container.setAttribute('aria-label', k.label);
107
+ }
108
+
109
+ k.opts.forEach(([value, text]) => {
110
+ const optLabel = document.createElement('label');
111
+ optLabel.className = k.swatch ? 'theme-accent' : '';
112
+ if (k.swatch) optLabel.style.setProperty('--dot', accentColors[value] || 'var(--accent)');
113
+
114
+ const input = document.createElement('input');
115
+ input.type = 'radio';
116
+ input.name = name;
117
+ input.value = value;
118
+ input.checked = current[k.key] === value;
119
+ if (k.swatch) input.setAttribute('aria-label', text);
120
+ optLabel.appendChild(input);
121
+
122
+ const face = document.createElement('span');
123
+ face.className = k.swatch ? 'theme-accent-dot' : '';
124
+ if (!k.swatch) face.textContent = text;
125
+ optLabel.appendChild(face);
126
+
127
+ container.appendChild(optLabel);
128
+ });
129
+
130
+ group.appendChild(container);
131
+ panel.appendChild(group);
132
+ });
133
+
134
+ this.replaceChildren(panel);
135
+
136
+ this.addEventListener('change', (e) => {
137
+ const input = e.target.closest('input[type="radio"]');
138
+ if (!input) return;
139
+ const holder = input.closest('[data-knob]');
140
+ if (!holder) return;
141
+ const key = holder.dataset.knob;
142
+ const value = input.value;
143
+ document.documentElement.setAttribute(byKey(key).attr, value);
144
+ store.set(key, value);
145
+ this.dispatchEvent(new CustomEvent('themechange', {
146
+ bubbles: true,
147
+ detail: { key, value },
148
+ }));
149
+ });
150
+ }
151
+ }
152
+
153
+ if (!customElements.get('x-theme')) customElements.define('x-theme', ThemeControl);
154
+ })();