UNPKG

suneditor

Version:

Vanilla JavaScript based WYSIWYG web editor

1,026 lines (927 loc) 32.8 kB
import { dom, env, keyCodeMap } from '../../helper'; const { _w } = env; const MENU_MIN_HEIGHT = 38; /** * @typedef {Object} SelectMenuParams * @property {string} position Position of the select menu, specified as `"[left|right]-[middle|top|bottom]"` or `"[top|bottom]-[center|left|right]"`. * ```js * // position * 'left-bottom' // menu appears below, aligned to the left * 'top-center' // menu appears above, centered * ``` * @property {boolean} [checkList=false] Flag to determine if the checklist is enabled (`true` or `false`) * @property {"rtl" | "ltr"} [dir="ltr"] Optional text direction: `"rtl"` for right-to-left, `"ltr"` for left-to-right * @property {number} [splitNum=0] Optional split number for horizontal positioning; defines how many items per row * @property {() => void} [openMethod] Optional method to call when the menu is opened * @property {() => void} [closeMethod] Optional method to call when the menu is closed * @property {() => boolean} [subEscMethod] Optional owner hook invoked on ESC before the menu closes. * Return `true` if it dismissed an owner-managed sub-panel (e.g. CommandMenu's flyout), so ESC only * closes that sub-panel and keeps the menu open. * @property {string} [maxHeight] Optional max-height CSS value (e.g. `"200px"`). Enables scrolling when items exceed this height. * @property {string} [minWidth] Optional min-width CSS value (e.g. `"130px"`). * @property {*} [keydownTarget] Optional override for the keyboard navigation target. By default `on()` listens * - on the iframe `contentWindow` (`_ww`) when the refer isn't an input — appropriate when the * - refer is inside the wysiwyg. Set this to `window` (parent) for menus whose refer lives in * - the parent doc (e.g. BlockHandle's dragBtn in `carrierWrapper`). Also avoids * - cross-origin/sandboxed iframe `addEventListener` errors. */ /** * @class * @description Creates a select menu */ class SelectMenu { #$; #dirPosition; #dirSubPosition; #textDirDiff; #eventHandlers; #globalEventHandlers; #refer = null; #keydownTarget = null; #keydownTargetOverride = null; #selectMethod = null; #bindClose_key = null; #bindClose_mousedown = null; #bindClose_click = null; #bindSubmenuReposition = null; #events = null; #lastMainPosition = null; #lastSubPosition = null; // submenu #submenuData = new Map(); #activeSubmenuIndex = -1; #submenuItemIndex = -1; #inSubmenu = false; #submenuHoverTimer = null; /** * @constructor * @param {SunEditor.Deps} $ Kernel dependencies * @param {SelectMenuParams} params SelectMenu options */ constructor($, params) { this.#$ = $; // members const positionItems = params.position.split('-'); this.form = null; this.items = []; /** @type {HTMLLIElement[]} */ this.menus = null; this.menuLen = 0; this.index = -1; this.item = null; this.isOpen = false; this.checkList = !!params.checkList; this.position = positionItems[0]; this.subPosition = positionItems[1]; this.splitNum = params.splitNum || 0; this.horizontal = !!this.splitNum; this.openMethod = params.openMethod; this.closeMethod = params.closeMethod; this.subEscMethod = params.subEscMethod || null; this.maxHeight = params.maxHeight || ''; this.minWidth = params.minWidth || ''; this.#keydownTargetOverride = params.keydownTarget || null; this.#dirPosition = /^(left|right)$/.test(this.position) ? this.position === 'left' ? 'right' : 'left' : this.position; this.#dirSubPosition = /^(left|right)$/.test(this.subPosition) ? this.subPosition === 'left' ? 'right' : 'left' : this.subPosition; this.#textDirDiff = params.dir === 'ltr' ? false : params.dir === 'rtl' ? true : null; this.#eventHandlers = { mousedown: this.#OnMousedown_list.bind(this), mousemove: this.#OnMouseMove_list.bind(this), click: this.#OnClick_list.bind(this), keydown: this.#OnKeyDown_refer.bind(this), }; this.#globalEventHandlers = { keydown: this.#CloseListener_key.bind(this), mousedown: this.#CloseListener_mousedown.bind(this), click: this.#CloseListener_click.bind(this), submenuReposition: this.#OnSubmenuReposition.bind(this), }; } /** * @description Creates the select menu items. * @param {Array<*>} items - Selectable items. * - Plain entry: any value (string/object); passed to the `selectMethod` callback when picked. * - Submenu entry: `{ children: Array<*>, childMenus?: Array<string|HTMLElement> }` — * `children` are the child values delivered to `selectMethod` on selection; `childMenus` * is the optional display content for each child (HTML string or `HTMLElement`). When * omitted, `children` doubles as the display content. * @param {Array<string>|SunEditor.NodeCollection} [menus] - Optional list of display elements * (HTML strings or nodes) for the top-level rows. Defaults to `items`. For submenu entries * this controls the parent row's content; child rows use `childMenus` (or `children`). * @example * // Submenu — "List" opens a hover submenu of UL/OL options * selectMenu.create( * [{ children: ['ul', 'ol'], childMenus: ['<i>•</i> Bulleted', '<i>1.</i> Numbered'] }], * ['List'] * ); */ create(items, menus) { this.form.firstElementChild.innerHTML = ''; // remove existing submenu elements from form for (const [, data] of this.#submenuData) { data.element?.remove(); } this.#submenuData.clear(); menus ||= items; let html = ''; for (let i = 0, len = menus.length; i < len; i++) { if (i > 0 && i % this.splitNum === 0) { this.#createFormat(html); html = ''; } const item = items[i]; const menuContent = typeof menus[i] === 'string' ? menus[i] : /** @type {HTMLElement} */ (menus[i]).outerHTML; const itemObj = /** @type {{children?: Array, childMenus?: Array}} */ ( item && typeof item === 'object' ? item : {} ); const hasChildren = itemObj.children?.length > 0; if (hasChildren) { let subHtml = ''; const childMenus = itemObj.childMenus || itemObj.children; for (let c = 0; c < childMenus.length; c++) { subHtml += `<li class="se-select-item" data-parent-index="${i}" data-child-index="${c}">${typeof childMenus[c] === 'string' ? childMenus[c] : childMenus[c].outerHTML}</li>`; } html += `<li class="se-select-item se-has-submenu" data-index="${i}">${menuContent}` + `<span class="se-submenu-arrow">${this.#$.icons.menu_arrow_right}</span></li>`; // `popover: manual` lifts the submenu into the top layer so it escapes any // `overflow: hidden` on the form's ancestors (toolbar, dropdown panels, ...). const subEl = dom.utils.createElement( 'DIV', { class: 'se-select-submenu', popover: 'manual', 'data-parent-index': String(i) }, `<ul class="se-list-basic se-list-checked">${subHtml}</ul>`, ); this.#submenuData.set(i, { items: itemObj.children, menus: childMenus, element: subEl }); } else { html += `<li class="se-select-item" data-index="${i}">${menuContent}</li>`; } } this.#createFormat(html); // append submenu elements to form (outside se-list-inner) for (const [, data] of this.#submenuData) { this.form.appendChild(data.element); } this.items = /** @type {Array<string|Node>} */ (items); this.menus = Array.from(this.form.querySelectorAll('li[data-index]')); this.menuLen = this.menus.length; } /** * @description Initializes the select menu and attaches it to a reference element. * @param {Node} referElement - The element that triggers the select menu. * @param {(command: string) => void} selectMethod - The function to execute when an item is selected. * @param {{class?: string, style?: string}} [attr={}] - Additional attributes for the select menu container. * @example * // Basic: attach menu to a button with a selection callback * selectMenu.on(this.alignButton, this.onAlignSelect.bind(this)); * * // With custom attributes for styling * selectMenu.on(this.alignButton, this.onAlignSelect.bind(this), { class: 'se-figure-select-list' }); */ on(referElement, selectMethod, attr = {}) { this.#refer = /** @type {HTMLElement} */ (referElement); this.#keydownTarget = this.#keydownTargetOverride || (dom.check.isInputElement(referElement) ? referElement : this.#$.frameContext.get('_ww')); this.#selectMethod = selectMethod; let innerStyle = ''; if (this.maxHeight) innerStyle += 'max-height:' + this.maxHeight + ';overflow-y:auto;'; if (this.minWidth) innerStyle += 'min-width:' + this.minWidth + ';'; this.form = dom.utils.createElement( 'DIV', { class: 'se-select-menu' + (this.#textDirDiff === true ? ' se-rtl' : '') + (attr.class ? ' ' + attr.class : ''), style: attr.style || '', }, '<div class="se-list-inner"' + (innerStyle ? ' style="' + innerStyle + '"' : '') + '></div>', ); referElement.parentNode.insertBefore(this.form, referElement); } /** * @description Select menu open * @param {?string} [position] `"[left|right]-[middle|top|bottom] | [top|bottom]-[center|left|right]"` * Always specify in LTR orientation. In RTL environments, left/right are automatically swapped. * @param {?string} [onItemQuerySelector] The querySelector string of the menu to be activated * @example * // Open with default position (uses constructor's position param) * selectMenu.open(); * * // Open at a specific position (always use LTR basis; RTL is auto-mirrored) * selectMenu.open('bottom-left'); * * // Open with an active item highlighted via querySelector * selectMenu.open('', '[data-command="' + this.align + '"]'); */ open(position, onItemQuerySelector) { this.#$.ui.selectMenuOn = true; this.openMethod?.(); this.#addEvents(); this.#addGlobalEvent(); const positionItems = position ? position.split('-') : []; const mainPosition = positionItems[0] || (this.#textDirDiff !== null && this.#textDirDiff !== this.#$.options.get('_rtl') ? this.#dirPosition : this.position); const subPosition = positionItems[1] || (this.#textDirDiff !== null && this.#textDirDiff !== this.#$.options.get('_rtl') ? this.#dirSubPosition : this.subPosition); this.#lastMainPosition = mainPosition; this.#lastSubPosition = subPosition; this.#setPosition(mainPosition, subPosition, onItemQuerySelector); this.isOpen = true; } /** * @description Re-runs positioning using the same direction the menu was opened with. * Use when the reference element has moved (e.g. scroll repositioned the trigger) but * the menu should stay open and follow. */ reposition() { if (!this.isOpen || !this.#lastMainPosition) return; this.#setPosition(this.#lastMainPosition, this.#lastSubPosition); } /** * @description Soft-hide / soft-show without changing open state. * close listeners (outside click, ESC) keep working, but is visually hidden until the trigger comes back. */ setHidden(hidden) { if (!this.isOpen) return; if (hidden) { this.#closeSubmenu(); this.form.style.display = 'none'; } else { this.reposition(); } } /** * @description Select menu close */ close() { this.#$.ui.selectMenuOn = false; dom.utils.removeClass(this.#refer, 'on'); this.#init(); this.form?.removeAttribute('style'); this.isOpen = false; this.closeMethod?.(); } /** * @description Get the index of the selected item * @param {number} index Item index * @returns */ getItem(index) { return this.items[index]; } /** * @description Set the index of the selected item * @param {number} index Item index */ setItem(index) { this.#selectItem(index); } /** * @description Whether a native submenu is currently open. Used by owners (e.g. a Controller) to * let ESC dismiss only the open submenu instead of the whole menu. * @returns {boolean} */ hasOpenSubmenu() { return this.#activeSubmenuIndex > -1; } /** * @description Opens the submenu for the given parent index. * @param {number} parentIndex */ #openSubmenu(parentIndex) { if (this.#activeSubmenuIndex === parentIndex) return; this.#closeSubmenu(); const parentLi = this.menus[parentIndex]; if (!parentLi || !dom.utils.hasClass(parentLi, 'se-has-submenu')) return; dom.utils.addClass(parentLi, 'se-submenu-open'); this.#activeSubmenuIndex = parentIndex; this.#submenuItemIndex = -1; this.#inSubmenu = false; const data = this.#submenuData.get(parentIndex); const sub = data?.element; if (sub) { // Show first so the top-layer containing block (viewport) is in effect when we const supportsPopover = typeof sub.showPopover === 'function'; sub.style.visibility = 'hidden'; if (supportsPopover) { if (!sub.matches(':popover-open')) sub.showPopover(); } else { sub.style.display = 'block'; } this.#positionSubmenu(parentLi, sub, supportsPopover); sub.style.visibility = ''; // Submenu is top-layer popover (viewport-fixed), but the parent form is in document // flow (absolute) and scrolls with the page/container. Re-run positioning on scroll // so the submenu stays anchored to the parent LI as it moves. this.#bindSubmenuReposition = this.#$.eventManager.addGlobalEvent( 'scroll', this.#globalEventHandlers.submenuReposition, true, ); } } /** * @description Position the submenu element next to the parent menu * @param {HTMLElement} parentLi * @param {HTMLElement} sub * @param {boolean} supportsPopover */ #positionSubmenu(parentLi, sub, supportsPopover) { // In the top layer the containing block is the viewport; otherwise it's the form. const formRect = supportsPopover ? { top: 0, left: 0 } : this.form.getBoundingClientRect(); const parentRect = parentLi.getBoundingClientRect(); const subW = sub.offsetWidth; const subH = sub.offsetHeight; const vpW = _w.innerWidth; const vpH = _w.innerHeight; // Horizontal: open in the arrow's direction — RTL prefers left of parent, LTR prefers // right. Flip to the opposite side if the preferred side overflows; if both overflow, // keep the smaller-overflow side and shift inward by exactly that amount. const rightVP = parentRect.right; const leftVP = parentRect.left - subW; const rightOverflow = Math.max(0, rightVP + subW - vpW); const leftOverflow = Math.max(0, -leftVP); const preferLeft = this.#textDirDiff === true; let leftPx; if (preferLeft) { if (leftOverflow === 0) leftPx = leftVP; else if (rightOverflow === 0) leftPx = rightVP; else if (leftOverflow <= rightOverflow) leftPx = leftVP + leftOverflow; else leftPx = rightVP - rightOverflow; } else { if (rightOverflow === 0) leftPx = rightVP; else if (leftOverflow === 0) leftPx = leftVP; else if (rightOverflow <= leftOverflow) leftPx = rightVP - rightOverflow; else leftPx = leftVP + leftOverflow; } sub.style.left = leftPx - formRect.left + 'px'; sub.style.right = ''; // Vertical: try top-aligned with parent; flip to bottom-anchored if it overflows // downward; if both directions overflow (submenu taller than viewport), keep the // side with the smaller overflow and shift inward by exactly that amount. const topVP = parentRect.top; const bottomVP = parentRect.bottom - subH; const downOverflow = Math.max(0, topVP + subH - vpH); const upOverflow = Math.max(0, -bottomVP); let topPx; if (downOverflow === 0) { topPx = topVP; } else if (upOverflow === 0) { topPx = bottomVP; } else if (downOverflow <= upOverflow) { topPx = topVP - downOverflow; } else { topPx = bottomVP + upOverflow; } sub.style.top = topPx - formRect.top + 'px'; } /** * @description Scroll/resize callback while submenu is open — re-runs positioning so the * top-layer popover follows the parent LI which scrolls with the document. */ #OnSubmenuReposition() { if (this.#activeSubmenuIndex < 0) return; const data = this.#submenuData.get(this.#activeSubmenuIndex); const sub = data?.element; const parentLi = this.menus[this.#activeSubmenuIndex]; if (!sub || !parentLi) return; this.#positionSubmenu(parentLi, sub, typeof sub.showPopover === 'function'); } /** * @description Closes any open submenu. */ #closeSubmenu() { if (this.#submenuHoverTimer) { _w.clearTimeout(this.#submenuHoverTimer); this.#submenuHoverTimer = null; } this.#bindSubmenuReposition &&= this.#$.eventManager.removeGlobalEvent(this.#bindSubmenuReposition); if (this.#activeSubmenuIndex > -1) { const parentLi = this.menus[this.#activeSubmenuIndex]; if (parentLi) dom.utils.removeClass(parentLi, 'se-submenu-open'); const data = this.#submenuData.get(this.#activeSubmenuIndex); if (data?.element) { if (typeof data.element.hidePopover === 'function') { if (data.element.matches(':popover-open')) data.element.hidePopover(); } else { data.element.style.display = ''; } dom.utils.removeClass(data.element.querySelectorAll('.se-select-item'), 'se-select-cursor'); } } this.#activeSubmenuIndex = -1; this.#submenuItemIndex = -1; this.#inSubmenu = false; } /** * @description Selects a child item from an open submenu. * @param {number} parentIndex * @param {number} childIndex */ #selectChild(parentIndex, childIndex) { const data = this.#submenuData.get(parentIndex); if (!data) return; if (this.checkList) { const childLi = data.element?.querySelectorAll('.se-select-item')[childIndex]; if (childLi) dom.utils.toggleClass(childLi, 'se-checked'); } this.#selectMethod(data.items[childIndex]); } /** * @description Moves selection within an open submenu. * @param {number} num Direction (-1 up, +1 down) */ #moveSubmenuItem(num) { const data = this.#submenuData.get(this.#activeSubmenuIndex); if (!data?.element) return; const items = data.element.querySelectorAll('.se-select-item'); const len = items.length; if (!len) return; num = this.#submenuItemIndex + num; const idx = (this.#submenuItemIndex = num >= len ? 0 : num < 0 ? len - 1 : num); dom.utils.removeClass(this.form, 'se-select-menu-mouse-move'); for (let i = 0; i < len; i++) { if (i === idx) dom.utils.addClass(items[i], 'se-select-cursor'); else dom.utils.removeClass(items[i], 'se-select-cursor'); } } /** * @description Appends a formatted list of items to the menu. * @param {string} html - The HTML string representing the menu items. */ #createFormat(html) { this.form.firstElementChild.innerHTML += `<ul class="se-list-basic se-list-checked${this.horizontal ? ' se-list-horizontal' : ''}">${html}</ul>`; } /** * @description Resets the menu state and removes event listeners. */ #init() { this.#removeEvents(); this.#removeGlobalEvent(); this.#closeSubmenu(); this.index = -1; this.item = null; if (this._onItem) { dom.utils.removeClass(this._onItem, 'se-select-on'); this._onItem = null; } } /** * @description Moves the selection up or down by a specified number of items. * @param {number} num - The number of items to move (negative for up, positive for down). */ #moveItem(num) { num = this.index + num; const len = this.menuLen; const selectIndex = (this.index = num >= len ? 0 : num < 0 ? len - 1 : num); this.#selectItem(selectIndex); } /** * @description Highlights and selects an item by index. * @param {number} selectIndex - The index of the item to select. */ #selectItem(selectIndex) { dom.utils.removeClass(this.form, 'se-select-menu-mouse-move'); const len = this.menuLen; for (let i = 0; i < len; i++) { if (i === selectIndex) { dom.utils.addClass(this.menus[i], 'se-select-cursor'); } else { dom.utils.removeClass(this.menus[i], 'se-select-cursor'); } } this.index = selectIndex; this.item = this.items[selectIndex]; } /** * @description Sets the position of the select menu relative to the reference element. * @param {string} position Menu position (`"left"`|`"right"`) | (`"top"`|`"bottom"`) * @param {string} subPosition Sub position (`"middle"`|`"top"`|`"bottom"`) | (`"center"`|`"left"`|`"right"`) * @param {string} [onItemQuerySelector] - A query selector string to highlight a specific item. * @param {boolean} [_re=false] - Whether this is a retry after adjusting the position. */ #setPosition(position, subPosition, onItemQuerySelector, _re) { const originP = position; const form = this.form; const target = this.#refer; form.style.visibility = 'hidden'; form.style.display = 'block'; dom.utils.removeClass(form, 'se-select-menu-scroll'); dom.utils.addClass(target, 'on'); const formW = form.offsetWidth; const targetW = target.offsetWidth; const targetL = target.offsetLeft; let side = false; let l = 0, t = 0; if (position === 'left') { l = targetL - formW - 1; position = subPosition; side = true; } else if (position === 'right') { l = targetL + targetW + 1; position = subPosition; side = true; } // set top position const globalTarget = this.#$.offset.get(target); const targetOffsetTop = target.offsetTop; const targetGlobalTop = globalTarget.top; const targetHeight = target.offsetHeight; const wbottom = dom.utils.getClientSize().h - (targetGlobalTop - _w.scrollY + targetHeight); const sideAddH = side ? targetHeight : 0; let overH = 10000; switch (position) { case 'middle': { let h = form.offsetHeight; const th = targetHeight / 2; t = targetOffsetTop - h / 2 + th; // over top if (targetGlobalTop < h / 2) { t += h / 2 - targetGlobalTop - th + 4; form.style.top = t + 'px'; } // over bottom let formT = this.#$.offset.getGlobal(form).top; const modH = h - (targetGlobalTop - formT) - wbottom - targetHeight; if (modH > 0) { t -= modH + 4; form.style.top = t + 'px'; } // over height formT = this.#$.offset.getGlobal(form).top; if (formT < 0) { h += formT - 4; t -= formT - 4; } form.style.height = h + 'px'; break; } case 'top': if (targetGlobalTop < form.offsetHeight - sideAddH) { if (!_re) { overH = 0; break; } overH = targetGlobalTop - 4 + sideAddH; if (overH >= MENU_MIN_HEIGHT) form.style.height = overH + 'px'; } t = targetOffsetTop - form.offsetHeight + sideAddH; break; case 'bottom': if (wbottom < form.offsetHeight + sideAddH) { if (!_re) { overH = 0; break; } overH = wbottom - 4 + sideAddH; if (overH >= MENU_MIN_HEIGHT) form.style.height = overH + 'px'; } t = targetOffsetTop + (side ? 0 : targetHeight); break; } if (overH < MENU_MIN_HEIGHT && !_re && position !== 'middle') { this.#setPosition(position === 'top' ? 'bottom' : 'top', subPosition, onItemQuerySelector, true); return; } if (!side) { switch (subPosition) { case 'center': l = targetL + targetW / 2 - formW / 2; break; case 'left': l = targetL; break; case 'right': l = targetL - (formW - targetW); break; } } form.style.left = l + 'px'; let fixedLeft = this.#$.offset.getGlobal(form).fixedLeft; let overW = 0; switch (side + '-' + (side ? originP : subPosition)) { case 'true-left': if (fixedLeft < 0) l = targetL + targetW + 1; break; case 'true-right': if (fixedLeft + formW > _w.innerWidth) l = targetL - formW - 1; break; case 'false-center': overW = _w.innerWidth - (fixedLeft + formW); if (overW < 0) l += overW - 4; form.style.left = l + 'px'; fixedLeft = this.#$.offset.getGlobal(form).fixedLeft; if (fixedLeft < 0) l -= fixedLeft - 4; break; case 'false-left': overW = _w.innerWidth - (fixedLeft + formW); if (overW < 0) l += overW - 4; break; case 'false-right': if (fixedLeft < 0) l -= fixedLeft - 4; break; } if (onItemQuerySelector) { const item = form.firstElementChild.querySelector(onItemQuerySelector); if (item) { this._onItem = item; dom.utils.addClass(item, 'se-select-on'); } } form.style.left = l + 'px'; form.style.top = t + 'px'; form.style.visibility = ''; } /** * @description Selects an item and triggers the callback function. * @param {number} index - The index of the item to select. */ #select(index) { if (this.checkList) dom.utils.toggleClass(this.menus[index], 'se-checked'); this.#selectMethod(this.getItem(index)); } /** * @description Adds event listeners for menu interactions. */ #addEvents() { this.#removeEvents(); this.#events = { mousedown: this.#$.eventManager.addEvent(this.form, 'mousedown', this.#eventHandlers.mousedown), mousemove: this.#$.eventManager.addEvent(this.form, 'mousemove', this.#eventHandlers.mousemove), click: this.#$.eventManager.addEvent(this.form, 'click', this.#eventHandlers.click), keydown: this.#$.eventManager.addEvent(this.#keydownTarget, 'keydown', this.#eventHandlers.keydown), }; } /** * @description Removes event listeners for menu interactions. */ #removeEvents() { if (!this.#events) return; this.#$.eventManager.removeEvent(this.#events.mousedown); this.#$.eventManager.removeEvent(this.#events.mousemove); this.#$.eventManager.removeEvent(this.#events.click); this.#$.eventManager.removeEvent(this.#events.keydown); this.#events = null; } /** * @description Adds global event listeners for closing the menu. */ #addGlobalEvent() { this.#removeGlobalEvent(); this.#bindClose_key = this.#$.eventManager.addGlobalEvent('keydown', this.#globalEventHandlers.keydown, true); this.#bindClose_mousedown = this.#$.eventManager.addGlobalEvent( 'mousedown', this.#globalEventHandlers.mousedown, true, ); } /** * @description Removes global event listeners for closing the menu. */ #removeGlobalEvent() { this.#bindClose_key &&= this.#$.eventManager.removeGlobalEvent(this.#bindClose_key); this.#bindClose_mousedown &&= this.#$.eventManager.removeGlobalEvent(this.#bindClose_mousedown); this.#bindClose_click &&= this.#$.eventManager.removeGlobalEvent(this.#bindClose_click); } /** * @param {KeyboardEvent} e - Event object */ #OnKeyDown_refer(e) { // submenu keyboard navigation if (this.#inSubmenu) { switch (e.code) { case 'ArrowUp': e.preventDefault(); e.stopPropagation(); this.#moveSubmenuItem(-1); return; case 'ArrowDown': e.preventDefault(); e.stopPropagation(); this.#moveSubmenuItem(1); return; case 'ArrowLeft': e.preventDefault(); e.stopPropagation(); // exit submenu back to parent this.#inSubmenu = false; this.#submenuItemIndex = -1; { const subData = this.#submenuData.get(this.#activeSubmenuIndex); if (subData?.element) dom.utils.removeClass( subData.element.querySelectorAll('.se-select-item'), 'se-select-cursor', ); } return; case 'Enter': case 'Space': if (this.#submenuItemIndex > -1) { e.preventDefault(); e.stopPropagation(); this.#selectChild(this.#activeSubmenuIndex, this.#submenuItemIndex); } return; } } let moveIndex; switch (e.code) { case 'ArrowUp': // up e.preventDefault(); e.stopPropagation(); if (this.horizontal && this.index > -1) { const num = this.splitNum; moveIndex = this.index - num < 0 ? num : -num; } else { moveIndex = -1; } break; case 'ArrowDown': // down e.preventDefault(); e.stopPropagation(); if (this.horizontal && this.index > -1) { const num = this.splitNum; moveIndex = this.index + num > this.menuLen ? -num : num; } else { moveIndex = 1; } break; case 'ArrowLeft': // left e.preventDefault(); e.stopPropagation(); moveIndex = -1; break; case 'ArrowRight': // right — enter submenu if available e.preventDefault(); e.stopPropagation(); if (this.index > -1 && this.#submenuData.has(this.index)) { this.#openSubmenu(this.index); this.#inSubmenu = true; this.#moveSubmenuItem(1); return; } moveIndex = 1; break; case 'Enter': case 'Space': // enter, space if (this.index > -1) { e.preventDefault(); e.stopPropagation(); if (this.#submenuData.has(this.index)) { this.#openSubmenu(this.index); this.#inSubmenu = true; this.#moveSubmenuItem(1); } else { this.#select(this.index); } } break; } if (moveIndex) { this.#closeSubmenu(); this.#moveItem(moveIndex); } } /** * @param {MouseEvent} e - Event object */ #OnMousedown_list(e) { if (env.isGecko) { const eventTarget = dom.query.getEventTarget(e); const target = dom.query.getParentElement(eventTarget, '.se-select-item'); if (target) this.#$.eventManager._injectActiveEvent(target); } } /** * @param {MouseEvent} e - Event object */ #OnMouseMove_list(e) { const eventTarget = dom.query.getEventTarget(e); dom.utils.addClass(this.form, 'se-select-menu-mouse-move'); // `data-*` attrs live on `<li>`, but events may originate from nested children // (e.g. CommandMenu wraps row content in a `<button>` for native :disabled). Walk // up so the lookup still works when the target isn't the `<li>` itself. const childTarget = eventTarget.closest?.('[data-child-index]'); const childIndex = childTarget?.getAttribute('data-child-index'); if (childIndex !== null && childIndex !== undefined) { this.#submenuItemIndex = Number(childIndex); const subData = this.#submenuData.get(this.#activeSubmenuIndex); if (subData?.element) { const items = subData.element.querySelectorAll('.se-select-item'); dom.utils.removeClass(items, 'se-select-cursor'); dom.utils.addClass(items[this.#submenuItemIndex], 'se-select-cursor'); } return; } const indexTarget = eventTarget.closest?.('[data-index]'); const index = indexTarget?.getAttribute('data-index'); if (!index) return; const numIndex = Number(index); this.index = numIndex; // submenu hover logic if (this.#submenuData.has(numIndex)) { if (this.#submenuHoverTimer) _w.clearTimeout(this.#submenuHoverTimer); this.#openSubmenu(numIndex); } else if (this.#activeSubmenuIndex > -1) { // check if mouse is inside the active submenu const activeData = this.#submenuData.get(this.#activeSubmenuIndex); if (!activeData?.element?.contains(eventTarget)) { this.#closeSubmenu(); } } } /** * @param {MouseEvent} e - Event object */ #OnClick_list(e) { let target = dom.query.getEventTarget(e); let index = null; let childIndex = null; while (!index && !childIndex && !/UL/i.test(target.tagName) && !dom.utils.hasClass(target, 'se-select-menu')) { childIndex = target.getAttribute('data-child-index'); if (!childIndex) index = target.getAttribute('data-index'); target = target.parentElement; } // child item click if (childIndex !== null) { let parentTarget = target; let parentIndex = null; while (!parentIndex && parentTarget) { parentIndex = parentTarget.getAttribute('data-parent-index') || parentTarget.getAttribute('data-index'); parentTarget = parentTarget.parentElement; } if (parentIndex !== null) this.#selectChild(Number(parentIndex), Number(childIndex)); return; } if (!index) return; const numIndex = Number(index); // parent with children — toggle submenu instead of selecting if (this.#submenuData.has(numIndex)) { if (this.#activeSubmenuIndex === numIndex) this.#closeSubmenu(); else this.#openSubmenu(numIndex); return; } this.#select(numIndex); } /** * @param {KeyboardEvent} e - Event object */ #CloseListener_key(e) { if (!keyCodeMap.isEsc(e.code)) return; if (this.#activeSubmenuIndex > -1) { e.preventDefault(); e.stopPropagation(); const parentIndex = this.#activeSubmenuIndex; this.#closeSubmenu(); this.#selectItem(parentIndex); return; } if (this.subEscMethod?.()) { e.preventDefault(); e.stopPropagation(); return; } this.close(); } /** * @param {MouseEvent} e - Event object */ #CloseListener_mousedown(e) { const eventTarget = dom.query.getEventTarget(e); if (this.form.contains(eventTarget)) return; if (!this.#refer.contains(eventTarget)) { this.close(); } else if (!dom.check.isInputElement(eventTarget)) { this.#bindClose_click = this.#$.eventManager.addGlobalEvent('click', this.#globalEventHandlers.click, true); } } /** * @param {MouseEvent} e - Event object */ #CloseListener_click(e) { this.#bindClose_click = this.#$.eventManager.removeGlobalEvent(this.#bindClose_click); if (e.target === this.#refer) { e.stopPropagation(); this.close(); } } } export default SelectMenu;