@waggylabs/yumekit 0.4.3 → 0.4.4

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.
@@ -1,6 +1,221 @@
1
- import { resolveAnchor } from '../../modules/helpers.js';
1
+ import { createElement, resolveAnchor } from '../../modules/helpers.js';
2
+ import { getIcon } from '../../icons/registry.js';
3
+
4
+ // Allowlist-based SVG sanitizer — only known-safe elements and attributes are kept.
5
+ const ALLOWED_ELEMENTS = new Set([
6
+ "svg", "g", "path", "circle", "ellipse", "rect", "line", "polyline",
7
+ "polygon", "text", "tspan", "defs", "clippath", "mask", "lineargradient",
8
+ "radialgradient", "stop", "symbol", "title", "desc", "metadata",
9
+ ]);
10
+
11
+ const ALLOWED_ATTRS = new Set([
12
+ "viewbox", "xmlns", "fill", "stroke", "stroke-width", "stroke-linecap",
13
+ "stroke-linejoin", "stroke-dasharray", "stroke-dashoffset", "stroke-miterlimit",
14
+ "stroke-opacity", "fill-opacity", "fill-rule", "clip-rule", "opacity",
15
+ "d", "cx", "cy", "r", "rx", "ry", "x", "x1", "x2", "y", "y1", "y2",
16
+ "width", "height", "points", "transform", "id", "class", "clip-path", "mask",
17
+ "offset", "stop-color", "stop-opacity", "gradient-units", "gradienttransform",
18
+ "gradientunits", "spreadmethod", "patternunits", "patterntransform",
19
+ "font-size", "font-family", "font-weight", "text-anchor", "dominant-baseline",
20
+ "alignment-baseline", "dx", "dy", "rotate", "textlength", "lengthadjust",
21
+ "display", "visibility", "color", "vector-effect",
22
+ ]);
23
+
24
+ function sanitizeSvg(raw) {
25
+ if (!raw) return "";
26
+ const doc = new DOMParser().parseFromString(raw, "image/svg+xml");
27
+ const svg = doc.querySelector("svg");
28
+ if (!svg) return "";
29
+
30
+ const walk = (el) => {
31
+ for (const child of [...el.children]) {
32
+ if (!ALLOWED_ELEMENTS.has(child.tagName.toLowerCase())) {
33
+ child.remove();
34
+ continue;
35
+ }
36
+ for (const attr of [...child.attributes]) {
37
+ if (!ALLOWED_ATTRS.has(attr.name.toLowerCase())) {
38
+ child.removeAttribute(attr.name);
39
+ }
40
+ }
41
+ walk(child);
42
+ }
43
+ };
44
+
45
+ for (const attr of [...svg.attributes]) {
46
+ if (!ALLOWED_ATTRS.has(attr.name.toLowerCase())) {
47
+ svg.removeAttribute(attr.name);
48
+ }
49
+ }
50
+ walk(svg);
51
+ return svg.outerHTML;
52
+ }
53
+
54
+ // Cache sanitized SVG markup per icon name to avoid repeated DOMParser + DOM-walk
55
+ // on every render. The cache is naturally bounded by the number of registered icons.
56
+ const sanitizedSvgCache = new Map();
57
+
58
+ function getCachedSvg(name) {
59
+ if (sanitizedSvgCache.has(name)) return sanitizedSvgCache.get(name);
60
+ const result = sanitizeSvg(getIcon(name));
61
+ sanitizedSvgCache.set(name, result);
62
+ return result;
63
+ }
64
+
65
+ class YumeIcon extends HTMLElement {
66
+ static get observedAttributes() {
67
+ return ["name", "size", "color", "label", "weight"];
68
+ }
69
+
70
+ // -------------------------------------------------------------------------
71
+ // Lifecycle
72
+ // -------------------------------------------------------------------------
73
+
74
+ constructor() {
75
+ super();
76
+ this.attachShadow({ mode: "open" });
77
+ }
78
+
79
+ connectedCallback() {
80
+ this.render();
81
+ }
2
82
 
3
- var chevronRight = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <polyline points=\"9 18 15 12 9 6\"/>\n</svg>\n";
83
+ attributeChangedCallback(name, oldVal, newVal) {
84
+ if (oldVal === newVal) return;
85
+ this.render();
86
+ }
87
+
88
+ // -------------------------------------------------------------------------
89
+ // Getters / Setters
90
+ // -------------------------------------------------------------------------
91
+
92
+ /** Color theme: "base" | "primary" | "secondary" | "success" | "warning" | "error" | "help". */
93
+ get color() { return this.getAttribute("color") || ""; }
94
+ set color(val) {
95
+ if (val) this.setAttribute("color", val);
96
+ else this.removeAttribute("color");
97
+ }
98
+
99
+ /** Accessible label for the icon. When set, the icon gets role="img". */
100
+ get label() { return this.getAttribute("label") || ""; }
101
+ set label(val) {
102
+ if (val) this.setAttribute("label", val);
103
+ else this.removeAttribute("label");
104
+ }
105
+
106
+ /** The registered icon name to display. */
107
+ get name() { return this.getAttribute("name") || ""; }
108
+ set name(val) { this.setAttribute("name", val); }
109
+
110
+ /** Icon size: "x-small" | "small" | "medium" | "large" | "x-large" (default "medium"). */
111
+ get size() { return this.getAttribute("size") || "medium"; }
112
+ set size(val) { this.setAttribute("size", val); }
113
+
114
+ /** Stroke weight: "thin" | "regular" | "thick". */
115
+ get weight() { return this.getAttribute("weight") || "regular"; }
116
+ set weight(val) {
117
+ if (val) this.setAttribute("weight", val);
118
+ else this.removeAttribute("weight");
119
+ }
120
+
121
+ // -------------------------------------------------------------------------
122
+ // Public
123
+ // -------------------------------------------------------------------------
124
+
125
+ render() {
126
+ const svg = getCachedSvg(this.name);
127
+ const sizeVal = this._getSize(this.size);
128
+ const colorVal = this.color ? this._getColor(this.color) : "inherit";
129
+ const weightVal = this._getWeight(this.weight);
130
+
131
+ this._updateAria();
132
+
133
+ this.shadowRoot.innerHTML = `
134
+ <style>
135
+ :host {
136
+ display: inline-flex;
137
+ align-items: center;
138
+ justify-content: center;
139
+ width: ${sizeVal};
140
+ height: ${sizeVal};
141
+ color: ${colorVal};
142
+ line-height: 0;
143
+ }
144
+ .icon-wrapper svg {
145
+ width: 100%;
146
+ height: 100%;
147
+ }
148
+ ${this._getWeightCSS(weightVal)}
149
+ </style>
150
+ <span class="icon-wrapper" part="icon">${svg}</span>
151
+ `;
152
+ }
153
+
154
+ // -------------------------------------------------------------------------
155
+ // Private
156
+ // -------------------------------------------------------------------------
157
+
158
+ _getColor(color) {
159
+ const map = {
160
+ base: "var(--base-content--, #f7f7fa)",
161
+ primary: "var(--primary-content--, #0576ff)",
162
+ secondary: "var(--secondary-content--, #04b8b8)",
163
+ success: "var(--success-content--, #2dba73)",
164
+ warning: "var(--warning-content--, #d17f04)",
165
+ error: "var(--error-content--, #b80421)",
166
+ help: "var(--help-content--, #5405ff)",
167
+ };
168
+ if (map[color]) return map[color];
169
+ if (color && (color.startsWith("#") || color.startsWith("rgb") || color.startsWith("hsl"))) {
170
+ return color;
171
+ }
172
+ return map.base;
173
+ }
174
+
175
+ _getSize(size) {
176
+ const map = {
177
+ "x-small": "var(--component-icon-size-x-small, 10px)",
178
+ small: "var(--component-icon-size-small, 14px)",
179
+ medium: "var(--component-icon-size-medium, 18px)",
180
+ large: "var(--component-icon-size-large, 22px)",
181
+ "x-large": "var(--component-icon-size-x-large, 28px)",
182
+ };
183
+ return map[size] || map.medium;
184
+ }
185
+
186
+ _getWeight(weight) {
187
+ const map = {
188
+ "x-thin": "1",
189
+ thin: "1.5",
190
+ regular: "2",
191
+ thick: "2.5",
192
+ "x-thick": "3",
193
+ };
194
+ return map[weight] || "";
195
+ }
196
+
197
+ _getWeightCSS(weightVal) {
198
+ if (!weightVal) return "";
199
+ return `.icon-wrapper svg,
200
+ .icon-wrapper svg * { stroke-width: ${weightVal} !important; }`;
201
+ }
202
+
203
+ _updateAria() {
204
+ if (this.label) {
205
+ this.setAttribute("role", "img");
206
+ this.setAttribute("aria-label", this.label);
207
+ this.removeAttribute("aria-hidden");
208
+ } else {
209
+ this.setAttribute("aria-hidden", "true");
210
+ this.removeAttribute("role");
211
+ this.removeAttribute("aria-label");
212
+ }
213
+ }
214
+ }
215
+
216
+ if (!customElements.get("y-icon")) {
217
+ customElements.define("y-icon", YumeIcon);
218
+ }
4
219
 
