@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,4 +1,4 @@
1
- import { contrastTextColor, resolveAnchor } from '../../modules/helpers.js';
1
+ import { contrastTextColor, createElement, resolveAnchor } from '../../modules/helpers.js';
2
2
  import { getIcon } from '../../icons/registry.js';
3
3
 
4
4
  class YumeButton extends HTMLElement {
@@ -782,16 +782,6 @@ if (!customElements.get("y-icon")) {
782
782
  customElements.define("y-icon", YumeIcon);
783
783
  }
784
784
 
785
- 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";
786
-
787
- var chevronDown = "<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=\"6 9 12 15 18 9\"/>\n</svg>\n";
788
-
789
- var expandLeft = "<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=\"11 17 6 12 11 7\"/>\n <polyline points=\"18 17 13 12 18 7\"/>\n</svg>\n";
790
-
791
- var expandRight = "<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=\"13 17 18 12 13 7\"/>\n <polyline points=\"6 17 11 12 6 7\"/>\n</svg>\n";
792
-
793
- var menu = "<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 <line x1=\"4\" y1=\"6\" x2=\"20\" y2=\"6\"/>\n <line x1=\"4\" y1=\"12\" x2=\"20\" y2=\"12\"/>\n <line x1=\"4\" y1=\"18\" x2=\"20\" y2=\"18\"/>\n</svg>\n";
794
-
795
785
  class YumeMenu extends HTMLElement {
796
786
  static get observedAttributes() {
797
787
  return ["items", "anchor", "visible", "direction", "size", "history"];
@@ -807,6 +797,8 @@ class YumeMenu extends HTMLElement {
807
797
  this._onAnchorClick = this._onAnchorClick.bind(this);
808
798
  this._onDocumentClick = this._onDocumentClick.bind(this);
809
799
  this._onScrollOrResize = this._onScrollOrResize.bind(this);
800
+ this._isReady = false;
801
+ this._slottedHandlers = new Map();
810
802
  }
811
803
 
812
804
  connectedCallback() {
@@ -823,10 +815,13 @@ class YumeMenu extends HTMLElement {
823
815
  this.style.zIndex = "1000";
824
816
  this.style.display = "none";
825
817
  if (this.visible) this._updatePosition();
818
+
819
+ this._isReady = true;
826
820
  }
827
821
 
828
822
  disconnectedCallback() {
829
823
  this._teardownAnchor();
824
+ this._teardownSlottedItems();
830
825
 
831
826
  document.removeEventListener("click", this._onDocumentClick);
832
827
  window.removeEventListener("scroll", this._onScrollOrResize, true);
@@ -846,6 +841,13 @@ class YumeMenu extends HTMLElement {
846
841
  if (name === "visible" || name === "direction") {
847
842
  this._updatePosition();
848
843
  }
844
+
845
+ if (name === "visible" && this._isReady) {
846
+ this.dispatchEvent(new CustomEvent(this.visible ? "open" : "close", {
847
+ bubbles: true,
848
+ composed: true,
849
+ }));
850
+ }
849
851
  }
850
852
 
851
853
  // -------------------------------------------------------------------------
@@ -860,6 +862,16 @@ class YumeMenu extends HTMLElement {
860
862
  get direction() { return this.getAttribute("direction") || "down"; }
861
863
  set direction(val) { this.setAttribute("direction", val); }
862
864
 
865
+ /**
866
+ * Navigation mode: omit for pushState (SPA-friendly), set to "false" for full-page navigation.
867
+ * Regardless of this setting, a cancelable "navigate" event is always dispatched first.
868
+ */
869
+ get history() { return this.getAttribute("history"); }
870
+ set history(val) {
871
+ if (val != null) this.setAttribute("history", val);
872
+ else this.removeAttribute("history");
873
+ }
874
+
863
875
  /** Menu items array (JSON attribute). */
864
876
  get items() {
865
877
  try {
@@ -872,16 +884,6 @@ class YumeMenu extends HTMLElement {
872
884
  this.setAttribute("items", Array.isArray(val) ? JSON.stringify(val) : (val ?? "[]"));
873
885
  }
874
886
 
875
- /**
876
- * Navigation mode: omit for pushState (SPA-friendly), set to "false" for full-page navigation.
877
- * Regardless of this setting, a cancelable "navigate" event is always dispatched first.
878
- */
879
- get history() { return this.getAttribute("history"); }
880
- set history(val) {
881
- if (val != null) this.setAttribute("history", val);
882
- else this.removeAttribute("history");
883
- }
884
-
885
887
  /** Size: "small" | "medium" | "large" (default "medium"). */
886
888
  get size() {
887
889
  const sz = this.getAttribute("size");
@@ -905,63 +907,94 @@ class YumeMenu extends HTMLElement {
905
907
  render() {
906
908
  this.shadowRoot.innerHTML = "";
907
909
 
908
- const style = document.createElement("style");
909
- style.textContent = this._buildStyles();
910
- this.shadowRoot.appendChild(style);
910
+ const style = createElement("style", {}, [this._buildStyles()]);
911
911
 
912
- const rootUl = this._createMenuList(this.items);
913
- rootUl.classList.add("menu");
914
- rootUl.setAttribute("role", "menu");
915
- rootUl.setAttribute("part", "menu");
912
+ const root = this._createMenuList(this.items);
913
+ root.classList.add("menu");
914
+ root.setAttribute("role", "menu");
915
+ root.setAttribute("part", "menu");
916
916
 
917
- this.shadowRoot.appendChild(rootUl);
917
+ const childSlot = createElement("slot");
918
+ childSlot.addEventListener("slotchange", () => this._processSlottedItems());
919
+ root.appendChild(childSlot);
920
+
921
+ this.shadowRoot.appendChild(style);
922
+ this.shadowRoot.appendChild(root);
923
+ this._processSlottedItems();
918
924
  }
919
925
 
920
926
  // -------------------------------------------------------------------------
921
927
  // Private
922
928
  // -------------------------------------------------------------------------
923
929
 
930
+ _activateItem(item) {
931
+ if (item.children?.length > 0) return;
932
+
933
+ this._dispatchSelect({
934
+ value: item.value ?? item.text,
935
+ item,
936
+ });
937
+
938
+ const href = item.href ?? item.url;
939
+ if (href) this._navigateTo(href);
940
+
941
+ this.visible = false;
942
+ }
943
+
944
+ _activateSlottedItem(el) {
945
+ this._dispatchSelect({
946
+ value: el.dataset.value ?? el.textContent.trim(),
947
+ element: el,
948
+ });
949
+ this.visible = false;
950
+ }
951
+
924
952
  _buildStyles() {
925
953
  const paddingVar = `var(--component-button-padding-${this.size}, 0.5rem)`;
926
954
  return `
927
- ul.menu,
928
- ul.submenu {
929
- list-style: none;
930
- margin: 0;
931
- padding: 0;
955
+ .menu,
956
+ .submenu {
932
957
  background: var(--component-menu-background, #0c0c0d);
933
958
  border: var(--component-menu-border-width, 1px) solid var(--component-menu-border-color, #37383a);
934
959
  border-radius: var(--component-menu-border-radius, 4px);
935
960
  box-shadow: var(--component-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.15));
936
961
  min-width: 150px;
962
+ display: flex;
963
+ flex-direction: column;
937
964
  }
938
965
 
939
- li.menuitem {
966
+ .menuitem,
967
+ ::slotted(:not([slot])) {
940
968
  cursor: pointer;
941
969
  padding: ${paddingVar};
942
- display: flex;
943
- align-items: center;
944
- justify-content: space-between;
945
970
  white-space: nowrap;
946
971
  color: var(--component-menu-color, #f7f7fa);
947
972
  font-size: var(--font-size-button, 1em);
973
+ box-sizing: border-box;
974
+ }
975
+
976
+ .menuitem {
977
+ display: flex;
978
+ align-items: center;
979
+ justify-content: space-between;
948
980
  position: relative;
949
981
  }
950
982
 
951
- li.menuitem:hover {
983
+ .menuitem:hover,
984
+ ::slotted(:not([slot]):hover) {
952
985
  background: var(--component-menu-hover-background, #292a2b);
953
986
  }
954
987
 
955
- li.menuitem.selected {
988
+ .menuitem.selected {
956
989
  background: var(--component-menu-selected-background);
957
990
  color: var(--component-menu-selected-color);
958
991
  }
959
992
 
960
- li.menuitem.selected:hover {
993
+ .menuitem.selected:hover {
961
994
  background: var(--component-menu-selected-background);
962
995
  }
963
996
 
964
- ul.submenu {
997
+ .submenu {
965
998
  position: absolute;
966
999
  top: 0;
967
1000
  left: 100%;
@@ -969,8 +1002,8 @@ class YumeMenu extends HTMLElement {
969
1002
  z-index: var(--component-menu-z-index, 1001);
970
1003
  }
971
1004
 
972
- li.menuitem:hover > ul.submenu {
973
- display: block;
1005
+ .menuitem:hover > .submenu {
1006
+ display: flex;
974
1007
  }
975
1008
 
976
1009
  .submenu-indicator {
@@ -980,13 +1013,11 @@ class YumeMenu extends HTMLElement {
980
1013
  opacity: 0.6;
981
1014
  }
982
1015
 
983
- .submenu-indicator svg {
984
- width: 16px;
985
- height: 16px;
986
- }
987
-
988
1016
  .item-content {
989
1017
  flex: 1;
1018
+ display: inline-flex;
1019
+ align-items: center;
1020
+ gap: 0.5rem;
990
1021
  }
991
1022
  `;
992
1023
  }
@@ -999,91 +1030,140 @@ class YumeMenu extends HTMLElement {
999
1030
  });
1000
1031
  }
1001
1032
 
1002
- _createMenuList(items) {
1003
- const ul = document.createElement("ul");
1004
-
1005
- items.forEach((item) => {
1006
- const li = document.createElement("li");
1007
- li.className = item.selected ? "menuitem selected" : "menuitem";
1008
- li.setAttribute("role", "menuitem");
1009
- li.setAttribute("part", item.selected ? "menuitem selected" : "menuitem");
1010
- li.setAttribute("aria-current", item.selected ? "true" : "false");
1011
- li.tabIndex = 0;
1012
-
1013
- const contentWrapper = document.createElement("span");
1014
- contentWrapper.className = "item-content";
1015
-
1016
- if (item["icon-template"]) {
1017
- const iconTpl = this._findTemplate(item["icon-template"]);
1018
- if (iconTpl)
1019
- contentWrapper.appendChild(iconTpl.content.cloneNode(true));
1020
- }
1021
-
1022
- if (item.template) {
1023
- const textTpl = this._findTemplate(item.template);
1024
- if (textTpl) {
1025
- contentWrapper.appendChild(textTpl.content.cloneNode(true));
1026
- } else {
1027
- contentWrapper.append(item.text);
1028
- }
1029
- } else {
1030
- contentWrapper.append(item.text);
1031
- }
1033
+ _computeMenuOffset(direction, anchorRect, menuRect, vw, vh) {
1034
+ if (direction === "right") {
1035
+ let top = anchorRect.top;
1036
+ let left = anchorRect.right;
1037
+ if (left + menuRect.width > vw) left = anchorRect.left - menuRect.width;
1038
+ if (top + menuRect.height > vh) top = anchorRect.top - menuRect.height;
1039
+ return { top, left };
1040
+ }
1032
1041
 
1033
- li.appendChild(contentWrapper);
1042
+ if (direction === "up") {
1043
+ let top = anchorRect.top - menuRect.height;
1044
+ let left = anchorRect.left;
1045
+ if (top < 0) top = anchorRect.bottom;
1046
+ if (left + menuRect.width > vw) left = vw - menuRect.width - 10;
1047
+ return { top, left };
1048
+ }
1034
1049
 
1035
- if (item.url) {
1036
- li.addEventListener("click", () => {
1037
- const event = new CustomEvent("navigate", {
1038
- bubbles: true,
1039
- composed: true,
1040
- cancelable: true,
1041
- detail: { href: item.url },
1042
- });
1043
- const cancelled = !this.dispatchEvent(event);
1044
- if (cancelled) return;
1045
- if (this.getAttribute("history") !== "false") {
1046
- history.pushState({}, "", item.url);
1047
- window.dispatchEvent(new PopStateEvent("popstate", { state: {} }));
1048
- } else {
1049
- window.location.href = item.url;
1050
- }
1051
- });
1052
- }
1050
+ if (direction === "left") {
1051
+ let top = anchorRect.top;
1052
+ let left = anchorRect.left - menuRect.width;
1053
+ if (left < 0) left = anchorRect.right;
1054
+ if (top + menuRect.height > vh) top = anchorRect.top - menuRect.height;
1055
+ return { top, left };
1056
+ }
1053
1057
 
1054
- if (!item.children?.length) {
1055
- li.addEventListener("click", () => {
1056
- this.visible = false;
1057
- });
1058
- }
1058
+ // "down" (default)
1059
+ let top = anchorRect.bottom;
1060
+ let left = anchorRect.left;
1061
+ if (top + menuRect.height > vh) top = anchorRect.top - menuRect.height;
1062
+ if (left + menuRect.width > vw) left = vw - menuRect.width - 10;
1063
+ return { top, left };
1064
+ }
1059
1065
 
1060
- if (item.children?.length) {
1061
- const indicator = document.createElement("span");
1062
- indicator.className = "submenu-indicator";
1063
- indicator.innerHTML = chevronRight;
1064
- li.appendChild(indicator);
1066
+ _createItemContent(item) {
1067
+ const wrapper = createElement("span", { class: "item-content" });
1065
1068
 
1066
- const submenu = this._createMenuList(item.children);
1067
- submenu.classList.add("submenu");
1068
- submenu.setAttribute("role", "menu");
1069
- li.appendChild(submenu);
1070
- }
1069
+ if (item.icon) {
1070
+ wrapper.appendChild(createElement("y-icon", { name: item.icon, size: this.size }));
1071
+ } else if (item["icon-template"]) {
1072
+ YumeMenu._warnTemplateFieldDeprecated();
1073
+ const tpl = this._findTemplate(item["icon-template"]);
1074
+ if (tpl) wrapper.appendChild(tpl.content.cloneNode(true));
1075
+ }
1076
+
1077
+ if (item.template) {
1078
+ YumeMenu._warnTemplateFieldDeprecated();
1079
+ const tpl = this._findTemplate(item.template);
1080
+ if (tpl) wrapper.appendChild(tpl.content.cloneNode(true));
1081
+ else wrapper.append(item.text ?? "");
1082
+ } else {
1083
+ wrapper.append(item.text ?? "");
1084
+ }
1085
+
1086
+ if (!item.slot) return wrapper;
1087
+
1088
+ const slotEl = createElement("slot", { name: item.slot });
1089
+ slotEl.appendChild(wrapper);
1090
+ return slotEl;
1091
+ }
1071
1092
 
1072
- ul.appendChild(li);
1093
+ _createMenuItem(item) {
1094
+ const isSelected = !!item.selected;
1095
+ const partValue = isSelected ? "menuitem selected" : "menuitem";
1096
+
1097
+ const itemEl = createElement("div", {
1098
+ class: partValue,
1099
+ role: "menuitem",
1100
+ part: partValue,
1101
+ "aria-current": isSelected ? "true" : "false",
1102
+ tabindex: "0",
1073
1103
  });
1074
1104
 
1075
- return ul;
1105
+ itemEl.appendChild(this._createItemContent(item));
1106
+
1107
+ if (item.url && !item.href) YumeMenu._warnUrlDeprecated();
1108
+
1109
+ itemEl.addEventListener("click", () => this._activateItem(item));
1110
+
1111
+ if (item.children?.length > 0) {
1112
+ itemEl.appendChild(this._createSubmenuIndicator());
1113
+
1114
+ const submenu = this._createMenuList(item.children);
1115
+ submenu.classList.add("submenu");
1116
+ submenu.setAttribute("role", "menu");
1117
+ itemEl.appendChild(submenu);
1118
+ }
1119
+
1120
+ return itemEl;
1121
+ }
1122
+
1123
+ _createMenuList(items) {
1124
+ const container = createElement("div");
1125
+ items.forEach((item) => container.appendChild(this._createMenuItem(item)));
1126
+ return container;
1127
+ }
1128
+
1129
+ _createSubmenuIndicator() {
1130
+ return createElement("span", { class: "submenu-indicator" }, [
1131
+ createElement("y-icon", { name: "chevron-right", size: this.size }),
1132
+ ]);
1133
+ }
1134
+
1135
+ _dispatchSelect(detail) {
1136
+ this.dispatchEvent(new CustomEvent("select", {
1137
+ detail,
1138
+ bubbles: true,
1139
+ composed: true,
1140
+ }));
1076
1141
  }
1077
1142
 
1078
1143
  _findTemplate(name) {
1079
1144
  return this.querySelector(`template[slot="${name}"]`);
1080
1145
  }
1081
1146
 
1147
+ _navigateTo(href) {
1148
+ const event = new CustomEvent("navigate", {
1149
+ bubbles: true,
1150
+ composed: true,
1151
+ cancelable: true,
1152
+ detail: { href },
1153
+ });
1154
+ if (!this.dispatchEvent(event)) return;
1155
+
1156
+ if (this.getAttribute("history") === "false") {
1157
+ window.location.href = href;
1158
+ } else {
1159
+ history.pushState({}, "", href);
1160
+ window.dispatchEvent(new PopStateEvent("popstate", { state: {} }));
1161
+ }
1162
+ }
1163
+
1082
1164
  _onAnchorClick(e) {
1083
1165
  e.stopPropagation();
1084
- if (!this.visible) {
1085
- YumeMenu._closeAll(this);
1086
- }
1166
+ if (!this.visible) YumeMenu._closeAll(this);
1087
1167
  this.visible = !this.visible;
1088
1168
  }
1089
1169
 
@@ -1098,9 +1178,34 @@ class YumeMenu extends HTMLElement {
1098
1178
  if (this.visible) this._updatePosition();
1099
1179
  }
1100
1180
 
1181
+ _processSlottedItems() {
1182
+ const slot = this.shadowRoot.querySelector(".menu > slot");
1183
+ if (!slot) return;
1184
+
1185
+ const assigned = new Set(slot.assignedElements());
1186
+
1187
+ for (const [el, handler] of this._slottedHandlers) {
1188
+ if (assigned.has(el)) continue;
1189
+ el.removeEventListener("click", handler);
1190
+ this._slottedHandlers.delete(el);
1191
+ }
1192
+
1193
+ for (const el of assigned) {
1194
+ if (this._slottedHandlers.has(el)) continue;
1195
+
1196
+ if (!el.hasAttribute("role")) el.setAttribute("role", "menuitem");
1197
+ if (el.tabIndex < 0) el.tabIndex = 0;
1198
+
1199
+ const handler = () => this._activateSlottedItem(el);
1200
+ el.addEventListener("click", handler);
1201
+ this._slottedHandlers.set(el, handler);
1202
+ }
1203
+ }
1204
+
1101
1205
  _setupAnchor() {
1102
1206
  const id = this.anchor;
1103
1207
  if (!id) return;
1208
+
1104
1209
  const root = this.getRootNode();
1105
1210
  this._anchorResolveDispose = resolveAnchor(
1106
1211
  this,
@@ -1124,6 +1229,13 @@ class YumeMenu extends HTMLElement {
1124
1229
  }
1125
1230
  }
1126
1231
 
1232
+ _teardownSlottedItems() {
1233
+ for (const [el, handler] of this._slottedHandlers) {
1234
+ el.removeEventListener("click", handler);
1235
+ }
1236
+ this._slottedHandlers.clear();
1237
+ }
1238
+
1127
1239
  _updatePosition() {
1128
1240
  if (!this.visible || !this._anchorEl) {
1129
1241
  this.style.display = "none";
@@ -1132,7 +1244,7 @@ class YumeMenu extends HTMLElement {
1132
1244
 
1133
1245
  const anchorRect = this._anchorEl.getBoundingClientRect();
1134
1246
 
1135
- // Temporarily show off-screen to measure actual dimensions
1247
+ // Measure menu off-screen so we know its size before placing it.
1136
1248
  this.style.visibility = "hidden";
1137
1249
  this.style.display = "block";
1138
1250
  const menuRect = this.getBoundingClientRect();
@@ -1140,58 +1252,38 @@ class YumeMenu extends HTMLElement {
1140
1252
 
1141
1253
  const vw = window.innerWidth;
1142
1254
  const vh = window.innerHeight;
1255
+ const { top, left } = this._computeMenuOffset(
1256
+ this.direction,
1257
+ anchorRect,
1258
+ menuRect,
1259
+ vw,
1260
+ vh,
1261
+ );
1143
1262
 
1144
- let top, left;
1145
-
1146
- if (this.direction === "right") {
1147
- top = anchorRect.top;
1148
- left = anchorRect.right;
1149
-
1150
- if (left + menuRect.width > vw) {
1151
- left = anchorRect.left - menuRect.width;
1152
- }
1153
- if (top + menuRect.height > vh) {
1154
- top = anchorRect.top - menuRect.height;
1155
- }
1156
- } else if (this.direction === "up") {
1157
- top = anchorRect.top - menuRect.height;
1158
- left = anchorRect.left;
1159
-
1160
- if (top < 0) {
1161
- top = anchorRect.bottom;
1162
- }
1163
- if (left + menuRect.width > vw) {
1164
- left = vw - menuRect.width - 10;
1165
- }
1166
- } else if (this.direction === "left") {
1167
- top = anchorRect.top;
1168
- left = anchorRect.left - menuRect.width;
1169
-
1170
- if (left < 0) {
1171
- left = anchorRect.right;
1172
- }
1173
- if (top + menuRect.height > vh) {
1174
- top = anchorRect.top - menuRect.height;
1175
- }
1176
- } else {
1177
- // "down" (default)
1178
- top = anchorRect.bottom;
1179
- left = anchorRect.left;
1263
+ const clampedTop = Math.max(0, Math.min(top, vh - menuRect.height));
1264
+ const clampedLeft = Math.max(0, Math.min(left, vw - menuRect.width));
1180
1265
 
1181
- if (top + menuRect.height > vh) {
1182
- top = anchorRect.top - menuRect.height;
1183
- }
1184
- if (left + menuRect.width > vw) {
1185
- left = vw - menuRect.width - 10;
1186
- }
1187
- }
1266
+ this.style.top = `${clampedTop}px`;
1267
+ this.style.left = `${clampedLeft}px`;
1268
+ this.style.display = "block";
1269
+ }
1188
1270
 
1189
- top = Math.max(0, Math.min(top, vh - menuRect.height));
1190
- left = Math.max(0, Math.min(left, vw - menuRect.width));
1271
+ static _warnTemplateFieldDeprecated() {
1272
+ if (YumeMenu._templateFieldDeprecationWarned) return;
1273
+ YumeMenu._templateFieldDeprecationWarned = true;
1274
+ // eslint-disable-next-line no-console
1275
+ console.warn(
1276
+ "[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.",
1277
+ );
1278
+ }
1191
1279
 
1192
- this.style.top = `${top}px`;
1193
- this.style.left = `${left}px`;
1194
- this.style.display = "block";
1280
+ static _warnUrlDeprecated() {
1281
+ if (YumeMenu._urlDeprecationWarned) return;
1282
+ YumeMenu._urlDeprecationWarned = true;
1283
+ // eslint-disable-next-line no-console
1284
+ console.warn(
1285
+ "[y-menu] item.url is deprecated; use item.href instead. Support for item.url will be removed in a future release.",
1286
+ );
1195
1287
  }
1196
1288
  }
1197
1289
 
@@ -1249,6 +1341,7 @@ class YumeAppbar extends HTMLElement {
1249
1341
  this._idCounter = 0;
1250
1342
  this._mql = null;
1251
1343
  this._isMobile = false;
1344
+ this._mobileOutsideClick = null;
1252
1345
  }
1253
1346
 
1254
1347
  connectedCallback() {
@@ -1258,6 +1351,7 @@ class YumeAppbar extends HTMLElement {
1258
1351
 
1259
1352
  disconnectedCallback() {
1260
1353
  this._teardownMediaQuery();
1354
+ this._teardownMobileOutsideClick();
1261
1355
  }
1262
1356
 
1263
1357
  attributeChangedCallback(name, oldVal, newVal) {
@@ -1273,12 +1367,26 @@ class YumeAppbar extends HTMLElement {
1273
1367
  // -------------------------------------------------------------------------
1274
1368
 
1275
1369
  /** Whether the sidebar is currently collapsed. */
1276
- get collapsed() { return this.hasAttribute("collapsed"); }
1370
+ get collapsed() {
1371
+ return this.hasAttribute("collapsed");
1372
+ }
1277
1373
  set collapsed(val) {
1278
1374
  if (val) this.setAttribute("collapsed", "");
1279
1375
  else this.removeAttribute("collapsed");
1280
1376
  }
1281
1377
 
1378
+ /**
1379
+ * Navigation mode: omit for pushState (SPA-friendly), set to "false" for full-page navigation.
1380
+ * Regardless of this setting, a cancelable "navigate" event is always dispatched first.
1381
+ */
1382
+ get history() {
1383
+ return this.getAttribute("history");
1384
+ }
1385
+ set history(val) {
1386
+ if (val != null) this.setAttribute("history", val);
1387
+ else this.removeAttribute("history");
1388
+ }
1389
+
1282
1390
  /** Nav items array parsed from the "items" attribute. */
1283
1391
  get items() {
1284
1392
  try {
@@ -1287,47 +1395,53 @@ class YumeAppbar extends HTMLElement {
1287
1395
  return [];
1288
1396
  }
1289
1397
  }
1290
- set items(val) { this.setAttribute("items", JSON.stringify(val)); }
1398
+ set items(val) {
1399
+ this.setAttribute("items", JSON.stringify(val));
1400
+ }
1291
1401
 
1292
1402
  /**
1293
1403
  * Direction menus pop out from nav buttons:
1294
1404
  * "right", "down", or unset (auto: vertical → right, horizontal → down).
1295
1405
  */
1296
- get menuDirection() { return this.getAttribute("menu-direction") || ""; }
1406
+ get menuDirection() {
1407
+ return this.getAttribute("menu-direction") || "";
1408
+ }
1297
1409
  set menuDirection(val) {
1298
1410
  if (val) this.setAttribute("menu-direction", val);
1299
1411
  else this.removeAttribute("menu-direction");
1300
1412
  }
1301
1413
 
1302
1414
  /** Whether the appbar is currently rendering in mobile mode. */
1303
- get mobile() { return this._isMobile; }
1415
+ get mobile() {
1416
+ return this._isMobile;
1417
+ }
1304
1418
 
1305
1419
  /**
1306
1420
  * Override the mobile breakpoint (in pixels) for this instance.
1307
1421
  * Falls back to the CSS variable --component-appbar-mobile-breakpoint (default 768).
1308
1422
  */
1309
- get mobileBreakpoint() { return this.getAttribute("mobile-breakpoint") || ""; }
1423
+ get mobileBreakpoint() {
1424
+ return this.getAttribute("mobile-breakpoint") || "";
1425
+ }
1310
1426
  set mobileBreakpoint(val) {
1311
1427
  if (val) this.setAttribute("mobile-breakpoint", val);
1312
1428
  else this.removeAttribute("mobile-breakpoint");
1313
1429
  }
1314
1430
 
1315
1431
  /** Layout orientation: "vertical" | "horizontal" (default "vertical"). */
1316
- get orientation() { return this.getAttribute("orientation") || "vertical"; }
1317
- set orientation(val) { this.setAttribute("orientation", val); }
1432
+ get orientation() {
1433
+ return this.getAttribute("orientation") || "vertical";
1434
+ }
1435
+ set orientation(val) {
1436
+ this.setAttribute("orientation", val);
1437
+ }
1318
1438
 
1319
1439
  /** Size variant: "small" | "medium" | "large" (default "medium"). */
1320
- get size() { return this.getAttribute("size") || "medium"; }
1321
- set size(val) { this.setAttribute("size", val); }
1322
-
1323
- /**
1324
- * Navigation mode: omit for pushState (SPA-friendly), set to "false" for full-page navigation.
1325
- * Regardless of this setting, a cancelable "navigate" event is always dispatched first.
1326
- */
1327
- get history() { return this.getAttribute("history"); }
1328
- set history(val) {
1329
- if (val != null) this.setAttribute("history", val);
1330
- else this.removeAttribute("history");
1440
+ get size() {
1441
+ return this.getAttribute("size") || "medium";
1442
+ }
1443
+ set size(val) {
1444
+ this.setAttribute("size", val);
1331
1445
  }
1332
1446
 
1333
1447
  /** Sticky position: "start" | "end" | false. */
@@ -1344,201 +1458,88 @@ class YumeAppbar extends HTMLElement {
1344
1458
  // Public
1345
1459
  // -------------------------------------------------------------------------
1346
1460
 
1461
+ render() {
1462
+ if (this._isMobile) this._renderMobile();
1463
+ else this._renderDesktop();
1464
+ }
1465
+
1347
1466
  /** Toggles the collapsed state of the sidebar. */
1348
1467
  toggle() {
1349
1468
  this.collapsed = !this.collapsed;
1350
1469
  }
1351
1470
 
1352
- render() {
1353
- if (this._isMobile) {
1354
- this._renderMobile();
1355
- } else {
1356
- this._renderDesktop();
1357
- }
1358
- }
1359
-
1360
1471
  // -------------------------------------------------------------------------
1361
1472
  // Private
1362
1473
  // -------------------------------------------------------------------------
1363
1474
 
1364
- _buildCollapseButton(cfg, isCollapsed) {
1365
- const btn = document.createElement("y-button");
1366
- btn.setAttribute("color", "base");
1367
- btn.setAttribute("style-type", "flat");
1368
- btn.setAttribute("size", cfg.buttonSize);
1369
- btn.setAttribute("aria-label", isCollapsed ? "Expand sidebar" : "Collapse sidebar");
1370
- btn.className = "collapse-btn";
1371
-
1372
- const icon = document.createElement("span");
1373
- icon.slot = "left-icon";
1374
- icon.innerHTML = isCollapsed ? expandRight : expandLeft;
1375
- btn.appendChild(icon);
1376
-
1377
- if (!isCollapsed) {
1378
- btn.appendChild(document.createTextNode("Collapse"));
1379
- }
1380
-
1381
- btn.addEventListener("click", this._onCollapseClick);
1382
- return btn;
1383
- }
1384
-
1385
- _buildHeader() {
1386
- const header = document.createElement("div");
1387
- header.className = "appbar-header";
1388
- header.setAttribute("part", "header");
1389
-
1390
- const headerContent = document.createElement("div");
1391
- headerContent.className = "header-content";
1392
-
1393
- const logoWrapper = document.createElement("div");
1394
- logoWrapper.className = "logo-wrapper";
1395
- logoWrapper.appendChild(this._makeSlot("logo"));
1396
- headerContent.appendChild(logoWrapper);
1475
+ _buildBody(cfg, isCollapsed, menuDir) {
1476
+ const body = createElement("div", { class: "appbar-body", part: "body" });
1397
1477
 
1398
- const titleWrapper = document.createElement("div");
1399
- titleWrapper.className = "header-title";
1400
- titleWrapper.appendChild(this._makeSlot("title"));
1401
- headerContent.appendChild(titleWrapper);
1478
+ this.items.forEach((item) => {
1479
+ body.appendChild(
1480
+ this._buildNavItem(item, cfg, isCollapsed, menuDir),
1481
+ );
1482
+ });
1483
+ body.appendChild(createElement("slot", {}));
1402
1484
 
1403
- header.appendChild(headerContent);
1404
- header.appendChild(this._makeSlot("header"));
1405
- return header;
1485
+ return body;
1406
1486
  }
1407
1487
 
1408
- _buildNavItem(item, cfg, isVertical, isCollapsed, menuDir) {
1409
- const hasChildren = item.children?.length > 0;
1410
- const wrapper = document.createElement("div");
1411
- const btn = document.createElement("y-button");
1412
- const btnId = this._uid("appbar-btn");
1413
-
1414
- wrapper.className = "nav-item";
1415
- btn.id = btnId;
1416
- btn.setAttribute("color", this._isItemActive(item) ? "primary" : "base");
1417
- btn.setAttribute("style-type", "flat");
1418
- btn.setAttribute("size", cfg.buttonSize);
1419
-
1420
- if (item.icon) {
1421
- if (item.icon.trim().startsWith("<")) {
1422
- const iconEl = document.createElement("span");
1423
- iconEl.slot = "left-icon";
1424
- iconEl.setAttribute("part", "icon");
1425
- iconEl.innerHTML = item.icon;
1426
- btn.appendChild(iconEl);
1427
- } else {
1428
- const iconEl = document.createElement("y-icon");
1429
- iconEl.slot = "left-icon";
1430
- iconEl.setAttribute("part", "icon");
1431
- iconEl.setAttribute("name", item.icon);
1432
- iconEl.setAttribute("size", cfg.iconSize);
1433
- btn.appendChild(iconEl);
1434
- }
1435
- }
1436
-
1437
- if (item.text && !isCollapsed) {
1438
- btn.appendChild(document.createTextNode(item.text));
1439
- }
1440
-
1441
- if (hasChildren && !isCollapsed) {
1442
- const arrow = document.createElement("span");
1443
- arrow.slot = "right-icon";
1444
- arrow.innerHTML = isVertical ? chevronRight : chevronDown;
1445
- btn.appendChild(arrow);
1446
- }
1447
-
1448
- if (item.href && !hasChildren) {
1449
- btn.addEventListener("click", () => {
1450
- const event = new CustomEvent("navigate", {
1451
- bubbles: true,
1452
- composed: true,
1453
- cancelable: true,
1454
- detail: { href: item.href },
1455
- });
1456
- const cancelled = !this.dispatchEvent(event);
1457
- if (cancelled) return;
1458
- if (this.getAttribute("history") !== "false") {
1459
- history.pushState({}, "", item.href);
1460
- window.dispatchEvent(new PopStateEvent("popstate", { state: {} }));
1461
- } else {
1462
- window.location.href = item.href;
1463
- }
1464
- });
1465
- }
1466
-
1467
- if (item.slot) {
1468
- const slot = this._makeSlot(item.slot);
1469
- slot.appendChild(btn);
1470
- wrapper.appendChild(slot);
1471
- } else {
1472
- wrapper.appendChild(btn);
1473
- }
1474
-
1475
- if (hasChildren) {
1476
- const menuEl = document.createElement("y-menu");
1477
- menuEl.setAttribute("anchor", btnId);
1478
- menuEl.setAttribute("direction", menuDir);
1479
- menuEl.setAttribute("size", cfg.buttonSize);
1480
- menuEl.items = item.children;
1481
- wrapper.appendChild(menuEl);
1482
- }
1488
+ _buildCollapseButton(cfg, isCollapsed) {
1489
+ const icon = createElement("y-icon", {
1490
+ slot: "left-icon",
1491
+ name: isCollapsed ? "expand-right" : "expand-left",
1492
+ size: cfg.iconSize,
1493
+ });
1483
1494
 
1484
- return wrapper;
1485
- }
1495
+ const children = isCollapsed ? [icon] : [icon, "Collapse"];
1496
+ const btn = createElement(
1497
+ "y-button",
1498
+ {
1499
+ class: "collapse-btn",
1500
+ color: "base",
1501
+ "style-type": "flat",
1502
+ size: cfg.buttonSize,
1503
+ "aria-label": isCollapsed
1504
+ ? "Expand sidebar"
1505
+ : "Collapse sidebar",
1506
+ },
1507
+ children,
1508
+ );
1486
1509
 
1487
- _getBreakpointPx() {
1488
- const attr = this.mobileBreakpoint;
1489
- if (attr) {
1490
- const px = parseInt(attr, 10);
1491
- if (!isNaN(px) && px > 0) return px;
1492
- }
1493
- const cssVal = getComputedStyle(document.documentElement)
1494
- .getPropertyValue("--component-appbar-mobile-breakpoint")
1495
- .trim();
1496
- if (cssVal) {
1497
- const px = parseInt(cssVal, 10);
1498
- if (!isNaN(px) && px > 0) return px;
1499
- }
1500
- return 768;
1510
+ btn.addEventListener("click", this._onCollapseClick);
1511
+ return btn;
1501
1512
  }
1502
1513
 
1503
- _initRender() {
1504
- this.shadowRoot.innerHTML = "";
1505
- this._idCounter = 0;
1506
- }
1514
+ _buildDesktopBar(cfg, isVertical, isCollapsed, menuDir) {
1515
+ const classes = ["appbar", isVertical ? "vertical" : "horizontal"];
1516
+ if (isCollapsed) classes.push("collapsed");
1507
1517
 
1508
- _isItemActive(item) {
1509
- if (item.selected) return true;
1510
- if (item.href) {
1511
- const loc = window.location;
1512
- const current = loc.pathname + loc.search + loc.hash;
1513
- return item.href === current || item.href === loc.href;
1514
- }
1515
- return false;
1516
- }
1518
+ const bar = createElement("div", {
1519
+ class: classes.join(" "),
1520
+ role: "navigation",
1521
+ });
1517
1522
 
1518
- _makeSlot(name) {
1519
- const slot = document.createElement("slot");
1520
- slot.name = name;
1521
- return slot;
1522
- }
1523
+ // Layout-specific sizing tokens consumed by the stylesheet.
1524
+ const iconColWidth = `calc(${cfg.collapsedWidth} - 2 * var(--_appbar-padding) - 2 * var(--component-appbar-border-width, var(--component-sidebar-border-width, 2px)) - 2 * var(--component-button-border-width, 1px))`;
1525
+ bar.style.setProperty("--_appbar-padding", cfg.padding);
1526
+ bar.style.setProperty("--_appbar-collapsed-width", cfg.collapsedWidth);
1527
+ bar.style.setProperty("--_appbar-body-gap", cfg.bodyGap);
1528
+ bar.style.setProperty(
1529
+ "--_button-padding",
1530
+ `var(--component-button-padding-${cfg.buttonSize})`,
1531
+ );
1532
+ bar.style.setProperty("--_icon-col-width", iconColWidth);
1523
1533
 
1524
- _onCollapseClick() {
1525
- this.toggle();
1526
- }
1534
+ bar.appendChild(this._buildHeader());
1535
+ bar.appendChild(this._buildBody(cfg, isCollapsed, menuDir));
1536
+ bar.appendChild(this._buildFooter(cfg, isVertical, isCollapsed));
1527
1537
 
1528
- _onMediaChange(e) {
1529
- this._isMobile = e.matches;
1530
- this.render();
1538
+ return bar;
1531
1539
  }
1532
1540
 
1533
- _renderDesktop() {
1534
- this._initRender();
1535
- const isVertical = this.orientation === "vertical";
1536
- const isCollapsed = this.collapsed && isVertical;
1537
- const cfg = SIZE_CONFIG[this.size] || SIZE_CONFIG.medium;
1538
- const menuDir = this.menuDirection || (isVertical ? "right" : "down");
1539
-
1540
- const style = document.createElement("style");
1541
- style.textContent = `
1541
+ _buildDesktopStyles() {
1542
+ return `
1542
1543
  :host {
1543
1544
  display: block;
1544
1545
  font-family: var(--font-family-body, sans-serif);
@@ -1766,55 +1767,148 @@ class YumeAppbar extends HTMLElement {
1766
1767
  ::slotted(*) {
1767
1768
  display: block;
1768
1769
  }
1770
+ .appbar.vertical ::slotted(:not([slot])) {
1771
+ width: 100%;
1772
+ }
1773
+ .appbar.horizontal ::slotted(:not([slot])) {
1774
+ display: inline-flex;
1775
+ align-items: center;
1776
+ }
1777
+ .appbar.vertical.collapsed ::slotted(:not([slot])) {
1778
+ width: var(--_icon-col-width);
1779
+ overflow: hidden;
1780
+ }
1769
1781
  span[slot="left-icon"] svg,
1770
1782
  span[slot="right-icon"] svg {
1771
1783
  width: var(--component-icon-size-large, 1.25em);
1772
1784
  height: var(--component-icon-size-large, 1.25em);
1773
1785
  }
1774
1786
  `;
1775
- this.shadowRoot.appendChild(style);
1787
+ }
1788
+
1789
+ _buildFooter(cfg, isVertical, isCollapsed) {
1790
+ const footer = createElement("div", { class: "appbar-footer", part: "footer" });
1791
+ footer.appendChild(createElement("slot", { name: "footer" }));
1792
+
1793
+ if (isVertical) {
1794
+ footer.appendChild(this._buildCollapseButton(cfg, isCollapsed));
1795
+ }
1796
+
1797
+ return footer;
1798
+ }
1799
+
1800
+ _buildHeader() {
1801
+ const logoWrapper = createElement("div", { class: "logo-wrapper" }, [
1802
+ createElement("slot", { name: "logo" }),
1803
+ ]);
1804
+ const titleWrapper = createElement("div", { class: "header-title" }, [
1805
+ createElement("slot", { name: "title" }),
1806
+ ]);
1807
+ const headerContent = createElement("div", { class: "header-content" }, [
1808
+ logoWrapper,
1809
+ titleWrapper,
1810
+ ]);
1811
+
1812
+ return createElement("div", { class: "appbar-header", part: "header" }, [
1813
+ headerContent,
1814
+ createElement("slot", { name: "header" }),
1815
+ ]);
1816
+ }
1817
+
1818
+ _buildItemIcon(iconValue, cfg) {
1819
+ // Raw SVG markup is preserved as a public escape hatch; everything else
1820
+ // routes through y-icon for consistent sizing/theming.
1821
+ if (iconValue.trim().startsWith("<")) {
1822
+ const span = createElement("span", { slot: "left-icon", part: "icon" });
1823
+ span.innerHTML = iconValue;
1824
+ return span;
1825
+ }
1826
+ return createElement("y-icon", {
1827
+ slot: "left-icon",
1828
+ part: "icon",
1829
+ name: iconValue,
1830
+ size: cfg.iconSize,
1831
+ });
1832
+ }
1776
1833
 
1777
- const bar = document.createElement("div");
1778
- bar.className = `appbar ${isVertical ? "vertical" : "horizontal"}`;
1779
- if (isCollapsed) bar.classList.add("collapsed");
1780
- bar.setAttribute("role", "navigation");
1834
+ _buildMobileBar(cfg) {
1835
+ const bar = createElement("div", { class: "appbar", role: "navigation" });
1781
1836
  bar.style.setProperty("--_appbar-padding", cfg.padding);
1782
- bar.style.setProperty("--_appbar-collapsed-width", cfg.collapsedWidth);
1783
- bar.style.setProperty("--_appbar-body-gap", cfg.bodyGap);
1784
- bar.style.setProperty("--_button-padding", `var(--component-button-padding-${cfg.buttonSize})`);
1785
- bar.style.setProperty(
1786
- "--_icon-col-width",
1787
- `calc(${cfg.collapsedWidth} - 2 * var(--_appbar-padding) - 2 * var(--component-appbar-border-width, var(--component-sidebar-border-width, 2px)) - 2 * var(--component-button-border-width, 1px))`,
1788
- );
1789
1837
 
1790
- bar.appendChild(this._buildHeader());
1838
+ bar.appendChild(this._buildMobileStart(cfg));
1839
+ bar.appendChild(this._buildMobileCenter());
1840
+ bar.appendChild(this._buildMobileEnd());
1841
+
1842
+ return bar;
1843
+ }
1844
+
1845
+ _buildMobileCenter() {
1846
+ return createElement("div", { class: "mobile-center" }, [
1847
+ createElement("slot", { name: "logo" }),
1848
+ createElement("slot", { name: "title" }),
1849
+ ]);
1850
+ }
1851
+
1852
+ _buildMobileEnd() {
1853
+ return createElement("div", { class: "mobile-end", part: "footer" }, [
1854
+ createElement("slot", { name: "footer" }),
1855
+ ]);
1856
+ }
1791
1857
 
1792
- const body = document.createElement("div");
1793
- body.className = "appbar-body";
1794
- body.setAttribute("part", "body");
1858
+ _buildMobileStart(cfg) {
1859
+ const menuBtnId = this._uid("appbar-mobile-menu");
1860
+ const panelId = this._uid("appbar-mobile-panel");
1861
+
1862
+ const menuBtn = createElement(
1863
+ "y-button",
1864
+ {
1865
+ id: menuBtnId,
1866
+ color: "base",
1867
+ "style-type": "flat",
1868
+ size: cfg.buttonSize,
1869
+ "aria-label": "Open menu",
1870
+ "aria-controls": panelId,
1871
+ "aria-expanded": "false",
1872
+ },
1873
+ [
1874
+ createElement("y-icon", {
1875
+ slot: "left-icon",
1876
+ name: "menu",
1877
+ size: cfg.iconSize,
1878
+ }),
1879
+ ],
1880
+ );
1881
+
1882
+ const panel = createElement("div", { id: panelId, class: "mobile-panel" });
1795
1883
  this.items.forEach((item) => {
1796
- body.appendChild(this._buildNavItem(item, cfg, isVertical, isCollapsed, menuDir));
1884
+ panel.appendChild(this._buildNavItem(item, cfg, false, "down"));
1797
1885
  });
1798
- bar.appendChild(body);
1886
+ panel.appendChild(createElement("slot", {}));
1799
1887
 
1800
- const footer = document.createElement("div");
1801
- footer.className = "appbar-footer";
1802
- footer.setAttribute("part", "footer");
1803
- footer.appendChild(this._makeSlot("footer"));
1804
- if (isVertical) {
1805
- footer.appendChild(this._buildCollapseButton(cfg, isCollapsed));
1806
- }
1807
- bar.appendChild(footer);
1888
+ const closePanel = () => {
1889
+ panel.classList.remove("open");
1890
+ menuBtn.setAttribute("aria-expanded", "false");
1891
+ };
1808
1892
 
1809
- this.shadowRoot.appendChild(bar);
1810
- }
1893
+ menuBtn.addEventListener("click", (e) => {
1894
+ e.stopPropagation();
1895
+ const open = panel.classList.toggle("open");
1896
+ menuBtn.setAttribute("aria-expanded", open ? "true" : "false");
1897
+ });
1811
1898
 
1812
- _renderMobile() {
1813
- this._initRender();
1814
- const cfg = SIZE_CONFIG[this.size] || SIZE_CONFIG.medium;
1899
+ panel.addEventListener("navigate", closePanel);
1815
1900
 
1816
- const style = document.createElement("style");
1817
- style.textContent = `
1901
+ this._mobileOutsideClick = (e) => {
1902
+ if (e.composedPath().includes(this)) return;
1903
+ closePanel();
1904
+ };
1905
+ document.addEventListener("pointerdown", this._mobileOutsideClick);
1906
+
1907
+ return createElement("div", { class: "mobile-start" }, [menuBtn, panel]);
1908
+ }
1909
+
1910
+ _buildMobileStyles() {
1911
+ return `
1818
1912
  :host {
1819
1913
  display: block;
1820
1914
  font-family: var(--font-family-body, sans-serif);
@@ -1855,6 +1949,7 @@ class YumeAppbar extends HTMLElement {
1855
1949
  display: flex;
1856
1950
  align-items: center;
1857
1951
  flex-shrink: 0;
1952
+ position: relative;
1858
1953
  }
1859
1954
 
1860
1955
  .mobile-center {
@@ -1871,8 +1966,38 @@ class YumeAppbar extends HTMLElement {
1871
1966
  flex-shrink: 0;
1872
1967
  }
1873
1968
 
1874
- .mobile-end ::slotted(*) {
1969
+ .mobile-panel {
1970
+ position: absolute;
1971
+ top: 100%;
1972
+ left: 0;
1973
+ margin-top: 4px;
1974
+ background: var(--component-appbar-background, #0c0c0d);
1975
+ border: var(--component-appbar-border-width, var(--component-sidebar-border-width, 2px)) solid var(--component-appbar-border-color, #37383a);
1976
+ border-radius: var(--component-appbar-border-radius, var(--component-sidebar-border-radius, 4px));
1977
+ padding: var(--_appbar-padding);
1978
+ display: none;
1979
+ flex-direction: column;
1980
+ gap: 2px;
1981
+ min-width: 180px;
1982
+ z-index: var(--component-appbar-z-index, 100);
1983
+ }
1984
+ .mobile-panel.open {
1985
+ display: flex;
1986
+ }
1987
+ .mobile-panel .nav-item {
1988
+ display: flex;
1989
+ width: 100%;
1990
+ }
1991
+ .mobile-panel .nav-item y-button {
1875
1992
  display: block;
1993
+ width: 100%;
1994
+ }
1995
+ .mobile-panel .nav-item y-button::part(button) {
1996
+ width: 100%;
1997
+ justify-content: flex-start;
1998
+ }
1999
+ .mobile-panel ::slotted(:not([slot])) {
2000
+ width: 100%;
1876
2001
  }
1877
2002
 
1878
2003
  ::slotted(*) {
@@ -1884,56 +2009,147 @@ class YumeAppbar extends HTMLElement {
1884
2009
  height: var(--component-icon-size-large, 1.25em);
1885
2010
  }
1886
2011
  `;
1887
- this.shadowRoot.appendChild(style);
2012
+ }
1888
2013
 
1889
- const bar = document.createElement("div");
1890
- bar.className = "appbar";
1891
- bar.setAttribute("role", "navigation");
1892
- bar.style.setProperty("--_appbar-padding", cfg.padding);
2014
+ _buildNavItem(item, cfg, isCollapsed, menuDir) {
2015
+ const hasChildren = item.children?.length > 0;
2016
+ const showLabel = item.text && !isCollapsed;
2017
+ const showArrow = hasChildren && !isCollapsed;
2018
+ const btnId = this._uid("appbar-btn");
2019
+
2020
+ const btn = createElement("y-button", {
2021
+ id: btnId,
2022
+ color: this._isItemActive(item) ? "primary" : "base",
2023
+ "style-type": "flat",
2024
+ size: cfg.buttonSize,
2025
+ });
1893
2026
 
1894
- /* ── Left: hamburger button ── */
1895
- const startSection = document.createElement("div");
1896
- startSection.className = "mobile-start";
2027
+ if (item.icon) btn.appendChild(this._buildItemIcon(item.icon, cfg));
2028
+ if (showLabel) btn.appendChild(document.createTextNode(item.text));
2029
+ if (showArrow) {
2030
+ btn.appendChild(
2031
+ createElement("y-icon", {
2032
+ slot: "right-icon",
2033
+ name: `chevron-${menuDir}`,
2034
+ size: cfg.iconSize,
2035
+ }),
2036
+ );
2037
+ }
1897
2038
 
1898
- const menuBtn = document.createElement("y-button");
1899
- const menuBtnId = this._uid("appbar-mobile-menu");
1900
- menuBtn.id = menuBtnId;
1901
- menuBtn.setAttribute("color", "base");
1902
- menuBtn.setAttribute("style-type", "flat");
1903
- menuBtn.setAttribute("size", cfg.buttonSize);
1904
- menuBtn.setAttribute("aria-label", "Open menu");
1905
-
1906
- const menuIcon = document.createElement("span");
1907
- menuIcon.slot = "left-icon";
1908
- menuIcon.innerHTML = menu;
1909
- menuBtn.appendChild(menuIcon);
1910
- startSection.appendChild(menuBtn);
1911
-
1912
- const navItems = this.items;
1913
- if (navItems.length > 0) {
1914
- const mobileMenu = document.createElement("y-menu");
1915
- mobileMenu.setAttribute("anchor", menuBtnId);
1916
- mobileMenu.setAttribute("direction", "down");
1917
- mobileMenu.setAttribute("size", cfg.buttonSize);
1918
- mobileMenu.items = this._toMenuItems(navItems);
1919
- startSection.appendChild(mobileMenu);
2039
+ if (item.href && !hasChildren) {
2040
+ btn.addEventListener("click", () => this._navigateTo(item.href));
2041
+ }
2042
+
2043
+ const wrapper = createElement("div", { class: "nav-item" });
2044
+ if (item.slot) {
2045
+ const slot = createElement("slot", { name: item.slot });
2046
+ slot.appendChild(btn);
2047
+ wrapper.appendChild(slot);
2048
+ } else {
2049
+ wrapper.appendChild(btn);
2050
+ }
2051
+
2052
+ if (hasChildren) {
2053
+ const menuEl = createElement("y-menu", {
2054
+ anchor: btnId,
2055
+ direction: menuDir,
2056
+ size: cfg.buttonSize,
2057
+ });
2058
+ menuEl.items = item.children;
2059
+ wrapper.appendChild(menuEl);
2060
+ }
2061
+
2062
+ return wrapper;
2063
+ }
2064
+
2065
+ _getBreakpointPx() {
2066
+ const attr = this.mobileBreakpoint;
2067
+ if (attr) {
2068
+ const px = parseInt(attr, 10);
2069
+ if (!isNaN(px) && px > 0) return px;
2070
+ }
2071
+
2072
+ const cssVal = getComputedStyle(document.documentElement)
2073
+ .getPropertyValue("--component-appbar-mobile-breakpoint")
2074
+ .trim();
2075
+ if (cssVal) {
2076
+ const px = parseInt(cssVal, 10);
2077
+ if (!isNaN(px) && px > 0) return px;
1920
2078
  }
1921
- bar.appendChild(startSection);
1922
-
1923
- /* ── Center: logo + title ── */
1924
- const centerSection = document.createElement("div");
1925
- centerSection.className = "mobile-center";
1926
- centerSection.appendChild(this._makeSlot("logo"));
1927
- centerSection.appendChild(this._makeSlot("title"));
1928
- bar.appendChild(centerSection);
1929
-
1930
- /* ── Right: footer slot ── */
1931
- const endSection = document.createElement("div");
1932
- endSection.className = "mobile-end";
1933
- endSection.setAttribute("part", "footer");
1934
- endSection.appendChild(this._makeSlot("footer"));
1935
- bar.appendChild(endSection);
1936
2079
 
2080
+ return 768;
2081
+ }
2082
+
2083
+ _initRender() {
2084
+ this._teardownMobileOutsideClick();
2085
+ this.shadowRoot.innerHTML = "";
2086
+ this._idCounter = 0;
2087
+ }
2088
+
2089
+ _isItemActive(item) {
2090
+ if (item.selected) return true;
2091
+ if (!item.href) return false;
2092
+
2093
+ const loc = window.location;
2094
+ const current = loc.pathname + loc.search + loc.hash;
2095
+
2096
+ return item.href === current || item.href === loc.href;
2097
+ }
2098
+
2099
+ _navigateTo(href) {
2100
+ const event = new CustomEvent("navigate", {
2101
+ bubbles: true,
2102
+ composed: true,
2103
+ cancelable: true,
2104
+ detail: { href },
2105
+ });
2106
+ if (!this.dispatchEvent(event)) return;
2107
+
2108
+ if (this.getAttribute("history") === "false") {
2109
+ window.location.href = href;
2110
+ } else {
2111
+ history.pushState({}, "", href);
2112
+ window.dispatchEvent(new PopStateEvent("popstate", { state: {} }));
2113
+ }
2114
+ }
2115
+
2116
+ _onCollapseClick() {
2117
+ this.toggle();
2118
+ }
2119
+
2120
+ _onMediaChange(e) {
2121
+ this._isMobile = e.matches;
2122
+ this.render();
2123
+ }
2124
+
2125
+ _renderDesktop() {
2126
+ this._initRender();
2127
+
2128
+ const isVertical = this.orientation === "vertical";
2129
+ const isCollapsed = this.collapsed && isVertical;
2130
+ const cfg = SIZE_CONFIG[this.size] || SIZE_CONFIG.medium;
2131
+ const menuDir = this.menuDirection || (isVertical ? "right" : "down");
2132
+
2133
+ const style = createElement("style", {}, [this._buildDesktopStyles()]);
2134
+ const bar = this._buildDesktopBar(
2135
+ cfg,
2136
+ isVertical,
2137
+ isCollapsed,
2138
+ menuDir,
2139
+ );
2140
+
2141
+ this.shadowRoot.appendChild(style);
2142
+ this.shadowRoot.appendChild(bar);
2143
+ }
2144
+
2145
+ _renderMobile() {
2146
+ this._initRender();
2147
+ const cfg = SIZE_CONFIG[this.size] || SIZE_CONFIG.medium;
2148
+
2149
+ const style = createElement("style", {}, [this._buildMobileStyles()]);
2150
+ const bar = this._buildMobileBar(cfg);
2151
+
2152
+ this.shadowRoot.appendChild(style);
1937
2153
  this.shadowRoot.appendChild(bar);
1938
2154
  }
1939
2155
 
@@ -1950,26 +2166,15 @@ class YumeAppbar extends HTMLElement {
1950
2166
  }
1951
2167
 
1952
2168
  _teardownMediaQuery() {
1953
- if (this._mql) {
1954
- this._mql.removeEventListener("change", this._onMediaChange);
1955
- this._mql = null;
1956
- }
2169
+ if (!this._mql) return;
2170
+ this._mql.removeEventListener("change", this._onMediaChange);
2171
+ this._mql = null;
1957
2172
  }
1958
2173
 
1959
- /**
1960
- * Convert appbar nav items to y-menu item format.
1961
- * Maps `href` → `url` and recursively converts children.
1962
- */
1963
- _toMenuItems(items) {
1964
- return items.map((item) => {
1965
- const mi = { text: item.text || "" };
1966
- if (item.href) mi.url = item.href;
1967
- if (item.icon) mi.icon = item.icon;
1968
- if (item.children?.length) {
1969
- mi.children = this._toMenuItems(item.children);
1970
- }
1971
- return mi;
1972
- });
2174
+ _teardownMobileOutsideClick() {
2175
+ if (!this._mobileOutsideClick) return;
2176
+ document.removeEventListener("pointerdown", this._mobileOutsideClick);
2177
+ this._mobileOutsideClick = null;
1973
2178
  }
1974
2179
 
1975
2180
  _uid(prefix) {