gitea源码

overflow-menu.ts 9.5KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import {throttle} from 'throttle-debounce';
  2. import {createTippy} from '../modules/tippy.ts';
  3. import {addDelegatedEventListener, isDocumentFragmentOrElementNode} from '../utils/dom.ts';
  4. import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';
  5. window.customElements.define('overflow-menu', class extends HTMLElement {
  6. tippyContent: HTMLDivElement;
  7. tippyItems: Array<HTMLElement>;
  8. button: HTMLButtonElement;
  9. menuItemsEl: HTMLElement;
  10. resizeObserver: ResizeObserver;
  11. mutationObserver: MutationObserver;
  12. lastWidth: number;
  13. updateButtonActivationState() {
  14. if (!this.button || !this.tippyContent) return;
  15. this.button.classList.toggle('active', Boolean(this.tippyContent.querySelector('.item.active')));
  16. }
  17. updateItems = throttle(100, () => {
  18. if (!this.tippyContent) {
  19. const div = document.createElement('div');
  20. div.tabIndex = -1; // for initial focus, programmatic focus only
  21. div.addEventListener('keydown', (e) => {
  22. if (e.key === 'Tab') {
  23. const items = this.tippyContent.querySelectorAll<HTMLElement>('[role="menuitem"]');
  24. if (e.shiftKey) {
  25. if (document.activeElement === items[0]) {
  26. e.preventDefault();
  27. items[items.length - 1].focus();
  28. }
  29. } else {
  30. if (document.activeElement === items[items.length - 1]) {
  31. e.preventDefault();
  32. items[0].focus();
  33. }
  34. }
  35. } else if (e.key === 'Escape') {
  36. e.preventDefault();
  37. e.stopPropagation();
  38. this.button._tippy.hide();
  39. this.button.focus();
  40. } else if (e.key === ' ' || e.code === 'Enter') {
  41. if (document.activeElement?.matches('[role="menuitem"]')) {
  42. e.preventDefault();
  43. e.stopPropagation();
  44. (document.activeElement as HTMLElement).click();
  45. }
  46. } else if (e.key === 'ArrowDown') {
  47. if (document.activeElement?.matches('.tippy-target')) {
  48. e.preventDefault();
  49. e.stopPropagation();
  50. document.activeElement.querySelector<HTMLElement>('[role="menuitem"]:first-of-type').focus();
  51. } else if (document.activeElement?.matches('[role="menuitem"]')) {
  52. e.preventDefault();
  53. e.stopPropagation();
  54. (document.activeElement.nextElementSibling as HTMLElement)?.focus();
  55. }
  56. } else if (e.key === 'ArrowUp') {
  57. if (document.activeElement?.matches('.tippy-target')) {
  58. e.preventDefault();
  59. e.stopPropagation();
  60. document.activeElement.querySelector<HTMLElement>('[role="menuitem"]:last-of-type').focus();
  61. } else if (document.activeElement?.matches('[role="menuitem"]')) {
  62. e.preventDefault();
  63. e.stopPropagation();
  64. (document.activeElement.previousElementSibling as HTMLElement)?.focus();
  65. }
  66. }
  67. });
  68. div.classList.add('tippy-target');
  69. this.handleItemClick(div, '.tippy-target > .item');
  70. this.tippyContent = div;
  71. } // end if: no tippyContent and create a new one
  72. const itemFlexSpace = this.menuItemsEl.querySelector<HTMLSpanElement>('.item-flex-space');
  73. const itemOverFlowMenuButton = this.querySelector<HTMLButtonElement>('.overflow-menu-button');
  74. // move items in tippy back into the menu items for subsequent measurement
  75. for (const item of this.tippyItems || []) {
  76. if (!itemFlexSpace || item.getAttribute('data-after-flex-space')) {
  77. this.menuItemsEl.append(item);
  78. } else {
  79. itemFlexSpace.insertAdjacentElement('beforebegin', item);
  80. }
  81. }
  82. // measure which items are partially outside the element and move them into the button menu
  83. // flex space and overflow menu are excluded from measurement
  84. itemFlexSpace?.style.setProperty('display', 'none', 'important');
  85. itemOverFlowMenuButton?.style.setProperty('display', 'none', 'important');
  86. this.tippyItems = [];
  87. const menuRight = this.offsetLeft + this.offsetWidth;
  88. const menuItems = this.menuItemsEl.querySelectorAll<HTMLElement>('.item, .item-flex-space');
  89. let afterFlexSpace = false;
  90. for (const [idx, item] of menuItems.entries()) {
  91. if (item.classList.contains('item-flex-space')) {
  92. afterFlexSpace = true;
  93. continue;
  94. }
  95. if (afterFlexSpace) item.setAttribute('data-after-flex-space', 'true');
  96. const itemRight = item.offsetLeft + item.offsetWidth;
  97. if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button with some extra space
  98. const onlyLastItem = idx === menuItems.length - 1 && this.tippyItems.length === 0;
  99. const lastItemFit = onlyLastItem && menuRight - itemRight > 0;
  100. const moveToPopup = !onlyLastItem || !lastItemFit;
  101. if (moveToPopup) this.tippyItems.push(item);
  102. }
  103. }
  104. itemFlexSpace?.style.removeProperty('display');
  105. itemOverFlowMenuButton?.style.removeProperty('display');
  106. // if there are no overflown items, remove any previously created button
  107. if (!this.tippyItems?.length) {
  108. const btn = this.querySelector('.overflow-menu-button');
  109. btn?._tippy?.destroy();
  110. btn?.remove();
  111. this.button = null;
  112. return;
  113. }
  114. // remove aria role from items that moved from tippy to menu
  115. for (const item of menuItems) {
  116. if (!this.tippyItems.includes(item)) {
  117. item.removeAttribute('role');
  118. }
  119. }
  120. // move all items that overflow into tippy
  121. for (const item of this.tippyItems) {
  122. item.setAttribute('role', 'menuitem');
  123. this.tippyContent.append(item);
  124. }
  125. // update existing tippy
  126. if (this.button?._tippy) {
  127. this.button._tippy.setContent(this.tippyContent);
  128. this.updateButtonActivationState();
  129. return;
  130. }
  131. // create button initially
  132. this.button = document.createElement('button');
  133. this.button.classList.add('overflow-menu-button');
  134. this.button.setAttribute('aria-label', window.config.i18n.more_items);
  135. this.button.innerHTML = octiconKebabHorizontal;
  136. this.append(this.button);
  137. createTippy(this.button, {
  138. trigger: 'click',
  139. hideOnClick: true,
  140. interactive: true,
  141. placement: 'bottom-end',
  142. role: 'menu',
  143. theme: 'menu',
  144. content: this.tippyContent,
  145. onShow: () => { // FIXME: onShown doesn't work (never be called)
  146. setTimeout(() => {
  147. this.tippyContent.focus();
  148. }, 0);
  149. },
  150. });
  151. this.updateButtonActivationState();
  152. });
  153. init() {
  154. // for horizontal menus where fomantic boldens active items, prevent this bold text from
  155. // enlarging the menu's active item replacing the text node with a div that renders a
  156. // invisible pseudo-element that enlarges the box.
  157. if (this.matches('.ui.secondary.pointing.menu, .ui.tabular.menu')) {
  158. for (const item of this.querySelectorAll('.item')) {
  159. for (const child of item.childNodes) {
  160. if (child.nodeType === Node.TEXT_NODE) {
  161. const text = child.textContent.trim(); // whitespace is insignificant inside flexbox
  162. if (!text) continue;
  163. const span = document.createElement('span');
  164. span.classList.add('resize-for-semibold');
  165. span.setAttribute('data-text', text);
  166. span.textContent = text;
  167. child.replaceWith(span);
  168. }
  169. }
  170. }
  171. }
  172. // ResizeObserver triggers on initial render, so we don't manually call `updateItems` here which
  173. // also avoids a full-page FOUC in Firefox that happens when `updateItems` is called too soon.
  174. this.resizeObserver = new ResizeObserver((entries) => {
  175. for (const entry of entries) {
  176. const newWidth = entry.contentBoxSize[0].inlineSize;
  177. if (newWidth !== this.lastWidth) {
  178. requestAnimationFrame(() => {
  179. this.updateItems();
  180. });
  181. this.lastWidth = newWidth;
  182. }
  183. }
  184. });
  185. this.resizeObserver.observe(this);
  186. this.handleItemClick(this, '.overflow-menu-items > .item');
  187. }
  188. handleItemClick(el: Element, selector: string) {
  189. addDelegatedEventListener(el, 'click', selector, () => {
  190. this.button?._tippy?.hide();
  191. this.updateButtonActivationState();
  192. });
  193. }
  194. connectedCallback() {
  195. this.setAttribute('role', 'navigation');
  196. // check whether the mandatory `.overflow-menu-items` element is present initially which happens
  197. // with Vue which renders differently than browsers. If it's not there, like in the case of browser
  198. // template rendering, wait for its addition.
  199. // The eslint rule is not sophisticated enough or aware of this problem, see
  200. // https://github.com/43081j/eslint-plugin-wc/pull/130
  201. const menuItemsEl = this.querySelector<HTMLElement>('.overflow-menu-items'); // eslint-disable-line wc/no-child-traversal-in-connectedcallback
  202. if (menuItemsEl) {
  203. this.menuItemsEl = menuItemsEl;
  204. this.init();
  205. } else {
  206. this.mutationObserver = new MutationObserver((mutations) => {
  207. for (const mutation of mutations) {
  208. for (const node of mutation.addedNodes as NodeListOf<HTMLElement>) {
  209. if (!isDocumentFragmentOrElementNode(node)) continue;
  210. if (node.classList.contains('overflow-menu-items')) {
  211. this.menuItemsEl = node;
  212. this.mutationObserver?.disconnect();
  213. this.init();
  214. }
  215. }
  216. }
  217. });
  218. this.mutationObserver.observe(this, {childList: true});
  219. }
  220. }
  221. disconnectedCallback() {
  222. this.mutationObserver?.disconnect();
  223. this.resizeObserver?.disconnect();
  224. }
  225. });