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,122 @@
1
+ /* MTUI <x-pull-refresh> — tier: Enhanced over .screen-list.
2
+ Wraps a scrollable region; pulling down past the threshold at
3
+ scrollTop 0 dispatches a bubbling `refresh` CustomEvent whose
4
+ detail.done() the app calls when its async work finishes.
5
+ Pointer Events, so mouse/trackpad/pen work the same as touch — this is
6
+ a drag gesture, not a touch-only affordance.
7
+ Basic (no JS): no affordance — the list just scrolls normally.
8
+ <x-pull-refresh>
9
+ <div class="screen-list">…</div>
10
+ </x-pull-refresh>
11
+ app.addEventListener('refresh', (e) => {
12
+ loadMessages().then(() => { e.detail.done(); mtui.toast('Updated'); });
13
+ }); */
14
+ (function () {
15
+ 'use strict';
16
+ if (customElements.get('x-pull-refresh')) return;
17
+
18
+ const THRESHOLD = 64;
19
+ const MAX_PULL = 96;
20
+
21
+ class XPullRefresh extends HTMLElement {
22
+ connectedCallback() {
23
+ if (this._built) return;
24
+ const scroller = this.querySelector('.screen-list, [data-pull-scroll]');
25
+ if (!scroller) return;
26
+ this._built = true;
27
+ this._build(scroller);
28
+ }
29
+
30
+ _build(scroller) {
31
+ scroller.style.position = 'relative';
32
+ scroller.style.overscrollBehaviorY = 'contain'; // don't chain into page scroll
33
+ const indicator = document.createElement('div');
34
+ indicator.className = 'pull-indicator';
35
+ scroller.prepend(indicator);
36
+
37
+ const reduced = () => {
38
+ const v = getComputedStyle(document.documentElement)
39
+ .getPropertyValue('--t-lift').trim();
40
+ return (parseFloat(v) || 0) === 0;
41
+ };
42
+
43
+ let startY = 0;
44
+ let dragging = false; // pointer is down and we've confirmed this is a pull, not a click
45
+ let refreshing = false;
46
+ let activePointerId = null;
47
+ let currentPull = 0;
48
+
49
+ const setPull = (px) => {
50
+ currentPull = px;
51
+ indicator.style.transform = `translateY(${px}px) rotate(${px * 3}deg)`;
52
+ };
53
+
54
+ scroller.addEventListener('pointerdown', (e) => {
55
+ if (refreshing || scroller.scrollTop > 0 || !e.isPrimary) return;
56
+ startY = e.clientY;
57
+ activePointerId = e.pointerId;
58
+ // Not yet a confirmed drag — a plain click/tap must still work
59
+ // normally. document-level listeners (not scroller-level) so the
60
+ // gesture keeps tracking even if the pointer leaves the element.
61
+ document.addEventListener('pointermove', onMove);
62
+ document.addEventListener('pointerup', onUp);
63
+ document.addEventListener('pointercancel', onUp);
64
+ });
65
+
66
+ function onMove(e) {
67
+ if (e.pointerId !== activePointerId) return;
68
+ const dy = e.clientY - startY;
69
+ if (dy <= 0) { dragging = false; setPull(0); return; }
70
+ // Only now, once it's clearly a downward pull, do we take over the
71
+ // gesture — this is what keeps the page (and the internal list)
72
+ // from scrolling along with the pull.
73
+ dragging = true;
74
+ e.preventDefault();
75
+ setPull(Math.min(dy * 0.5, MAX_PULL));
76
+ }
77
+
78
+ function onUp() {
79
+ document.removeEventListener('pointermove', onMove);
80
+ document.removeEventListener('pointerup', onUp);
81
+ document.removeEventListener('pointercancel', onUp);
82
+ activePointerId = null;
83
+ if (!dragging) return;
84
+ dragging = false;
85
+ if (currentPull >= THRESHOLD) beginRefresh(); else springBack();
86
+ }
87
+
88
+ function springBack() {
89
+ indicator.style.transition = reduced() ? 'none' : 'transform var(--t-knob) var(--spring)';
90
+ setPull(0);
91
+ indicator.addEventListener('transitionend', function onEnd() {
92
+ indicator.style.transition = '';
93
+ indicator.removeEventListener('transitionend', onEnd);
94
+ });
95
+ }
96
+
97
+ function beginRefresh() {
98
+ refreshing = true;
99
+ indicator.style.transition = reduced() ? 'none' : 'transform var(--t-knob) var(--spring)';
100
+ setPull(THRESHOLD * 0.6);
101
+ indicator.dataset.spinning = '';
102
+
103
+ let settled = false;
104
+ const done = () => {
105
+ if (settled) return;
106
+ settled = true;
107
+ delete indicator.dataset.spinning;
108
+ refreshing = false;
109
+ springBack();
110
+ };
111
+
112
+ scroller.dispatchEvent(new CustomEvent('refresh', {
113
+ bubbles: true,
114
+ detail: { done },
115
+ }));
116
+ setTimeout(done, 15000); // safety: never spin forever if the app forgets done()
117
+ }
118
+ }
119
+ }
120
+
121
+ customElements.define('x-pull-refresh', XPullRefresh);
122
+ })();
package/js/telegram.js ADDED
@@ -0,0 +1,175 @@
1
+ /* MTUI Telegram adapter — tier: JS-only, layered opt-in (loads harmlessly
2
+ anywhere; no-ops unless window.Telegram.WebApp is present).
3
+
4
+ Maps the host theme onto MTUI so a mini app looks and feels native to the
5
+ Telegram shell: the user's background, surfaces, text, and brand accent all
6
+ come straight from themeParams. Telegram supplies ~2 backgrounds + 2 text
7
+ levels + an accent; MTUI's ladder needs 4 surfaces, 3 text tones, and a
8
+ full accent family, so the gaps are DERIVED in OKLCH from the host's own
9
+ colors (never invented from scratch):
10
+ pass-through : --bg --surface --text --text-muted --accent --on-accent
11
+ --accent-text (host values, verbatim)
12
+ derived : --surface-2 --surface-3 (step from surface toward text)
13
+ --text-faint (muted blended toward bg)
14
+ --accent-hover (accent lightness −0.05)
15
+ --accent-tint (§2.14 tint from the accent hue)
16
+ Contrast is delegated to the host pairings (button_text_color, link_color),
17
+ which Telegram themes already guarantee legible.
18
+
19
+ themeParams -> MTUI slots:
20
+ secondary_bg_color -> --bg (the recessed page background)
21
+ bg_color -> --surface (raised cards/sections)
22
+ text_color -> --text
23
+ hint_color -> --text-muted
24
+ button_color -> --accent (+ button_text_color -> --on-accent)
25
+ link_color -> --accent-text
26
+ Re-applied on load and on Telegram's themeChanged; data-theme tracks
27
+ colorScheme so MTUI's status tints and forced-colors pick the right base.
28
+
29
+ API: mtui.telegram.sync() // read live WebApp + apply
30
+ mtui.telegram.apply(themeParams, scheme) // apply given values (testing)
31
+
32
+ Basic (JS off, or outside Telegram): silent no-op — the screen keeps the
33
+ tokens.css defaults, functional and on-language. */
34
+ (function () {
35
+ 'use strict';
36
+
37
+ const clamp01 = (n) => Math.min(1, Math.max(0, n));
38
+ const r3 = (n) => Number(n.toFixed(3));
39
+ const r1 = (n) => Number(n.toFixed(1));
40
+
41
+ // "#rgb" / "#rrggbb" (with or without #) -> "#rrggbb", or null if unusable.
42
+ function normHex(hex) {
43
+ if (typeof hex !== 'string') return null;
44
+ let h = hex.trim().replace(/^#/, '');
45
+ if (h.length === 3) h = h.split('').map((c) => c + c).join('');
46
+ return /^[0-9a-fA-F]{6}$/.test(h) ? '#' + h.toLowerCase() : null;
47
+ }
48
+
49
+ // sRGB hex -> OKLCH {L (0–1), C, H (deg)}. null on unparseable input.
50
+ function hexToLCH(hex) {
51
+ const h = normHex(hex);
52
+ if (!h) return null;
53
+ const toLinear = (v) => {
54
+ const c = v / 255;
55
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
56
+ };
57
+ const r = toLinear(parseInt(h.slice(1, 3), 16));
58
+ const g = toLinear(parseInt(h.slice(3, 5), 16));
59
+ const b = toLinear(parseInt(h.slice(5, 7), 16));
60
+ // linear sRGB -> LMS -> OKLab (Ottosson matrices).
61
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
62
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
63
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
64
+ const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
65
+ const oa = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
66
+ const ob = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
67
+ let H = (Math.atan2(ob, oa) * 180) / Math.PI;
68
+ if (H < 0) H += 360;
69
+ return { L, C: Math.sqrt(oa * oa + ob * ob), H };
70
+ }
71
+
72
+ const fmt = (lch) => `oklch(${r1(lch.L * 100)}% ${r3(lch.C)} ${Math.round(lch.H)})`;
73
+
74
+ // Nudge lightness a fixed delta in the direction of a target L (chroma/hue
75
+ // kept). Light theme: surface is light, text dark -> steps darker (recede);
76
+ // dark theme: steps lighter. Same rule works for both.
77
+ const towardL = (base, targetL, delta) => ({
78
+ L: clamp01(base.L + Math.sign(targetL - base.L || 1) * delta),
79
+ C: base.C,
80
+ H: base.H,
81
+ });
82
+
83
+ // Blend two OKLCH colors (shortest-path hue) — used for near-grey text tones.
84
+ function mix(a, b, t) {
85
+ let dh = b.H - a.H;
86
+ if (dh > 180) dh -= 360;
87
+ if (dh < -180) dh += 360;
88
+ return { L: a.L + (b.L - a.L) * t, C: a.C + (b.C - a.C) * t, H: a.H + dh * t };
89
+ }
90
+
91
+ // §2.14 tint from the accent's hue/chroma, per theme.
92
+ const tint = (acc, dark) =>
93
+ dark
94
+ ? { L: 0.30, C: 0.35 * acc.C, H: acc.H }
95
+ : { L: 0.92, C: Math.min(0.32 * acc.C, 0.05), H: acc.H };
96
+
97
+ const MANAGED = [
98
+ '--bg', '--surface', '--surface-2', '--surface-3',
99
+ '--text', '--text-muted', '--text-faint',
100
+ '--accent', '--accent-hover', '--accent-text', '--accent-tint', '--on-accent',
101
+ ];
102
+
103
+ function apply(themeParams, scheme) {
104
+ const root = document.documentElement;
105
+ const dark = scheme === 'dark';
106
+ root.setAttribute('data-theme', dark ? 'dark' : 'light');
107
+
108
+ // Start clean so a sparser theme (e.g. after themeChanged) can't leave
109
+ // stale overrides from a richer previous one.
110
+ MANAGED.forEach((p) => root.style.removeProperty(p));
111
+
112
+ const tp = themeParams || {};
113
+ const surfaceHex = normHex(tp.bg_color) || normHex(tp.section_bg_color) || normHex(tp.secondary_bg_color);
114
+ const bgRaw = normHex(tp.secondary_bg_color);
115
+ // Only use the host bg if it's actually distinct from the surface; a sparse
116
+ // theme (only secondary_bg_color) would otherwise collapse the tonal step.
117
+ const bgHex = bgRaw && bgRaw !== surfaceHex ? bgRaw : null;
118
+ const textHex = normHex(tp.text_color);
119
+
120
+ // Need at least a base surface + text to meaningfully theme the shell;
121
+ // otherwise leave the tokens.css defaults in place.
122
+ if (!surfaceHex || !textHex) return;
123
+
124
+ const surface = hexToLCH(surfaceHex);
125
+ const text = hexToLCH(textHex);
126
+ const bg = bgHex ? hexToLCH(bgHex) : { L: clamp01(surface.L - 0.05), C: surface.C, H: surface.H };
127
+ const mutedHex = normHex(tp.hint_color) || normHex(tp.subtitle_text_color);
128
+ const muted = mutedHex ? hexToLCH(mutedHex) : mix(text, bg, 0.45);
129
+
130
+ const set = (prop, val) => root.style.setProperty(prop, val);
131
+ set('--bg', bgHex || fmt(bg));
132
+ set('--surface', surfaceHex);
133
+ set('--surface-2', fmt(towardL(surface, text.L, 0.035)));
134
+ set('--surface-3', fmt(towardL(surface, text.L, 0.075)));
135
+ set('--text', textHex);
136
+ set('--text-muted', mutedHex || fmt(muted));
137
+ set('--text-faint', fmt(mix(muted, bg, 0.45)));
138
+
139
+ const accentHex = normHex(tp.button_color);
140
+ if (accentHex) {
141
+ const acc = hexToLCH(accentHex);
142
+ root.setAttribute('data-accent', 'custom');
143
+ set('--accent', accentHex);
144
+ set('--on-accent', normHex(tp.button_text_color) || '#FFFFFF');
145
+ set('--accent-hover', fmt({ L: clamp01(acc.L - 0.05), C: acc.C, H: acc.H }));
146
+ set('--accent-text', normHex(tp.link_color) || normHex(tp.accent_text_color) || accentHex);
147
+ set('--accent-tint', fmt(tint(acc, dark)));
148
+ } else {
149
+ // No host accent this round — drop a stale data-accent="custom" so the
150
+ // accent doesn't silently revert to the default ramp while the attr lies.
151
+ root.removeAttribute('data-accent');
152
+ }
153
+ }
154
+
155
+ function currentWebApp() {
156
+ return (window.Telegram && window.Telegram.WebApp) || null;
157
+ }
158
+
159
+ function sync() {
160
+ const wa = currentWebApp();
161
+ if (!wa) return false;
162
+ apply(wa.themeParams || {}, wa.colorScheme);
163
+ return true;
164
+ }
165
+
166
+ window.mtui = Object.assign(window.mtui || {}, {
167
+ telegram: { sync, apply },
168
+ });
169
+
170
+ const wa = currentWebApp();
171
+ if (wa) {
172
+ sync();
173
+ if (typeof wa.onEvent === 'function') wa.onEvent('themeChanged', sync);
174
+ }
175
+ })();
@@ -0,0 +1,144 @@
1
+ /* MTUI <x-colorswatches> — tier: Enhanced over Basic native
2
+ <input type="color">. Curated swatches only, never a free color wheel.
3
+ Basic (no JS): the native color input renders and works normally.
4
+ <x-colorswatches data-colors="#BC4B2A,#3556C9,#2F7A50,#6E46BD,#7A5600">
5
+ <label class="field">
6
+ <span class="t-label">Tag color</span>
7
+ <input type="color" value="#BC4B2A">
8
+ </label>
9
+ </x-colorswatches>
10
+ data-colors on the host element is the one canonical swatch source.
11
+ Dispatches a bubbling `change` CustomEvent (detail: { value }) on the
12
+ underlying input. */
13
+ (function () {
14
+ 'use strict';
15
+ if (customElements.get('x-colorswatches')) return;
16
+
17
+ let seq = 0;
18
+
19
+ function luminance(hex) {
20
+ const n = hex.replace('#', '');
21
+ const [r, g, b] = [0, 2, 4].map((i) => {
22
+ const c = parseInt(n.slice(i, i + 2), 16) / 255;
23
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
24
+ });
25
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
26
+ }
27
+
28
+ class XColorswatches extends HTMLElement {
29
+ connectedCallback() {
30
+ if (this._built) return;
31
+ const input = this.querySelector('input[type="color"]');
32
+ if (!input) return;
33
+ this._built = true;
34
+ this._build(input);
35
+ }
36
+
37
+ _build(input) {
38
+ const panelId = `x-swatches-panel-${++seq}`;
39
+ const labelText = this.querySelector('.t-label')?.textContent.trim();
40
+ const colors = (this.dataset.colors || '')
41
+ .split(',')
42
+ .map((c) => c.trim())
43
+ .filter(Boolean);
44
+ if (!colors.length) return;
45
+
46
+ input.setAttribute('tabindex', '-1');
47
+ input.setAttribute('aria-hidden', 'true');
48
+ input.style.position = 'absolute';
49
+ input.style.opacity = '0';
50
+ input.style.pointerEvents = 'none';
51
+ input.style.inlineSize = '1px';
52
+ input.style.blockSize = '1px';
53
+
54
+ const trigger = document.createElement('button');
55
+ trigger.type = 'button';
56
+ trigger.className = 'swatches-trigger';
57
+ trigger.setAttribute('aria-haspopup', 'true');
58
+ trigger.setAttribute('aria-expanded', 'false');
59
+ trigger.setAttribute('popovertarget', panelId);
60
+ if (labelText) trigger.setAttribute('aria-label', labelText);
61
+ const swatchPreview = document.createElement('span');
62
+ swatchPreview.className = 'swatches-trigger-swatch';
63
+ const triggerLabel = document.createElement('span');
64
+ trigger.append(swatchPreview, triggerLabel);
65
+
66
+ const panel = document.createElement('div');
67
+ panel.className = 'swatches-panel menu';
68
+ panel.id = panelId;
69
+ panel.setAttribute('popover', '');
70
+ panel.setAttribute('role', 'radiogroup');
71
+ if (labelText) panel.setAttribute('aria-label', labelText);
72
+
73
+ const swatches = colors.map((color) => {
74
+ const btn = document.createElement('button');
75
+ btn.type = 'button';
76
+ btn.className = 'swatch';
77
+ btn.style.background = color;
78
+ btn.style.setProperty('--swatch-check-color', luminance(color) > 0.5 ? '#000' : '#fff');
79
+ btn.setAttribute('role', 'radio');
80
+ btn.dataset.color = color;
81
+ btn.setAttribute('aria-label', color);
82
+ btn.addEventListener('click', () => commit(color));
83
+ panel.append(btn);
84
+ return btn;
85
+ });
86
+
87
+ input.before(trigger);
88
+ document.body.append(panel);
89
+
90
+ const syncFace = () => {
91
+ const value = input.value.toUpperCase();
92
+ swatchPreview.style.background = value;
93
+ triggerLabel.textContent = value;
94
+ swatches.forEach((s) => {
95
+ s.setAttribute('aria-checked', String(s.dataset.color.toUpperCase() === value));
96
+ s.tabIndex = s.dataset.color.toUpperCase() === value ? 0 : -1;
97
+ });
98
+ };
99
+
100
+ function commit(color) {
101
+ const changed = input.value.toUpperCase() !== color.toUpperCase();
102
+ input.value = color;
103
+ syncFace();
104
+ panel.hidePopover();
105
+ trigger.focus();
106
+ if (changed) {
107
+ input.dispatchEvent(new CustomEvent('change', {
108
+ bubbles: true,
109
+ detail: { value: input.value },
110
+ }));
111
+ }
112
+ }
113
+
114
+ panel.addEventListener('keydown', (e) => {
115
+ const current = document.activeElement.closest('.swatch');
116
+ if (!current) return;
117
+ const idx = swatches.indexOf(current);
118
+ if (e.key === 'ArrowRight') {
119
+ e.preventDefault();
120
+ (swatches[idx + 1] || swatches[0]).focus();
121
+ } else if (e.key === 'ArrowLeft') {
122
+ e.preventDefault();
123
+ (swatches[idx - 1] || swatches[swatches.length - 1]).focus();
124
+ } else if (e.key === 'Enter' || e.key === ' ') {
125
+ e.preventDefault();
126
+ commit(current.dataset.color);
127
+ }
128
+ });
129
+
130
+ panel.addEventListener('toggle', (e) => {
131
+ const open = e.newState === 'open';
132
+ trigger.setAttribute('aria-expanded', String(open));
133
+ if (open) panel.querySelector('[tabindex="0"]')?.focus();
134
+ });
135
+
136
+ if (!colors.some((c) => c.toUpperCase() === input.value.toUpperCase())) {
137
+ input.value = colors[0];
138
+ }
139
+ syncFace();
140
+ }
141
+ }
142
+
143
+ customElements.define('x-colorswatches', XColorswatches);
144
+ })();
@@ -0,0 +1,190 @@
1
+ /* MTUI <x-contextmenu> — tier: Enhanced. Wraps any region; a <template>
2
+ child declares the menu, cloned into a popover panel positioned at the
3
+ pointer (right-click) or touch point (long-press ~500ms).
4
+ Uses popover="manual" with its own light-dismiss: the native "auto"
5
+ popover's built-in light-dismiss reacts to the SAME right-click
6
+ gesture that opens it (contextmenu fires on the button's press on some
7
+ platforms, release on others), so it could close itself the instant
8
+ the mouse button lifts — reading as "the menu only stays up while I
9
+ hold the click down." Registering our own outside-pointerdown listener
10
+ a tick after opening sidesteps that race.
11
+ Basic (no JS): the browser's native context menu appears — content
12
+ itself is unaffected.
13
+ <x-contextmenu>
14
+ <div class="card">Right-click me</div>
15
+ <template>
16
+ <button class="menu-item" data-value="rename">Rename</button>
17
+ <button class="menu-item" data-value="delete" data-danger>Delete</button>
18
+ </template>
19
+ </x-contextmenu>
20
+ Item activation dispatches a bubbling `select` CustomEvent
21
+ (detail: { value, target }) — value is the clicked item's data-value;
22
+ target is the element the gesture opened on. Wrap a whole list in ONE
23
+ x-contextmenu and read detail.target.closest('.item') to know which row
24
+ fired, instead of one menu per row.
25
+ Touch discoverability: a right-click/long-press has no visible affordance,
26
+ so give each row a trigger button carrying data-contextmenu-trigger (e.g. a
27
+ trailing "more" icon) — clicking it opens the same menu, anchored to the
28
+ button, with target set to it. Add aria-haspopup="menu" to that button. */
29
+ (function () {
30
+ 'use strict';
31
+ if (customElements.get('x-contextmenu')) return;
32
+
33
+ let seq = 0;
34
+ const LONG_PRESS_MS = 500;
35
+ const MOVE_TOLERANCE = 10;
36
+
37
+ class XContextmenu extends HTMLElement {
38
+ connectedCallback() {
39
+ if (this._built) return;
40
+ const template = this.querySelector('template');
41
+ if (!template) return;
42
+ this._built = true;
43
+ this._build(template);
44
+ }
45
+
46
+ _build(template) {
47
+ const panelId = `x-contextmenu-panel-${++seq}`;
48
+ const panel = document.createElement('div');
49
+ panel.className = 'menu context-menu';
50
+ panel.id = panelId;
51
+ panel.setAttribute('popover', 'manual');
52
+ panel.setAttribute('role', 'menu');
53
+ panel.append(template.content.cloneNode(true));
54
+ document.body.append(panel);
55
+
56
+ // The element the gesture landed on, captured at open time (the panel
57
+ // lives in <body>, so the item click's own target is the menu item, not
58
+ // the row). Lets ONE x-contextmenu wrap a whole list: select.detail.target
59
+ // is the origin element — the app calls .closest('.item') to find the row.
60
+ let origin = null;
61
+
62
+ const items = [...panel.querySelectorAll('.menu-item')];
63
+ items.forEach((item) => {
64
+ item.setAttribute('role', 'menuitem');
65
+ item.addEventListener('click', () => {
66
+ const target = origin;
67
+ close();
68
+ this.dispatchEvent(new CustomEvent('select', {
69
+ bubbles: true,
70
+ detail: { value: item.dataset.value, target },
71
+ }));
72
+ });
73
+ });
74
+
75
+ let returnFocus = null;
76
+ let onOutside = null;
77
+ let isOpen = false;
78
+
79
+ function close() {
80
+ if (!isOpen) return;
81
+ isOpen = false;
82
+ panel.hidePopover();
83
+ if (onOutside) {
84
+ document.removeEventListener('pointerdown', onOutside, true);
85
+ onOutside = null;
86
+ }
87
+ returnFocus?.focus();
88
+ returnFocus = null;
89
+ }
90
+ this._teardown = () => { close(); panel.remove(); };
91
+
92
+ const openAt = (x, y, originEl) => {
93
+ if (isOpen) close(); // reposition on re-open; avoids double showPopover
94
+ origin = originEl || null;
95
+ // Keyboard-invoked (Menu key / Shift+F10) contextmenu reports 0/absent
96
+ // coords — anchor to the focused element instead of the corner.
97
+ if (!x && !y && originEl && originEl.getBoundingClientRect) {
98
+ const r = originEl.getBoundingClientRect();
99
+ x = r.left;
100
+ y = r.bottom;
101
+ }
102
+ returnFocus = document.activeElement;
103
+ isOpen = true;
104
+ panel.style.position = 'fixed';
105
+ panel.style.margin = '0';
106
+ panel.showPopover();
107
+ const gap = 4;
108
+ const rect = panel.getBoundingClientRect();
109
+ const left = Math.min(Math.max(gap, x), innerWidth - rect.width - gap);
110
+ const top = Math.min(Math.max(gap, y), innerHeight - rect.height - gap);
111
+ panel.style.left = `${Math.round(left)}px`;
112
+ panel.style.top = `${Math.round(top)}px`;
113
+ items[0]?.focus();
114
+
115
+ onOutside = (e) => {
116
+ if (!panel.contains(e.target)) close();
117
+ };
118
+ // Registered on the next tick — see the file header comment on why
119
+ // this can't be wired synchronously during the opening gesture.
120
+ setTimeout(() => document.addEventListener('pointerdown', onOutside, true), 0);
121
+ };
122
+
123
+ this.addEventListener('contextmenu', (e) => {
124
+ e.preventDefault();
125
+ openAt(e.clientX, e.clientY, e.target);
126
+ });
127
+
128
+ // A visible tap affordance: any [data-contextmenu-trigger] inside the
129
+ // region opens the same menu on click (mouse/touch/keyboard), anchored
130
+ // to the button. origin = the trigger, so select.detail.target.closest()
131
+ // still finds the row. Gesture-only menus are undiscoverable on touch.
132
+ this.addEventListener('click', (e) => {
133
+ const trigger = e.target.closest('[data-contextmenu-trigger]');
134
+ if (!trigger || !this.contains(trigger)) return;
135
+ e.preventDefault();
136
+ e.stopPropagation();
137
+ const r = trigger.getBoundingClientRect();
138
+ openAt(r.left, r.bottom, trigger);
139
+ });
140
+
141
+ let pressTimer = null;
142
+ let startX = 0;
143
+ let startY = 0;
144
+ let startTarget = null;
145
+ this.addEventListener('touchstart', (e) => {
146
+ const t = e.touches[0];
147
+ startX = t.clientX;
148
+ startY = t.clientY;
149
+ startTarget = e.target;
150
+ pressTimer = setTimeout(() => openAt(startX, startY, startTarget), LONG_PRESS_MS);
151
+ }, { passive: true });
152
+ this.addEventListener('touchmove', (e) => {
153
+ const t = e.touches[0];
154
+ if (Math.abs(t.clientX - startX) > MOVE_TOLERANCE
155
+ || Math.abs(t.clientY - startY) > MOVE_TOLERANCE) {
156
+ clearTimeout(pressTimer);
157
+ }
158
+ }, { passive: true });
159
+ this.addEventListener('touchend', () => clearTimeout(pressTimer));
160
+ this.addEventListener('touchcancel', () => clearTimeout(pressTimer));
161
+
162
+ panel.addEventListener('keydown', (e) => {
163
+ const idx = items.indexOf(document.activeElement);
164
+ if (e.key === 'ArrowDown') {
165
+ e.preventDefault();
166
+ (items[idx + 1] || items[0])?.focus();
167
+ } else if (e.key === 'ArrowUp') {
168
+ e.preventDefault();
169
+ (items[idx - 1] || items[items.length - 1])?.focus();
170
+ } else if (e.key === 'Enter' || e.key === ' ') {
171
+ e.preventDefault();
172
+ document.activeElement?.click();
173
+ } else if (e.key === 'Escape') {
174
+ e.preventDefault();
175
+ close();
176
+ }
177
+ });
178
+ }
179
+
180
+ disconnectedCallback() {
181
+ // Remove the body-appended panel + any open outside-listener so a
182
+ // removed host doesn't leak (and can rebuild if re-added).
183
+ this._teardown?.();
184
+ this._teardown = null;
185
+ this._built = false;
186
+ }
187
+ }
188
+
189
+ customElements.define('x-contextmenu', XContextmenu);
190
+ })();