@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.
package/dist/index.js CHANGED
@@ -1696,6 +1696,8 @@ class YumeMenu extends HTMLElement {
1696
1696
  this._onAnchorClick = this._onAnchorClick.bind(this);
1697
1697
  this._onDocumentClick = this._onDocumentClick.bind(this);
1698
1698
  this._onScrollOrResize = this._onScrollOrResize.bind(this);
1699
+ this._isReady = false;
1700
+ this._slottedHandlers = new Map();
1699
1701
  }
1700
1702
 
1701
1703
  connectedCallback() {
@@ -1712,10 +1714,13 @@ class YumeMenu extends HTMLElement {
1712
1714
  this.style.zIndex = "1000";
1713
1715
  this.style.display = "none";
1714
1716
  if (this.visible) this._updatePosition();
1717
+
1718
+ this._isReady = true;
1715
1719
  }
1716
1720
 
1717
1721
  disconnectedCallback() {
1718
1722
  this._teardownAnchor();
1723
+ this._teardownSlottedItems();
1719
1724
 
1720
1725
  document.removeEventListener("click", this._onDocumentClick);
1721
1726
  window.removeEventListener("scroll", this._onScrollOrResize, true);
@@ -1735,6 +1740,13 @@ class YumeMenu extends HTMLElement {
1735
1740
  if (name === "visible" || name === "direction") {
1736
1741
  this._updatePosition();
1737
1742
  }
1743
+
1744
+ if (name === "visible" && this._isReady) {
1745
+ this.dispatchEvent(new CustomEvent(this.visible ? "open" : "close", {
1746
+ bubbles: true,
1747
+ composed: true,
1748
+ }));
1749
+ }
1738
1750
  }
1739
1751
 
1740
1752
  // -------------------------------------------------------------------------
@@ -1749,6 +1761,16 @@ class YumeMenu extends HTMLElement {
1749
1761
  get direction() { return this.getAttribute("direction") || "down"; }
1750
1762
  set direction(val) { this.setAttribute("direction", val); }
1751
1763
 
1764
+ /**
1765
+ * Navigation mode: omit for pushState (SPA-friendly), set to "false" for full-page navigation.
1766
+ * Regardless of this setting, a cancelable "navigate" event is always dispatched first.
1767
+ */
1768
+ get history() { return this.getAttribute("history"); }
1769
+ set history(val) {
1770
+ if (val != null) this.setAttribute("history", val);
1771
+ else this.removeAttribute("history");
1772
+ }
1773
+
1752
1774
  /** Menu items array (JSON attribute). */
1753
1775
  get items() {
1754
1776
  try {
@@ -1761,16 +1783,6 @@ class YumeMenu extends HTMLElement {
1761
1783
  this.setAttribute("items", Array.isArray(val) ? JSON.stringify(val) : (val ?? "[]"));
1762
1784
  }
1763
1785
 
1764
- /**
1765
- * Navigation mode: omit for pushState (SPA-friendly), set to "false" for full-page navigation.
1766
- * Regardless of this setting, a cancelable "navigate" event is always dispatched first.
1767
- */
1768
- get history() { return this.getAttribute("history"); }
1769
- set history(val) {
1770
- if (val != null) this.setAttribute("history", val);
1771
- else this.removeAttribute("history");
1772
- }
1773
-
1774
1786
  /** Size: "small" | "medium" | "large" (default "medium"). */
1775
1787
  get size() {
1776
1788
  const sz = this.getAttribute("size");
@@ -1794,63 +1806,94 @@ class YumeMenu extends HTMLElement {
1794
1806
  render() {
1795
1807
  this.shadowRoot.innerHTML = "";
1796
1808
 
1797
- const style = document.createElement("style");
1798
- style.textContent = this._buildStyles();
1799
- this.shadowRoot.appendChild(style);
1809
+ const style = createElement("style", {}, [this._buildStyles()]);
1810
+
1811
+ const root = this._createMenuList(this.items);
1812
+ root.classList.add("menu");
1813
+ root.setAttribute("role", "menu");
1814
+ root.setAttribute("part", "menu");
1800
1815
 
1801
- const rootUl = this._createMenuList(this.items);
1802
- rootUl.classList.add("menu");
1803
- rootUl.setAttribute("role", "menu");
1804
- rootUl.setAttribute("part", "menu");
1816
+ const childSlot = createElement("slot");
1817
+ childSlot.addEventListener("slotchange", () => this._processSlottedItems());
1818
+ root.appendChild(childSlot);
1805
1819
 
1806
- this.shadowRoot.appendChild(rootUl);
1820
+ this.shadowRoot.appendChild(style);
1821
+ this.shadowRoot.appendChild(root);
1822
+ this._processSlottedItems();
1807
1823
  }
1808
1824
 
1809
1825
  // -------------------------------------------------------------------------
1810
1826
  // Private
1811
1827
  // -------------------------------------------------------------------------
1812
1828
 
1829
+ _activateItem(item) {
1830
+ if (item.children?.length > 0) return;
1831
+
1832
+ this._dispatchSelect({
1833
+ value: item.value ?? item.text,
1834
+ item,
1835
+ });
1836
+
1837
+ const href = item.href ?? item.url;
1838
+ if (href) this._navigateTo(href);
1839
+
1840
+ this.visible = false;
1841
+ }
1842
+
1843
+ _activateSlottedItem(el) {
1844
+ this._dispatchSelect({
1845
+ value: el.dataset.value ?? el.textContent.trim(),
1846
+ element: el,
1847
+ });
1848
+ this.visible = false;
1849
+ }
1850
+
1813
1851
  _buildStyles() {
1814
1852
  const paddingVar = `var(--component-button-padding-${this.size}, 0.5rem)`;
1815
1853
  return `
1816
- ul.menu,
1817
- ul.submenu {
1818
- list-style: none;
1819
- margin: 0;
1820
- padding: 0;
1854
+ .menu,
1855
+ .submenu {
1821
1856
  background: var(--component-menu-background, #0c0c0d);
1822
1857
  border: var(--component-menu-border-width, 1px) solid var(--component-menu-border-color, #37383a);
1823
1858
  border-radius: var(--component-menu-border-radius, 4px);
1824
1859
  box-shadow: var(--component-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.15));
1825
1860
  min-width: 150px;
1861
+ display: flex;
1862
+ flex-direction: column;
1826
1863
  }
1827
1864
 
1828
- li.menuitem {
1865
+ .menuitem,
1866
+ ::slotted(:not([slot])) {
1829
1867
  cursor: pointer;
1830
1868
  padding: ${paddingVar};
1831
- display: flex;
1832
- align-items: center;
1833
- justify-content: space-between;
1834
1869
  white-space: nowrap;
1835
1870
  color: var(--component-menu-color, #f7f7fa);
1836
1871
  font-size: var(--font-size-button, 1em);
1872
+ box-sizing: border-box;
1873
+ }
1874
+
1875
+ .menuitem {
1876
+ display: flex;
1877
+ align-items: center;
1878
+ justify-content: space-between;
1837
1879
  position: relative;
1838
1880
  }
1839
1881
 
1840
- li.menuitem:hover {
1882
+ .menuitem:hover,
1883
+ ::slotted(:not([slot]):hover) {
1841
1884
  background: var(--component-menu-hover-background, #292a2b);
1842
1885
  }
1843
1886
 
1844
- li.menuitem.selected {
1887
+ .menuitem.selected {
1845
1888
  background: var(--component-menu-selected-background);
1846
1889
  color: var(--component-menu-selected-color);
1847
1890
  }
1848
1891
 
1849
- li.menuitem.selected:hover {
1892
+ .menuitem.selected:hover {
1850
1893
  background: var(--component-menu-selected-background);
1851
1894
  }
1852
1895
 
1853
- ul.submenu {
1896
+ .submenu {
1854
1897
  position: absolute;
1855
1898
  top: 0;
1856
1899
  left: 100%;
@@ -1858,8 +1901,8 @@ class YumeMenu extends HTMLElement {
1858
1901
  z-index: var(--component-menu-z-index, 1001);
1859
1902
  }
1860
1903
 
1861
- li.menuitem:hover > ul.submenu {
1862
- display: block;
1904
+ .menuitem:hover > .submenu {
1905
+ display: flex;
1863
1906
  }
1864
1907
 
1865
1908
  .submenu-indicator {
@@ -1869,13 +1912,11 @@ class YumeMenu extends HTMLElement {
1869
1912
  opacity: 0.6;
1870
1913
  }
1871
1914
 
1872
- .submenu-indicator svg {
1873
- width: 16px;
1874
- height: 16px;
1875
- }
1876
-
1877
1915
  .item-content {
1878
1916
  flex: 1;
1917
+ display: inline-flex;
1918
+ align-items: center;
1919
+ gap: 0.5rem;
1879
1920
  }
1880
1921
  `;
1881
1922
  }
@@ -1888,91 +1929,140 @@ class YumeMenu extends HTMLElement {
1888
1929
  });
1889
1930
  }
1890
1931
 
1891
- _createMenuList(items) {
1892
- const ul = document.createElement("ul");
1932
+ _computeMenuOffset(direction, anchorRect, menuRect, vw, vh) {
1933
+ if (direction === "right") {
1934
+ let top = anchorRect.top;
1935
+ let left = anchorRect.right;
1936
+ if (left + menuRect.width > vw) left = anchorRect.left - menuRect.width;
1937
+ if (top + menuRect.height > vh) top = anchorRect.top - menuRect.height;
1938
+ return { top, left };
1939
+ }
1893
1940
 
1894
- items.forEach((item) => {
1895
- const li = document.createElement("li");
1896
- li.className = item.selected ? "menuitem selected" : "menuitem";
1897
- li.setAttribute("role", "menuitem");
1898
- li.setAttribute("part", item.selected ? "menuitem selected" : "menuitem");
1899
- li.setAttribute("aria-current", item.selected ? "true" : "false");
1900
- li.tabIndex = 0;
1901
-
1902
- const contentWrapper = document.createElement("span");
1903
- contentWrapper.className = "item-content";
1904
-
1905
- if (item["icon-template"]) {
1906
- const iconTpl = this._findTemplate(item["icon-template"]);
1907
- if (iconTpl)
1908
- contentWrapper.appendChild(iconTpl.content.cloneNode(true));
1909
- }
1910
-
1911
- if (item.template) {
1912
- const textTpl = this._findTemplate(item.template);
1913
- if (textTpl) {
1914
- contentWrapper.appendChild(textTpl.content.cloneNode(true));
1915
- } else {
1916
- contentWrapper.append(item.text);
1917
- }
1918
- } else {
1919
- contentWrapper.append(item.text);
1920
- }
1941
+ if (direction === "up") {
1942
+ let top = anchorRect.top - menuRect.height;
1943
+ let left = anchorRect.left;
1944
+ if (top < 0) top = anchorRect.bottom;
1945
+ if (left + menuRect.width > vw) left = vw - menuRect.width - 10;
1946
+ return { top, left };
1947
+ }
1921
1948
 
1922
- li.appendChild(contentWrapper);
1949
+ if (direction === "left") {
1950
+ let top = anchorRect.top;
1951
+ let left = anchorRect.left - menuRect.width;
1952
+ if (left < 0) left = anchorRect.right;
1953
+ if (top + menuRect.height > vh) top = anchorRect.top - menuRect.height;
1954
+ return { top, left };
1955
+ }
1923
1956
 
1924
- if (item.url) {
1925
- li.addEventListener("click", () => {
1926
- const event = new CustomEvent("navigate", {
1927
- bubbles: true,
1928
- composed: true,
1929
- cancelable: true,
1930
- detail: { href: item.url },
1931
- });
1932
- const cancelled = !this.dispatchEvent(event);
1933
- if (cancelled) return;
1934
- if (this.getAttribute("history") !== "false") {
1935
- history.pushState({}, "", item.url);
1936
- window.dispatchEvent(new PopStateEvent("popstate", { state: {} }));
1937
- } else {
1938
- window.location.href = item.url;
1939
- }
1940
- });
1941
- }
1957
+ // "down" (default)
1958
+ let top = anchorRect.bottom;
1959
+ let left = anchorRect.left;
1960
+ if (top + menuRect.height > vh) top = anchorRect.top - menuRect.height;
1961
+ if (left + menuRect.width > vw) left = vw - menuRect.width - 10;
1962
+ return { top, left };
1963
+ }
1942
1964
 
1943
- if (!item.children?.length) {
1944
- li.addEventListener("click", () => {
1945
- this.visible = false;
1946
- });
1947
- }
1965
+ _createItemContent(item) {
1966
+ const wrapper = createElement("span", { class: "item-content" });
1967
+
1968
+ if (item.icon) {
1969
+ wrapper.appendChild(createElement("y-icon", { name: item.icon, size: this.size }));
1970
+ } else if (item["icon-template"]) {
1971
+ YumeMenu._warnTemplateFieldDeprecated();
1972
+ const tpl = this._findTemplate(item["icon-template"]);
1973
+ if (tpl) wrapper.appendChild(tpl.content.cloneNode(true));
1974
+ }
1975
+
1976
+ if (item.template) {
1977
+ YumeMenu._warnTemplateFieldDeprecated();
1978
+ const tpl = this._findTemplate(item.template);
1979
+ if (tpl) wrapper.appendChild(tpl.content.cloneNode(true));
1980
+ else wrapper.append(item.text ?? "");
1981
+ } else {
1982
+ wrapper.append(item.text ?? "");
1983
+ }
1948
1984
 
1949
- if (item.children?.length) {
1950
- const indicator = document.createElement("span");
1951
- indicator.className = "submenu-indicator";
1952
- indicator.innerHTML = chevronRight;
1953
- li.appendChild(indicator);
1985
+ if (!item.slot) return wrapper;
1954
1986
 
1955
- const submenu = this._createMenuList(item.children);
1956
- submenu.classList.add("submenu");
1957
- submenu.setAttribute("role", "menu");
1958
- li.appendChild(submenu);
1959
- }
1987
+ const slotEl = createElement("slot", { name: item.slot });
1988
+ slotEl.appendChild(wrapper);
1989
+ return slotEl;
1990
+ }
1960
1991
 
1961
- ul.appendChild(li);
1992
+ _createMenuItem(item) {
1993
+ const isSelected = !!item.selected;
1994
+ const partValue = isSelected ? "menuitem selected" : "menuitem";
1995
+
1996
+ const itemEl = createElement("div", {
1997
+ class: partValue,
1998
+ role: "menuitem",
1999
+ part: partValue,
2000
+ "aria-current": isSelected ? "true" : "false",
2001
+ tabindex: "0",
1962
2002
  });
1963
2003
 
1964
- return ul;
2004
+ itemEl.appendChild(this._createItemContent(item));
2005
+
2006
+ if (item.url && !item.href) YumeMenu._warnUrlDeprecated();
2007
+
2008
+ itemEl.addEventListener("click", () => this._activateItem(item));
2009
+
2010
+ if (item.children?.length > 0) {
2011
+ itemEl.appendChild(this._createSubmenuIndicator());
2012
+
2013
+ const submenu = this._createMenuList(item.children);
2014
+ submenu.classList.add("submenu");
2015
+ submenu.setAttribute("role", "menu");
2016
+ itemEl.appendChild(submenu);
2017
+ }
2018
+
2019
+ return itemEl;
2020
+ }
2021
+
2022
+ _createMenuList(items) {
2023
+ const container = createElement("div");
2024
+ items.forEach((item) => container.appendChild(this._createMenuItem(item)));
2025
+ return container;
2026
+ }
2027
+
2028
+ _createSubmenuIndicator() {
2029
+ return createElement("span", { class: "submenu-indicator" }, [
2030
+ createElement("y-icon", { name: "chevron-right", size: this.size }),
2031
+ ]);
2032
+ }
2033
+
2034
+ _dispatchSelect(detail) {
2035
+ this.dispatchEvent(new CustomEvent("select", {
2036
+ detail,
2037
+ bubbles: true,
2038
+ composed: true,
2039
+ }));
1965
2040
  }
1966
2041
 
1967
2042
  _findTemplate(name) {
1968
2043
  return this.querySelector(`template[slot="${name}"]`);
1969
2044
  }
1970
2045
 
2046
+ _navigateTo(href) {
2047
+ const event = new CustomEvent("navigate", {
2048
+ bubbles: true,
2049
+ composed: true,
2050
+ cancelable: true,
2051
+ detail: { href },
2052
+ });
2053
+ if (!this.dispatchEvent(event)) return;
2054
+
2055
+ if (this.getAttribute("history") === "false") {
2056
+ window.location.href = href;
2057
+ } else {
2058
+ history.pushState({}, "", href);
2059
+ window.dispatchEvent(new PopStateEvent("popstate", { state: {} }));
2060
+ }
2061
+ }
2062
+
1971
2063
  _onAnchorClick(e) {
1972
2064
  e.stopPropagation();
1973
- if (!this.visible) {
1974
- YumeMenu._closeAll(this);
1975
- }
2065
+ if (!this.visible) YumeMenu._closeAll(this);
1976
2066
  this.visible = !this.visible;
1977
2067
  }
1978
2068
 
@@ -1987,9 +2077,34 @@ class YumeMenu extends HTMLElement {
1987
2077
  if (this.visible) this._updatePosition();
1988
2078
  }
1989
2079
 
2080
+ _processSlottedItems() {
2081
+ const slot = this.shadowRoot.querySelector(".menu > slot");
2082
+ if (!slot) return;
2083
+
2084
+ const assigned = new Set(slot.assignedElements());
2085
+
2086
+ for (const [el, handler] of this._slottedHandlers) {
2087
+ if (assigned.has(el)) continue;
2088
+ el.removeEventListener("click", handler);
2089
+ this._slottedHandlers.delete(el);
2090
+ }
2091
+
2092
+ for (const el of assigned) {
2093
+ if (this._slottedHandlers.has(el)) continue;
2094
+
2095
+ if (!el.hasAttribute("role")) el.setAttribute("role", "menuitem");
2096
+ if (el.tabIndex < 0) el.tabIndex = 0;
2097
+
2098
+ const handler = () => this._activateSlottedItem(el);
2099
+ el.addEventListener("click", handler);
2100
+ this._slottedHandlers.set(el, handler);
2101
+ }
2102
+ }
2103
+
1990
2104
  _setupAnchor() {
1991
2105
  const id = this.anchor;
1992
2106
  if (!id) return;
2107
+
1993
2108
  const root = this.getRootNode();
1994
2109
  this._anchorResolveDispose = resolveAnchor(
1995
2110
  this,
@@ -2013,6 +2128,13 @@ class YumeMenu extends HTMLElement {
2013
2128
  }
2014
2129
  }
2015
2130
 
2131
+ _teardownSlottedItems() {
2132
+ for (const [el, handler] of this._slottedHandlers) {
2133
+ el.removeEventListener("click", handler);
2134
+ }
2135
+ this._slottedHandlers.clear();
2136
+ }
2137
+
2016
2138
  _updatePosition() {
2017
2139
  if (!this.visible || !this._anchorEl) {
2018
2140
  this.style.display = "none";
@@ -2021,7 +2143,7 @@ class YumeMenu extends HTMLElement {
2021
2143
 
2022
2144
  const anchorRect = this._anchorEl.getBoundingClientRect();
2023
2145
 
2024
- // Temporarily show off-screen to measure actual dimensions
2146
+ // Measure menu off-screen so we know its size before placing it.
2025
2147
  this.style.visibility = "hidden";
2026
2148
  this.style.display = "block";
2027
2149
  const menuRect = this.getBoundingClientRect();
@@ -2029,58 +2151,38 @@ class YumeMenu extends HTMLElement {
2029
2151
 
2030
2152
  const vw = window.innerWidth;
2031
2153
  const vh = window.innerHeight;
2154
+ const { top, left } = this._computeMenuOffset(
2155
+ this.direction,
2156
+ anchorRect,
2157
+ menuRect,
2158
+ vw,
2159
+ vh,
2160
+ );
2032
2161
 
2033
- let top, left;
2034
-
2035
- if (this.direction === "right") {
2036
- top = anchorRect.top;
2037
- left = anchorRect.right;
2038
-
2039
- if (left + menuRect.width > vw) {
2040
- left = anchorRect.left - menuRect.width;
2041
- }
2042
- if (top + menuRect.height > vh) {
2043
- top = anchorRect.top - menuRect.height;
2044
- }
2045
- } else if (this.direction === "up") {
2046
- top = anchorRect.top - menuRect.height;
2047
- left = anchorRect.left;
2048
-
2049
- if (top < 0) {
2050
- top = anchorRect.bottom;
2051
- }
2052
- if (left + menuRect.width > vw) {
2053
- left = vw - menuRect.width - 10;
2054
- }
2055
- } else if (this.direction === "left") {
2056
- top = anchorRect.top;
2057
- left = anchorRect.left - menuRect.width;
2162
+ const clampedTop = Math.max(0, Math.min(top, vh - menuRect.height));
2163
+ const clampedLeft = Math.max(0, Math.min(left, vw - menuRect.width));
2058
2164
 
2059
- if (left < 0) {
2060
- left = anchorRect.right;
2061
- }
2062
- if (top + menuRect.height > vh) {
2063
- top = anchorRect.top - menuRect.height;
2064
- }
2065
- } else {
2066
- // "down" (default)
2067
- top = anchorRect.bottom;
2068
- left = anchorRect.left;
2069
-
2070
- if (top + menuRect.height > vh) {
2071
- top = anchorRect.top - menuRect.height;
2072
- }
2073
- if (left + menuRect.width > vw) {
2074
- left = vw - menuRect.width - 10;
2075
- }
2076
- }
2165
+ this.style.top = `${clampedTop}px`;
2166
+ this.style.left = `${clampedLeft}px`;
2167
+ this.style.display = "block";
2168
+ }
2077
2169
 
2078
- top = Math.max(0, Math.min(top, vh - menuRect.height));
2079
- left = Math.max(0, Math.min(left, vw - menuRect.width));
2170
+ static _warnTemplateFieldDeprecated() {
2171
+ if (YumeMenu._templateFieldDeprecationWarned) return;
2172
+ YumeMenu._templateFieldDeprecationWarned = true;
2173
+ // eslint-disable-next-line no-console
2174
+ console.warn(
2175
+ "[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.",
2176
+ );
2177
+ }
2080
2178
 
2081
- this.style.top = `${top}px`;
2082
- this.style.left = `${left}px`;
2083
- this.style.display = "block";
2179
+ static _warnUrlDeprecated() {
2180
+ if (YumeMenu._urlDeprecationWarned) return;
2181
+ YumeMenu._urlDeprecationWarned = true;
2182
+ // eslint-disable-next-line no-console
2183
+ console.warn(
2184
+ "[y-menu] item.url is deprecated; use item.href instead. Support for item.url will be removed in a future release.",
2185
+ );
2084
2186
  }
2085
2187
  }
2086
2188
 
@@ -2138,6 +2240,7 @@ class YumeAppbar extends HTMLElement {
2138
2240
  this._idCounter = 0;
2139
2241
  this._mql = null;
2140
2242
  this._isMobile = false;
2243
+ this._mobileOutsideClick = null;
2141
2244
  }
2142
2245
 
2143
2246
  connectedCallback() {
@@ -2147,6 +2250,7 @@ class YumeAppbar extends HTMLElement {
2147
2250
 
2148
2251
  disconnectedCallback() {
2149
2252
  this._teardownMediaQuery();
2253
+ this._teardownMobileOutsideClick();
2150
2254
  }
2151
2255
 
2152
2256
  attributeChangedCallback(name, oldVal, newVal) {
@@ -2162,12 +2266,26 @@ class YumeAppbar extends HTMLElement {
2162
2266
  // -------------------------------------------------------------------------
2163
2267
 
2164
2268
  /** Whether the sidebar is currently collapsed. */
2165
- get collapsed() { return this.hasAttribute("collapsed"); }
2269
+ get collapsed() {
2270
+ return this.hasAttribute("collapsed");
2271
+ }
2166
2272
  set collapsed(val) {
2167
2273
  if (val) this.setAttribute("collapsed", "");
2168
2274
  else this.removeAttribute("collapsed");
2169
2275
  }
2170
2276
 
2277
+ /**
2278
+ * Navigation mode: omit for pushState (SPA-friendly), set to "false" for full-page navigation.
2279
+ * Regardless of this setting, a cancelable "navigate" event is always dispatched first.
2280
+ */
2281
+ get history() {
2282
+ return this.getAttribute("history");
2283
+ }
2284
+ set history(val) {
2285
+ if (val != null) this.setAttribute("history", val);
2286
+ else this.removeAttribute("history");
2287
+ }
2288
+
2171
2289
  /** Nav items array parsed from the "items" attribute. */
2172
2290
  get items() {
2173
2291
  try {
@@ -2176,47 +2294,53 @@ class YumeAppbar extends HTMLElement {
2176
2294
  return [];
2177
2295
  }
2178
2296
  }
2179
- set items(val) { this.setAttribute("items", JSON.stringify(val)); }
2297
+ set items(val) {
2298
+ this.setAttribute("items", JSON.stringify(val));
2299
+ }
2180
2300
 
2181
2301
  /**
2182
2302
  * Direction menus pop out from nav buttons:
2183
2303
  * "right", "down", or unset (auto: vertical → right, horizontal → down).
2184
2304
  */
2185
- get menuDirection() { return this.getAttribute("menu-direction") || ""; }
2305
+ get menuDirection() {
2306
+ return this.getAttribute("menu-direction") || "";
2307
+ }
2186
2308
  set menuDirection(val) {
2187
2309
  if (val) this.setAttribute("menu-direction", val);
2188
2310
  else this.removeAttribute("menu-direction");
2189
2311
  }
2190
2312
 
2191
2313
  /** Whether the appbar is currently rendering in mobile mode. */
2192
- get mobile() { return this._isMobile; }
2314
+ get mobile() {
2315
+ return this._isMobile;
2316
+ }
2193
2317
 
2194
2318
  /**
2195
2319
  * Override the mobile breakpoint (in pixels) for this instance.
2196
2320
  * Falls back to the CSS variable --component-appbar-mobile-breakpoint (default 768).
2197
2321
  */
2198
- get mobileBreakpoint() { return this.getAttribute("mobile-breakpoint") || ""; }
2322
+ get mobileBreakpoint() {
2323
+ return this.getAttribute("mobile-breakpoint") || "";
2324
+ }
2199
2325
  set mobileBreakpoint(val) {
2200
2326
  if (val) this.setAttribute("mobile-breakpoint", val);
2201
2327
  else this.removeAttribute("mobile-breakpoint");
2202
2328
  }
2203
2329
 
2204
2330
  /** Layout orientation: "vertical" | "horizontal" (default "vertical"). */
2205
- get orientation() { return this.getAttribute("orientation") || "vertical"; }
2206
- set orientation(val) { this.setAttribute("orientation", val); }
2331
+ get orientation() {
2332
+ return this.getAttribute("orientation") || "vertical";
2333
+ }
2334
+ set orientation(val) {
2335
+ this.setAttribute("orientation", val);
2336
+ }
2207
2337
 
2208
2338
  /** Size variant: "small" | "medium" | "large" (default "medium"). */
2209
- get size() { return this.getAttribute("size") || "medium"; }
2210
- set size(val) { this.setAttribute("size", val); }
2211
-
2212
- /**
2213
- * Navigation mode: omit for pushState (SPA-friendly), set to "false" for full-page navigation.
2214
- * Regardless of this setting, a cancelable "navigate" event is always dispatched first.
2215
- */
2216
- get history() { return this.getAttribute("history"); }
2217
- set history(val) {
2218
- if (val != null) this.setAttribute("history", val);
2219
- else this.removeAttribute("history");
2339
+ get size() {
2340
+ return this.getAttribute("size") || "medium";
2341
+ }
2342
+ set size(val) {
2343
+ this.setAttribute("size", val);
2220
2344
  }
2221
2345
 
2222
2346
  /** Sticky position: "start" | "end" | false. */
@@ -2233,201 +2357,88 @@ class YumeAppbar extends HTMLElement {
2233
2357
  // Public
2234
2358
  // -------------------------------------------------------------------------
2235
2359
 
2360
+ render() {
2361
+ if (this._isMobile) this._renderMobile();
2362
+ else this._renderDesktop();
2363
+ }
2364
+
2236
2365
  /** Toggles the collapsed state of the sidebar. */
2237
2366
  toggle() {
2238
2367
  this.collapsed = !this.collapsed;
2239
2368
  }
2240
2369
 
2241
- render() {
2242
- if (this._isMobile) {
2243
- this._renderMobile();
2244
- } else {
2245
- this._renderDesktop();
2246
- }
2247
- }
2248
-
2249
2370
  // -------------------------------------------------------------------------
2250
2371
  // Private
2251
2372
  // -------------------------------------------------------------------------
2252
2373
 
2253
- _buildCollapseButton(cfg, isCollapsed) {
2254
- const btn = document.createElement("y-button");
2255
- btn.setAttribute("color", "base");
2256
- btn.setAttribute("style-type", "flat");
2257
- btn.setAttribute("size", cfg.buttonSize);
2258
- btn.setAttribute("aria-label", isCollapsed ? "Expand sidebar" : "Collapse sidebar");
2259
- btn.className = "collapse-btn";
2260
-
2261
- const icon = document.createElement("span");
2262
- icon.slot = "left-icon";
2263
- icon.innerHTML = isCollapsed ? expandRight : expandLeft;
2264
- btn.appendChild(icon);
2265
-
2266
- if (!isCollapsed) {
2267
- btn.appendChild(document.createTextNode("Collapse"));
2268
- }
2269
-
2270
- btn.addEventListener("click", this._onCollapseClick);
2271
- return btn;
2272
- }
2273
-
2274
- _buildHeader() {
2275
- const header = document.createElement("div");
2276
- header.className = "appbar-header";
2277
- header.setAttribute("part", "header");
2278
-
2279
- const headerContent = document.createElement("div");
2280
- headerContent.className = "header-content";
2281
-
2282
- const logoWrapper = document.createElement("div");
2283
- logoWrapper.className = "logo-wrapper";
2284
- logoWrapper.appendChild(this._makeSlot("logo"));
2285
- headerContent.appendChild(logoWrapper);
2374
+ _buildBody(cfg, isCollapsed, menuDir) {
2375
+ const body = createElement("div", { class: "appbar-body", part: "body" });
2286
2376
 
2287
- const titleWrapper = document.createElement("div");
2288
- titleWrapper.className = "header-title";
2289
- titleWrapper.appendChild(this._makeSlot("title"));
2290
- headerContent.appendChild(titleWrapper);
2291
-
2292
- header.appendChild(headerContent);
2293
- header.appendChild(this._makeSlot("header"));
2294
- return header;
2295
- }
2296
-
2297
- _buildNavItem(item, cfg, isVertical, isCollapsed, menuDir) {
2298
- const hasChildren = item.children?.length > 0;
2299
- const wrapper = document.createElement("div");
2300
- const btn = document.createElement("y-button");
2301
- const btnId = this._uid("appbar-btn");
2302
-
2303
- wrapper.className = "nav-item";
2304
- btn.id = btnId;
2305
- btn.setAttribute("color", this._isItemActive(item) ? "primary" : "base");
2306
- btn.setAttribute("style-type", "flat");
2307
- btn.setAttribute("size", cfg.buttonSize);
2308
-
2309
- if (item.icon) {
2310
- if (item.icon.trim().startsWith("<")) {
2311
- const iconEl = document.createElement("span");
2312
- iconEl.slot = "left-icon";
2313
- iconEl.setAttribute("part", "icon");
2314
- iconEl.innerHTML = item.icon;
2315
- btn.appendChild(iconEl);
2316
- } else {
2317
- const iconEl = document.createElement("y-icon");
2318
- iconEl.slot = "left-icon";
2319
- iconEl.setAttribute("part", "icon");
2320
- iconEl.setAttribute("name", item.icon);
2321
- iconEl.setAttribute("size", cfg.iconSize);
2322
- btn.appendChild(iconEl);
2323
- }
2324
- }
2325
-
2326
- if (item.text && !isCollapsed) {
2327
- btn.appendChild(document.createTextNode(item.text));
2328
- }
2329
-
2330
- if (hasChildren && !isCollapsed) {
2331
- const arrow = document.createElement("span");
2332
- arrow.slot = "right-icon";
2333
- arrow.innerHTML = isVertical ? chevronRight : chevronDown;
2334
- btn.appendChild(arrow);
2335
- }
2336
-
2337
- if (item.href && !hasChildren) {
2338
- btn.addEventListener("click", () => {
2339
- const event = new CustomEvent("navigate", {
2340
- bubbles: true,
2341
- composed: true,
2342
- cancelable: true,
2343
- detail: { href: item.href },
2344
- });
2345
- const cancelled = !this.dispatchEvent(event);
2346
- if (cancelled) return;
2347
- if (this.getAttribute("history") !== "false") {
2348
- history.pushState({}, "", item.href);
2349
- window.dispatchEvent(new PopStateEvent("popstate", { state: {} }));
2350
- } else {
2351
- window.location.href = item.href;
2352
- }
2353
- });
2354
- }
2355
-
2356
- if (item.slot) {
2357
- const slot = this._makeSlot(item.slot);
2358
- slot.appendChild(btn);
2359
- wrapper.appendChild(slot);
2360
- } else {
2361
- wrapper.appendChild(btn);
2362
- }
2363
-
2364
- if (hasChildren) {
2365
- const menuEl = document.createElement("y-menu");
2366
- menuEl.setAttribute("anchor", btnId);
2367
- menuEl.setAttribute("direction", menuDir);
2368
- menuEl.setAttribute("size", cfg.buttonSize);
2369
- menuEl.items = item.children;
2370
- wrapper.appendChild(menuEl);
2371
- }
2372
-
2373
- return wrapper;
2374
- }
2377
+ this.items.forEach((item) => {
2378
+ body.appendChild(
2379
+ this._buildNavItem(item, cfg, isCollapsed, menuDir),
2380
+ );
2381
+ });
2382
+ body.appendChild(createElement("slot", {}));
2375
2383
 
2376
- _getBreakpointPx() {
2377
- const attr = this.mobileBreakpoint;
2378
- if (attr) {
2379
- const px = parseInt(attr, 10);
2380
- if (!isNaN(px) && px > 0) return px;
2381
- }
2382
- const cssVal = getComputedStyle(document.documentElement)
2383
- .getPropertyValue("--component-appbar-mobile-breakpoint")
2384
- .trim();
2385
- if (cssVal) {
2386
- const px = parseInt(cssVal, 10);
2387
- if (!isNaN(px) && px > 0) return px;
2388
- }
2389
- return 768;
2384
+ return body;
2390
2385
  }
2391
2386
 
2392
- _initRender() {
2393
- this.shadowRoot.innerHTML = "";
2394
- this._idCounter = 0;
2395
- }
2387
+ _buildCollapseButton(cfg, isCollapsed) {
2388
+ const icon = createElement("y-icon", {
2389
+ slot: "left-icon",
2390
+ name: isCollapsed ? "expand-right" : "expand-left",
2391
+ size: cfg.iconSize,
2392
+ });
2396
2393
 
2397
- _isItemActive(item) {
2398
- if (item.selected) return true;
2399
- if (item.href) {
2400
- const loc = window.location;
2401
- const current = loc.pathname + loc.search + loc.hash;
2402
- return item.href === current || item.href === loc.href;
2403
- }
2404
- return false;
2405
- }
2394
+ const children = isCollapsed ? [icon] : [icon, "Collapse"];
2395
+ const btn = createElement(
2396
+ "y-button",
2397
+ {
2398
+ class: "collapse-btn",
2399
+ color: "base",
2400
+ "style-type": "flat",
2401
+ size: cfg.buttonSize,
2402
+ "aria-label": isCollapsed
2403
+ ? "Expand sidebar"
2404
+ : "Collapse sidebar",
2405
+ },
2406
+ children,
2407
+ );
2406
2408
 
2407
- _makeSlot(name) {
2408
- const slot = document.createElement("slot");
2409
- slot.name = name;
2410
- return slot;
2409
+ btn.addEventListener("click", this._onCollapseClick);
2410
+ return btn;
2411
2411
  }
2412
2412
 
2413
- _onCollapseClick() {
2414
- this.toggle();
2415
- }
2413
+ _buildDesktopBar(cfg, isVertical, isCollapsed, menuDir) {
2414
+ const classes = ["appbar", isVertical ? "vertical" : "horizontal"];
2415
+ if (isCollapsed) classes.push("collapsed");
2416
2416
 
2417
- _onMediaChange(e) {
2418
- this._isMobile = e.matches;
2419
- this.render();
2420
- }
2417
+ const bar = createElement("div", {
2418
+ class: classes.join(" "),
2419
+ role: "navigation",
2420
+ });
2421
2421
 
2422
- _renderDesktop() {
2423
- this._initRender();
2424
- const isVertical = this.orientation === "vertical";
2425
- const isCollapsed = this.collapsed && isVertical;
2426
- const cfg = SIZE_CONFIG[this.size] || SIZE_CONFIG.medium;
2427
- const menuDir = this.menuDirection || (isVertical ? "right" : "down");
2422
+ // Layout-specific sizing tokens consumed by the stylesheet.
2423
+ 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))`;
2424
+ bar.style.setProperty("--_appbar-padding", cfg.padding);
2425
+ bar.style.setProperty("--_appbar-collapsed-width", cfg.collapsedWidth);
2426
+ bar.style.setProperty("--_appbar-body-gap", cfg.bodyGap);
2427
+ bar.style.setProperty(
2428
+ "--_button-padding",
2429
+ `var(--component-button-padding-${cfg.buttonSize})`,
2430
+ );
2431
+ bar.style.setProperty("--_icon-col-width", iconColWidth);
2428
2432
 
2429
- const style = document.createElement("style");
2430
- style.textContent = `
2433
+ bar.appendChild(this._buildHeader());
2434
+ bar.appendChild(this._buildBody(cfg, isCollapsed, menuDir));
2435
+ bar.appendChild(this._buildFooter(cfg, isVertical, isCollapsed));
2436
+
2437
+ return bar;
2438
+ }
2439
+
2440
+ _buildDesktopStyles() {
2441
+ return `
2431
2442
  :host {
2432
2443
  display: block;
2433
2444
  font-family: var(--font-family-body, sans-serif);
@@ -2655,55 +2666,148 @@ class YumeAppbar extends HTMLElement {
2655
2666
  ::slotted(*) {
2656
2667
  display: block;
2657
2668
  }
2669
+ .appbar.vertical ::slotted(:not([slot])) {
2670
+ width: 100%;
2671
+ }
2672
+ .appbar.horizontal ::slotted(:not([slot])) {
2673
+ display: inline-flex;
2674
+ align-items: center;
2675
+ }
2676
+ .appbar.vertical.collapsed ::slotted(:not([slot])) {
2677
+ width: var(--_icon-col-width);
2678
+ overflow: hidden;
2679
+ }
2658
2680
  span[slot="left-icon"] svg,
2659
2681
  span[slot="right-icon"] svg {
2660
2682
  width: var(--component-icon-size-large, 1.25em);
2661
2683
  height: var(--component-icon-size-large, 1.25em);
2662
2684
  }
2663
2685
  `;
2664
- this.shadowRoot.appendChild(style);
2686
+ }
2687
+
2688
+ _buildFooter(cfg, isVertical, isCollapsed) {
2689
+ const footer = createElement("div", { class: "appbar-footer", part: "footer" });
2690
+ footer.appendChild(createElement("slot", { name: "footer" }));
2691
+
2692
+ if (isVertical) {
2693
+ footer.appendChild(this._buildCollapseButton(cfg, isCollapsed));
2694
+ }
2695
+
2696
+ return footer;
2697
+ }
2698
+
2699
+ _buildHeader() {
2700
+ const logoWrapper = createElement("div", { class: "logo-wrapper" }, [
2701
+ createElement("slot", { name: "logo" }),
2702
+ ]);
2703
+ const titleWrapper = createElement("div", { class: "header-title" }, [
2704
+ createElement("slot", { name: "title" }),
2705
+ ]);
2706
+ const headerContent = createElement("div", { class: "header-content" }, [
2707
+ logoWrapper,
2708
+ titleWrapper,
2709
+ ]);
2710
+
2711
+ return createElement("div", { class: "appbar-header", part: "header" }, [
2712
+ headerContent,
2713
+ createElement("slot", { name: "header" }),
2714
+ ]);
2715
+ }
2716
+
2717
+ _buildItemIcon(iconValue, cfg) {
2718
+ // Raw SVG markup is preserved as a public escape hatch; everything else
2719
+ // routes through y-icon for consistent sizing/theming.
2720
+ if (iconValue.trim().startsWith("<")) {
2721
+ const span = createElement("span", { slot: "left-icon", part: "icon" });
2722
+ span.innerHTML = iconValue;
2723
+ return span;
2724
+ }
2725
+ return createElement("y-icon", {
2726
+ slot: "left-icon",
2727
+ part: "icon",
2728
+ name: iconValue,
2729
+ size: cfg.iconSize,
2730
+ });
2731
+ }
2665
2732
 
2666
- const bar = document.createElement("div");
2667
- bar.className = `appbar ${isVertical ? "vertical" : "horizontal"}`;
2668
- if (isCollapsed) bar.classList.add("collapsed");
2669
- bar.setAttribute("role", "navigation");
2733
+ _buildMobileBar(cfg) {
2734
+ const bar = createElement("div", { class: "appbar", role: "navigation" });
2670
2735
  bar.style.setProperty("--_appbar-padding", cfg.padding);
2671
- bar.style.setProperty("--_appbar-collapsed-width", cfg.collapsedWidth);
2672
- bar.style.setProperty("--_appbar-body-gap", cfg.bodyGap);
2673
- bar.style.setProperty("--_button-padding", `var(--component-button-padding-${cfg.buttonSize})`);
2674
- bar.style.setProperty(
2675
- "--_icon-col-width",
2676
- `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))`,
2677
- );
2678
2736
 
2679
- bar.appendChild(this._buildHeader());
2737
+ bar.appendChild(this._buildMobileStart(cfg));
2738
+ bar.appendChild(this._buildMobileCenter());
2739
+ bar.appendChild(this._buildMobileEnd());
2680
2740
 
2681
- const body = document.createElement("div");
2682
- body.className = "appbar-body";
2683
- body.setAttribute("part", "body");
2741
+ return bar;
2742
+ }
2743
+
2744
+ _buildMobileCenter() {
2745
+ return createElement("div", { class: "mobile-center" }, [
2746
+ createElement("slot", { name: "logo" }),
2747
+ createElement("slot", { name: "title" }),
2748
+ ]);
2749
+ }
2750
+
2751
+ _buildMobileEnd() {
2752
+ return createElement("div", { class: "mobile-end", part: "footer" }, [
2753
+ createElement("slot", { name: "footer" }),
2754
+ ]);
2755
+ }
2756
+
2757
+ _buildMobileStart(cfg) {
2758
+ const menuBtnId = this._uid("appbar-mobile-menu");
2759
+ const panelId = this._uid("appbar-mobile-panel");
2760
+
2761
+ const menuBtn = createElement(
2762
+ "y-button",
2763
+ {
2764
+ id: menuBtnId,
2765
+ color: "base",
2766
+ "style-type": "flat",
2767
+ size: cfg.buttonSize,
2768
+ "aria-label": "Open menu",
2769
+ "aria-controls": panelId,
2770
+ "aria-expanded": "false",
2771
+ },
2772
+ [
2773
+ createElement("y-icon", {
2774
+ slot: "left-icon",
2775
+ name: "menu",
2776
+ size: cfg.iconSize,
2777
+ }),
2778
+ ],
2779
+ );
2780
+
2781
+ const panel = createElement("div", { id: panelId, class: "mobile-panel" });
2684
2782
  this.items.forEach((item) => {
2685
- body.appendChild(this._buildNavItem(item, cfg, isVertical, isCollapsed, menuDir));
2783
+ panel.appendChild(this._buildNavItem(item, cfg, false, "down"));
2686
2784
  });
2687
- bar.appendChild(body);
2785
+ panel.appendChild(createElement("slot", {}));
2688
2786
 
2689
- const footer = document.createElement("div");
2690
- footer.className = "appbar-footer";
2691
- footer.setAttribute("part", "footer");
2692
- footer.appendChild(this._makeSlot("footer"));
2693
- if (isVertical) {
2694
- footer.appendChild(this._buildCollapseButton(cfg, isCollapsed));
2695
- }
2696
- bar.appendChild(footer);
2787
+ const closePanel = () => {
2788
+ panel.classList.remove("open");
2789
+ menuBtn.setAttribute("aria-expanded", "false");
2790
+ };
2697
2791
 
2698
- this.shadowRoot.appendChild(bar);
2699
- }
2792
+ menuBtn.addEventListener("click", (e) => {
2793
+ e.stopPropagation();
2794
+ const open = panel.classList.toggle("open");
2795
+ menuBtn.setAttribute("aria-expanded", open ? "true" : "false");
2796
+ });
2700
2797
 
2701
- _renderMobile() {
2702
- this._initRender();
2703
- const cfg = SIZE_CONFIG[this.size] || SIZE_CONFIG.medium;
2798
+ panel.addEventListener("navigate", closePanel);
2704
2799
 
2705
- const style = document.createElement("style");
2706
- style.textContent = `
2800
+ this._mobileOutsideClick = (e) => {
2801
+ if (e.composedPath().includes(this)) return;
2802
+ closePanel();
2803
+ };
2804
+ document.addEventListener("pointerdown", this._mobileOutsideClick);
2805
+
2806
+ return createElement("div", { class: "mobile-start" }, [menuBtn, panel]);
2807
+ }
2808
+
2809
+ _buildMobileStyles() {
2810
+ return `
2707
2811
  :host {
2708
2812
  display: block;
2709
2813
  font-family: var(--font-family-body, sans-serif);
@@ -2744,6 +2848,7 @@ class YumeAppbar extends HTMLElement {
2744
2848
  display: flex;
2745
2849
  align-items: center;
2746
2850
  flex-shrink: 0;
2851
+ position: relative;
2747
2852
  }
2748
2853
 
2749
2854
  .mobile-center {
@@ -2760,8 +2865,38 @@ class YumeAppbar extends HTMLElement {
2760
2865
  flex-shrink: 0;
2761
2866
  }
2762
2867
 
2763
- .mobile-end ::slotted(*) {
2868
+ .mobile-panel {
2869
+ position: absolute;
2870
+ top: 100%;
2871
+ left: 0;
2872
+ margin-top: 4px;
2873
+ background: var(--component-appbar-background, #0c0c0d);
2874
+ border: var(--component-appbar-border-width, var(--component-sidebar-border-width, 2px)) solid var(--component-appbar-border-color, #37383a);
2875
+ border-radius: var(--component-appbar-border-radius, var(--component-sidebar-border-radius, 4px));
2876
+ padding: var(--_appbar-padding);
2877
+ display: none;
2878
+ flex-direction: column;
2879
+ gap: 2px;
2880
+ min-width: 180px;
2881
+ z-index: var(--component-appbar-z-index, 100);
2882
+ }
2883
+ .mobile-panel.open {
2884
+ display: flex;
2885
+ }
2886
+ .mobile-panel .nav-item {
2887
+ display: flex;
2888
+ width: 100%;
2889
+ }
2890
+ .mobile-panel .nav-item y-button {
2764
2891
  display: block;
2892
+ width: 100%;
2893
+ }
2894
+ .mobile-panel .nav-item y-button::part(button) {
2895
+ width: 100%;
2896
+ justify-content: flex-start;
2897
+ }
2898
+ .mobile-panel ::slotted(:not([slot])) {
2899
+ width: 100%;
2765
2900
  }
2766
2901
 
2767
2902
  ::slotted(*) {
@@ -2773,56 +2908,147 @@ class YumeAppbar extends HTMLElement {
2773
2908
  height: var(--component-icon-size-large, 1.25em);
2774
2909
  }
2775
2910
  `;
2776
- this.shadowRoot.appendChild(style);
2911
+ }
2777
2912
 
2778
- const bar = document.createElement("div");
2779
- bar.className = "appbar";
2780
- bar.setAttribute("role", "navigation");
2781
- bar.style.setProperty("--_appbar-padding", cfg.padding);
2913
+ _buildNavItem(item, cfg, isCollapsed, menuDir) {
2914
+ const hasChildren = item.children?.length > 0;
2915
+ const showLabel = item.text && !isCollapsed;
2916
+ const showArrow = hasChildren && !isCollapsed;
2917
+ const btnId = this._uid("appbar-btn");
2782
2918
 
2783
- /* ── Left: hamburger button ── */
2784
- const startSection = document.createElement("div");
2785
- startSection.className = "mobile-start";
2919
+ const btn = createElement("y-button", {
2920
+ id: btnId,
2921
+ color: this._isItemActive(item) ? "primary" : "base",
2922
+ "style-type": "flat",
2923
+ size: cfg.buttonSize,
2924
+ });
2786
2925
 
2787
- const menuBtn = document.createElement("y-button");
2788
- const menuBtnId = this._uid("appbar-mobile-menu");
2789
- menuBtn.id = menuBtnId;
2790
- menuBtn.setAttribute("color", "base");
2791
- menuBtn.setAttribute("style-type", "flat");
2792
- menuBtn.setAttribute("size", cfg.buttonSize);
2793
- menuBtn.setAttribute("aria-label", "Open menu");
2794
-
2795
- const menuIcon = document.createElement("span");
2796
- menuIcon.slot = "left-icon";
2797
- menuIcon.innerHTML = menu;
2798
- menuBtn.appendChild(menuIcon);
2799
- startSection.appendChild(menuBtn);
2800
-
2801
- const navItems = this.items;
2802
- if (navItems.length > 0) {
2803
- const mobileMenu = document.createElement("y-menu");
2804
- mobileMenu.setAttribute("anchor", menuBtnId);
2805
- mobileMenu.setAttribute("direction", "down");
2806
- mobileMenu.setAttribute("size", cfg.buttonSize);
2807
- mobileMenu.items = this._toMenuItems(navItems);
2808
- startSection.appendChild(mobileMenu);
2809
- }
2810
- bar.appendChild(startSection);
2811
-
2812
- /* ── Center: logo + title ── */
2813
- const centerSection = document.createElement("div");
2814
- centerSection.className = "mobile-center";
2815
- centerSection.appendChild(this._makeSlot("logo"));
2816
- centerSection.appendChild(this._makeSlot("title"));
2817
- bar.appendChild(centerSection);
2818
-
2819
- /* ── Right: footer slot ── */
2820
- const endSection = document.createElement("div");
2821
- endSection.className = "mobile-end";
2822
- endSection.setAttribute("part", "footer");
2823
- endSection.appendChild(this._makeSlot("footer"));
2824
- bar.appendChild(endSection);
2926
+ if (item.icon) btn.appendChild(this._buildItemIcon(item.icon, cfg));
2927
+ if (showLabel) btn.appendChild(document.createTextNode(item.text));
2928
+ if (showArrow) {
2929
+ btn.appendChild(
2930
+ createElement("y-icon", {
2931
+ slot: "right-icon",
2932
+ name: `chevron-${menuDir}`,
2933
+ size: cfg.iconSize,
2934
+ }),
2935
+ );
2936
+ }
2937
+
2938
+ if (item.href && !hasChildren) {
2939
+ btn.addEventListener("click", () => this._navigateTo(item.href));
2940
+ }
2941
+
2942
+ const wrapper = createElement("div", { class: "nav-item" });
2943
+ if (item.slot) {
2944
+ const slot = createElement("slot", { name: item.slot });
2945
+ slot.appendChild(btn);
2946
+ wrapper.appendChild(slot);
2947
+ } else {
2948
+ wrapper.appendChild(btn);
2949
+ }
2950
+
2951
+ if (hasChildren) {
2952
+ const menuEl = createElement("y-menu", {
2953
+ anchor: btnId,
2954
+ direction: menuDir,
2955
+ size: cfg.buttonSize,
2956
+ });
2957
+ menuEl.items = item.children;
2958
+ wrapper.appendChild(menuEl);
2959
+ }
2960
+
2961
+ return wrapper;
2962
+ }
2963
+
2964
+ _getBreakpointPx() {
2965
+ const attr = this.mobileBreakpoint;
2966
+ if (attr) {
2967
+ const px = parseInt(attr, 10);
2968
+ if (!isNaN(px) && px > 0) return px;
2969
+ }
2970
+
2971
+ const cssVal = getComputedStyle(document.documentElement)
2972
+ .getPropertyValue("--component-appbar-mobile-breakpoint")
2973
+ .trim();
2974
+ if (cssVal) {
2975
+ const px = parseInt(cssVal, 10);
2976
+ if (!isNaN(px) && px > 0) return px;
2977
+ }
2978
+
2979
+ return 768;
2980
+ }
2981
+
2982
+ _initRender() {
2983
+ this._teardownMobileOutsideClick();
2984
+ this.shadowRoot.innerHTML = "";
2985
+ this._idCounter = 0;
2986
+ }
2987
+
2988
+ _isItemActive(item) {
2989
+ if (item.selected) return true;
2990
+ if (!item.href) return false;
2991
+
2992
+ const loc = window.location;
2993
+ const current = loc.pathname + loc.search + loc.hash;
2994
+
2995
+ return item.href === current || item.href === loc.href;
2996
+ }
2997
+
2998
+ _navigateTo(href) {
2999
+ const event = new CustomEvent("navigate", {
3000
+ bubbles: true,
3001
+ composed: true,
3002
+ cancelable: true,
3003
+ detail: { href },
3004
+ });
3005
+ if (!this.dispatchEvent(event)) return;
3006
+
3007
+ if (this.getAttribute("history") === "false") {
3008
+ window.location.href = href;
3009
+ } else {
3010
+ history.pushState({}, "", href);
3011
+ window.dispatchEvent(new PopStateEvent("popstate", { state: {} }));
3012
+ }
3013
+ }
3014
+
3015
+ _onCollapseClick() {
3016
+ this.toggle();
3017
+ }
3018
+
3019
+ _onMediaChange(e) {
3020
+ this._isMobile = e.matches;
3021
+ this.render();
3022
+ }
3023
+
3024
+ _renderDesktop() {
3025
+ this._initRender();
3026
+
3027
+ const isVertical = this.orientation === "vertical";
3028
+ const isCollapsed = this.collapsed && isVertical;
3029
+ const cfg = SIZE_CONFIG[this.size] || SIZE_CONFIG.medium;
3030
+ const menuDir = this.menuDirection || (isVertical ? "right" : "down");
3031
+
3032
+ const style = createElement("style", {}, [this._buildDesktopStyles()]);
3033
+ const bar = this._buildDesktopBar(
3034
+ cfg,
3035
+ isVertical,
3036
+ isCollapsed,
3037
+ menuDir,
3038
+ );
3039
+
3040
+ this.shadowRoot.appendChild(style);
3041
+ this.shadowRoot.appendChild(bar);
3042
+ }
3043
+
3044
+ _renderMobile() {
3045
+ this._initRender();
3046
+ const cfg = SIZE_CONFIG[this.size] || SIZE_CONFIG.medium;
3047
+
3048
+ const style = createElement("style", {}, [this._buildMobileStyles()]);
3049
+ const bar = this._buildMobileBar(cfg);
2825
3050
 
3051
+ this.shadowRoot.appendChild(style);
2826
3052
  this.shadowRoot.appendChild(bar);
2827
3053
  }
2828
3054
 
@@ -2839,26 +3065,15 @@ class YumeAppbar extends HTMLElement {
2839
3065
  }
2840
3066
 
2841
3067
  _teardownMediaQuery() {
2842
- if (this._mql) {
2843
- this._mql.removeEventListener("change", this._onMediaChange);
2844
- this._mql = null;
2845
- }
3068
+ if (!this._mql) return;
3069
+ this._mql.removeEventListener("change", this._onMediaChange);
3070
+ this._mql = null;
2846
3071
  }
2847
3072
 
2848
- /**
2849
- * Convert appbar nav items to y-menu item format.
2850
- * Maps `href` → `url` and recursively converts children.
2851
- */
2852
- _toMenuItems(items) {
2853
- return items.map((item) => {
2854
- const mi = { text: item.text || "" };
2855
- if (item.href) mi.url = item.href;
2856
- if (item.icon) mi.icon = item.icon;
2857
- if (item.children?.length) {
2858
- mi.children = this._toMenuItems(item.children);
2859
- }
2860
- return mi;
2861
- });
3073
+ _teardownMobileOutsideClick() {
3074
+ if (!this._mobileOutsideClick) return;
3075
+ document.removeEventListener("pointerdown", this._mobileOutsideClick);
3076
+ this._mobileOutsideClick = null;
2862
3077
  }
2863
3078
 
2864
3079
  _uid(prefix) {
@@ -3369,6 +3584,7 @@ class YumeBadge extends HTMLElement {
3369
3584
  constructor() {
3370
3585
  super();
3371
3586
  this.attachShadow({ mode: "open" });
3587
+ this._onSlotChange = this._onSlotChange.bind(this);
3372
3588
  this.render();
3373
3589
  }
3374
3590
 
@@ -3411,21 +3627,24 @@ class YumeBadge extends HTMLElement {
3411
3627
  render() {
3412
3628
  const [badgeColor, badgeTextColor] = this._getBadgeColors(this.color);
3413
3629
  const { fontSize, padding, minSize } = this._getSizeAttributes(this.size);
3414
- const hasTarget = this._hasTargetContent();
3415
- const positionCSS = hasTarget
3416
- ? this._getBadgePosition(this.position, this.alignment)
3417
- : "";
3630
+ const positionCSS = this._getBadgePosition(this.position, this.alignment);
3418
3631
 
3419
3632
  this.shadowRoot.innerHTML = `
3420
3633
  <style>
3421
3634
  :host {
3422
- position: ${hasTarget ? "relative" : "static"};
3423
3635
  display: inline-flex;
3424
3636
  align-items: center;
3425
3637
  }
3638
+ .root {
3639
+ position: static;
3640
+ display: inline-flex;
3641
+ align-items: center;
3642
+ }
3643
+ .root.has-target {
3644
+ position: relative;
3645
+ }
3426
3646
  .badge {
3427
- position: ${hasTarget ? "absolute" : "static"};
3428
- ${positionCSS}
3647
+ position: static;
3429
3648
  background: ${badgeColor};
3430
3649
  color: ${badgeTextColor};
3431
3650
  font-size: ${fontSize};
@@ -3440,14 +3659,24 @@ class YumeBadge extends HTMLElement {
3440
3659
  height: ${minSize};
3441
3660
  z-index: 20;
3442
3661
  }
3662
+ .root.has-target .badge {
3663
+ position: absolute;
3664
+ ${positionCSS}
3665
+ }
3443
3666
  ::slotted(*) {
3444
3667
  position: relative;
3445
3668
  display: inline-block;
3446
3669
  }
3447
3670
  </style>
3448
- ${hasTarget ? "<slot></slot>" : ""}
3449
- <div class="badge" part="badge">${this.value}</div>
3671
+ <div class="root" part="root">
3672
+ <slot></slot>
3673
+ <div class="badge" part="badge">${this.value}</div>
3674
+ </div>
3450
3675
  `;
3676
+
3677
+ const slot = this.shadowRoot.querySelector("slot");
3678
+ slot.addEventListener("slotchange", this._onSlotChange);
3679
+ this._onSlotChange();
3451
3680
  }
3452
3681
 
3453
3682
  // -------------------------------------------------------------------------
@@ -3499,12 +3728,14 @@ class YumeBadge extends HTMLElement {
3499
3728
  return sizeMap[size] || sizeMap.small;
3500
3729
  }
3501
3730
 
3502
- _hasTargetContent() {
3503
- return Array.from(this.childNodes).some((node) => {
3504
- if (node.nodeType === Node.ELEMENT_NODE) return true;
3505
- if (node.nodeType === Node.TEXT_NODE) return node.textContent.trim() !== "";
3506
- return false;
3507
- });
3731
+ _onSlotChange() {
3732
+ const slot = this.shadowRoot.querySelector("slot");
3733
+ const root = this.shadowRoot.querySelector(".root");
3734
+ if (!slot || !root) return;
3735
+ const hasElement = slot
3736
+ .assignedNodes({ flatten: true })
3737
+ .some((n) => n.nodeType === Node.ELEMENT_NODE);
3738
+ root.classList.toggle("has-target", hasElement);
3508
3739
  }
3509
3740
  }
3510
3741
 
@@ -16584,4 +16815,4 @@ if (
16584
16815
  document.head.appendChild(style);
16585
16816
  }
16586
16817
 
16587
- export { YumeAppbar, YumeAvatar, YumeBadge, YumeBanner, YumeBreadcrumbs, YumeButton, YumeButtonGroup, YumeCard, YumeCheckbox, YumeColor, YumeColorpicker, YumeDate, YumeDatepicker, YumeDock, YumeGallery, YumeIcon, YumeInput, YumePanel, YumePanelBar, YumeProgress, YumeRadio, YumeRating, YumeSelect, YumeSlider, YumeStack, YumeStepper, YumeTable, YumeTabs, YumeTag, YumeTextarea, YumeTheme, YumeToast, YumeTooltip, getIcon, registerIcon, registerIcons };
16818
+ export { YumeAppbar, YumeAvatar, YumeBadge, YumeBanner, YumeBreadcrumbs, YumeButton, YumeButtonGroup, YumeCard, YumeCheckbox, YumeColor, YumeColorpicker, YumeDate, YumeDatepicker, YumeDock, YumeGallery, YumeIcon, YumeInput, YumeMenu, YumePanel, YumePanelBar, YumeProgress, YumeRadio, YumeRating, YumeSelect, YumeSlider, YumeStack, YumeStepper, YumeTable, YumeTabs, YumeTag, YumeTextarea, YumeTheme, YumeToast, YumeTooltip, getIcon, registerIcon, registerIcons };