2024-03-15 05:05:31 +03:00
import { throttle } from 'throttle-debounce' ;
2024-07-07 18:32:30 +03:00
import { createTippy } from '../modules/tippy.ts' ;
import { isDocumentFragmentOrElementNode } from '../utils/dom.ts' ;
2024-03-15 05:05:31 +03:00
import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg' ;
window . customElements . define ( 'overflow-menu' , class extends HTMLElement {
2024-10-31 17:57:40 +03:00
tippyContent : HTMLDivElement ;
tippyItems : Array < HTMLElement > ;
button : HTMLButtonElement ;
menuItemsEl : HTMLElement ;
resizeObserver : ResizeObserver ;
mutationObserver : MutationObserver ;
lastWidth : number ;
2024-12-09 10:54:59 +03:00
updateItems = throttle ( 100 , ( ) = > {
2024-03-15 05:05:31 +03:00
if ( ! this . tippyContent ) {
const div = document . createElement ( 'div' ) ;
div . classList . add ( 'tippy-target' ) ;
2024-04-30 07:26:13 +03:00
div . tabIndex = - 1 ; // for initial focus, programmatic focus only
2024-03-15 05:05:31 +03:00
div . addEventListener ( 'keydown' , ( e ) = > {
if ( e . key === 'Tab' ) {
2024-10-31 17:57:40 +03:00
const items = this . tippyContent . querySelectorAll < HTMLElement > ( '[role="menuitem"]' ) ;
2024-03-15 05:05:31 +03:00
if ( e . shiftKey ) {
if ( document . activeElement === items [ 0 ] ) {
e . preventDefault ( ) ;
items [ items . length - 1 ] . focus ( ) ;
}
} else {
if ( document . activeElement === items [ items . length - 1 ] ) {
e . preventDefault ( ) ;
items [ 0 ] . focus ( ) ;
}
}
} else if ( e . key === 'Escape' ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
this . button . _tippy . hide ( ) ;
this . button . focus ( ) ;
} else if ( e . key === ' ' || e . code === 'Enter' ) {
if ( document . activeElement ? . matches ( '[role="menuitem"]' ) ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
2024-10-31 17:57:40 +03:00
( document . activeElement as HTMLElement ) . click ( ) ;
2024-03-15 05:05:31 +03:00
}
} else if ( e . key === 'ArrowDown' ) {
if ( document . activeElement ? . matches ( '.tippy-target' ) ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
2024-10-31 17:57:40 +03:00
document . activeElement . querySelector < HTMLElement > ( '[role="menuitem"]:first-of-type' ) . focus ( ) ;
2024-03-15 05:05:31 +03:00
} else if ( document . activeElement ? . matches ( '[role="menuitem"]' ) ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
2024-10-31 17:57:40 +03:00
( document . activeElement . nextElementSibling as HTMLElement ) ? . focus ( ) ;
2024-03-15 05:05:31 +03:00
}
} else if ( e . key === 'ArrowUp' ) {
if ( document . activeElement ? . matches ( '.tippy-target' ) ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
2024-10-31 17:57:40 +03:00
document . activeElement . querySelector < HTMLElement > ( '[role="menuitem"]:last-of-type' ) . focus ( ) ;
2024-03-15 05:05:31 +03:00
} else if ( document . activeElement ? . matches ( '[role="menuitem"]' ) ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
2024-10-31 17:57:40 +03:00
( document . activeElement . previousElementSibling as HTMLElement ) ? . focus ( ) ;
2024-03-15 05:05:31 +03:00
}
}
} ) ;
this . append ( div ) ;
this . tippyContent = div ;
}
2024-10-31 17:57:40 +03:00
const itemFlexSpace = this . menuItemsEl . querySelector < HTMLSpanElement > ( '.item-flex-space' ) ;
const itemOverFlowMenuButton = this . querySelector < HTMLButtonElement > ( '.overflow-menu-button' ) ;
2024-04-30 07:26:13 +03:00
2024-03-15 05:05:31 +03:00
// move items in tippy back into the menu items for subsequent measurement
for ( const item of this . tippyItems || [ ] ) {
2024-04-30 07:26:13 +03:00
if ( ! itemFlexSpace || item . getAttribute ( 'data-after-flex-space' ) ) {
this . menuItemsEl . append ( item ) ;
} else {
itemFlexSpace . insertAdjacentElement ( 'beforebegin' , item ) ;
}
2024-03-15 05:05:31 +03:00
}
// measure which items are partially outside the element and move them into the button menu
2024-06-25 17:24:15 +03:00
// flex space and overflow menu are excluded from measurement
2024-04-30 07:26:13 +03:00
itemFlexSpace ? . style . setProperty ( 'display' , 'none' , 'important' ) ;
2024-06-25 17:24:15 +03:00
itemOverFlowMenuButton ? . style . setProperty ( 'display' , 'none' , 'important' ) ;
2024-03-15 05:05:31 +03:00
this . tippyItems = [ ] ;
const menuRight = this . offsetLeft + this . offsetWidth ;
2024-10-31 17:57:40 +03:00
const menuItems = this . menuItemsEl . querySelectorAll < HTMLElement > ( '.item, .item-flex-space' ) ;
2024-04-30 07:26:13 +03:00
let afterFlexSpace = false ;
2024-03-15 05:05:31 +03:00
for ( const item of menuItems ) {
2024-04-30 07:26:13 +03:00
if ( item . classList . contains ( 'item-flex-space' ) ) {
afterFlexSpace = true ;
continue ;
}
if ( afterFlexSpace ) item . setAttribute ( 'data-after-flex-space' , 'true' ) ;
2024-03-15 05:05:31 +03:00
const itemRight = item . offsetLeft + item . offsetWidth ;
2024-04-30 07:26:13 +03:00
if ( menuRight - itemRight < 38 ) { // roughly the width of .overflow-menu-button with some extra space
2024-03-15 05:05:31 +03:00
this . tippyItems . push ( item ) ;
}
}
2024-04-30 07:26:13 +03:00
itemFlexSpace ? . style . removeProperty ( 'display' ) ;
2024-06-25 17:24:15 +03:00
itemOverFlowMenuButton ? . style . removeProperty ( 'display' ) ;
2024-03-15 05:05:31 +03:00
// if there are no overflown items, remove any previously created button
if ( ! this . tippyItems ? . length ) {
const btn = this . querySelector ( '.overflow-menu-button' ) ;
btn ? . _tippy ? . destroy ( ) ;
btn ? . remove ( ) ;
return ;
}
// remove aria role from items that moved from tippy to menu
for ( const item of menuItems ) {
if ( ! this . tippyItems . includes ( item ) ) {
item . removeAttribute ( 'role' ) ;
}
}
// move all items that overflow into tippy
for ( const item of this . tippyItems ) {
item . setAttribute ( 'role' , 'menuitem' ) ;
this . tippyContent . append ( item ) ;
}
// update existing tippy
if ( this . button ? . _tippy ) {
this . button . _tippy . setContent ( this . tippyContent ) ;
return ;
}
// create button initially
const btn = document . createElement ( 'button' ) ;
2024-04-30 07:26:13 +03:00
btn . classList . add ( 'overflow-menu-button' ) ;
2024-03-15 05:05:31 +03:00
btn . setAttribute ( 'aria-label' , window . config . i18n . more_items ) ;
btn . innerHTML = octiconKebabHorizontal ;
this . append ( btn ) ;
this . button = btn ;
createTippy ( btn , {
trigger : 'click' ,
hideOnClick : true ,
interactive : true ,
placement : 'bottom-end' ,
role : 'menu' ,
2024-04-30 17:52:46 +03:00
theme : 'menu' ,
2024-03-15 05:05:31 +03:00
content : this.tippyContent ,
onShow : ( ) = > { // FIXME: onShown doesn't work (never be called)
setTimeout ( ( ) = > {
this . tippyContent . focus ( ) ;
} , 0 ) ;
} ,
} ) ;
} ) ;
init() {
2024-03-20 20:00:35 +03:00
// for horizontal menus where fomantic boldens active items, prevent this bold text from
// enlarging the menu's active item replacing the text node with a div that renders a
// invisible pseudo-element that enlarges the box.
if ( this . matches ( '.ui.secondary.pointing.menu, .ui.tabular.menu' ) ) {
for ( const item of this . querySelectorAll ( '.item' ) ) {
for ( const child of item . childNodes ) {
if ( child . nodeType === Node . TEXT_NODE ) {
const text = child . textContent . trim ( ) ; // whitespace is insignificant inside flexbox
if ( ! text ) continue ;
const span = document . createElement ( 'span' ) ;
span . classList . add ( 'resize-for-semibold' ) ;
span . setAttribute ( 'data-text' , text ) ;
span . textContent = text ;
child . replaceWith ( span ) ;
}
}
}
}
2024-03-15 05:05:31 +03:00
// ResizeObserver triggers on initial render, so we don't manually call `updateItems` here which
// also avoids a full-page FOUC in Firefox that happens when `updateItems` is called too soon.
this . resizeObserver = new ResizeObserver ( ( entries ) = > {
for ( const entry of entries ) {
const newWidth = entry . contentBoxSize [ 0 ] . inlineSize ;
if ( newWidth !== this . lastWidth ) {
requestAnimationFrame ( ( ) = > {
this . updateItems ( ) ;
} ) ;
this . lastWidth = newWidth ;
}
}
} ) ;
this . resizeObserver . observe ( this ) ;
}
connectedCallback() {
this . setAttribute ( 'role' , 'navigation' ) ;
// check whether the mandatory `.overflow-menu-items` element is present initially which happens
// with Vue which renders differently than browsers. If it's not there, like in the case of browser
// template rendering, wait for its addition.
// The eslint rule is not sophisticated enough or aware of this problem, see
// https://github.com/43081j/eslint-plugin-wc/pull/130
2024-10-31 17:57:40 +03:00
const menuItemsEl = this . querySelector < HTMLElement > ( '.overflow-menu-items' ) ; // eslint-disable-line wc/no-child-traversal-in-connectedcallback
2024-03-15 05:05:31 +03:00
if ( menuItemsEl ) {
this . menuItemsEl = menuItemsEl ;
this . init ( ) ;
} else {
this . mutationObserver = new MutationObserver ( ( mutations ) = > {
for ( const mutation of mutations ) {
2024-10-31 17:57:40 +03:00
for ( const node of mutation . addedNodes as NodeListOf < HTMLElement > ) {
2024-03-15 05:05:31 +03:00
if ( ! isDocumentFragmentOrElementNode ( node ) ) continue ;
if ( node . classList . contains ( 'overflow-menu-items' ) ) {
this . menuItemsEl = node ;
this . mutationObserver ? . disconnect ( ) ;
this . init ( ) ;
}
}
}
} ) ;
this . mutationObserver . observe ( this , { childList : true } ) ;
}
}
disconnectedCallback() {
this . mutationObserver ? . disconnect ( ) ;
this . resizeObserver ? . disconnect ( ) ;
}
} ) ;