1145 lines
37 KiB
JavaScript
1145 lines
37 KiB
JavaScript
/*
|
||
license: The MIT License, Copyright (c) 2018-2025 YUKI "Piro" Hiroshi
|
||
original:
|
||
https://github.com/piroor/webextensions-lib-menu-ui
|
||
*/
|
||
'use strict';
|
||
|
||
{
|
||
class MenuUI {
|
||
static $wait(timeout) {
|
||
return new Promise((resolve, _reject) => {
|
||
setTimeout(resolve, timeout);
|
||
});
|
||
}
|
||
|
||
// XPath Utilities
|
||
static $hasClass(className) {
|
||
return `contains(concat(" ", normalize-space(@class), " "), " ${className} ")`;
|
||
};
|
||
|
||
static $evaluateXPath(expression, context, type) {
|
||
if (!type)
|
||
type = XPathResult.ORDERED_NODE_SNAPSHOT_TYPE;
|
||
try {
|
||
return (context.ownerDocument || context).evaluate(
|
||
expression,
|
||
(context || document),
|
||
null,
|
||
type,
|
||
null
|
||
);
|
||
}
|
||
catch(_e) {
|
||
return {
|
||
singleNodeValue: null,
|
||
snapshotLength: 0,
|
||
snapshotItem: function() {
|
||
return null
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
static $getArrayFromXPathResult(result) {
|
||
const max = result.snapshotLength;
|
||
const array = new Array(max);
|
||
if (!max)
|
||
return array;
|
||
|
||
for (let i = 0; i < max; i++) {
|
||
array[i] = result.snapshotItem(i);
|
||
}
|
||
return array;
|
||
}
|
||
|
||
RTL_LANGUAGES = new Set([
|
||
'ar',
|
||
'he',
|
||
'fa',
|
||
'ur',
|
||
'ps',
|
||
'sd',
|
||
'ckb',
|
||
'prs',
|
||
'rhg',
|
||
]);
|
||
|
||
get isRTL() {
|
||
const lang = (
|
||
navigator.language ||
|
||
navigator.userLanguage ||
|
||
//(new Intl.DateTimeFormat()).resolvedOptions().locale ||
|
||
''
|
||
).split('-')[0];
|
||
return this.RTL_LANGUAGES.has(lang);
|
||
}
|
||
|
||
constructor(params = {}) {
|
||
this.$lastHoverItem = null;
|
||
this.$lastFocusedItem = null;
|
||
this.$mouseDownFired = false;
|
||
|
||
this.root = params.root;
|
||
this.onCommand = params.onCommand || (() => {});
|
||
this.onShown = params.onShown || (() => {});
|
||
this.onHidden = params.onHidden || (() => {});
|
||
this.animationDuration = params.animationDuration || 150;
|
||
this.subMenuOpenDelay = params.subMenuOpenDelay || 300;
|
||
this.subMenuCloseDelay = params.subMenuCloseDelay || 300;
|
||
this.appearance = params.appearance || 'menu';
|
||
this.incrementalSearch = params.incrementalSearch || false;
|
||
this.incrementalSearchTimeout = params.incrementalSearchTimeout || 1000;
|
||
|
||
this.$onBlur = this.$onBlur.bind(this);
|
||
this.$onMouseOver = this.$onMouseOver.bind(this);
|
||
this.$onMouseDown = this.$onMouseDown.bind(this);
|
||
this.$onMouseUp = this.$onMouseUp.bind(this);
|
||
this.$onClick = this.$onClick.bind(this);
|
||
this.$onKeyDown = this.$onKeyDown.bind(this);
|
||
this.$onKeyUp = this.$onKeyUp.bind(this);
|
||
this.$onTransitionEnd = this.$onTransitionEnd.bind(this);
|
||
this.$onContextMenu = this.$onContextMenu.bind(this);
|
||
|
||
this.root.classList.toggle('rtl', this.isRTL);
|
||
|
||
if (!this.root.id)
|
||
this.root.id = `MenuUI-root-${this.$uniqueKey}-${parseInt(Math.random() * Math.pow(2, 16))}`;
|
||
|
||
this.root.classList.add(this.$commonClass);
|
||
this.root.classList.add('menu-ui');
|
||
this.root.classList.add(this.appearance);
|
||
this.root.setAttribute('role', 'menu');
|
||
|
||
this.$screen = document.createElement('div');
|
||
this.$screen.classList.add(this.$commonClass);
|
||
this.$screen.classList.add('menu-ui-blocking-screen');
|
||
this.root.parentNode.insertBefore(this.$screen, this.root.nextSibling);
|
||
|
||
this.$marker = document.createElement('span');
|
||
this.$marker.classList.add(this.$commonClass);
|
||
this.$marker.classList.add('menu-ui-marker');
|
||
this.$marker.classList.add(this.appearance);
|
||
this.root.parentNode.insertBefore(this.$marker, this.root.nextSibling);
|
||
|
||
this.$lastKeyInputAt = -1;
|
||
this.$incrementalSearchString = '';
|
||
}
|
||
|
||
get opened() {
|
||
return this.root.classList.contains('open');
|
||
}
|
||
|
||
$updateAccessKey(item) {
|
||
const ACCESS_KEY_MATCHER = /(&([^\s]))/i;
|
||
|
||
const title = item.getAttribute('title');
|
||
if (title)
|
||
item.setAttribute('title', title.replace(ACCESS_KEY_MATCHER, '$2'));
|
||
|
||
const label = MenuUI.$evaluateXPath('child::text()', item, XPathResult.STRING_TYPE).stringValue;
|
||
item.dataset.lowerCasedText = label.toLowerCase();
|
||
|
||
const matchedKey = label.match(ACCESS_KEY_MATCHER);
|
||
if (matchedKey) {
|
||
const textNode = MenuUI.$evaluateXPath(
|
||
`child::node()[contains(self::text(), "${matchedKey[1]}")]`,
|
||
item,
|
||
XPathResult.FIRST_ORDERED_NODE_TYPE
|
||
).singleNodeValue;
|
||
if (textNode) {
|
||
const range = document.createRange();
|
||
const startPosition = textNode.nodeValue.indexOf(matchedKey[1]);
|
||
range.setStart(textNode, startPosition);
|
||
range.setEnd(textNode, startPosition + 2);
|
||
range.deleteContents();
|
||
const accessKeyNode = document.createElement('span');
|
||
accessKeyNode.classList.add('accesskey');
|
||
accessKeyNode.textContent = matchedKey[2];
|
||
range.insertNode(accessKeyNode);
|
||
range.detach();
|
||
}
|
||
item.dataset.accessKey = matchedKey[2].toLowerCase();
|
||
}
|
||
else if (/^([^\s])/i.test(item.textContent))
|
||
item.dataset.subAccessKey = RegExp.$1.toLowerCase();
|
||
else
|
||
item.dataset.accessKey = item.dataset.subAccessKey = null;
|
||
}
|
||
|
||
async open(options = {}) {
|
||
if (this.closeTimeout) {
|
||
clearTimeout(this.closeTimeout);
|
||
delete this.closeTimeout;
|
||
this.$onClosed();
|
||
}
|
||
this.canceller = options.canceller;
|
||
this.$mouseDownAfterOpen = false;
|
||
this.$lastFocusedItem = null;
|
||
this.$lastHoverItem = null;
|
||
this.anchor = options.anchor;
|
||
this.$updateItems(this.root);
|
||
this.root.classList.add('open');
|
||
this.$screen.classList.add('open');
|
||
this.$marker.classList.remove('top');
|
||
this.$marker.classList.remove('bottom');
|
||
if (this.anchor) {
|
||
this.anchor.classList.add('open');
|
||
this.$marker.style.transition = `opacity ${this.animationDuration}ms ease-out`;
|
||
this.$marker.classList.add('open');
|
||
}
|
||
this.$updatePositions(this.root, options);
|
||
this.onShown();
|
||
return new Promise(async (resolve, _reject) => {
|
||
await MenuUI.$wait(0);
|
||
if (this.$tryCancelOpen()) {
|
||
this.close().then(resolve);
|
||
return;
|
||
}
|
||
await MenuUI.$wait(this.animationDuration);
|
||
if (this.$tryCancelOpen()) {
|
||
this.close().then(resolve);
|
||
return;
|
||
}
|
||
this.root.parentNode.addEventListener('mouseover', this.$onMouseOver);
|
||
this.root.addEventListener('transitionend', this.$onTransitionEnd);
|
||
window.addEventListener('contextmenu', this.$onContextMenu, { capture: true });
|
||
window.addEventListener('mousedown', this.$onMouseDown, { capture: true });
|
||
window.addEventListener('mouseup', this.$onMouseUp, { capture: true });
|
||
window.addEventListener('click', this.$onClick, { capture: true });
|
||
window.addEventListener('keydown', this.$onKeyDown, { capture: true });
|
||
window.addEventListener('keyup', this.$onKeyUp, { capture: true });
|
||
window.addEventListener('blur', this.$onBlur, { capture: true });
|
||
resolve();
|
||
});
|
||
}
|
||
|
||
$tryCancelOpen() {
|
||
if (!(typeof this.canceller == 'function'))
|
||
return false;
|
||
try {
|
||
return this.canceller();
|
||
}
|
||
catch(_e) {
|
||
}
|
||
return false;
|
||
}
|
||
|
||
updateMenuItem(item) {
|
||
this.$updateItems(item);
|
||
const submenu = item.querySelector('ul');
|
||
if (submenu)
|
||
this.$updatePositions(submenu);
|
||
}
|
||
|
||
$updateItems(parent) {
|
||
parent.setAttribute('role', 'menu');
|
||
for (const item of parent.querySelectorAll('li:not(.separator)')) {
|
||
item.setAttribute('tabindex', -1);
|
||
item.classList.remove('open');
|
||
|
||
if (item.classList.contains('checkbox'))
|
||
item.setAttribute('role', 'menuitemcheckbox');
|
||
else if (item.classList.contains('radio'))
|
||
item.setAttribute('role', 'menuitemradio');
|
||
else if (item.classList.contains('separator'))
|
||
item.setAttribute('role', 'separator');
|
||
else
|
||
item.setAttribute('role', 'menuitem');
|
||
|
||
if (item.matches('.checked, .radio')) {
|
||
if (item.classList.contains('checked'))
|
||
item.setAttribute('aria-checked', 'true');
|
||
else
|
||
item.setAttribute('aria-checked', 'false');
|
||
}
|
||
else {
|
||
item.removeAttribute('aria-checked');
|
||
}
|
||
|
||
this.$updateAccessKey(item);
|
||
const icon = item.querySelector('span.icon') || document.createElement('span');
|
||
if (!icon.parentNode) {
|
||
icon.classList.add('icon');
|
||
item.insertBefore(icon, item.firstChild);
|
||
}
|
||
if (item.dataset.icon) {
|
||
if (item.dataset.iconColor) {
|
||
item.style.backgroundImage = '';
|
||
icon.style.backgroundColor = item.dataset.iconColor;
|
||
icon.style.mask = `url(${JSON.stringify(item.dataset.icon)}) no-repeat center / 100%`;
|
||
}
|
||
else {
|
||
item.style.backgroundImage = `url(${JSON.stringify(item.dataset.icon)})`;
|
||
icon.style.backgroundColor =
|
||
icon.style.mask = '';
|
||
}
|
||
}
|
||
else {
|
||
item.style.backgroundImage =
|
||
icon.style.backgroundColor =
|
||
icon.style.mask = '';
|
||
}
|
||
if (item.querySelector('ul'))
|
||
item.classList.add('has-submenu');
|
||
else
|
||
item.classList.remove('has-submenu');
|
||
}
|
||
}
|
||
|
||
$updatePositions(parent, options = {}) {
|
||
const menus = [parent].concat(Array.from(parent.querySelectorAll('ul')));
|
||
for (const menu of menus) {
|
||
if (this.animationDuration)
|
||
menu.style.transition = `opacity ${this.animationDuration}ms ease-out`;
|
||
else
|
||
menu.style.transition = '';
|
||
this.$updatePosition(menu, options);
|
||
}
|
||
}
|
||
|
||
$updatePosition(menu, options = {}) {
|
||
let left = options.left;
|
||
let top = options.top;
|
||
const containerRect = this.$containerRect;
|
||
const menuRect = menu.getBoundingClientRect();
|
||
|
||
if (options.anchor &&
|
||
(left === undefined || top === undefined) &&
|
||
menu == this.root) {
|
||
const anchorRect = options.anchor.getBoundingClientRect();
|
||
if (containerRect.bottom - anchorRect.bottom >= menuRect.height) {
|
||
top = anchorRect.bottom;
|
||
this.$marker.classList.add('top');
|
||
this.$marker.classList.remove('bottom');
|
||
this.$marker.style.top = `calc(${top}px - 0.4em)`;
|
||
}
|
||
else if (anchorRect.top - containerRect.top >= menuRect.height) {
|
||
top = Math.max(0, anchorRect.top - menuRect.height);
|
||
this.$marker.classList.add('bottom');
|
||
this.$marker.classList.remove('top');
|
||
this.$marker.style.top = `calc(${top}px + ${menuRect.height}px - 0.6em)`;
|
||
}
|
||
else {
|
||
top = Math.max(0, containerRect.top - menuRect.height);
|
||
this.$marker.classList.remove('bottom');
|
||
this.$marker.classList.remove('top');
|
||
this.$marker.style.top = `calc(${top}px + ${menuRect.height}px - 0.6em)`;
|
||
}
|
||
|
||
const canPlaceAtRight = containerRect.right - anchorRect.left >= menuRect.width;
|
||
const canPlaceAtLeft = anchorRect.left - containerRect.left >= menuRect.width;
|
||
|
||
if (canPlaceAtRight || canPlaceAtLeft) {
|
||
if (this.isRTL) {
|
||
if (canPlaceAtLeft) {
|
||
left = Math.max(0, anchorRect.right - menuRect.width);
|
||
this.$marker.style.left = `calc(${left}px + ${menuRect.width}px - 1.5em)`;
|
||
}
|
||
else {
|
||
left = anchorRect.left;
|
||
this.$marker.style.left = `calc(${left}px + 0.5em)`;
|
||
}
|
||
}
|
||
else {
|
||
if (canPlaceAtRight) {
|
||
left = anchorRect.left;
|
||
this.$marker.style.left = `calc(${left}px + 0.5em)`;
|
||
}
|
||
else {
|
||
left = Math.max(0, anchorRect.right - menuRect.width);
|
||
this.$marker.style.left = `calc(${left}px + ${menuRect.width}px - 1.5em)`;
|
||
}
|
||
}
|
||
|
||
}
|
||
else {
|
||
left = Math.max(0, containerRect.left - menuRect.width);
|
||
this.$marker.style.left = `calc(${left}px + ${menuRect.width}px - 1.5em)`;
|
||
}
|
||
}
|
||
|
||
let parentRect;
|
||
if (menu.parentNode.localName == 'li') {
|
||
parentRect = menu.parentNode.getBoundingClientRect();
|
||
left = this.isRTL ? parentRect.left - menuRect.width : parentRect.right;
|
||
top = parentRect.top;
|
||
}
|
||
|
||
if (left === undefined)
|
||
left = Math.max(0, (containerRect.width - menuRect.width) / 2);
|
||
else if (this.isRTL)
|
||
left -= menuRect.width;
|
||
|
||
if (top === undefined)
|
||
top = Math.max(0, (containerRect.height - menuRect.height) / 2);
|
||
|
||
if (!options.anchor && menu == this.root) {
|
||
// reposition to avoid the menu is opened below the cursor
|
||
if (containerRect.bottom - top < menuRect.height) {
|
||
top = top - menuRect.height;
|
||
}
|
||
if (containerRect.right - left < menuRect.width) {
|
||
left = left - menuRect.width;
|
||
}
|
||
}
|
||
|
||
const minMargin = 3;
|
||
const overwrap = 4;
|
||
const firstTryLeft = Math.max(minMargin, Math.min(left - overwrap, containerRect.width - menuRect.width - minMargin));
|
||
if (parentRect &&
|
||
firstTryLeft < parentRect.right - overwrap &&
|
||
containerRect.left < parentRect.left - menuRect.width + overwrap) {
|
||
left = parentRect.left - menuRect.width + overwrap;
|
||
}
|
||
else {
|
||
left = firstTryLeft;
|
||
}
|
||
menu.style.left = `${left}px`;
|
||
|
||
top = Math.max(minMargin, Math.min(top, containerRect.height - menuRect.height - minMargin));
|
||
if (menu == this.root && this.$marker.classList.contains('top'))
|
||
menu.style.top = `calc(${top}px + 0.5em)`;
|
||
else if (menu == this.root && this.$marker.classList.contains('bottom'))
|
||
menu.style.top = `calc(${top}px - 0.5em)`;
|
||
else
|
||
menu.style.top = `${top}px`;
|
||
}
|
||
|
||
async close() {
|
||
if (!this.opened)
|
||
return;
|
||
this.$tryCancelOpen();
|
||
this.root.classList.remove('open');
|
||
this.$screen.classList.remove('open');
|
||
if (this.anchor) {
|
||
this.anchor.classList.remove('open');
|
||
this.$marker.classList.remove('open');
|
||
}
|
||
this.$mouseDownAfterOpen = false;
|
||
this.$lastFocusedItem = null;
|
||
this.$lastHoverItem = null;
|
||
this.$mouseDownFired = false;
|
||
this.anchor = null;
|
||
this.canceller = null;
|
||
return new Promise((resolve, _reject) => {
|
||
this.closeTimeout = setTimeout(() => {
|
||
delete this.closeTimeout;
|
||
this.$onClosed();
|
||
resolve();
|
||
}, this.animationDuration);
|
||
});
|
||
}
|
||
$onClosed() {
|
||
const menus = [this.root].concat(Array.from(this.root.querySelectorAll('ul')));
|
||
for (const menu of menus) {
|
||
this.$updatePosition(menu, { left: 0, right: 0 });
|
||
}
|
||
this.root.parentNode.removeEventListener('mouseover', this.$onMouseOver);
|
||
this.root.removeEventListener('transitionend', this.$onTransitionEnd);
|
||
window.removeEventListener('contextmenu', this.$onContextMenu, { capture: true });
|
||
window.removeEventListener('mousedown', this.$onMouseDown, { capture: true });
|
||
window.removeEventListener('mouseup', this.$onMouseUp, { capture: true });
|
||
window.removeEventListener('click', this.$onClick, { capture: true });
|
||
window.removeEventListener('keydown', this.$onKeyDown, { capture: true });
|
||
window.removeEventListener('keyup', this.$onKeyUp, { capture: true });
|
||
window.removeEventListener('blur', this.$onBlur, { capture: true });
|
||
this.onHidden();
|
||
}
|
||
|
||
get $containerRect() {
|
||
const x = 0;
|
||
const y = 0;
|
||
const width = window.innerWidth;
|
||
const height = window.innerHeight;
|
||
return {
|
||
x, y, width, height,
|
||
left: x,
|
||
top: y,
|
||
right: width,
|
||
bottom: height
|
||
};
|
||
}
|
||
|
||
focusTo(item) {
|
||
this.$lastFocusedItem = this.$lastHoverItem = item;
|
||
this.$lastFocusedItem.focus();
|
||
this.$lastFocusedItem.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
|
||
$onBlur(event) {
|
||
if (event.target == document)
|
||
this.close();
|
||
}
|
||
|
||
$onMouseOver(event) {
|
||
let item = this.$getEffectiveItem(event.target);
|
||
if (this.delayedOpen && this.delayedOpen.item != item) {
|
||
clearTimeout(this.delayedOpen.timer);
|
||
this.delayedOpen = null;
|
||
}
|
||
if (item && item.delayedClose) {
|
||
clearTimeout(item.delayedClose);
|
||
item.delayedClose = null;
|
||
}
|
||
if (item && item.classList.contains('separator')) {
|
||
this.$lastHoverItem = item;
|
||
item = null;
|
||
}
|
||
if (!item) {
|
||
if (this.$lastFocusedItem) {
|
||
if (this.$lastFocusedItem.parentNode != this.root) {
|
||
this.focusTo(this.$lastFocusedItem.parentNode.parentNode);
|
||
}
|
||
else {
|
||
this.$lastFocusedItem.blur();
|
||
this.$lastFocusedItem = null;
|
||
}
|
||
}
|
||
this.$setHover(null);
|
||
return;
|
||
}
|
||
|
||
this.$setHover(item);
|
||
this.$closeOtherSubmenus(item);
|
||
this.focusTo(item);
|
||
|
||
this.delayedOpen = {
|
||
item: item,
|
||
timer: setTimeout(() => {
|
||
this.delayedOpen = null;
|
||
this.$openSubmenuFor(item);
|
||
}, this.subMenuOpenDelay)
|
||
};
|
||
}
|
||
|
||
$setHover(item) {
|
||
for (const item of this.root.querySelectorAll('li.hover')) {
|
||
if (item != item)
|
||
item.classList.remove('hover');
|
||
}
|
||
if (item)
|
||
item.classList.add('hover');
|
||
}
|
||
|
||
$openSubmenuFor(item) {
|
||
const items = MenuUI.$evaluateXPath(
|
||
`ancestor-or-self::li[${MenuUI.$hasClass('has-submenu')}][not(${MenuUI.$hasClass('disabled')})]`,
|
||
item
|
||
);
|
||
for (const item of MenuUI.$getArrayFromXPathResult(items)) {
|
||
item.classList.add('open');
|
||
}
|
||
}
|
||
|
||
$closeOtherSubmenus(item) {
|
||
const items = MenuUI.$evaluateXPath(
|
||
`preceding-sibling::li[${MenuUI.$hasClass('has-submenu')}] |
|
||
following-sibling::li[${MenuUI.$hasClass('has-submenu')}] |
|
||
preceding-sibling::li/descendant::li[${MenuUI.$hasClass('has-submenu')}] |
|
||
following-sibling::li/descendant::li[${MenuUI.$hasClass('has-submenu')}]`,
|
||
item
|
||
);
|
||
for (const item of MenuUI.$getArrayFromXPathResult(items)) {
|
||
item.delayedClose = setTimeout(() => {
|
||
item.classList.remove('open');
|
||
}, this.subMenuCloseDelay);
|
||
}
|
||
}
|
||
|
||
$onMouseDown(event) {
|
||
event.stopImmediatePropagation();
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
this.$mouseDownAfterOpen = true;
|
||
this.$mouseDownFired = true;
|
||
}
|
||
|
||
$getEffectiveItem(node) {
|
||
const target = node.closest('li');
|
||
let untransparentTarget = target && target.closest('ul');
|
||
while (untransparentTarget) {
|
||
if (parseFloat(window.getComputedStyle(untransparentTarget, null).opacity) < 1)
|
||
return null;
|
||
untransparentTarget = untransparentTarget.parentNode.closest('ul');
|
||
if (untransparentTarget == document)
|
||
break;
|
||
}
|
||
return target;
|
||
}
|
||
|
||
$onMouseUp(event) {
|
||
if (!this.$mouseDownAfterOpen &&
|
||
event.target.closest(`#${this.root.id}`))
|
||
this.$onClick(event);
|
||
}
|
||
|
||
async $onClick(event) {
|
||
event.stopImmediatePropagation();
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
|
||
const target = this.$getEffectiveItem(event.target);
|
||
if (!target ||
|
||
target.classList.contains('separator') ||
|
||
target.classList.contains('has-submenu') ||
|
||
target.classList.contains('disabled')) {
|
||
if (this.$mouseDownFired && // ignore "click" event triggered by a mousedown fired before the menu is opened (like long-press)
|
||
!event.target.closest(`#${this.root.id}`))
|
||
return this.close();
|
||
return;
|
||
}
|
||
|
||
this.onCommand(target, event);
|
||
}
|
||
|
||
$getNextFocusedItemByAccesskey(key) {
|
||
for (const attribute of ['access-key', 'sub-access-key']) {
|
||
const current = this.$lastHoverItem || this.$lastFocusedItem || this.root.firstChild;
|
||
const condition = `@data-${attribute}="${key.toLowerCase()}"`;
|
||
const item = this.$getNextItem(current, condition);
|
||
if (item)
|
||
return item;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
$incrementalSearchNextFocusedItem(key) {
|
||
this.$incrementalSearchString += key.toLowerCase();
|
||
const current = this.$lastHoverItem || this.$lastFocusedItem || this.root.firstChild;
|
||
const condition = `starts-with(@data-lower-cased-text, "${this.$incrementalSearchString.replace(/"/g, '\\"')}")`;
|
||
if (this.$isItemMatches(current, condition))
|
||
return current;
|
||
const item = this.$getNextItem(current, condition);
|
||
return item;
|
||
}
|
||
|
||
$shouldSearchIncremental() {
|
||
if (!this.incrementalSearch)
|
||
return false;
|
||
|
||
const last = this.$lastKeyInputAt;
|
||
const now = this.$lastKeyInputAt = Date.now();
|
||
if (last < 0)
|
||
return true; // start
|
||
|
||
if (now - last > this.incrementalSearchTimeout)
|
||
this.$incrementalSearchString = '';
|
||
|
||
return true; // continue
|
||
}
|
||
|
||
$onKeyDown(event) {
|
||
switch (event.key) {
|
||
case 'ArrowUp':
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
this.$advanceFocus(-1);
|
||
break;
|
||
|
||
case 'ArrowDown':
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
this.$advanceFocus(1);
|
||
break;
|
||
|
||
case 'ArrowRight':
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
if (this.isRTL)
|
||
this.$digOut();
|
||
else
|
||
this.$digIn();
|
||
break;
|
||
|
||
case 'ArrowLeft':
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
if (this.isRTL)
|
||
this.$digIn();
|
||
else
|
||
this.$digOut();
|
||
break;
|
||
|
||
case 'Home':
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
this.$advanceFocus(1, (
|
||
this.$lastHoverItem && this.$lastHoverItem.parentNode ||
|
||
this.$lastFocusedItem && this.$lastFocusedItem.parentNode ||
|
||
this.root
|
||
).lastChild);
|
||
break;
|
||
|
||
case 'End':
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
this.$advanceFocus(-1, (
|
||
this.$lastHoverItem && this.$lastHoverItem.parentNode ||
|
||
this.$lastFocusedItem && this.$lastFocusedItem.parentNode ||
|
||
this.root
|
||
).firstChild);
|
||
break;
|
||
|
||
case 'Enter': {
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
const targetItem = this.$lastHoverItem || this.$lastFocusedItem;
|
||
if (targetItem) {
|
||
if (targetItem.classList.contains('disabled'))
|
||
this.close();
|
||
else if (!targetItem.classList.contains('separator'))
|
||
this.onCommand(targetItem, event);
|
||
}
|
||
}; break;
|
||
|
||
case 'Escape': {
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
const targetItem = this.$lastHoverItem || this.$lastFocusedItem;
|
||
if (!targetItem ||
|
||
targetItem.parentNode == this.root)
|
||
this.close();
|
||
else
|
||
this.$digOut();
|
||
}; break;
|
||
|
||
case 'BackSpace': {
|
||
if (this.$shouldSearchIncremental()) {
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
this.$incrementalSearchString = this.$incrementalSearchString.slice(0, this.$incrementalSearchString.length - 1);
|
||
const item = this.$incrementalSearchNextFocusedItem('');
|
||
if (item) {
|
||
this.focusTo(item);
|
||
this.$setHover(null);
|
||
}
|
||
}
|
||
}; break;
|
||
|
||
default:
|
||
if (event.key.length == 1) {
|
||
if (this.$shouldSearchIncremental()) {
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
const item = this.$incrementalSearchNextFocusedItem(event.key);
|
||
if (item) {
|
||
this.focusTo(item);
|
||
this.$setHover(null);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const item = this.$getNextFocusedItemByAccesskey(event.key);
|
||
if (item) {
|
||
this.focusTo(item);
|
||
this.$setHover(null);
|
||
if (this.$getNextFocusedItemByAccesskey(event.key) == item &&
|
||
!item.classList.contains('disabled')) {
|
||
if (item.querySelector('ul'))
|
||
this.$digIn();
|
||
else
|
||
this.onCommand(item, event);
|
||
}
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
$onKeyUp(event) {
|
||
switch (event.key) {
|
||
case 'ArrowUp':
|
||
case 'ArrowDown':
|
||
case 'ArrowRight':
|
||
case 'ArrowLeft':
|
||
case 'Home':
|
||
case 'End':
|
||
case 'Enter':
|
||
case 'Escape':
|
||
case 'Bakcspace':
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
return;
|
||
|
||
default:
|
||
if (event.key.length == 1 &&
|
||
(this.$shouldSearchIncremental() ||
|
||
this.$getNextFocusedItemByAccesskey(event.key))) {
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
$getPreviousItem(base, condition = '') {
|
||
const extrcondition = condition ? `[${condition}]` : '' ;
|
||
const item = (
|
||
MenuUI.$evaluateXPath(
|
||
`preceding-sibling::li[not(${MenuUI.$hasClass('separator')})]${extrcondition}[1]`,
|
||
base,
|
||
XPathResult.FIRST_ORDERED_NODE_TYPE
|
||
).singleNodeValue ||
|
||
MenuUI.$evaluateXPath(
|
||
`following-sibling::li[not(${MenuUI.$hasClass('separator')})]${extrcondition}[last()]`,
|
||
base,
|
||
XPathResult.FIRST_ORDERED_NODE_TYPE
|
||
).singleNodeValue ||
|
||
MenuUI.$evaluateXPath(
|
||
`self::li[not(${MenuUI.$hasClass('separator')})]${extrcondition}`,
|
||
base,
|
||
XPathResult.FIRST_ORDERED_NODE_TYPE
|
||
).singleNodeValue
|
||
);
|
||
if (window.getComputedStyle(item, null).display == 'none')
|
||
return this.$getPreviousItem(item, condition);
|
||
return item;
|
||
}
|
||
|
||
$getNextItem(base, condition = '') {
|
||
const extrcondition = condition ? `[${condition}]` : '' ;
|
||
const item = (
|
||
MenuUI.$evaluateXPath(
|
||
`following-sibling::li[not(${MenuUI.$hasClass('separator')})]${extrcondition}[1]`,
|
||
base,
|
||
XPathResult.FIRST_ORDERED_NODE_TYPE
|
||
).singleNodeValue ||
|
||
MenuUI.$evaluateXPath(
|
||
`preceding-sibling::li[not(${MenuUI.$hasClass('separator')})]${extrcondition}[last()]`,
|
||
base,
|
||
XPathResult.FIRST_ORDERED_NODE_TYPE
|
||
).singleNodeValue ||
|
||
MenuUI.$evaluateXPath(
|
||
`self::li[not(${MenuUI.$hasClass('separator')})]${extrcondition}`,
|
||
base,
|
||
XPathResult.FIRST_ORDERED_NODE_TYPE
|
||
).singleNodeValue
|
||
);
|
||
if (item && window.getComputedStyle(item, null).display == 'none')
|
||
return this.$getNextItem(item, condition);
|
||
return item;
|
||
}
|
||
|
||
$isItemMatches(base, condition = '') {
|
||
const extrcondition = condition ? `[${condition}]` : '' ;
|
||
return !!MenuUI.$evaluateXPath(
|
||
`self::li[not(${MenuUI.$hasClass('separator')})]${extrcondition}`,
|
||
base,
|
||
XPathResult.FIRST_ORDERED_NODE_TYPE
|
||
).singleNodeValue;
|
||
}
|
||
|
||
$advanceFocus(direction, lastFocused = null) {
|
||
lastFocused = lastFocused || this.$lastHoverItem || this.$lastFocusedItem;
|
||
if (!lastFocused) {
|
||
if (direction < 0)
|
||
this.$lastFocusedItem = lastFocused = this.root.firstChild;
|
||
else
|
||
this.$lastFocusedItem = lastFocused = this.root.lastChild;
|
||
}
|
||
this.focusTo(direction < 0 ? this.$getPreviousItem(lastFocused) : this.$getNextItem(lastFocused));
|
||
this.$setHover(null);
|
||
}
|
||
|
||
$digIn() {
|
||
if (!this.$lastFocusedItem) {
|
||
this.$advanceFocus(1, this.root.lastChild);
|
||
return;
|
||
}
|
||
const submenu = this.$lastFocusedItem.querySelector('ul');
|
||
if (!submenu || this.$lastFocusedItem.classList.contains('disabled'))
|
||
return;
|
||
this.$closeOtherSubmenus(this.$lastFocusedItem);
|
||
this.$openSubmenuFor(this.$lastFocusedItem);
|
||
this.$advanceFocus(1, submenu.lastChild);
|
||
}
|
||
|
||
$digOut() {
|
||
const targetItem = this.$lastHoverItem || this.$lastFocusedItem;
|
||
if (!targetItem ||
|
||
targetItem.parentNode == this.root)
|
||
return;
|
||
this.$closeOtherSubmenus(targetItem);
|
||
this.$lastFocusedItem = targetItem.parentNode.parentNode;
|
||
this.$closeOtherSubmenus(this.$lastFocusedItem);
|
||
this.$lastFocusedItem.classList.remove('open');
|
||
this.focusTo(targetItem.parentNode.parentNode);
|
||
this.$setHover(null);
|
||
}
|
||
|
||
$onTransitionEnd(event) {
|
||
const hoverItems = this.root.querySelectorAll('li:hover');
|
||
if (hoverItems.length == 0)
|
||
return;
|
||
const $lastHoverItem = hoverItems[hoverItems.length - 1];
|
||
const item = this.$getEffectiveItem($lastHoverItem);
|
||
if (!item)
|
||
return;
|
||
if (item.parentNode != event.target)
|
||
return;
|
||
this.$setHover(item);
|
||
this.focusTo(item);
|
||
}
|
||
|
||
$onContextMenu(event) {
|
||
event.stopImmediatePropagation();
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
}
|
||
|
||
|
||
static $installStyles() {
|
||
this.style = document.createElement('style');
|
||
this.style.setAttribute('type', 'text/css');
|
||
const common = `.${this.$commonClass}`;
|
||
this.style.textContent = `
|
||
${common}.menu-ui,
|
||
${common}.menu-ui ul {
|
||
background-color: var(--menu-ui-background-color);
|
||
color: var(--menu-ui-text-color);
|
||
cursor: default;
|
||
margin: 0;
|
||
max-height: calc(100% - 6px);
|
||
max-width: calc(100% - 6px);
|
||
opacity: 0;
|
||
overflow: hidden; /* because scrollbars always trap mouse events even if it is invisible. See also: https://github.com/piroor/treestyletab/issues/2386 */
|
||
padding: 0;
|
||
pointer-events: none;
|
||
position: fixed;
|
||
z-index: 999999;
|
||
}
|
||
${common}.menu-ui.rtl {
|
||
direction: rtl;
|
||
}
|
||
|
||
${common}.menu-ui.open,
|
||
${common}.menu-ui.open li.open > ul {
|
||
opacity: 1;
|
||
overflow: auto;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
${common}.menu-ui li {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
${common}.menu-ui li:not(.separator):focus,
|
||
${common}.menu-ui li:not(.separator).open {
|
||
background-color: var(--menu-ui-background-color-active);
|
||
color: var(--menu-ui-text-color-active);
|
||
}
|
||
|
||
${common}.menu-ui li.radio.checked::before,
|
||
${common}.menu-ui li.checkbox.checked::before {
|
||
content: "✔";
|
||
position: absolute;
|
||
inset-inline-start: 0.5em;
|
||
}
|
||
|
||
${common}.menu-ui li.separator {
|
||
height: 0.5em;
|
||
visibility: hidden;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
${common}.menu-ui li.has-submenu,
|
||
${common}.menu-ui.menu li.has-submenu {
|
||
padding-inline-end: 1em;
|
||
}
|
||
${common}.menu-ui li.has-submenu::after {
|
||
content: "❯";
|
||
inset-inline-end: 0.25em;
|
||
position: absolute;
|
||
transform: scale(0.75);
|
||
}
|
||
|
||
${common}.menu-ui .accesskey {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
${common}.menu-ui-blocking-screen {
|
||
display: none;
|
||
}
|
||
|
||
${common}.menu-ui-blocking-screen.open {
|
||
bottom: 0;
|
||
display: block;
|
||
left: 0;
|
||
position: fixed;
|
||
right: 0;
|
||
top: 0;
|
||
z-index: 899999;
|
||
}
|
||
|
||
${common}.menu-ui.menu li:not(.separator):focus,
|
||
${common}.menu-ui.menu li:not(.separator).open {
|
||
outline: none;
|
||
}
|
||
|
||
${common}.menu-ui.panel li:not(.separator):focus ul li:not(:focus):not(.open),
|
||
${common}.menu-ui.panel li:not(.separator).open ul li:not(:focus):not(.open) {
|
||
background-color: transparent;
|
||
color: var(--menu-ui-text-color);
|
||
}
|
||
|
||
${common}.menu-ui-marker {
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
position: fixed;
|
||
}
|
||
|
||
${common}.menu-ui-marker.open {
|
||
border: 0.5em solid transparent;
|
||
content: "";
|
||
height: 0;
|
||
left: 0;
|
||
opacity: 1;
|
||
top: 0;
|
||
width: 0;
|
||
z-index: 999999;
|
||
}
|
||
|
||
${common}.menu-ui-marker.top {
|
||
border-bottom: 0.5em solid var(--menu-ui-background-color);
|
||
}
|
||
${common}.menu-ui-marker.bottom {
|
||
border-top: 0.5em solid var(--menu-ui-background-color);
|
||
}
|
||
|
||
${common}.menu-ui li.disabled {
|
||
opacity: 0.5;
|
||
}
|
||
|
||
${common}.menu-ui li[data-icon],
|
||
${common}.menu-ui.menu li[data-icon],
|
||
${common}.menu-ui.panel li[data-icon] {
|
||
--icon-size: 16px;
|
||
background-repeat: no-repeat;
|
||
background-size: var(--icon-size);
|
||
padding-inline-start: calc(var(--icon-size) + 0.7em);
|
||
}
|
||
${common}.menu-ui:not(.rtl) li[data-icon],
|
||
${common}.menu-ui:not(.rtl).menu li[data-icon],
|
||
${common}.menu-ui:not(.rtl).panel li[data-icon] {
|
||
background-position: left center;
|
||
}
|
||
${common}.menu-ui.rtl li[data-icon],
|
||
${common}.menu-ui.rtl.menu li[data-icon],
|
||
${common}.menu-ui.rtl.panel li[data-icon] {
|
||
background-position: right center;
|
||
}
|
||
|
||
${common}.menu-ui li.checkbox,
|
||
${common}.menu-ui li.radio,
|
||
${common}.menu-ui.menu li.checkbox,
|
||
${common}.menu-ui.panel li.checkbox,
|
||
${common}.menu-ui.menu li.radio,
|
||
${common}.menu-ui.panel li.radio {
|
||
padding-inline-start: 1.7em;
|
||
}
|
||
|
||
/* panel-like appearance */
|
||
${common}.panel {
|
||
--menu-ui-background-color: -moz-dialog;
|
||
--menu-ui-text-color: -moz-dialogtext;
|
||
--menu-ui-background-color-active: Highlight;
|
||
--menu-ui-text-color-active: HighlightText;
|
||
}
|
||
${common}.panel li.disabled {
|
||
--menu-ui-background-color-active: InactiveCaptionText;
|
||
--menu-ui-text-color-active: InactiveCaption;
|
||
}
|
||
${common}.menu-ui.panel,
|
||
${common}.menu-ui.panel ul {
|
||
box-shadow: 0.1em 0.1em 0.8em rgba(0, 0, 0, 0.65);
|
||
padding: 0.25em 0;
|
||
}
|
||
|
||
${common}.menu-ui.panel li {
|
||
padding: 0.15em 1em 0.15em 0.7em;
|
||
}
|
||
|
||
|
||
/* Menu-like appearance */
|
||
${common}.menu {
|
||
--menu-ui-background-color: Menu;
|
||
--menu-ui-text-color: MenuText;
|
||
--menu-ui-background-color-active: Highlight;
|
||
--menu-ui-text-color-active: HighlightText;
|
||
}
|
||
${common}.menu li.disabled {
|
||
--menu-ui-background-color-active: InactiveCaptionText;
|
||
--menu-ui-text-color-active: InactiveCaption;
|
||
}
|
||
${common}.menu-ui.menu,
|
||
${common}.menu-ui.menu ul {
|
||
border: 1px outset Menu;
|
||
box-shadow: 0.1em 0.1em 0.5em rgba(0, 0, 0, 0.65);
|
||
font: -moz-pull-down-menu;
|
||
}
|
||
|
||
${common}.menu-ui.menu li {
|
||
padding: 0.15em 0.5em 0.15em 0.7em;
|
||
}
|
||
|
||
${common}.menu-ui.menu li.separator {
|
||
border: 1px inset Menu;
|
||
height: 0;
|
||
margin: 0 0.5em;
|
||
max-height: 0;
|
||
opacity: 0.5;
|
||
padding: 0;
|
||
visibility: visible;
|
||
}
|
||
|
||
${common}.menu-ui.menu:not(.rtl) li[data-icon]:not([data-icon-color]),
|
||
${common}.menu-ui.panel:not(.rtl) li[data-icon]:not([data-icon-color]) {
|
||
background-position: 0.5em center;
|
||
}
|
||
${common}.menu-ui.menu.rtl li[data-icon]:not([data-icon-color]),
|
||
${common}.menu-ui.panel.rtl li[data-icon]:not([data-icon-color]) {
|
||
background-position: calc(100% - 0.5em) center;
|
||
}
|
||
|
||
${common}.menu-ui li:not([data-icon]) .icon,
|
||
${common}.menu-ui li[data-icon]:not([data-icon-color]) .icon {
|
||
display: none;
|
||
}
|
||
|
||
${common}.menu-ui li[data-icon][data-icon-color] .icon {
|
||
display: inline-block;
|
||
height: var(--icon-size);
|
||
inset-inline-start: 0.5em;
|
||
max-height: var(--icon-size);
|
||
max-width: var(--icon-size);
|
||
position: absolute;
|
||
width: var(--icon-size);
|
||
}
|
||
`;
|
||
document.head.appendChild(this.style);
|
||
}
|
||
|
||
static init() {
|
||
MenuUI.$uniqueKey = parseInt(Math.random() * Math.pow(2, 16));
|
||
MenuUI.$commonClass = `menu-ui-${MenuUI.$uniqueKey}`;
|
||
|
||
MenuUI.prototype.$uniqueKey = MenuUI.$uniqueKey;
|
||
MenuUI.prototype.$commonClass = MenuUI.$commonClass;
|
||
|
||
MenuUI.$installStyles();
|
||
|
||
window.MenuUI = MenuUI;
|
||
}
|
||
};
|
||
|
||
MenuUI.init();
|
||
}
|
||
export default MenuUI;
|