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.
- package/DESIGN.md +310 -0
- package/LICENSE +24 -0
- package/README.md +119 -0
- package/css/base.css +200 -0
- package/css/components.css +1496 -0
- package/css/layout.css +380 -0
- package/css/tokens.css +318 -0
- package/dist/mtui.css +2394 -0
- package/dist/mtui.js +1719 -0
- package/dist/mtui.min.css +1 -0
- package/dist/mtui.min.js +1 -0
- package/fonts/OFL.txt +93 -0
- package/fonts/noto-sans-arabic.woff2 +0 -0
- package/fonts/noto-sans-latin-ext.woff2 +0 -0
- package/fonts/noto-sans-latin.woff2 +0 -0
- package/fonts/noto-sans-mono-latin.woff2 +0 -0
- package/js/accordion.js +64 -0
- package/js/feedback.js +171 -0
- package/js/menu.js +72 -0
- package/js/pull-refresh.js +122 -0
- package/js/telegram.js +175 -0
- package/js/x-colorswatches.js +144 -0
- package/js/x-contextmenu.js +190 -0
- package/js/x-datepicker.js +321 -0
- package/js/x-select.js +139 -0
- package/js/x-tabs.js +48 -0
- package/js/x-theme.js +154 -0
- package/js/x-toast.js +119 -0
- package/llms.txt +409 -0
- package/package.json +48 -0
package/dist/mtui.js
ADDED
|
@@ -0,0 +1,1719 @@
|
|
|
1
|
+
/* MTUI accordion motion — tier: lightly Enhanced over Basic <details>.
|
|
2
|
+
Basic (no JS): native <details>/<summary> opens/closes instantly, fully
|
|
3
|
+
functional. Enhanced: smooth height + chevron animation both ways via
|
|
4
|
+
the Web Animations API. Duration/easing are read live from --t-lift and
|
|
5
|
+
--ease, so reduced-motion (which collapses --t-lift to 0ms) disables
|
|
6
|
+
this for free — no separate check needed.
|
|
7
|
+
No custom element: this is an ambient enhancement over any
|
|
8
|
+
.accordion > summary in the page, same pattern as js/menu.js. */
|
|
9
|
+
(function () {
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
function durationMs() {
|
|
13
|
+
const v = getComputedStyle(document.documentElement)
|
|
14
|
+
.getPropertyValue('--t-lift').trim();
|
|
15
|
+
return parseFloat(v) || 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
document.addEventListener('click', (e) => {
|
|
19
|
+
const summary = e.target.closest('.accordion > summary');
|
|
20
|
+
if (!summary) return;
|
|
21
|
+
const details = summary.parentElement;
|
|
22
|
+
if ('animating' in details.dataset) { e.preventDefault(); return; }
|
|
23
|
+
|
|
24
|
+
const duration = durationMs();
|
|
25
|
+
if (duration === 0) return; // reduced motion: native instant toggle
|
|
26
|
+
|
|
27
|
+
const body = details.querySelector(':scope > .accordion-body');
|
|
28
|
+
if (!body) return; // nothing to animate; let the native toggle happen
|
|
29
|
+
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
const opening = !details.open;
|
|
32
|
+
details.dataset.animating = '';
|
|
33
|
+
details.dataset.expanded = opening ? 'true' : 'false';
|
|
34
|
+
|
|
35
|
+
if (opening) details.open = true; // must be open to measure scrollHeight
|
|
36
|
+
// border-box means blockSize alone can't go below the body's own
|
|
37
|
+
// padding — animate padding to 0 in lockstep so the collapsed state is
|
|
38
|
+
// a true 0px, not a floor of ~24px of bare padding.
|
|
39
|
+
const cs = getComputedStyle(body);
|
|
40
|
+
const padTop = cs.paddingBlockStart;
|
|
41
|
+
const padBottom = cs.paddingBlockEnd;
|
|
42
|
+
const endHeight = body.scrollHeight;
|
|
43
|
+
const easing = getComputedStyle(document.documentElement)
|
|
44
|
+
.getPropertyValue('--ease').trim() || 'ease';
|
|
45
|
+
|
|
46
|
+
const open = { blockSize: `${endHeight}px`, paddingBlockStart: padTop, paddingBlockEnd: padBottom };
|
|
47
|
+
const closed = { blockSize: '0px', paddingBlockStart: '0px', paddingBlockEnd: '0px' };
|
|
48
|
+
|
|
49
|
+
body.style.overflow = 'hidden';
|
|
50
|
+
const anim = body.animate(
|
|
51
|
+
opening ? [closed, open] : [open, closed],
|
|
52
|
+
{ duration, easing }
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
anim.onfinish = () => {
|
|
56
|
+
body.style.blockSize = '';
|
|
57
|
+
body.style.paddingBlockStart = '';
|
|
58
|
+
body.style.paddingBlockEnd = '';
|
|
59
|
+
body.style.overflow = '';
|
|
60
|
+
delete details.dataset.animating;
|
|
61
|
+
if (!opening) details.open = false; // unmount only after fully collapsed
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
})();
|
|
65
|
+
/* MTUI feedback — tier: JS-only, layered opt-in over phase 04-06 components
|
|
66
|
+
(doesn't change their markup or behavior contracts).
|
|
67
|
+
Five synthetic WebAudio cues (no audio files) + matching haptics, gated
|
|
68
|
+
by one global mute persisted to localStorage. AudioContext is created
|
|
69
|
+
lazily on first use; if it's still suspended (no user gesture has
|
|
70
|
+
unlocked audio yet) or unavailable, playback silently no-ops.
|
|
71
|
+
API: mtui.feedback.play('tick'|'toggle-on'|'toggle-off'|'success'|'error')
|
|
72
|
+
mtui.feedback.muted // get
|
|
73
|
+
mtui.feedback.muted = true // set, persists
|
|
74
|
+
|
|
75
|
+
Opt-in wiring — nothing fires without a matching visible event, and
|
|
76
|
+
none of this listens for hover, scroll, or focus (DESIGN.md §4.10):
|
|
77
|
+
.switch/.checkbox/.radio[data-feedback] -> toggle-on / toggle-off on change
|
|
78
|
+
button/.btn[data-feedback] -> success on click, or the
|
|
79
|
+
cue named in the attribute
|
|
80
|
+
("confirm actions")
|
|
81
|
+
.toast[data-kind] -> that cue when the toast mounts
|
|
82
|
+
[data-feedback][data-status="danger"] -> error when it mounts
|
|
83
|
+
(alert / empty boundaries)
|
|
84
|
+
|
|
85
|
+
Basic (JS off): silent — there is no audio-file fallback to degrade to. */
|
|
86
|
+
(function () {
|
|
87
|
+
'use strict';
|
|
88
|
+
|
|
89
|
+
const MUTE_KEY = 'mtui-feedback-muted';
|
|
90
|
+
|
|
91
|
+
// Frequencies, durations and the 0.06 gain ceiling are locked by
|
|
92
|
+
// DESIGN.md §2.13. `steps` are discrete pitches (a clean interval, not a
|
|
93
|
+
// continuous portamento glide) so toggles read as a crisp two-note tap
|
|
94
|
+
// rather than a slide-whistle swoop. Everything else — soft attack,
|
|
95
|
+
// exponential decay, lowpass — is envelope shaping, not spec values.
|
|
96
|
+
const CUES = {
|
|
97
|
+
tick: { wave: 'sine', steps: [880], duration: 0.05, peak: 0.04 },
|
|
98
|
+
'toggle-on': { wave: 'sine', steps: [520, 784], duration: 0.09, peak: 0.05, haptic: 8 },
|
|
99
|
+
'toggle-off': { wave: 'sine', steps: [784, 520], duration: 0.09, peak: 0.05, haptic: 8 },
|
|
100
|
+
success: { wave: 'sine', steps: [660, 880], duration: 0.14, peak: 0.05, haptic: 8 },
|
|
101
|
+
error: { wave: 'triangle', steps: [233], duration: 0.16, peak: 0.05, haptic: [20, 40, 20] },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function isMuted() {
|
|
105
|
+
try {
|
|
106
|
+
return localStorage.getItem(MUTE_KEY) === '1';
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function setMuted(value) {
|
|
113
|
+
try {
|
|
114
|
+
localStorage.setItem(MUTE_KEY, value ? '1' : '0');
|
|
115
|
+
} catch (e) {
|
|
116
|
+
/* storage unavailable (private mode, quota, policy) — mute stays session-only */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let ctx = null;
|
|
121
|
+
function audioCtx() {
|
|
122
|
+
if (ctx) return ctx;
|
|
123
|
+
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
|
124
|
+
ctx = AudioContextClass ? new AudioContextClass() : null;
|
|
125
|
+
return ctx;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function tone(cue) {
|
|
129
|
+
const c = audioCtx();
|
|
130
|
+
if (!c) return;
|
|
131
|
+
if (c.state === 'suspended') c.resume().catch(() => {});
|
|
132
|
+
|
|
133
|
+
const t0 = c.currentTime;
|
|
134
|
+
const osc = c.createOscillator();
|
|
135
|
+
const gain = c.createGain();
|
|
136
|
+
const lp = c.createBiquadFilter();
|
|
137
|
+
osc.type = cue.wave;
|
|
138
|
+
|
|
139
|
+
// Discrete pitch steps — each note holds for an equal slice, no glide.
|
|
140
|
+
const steps = cue.steps;
|
|
141
|
+
const slice = cue.duration / steps.length;
|
|
142
|
+
steps.forEach((f, i) => osc.frequency.setValueAtTime(f, t0 + i * slice));
|
|
143
|
+
|
|
144
|
+
// Gentle lowpass rolls off the brittle upper harmonics (esp. the
|
|
145
|
+
// triangle error cue) so the tone stays soft rather than buzzy.
|
|
146
|
+
lp.type = 'lowpass';
|
|
147
|
+
lp.frequency.setValueAtTime(Math.max(...steps) * 3, t0);
|
|
148
|
+
lp.Q.setValueAtTime(0.7, t0);
|
|
149
|
+
|
|
150
|
+
// Percussive envelope: click-free ~4ms attack, then a natural
|
|
151
|
+
// exponential decay — no flat sustain plateau (that's the "beep").
|
|
152
|
+
// exponentialRampToValueAtTime can't reach 0, so decay to near-zero.
|
|
153
|
+
const peak = Math.min(cue.peak || 0.05, 0.06);
|
|
154
|
+
gain.gain.setValueAtTime(0.0001, t0);
|
|
155
|
+
gain.gain.exponentialRampToValueAtTime(peak, t0 + 0.004);
|
|
156
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + cue.duration);
|
|
157
|
+
gain.gain.setValueAtTime(0, t0 + cue.duration + 0.005);
|
|
158
|
+
|
|
159
|
+
osc.connect(lp).connect(gain).connect(c.destination);
|
|
160
|
+
osc.start(t0);
|
|
161
|
+
osc.stop(t0 + cue.duration + 0.02);
|
|
162
|
+
osc.addEventListener('ended', () => {
|
|
163
|
+
osc.disconnect();
|
|
164
|
+
lp.disconnect();
|
|
165
|
+
gain.disconnect();
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function buzz(pattern) {
|
|
170
|
+
if (typeof navigator.vibrate !== 'function') return;
|
|
171
|
+
try {
|
|
172
|
+
navigator.vibrate(pattern);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
/* unsupported in this context — no-op */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function play(name) {
|
|
179
|
+
const cue = CUES[name];
|
|
180
|
+
if (!cue || isMuted()) return;
|
|
181
|
+
tone(cue);
|
|
182
|
+
if (cue.haptic) buzz(cue.haptic);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
window.mtui = Object.assign(window.mtui || {}, {
|
|
186
|
+
feedback: {
|
|
187
|
+
play,
|
|
188
|
+
get muted() {
|
|
189
|
+
return isMuted();
|
|
190
|
+
},
|
|
191
|
+
set muted(value) {
|
|
192
|
+
setMuted(!!value);
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
document.addEventListener('change', (e) => {
|
|
198
|
+
if (e.target.matches('.switch[data-feedback], .checkbox[data-feedback], .radio[data-feedback]')) {
|
|
199
|
+
play(e.target.checked ? 'toggle-on' : 'toggle-off');
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
document.addEventListener('click', (e) => {
|
|
204
|
+
const el = e.target.closest('button[data-feedback], .btn[data-feedback]');
|
|
205
|
+
if (el) play(el.getAttribute('data-feedback') || 'success');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// A parent (e.g. the <x-toast> mount) and its just-appended child can
|
|
209
|
+
// land in separate MutationRecords from the same synchronous batch; by
|
|
210
|
+
// the time this callback runs both are already in the live tree, so
|
|
211
|
+
// scanning the parent's subtree can rediscover a child another record
|
|
212
|
+
// already reported. Track fired nodes so each element cues at most once.
|
|
213
|
+
const MOUNT_SELECTOR = '.toast[data-kind], [data-feedback][data-status="danger"]';
|
|
214
|
+
const fired = new WeakSet();
|
|
215
|
+
function scan(node) {
|
|
216
|
+
if (node.nodeType !== 1) return;
|
|
217
|
+
const found = node.matches(MOUNT_SELECTOR) ? [node] : [];
|
|
218
|
+
found.concat([...node.querySelectorAll(MOUNT_SELECTOR)]).forEach((el) => {
|
|
219
|
+
if (fired.has(el)) return;
|
|
220
|
+
fired.add(el);
|
|
221
|
+
play(el.classList.contains('toast') ? el.dataset.kind : 'error');
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Guard document.body: if this script is included in <head> without defer,
|
|
226
|
+
// body is null. The API + change/click listeners above are already live;
|
|
227
|
+
// only the mount-cue observer needs a live body, so defer it if needed.
|
|
228
|
+
function observeMounts() {
|
|
229
|
+
new MutationObserver((mutations) => {
|
|
230
|
+
mutations.forEach((m) => m.addedNodes.forEach(scan));
|
|
231
|
+
}).observe(document.body, { childList: true, subtree: true });
|
|
232
|
+
}
|
|
233
|
+
if (document.body) observeMounts();
|
|
234
|
+
else document.addEventListener('DOMContentLoaded', observeMounts, { once: true });
|
|
235
|
+
})();
|
|
236
|
+
/* MTUI menu positioning — tier: lightly Enhanced (no custom element).
|
|
237
|
+
Anchors any .menu[popover] below its [popovertarget] trigger, aligned to
|
|
238
|
+
the trigger's inline-start edge, flipped above when there is no room and
|
|
239
|
+
clamped to the viewport. Re-anchors on scroll/resize while open, so the
|
|
240
|
+
panel tracks its trigger instead of staying pinned at the viewport
|
|
241
|
+
position it opened at (which would visibly drift away from the trigger
|
|
242
|
+
as the page scrolls underneath it).
|
|
243
|
+
Basic (JS off): the popover opens centered in the top layer — Esc and
|
|
244
|
+
light-dismiss still work; nothing breaks. */
|
|
245
|
+
(function () {
|
|
246
|
+
'use strict';
|
|
247
|
+
|
|
248
|
+
function place(menu, trigger) {
|
|
249
|
+
const gap = parseFloat(
|
|
250
|
+
getComputedStyle(document.documentElement).getPropertyValue('--sp-8')
|
|
251
|
+
) || 8;
|
|
252
|
+
const t = trigger.getBoundingClientRect();
|
|
253
|
+
const m = menu.getBoundingClientRect();
|
|
254
|
+
const rtl = getComputedStyle(menu).direction === 'rtl';
|
|
255
|
+
|
|
256
|
+
let top = t.bottom + gap;
|
|
257
|
+
if (top + m.height > window.innerHeight - gap) {
|
|
258
|
+
top = Math.max(gap, t.top - m.height - gap);
|
|
259
|
+
}
|
|
260
|
+
let x = rtl ? t.right - m.width : t.left;
|
|
261
|
+
x = Math.min(Math.max(gap, x), window.innerWidth - m.width - gap);
|
|
262
|
+
|
|
263
|
+
menu.style.position = 'fixed';
|
|
264
|
+
menu.style.top = `${Math.round(top)}px`;
|
|
265
|
+
menu.style.left = `${Math.round(x)}px`;
|
|
266
|
+
menu.style.margin = '0';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let tracking = null; // { menu, trigger, raf }
|
|
270
|
+
|
|
271
|
+
function stopTracking() {
|
|
272
|
+
if (!tracking) return;
|
|
273
|
+
cancelAnimationFrame(tracking.raf);
|
|
274
|
+
window.removeEventListener('scroll', tracking.onScroll, true);
|
|
275
|
+
window.removeEventListener('resize', tracking.onScroll);
|
|
276
|
+
tracking = null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
document.addEventListener('toggle', (e) => {
|
|
280
|
+
const menu = e.target;
|
|
281
|
+
if (!(menu instanceof HTMLElement) || !menu.matches('.menu[popover]')) return;
|
|
282
|
+
|
|
283
|
+
if (e.newState !== 'open') {
|
|
284
|
+
if (tracking && tracking.menu === menu) stopTracking();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const trigger = document.querySelector(`[popovertarget="${menu.id}"]`);
|
|
289
|
+
if (!trigger) return;
|
|
290
|
+
|
|
291
|
+
if (tracking) stopTracking();
|
|
292
|
+
place(menu, trigger);
|
|
293
|
+
|
|
294
|
+
let queued = false;
|
|
295
|
+
const onScroll = () => {
|
|
296
|
+
if (queued) return;
|
|
297
|
+
queued = true;
|
|
298
|
+
tracking.raf = requestAnimationFrame(() => {
|
|
299
|
+
queued = false;
|
|
300
|
+
place(menu, trigger);
|
|
301
|
+
});
|
|
302
|
+
};
|
|
303
|
+
tracking = { menu, trigger, raf: 0, onScroll };
|
|
304
|
+
window.addEventListener('scroll', onScroll, true); // capture: any scrolling ancestor
|
|
305
|
+
window.addEventListener('resize', onScroll);
|
|
306
|
+
}, true); /* popover toggle doesn't bubble — capture phase catches it */
|
|
307
|
+
})();
|
|
308
|
+
/* MTUI <x-pull-refresh> — tier: Enhanced over .screen-list.
|
|
309
|
+
Wraps a scrollable region; pulling down past the threshold at
|
|
310
|
+
scrollTop 0 dispatches a bubbling `refresh` CustomEvent whose
|
|
311
|
+
detail.done() the app calls when its async work finishes.
|
|
312
|
+
Pointer Events, so mouse/trackpad/pen work the same as touch — this is
|
|
313
|
+
a drag gesture, not a touch-only affordance.
|
|
314
|
+
Basic (no JS): no affordance — the list just scrolls normally.
|
|
315
|
+
<x-pull-refresh>
|
|
316
|
+
<div class="screen-list">…</div>
|
|
317
|
+
</x-pull-refresh>
|
|
318
|
+
app.addEventListener('refresh', (e) => {
|
|
319
|
+
loadMessages().then(() => { e.detail.done(); mtui.toast('Updated'); });
|
|
320
|
+
}); */
|
|
321
|
+
(function () {
|
|
322
|
+
'use strict';
|
|
323
|
+
if (customElements.get('x-pull-refresh')) return;
|
|
324
|
+
|
|
325
|
+
const THRESHOLD = 64;
|
|
326
|
+
const MAX_PULL = 96;
|
|
327
|
+
|
|
328
|
+
class XPullRefresh extends HTMLElement {
|
|
329
|
+
connectedCallback() {
|
|
330
|
+
if (this._built) return;
|
|
331
|
+
const scroller = this.querySelector('.screen-list, [data-pull-scroll]');
|
|
332
|
+
if (!scroller) return;
|
|
333
|
+
this._built = true;
|
|
334
|
+
this._build(scroller);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
_build(scroller) {
|
|
338
|
+
scroller.style.position = 'relative';
|
|
339
|
+
scroller.style.overscrollBehaviorY = 'contain'; // don't chain into page scroll
|
|
340
|
+
const indicator = document.createElement('div');
|
|
341
|
+
indicator.className = 'pull-indicator';
|
|
342
|
+
scroller.prepend(indicator);
|
|
343
|
+
|
|
344
|
+
const reduced = () => {
|
|
345
|
+
const v = getComputedStyle(document.documentElement)
|
|
346
|
+
.getPropertyValue('--t-lift').trim();
|
|
347
|
+
return (parseFloat(v) || 0) === 0;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
let startY = 0;
|
|
351
|
+
let dragging = false; // pointer is down and we've confirmed this is a pull, not a click
|
|
352
|
+
let refreshing = false;
|
|
353
|
+
let activePointerId = null;
|
|
354
|
+
let currentPull = 0;
|
|
355
|
+
|
|
356
|
+
const setPull = (px) => {
|
|
357
|
+
currentPull = px;
|
|
358
|
+
indicator.style.transform = `translateY(${px}px) rotate(${px * 3}deg)`;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
scroller.addEventListener('pointerdown', (e) => {
|
|
362
|
+
if (refreshing || scroller.scrollTop > 0 || !e.isPrimary) return;
|
|
363
|
+
startY = e.clientY;
|
|
364
|
+
activePointerId = e.pointerId;
|
|
365
|
+
// Not yet a confirmed drag — a plain click/tap must still work
|
|
366
|
+
// normally. document-level listeners (not scroller-level) so the
|
|
367
|
+
// gesture keeps tracking even if the pointer leaves the element.
|
|
368
|
+
document.addEventListener('pointermove', onMove);
|
|
369
|
+
document.addEventListener('pointerup', onUp);
|
|
370
|
+
document.addEventListener('pointercancel', onUp);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
function onMove(e) {
|
|
374
|
+
if (e.pointerId !== activePointerId) return;
|
|
375
|
+
const dy = e.clientY - startY;
|
|
376
|
+
if (dy <= 0) { dragging = false; setPull(0); return; }
|
|
377
|
+
// Only now, once it's clearly a downward pull, do we take over the
|
|
378
|
+
// gesture — this is what keeps the page (and the internal list)
|
|
379
|
+
// from scrolling along with the pull.
|
|
380
|
+
dragging = true;
|
|
381
|
+
e.preventDefault();
|
|
382
|
+
setPull(Math.min(dy * 0.5, MAX_PULL));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function onUp() {
|
|
386
|
+
document.removeEventListener('pointermove', onMove);
|
|
387
|
+
document.removeEventListener('pointerup', onUp);
|
|
388
|
+
document.removeEventListener('pointercancel', onUp);
|
|
389
|
+
activePointerId = null;
|
|
390
|
+
if (!dragging) return;
|
|
391
|
+
dragging = false;
|
|
392
|
+
if (currentPull >= THRESHOLD) beginRefresh(); else springBack();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function springBack() {
|
|
396
|
+
indicator.style.transition = reduced() ? 'none' : 'transform var(--t-knob) var(--spring)';
|
|
397
|
+
setPull(0);
|
|
398
|
+
indicator.addEventListener('transitionend', function onEnd() {
|
|
399
|
+
indicator.style.transition = '';
|
|
400
|
+
indicator.removeEventListener('transitionend', onEnd);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function beginRefresh() {
|
|
405
|
+
refreshing = true;
|
|
406
|
+
indicator.style.transition = reduced() ? 'none' : 'transform var(--t-knob) var(--spring)';
|
|
407
|
+
setPull(THRESHOLD * 0.6);
|
|
408
|
+
indicator.dataset.spinning = '';
|
|
409
|
+
|
|
410
|
+
let settled = false;
|
|
411
|
+
const done = () => {
|
|
412
|
+
if (settled) return;
|
|
413
|
+
settled = true;
|
|
414
|
+
delete indicator.dataset.spinning;
|
|
415
|
+
refreshing = false;
|
|
416
|
+
springBack();
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
scroller.dispatchEvent(new CustomEvent('refresh', {
|
|
420
|
+
bubbles: true,
|
|
421
|
+
detail: { done },
|
|
422
|
+
}));
|
|
423
|
+
setTimeout(done, 15000); // safety: never spin forever if the app forgets done()
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
customElements.define('x-pull-refresh', XPullRefresh);
|
|
429
|
+
})();
|
|
430
|
+
/* MTUI Telegram adapter — tier: JS-only, layered opt-in (loads harmlessly
|
|
431
|
+
anywhere; no-ops unless window.Telegram.WebApp is present).
|
|
432
|
+
|
|
433
|
+
Maps the host theme onto MTUI so a mini app looks and feels native to the
|
|
434
|
+
Telegram shell: the user's background, surfaces, text, and brand accent all
|
|
435
|
+
come straight from themeParams. Telegram supplies ~2 backgrounds + 2 text
|
|
436
|
+
levels + an accent; MTUI's ladder needs 4 surfaces, 3 text tones, and a
|
|
437
|
+
full accent family, so the gaps are DERIVED in OKLCH from the host's own
|
|
438
|
+
colors (never invented from scratch):
|
|
439
|
+
pass-through : --bg --surface --text --text-muted --accent --on-accent
|
|
440
|
+
--accent-text (host values, verbatim)
|
|
441
|
+
derived : --surface-2 --surface-3 (step from surface toward text)
|
|
442
|
+
--text-faint (muted blended toward bg)
|
|
443
|
+
--accent-hover (accent lightness −0.05)
|
|
444
|
+
--accent-tint (§2.14 tint from the accent hue)
|
|
445
|
+
Contrast is delegated to the host pairings (button_text_color, link_color),
|
|
446
|
+
which Telegram themes already guarantee legible.
|
|
447
|
+
|
|
448
|
+
themeParams -> MTUI slots:
|
|
449
|
+
secondary_bg_color -> --bg (the recessed page background)
|
|
450
|
+
bg_color -> --surface (raised cards/sections)
|
|
451
|
+
text_color -> --text
|
|
452
|
+
hint_color -> --text-muted
|
|
453
|
+
button_color -> --accent (+ button_text_color -> --on-accent)
|
|
454
|
+
link_color -> --accent-text
|
|
455
|
+
Re-applied on load and on Telegram's themeChanged; data-theme tracks
|
|
456
|
+
colorScheme so MTUI's status tints and forced-colors pick the right base.
|
|
457
|
+
|
|
458
|
+
API: mtui.telegram.sync() // read live WebApp + apply
|
|
459
|
+
mtui.telegram.apply(themeParams, scheme) // apply given values (testing)
|
|
460
|
+
|
|
461
|
+
Basic (JS off, or outside Telegram): silent no-op — the screen keeps the
|
|
462
|
+
tokens.css defaults, functional and on-language. */
|
|
463
|
+
(function () {
|
|
464
|
+
'use strict';
|
|
465
|
+
|
|
466
|
+
const clamp01 = (n) => Math.min(1, Math.max(0, n));
|
|
467
|
+
const r3 = (n) => Number(n.toFixed(3));
|
|
468
|
+
const r1 = (n) => Number(n.toFixed(1));
|
|
469
|
+
|
|
470
|
+
// "#rgb" / "#rrggbb" (with or without #) -> "#rrggbb", or null if unusable.
|
|
471
|
+
function normHex(hex) {
|
|
472
|
+
if (typeof hex !== 'string') return null;
|
|
473
|
+
let h = hex.trim().replace(/^#/, '');
|
|
474
|
+
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
|
475
|
+
return /^[0-9a-fA-F]{6}$/.test(h) ? '#' + h.toLowerCase() : null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// sRGB hex -> OKLCH {L (0–1), C, H (deg)}. null on unparseable input.
|
|
479
|
+
function hexToLCH(hex) {
|
|
480
|
+
const h = normHex(hex);
|
|
481
|
+
if (!h) return null;
|
|
482
|
+
const toLinear = (v) => {
|
|
483
|
+
const c = v / 255;
|
|
484
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
485
|
+
};
|
|
486
|
+
const r = toLinear(parseInt(h.slice(1, 3), 16));
|
|
487
|
+
const g = toLinear(parseInt(h.slice(3, 5), 16));
|
|
488
|
+
const b = toLinear(parseInt(h.slice(5, 7), 16));
|
|
489
|
+
// linear sRGB -> LMS -> OKLab (Ottosson matrices).
|
|
490
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
491
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
492
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
493
|
+
const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
|
|
494
|
+
const oa = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
|
|
495
|
+
const ob = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
|
|
496
|
+
let H = (Math.atan2(ob, oa) * 180) / Math.PI;
|
|
497
|
+
if (H < 0) H += 360;
|
|
498
|
+
return { L, C: Math.sqrt(oa * oa + ob * ob), H };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const fmt = (lch) => `oklch(${r1(lch.L * 100)}% ${r3(lch.C)} ${Math.round(lch.H)})`;
|
|
502
|
+
|
|
503
|
+
// Nudge lightness a fixed delta in the direction of a target L (chroma/hue
|
|
504
|
+
// kept). Light theme: surface is light, text dark -> steps darker (recede);
|
|
505
|
+
// dark theme: steps lighter. Same rule works for both.
|
|
506
|
+
const towardL = (base, targetL, delta) => ({
|
|
507
|
+
L: clamp01(base.L + Math.sign(targetL - base.L || 1) * delta),
|
|
508
|
+
C: base.C,
|
|
509
|
+
H: base.H,
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// Blend two OKLCH colors (shortest-path hue) — used for near-grey text tones.
|
|
513
|
+
function mix(a, b, t) {
|
|
514
|
+
let dh = b.H - a.H;
|
|
515
|
+
if (dh > 180) dh -= 360;
|
|
516
|
+
if (dh < -180) dh += 360;
|
|
517
|
+
return { L: a.L + (b.L - a.L) * t, C: a.C + (b.C - a.C) * t, H: a.H + dh * t };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// §2.14 tint from the accent's hue/chroma, per theme.
|
|
521
|
+
const tint = (acc, dark) =>
|
|
522
|
+
dark
|
|
523
|
+
? { L: 0.30, C: 0.35 * acc.C, H: acc.H }
|
|
524
|
+
: { L: 0.92, C: Math.min(0.32 * acc.C, 0.05), H: acc.H };
|
|
525
|
+
|
|
526
|
+
const MANAGED = [
|
|
527
|
+
'--bg', '--surface', '--surface-2', '--surface-3',
|
|
528
|
+
'--text', '--text-muted', '--text-faint',
|
|
529
|
+
'--accent', '--accent-hover', '--accent-text', '--accent-tint', '--on-accent',
|
|
530
|
+
];
|
|
531
|
+
|
|
532
|
+
function apply(themeParams, scheme) {
|
|
533
|
+
const root = document.documentElement;
|
|
534
|
+
const dark = scheme === 'dark';
|
|
535
|
+
root.setAttribute('data-theme', dark ? 'dark' : 'light');
|
|
536
|
+
|
|
537
|
+
// Start clean so a sparser theme (e.g. after themeChanged) can't leave
|
|
538
|
+
// stale overrides from a richer previous one.
|
|
539
|
+
MANAGED.forEach((p) => root.style.removeProperty(p));
|
|
540
|
+
|
|
541
|
+
const tp = themeParams || {};
|
|
542
|
+
const surfaceHex = normHex(tp.bg_color) || normHex(tp.section_bg_color) || normHex(tp.secondary_bg_color);
|
|
543
|
+
const bgRaw = normHex(tp.secondary_bg_color);
|
|
544
|
+
// Only use the host bg if it's actually distinct from the surface; a sparse
|
|
545
|
+
// theme (only secondary_bg_color) would otherwise collapse the tonal step.
|
|
546
|
+
const bgHex = bgRaw && bgRaw !== surfaceHex ? bgRaw : null;
|
|
547
|
+
const textHex = normHex(tp.text_color);
|
|
548
|
+
|
|
549
|
+
// Need at least a base surface + text to meaningfully theme the shell;
|
|
550
|
+
// otherwise leave the tokens.css defaults in place.
|
|
551
|
+
if (!surfaceHex || !textHex) return;
|
|
552
|
+
|
|
553
|
+
const surface = hexToLCH(surfaceHex);
|
|
554
|
+
const text = hexToLCH(textHex);
|
|
555
|
+
const bg = bgHex ? hexToLCH(bgHex) : { L: clamp01(surface.L - 0.05), C: surface.C, H: surface.H };
|
|
556
|
+
const mutedHex = normHex(tp.hint_color) || normHex(tp.subtitle_text_color);
|
|
557
|
+
const muted = mutedHex ? hexToLCH(mutedHex) : mix(text, bg, 0.45);
|
|
558
|
+
|
|
559
|
+
const set = (prop, val) => root.style.setProperty(prop, val);
|
|
560
|
+
set('--bg', bgHex || fmt(bg));
|
|
561
|
+
set('--surface', surfaceHex);
|
|
562
|
+
set('--surface-2', fmt(towardL(surface, text.L, 0.035)));
|
|
563
|
+
set('--surface-3', fmt(towardL(surface, text.L, 0.075)));
|
|
564
|
+
set('--text', textHex);
|
|
565
|
+
set('--text-muted', mutedHex || fmt(muted));
|
|
566
|
+
set('--text-faint', fmt(mix(muted, bg, 0.45)));
|
|
567
|
+
|
|
568
|
+
const accentHex = normHex(tp.button_color);
|
|
569
|
+
if (accentHex) {
|
|
570
|
+
const acc = hexToLCH(accentHex);
|
|
571
|
+
root.setAttribute('data-accent', 'custom');
|
|
572
|
+
set('--accent', accentHex);
|
|
573
|
+
set('--on-accent', normHex(tp.button_text_color) || '#FFFFFF');
|
|
574
|
+
set('--accent-hover', fmt({ L: clamp01(acc.L - 0.05), C: acc.C, H: acc.H }));
|
|
575
|
+
set('--accent-text', normHex(tp.link_color) || normHex(tp.accent_text_color) || accentHex);
|
|
576
|
+
set('--accent-tint', fmt(tint(acc, dark)));
|
|
577
|
+
} else {
|
|
578
|
+
// No host accent this round — drop a stale data-accent="custom" so the
|
|
579
|
+
// accent doesn't silently revert to the default ramp while the attr lies.
|
|
580
|
+
root.removeAttribute('data-accent');
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function currentWebApp() {
|
|
585
|
+
return (window.Telegram && window.Telegram.WebApp) || null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function sync() {
|
|
589
|
+
const wa = currentWebApp();
|
|
590
|
+
if (!wa) return false;
|
|
591
|
+
apply(wa.themeParams || {}, wa.colorScheme);
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
window.mtui = Object.assign(window.mtui || {}, {
|
|
596
|
+
telegram: { sync, apply },
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const wa = currentWebApp();
|
|
600
|
+
if (wa) {
|
|
601
|
+
sync();
|
|
602
|
+
if (typeof wa.onEvent === 'function') wa.onEvent('themeChanged', sync);
|
|
603
|
+
}
|
|
604
|
+
})();
|
|
605
|
+
/* MTUI <x-colorswatches> — tier: Enhanced over Basic native
|
|
606
|
+
<input type="color">. Curated swatches only, never a free color wheel.
|
|
607
|
+
Basic (no JS): the native color input renders and works normally.
|
|
608
|
+
<x-colorswatches data-colors="#BC4B2A,#3556C9,#2F7A50,#6E46BD,#7A5600">
|
|
609
|
+
<label class="field">
|
|
610
|
+
<span class="t-label">Tag color</span>
|
|
611
|
+
<input type="color" value="#BC4B2A">
|
|
612
|
+
</label>
|
|
613
|
+
</x-colorswatches>
|
|
614
|
+
data-colors on the host element is the one canonical swatch source.
|
|
615
|
+
Dispatches a bubbling `change` CustomEvent (detail: { value }) on the
|
|
616
|
+
underlying input. */
|
|
617
|
+
(function () {
|
|
618
|
+
'use strict';
|
|
619
|
+
if (customElements.get('x-colorswatches')) return;
|
|
620
|
+
|
|
621
|
+
let seq = 0;
|
|
622
|
+
|
|
623
|
+
function luminance(hex) {
|
|
624
|
+
const n = hex.replace('#', '');
|
|
625
|
+
const [r, g, b] = [0, 2, 4].map((i) => {
|
|
626
|
+
const c = parseInt(n.slice(i, i + 2), 16) / 255;
|
|
627
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
628
|
+
});
|
|
629
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
class XColorswatches extends HTMLElement {
|
|
633
|
+
connectedCallback() {
|
|
634
|
+
if (this._built) return;
|
|
635
|
+
const input = this.querySelector('input[type="color"]');
|
|
636
|
+
if (!input) return;
|
|
637
|
+
this._built = true;
|
|
638
|
+
this._build(input);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
_build(input) {
|
|
642
|
+
const panelId = `x-swatches-panel-${++seq}`;
|
|
643
|
+
const labelText = this.querySelector('.t-label')?.textContent.trim();
|
|
644
|
+
const colors = (this.dataset.colors || '')
|
|
645
|
+
.split(',')
|
|
646
|
+
.map((c) => c.trim())
|
|
647
|
+
.filter(Boolean);
|
|
648
|
+
if (!colors.length) return;
|
|
649
|
+
|
|
650
|
+
input.setAttribute('tabindex', '-1');
|
|
651
|
+
input.setAttribute('aria-hidden', 'true');
|
|
652
|
+
input.style.position = 'absolute';
|
|
653
|
+
input.style.opacity = '0';
|
|
654
|
+
input.style.pointerEvents = 'none';
|
|
655
|
+
input.style.inlineSize = '1px';
|
|
656
|
+
input.style.blockSize = '1px';
|
|
657
|
+
|
|
658
|
+
const trigger = document.createElement('button');
|
|
659
|
+
trigger.type = 'button';
|
|
660
|
+
trigger.className = 'swatches-trigger';
|
|
661
|
+
trigger.setAttribute('aria-haspopup', 'true');
|
|
662
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
663
|
+
trigger.setAttribute('popovertarget', panelId);
|
|
664
|
+
if (labelText) trigger.setAttribute('aria-label', labelText);
|
|
665
|
+
const swatchPreview = document.createElement('span');
|
|
666
|
+
swatchPreview.className = 'swatches-trigger-swatch';
|
|
667
|
+
const triggerLabel = document.createElement('span');
|
|
668
|
+
trigger.append(swatchPreview, triggerLabel);
|
|
669
|
+
|
|
670
|
+
const panel = document.createElement('div');
|
|
671
|
+
panel.className = 'swatches-panel menu';
|
|
672
|
+
panel.id = panelId;
|
|
673
|
+
panel.setAttribute('popover', '');
|
|
674
|
+
panel.setAttribute('role', 'radiogroup');
|
|
675
|
+
if (labelText) panel.setAttribute('aria-label', labelText);
|
|
676
|
+
|
|
677
|
+
const swatches = colors.map((color) => {
|
|
678
|
+
const btn = document.createElement('button');
|
|
679
|
+
btn.type = 'button';
|
|
680
|
+
btn.className = 'swatch';
|
|
681
|
+
btn.style.background = color;
|
|
682
|
+
btn.style.setProperty('--swatch-check-color', luminance(color) > 0.5 ? '#000' : '#fff');
|
|
683
|
+
btn.setAttribute('role', 'radio');
|
|
684
|
+
btn.dataset.color = color;
|
|
685
|
+
btn.setAttribute('aria-label', color);
|
|
686
|
+
btn.addEventListener('click', () => commit(color));
|
|
687
|
+
panel.append(btn);
|
|
688
|
+
return btn;
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
input.before(trigger);
|
|
692
|
+
document.body.append(panel);
|
|
693
|
+
|
|
694
|
+
const syncFace = () => {
|
|
695
|
+
const value = input.value.toUpperCase();
|
|
696
|
+
swatchPreview.style.background = value;
|
|
697
|
+
triggerLabel.textContent = value;
|
|
698
|
+
swatches.forEach((s) => {
|
|
699
|
+
s.setAttribute('aria-checked', String(s.dataset.color.toUpperCase() === value));
|
|
700
|
+
s.tabIndex = s.dataset.color.toUpperCase() === value ? 0 : -1;
|
|
701
|
+
});
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
function commit(color) {
|
|
705
|
+
const changed = input.value.toUpperCase() !== color.toUpperCase();
|
|
706
|
+
input.value = color;
|
|
707
|
+
syncFace();
|
|
708
|
+
panel.hidePopover();
|
|
709
|
+
trigger.focus();
|
|
710
|
+
if (changed) {
|
|
711
|
+
input.dispatchEvent(new CustomEvent('change', {
|
|
712
|
+
bubbles: true,
|
|
713
|
+
detail: { value: input.value },
|
|
714
|
+
}));
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
panel.addEventListener('keydown', (e) => {
|
|
719
|
+
const current = document.activeElement.closest('.swatch');
|
|
720
|
+
if (!current) return;
|
|
721
|
+
const idx = swatches.indexOf(current);
|
|
722
|
+
if (e.key === 'ArrowRight') {
|
|
723
|
+
e.preventDefault();
|
|
724
|
+
(swatches[idx + 1] || swatches[0]).focus();
|
|
725
|
+
} else if (e.key === 'ArrowLeft') {
|
|
726
|
+
e.preventDefault();
|
|
727
|
+
(swatches[idx - 1] || swatches[swatches.length - 1]).focus();
|
|
728
|
+
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
729
|
+
e.preventDefault();
|
|
730
|
+
commit(current.dataset.color);
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
panel.addEventListener('toggle', (e) => {
|
|
735
|
+
const open = e.newState === 'open';
|
|
736
|
+
trigger.setAttribute('aria-expanded', String(open));
|
|
737
|
+
if (open) panel.querySelector('[tabindex="0"]')?.focus();
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
if (!colors.some((c) => c.toUpperCase() === input.value.toUpperCase())) {
|
|
741
|
+
input.value = colors[0];
|
|
742
|
+
}
|
|
743
|
+
syncFace();
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
customElements.define('x-colorswatches', XColorswatches);
|
|
748
|
+
})();
|
|
749
|
+
/* MTUI <x-contextmenu> — tier: Enhanced. Wraps any region; a <template>
|
|
750
|
+
child declares the menu, cloned into a popover panel positioned at the
|
|
751
|
+
pointer (right-click) or touch point (long-press ~500ms).
|
|
752
|
+
Uses popover="manual" with its own light-dismiss: the native "auto"
|
|
753
|
+
popover's built-in light-dismiss reacts to the SAME right-click
|
|
754
|
+
gesture that opens it (contextmenu fires on the button's press on some
|
|
755
|
+
platforms, release on others), so it could close itself the instant
|
|
756
|
+
the mouse button lifts — reading as "the menu only stays up while I
|
|
757
|
+
hold the click down." Registering our own outside-pointerdown listener
|
|
758
|
+
a tick after opening sidesteps that race.
|
|
759
|
+
Basic (no JS): the browser's native context menu appears — content
|
|
760
|
+
itself is unaffected.
|
|
761
|
+
<x-contextmenu>
|
|
762
|
+
<div class="card">Right-click me</div>
|
|
763
|
+
<template>
|
|
764
|
+
<button class="menu-item" data-value="rename">Rename</button>
|
|
765
|
+
<button class="menu-item" data-value="delete" data-danger>Delete</button>
|
|
766
|
+
</template>
|
|
767
|
+
</x-contextmenu>
|
|
768
|
+
Item activation dispatches a bubbling `select` CustomEvent
|
|
769
|
+
(detail: { value, target }) — value is the clicked item's data-value;
|
|
770
|
+
target is the element the gesture opened on. Wrap a whole list in ONE
|
|
771
|
+
x-contextmenu and read detail.target.closest('.item') to know which row
|
|
772
|
+
fired, instead of one menu per row.
|
|
773
|
+
Touch discoverability: a right-click/long-press has no visible affordance,
|
|
774
|
+
so give each row a trigger button carrying data-contextmenu-trigger (e.g. a
|
|
775
|
+
trailing "more" icon) — clicking it opens the same menu, anchored to the
|
|
776
|
+
button, with target set to it. Add aria-haspopup="menu" to that button. */
|
|
777
|
+
(function () {
|
|
778
|
+
'use strict';
|
|
779
|
+
if (customElements.get('x-contextmenu')) return;
|
|
780
|
+
|
|
781
|
+
let seq = 0;
|
|
782
|
+
const LONG_PRESS_MS = 500;
|
|
783
|
+
const MOVE_TOLERANCE = 10;
|
|
784
|
+
|
|
785
|
+
class XContextmenu extends HTMLElement {
|
|
786
|
+
connectedCallback() {
|
|
787
|
+
if (this._built) return;
|
|
788
|
+
const template = this.querySelector('template');
|
|
789
|
+
if (!template) return;
|
|
790
|
+
this._built = true;
|
|
791
|
+
this._build(template);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
_build(template) {
|
|
795
|
+
const panelId = `x-contextmenu-panel-${++seq}`;
|
|
796
|
+
const panel = document.createElement('div');
|
|
797
|
+
panel.className = 'menu context-menu';
|
|
798
|
+
panel.id = panelId;
|
|
799
|
+
panel.setAttribute('popover', 'manual');
|
|
800
|
+
panel.setAttribute('role', 'menu');
|
|
801
|
+
panel.append(template.content.cloneNode(true));
|
|
802
|
+
document.body.append(panel);
|
|
803
|
+
|
|
804
|
+
// The element the gesture landed on, captured at open time (the panel
|
|
805
|
+
// lives in <body>, so the item click's own target is the menu item, not
|
|
806
|
+
// the row). Lets ONE x-contextmenu wrap a whole list: select.detail.target
|
|
807
|
+
// is the origin element — the app calls .closest('.item') to find the row.
|
|
808
|
+
let origin = null;
|
|
809
|
+
|
|
810
|
+
const items = [...panel.querySelectorAll('.menu-item')];
|
|
811
|
+
items.forEach((item) => {
|
|
812
|
+
item.setAttribute('role', 'menuitem');
|
|
813
|
+
item.addEventListener('click', () => {
|
|
814
|
+
const target = origin;
|
|
815
|
+
close();
|
|
816
|
+
this.dispatchEvent(new CustomEvent('select', {
|
|
817
|
+
bubbles: true,
|
|
818
|
+
detail: { value: item.dataset.value, target },
|
|
819
|
+
}));
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
let returnFocus = null;
|
|
824
|
+
let onOutside = null;
|
|
825
|
+
let isOpen = false;
|
|
826
|
+
|
|
827
|
+
function close() {
|
|
828
|
+
if (!isOpen) return;
|
|
829
|
+
isOpen = false;
|
|
830
|
+
panel.hidePopover();
|
|
831
|
+
if (onOutside) {
|
|
832
|
+
document.removeEventListener('pointerdown', onOutside, true);
|
|
833
|
+
onOutside = null;
|
|
834
|
+
}
|
|
835
|
+
returnFocus?.focus();
|
|
836
|
+
returnFocus = null;
|
|
837
|
+
}
|
|
838
|
+
this._teardown = () => { close(); panel.remove(); };
|
|
839
|
+
|
|
840
|
+
const openAt = (x, y, originEl) => {
|
|
841
|
+
if (isOpen) close(); // reposition on re-open; avoids double showPopover
|
|
842
|
+
origin = originEl || null;
|
|
843
|
+
// Keyboard-invoked (Menu key / Shift+F10) contextmenu reports 0/absent
|
|
844
|
+
// coords — anchor to the focused element instead of the corner.
|
|
845
|
+
if (!x && !y && originEl && originEl.getBoundingClientRect) {
|
|
846
|
+
const r = originEl.getBoundingClientRect();
|
|
847
|
+
x = r.left;
|
|
848
|
+
y = r.bottom;
|
|
849
|
+
}
|
|
850
|
+
returnFocus = document.activeElement;
|
|
851
|
+
isOpen = true;
|
|
852
|
+
panel.style.position = 'fixed';
|
|
853
|
+
panel.style.margin = '0';
|
|
854
|
+
panel.showPopover();
|
|
855
|
+
const gap = 4;
|
|
856
|
+
const rect = panel.getBoundingClientRect();
|
|
857
|
+
const left = Math.min(Math.max(gap, x), innerWidth - rect.width - gap);
|
|
858
|
+
const top = Math.min(Math.max(gap, y), innerHeight - rect.height - gap);
|
|
859
|
+
panel.style.left = `${Math.round(left)}px`;
|
|
860
|
+
panel.style.top = `${Math.round(top)}px`;
|
|
861
|
+
items[0]?.focus();
|
|
862
|
+
|
|
863
|
+
onOutside = (e) => {
|
|
864
|
+
if (!panel.contains(e.target)) close();
|
|
865
|
+
};
|
|
866
|
+
// Registered on the next tick — see the file header comment on why
|
|
867
|
+
// this can't be wired synchronously during the opening gesture.
|
|
868
|
+
setTimeout(() => document.addEventListener('pointerdown', onOutside, true), 0);
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
this.addEventListener('contextmenu', (e) => {
|
|
872
|
+
e.preventDefault();
|
|
873
|
+
openAt(e.clientX, e.clientY, e.target);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
// A visible tap affordance: any [data-contextmenu-trigger] inside the
|
|
877
|
+
// region opens the same menu on click (mouse/touch/keyboard), anchored
|
|
878
|
+
// to the button. origin = the trigger, so select.detail.target.closest()
|
|
879
|
+
// still finds the row. Gesture-only menus are undiscoverable on touch.
|
|
880
|
+
this.addEventListener('click', (e) => {
|
|
881
|
+
const trigger = e.target.closest('[data-contextmenu-trigger]');
|
|
882
|
+
if (!trigger || !this.contains(trigger)) return;
|
|
883
|
+
e.preventDefault();
|
|
884
|
+
e.stopPropagation();
|
|
885
|
+
const r = trigger.getBoundingClientRect();
|
|
886
|
+
openAt(r.left, r.bottom, trigger);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
let pressTimer = null;
|
|
890
|
+
let startX = 0;
|
|
891
|
+
let startY = 0;
|
|
892
|
+
let startTarget = null;
|
|
893
|
+
this.addEventListener('touchstart', (e) => {
|
|
894
|
+
const t = e.touches[0];
|
|
895
|
+
startX = t.clientX;
|
|
896
|
+
startY = t.clientY;
|
|
897
|
+
startTarget = e.target;
|
|
898
|
+
pressTimer = setTimeout(() => openAt(startX, startY, startTarget), LONG_PRESS_MS);
|
|
899
|
+
}, { passive: true });
|
|
900
|
+
this.addEventListener('touchmove', (e) => {
|
|
901
|
+
const t = e.touches[0];
|
|
902
|
+
if (Math.abs(t.clientX - startX) > MOVE_TOLERANCE
|
|
903
|
+
|| Math.abs(t.clientY - startY) > MOVE_TOLERANCE) {
|
|
904
|
+
clearTimeout(pressTimer);
|
|
905
|
+
}
|
|
906
|
+
}, { passive: true });
|
|
907
|
+
this.addEventListener('touchend', () => clearTimeout(pressTimer));
|
|
908
|
+
this.addEventListener('touchcancel', () => clearTimeout(pressTimer));
|
|
909
|
+
|
|
910
|
+
panel.addEventListener('keydown', (e) => {
|
|
911
|
+
const idx = items.indexOf(document.activeElement);
|
|
912
|
+
if (e.key === 'ArrowDown') {
|
|
913
|
+
e.preventDefault();
|
|
914
|
+
(items[idx + 1] || items[0])?.focus();
|
|
915
|
+
} else if (e.key === 'ArrowUp') {
|
|
916
|
+
e.preventDefault();
|
|
917
|
+
(items[idx - 1] || items[items.length - 1])?.focus();
|
|
918
|
+
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
919
|
+
e.preventDefault();
|
|
920
|
+
document.activeElement?.click();
|
|
921
|
+
} else if (e.key === 'Escape') {
|
|
922
|
+
e.preventDefault();
|
|
923
|
+
close();
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
disconnectedCallback() {
|
|
929
|
+
// Remove the body-appended panel + any open outside-listener so a
|
|
930
|
+
// removed host doesn't leak (and can rebuild if re-added).
|
|
931
|
+
this._teardown?.();
|
|
932
|
+
this._teardown = null;
|
|
933
|
+
this._built = false;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
customElements.define('x-contextmenu', XContextmenu);
|
|
938
|
+
})();
|
|
939
|
+
/* MTUI <x-datepicker> — tier: Enhanced over Basic native <input type="date">.
|
|
940
|
+
Single date only — no ranges, no times (kept out of v1 scope).
|
|
941
|
+
Basic (no JS): the native date input renders and works normally.
|
|
942
|
+
<x-datepicker>
|
|
943
|
+
<label class="field">
|
|
944
|
+
<span class="t-label">Start date</span>
|
|
945
|
+
<input type="date" min="2026-01-01" max="2026-12-31" value="2026-07-07">
|
|
946
|
+
</label>
|
|
947
|
+
</x-datepicker>
|
|
948
|
+
Dispatches a bubbling `change` CustomEvent (detail: { value }) on the
|
|
949
|
+
underlying input, value in the native YYYY-MM-DD format.
|
|
950
|
+
Tap the header to zoom out: day grid -> month grid -> scrollable year
|
|
951
|
+
list -> back to day grid. Prev/next step by month/year/scroll-page
|
|
952
|
+
depending on the current level — faster than repeated month clicks
|
|
953
|
+
when jumping further than a month or two. */
|
|
954
|
+
(function () {
|
|
955
|
+
'use strict';
|
|
956
|
+
if (customElements.get('x-datepicker')) return;
|
|
957
|
+
|
|
958
|
+
let seq = 0;
|
|
959
|
+
const iso = (d) => {
|
|
960
|
+
const y = d.getFullYear();
|
|
961
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
962
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
963
|
+
return `${y}-${m}-${day}`;
|
|
964
|
+
};
|
|
965
|
+
const parseIso = (s) => {
|
|
966
|
+
if (!s) return null;
|
|
967
|
+
const [y, m, d] = s.split('-').map(Number);
|
|
968
|
+
return new Date(y, m - 1, d);
|
|
969
|
+
};
|
|
970
|
+
const sameDay = (a, b) => a && b
|
|
971
|
+
&& a.getFullYear() === b.getFullYear()
|
|
972
|
+
&& a.getMonth() === b.getMonth()
|
|
973
|
+
&& a.getDate() === b.getDate();
|
|
974
|
+
|
|
975
|
+
class XDatepicker extends HTMLElement {
|
|
976
|
+
connectedCallback() {
|
|
977
|
+
if (this._built) return;
|
|
978
|
+
const input = this.querySelector('input[type="date"]');
|
|
979
|
+
if (!input) return;
|
|
980
|
+
this._built = true;
|
|
981
|
+
this._build(input);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
_build(input) {
|
|
985
|
+
const panelId = `x-datepicker-panel-${++seq}`;
|
|
986
|
+
const labelText = this.querySelector('.t-label')?.textContent.trim();
|
|
987
|
+
const lang = document.documentElement.lang || undefined;
|
|
988
|
+
const monthFmt = new Intl.DateTimeFormat(lang, { month: 'long', year: 'numeric' });
|
|
989
|
+
const monthShortFmt = new Intl.DateTimeFormat(lang, { month: 'short' });
|
|
990
|
+
const weekdayFmt = new Intl.DateTimeFormat(lang, { weekday: 'short' });
|
|
991
|
+
const min = parseIso(input.min);
|
|
992
|
+
const max = parseIso(input.max);
|
|
993
|
+
|
|
994
|
+
input.setAttribute('tabindex', '-1');
|
|
995
|
+
input.setAttribute('aria-hidden', 'true');
|
|
996
|
+
input.style.position = 'absolute';
|
|
997
|
+
input.style.opacity = '0';
|
|
998
|
+
input.style.pointerEvents = 'none';
|
|
999
|
+
input.style.inlineSize = '1px';
|
|
1000
|
+
input.style.blockSize = '1px';
|
|
1001
|
+
|
|
1002
|
+
const trigger = document.createElement('button');
|
|
1003
|
+
trigger.type = 'button';
|
|
1004
|
+
trigger.className = 'select-trigger';
|
|
1005
|
+
trigger.setAttribute('aria-haspopup', 'dialog');
|
|
1006
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
1007
|
+
trigger.setAttribute('popovertarget', panelId);
|
|
1008
|
+
if (labelText) trigger.setAttribute('aria-label', labelText);
|
|
1009
|
+
const triggerLabel = document.createElement('span');
|
|
1010
|
+
trigger.append(triggerLabel);
|
|
1011
|
+
|
|
1012
|
+
const panel = document.createElement('div');
|
|
1013
|
+
panel.className = 'datepicker-panel menu';
|
|
1014
|
+
panel.id = panelId;
|
|
1015
|
+
panel.setAttribute('popover', '');
|
|
1016
|
+
if (labelText) panel.setAttribute('aria-label', labelText);
|
|
1017
|
+
|
|
1018
|
+
const header = document.createElement('div');
|
|
1019
|
+
header.className = 'datepicker-header';
|
|
1020
|
+
const prev = document.createElement('button');
|
|
1021
|
+
prev.type = 'button';
|
|
1022
|
+
prev.className = 'datepicker-nav';
|
|
1023
|
+
prev.dataset.dir = 'prev';
|
|
1024
|
+
const headerLabel = document.createElement('button');
|
|
1025
|
+
headerLabel.type = 'button';
|
|
1026
|
+
headerLabel.className = 'datepicker-header-label';
|
|
1027
|
+
const next = document.createElement('button');
|
|
1028
|
+
next.type = 'button';
|
|
1029
|
+
next.className = 'datepicker-nav';
|
|
1030
|
+
next.dataset.dir = 'next';
|
|
1031
|
+
header.append(prev, headerLabel, next);
|
|
1032
|
+
|
|
1033
|
+
const weekdays = document.createElement('div');
|
|
1034
|
+
weekdays.className = 'datepicker-weekdays';
|
|
1035
|
+
const weekStart = new Date(2024, 0, 7); // a Sunday
|
|
1036
|
+
for (let i = 0; i < 7; i++) {
|
|
1037
|
+
const d = document.createElement('span');
|
|
1038
|
+
const day = new Date(weekStart);
|
|
1039
|
+
day.setDate(day.getDate() + i);
|
|
1040
|
+
d.textContent = weekdayFmt.format(day);
|
|
1041
|
+
weekdays.append(d);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const grid = document.createElement('div');
|
|
1045
|
+
grid.className = 'datepicker-grid';
|
|
1046
|
+
grid.setAttribute('role', 'grid');
|
|
1047
|
+
|
|
1048
|
+
const footer = document.createElement('div');
|
|
1049
|
+
footer.className = 'datepicker-footer';
|
|
1050
|
+
const todayBtn = document.createElement('button');
|
|
1051
|
+
todayBtn.type = 'button';
|
|
1052
|
+
todayBtn.className = 'btn';
|
|
1053
|
+
todayBtn.setAttribute('data-variant', 'ghost');
|
|
1054
|
+
todayBtn.textContent = 'Today';
|
|
1055
|
+
footer.append(todayBtn);
|
|
1056
|
+
|
|
1057
|
+
panel.append(header, weekdays, grid, footer);
|
|
1058
|
+
input.before(trigger);
|
|
1059
|
+
document.body.append(panel);
|
|
1060
|
+
|
|
1061
|
+
let selected = parseIso(input.value);
|
|
1062
|
+
let viewYear = (selected || new Date()).getFullYear();
|
|
1063
|
+
let viewMonth = (selected || new Date()).getMonth();
|
|
1064
|
+
let viewMode = 'days'; // 'days' | 'months'
|
|
1065
|
+
|
|
1066
|
+
const syncTrigger = () => {
|
|
1067
|
+
triggerLabel.textContent = selected
|
|
1068
|
+
? new Intl.DateTimeFormat(lang, { dateStyle: 'medium' }).format(selected)
|
|
1069
|
+
: 'Select date';
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
function commit(date) {
|
|
1073
|
+
selected = date;
|
|
1074
|
+
input.value = iso(date);
|
|
1075
|
+
syncTrigger();
|
|
1076
|
+
panel.hidePopover();
|
|
1077
|
+
trigger.focus();
|
|
1078
|
+
input.dispatchEvent(new CustomEvent('change', {
|
|
1079
|
+
bubbles: true,
|
|
1080
|
+
detail: { value: input.value },
|
|
1081
|
+
}));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function render() {
|
|
1085
|
+
grid.innerHTML = '';
|
|
1086
|
+
if (viewMode === 'months') {
|
|
1087
|
+
renderMonths();
|
|
1088
|
+
} else if (viewMode === 'years') {
|
|
1089
|
+
renderYears();
|
|
1090
|
+
} else {
|
|
1091
|
+
renderDays();
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function renderDays() {
|
|
1096
|
+
weekdays.hidden = false;
|
|
1097
|
+
grid.setAttribute('data-view', 'days');
|
|
1098
|
+
headerLabel.textContent = monthFmt.format(new Date(viewYear, viewMonth, 1));
|
|
1099
|
+
prev.setAttribute('aria-label', 'Previous month');
|
|
1100
|
+
next.setAttribute('aria-label', 'Next month');
|
|
1101
|
+
|
|
1102
|
+
const firstOfMonth = new Date(viewYear, viewMonth, 1);
|
|
1103
|
+
const startOffset = firstOfMonth.getDay(); // Sunday-start grid (v1)
|
|
1104
|
+
const gridStart = new Date(viewYear, viewMonth, 1 - startOffset);
|
|
1105
|
+
const today = new Date();
|
|
1106
|
+
|
|
1107
|
+
for (let i = 0; i < 42; i++) {
|
|
1108
|
+
const day = new Date(gridStart);
|
|
1109
|
+
day.setDate(day.getDate() + i);
|
|
1110
|
+
const btn = document.createElement('button');
|
|
1111
|
+
btn.type = 'button';
|
|
1112
|
+
btn.className = 'datepicker-day';
|
|
1113
|
+
btn.setAttribute('role', 'gridcell');
|
|
1114
|
+
btn.textContent = String(day.getDate());
|
|
1115
|
+
btn.dataset.iso = iso(day);
|
|
1116
|
+
if (day.getMonth() !== viewMonth) btn.dataset.outside = '';
|
|
1117
|
+
if (sameDay(day, today)) btn.setAttribute('data-today', '');
|
|
1118
|
+
if (sameDay(day, selected)) btn.setAttribute('aria-selected', 'true');
|
|
1119
|
+
if ((min && day < min) || (max && day > max)) btn.disabled = true;
|
|
1120
|
+
btn.tabIndex = sameDay(day, selected) || (!selected && sameDay(day, today)) ? 0 : -1;
|
|
1121
|
+
btn.addEventListener('click', () => commit(new Date(day)));
|
|
1122
|
+
grid.append(btn);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function renderMonths() {
|
|
1127
|
+
weekdays.hidden = true;
|
|
1128
|
+
grid.setAttribute('data-view', 'months');
|
|
1129
|
+
headerLabel.textContent = String(viewYear);
|
|
1130
|
+
prev.setAttribute('aria-label', 'Previous year');
|
|
1131
|
+
next.setAttribute('aria-label', 'Next year');
|
|
1132
|
+
|
|
1133
|
+
for (let m = 0; m < 12; m++) {
|
|
1134
|
+
const btn = document.createElement('button');
|
|
1135
|
+
btn.type = 'button';
|
|
1136
|
+
btn.className = 'datepicker-day datepicker-month';
|
|
1137
|
+
btn.setAttribute('role', 'gridcell');
|
|
1138
|
+
btn.textContent = monthShortFmt.format(new Date(viewYear, m, 1));
|
|
1139
|
+
if (m === viewMonth) btn.setAttribute('aria-selected', 'true');
|
|
1140
|
+
btn.tabIndex = m === viewMonth ? 0 : -1;
|
|
1141
|
+
btn.addEventListener('click', () => {
|
|
1142
|
+
viewMonth = m;
|
|
1143
|
+
viewMode = 'days';
|
|
1144
|
+
render();
|
|
1145
|
+
grid.querySelector('[tabindex="0"]')?.focus();
|
|
1146
|
+
});
|
|
1147
|
+
grid.append(btn);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function renderYears() {
|
|
1152
|
+
weekdays.hidden = true;
|
|
1153
|
+
grid.setAttribute('data-view', 'years');
|
|
1154
|
+
headerLabel.textContent = 'Select year';
|
|
1155
|
+
prev.setAttribute('aria-label', 'Scroll up');
|
|
1156
|
+
next.setAttribute('aria-label', 'Scroll down');
|
|
1157
|
+
|
|
1158
|
+
const rangeStart = min ? min.getFullYear() : viewYear - 100;
|
|
1159
|
+
const rangeEnd = max ? max.getFullYear() : viewYear + 100;
|
|
1160
|
+
for (let y = rangeStart; y <= rangeEnd; y++) {
|
|
1161
|
+
const btn = document.createElement('button');
|
|
1162
|
+
btn.type = 'button';
|
|
1163
|
+
btn.className = 'datepicker-day datepicker-year';
|
|
1164
|
+
btn.setAttribute('role', 'gridcell');
|
|
1165
|
+
btn.textContent = String(y);
|
|
1166
|
+
if (y === viewYear) btn.setAttribute('aria-selected', 'true');
|
|
1167
|
+
btn.tabIndex = y === viewYear ? 0 : -1;
|
|
1168
|
+
btn.addEventListener('click', () => {
|
|
1169
|
+
viewYear = y;
|
|
1170
|
+
viewMode = 'months';
|
|
1171
|
+
render();
|
|
1172
|
+
grid.querySelector('[tabindex="0"]')?.focus();
|
|
1173
|
+
});
|
|
1174
|
+
grid.append(btn);
|
|
1175
|
+
}
|
|
1176
|
+
// bring the current year into view without an initial janky jump —
|
|
1177
|
+
// reduced motion collapses --t-lift to 0ms, so this stays instant.
|
|
1178
|
+
const smooth = (parseFloat(getComputedStyle(document.documentElement)
|
|
1179
|
+
.getPropertyValue('--t-lift')) || 0) > 0;
|
|
1180
|
+
requestAnimationFrame(() => {
|
|
1181
|
+
grid.querySelector('[aria-selected="true"]')
|
|
1182
|
+
?.scrollIntoView({ block: 'center', behavior: smooth ? 'smooth' : 'auto' });
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
headerLabel.addEventListener('click', () => {
|
|
1187
|
+
viewMode = viewMode === 'days' ? 'months' : viewMode === 'months' ? 'years' : 'days';
|
|
1188
|
+
render();
|
|
1189
|
+
grid.querySelector('[tabindex="0"]')?.focus();
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
prev.addEventListener('click', () => {
|
|
1193
|
+
if (viewMode === 'years') {
|
|
1194
|
+
grid.scrollBy({ top: -grid.clientHeight * 0.9 });
|
|
1195
|
+
} else if (viewMode === 'months') {
|
|
1196
|
+
viewYear -= 1;
|
|
1197
|
+
render();
|
|
1198
|
+
} else {
|
|
1199
|
+
viewMonth -= 1;
|
|
1200
|
+
if (viewMonth < 0) { viewMonth = 11; viewYear -= 1; }
|
|
1201
|
+
render();
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
next.addEventListener('click', () => {
|
|
1205
|
+
if (viewMode === 'years') {
|
|
1206
|
+
grid.scrollBy({ top: grid.clientHeight * 0.9 });
|
|
1207
|
+
} else if (viewMode === 'months') {
|
|
1208
|
+
viewYear += 1;
|
|
1209
|
+
render();
|
|
1210
|
+
} else {
|
|
1211
|
+
viewMonth += 1;
|
|
1212
|
+
if (viewMonth > 11) { viewMonth = 0; viewYear += 1; }
|
|
1213
|
+
render();
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
todayBtn.addEventListener('click', () => {
|
|
1217
|
+
const t = new Date();
|
|
1218
|
+
viewYear = t.getFullYear();
|
|
1219
|
+
viewMonth = t.getMonth();
|
|
1220
|
+
viewMode = 'days';
|
|
1221
|
+
render();
|
|
1222
|
+
commit(t);
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
grid.addEventListener('keydown', (e) => {
|
|
1226
|
+
const current = document.activeElement.closest('.datepicker-day');
|
|
1227
|
+
if (!current) return;
|
|
1228
|
+
const cells = [...grid.children];
|
|
1229
|
+
const idx = cells.indexOf(current);
|
|
1230
|
+
const rowSpan = viewMode === 'months' ? 4 : viewMode === 'years' ? 1 : 7;
|
|
1231
|
+
const moves = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: rowSpan, ArrowUp: -rowSpan };
|
|
1232
|
+
if (e.key in moves) {
|
|
1233
|
+
e.preventDefault();
|
|
1234
|
+
const target = cells[idx + moves[e.key]];
|
|
1235
|
+
if (target) { target.tabIndex = 0; current.tabIndex = -1; target.focus(); }
|
|
1236
|
+
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
1237
|
+
e.preventDefault();
|
|
1238
|
+
if (!current.disabled) current.click();
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
panel.addEventListener('toggle', (e) => {
|
|
1243
|
+
const open = e.newState === 'open';
|
|
1244
|
+
trigger.setAttribute('aria-expanded', String(open));
|
|
1245
|
+
if (open) {
|
|
1246
|
+
viewYear = (selected || new Date()).getFullYear();
|
|
1247
|
+
viewMonth = (selected || new Date()).getMonth();
|
|
1248
|
+
viewMode = 'days';
|
|
1249
|
+
render();
|
|
1250
|
+
grid.querySelector('[tabindex="0"]')?.focus();
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
syncTrigger();
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
customElements.define('x-datepicker', XDatepicker);
|
|
1259
|
+
})();
|
|
1260
|
+
/* MTUI <x-select> — tier: Enhanced over Basic .select (native <select>).
|
|
1261
|
+
Wraps existing Basic field-select markup, hides the native select from
|
|
1262
|
+
sight (kept as the value holder + no-JS fallback + form participant),
|
|
1263
|
+
and draws an MTUI trigger + popover-ring listbox panel in its place.
|
|
1264
|
+
Basic (no JS): the native select renders and works normally.
|
|
1265
|
+
<x-select>
|
|
1266
|
+
<label class="field">
|
|
1267
|
+
<span class="t-label">Role</span>
|
|
1268
|
+
<span class="select"><select>
|
|
1269
|
+
<option value="designer">Designer</option>
|
|
1270
|
+
<option value="engineer" selected>Engineer</option>
|
|
1271
|
+
</select></span>
|
|
1272
|
+
</label>
|
|
1273
|
+
</x-select>
|
|
1274
|
+
Dispatches a bubbling `change` CustomEvent (detail: { value }) on the
|
|
1275
|
+
underlying <select> whenever the value changes via the panel. */
|
|
1276
|
+
(function () {
|
|
1277
|
+
'use strict';
|
|
1278
|
+
if (customElements.get('x-select')) return;
|
|
1279
|
+
|
|
1280
|
+
let seq = 0;
|
|
1281
|
+
|
|
1282
|
+
class XSelect extends HTMLElement {
|
|
1283
|
+
connectedCallback() {
|
|
1284
|
+
if (this._built) return;
|
|
1285
|
+
const select = this.querySelector('select');
|
|
1286
|
+
if (!select) return; // nothing to enhance
|
|
1287
|
+
this._built = true;
|
|
1288
|
+
this._build(select);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
_build(select) {
|
|
1292
|
+
const wrap = select.closest('.select') || select;
|
|
1293
|
+
const panelId = `x-select-panel-${++seq}`;
|
|
1294
|
+
const labelText = this.querySelector('.t-label')?.textContent.trim();
|
|
1295
|
+
|
|
1296
|
+
wrap.style.display = 'none';
|
|
1297
|
+
select.setAttribute('tabindex', '-1');
|
|
1298
|
+
select.setAttribute('aria-hidden', 'true');
|
|
1299
|
+
|
|
1300
|
+
const trigger = document.createElement('button');
|
|
1301
|
+
trigger.type = 'button';
|
|
1302
|
+
trigger.className = 'select-trigger';
|
|
1303
|
+
trigger.setAttribute('aria-haspopup', 'listbox');
|
|
1304
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
1305
|
+
trigger.setAttribute('popovertarget', panelId);
|
|
1306
|
+
if (labelText) trigger.setAttribute('aria-label', labelText);
|
|
1307
|
+
if (select.disabled) trigger.disabled = true;
|
|
1308
|
+
const label = document.createElement('span');
|
|
1309
|
+
trigger.append(label);
|
|
1310
|
+
|
|
1311
|
+
const panel = document.createElement('div');
|
|
1312
|
+
panel.className = 'select-panel menu';
|
|
1313
|
+
panel.id = panelId;
|
|
1314
|
+
panel.setAttribute('popover', '');
|
|
1315
|
+
panel.setAttribute('role', 'listbox');
|
|
1316
|
+
if (labelText) panel.setAttribute('aria-label', labelText);
|
|
1317
|
+
|
|
1318
|
+
const options = [...select.options].map((opt, i) => {
|
|
1319
|
+
const row = document.createElement('button');
|
|
1320
|
+
row.type = 'button';
|
|
1321
|
+
row.className = 'menu-item select-option';
|
|
1322
|
+
row.textContent = opt.textContent;
|
|
1323
|
+
row.setAttribute('role', 'option');
|
|
1324
|
+
row.dataset.index = String(i);
|
|
1325
|
+
if (opt.disabled) {
|
|
1326
|
+
row.disabled = true;
|
|
1327
|
+
row.setAttribute('aria-disabled', 'true');
|
|
1328
|
+
}
|
|
1329
|
+
row.addEventListener('click', () => commit(i));
|
|
1330
|
+
panel.append(row);
|
|
1331
|
+
return row;
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
wrap.before(trigger);
|
|
1335
|
+
document.body.append(panel); // top layer via popover; anchored by js/menu.js
|
|
1336
|
+
|
|
1337
|
+
const syncFace = () => {
|
|
1338
|
+
label.textContent = select.options[select.selectedIndex]?.textContent || '';
|
|
1339
|
+
options.forEach((row, i) => {
|
|
1340
|
+
const selected = i === select.selectedIndex;
|
|
1341
|
+
row.setAttribute('aria-selected', String(selected));
|
|
1342
|
+
});
|
|
1343
|
+
};
|
|
1344
|
+
syncFace();
|
|
1345
|
+
|
|
1346
|
+
function commit(index) {
|
|
1347
|
+
const changed = select.selectedIndex !== index;
|
|
1348
|
+
select.selectedIndex = index;
|
|
1349
|
+
syncFace();
|
|
1350
|
+
panel.hidePopover();
|
|
1351
|
+
trigger.focus();
|
|
1352
|
+
if (changed) {
|
|
1353
|
+
select.dispatchEvent(new CustomEvent('change', {
|
|
1354
|
+
bubbles: true,
|
|
1355
|
+
detail: { value: select.value },
|
|
1356
|
+
}));
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
panel.addEventListener('keydown', (e) => {
|
|
1361
|
+
const enabled = options.filter((r) => !r.disabled);
|
|
1362
|
+
const current = document.activeElement.closest('.select-option');
|
|
1363
|
+
const idx = enabled.indexOf(current);
|
|
1364
|
+
if (e.key === 'ArrowDown') {
|
|
1365
|
+
e.preventDefault();
|
|
1366
|
+
(enabled[idx + 1] || enabled[0])?.focus();
|
|
1367
|
+
} else if (e.key === 'ArrowUp') {
|
|
1368
|
+
e.preventDefault();
|
|
1369
|
+
(enabled[idx - 1] || enabled[enabled.length - 1])?.focus();
|
|
1370
|
+
} else if (e.key === 'Home') {
|
|
1371
|
+
e.preventDefault();
|
|
1372
|
+
enabled[0]?.focus();
|
|
1373
|
+
} else if (e.key === 'End') {
|
|
1374
|
+
e.preventDefault();
|
|
1375
|
+
enabled[enabled.length - 1]?.focus();
|
|
1376
|
+
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
1377
|
+
e.preventDefault();
|
|
1378
|
+
if (current) commit(Number(current.dataset.index));
|
|
1379
|
+
} else if (e.key.length === 1) {
|
|
1380
|
+
const match = enabled.find((r) =>
|
|
1381
|
+
r.textContent.trim().toLowerCase().startsWith(e.key.toLowerCase()));
|
|
1382
|
+
match?.focus();
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
panel.addEventListener('toggle', (e) => {
|
|
1387
|
+
const open = e.newState === 'open';
|
|
1388
|
+
trigger.setAttribute('aria-expanded', String(open));
|
|
1389
|
+
if (open) {
|
|
1390
|
+
panel.style.minWidth = `${trigger.getBoundingClientRect().width}px`;
|
|
1391
|
+
(options[select.selectedIndex] || options[0])?.focus();
|
|
1392
|
+
}
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
customElements.define('x-select', XSelect);
|
|
1398
|
+
})();
|
|
1399
|
+
/* MTUI <x-tabs> — tier: Enhanced over Basic segmented.
|
|
1400
|
+
Upgrades a .segmented radio group + [data-panel] siblings in place:
|
|
1401
|
+
only the panel matching the checked radio's value shows.
|
|
1402
|
+
Basic (JS off): all panels render, radios still select — functional.
|
|
1403
|
+
Keyboard: native radio arrow-key behavior is the tab navigation.
|
|
1404
|
+
Events: bubbling `change` CustomEvent with detail { value } (the native
|
|
1405
|
+
radio change also bubbles through; listeners can read detail?.value ??
|
|
1406
|
+
el.value). */
|
|
1407
|
+
(function () {
|
|
1408
|
+
'use strict';
|
|
1409
|
+
if (customElements.get('x-tabs')) return;
|
|
1410
|
+
|
|
1411
|
+
class XTabs extends HTMLElement {
|
|
1412
|
+
connectedCallback() {
|
|
1413
|
+
this.addEventListener('change', this);
|
|
1414
|
+
this._sync(false);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
disconnectedCallback() {
|
|
1418
|
+
this.removeEventListener('change', this);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
get value() {
|
|
1422
|
+
const checked = this.querySelector('.segmented input:checked');
|
|
1423
|
+
return checked ? checked.value : null;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
handleEvent(e) {
|
|
1427
|
+
if (e.target === this || !e.target.matches('.segmented input')) return;
|
|
1428
|
+
this._sync(true);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
_sync(emit) {
|
|
1432
|
+
const value = this.value;
|
|
1433
|
+
this.querySelectorAll('[data-panel]').forEach((panel) => {
|
|
1434
|
+
panel.hidden = panel.dataset.panel !== value;
|
|
1435
|
+
});
|
|
1436
|
+
if (emit) {
|
|
1437
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
1438
|
+
bubbles: true,
|
|
1439
|
+
detail: { value },
|
|
1440
|
+
}));
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
customElements.define('x-tabs', XTabs);
|
|
1446
|
+
})();
|
|
1447
|
+
/* MTUI <x-theme> — tier: JS-only (an in-app theme switcher inherently needs
|
|
1448
|
+
JS; without it the element is empty and the app keeps its current/persisted
|
|
1449
|
+
theme, still functional and on-language).
|
|
1450
|
+
|
|
1451
|
+
The Telegram "Appearance" panel, MTUI-native: flip theme, tap an accent,
|
|
1452
|
+
set corners + density. Writes ONLY to the <html> knobs (root-only rule,
|
|
1453
|
+
DESIGN.md) and persists each choice to localStorage so it survives reloads.
|
|
1454
|
+
Built from canonical .segmented groups + a row of accent dots; accent
|
|
1455
|
+
colors are sampled live from tokens.css (never hard-coded).
|
|
1456
|
+
|
|
1457
|
+
Canonical markup — just the empty element; it renders its own controls:
|
|
1458
|
+
<x-theme></x-theme>
|
|
1459
|
+
|
|
1460
|
+
Emits a bubbling CustomEvent on every change:
|
|
1461
|
+
'themechange', detail: { key, value } // key: theme|accent|radius|density|tint
|
|
1462
|
+
|
|
1463
|
+
localStorage keys: mtui-theme, mtui-accent, mtui-radius, mtui-density, mtui-tint.
|
|
1464
|
+
Precedence on load: a stored value (only if it's a valid option) > current
|
|
1465
|
+
<html> attribute > default. */
|
|
1466
|
+
(function () {
|
|
1467
|
+
'use strict';
|
|
1468
|
+
|
|
1469
|
+
const KNOBS = [
|
|
1470
|
+
{ key: 'theme', attr: 'data-theme', label: 'Appearance',
|
|
1471
|
+
opts: [['light', 'Light'], ['dark', 'Dark']] },
|
|
1472
|
+
{ key: 'accent', attr: 'data-accent', label: 'Accent', swatch: true,
|
|
1473
|
+
opts: [['clay', 'Clay'], ['cobalt', 'Cobalt'], ['fern', 'Fern'], ['plum', 'Plum']] },
|
|
1474
|
+
{ key: 'radius', attr: 'data-radius', label: 'Corners',
|
|
1475
|
+
opts: [['round', 'Round'], ['soft', 'Soft'], ['sharp', 'Sharp']] },
|
|
1476
|
+
{ key: 'density', attr: 'data-density', label: 'Density',
|
|
1477
|
+
opts: [['comfortable', 'Comfortable'], ['compact', 'Compact']] },
|
|
1478
|
+
{ key: 'tint', attr: 'data-tint', label: 'Surface tint',
|
|
1479
|
+
opts: [['accent', 'Accent'], ['warm', 'Warm']] },
|
|
1480
|
+
];
|
|
1481
|
+
const byKey = (k) => KNOBS.find((x) => x.key === k);
|
|
1482
|
+
|
|
1483
|
+
const store = {
|
|
1484
|
+
get(key) { try { return localStorage.getItem('mtui-' + key); } catch (e) { return null; } },
|
|
1485
|
+
set(key, val) { try { localStorage.setItem('mtui-' + key, val); } catch (e) { /* storage off */ } },
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
// Read each accent's solid --accent from the stylesheet by briefly swapping
|
|
1489
|
+
// the root knob (root-only; all swaps + restore happen in one sync turn, so
|
|
1490
|
+
// nothing paints in between — no flash). Never invents a hex value.
|
|
1491
|
+
function sampleAccents(root, values) {
|
|
1492
|
+
const prev = root.getAttribute('data-accent');
|
|
1493
|
+
const cs = getComputedStyle(root);
|
|
1494
|
+
const out = {};
|
|
1495
|
+
try {
|
|
1496
|
+
values.forEach((v) => {
|
|
1497
|
+
root.setAttribute('data-accent', v);
|
|
1498
|
+
out[v] = cs.getPropertyValue('--accent').trim();
|
|
1499
|
+
});
|
|
1500
|
+
} finally {
|
|
1501
|
+
// Always restore, even if reading a value throws — never strand the
|
|
1502
|
+
// root on a sampled accent.
|
|
1503
|
+
if (prev === null) root.removeAttribute('data-accent');
|
|
1504
|
+
else root.setAttribute('data-accent', prev);
|
|
1505
|
+
}
|
|
1506
|
+
return out;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
let uid = 0;
|
|
1510
|
+
|
|
1511
|
+
class ThemeControl extends HTMLElement {
|
|
1512
|
+
connectedCallback() {
|
|
1513
|
+
if (this._built) return; // guard re-entry on move/reparent
|
|
1514
|
+
this._built = true;
|
|
1515
|
+
const root = document.documentElement;
|
|
1516
|
+
const id = ++uid;
|
|
1517
|
+
|
|
1518
|
+
const accentColors = sampleAccents(root, byKey('accent').opts.map((o) => o[0]));
|
|
1519
|
+
|
|
1520
|
+
// Resolve + apply each knob (stored pref wins) before reflecting state.
|
|
1521
|
+
const current = {};
|
|
1522
|
+
KNOBS.forEach((k) => {
|
|
1523
|
+
// Prefer a stored pref only if it's a real option (guards corrupt/stale
|
|
1524
|
+
// localStorage); otherwise keep the current attribute — which may be a
|
|
1525
|
+
// legit non-preset like data-accent="custom" from the generator/Telegram
|
|
1526
|
+
// — and never clobber it with the default.
|
|
1527
|
+
const stored = store.get(k.key);
|
|
1528
|
+
const valid = stored && k.opts.some(([v]) => v === stored);
|
|
1529
|
+
const val = (valid ? stored : root.getAttribute(k.attr)) || k.opts[0][0];
|
|
1530
|
+
root.setAttribute(k.attr, val);
|
|
1531
|
+
current[k.key] = val;
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
const panel = document.createElement('div');
|
|
1535
|
+
panel.className = 'theme-panel';
|
|
1536
|
+
|
|
1537
|
+
KNOBS.forEach((k) => {
|
|
1538
|
+
const group = document.createElement('div');
|
|
1539
|
+
group.className = 'theme-group';
|
|
1540
|
+
|
|
1541
|
+
const label = document.createElement('span');
|
|
1542
|
+
label.className = 't-label';
|
|
1543
|
+
label.textContent = k.label;
|
|
1544
|
+
group.appendChild(label);
|
|
1545
|
+
|
|
1546
|
+
const name = `mtui-${k.key}-${id}`;
|
|
1547
|
+
const container = document.createElement(k.swatch ? 'div' : 'fieldset');
|
|
1548
|
+
container.dataset.knob = k.key;
|
|
1549
|
+
container.className = k.swatch ? 'theme-accents' : 'segmented';
|
|
1550
|
+
if (k.swatch) {
|
|
1551
|
+
container.setAttribute('role', 'radiogroup');
|
|
1552
|
+
container.setAttribute('aria-label', k.label);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
k.opts.forEach(([value, text]) => {
|
|
1556
|
+
const optLabel = document.createElement('label');
|
|
1557
|
+
optLabel.className = k.swatch ? 'theme-accent' : '';
|
|
1558
|
+
if (k.swatch) optLabel.style.setProperty('--dot', accentColors[value] || 'var(--accent)');
|
|
1559
|
+
|
|
1560
|
+
const input = document.createElement('input');
|
|
1561
|
+
input.type = 'radio';
|
|
1562
|
+
input.name = name;
|
|
1563
|
+
input.value = value;
|
|
1564
|
+
input.checked = current[k.key] === value;
|
|
1565
|
+
if (k.swatch) input.setAttribute('aria-label', text);
|
|
1566
|
+
optLabel.appendChild(input);
|
|
1567
|
+
|
|
1568
|
+
const face = document.createElement('span');
|
|
1569
|
+
face.className = k.swatch ? 'theme-accent-dot' : '';
|
|
1570
|
+
if (!k.swatch) face.textContent = text;
|
|
1571
|
+
optLabel.appendChild(face);
|
|
1572
|
+
|
|
1573
|
+
container.appendChild(optLabel);
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
group.appendChild(container);
|
|
1577
|
+
panel.appendChild(group);
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
this.replaceChildren(panel);
|
|
1581
|
+
|
|
1582
|
+
this.addEventListener('change', (e) => {
|
|
1583
|
+
const input = e.target.closest('input[type="radio"]');
|
|
1584
|
+
if (!input) return;
|
|
1585
|
+
const holder = input.closest('[data-knob]');
|
|
1586
|
+
if (!holder) return;
|
|
1587
|
+
const key = holder.dataset.knob;
|
|
1588
|
+
const value = input.value;
|
|
1589
|
+
document.documentElement.setAttribute(byKey(key).attr, value);
|
|
1590
|
+
store.set(key, value);
|
|
1591
|
+
this.dispatchEvent(new CustomEvent('themechange', {
|
|
1592
|
+
bubbles: true,
|
|
1593
|
+
detail: { key, value },
|
|
1594
|
+
}));
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
if (!customElements.get('x-theme')) customElements.define('x-theme', ThemeControl);
|
|
1600
|
+
})();
|
|
1601
|
+
/* MTUI <x-toast> — tier: JS-only.
|
|
1602
|
+
Toast stack: inverse pills dropping from top center, max 3, slide-up
|
|
1603
|
+
exit, pause on hover/focus. Toasts never carry the only copy of
|
|
1604
|
+
critical information (there is no no-JS fallback).
|
|
1605
|
+
API: mtui.toast('Changes saved')
|
|
1606
|
+
mtui.toast('Message deleted', {
|
|
1607
|
+
action: { label: 'Undo', onClick: fn },
|
|
1608
|
+
duration: 4000, // ms visible; default 4000
|
|
1609
|
+
kind: 'success', // opt-in feedback cue: tick|success|error (js/feedback.js)
|
|
1610
|
+
}) → dismiss()
|
|
1611
|
+
The <x-toast> mount is created on first use (or place one in the page). */
|
|
1612
|
+
(function () {
|
|
1613
|
+
'use strict';
|
|
1614
|
+
if (customElements.get('x-toast')) return;
|
|
1615
|
+
|
|
1616
|
+
function cssVar(name) {
|
|
1617
|
+
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/* Removing a toast from the flex stack instantly snaps whatever was
|
|
1621
|
+
below it up into the gap — CSS has no built-in transition for a
|
|
1622
|
+
sibling's layout position changing. FLIP it: record where every
|
|
1623
|
+
remaining toast sits before removal, let the browser reflow, then
|
|
1624
|
+
play each one in from its old position to its new one. Skipped
|
|
1625
|
+
entirely under reduced motion (--t-knob collapses to 0ms). */
|
|
1626
|
+
function removeAndSettle(root, toast) {
|
|
1627
|
+
const duration = parseFloat(cssVar('--t-knob')) || 0;
|
|
1628
|
+
const siblings = [...root.querySelectorAll('.toast')].filter((t) => t !== toast);
|
|
1629
|
+
const before = siblings.map((el) => el.getBoundingClientRect().top);
|
|
1630
|
+
|
|
1631
|
+
toast.remove();
|
|
1632
|
+
if (duration === 0) return;
|
|
1633
|
+
|
|
1634
|
+
const easing = cssVar('--spring') || 'ease';
|
|
1635
|
+
siblings.forEach((el, i) => {
|
|
1636
|
+
const dy = before[i] - el.getBoundingClientRect().top;
|
|
1637
|
+
if (!dy) return;
|
|
1638
|
+
el.style.transition = 'none';
|
|
1639
|
+
el.style.transform = `translateY(${dy}px)`;
|
|
1640
|
+
el.getBoundingClientRect(); // force the browser to commit the jump before transitioning
|
|
1641
|
+
requestAnimationFrame(() => {
|
|
1642
|
+
el.style.transition = `transform ${duration}ms ${easing}`;
|
|
1643
|
+
el.style.transform = '';
|
|
1644
|
+
});
|
|
1645
|
+
el.addEventListener('transitionend', function onEnd(e) {
|
|
1646
|
+
if (e.propertyName !== 'transform') return;
|
|
1647
|
+
el.style.transition = '';
|
|
1648
|
+
el.removeEventListener('transitionend', onEnd);
|
|
1649
|
+
});
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
class XToast extends HTMLElement {
|
|
1654
|
+
connectedCallback() {
|
|
1655
|
+
this.setAttribute('role', 'status');
|
|
1656
|
+
this.setAttribute('aria-live', 'polite');
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
show(message, opts = {}) {
|
|
1660
|
+
const toast = document.createElement('div');
|
|
1661
|
+
toast.className = 'toast';
|
|
1662
|
+
if (opts.kind) toast.dataset.kind = opts.kind;
|
|
1663
|
+
const text = document.createElement('span');
|
|
1664
|
+
text.textContent = message;
|
|
1665
|
+
toast.append(text);
|
|
1666
|
+
|
|
1667
|
+
const dismiss = () => {
|
|
1668
|
+
if ('leaving' in toast.dataset) return;
|
|
1669
|
+
toast.dataset.leaving = '';
|
|
1670
|
+
clearTimeout(timer);
|
|
1671
|
+
toast.addEventListener('animationend', () => removeAndSettle(this, toast), { once: true });
|
|
1672
|
+
};
|
|
1673
|
+
|
|
1674
|
+
if (opts.action) {
|
|
1675
|
+
const btn = document.createElement('button');
|
|
1676
|
+
btn.className = 'toast-action';
|
|
1677
|
+
btn.textContent = opts.action.label;
|
|
1678
|
+
btn.addEventListener('click', () => {
|
|
1679
|
+
if (typeof opts.action.onClick === 'function') opts.action.onClick();
|
|
1680
|
+
dismiss();
|
|
1681
|
+
});
|
|
1682
|
+
toast.append(btn);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
this.append(toast);
|
|
1686
|
+
|
|
1687
|
+
const alive = [...this.querySelectorAll('.toast')]
|
|
1688
|
+
.filter((t) => !('leaving' in t.dataset));
|
|
1689
|
+
if (alive.length > 3) {
|
|
1690
|
+
const oldest = alive[0];
|
|
1691
|
+
oldest.dataset.leaving = '';
|
|
1692
|
+
oldest.addEventListener('animationend', () => removeAndSettle(this, oldest), { once: true });
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
let timer = setTimeout(dismiss, opts.duration || 4000);
|
|
1696
|
+
toast.addEventListener('mouseenter', () => clearTimeout(timer));
|
|
1697
|
+
toast.addEventListener('focusin', () => clearTimeout(timer));
|
|
1698
|
+
toast.addEventListener('mouseleave', () => {
|
|
1699
|
+
clearTimeout(timer);
|
|
1700
|
+
timer = setTimeout(dismiss, 1500);
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
return dismiss;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
customElements.define('x-toast', XToast);
|
|
1708
|
+
|
|
1709
|
+
window.mtui = Object.assign(window.mtui || {}, {
|
|
1710
|
+
toast(message, opts) {
|
|
1711
|
+
let mount = document.querySelector('x-toast');
|
|
1712
|
+
if (!mount) {
|
|
1713
|
+
mount = document.createElement('x-toast');
|
|
1714
|
+
document.body.append(mount);
|
|
1715
|
+
}
|
|
1716
|
+
return mount.show(message, opts);
|
|
1717
|
+
},
|
|
1718
|
+
});
|
|
1719
|
+
})();
|