5
220
  class YumeMenu extends HTMLElement {
6
221
  static get observedAttributes() {
@@ -17,6 +232,8 @@ class YumeMenu extends HTMLElement {
17
232
  this._onAnchorClick = this._onAnchorClick.bind(this);
18
233
  this._onDocumentClick = this._onDocumentClick.bind(this);
19
234
  this._onScrollOrResize = this._onScrollOrResize.bind(this);
235
+ this._isReady = false;
236
+ this._slottedHandlers = new Map();
20
237
  }
21
238
 
22
239
  connectedCallback() {
@@ -33,10 +250,13 @@ class YumeMenu extends HTMLElement {
33
250
  this.style.zIndex = "1000";
34
251
  this.style.display = "none";
35
252
  if (this.visible) this._updatePosition();
253
+
254
+ this._isReady = true;
36
255
  }
37
256
 
38
257
  disconnectedCallback() {
39
258
  this._teardownAnchor();
259
+ this._teardownSlottedItems();
40
260
 
41
261
  document.removeEventListener("click", this._onDocumentClick);
42
262
  window.removeEventListener("scroll", this._onScrollOrResize, true);
@@ -56,6 +276,13 @@ class YumeMenu extends HTMLElement {
56
276
  if (name === "visible" || name === "direction") {
57
277
  this._updatePosition();
58
278
  }
279
+
280
+ if (name === "visible" && this._isReady) {
281
+ this.dispatchEvent(new CustomEvent(this.visible ? "open" : "close", {
282
+ bubbles: true,
283
+ composed: true,
284
+ }));
285
+ }
59
286
  }
60
287
 
61
288
  // -------------------------------------------------------------------------
@@ -70,6 +297,16 @@ class YumeMenu extends HTMLElement {
70
297
  get direction() { return this.getAttribute("direction") || "down"; }
71
298
  set direction(val) { this.setAttribute("direction", val); }
72
299
 
300
+ /**
301
+ * Navigation mode: omit for pushState (SPA-friendly), set to "false" for full-page navigation.
302
+ * Regardless of this setting, a cancelable "navigate" event is always dispatched first.
303
+ */
304
+ get history() { return this.getAttribute("history"); }
305
+ set history(val) {
306
+ if (val != null) this.setAttribute("history", val);
307
+ else this.removeAttribute("history");
308
+ }
309
+
73
310
  /** Menu items array (JSON attribute). */
74
311
  get items() {
75
312
  try {
@@ -82,16 +319,6 @@ class YumeMenu extends HTMLElement {
82
319
  this.setAttribute("items", Array.isArray(val) ? JSON.stringify(val) : (val ?? "[]"));
83
320
  }
84
321
 
85
- /**
86
- * Navigation mode: omit for pushState (SPA-friendly), set to "false" for full-page navigation.
87
- * Regardless of this setting, a cancelable "navigate" event is always dispatched first.
88
- */
89
- get history() { return this.getAttribute("history"); }
90
- set history(val) {
91
- if (val != null) this.setAttribute("history", val);
92
- else this.removeAttribute("history");
93
- }
94
-
95
322
  /** Size: "small" | "medium" | "large" (default "medium"). */
96
323
  get size() {
97
324
  const sz = this.getAttribute("size");
@@ -115,63 +342,94 @@ class YumeMenu extends HTMLElement {
115
342
  render() {
116
343
  this.shadowRoot.innerHTML = "";
117
344
 
118
- const style = document.createElement("style");
119
- style.textContent = this._buildStyles();
120
- this.shadowRoot.appendChild(style);
345
+ const style = createElement("style", {}, [this._buildStyles()]);
346
+
347
+ const root = this._createMenuList(this.items);
348
+ root.classList.add("menu");
349
+ root.setAttribute("role", "menu");
350
+ root.setAttribute("part", "menu");
121
351
 
122
- const rootUl = this._createMenuList(this.items);
123
- rootUl.classList.add("menu");
124
- rootUl.setAttribute("role", "menu");
125
- rootUl.setAttribute("part", "menu");
352
+ const childSlot = createElement("slot");
353
+ childSlot.addEventListener("slotchange", () => this._processSlottedItems());
354
+ root.appendChild(childSlot);
126
355
 
127
- this.shadowRoot.appendChild(rootUl);
356
+ this.shadowRoot.appendChild(style);
357
+ this.shadowRoot.appendChild(root);
358
+ this._processSlottedItems();
128
359
  }
129
360
 
130
361
  // -------------------------------------------------------------------------
131
362
  // Private
132
363
  // -------------------------------------------------------------------------
133
364
 
365
+ _activateItem(item) {
366
+ if (item.children?.length > 0) return;
367
+
368
+ this._dispatchSelect({
369
+ value: item.value ?? item.text,
370
+ item,
371
+ });
372
+
373
+ const href = item.href ?? item.url;
374
+ if (href) this._navigateTo(href);
375
+
376
+ this.visible = false;
377
+ }
378
+
379
+ _activateSlottedItem(el) {
380
+ this._dispatchSelect({
381
+ value: el.dataset.value ?? el.textContent.trim(),
382
+ element: el,
383
+ });
384
+ this.visible = false;
385
+ }
386
+
134
387
  _buildStyles() {
135
388
  const paddingVar = `var(--component-button-padding-${this.size}, 0.5rem)`;
136
389
  return `
137
- ul.menu,
138
- ul.submenu {
139
- list-style: none;
140
- margin: 0;
141
- padding: 0;
390
+ .menu,
391
+ .submenu {
142
392
  background: var(--component-menu-background, #0c0c0d);
143
393
  border: var(--component-menu-border-width, 1px) solid var(--component-menu-border-color, #37383a);
144
394
  border-radius: var(--component-menu-border-radius, 4px);
145
395
  box-shadow: var(--component-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.15));
146
396
  min-width: 150px;
397
+ display: flex;
398
+ flex-direction: column;
147
399
  }
148
400
 
149
- li.menuitem {
401
+ .menuitem,
402
+ ::slotted(:not([slot])) {
150
403
  cursor: pointer;
151
404
  padding: ${paddingVar};
152
- display: flex;
153
- align-items: center;
154
- justify-content: space-between;
155
405
  white-space: nowrap;
156
406
  color: var(--component-menu-color, #f7f7fa);
157
407
  font-size: var(--font-size-button, 1em);
408
+ box-sizing: border-box;
409
+ }
410
+
411
+ .menuitem {
412
+ display: flex;
413
+ align-items: center;
414
+ justify-content: space-between;
158
415
  position: relative;
159
416
  }
160
417
 
161
- li.menuitem:hover {
418
+ .menuitem:hover,
419
+ ::slotted(:not([slot]):hover) {
162
420
  background: var(--component-menu-hover-background, #292a2b);
163
421
  }
164
422
 
165
- li.menuitem.selected {
423
+ .menuitem.selected {
166
424
  background: var(--component-menu-selected-background);
167
425
  color: var(--component-menu-selected-color);
168
426
  }
169
427
 
170
- li.menuitem.selected:hover {
428
+ .menuitem.selected:hover {
171
429
  background: var(--component-menu-selected-background);
172
430
  }
173
431
 
174
- ul.submenu {
432
+ .submenu {
175
433
  position: absolute;
176
434
  top: 0;
177
435
  left: 100%;
@@ -179,8 +437,8 @@ class YumeMenu extends HTMLElement {
179
437
  z-index: var(--component-menu-z-index, 1001);
180
438
  }
181
439
 
182
- li.menuitem:hover > ul.submenu {
183
- display: block;
440
+ .menuitem:hover > .submenu {
441
+ display: flex;
184
442
  }
185
443
 
186
444
  .submenu-indicator {
@@ -190,13 +448,11 @@ class YumeMenu extends HTMLElement {
190
448
  opacity: 0.6;
191
449
  }
192
450
 
193
- .submenu-indicator svg {
194
- width: 16px;
195
- height: 16px;
196
- }
197
-
198
451
  .item-content {
199
452
  flex: 1;
453
+ display: inline-flex;
454
+ align-items: center;
455
+ gap: 0.5rem;
200
456
  }
201
457
  `;
202
458
  }
@@ -209,91 +465,140 @@ class YumeMenu extends HTMLElement {
209
465
  });
210
466
  }
211
467
 
212
- _createMenuList(items) {
213
- const ul = document.createElement("ul");
214
-
215
- items.forEach((item) => {
216
- const li = document.createElement("li");
217
- li.className = item.selected ? "menuitem selected" : "menuitem";
218
- li.setAttribute("role", "menuitem");
219
- li.setAttribute("part", item.selected ? "menuitem selected" : "menuitem");
220
- li.setAttribute("aria-current", item.selected ? "true" : "false");
221
- li.tabIndex = 0;
222
-
223
- const contentWrapper = document.createElement("span");
224
- contentWrapper.className = "item-content";
225
-
226
- if (item["icon-template"]) {
227
- const iconTpl = this._findTemplate(item["icon-template"]);
228
- if (iconTpl)
229
- contentWrapper.appendChild(iconTpl.content.cloneNode(true));
230
- }
468
+ _computeMenuOffset(direction, anchorRect, menuRect, vw, vh) {
469
+ if (direction === "right") {
470
+ let top = anchorRect.top;
471
+ let left = anchorRect.right;
472
+ if (left + menuRect.width > vw) left = anchorRect.left - menuRect.width;
473
+ if (top + menuRect.height > vh) top = anchorRect.top - menuRect.height;
474
+ return { top, left };
475
+ }
231
476
 
232
- if (item.template) {
233
- const textTpl = this._findTemplate(item.template);
234
- if (textTpl) {
235
- contentWrapper.appendChild(textTpl.content.cloneNode(true));
236
- } else {
237
- contentWrapper.append(item.text);
238
- }
239
- } else {
240
- contentWrapper.append(item.text);
241
- }
477
+ if (direction === "up") {
478
+ let top = anchorRect.top - menuRect.height;
479
+ let left = anchorRect.left;
480
+ if (top < 0) top = anchorRect.bottom;
481
+ if (left + menuRect.width > vw) left = vw - menuRect.width - 10;
482
+ return { top, left };
483
+ }
242
484
 
243
- li.appendChild(contentWrapper);
244
-
245
- if (item.url) {
246
- li.addEventListener("click", () => {
247
- const event = new CustomEvent("navigate", {
248
- bubbles: true,
249
- composed: true,
250
- cancelable: true,
251
- detail: { href: item.url },
252
- });
253
- const cancelled = !this.dispatchEvent(event);
254
- if (cancelled) return;
255
- if (this.getAttribute("history") !== "false") {
256
- history.pushState({}, "", item.url);
257
- window.dispatchEvent(new PopStateEvent("popstate", { state: {} }));
258
- } else {
259
- window.location.href = item.url;
260
- }
261
- });
262
- }
485
+ if (direction === "left") {
486
+ let top = anchorRect.top;
487
+ let left = anchorRect.left - menuRect.width;
488
+ if (left < 0) left = anchorRect.right;
489
+ if (top + menuRect.height > vh) top = anchorRect.top - menuRect.height;
490
+ return { top, left };
491
+ }
263
492
 
264
- if (!item.children?.length) {
265
- li.addEventListener("click", () => {
266
- this.visible = false;
267
- });
268
- }
493
+ // "down" (default)
494
+ let top = anchorRect.bottom;
495
+ let left = anchorRect.left;
496
+ if (top + menuRect.height > vh) top = anchorRect.top - menuRect.height;
497
+ if (left + menuRect.width > vw) left = vw - menuRect.width - 10;
498
+ return { top, left };
499
+ }
269
500
 
270
- if (item.children?.length) {
271
- const indicator = document.createElement("span");
272
- indicator.className = "submenu-indicator";
273
- indicator.innerHTML = chevronRight;
274
- li.appendChild(indicator);
501
+ _createItemContent(item) {
502
+ const wrapper = createElement("span", { class: "item-content" });
275
503
 
276
- const submenu = this._createMenuList(item.children);
277
- submenu.classList.add("submenu");
278
- submenu.setAttribute("role", "menu");
279
- li.appendChild(submenu);
280
- }
504
+ if (item.icon) {
505
+ wrapper.appendChild(createElement("y-icon", { name: item.icon, size: this.size }));
506
+ } else if (item["icon-template"]) {
507
+ YumeMenu._warnTemplateFieldDeprecated();
508
+ const tpl = this._findTemplate(item["icon-template"]);
509
+ if (tpl) wrapper.appendChild(tpl.content.cloneNode(true));
510
+ }
281
511
 
282
- ul.appendChild(li);
512
+ if (item.template) {
513
+ YumeMenu._warnTemplateFieldDeprecated();
514
+ const tpl = this._findTemplate(item.template);
515
+ if (tpl) wrapper.appendChild(tpl.content.cloneNode(true));
516
+ else wrapper.append(item.text ?? "");
517
+ } else {
518
+ wrapper.append(item.text ?? "");
519
+ }
520
+
521
+ if (!item.slot) return wrapper;
522
+
523
+ const slotEl = createElement("slot", { name: item.slot });
524
+ slotEl.appendChild(wrapper);
525
+ return slotEl;
526
+ }
527
+
528
+ _createMenuItem(item) {
529
+ const isSelected = !!item.selected;
530
+ const partValue = isSelected ? "menuitem selected" : "menuitem";
531
+
532
+ const itemEl = createElement("div", {
533
+ class: partValue,
534
+ role: "menuitem",
535
+ part: partValue,
536
+ "aria-current": isSelected ? "true" : "false",
537
+ tabindex: "0",
283
538
  });
284
539
 
285
- return ul;
540
+ itemEl.appendChild(this._createItemContent(item));
541
+
542
+ if (item.url && !item.href) YumeMenu._warnUrlDeprecated();
543
+
544
+ itemEl.addEventListener("click", () => this._activateItem(item));
545
+
546
+ if (item.children?.length > 0) {
547
+ itemEl.appendChild(this._createSubmenuIndicator());
548
+
549
+ const submenu = this._createMenuList(item.children);
550
+ submenu.classList.add("submenu");
551
+ submenu.setAttribute("role", "menu");
552
+ itemEl.appendChild(submenu);
553
+ }
554
+
555
+ return itemEl;
556
+ }
557
+
558
+ _createMenuList(items) {
559
+ const container = createElement("div");
560
+ items.forEach((item) => container.appendChild(this._createMenuItem(item)));
561
+ return container;
562
+ }
563
+
564
+ _createSubmenuIndicator() {
565
+ return createElement("span", { class: "submenu-indicator" }, [
566
+ createElement("y-icon", { name: "chevron-right", size: this.size }),
567
+ ]);
568
+ }
569
+
570
+ _dispatchSelect(detail) {
571
+ this.dispatchEvent(new CustomEvent("select", {
572
+ detail,
573
+ bubbles: true,
574
+ composed: true,
575
+ }));
286
576
  }
287
577
 
288
578
  _findTemplate(name) {
289
579
  return this.querySelector(`template[slot="${name}"]`);
290
580
  }
291
581
 
582
+ _navigateTo(href) {
583
+ const event = new CustomEvent("navigate", {
584
+ bubbles: true,
585
+ composed: true,
586
+ cancelable: true,
587
+ detail: { href },
588
+ });
589
+ if (!this.dispatchEvent(event)) return;
590
+
591
+ if (this.getAttribute("history") === "false") {
592
+ window.location.href = href;
593
+ } else {
594
+ history.pushState({}, "", href);
595
+ window.dispatchEvent(new PopStateEvent("popstate", { state: {} }));
596
+ }
597
+ }
598
+
292
599
  _onAnchorClick(e) {
293
600
  e.stopPropagation();
294
- if (!this.visible) {
295
- YumeMenu._closeAll(this);
296
- }
601
+ if (!this.visible) YumeMenu._closeAll(this);
297
602
  this.visible = !this.visible;
298
603
  }
299
604
 
@@ -308,9 +613,34 @@ class YumeMenu extends HTMLElement {
308
613
  if (this.visible) this._updatePosition();
309
614
  }
310
615
 
616
+ _processSlottedItems() {
617
+ const slot = this.shadowRoot.querySelector(".menu > slot");
618
+ if (!slot) return;
619
+
620
+ const assigned = new Set(slot.assignedElements());
621
+
622
+ for (const [el, handler] of this._slottedHandlers) {
623
+ if (assigned.has(el)) continue;
624
+ el.removeEventListener("click", handler);
625
+ this._slottedHandlers.delete(el);
626
+ }
627
+
628
+ for (const el of assigned) {
629
+ if (this._slottedHandlers.has(el)) continue;
630
+
631
+ if (!el.hasAttribute("role")) el.setAttribute("role", "menuitem");
632
+ if (el.tabIndex < 0) el.tabIndex = 0;
633
+
634
+ const handler = () => this._activateSlottedItem(el);
635
+ el.addEventListener("click", handler);
636
+ this._slottedHandlers.set(el, handler);
637
+ }
638
+ }
639
+
311
640
  _setupAnchor() {
312
641
  const id = this.anchor;
313
642
  if (!id) return;
643
+
314
644
  const root = this.getRootNode();
315
645
  this._anchorResolveDispose = resolveAnchor(
316
646
  this,
@@ -334,6 +664,13 @@ class YumeMenu extends HTMLElement {
334
664
  }
335
665
  }
336
666
 
667
+ _teardownSlottedItems() {
668
+ for (const [el, handler] of this._slottedHandlers) {
669
+ el.removeEventListener("click", handler);
670
+ }
671
+ this._slottedHandlers.clear();
672
+ }
673
+
337
674
  _updatePosition() {
338
675
  if (!this.visible || !this._anchorEl) {
339
676
  this.style.display = "none";
@@ -342,7 +679,7 @@ class YumeMenu extends HTMLElement {
342
679
 
343
680
  const anchorRect = this._anchorEl.getBoundingClientRect();
344
681
 
345
- // Temporarily show off-screen to measure actual dimensions
682
+ // Measure menu off-screen so we know its size before placing it.
346
683
  this.style.visibility = "hidden";
347
684
  this.style.display = "block";
348
685
  const menuRect = this.getBoundingClientRect();
@@ -350,61 +687,43 @@ class YumeMenu extends HTMLElement {
350
687
 
351
688
  const vw = window.innerWidth;
352
689
  const vh = window.innerHeight;
690
+ const { top, left } = this._computeMenuOffset(
691
+ this.direction,
692
+ anchorRect,
693
+ menuRect,
694
+ vw,
695
+ vh,
696
+ );
353
697
 
354
- let top, left;
355
-
356
- if (this.direction === "right") {
357
- top = anchorRect.top;
358
- left = anchorRect.right;
359
-
360
- if (left + menuRect.width > vw) {
361
- left = anchorRect.left - menuRect.width;
362
- }
363
- if (top + menuRect.height > vh) {
364
- top = anchorRect.top - menuRect.height;
365
- }
366
- } else if (this.direction === "up") {
367
- top = anchorRect.top - menuRect.height;
368
- left = anchorRect.left;
369
-
370
- if (top < 0) {
371
- top = anchorRect.bottom;
372
- }
373
- if (left + menuRect.width > vw) {
374
- left = vw - menuRect.width - 10;
375
- }
376
- } else if (this.direction === "left") {
377
- top = anchorRect.top;
378
- left = anchorRect.left - menuRect.width;
379
-
380
- if (left < 0) {
381
- left = anchorRect.right;
382
- }
383
- if (top + menuRect.height > vh) {
384
- top = anchorRect.top - menuRect.height;
385
- }
386
- } else {
387
- // "down" (default)
388
- top = anchorRect.bottom;
389
- left = anchorRect.left;
698
+ const clampedTop = Math.max(0, Math.min(top, vh - menuRect.height));
699
+ const clampedLeft = Math.max(0, Math.min(left, vw - menuRect.width));
390
700
 
391
- if (top + menuRect.height > vh) {
392
- top = anchorRect.top - menuRect.height;
393
- }
394
- if (left + menuRect.width > vw) {
395
- left = vw - menuRect.width - 10;
396
- }
397
- }
701
+ this.style.top = `${clampedTop}px`;
702
+ this.style.left = `${clampedLeft}px`;
703
+ this.style.display = "block";
704
+ }
398
705
 
399
- top = Math.max(0, Math.min(top, vh - menuRect.height));
400
- left = Math.max(0, Math.min(left, vw - menuRect.width));
706
+ static _warnTemplateFieldDeprecated() {
707
+ if (YumeMenu._templateFieldDeprecationWarned) return;
708
+ YumeMenu._templateFieldDeprecationWarned = true;
709
+ // eslint-disable-next-line no-console
710
+ console.warn(
711
+ "[y-menu] item.template and item['icon-template'] are deprecated; use item.icon (icon name) and item.slot (named slot) instead. Support will be removed in a future release.",
712
+ );
713
+ }
401
714
 
402
- this.style.top = `${top}px`;
403
- this.style.left = `${left}px`;
404
- this.style.display = "block";
715
+ static _warnUrlDeprecated() {
716
+ if (YumeMenu._urlDeprecationWarned) return;
717
+ YumeMenu._urlDeprecationWarned = true;
718
+ // eslint-disable-next-line no-console
719
+ console.warn(
720
+ "[y-menu] item.url is deprecated; use item.href instead. Support for item.url will be removed in a future release.",
721
+ );
405
722
  }
406
723
  }
407
724
 
408
725
  if (!customElements.get("y-menu")) {
409
726
  customElements.define("y-menu", YumeMenu);
410
727
  }
728
+
729
+ export { YumeMenu };