BxDolMenuMoreAuto.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. /**
  2. * Copyright (c) UNA, Inc - https://una.io
  3. * MIT License - https://opensource.org/licenses/MIT
  4. *
  5. * @defgroup UnaCore UNA Core
  6. * @{
  7. */
  8. /**
  9. * A horizontal menu that moves the items which don't fit into its 'more' popup.
  10. *
  11. * Which items stay in the row is computed from the items' natural widths, measured on an invisible copy of the
  12. * row (so the visible row is never torn up to be measured), and the space the row has. The answer is applied
  13. * only when it changes, and it is remembered per window width: the next page applies it while it is parsed,
  14. * before the first paint, so on page switches the row shows at once and nothing moves.
  15. *
  16. * The first real measurement waits until the document is parsed (toolbar items that share the header with the
  17. * menu exist only then) and web fonts have loaded. After that a ResizeObserver on the menu's container updates
  18. * the row when the space really changes; only those updates glide the items to their new places.
  19. */
  20. function BxDolMenuMoreAuto(options)
  21. {
  22. this._sObject = options.sObject;
  23. this._iItemsStatic = undefined == options.iItemsStatic ? 0 : options.iItemsStatic;
  24. this._bItemsStaticOnly = undefined == options.bItemsStaticOnly ? 0 : options.bItemsStaticOnly;
  25. this._aHtmlIds = undefined == options.aHtmlIds ? {} : options.aHtmlIds;
  26. this._sClassReady = 'bx-mma-ready';
  27. this._sClassItem = '.bx-menu-item';
  28. this._sClassItemMore = this._sClassItem + '.bx-menu-item-more-auto';
  29. this._sClassItemMoreSubmenu = '.bx-menu-submenu-more-auto';
  30. this._sClassItemStatic = 'bx-menu-item-static';
  31. this._sStorageKey = 'bx-mma-' + this._sObject;
  32. this._iStorageWidths = 12;
  33. this._bStaticMode = this._bItemsStaticOnly && this._iItemsStatic > 0;
  34. this._oMenu = null;
  35. this._oItemMore = null;
  36. this._oSubmenu = null;
  37. this._aItems = [];
  38. this._eSubmenuFirst = null;
  39. this._sLayout = '';
  40. this._fLayoutWidth = -1;
  41. this._bSettled = false;
  42. this._oResizeObserver = null;
  43. }
  44. /**
  45. * Menus set up on this page. One web fonts listener (and, without ResizeObserver, one window resize handler) serves
  46. * them all. A menu whose element has left the page, e.g. a submenu in content that htmx swapped out, is dropped and
  47. * its observer disconnected, so nothing keeps it in memory.
  48. */
  49. BxDolMenuMoreAuto._aInstances = [];
  50. BxDolMenuMoreAuto._bListening = false;
  51. BxDolMenuMoreAuto._register = function(oInstance)
  52. {
  53. BxDolMenuMoreAuto._prune();
  54. BxDolMenuMoreAuto._aInstances.push(oInstance);
  55. if(BxDolMenuMoreAuto._bListening)
  56. return;
  57. BxDolMenuMoreAuto._bListening = true;
  58. // Item labels change width when a web font finishes loading after a menu has settled.
  59. if(document.fonts && document.fonts.addEventListener)
  60. document.fonts.addEventListener('loadingdone', function() {
  61. BxDolMenuMoreAuto._each(function(o) {
  62. if(o._bSettled)
  63. o._layout(true);
  64. });
  65. });
  66. if(typeof(window.ResizeObserver) == 'undefined')
  67. $(window).on('resize', function() {
  68. BxDolMenuMoreAuto._each(function(o) {
  69. o._onResize();
  70. });
  71. });
  72. };
  73. BxDolMenuMoreAuto._each = function(fCallback)
  74. {
  75. BxDolMenuMoreAuto._prune();
  76. $.each(BxDolMenuMoreAuto._aInstances.slice(), function(i, oInstance) {
  77. fCallback(oInstance);
  78. });
  79. };
  80. BxDolMenuMoreAuto._prune = function()
  81. {
  82. BxDolMenuMoreAuto._aInstances = $.grep(BxDolMenuMoreAuto._aInstances, function(oInstance) {
  83. if(oInstance._isOnPage())
  84. return true;
  85. oInstance._release();
  86. return false;
  87. });
  88. };
  89. BxDolMenuMoreAuto.prototype.init = function()
  90. {
  91. var $this = this;
  92. var oMenu = $('#' + this._aHtmlIds['main']);
  93. if(!oMenu.length)
  94. return;
  95. // Pages may run the init code again for a menu that is already set up (a re-rendered block keeps the element).
  96. var eMenu = oMenu.get(0);
  97. if(eMenu._bxMenuMoreAuto)
  98. return;
  99. eMenu._bxMenuMoreAuto = this;
  100. this._oMenu = oMenu;
  101. this._oItemMore = oMenu.children(this._sClassItemMore);
  102. this._oSubmenu = this._oItemMore.find(this._sClassItemMoreSubmenu);
  103. this._eSubmenuFirst = this._oSubmenu.children(this._sClassItem).get(0) || null;
  104. this._aItems = oMenu.children(this._sClassItem).not(this._sClassItemMore).toArray();
  105. // The first N items are persistent: they never move into 'more'.
  106. var iStatic = 0;
  107. $.each(this._aItems, function(i, eItem) {
  108. if(iStatic >= $this._iItemsStatic)
  109. return false;
  110. if(!$(eItem).hasClass($this._sClassItemStatic)) {
  111. $(eItem).addClass($this._sClassItemStatic);
  112. iStatic++;
  113. }
  114. });
  115. oMenu.css('overflow', 'visible');
  116. oMenu.parents('.bx-menu-more-auto-wrapper:first').css('overflow', 'visible');
  117. /**
  118. * The row this window width had on the previous page is applied right away, so the row can show before the
  119. * first paint. Without one, a first guess is applied and the row stays hidden (templates keep it hidden until
  120. * it is marked ready) until it is measured for real.
  121. */
  122. var sRemembered = this._bStaticMode ? '' : this._recall();
  123. if(sRemembered) {
  124. this._apply(sRemembered);
  125. this._reveal(false);
  126. }
  127. else
  128. this._layout(false);
  129. if(this._bStaticMode) {
  130. this._reveal(false);
  131. return;
  132. }
  133. var fSettle = function() {
  134. $this._whenFontsReady(function() {
  135. $this._settle();
  136. });
  137. };
  138. if(document.readyState == 'loading')
  139. $(document).ready(fSettle);
  140. else
  141. fSettle();
  142. //--- Track the space the row has: other header items appearing, a sidebar toggling, the window resizing ---//
  143. if(typeof(window.ResizeObserver) != 'undefined') {
  144. this._oResizeObserver = new ResizeObserver(function() {
  145. // Removing the element reports a resize too: that is when a menu leaves the page.
  146. if($this._isOnPage())
  147. $this._onResize();
  148. else
  149. BxDolMenuMoreAuto._prune();
  150. });
  151. this._oResizeObserver.observe(oMenu.parent().get(0));
  152. }
  153. BxDolMenuMoreAuto._register(this);
  154. };
  155. BxDolMenuMoreAuto.prototype._isOnPage = function()
  156. {
  157. return !!this._oMenu && this._oMenu.get(0).isConnected;
  158. };
  159. BxDolMenuMoreAuto.prototype._release = function()
  160. {
  161. if(!this._oResizeObserver)
  162. return;
  163. this._oResizeObserver.disconnect();
  164. this._oResizeObserver = null;
  165. };
  166. /**
  167. * Recalculate the row now, e.g. after an item's content has changed.
  168. */
  169. BxDolMenuMoreAuto.prototype.update = function(bForceCalculate)
  170. {
  171. if(this._bStaticMode || !this._oMenu)
  172. return;
  173. this._layout(this._bSettled);
  174. };
  175. BxDolMenuMoreAuto.prototype.more = function(oElement)
  176. {
  177. var oElement = $(oElement);
  178. oElement.parents('li:first').find('#' + this._aHtmlIds['more_auto_popup']).dolPopup({
  179. pointer: {
  180. el: oElement
  181. },
  182. moveToDocRoot: false,
  183. cssClass: 'bx-popup-menu',
  184. onShow: function() {
  185. oElement.attr('aria-expanded', 'true');
  186. },
  187. onHide: function() {
  188. oElement.attr('aria-expanded', 'false');
  189. }
  190. });
  191. };
  192. BxDolMenuMoreAuto.prototype._settle = function()
  193. {
  194. if(this._bSettled)
  195. return;
  196. /**
  197. * Usually the row is still hidden here, or it already is the remembered one and nothing moves. If the remembered
  198. * row turns out to be wrong (the toolbar changed since), the shown items glide to their places.
  199. */
  200. this._bSettled = true;
  201. this._layout(this._oMenu.hasClass(this._sClassReady));
  202. this._reveal(true);
  203. };
  204. /**
  205. * Mark the row ready, which shows it in templates that hide it until then. A remembered row may have been styled
  206. * hidden already (the browser can paint before the init code runs), so it skips the template's reveal transition.
  207. */
  208. BxDolMenuMoreAuto.prototype._reveal = function(bTransition)
  209. {
  210. var eMenu = this._oMenu.get(0);
  211. if(this._oMenu.hasClass(this._sClassReady))
  212. return;
  213. if(bTransition) {
  214. this._oMenu.addClass(this._sClassReady);
  215. return;
  216. }
  217. var sTransition = eMenu.style.transition;
  218. eMenu.style.transition = 'none';
  219. this._oMenu.addClass(this._sClassReady);
  220. this._fRevealOpacity = Number.parseFloat(window.getComputedStyle(eMenu).opacity); // Reading it applies the change before the transition comes back.
  221. eMenu.style.transition = sTransition;
  222. };
  223. BxDolMenuMoreAuto.prototype._onResize = function()
  224. {
  225. if(!this._bSettled)
  226. return;
  227. // Our own changes to the row don't change the container's width; skip the callbacks they cause.
  228. if(Math.abs(this._getAvailable() - this._fLayoutWidth) < 0.5)
  229. return;
  230. this._layout(true);
  231. };
  232. BxDolMenuMoreAuto.prototype._whenFontsReady = function(fCallback)
  233. {
  234. if(!document.fonts || typeof(document.fonts.ready) != 'object' || document.fonts.status == 'loaded') {
  235. fCallback();
  236. return;
  237. }
  238. var bDone = false;
  239. var fOnce = function() {
  240. if(bDone)
  241. return;
  242. bDone = true;
  243. fCallback();
  244. };
  245. document.fonts.ready.then(fOnce, fOnce);
  246. setTimeout(fOnce, 1000); // A font that never loads doesn't keep the menu hidden.
  247. };
  248. /**
  249. * Measure, decide which items stay in the row and apply it.
  250. * @param bAnimate - glide the items that stay to their new places (only for a row that is already shown)
  251. */
  252. BxDolMenuMoreAuto.prototype._layout = function(bAnimate)
  253. {
  254. if(this._bStaticMode) {
  255. this._apply(this._compute(null, 0));
  256. return;
  257. }
  258. var fAvailable = this._getAvailable();
  259. if(fAvailable <= 0)
  260. return; // Not displayed yet: the ResizeObserver calls again once it is.
  261. var sLayout = this._compute(this._measure(), fAvailable);
  262. var aBefore = bAnimate && sLayout != this._sLayout ? this._getPositions() : null;
  263. this._apply(sLayout);
  264. if(aBefore)
  265. this._animateFrom(aBefore);
  266. this._fLayoutWidth = this._getAvailable();
  267. if(this._bSettled)
  268. this._remember(sLayout);
  269. };
  270. /**
  271. * The width the row can use: its container's content width less the row's own padding.
  272. */
  273. BxDolMenuMoreAuto.prototype._getAvailable = function()
  274. {
  275. var eParent = this._oMenu.parent().get(0);
  276. var oStyleParent = window.getComputedStyle(eParent);
  277. var oStyleMenu = window.getComputedStyle(this._oMenu.get(0));
  278. return eParent.getBoundingClientRect().width
  279. - Number.parseFloat(oStyleParent.paddingLeft) - Number.parseFloat(oStyleParent.paddingRight)
  280. - Number.parseFloat(oStyleParent.borderLeftWidth) - Number.parseFloat(oStyleParent.borderRightWidth)
  281. - Number.parseFloat(oStyleMenu.paddingLeft) - Number.parseFloat(oStyleMenu.paddingRight)
  282. - Number.parseFloat(oStyleMenu.borderLeftWidth) - Number.parseFloat(oStyleMenu.borderRightWidth);
  283. };
  284. /**
  285. * Natural widths of all items and of 'more', taken from an invisible copy of the row placed next to it, so it
  286. * gets the same styles. Each item's share includes the gap before it, so any run of items adds up.
  287. * @return {items: [share, ...], more: share}
  288. */
  289. BxDolMenuMoreAuto.prototype._measure = function()
  290. {
  291. var $this = this;
  292. var eMenu = this._oMenu.get(0);
  293. var eProbe = eMenu.cloneNode(false);
  294. eProbe.removeAttribute('id');
  295. eProbe.setAttribute('aria-hidden', 'true');
  296. eProbe.style.cssText = 'position:absolute;top:0;left:0;width:max-content;max-width:none;overflow:visible;visibility:hidden;pointer-events:none;';
  297. var aClones = $.map(this._aItems, function(eItem) {
  298. var eClone = eItem.cloneNode(true);
  299. $this._styleForRow($(eClone));
  300. eProbe.appendChild(eClone);
  301. return eClone;
  302. });
  303. var eMoreClone = null;
  304. if(this._oItemMore.length) {
  305. // Without its popup, which holds the items that are in 'more' now.
  306. eMoreClone = this._oItemMore.get(0).cloneNode(true);
  307. $(eMoreClone).find('#' + this._aHtmlIds['more_auto_popup']).remove();
  308. eMoreClone.style.display = 'list-item';
  309. eProbe.appendChild(eMoreClone);
  310. }
  311. $(eProbe).find('[id]').removeAttr('id');
  312. eMenu.parentNode.insertBefore(eProbe, eMenu);
  313. var bRtl = window.getComputedStyle(eProbe).direction == 'rtl';
  314. var oProbe = eProbe.getBoundingClientRect();
  315. var oStyleProbe = window.getComputedStyle(eProbe);
  316. var fStart = bRtl ? oProbe.right - Number.parseFloat(oStyleProbe.paddingRight) - Number.parseFloat(oStyleProbe.borderRightWidth) : oProbe.left + Number.parseFloat(oStyleProbe.paddingLeft) + Number.parseFloat(oStyleProbe.borderLeftWidth);
  317. var fEdge = function(eElement) {
  318. var oRect = eElement.getBoundingClientRect();
  319. return bRtl ? fStart - oRect.left : oRect.right - fStart;
  320. };
  321. var fPrev = 0;
  322. var aItems = $.map(aClones, function(eClone) {
  323. if(!eClone.getClientRects().length)
  324. return 0; // Not displayed at this width.
  325. var fEnd = fEdge(eClone);
  326. var fShare = fEnd - fPrev;
  327. fPrev = fEnd;
  328. return fShare;
  329. });
  330. var fMore = eMoreClone && eMoreClone.getClientRects().length ? fEdge(eMoreClone) - fPrev : 0;
  331. eProbe.remove();
  332. return {items: aItems, more: fMore};
  333. };
  334. /**
  335. * Which items stay in the row: a string with '1' (row) or '0' (more) per item, in their order.
  336. * In static mode only the persistent items stay and no sizes are needed.
  337. */
  338. BxDolMenuMoreAuto.prototype._compute = function(oSizes, fAvailable)
  339. {
  340. var $this = this;
  341. var fEps = 0.5;
  342. var fAll = 0;
  343. if(oSizes)
  344. $.each(oSizes.items, function(i, fShare) {
  345. fAll += fShare;
  346. });
  347. var bAllFit = !this._bStaticMode && fAll <= fAvailable + fEps;
  348. var aResult = [];
  349. var bRelocate = false;
  350. var fUsed = oSizes ? oSizes.more : 0;
  351. $.each(this._aItems, function(i, eItem) {
  352. var bStatic = $(eItem).hasClass($this._sClassItemStatic);
  353. var fShare = oSizes ? oSizes.items[i] : 0;
  354. if(bAllFit || bStatic || (!$this._bStaticMode && !bRelocate && fUsed + fShare <= fAvailable + fEps)) {
  355. aResult.push('1');
  356. fUsed += fShare;
  357. return;
  358. }
  359. aResult.push('0');
  360. bRelocate = true;
  361. });
  362. return aResult.join('');
  363. };
  364. /**
  365. * Put each item in the row or in 'more', keeping their order, and touch only the ones that change place.
  366. */
  367. BxDolMenuMoreAuto.prototype._apply = function(sLayout)
  368. {
  369. if(sLayout.length != this._aItems.length || sLayout == this._sLayout)
  370. return;
  371. var eMenu = this._oMenu.get(0);
  372. var eSubmenu = this._oSubmenu.get(0);
  373. var eRowNext = this._oItemMore.get(0) || null;
  374. var eSubmenuNext = this._eSubmenuFirst;
  375. for(var i = this._aItems.length - 1; i >= 0; i--) {
  376. var eItem = this._aItems[i];
  377. if(sLayout.charAt(i) == '1' || !eSubmenu) {
  378. if(eItem.parentNode !== eMenu) {
  379. this._styleForRow($(eItem));
  380. eMenu.insertBefore(eItem, eRowNext); // eRowNext may be null (no 'more' item): append.
  381. }
  382. eRowNext = eItem;
  383. }
  384. else {
  385. if(eItem.parentNode !== eSubmenu) {
  386. this._styleForSubmenu($(eItem));
  387. eSubmenu.insertBefore(eItem, eSubmenuNext);
  388. }
  389. eSubmenuNext = eItem;
  390. }
  391. }
  392. // Shown and hidden at once: a jQuery show() animation would grow the item while the others move.
  393. if(sLayout.includes('0'))
  394. this._oItemMore.show();
  395. else
  396. this._oItemMore.hide();
  397. this._sLayout = sLayout;
  398. };
  399. BxDolMenuMoreAuto.prototype._styleForRow = function(oItem)
  400. {
  401. oItem.removeClass('bx-def-color-bg-hl-hover');
  402. oItem.find('a.bx-nobtn').removeClass('bx-nobtn').addClass('bx-btn');
  403. oItem.find('a.bx-nobtn-height').removeClass('bx-nobtn-height').addClass('bx-btn-height');
  404. };
  405. BxDolMenuMoreAuto.prototype._styleForSubmenu = function(oItem)
  406. {
  407. oItem.addClass('bx-def-color-bg-hl-hover');
  408. oItem.find('a.bx-btn').removeClass('bx-btn').addClass('bx-nobtn');
  409. oItem.find('a.bx-btn-height').removeClass('bx-btn-height').addClass('bx-nobtn-height');
  410. };
  411. /**
  412. * The remembered row is stored per window width, together with the item names it was computed for.
  413. */
  414. BxDolMenuMoreAuto.prototype._getSignature = function()
  415. {
  416. return $.map(this._aItems, function(eItem) {
  417. return (eItem.className.match(/bx-menu-item-([\w-]+)/) || ['', ''])[1] + ':' + $(eItem).text().trim().length;
  418. }).join(',');
  419. };
  420. BxDolMenuMoreAuto.prototype._recall = function()
  421. {
  422. try {
  423. var oStored = JSON.parse(window.localStorage.getItem(this._sStorageKey) || 'null');
  424. if(!oStored || oStored.sig != this._getSignature() || !oStored.rows)
  425. return '';
  426. var sLayout = oStored.rows[window.innerWidth];
  427. return typeof(sLayout) == 'string' && sLayout.length == this._aItems.length ? sLayout : '';
  428. }
  429. catch(e) {
  430. return ''; // Storage unavailable (private mode, blocked) or corrupt: lay out from scratch.
  431. }
  432. };
  433. BxDolMenuMoreAuto.prototype._remember = function(sLayout)
  434. {
  435. try {
  436. var sSignature = this._getSignature();
  437. var oStored = JSON.parse(window.localStorage.getItem(this._sStorageKey) || 'null');
  438. if(!oStored || oStored.sig != sSignature)
  439. oStored = {sig: sSignature, rows: {}, order: []};
  440. var sWidth = String(window.innerWidth);
  441. if(oStored.rows[sWidth] == sLayout)
  442. return;
  443. oStored.rows[sWidth] = sLayout;
  444. oStored.order = $.grep(oStored.order || [], function(s) { return s != sWidth; });
  445. oStored.order.push(sWidth);
  446. while(oStored.order.length > this._iStorageWidths)
  447. delete oStored.rows[oStored.order.shift()];
  448. window.localStorage.setItem(this._sStorageKey, JSON.stringify(oStored));
  449. }
  450. catch(e) {
  451. // Storage unavailable or full: the row is only not remembered for the next page.
  452. }
  453. };
  454. /**
  455. * Where each shown item sits before an update, so the items that stay can glide to their new place
  456. * (a centered menu shifts when an item moves in or out of 'more') instead of jumping.
  457. */
  458. BxDolMenuMoreAuto.prototype._getPositions = function()
  459. {
  460. var aResult = [];
  461. this._oMenu.children(this._sClassItem + ':visible').each(function() {
  462. aResult.push({item: this, left: this.getBoundingClientRect().left});
  463. });
  464. return aResult;
  465. };
  466. BxDolMenuMoreAuto.prototype._animateFrom = function(aBefore)
  467. {
  468. if(!aBefore.length || typeof(Element.prototype.animate) != 'function' || (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches))
  469. return;
  470. var oOptions = {duration: 200, easing: 'cubic-bezier(0.2, 0, 0, 1)'};
  471. this._oMenu.children(this._sClassItem + ':visible').each(function(iIndex, eItem) {
  472. var oBefore = aBefore.find(function(o) {
  473. return o.item === eItem;
  474. });
  475. if(!oBefore) {
  476. eItem.animate([{opacity: 0}, {opacity: 1}], oOptions);
  477. return;
  478. }
  479. var fShift = oBefore.left - eItem.getBoundingClientRect().left;
  480. if(Math.abs(fShift) >= 1)
  481. eItem.animate([{transform: 'translateX(' + fShift + 'px)'}, {transform: 'none'}], oOptions);
  482. });
  483. };
  484. /** @} */