From 2b33171b3a78301535a2dfa04fe11058c5de3c27 Mon Sep 17 00:00:00 2001 From: Andreas Kienast <a.fernandez@scripting-base.de> Date: Mon, 26 Feb 2024 09:06:28 +0100 Subject: [PATCH] [BUGFIX] Indicate loading process when requesting context menu This commit changes the rendering behavior of context menus. Previously, the context menu became visible once the server requests were handled successfully. This is an issue in scenarios where either the server is under load and may need some time to send a response, or the network connectivity of the backend user is not optimal. Now, when requesting a context menu, it's container is shown before any data is received and a spinner is rendered. Resolves: #103197 Releases: main, 12.4 Change-Id: I0cd36976a407bce7ce45520d86d7d28a5719cd92 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/83127 Reviewed-by: Markus Klein <markus.klein@typo3.org> Tested-by: Andreas Kienast <a.fernandez@scripting-base.de> Reviewed-by: Garvin Hicking <gh@faktor-e.de> Tested-by: core-ci <typo3@b13.com> Tested-by: Garvin Hicking <gh@faktor-e.de> Tested-by: Markus Klein <markus.klein@typo3.org> Reviewed-by: Andreas Kienast <a.fernandez@scripting-base.de> --- .../TypeScript/backend/context-menu.ts | 114 ++++++++++-------- .../TypeScript/filelist/file-list-actions.ts | 2 + .../Public/JavaScript/context-menu.js | 2 +- .../Public/JavaScript/file-list-actions.js | 2 +- 4 files changed, 68 insertions(+), 52 deletions(-) diff --git a/Build/Sources/TypeScript/backend/context-menu.ts b/Build/Sources/TypeScript/backend/context-menu.ts index 65dd7108ad0c..a80b2d8ca4a9 100644 --- a/Build/Sources/TypeScript/backend/context-menu.ts +++ b/Build/Sources/TypeScript/backend/context-menu.ts @@ -17,6 +17,7 @@ import ContextMenuActions from './context-menu-actions'; import DebounceEvent from '@typo3/core/event/debounce-event'; import RegularEvent from '@typo3/core/event/regular-event'; import { selector } from '@typo3/core/literals'; +import '@typo3/backend/element/spinner-element'; interface MousePosition { X: number; @@ -122,6 +123,7 @@ class ContextMenu { originalEvent: PointerEvent = null ): void { this.hideAll(); + this.initializeContextMenuContainer(); this.record = { table: table, uid: uid }; const focusableSource = eventSource.matches('a, button, [tabindex]:not([tabindex="-1"])') @@ -154,48 +156,49 @@ class ContextMenu { * Manipulates the DOM to add the divs needed for context menu at the bottom of the <body>-tag */ private initializeContextMenuContainer(): void { - if (document.querySelector('#contentMenu0') === null) { - const contextMenu1 = document.createElement('div'); - contextMenu1.classList.add('context-menu'); - contextMenu1.id = 'contentMenu0'; - contextMenu1.style.display = 'none'; - document.querySelector('body').append(contextMenu1); - - const contextMenu2 = document.createElement('div'); - contextMenu2.classList.add('context-menu'); - contextMenu2.id = 'contentMenu1'; - contextMenu2.style.display = 'none'; - contextMenu2.dataset.parent = '#contentMenu0' - document.querySelector('body').append(contextMenu2); - - document.querySelectorAll('.context-menu').forEach((contextMenu: Element): void => { - // Explicitly update cursor position if element is entered to avoid timing issues - new RegularEvent('mouseenter', (event: MouseEvent): void => { - this.storeMousePosition(event); - }).bindTo(contextMenu); - - new DebounceEvent('mouseleave', (event: MouseEvent) => { - const target: HTMLElement = event.target as HTMLElement; - const childMenu: HTMLElement | null = document.querySelector(selector`[data-parent="#${target.id}"]`); - - const hideThisMenu = - !ContextMenu.within(target, this.mousePos.X, this.mousePos.Y) // cursor it outside triggered context menu - && (childMenu === null || childMenu.offsetParent === null); // child menu, if any, is not visible - - if (hideThisMenu) { - this.hide(target); - - // close parent menu (if any) if cursor is outside its boundaries - let parent: HTMLElement | null; - if (typeof target.dataset.parent !== 'undefined' && (parent = document.querySelector(target.dataset.parent)) !== null) { - if (!ContextMenu.within(parent, this.mousePos.X, this.mousePos.Y)) { - this.hide(document.querySelector(target.dataset.parent)); - } + if (document.querySelector('#contentMenu0') !== null) { + return; + } + const contextMenu1 = document.createElement('div'); + contextMenu1.classList.add('context-menu'); + contextMenu1.id = 'contentMenu0'; + contextMenu1.style.display = 'none'; + document.querySelector('body').append(contextMenu1); + + const contextMenu2 = document.createElement('div'); + contextMenu2.classList.add('context-menu'); + contextMenu2.id = 'contentMenu1'; + contextMenu2.style.display = 'none'; + contextMenu2.dataset.parent = '#contentMenu0' + document.querySelector('body').append(contextMenu2); + + document.querySelectorAll('.context-menu').forEach((contextMenu: Element): void => { + // Explicitly update cursor position if element is entered to avoid timing issues + new RegularEvent('mouseenter', (event: MouseEvent): void => { + this.storeMousePosition(event); + }).bindTo(contextMenu); + + new DebounceEvent('mouseleave', (event: MouseEvent) => { + const target: HTMLElement = event.target as HTMLElement; + const childMenu: HTMLElement | null = document.querySelector(selector`[data-parent="#${target.id}"]`); + + const hideThisMenu = + !ContextMenu.within(target, this.mousePos.X, this.mousePos.Y) // cursor it outside triggered context menu + && (childMenu === null || childMenu.offsetParent === null); // child menu, if any, is not visible + + if (hideThisMenu) { + this.hide(target); + + // close parent menu (if any), if cursor is outside its boundaries + let parent: HTMLElement | null; + if (typeof target.dataset.parent !== 'undefined' && (parent = document.querySelector(target.dataset.parent)) !== null) { + if (!ContextMenu.within(parent, this.mousePos.X, this.mousePos.Y)) { + this.hide(document.querySelector(target.dataset.parent)); } } - }, 500).bindTo(contextMenu); - }); - } + } + }, 500).bindTo(contextMenu); + }); } private handleTriggerEvent(event: PointerEvent): void @@ -237,28 +240,44 @@ class ContextMenu { * Make the AJAX request * * @param {string} parameters Parameters sent to the server + * @param {MousePosition} position */ private fetch(parameters: string, position: MousePosition): void { + const stubMenu = this.renderStubMenu(0, position); const url = TYPO3.settings.ajaxUrls.contextmenu; (new AjaxRequest(url)).withQueryArguments(parameters).get().then(async (response: AjaxResponse): Promise<void> => { const data: MenuItems = await response.resolve(); if (typeof response !== 'undefined' && Object.keys(response).length > 0) { - this.populateData(data, 0, position); + this.populateData(data, 0); } + }).catch((): void => { + this.hide(stubMenu); }); } + private renderStubMenu(level: number, position: MousePosition): HTMLElement|null { + const contentMenuCurrent = document.querySelector('#contentMenu' + level) as HTMLElement; + if (contentMenuCurrent !== null) { + contentMenuCurrent.replaceChildren(document.createRange().createContextualFragment('<typo3-backend-spinner size="medium"></typo3-backend-spinner>')); + contentMenuCurrent.style.display = null; + position ??= this.getPosition(contentMenuCurrent); + const coordinates = this.toPixel(position); + + contentMenuCurrent.style.top = coordinates.top; + contentMenuCurrent.style.insetInlineStart = coordinates.start; + } + + return contentMenuCurrent; + } + /** * Fills the context menu with content and displays it correctly * depending on the mouse position * * @param {MenuItems} items The data that will be put in the menu * @param {number} level The depth of the context menu - * @param {MousPosition} */ - private populateData(items: MenuItems, level: number, position: MousePosition): void { - this.initializeContextMenuContainer(); - + private populateData(items: MenuItems, level: number): void { const contentMenuCurrent = document.querySelector('#contentMenu' + level) as HTMLElement; const contentMenuParent = document.querySelector('#contentMenu' + (level - 1)) as HTMLElement; if (contentMenuCurrent !== null && contentMenuParent?.offsetParent !== null) { @@ -272,11 +291,6 @@ class ContextMenu { contentMenuCurrent.innerHTML = ''; contentMenuCurrent.appendChild(menuGroup); contentMenuCurrent.style.display = null; - position ??= this.getPosition(contentMenuCurrent); - const coordinates = this.toPixel(position); - - contentMenuCurrent.style.top = coordinates.top; - contentMenuCurrent.style.insetInlineStart = coordinates.start; (contentMenuCurrent.querySelector('.context-menu-item[tabindex="-1"]') as HTMLElement).focus(); this.initializeEvents(contentMenuCurrent, level); } diff --git a/Build/Sources/TypeScript/filelist/file-list-actions.ts b/Build/Sources/TypeScript/filelist/file-list-actions.ts index ef4c74720d97..5f005c01bbb2 100644 --- a/Build/Sources/TypeScript/filelist/file-list-actions.ts +++ b/Build/Sources/TypeScript/filelist/file-list-actions.ts @@ -70,6 +70,8 @@ class FileListActions { constructor() { new RegularEvent('contextmenu', (event: Event, target: HTMLElement): void => { event.preventDefault(); + event.stopImmediatePropagation(); + const detail: FileListActionDetail = this.getActionDetail(event, target); switch (detail.action) { case 'primary': diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/context-menu.js b/typo3/sysext/backend/Resources/Public/JavaScript/context-menu.js index 33f13dbce778..cc569ab645a8 100644 --- a/typo3/sysext/backend/Resources/Public/JavaScript/context-menu.js +++ b/typo3/sysext/backend/Resources/Public/JavaScript/context-menu.js @@ -10,4 +10,4 @@ * * The TYPO3 project - inspiring people to share! */ -import AjaxRequest from"@typo3/core/ajax/ajax-request.js";import ContextMenuActions from"@typo3/backend/context-menu-actions.js";import DebounceEvent from"@typo3/core/event/debounce-event.js";import RegularEvent from"@typo3/core/event/regular-event.js";import{selector}from"@typo3/core/literals.js";class ContextMenu{constructor(){this.mousePos={X:null,Y:null},this.record={uid:null,table:null},this.eventSources=[],document.addEventListener("click",(e=>{this.handleTriggerEvent(e)})),document.addEventListener("contextmenu",(e=>{this.handleTriggerEvent(e)}))}static drawActionItem(e){const t=document.createElement("li");t.role="menuitem",t.classList.add("context-menu-item"),t.dataset.callbackAction=e.callbackAction,t.tabIndex=-1;const n=e.additionalAttributes||{};for(const e of Object.entries(n)){const[n,o]=e;t.setAttribute(n,o)}const o=document.createElement("span");o.classList.add("context-menu-item-icon"),o.innerHTML=e.icon;const s=document.createElement("span");return s.classList.add("context-menu-item-label"),s.innerHTML=e.label,t.append(o),t.append(s),t}static within(e,t,n){const o=e.getBoundingClientRect(),s=window.pageXOffset||document.documentElement.scrollLeft,i=window.pageYOffset||document.documentElement.scrollTop,c=t>=o.left+s&&t<=o.left+s+o.width,a=n>=o.top+i&&n<=o.top+i+o.height;return c&&a}show(e,t,n,o,s,i=null,c=null){this.hideAll(),this.record={table:e,uid:t};const a=i.matches('a, button, [tabindex]:not([tabindex="-1"])')?i:i.closest('a, button, [tabindex]:not([tabindex="-1"])');!1===this.eventSources.includes(a)&&this.eventSources.push(a);const r=new URLSearchParams;void 0!==e&&r.set("table",e),void 0!==t&&r.set("uid",t.toString()),void 0!==n&&r.set("context",n);let l=null;null!==c&&(this.storeMousePosition(c),l=this.mousePos),this.fetch(r.toString(),l)}initializeContextMenuContainer(){if(null===document.querySelector("#contentMenu0")){const e=document.createElement("div");e.classList.add("context-menu"),e.id="contentMenu0",e.style.display="none",document.querySelector("body").append(e);const t=document.createElement("div");t.classList.add("context-menu"),t.id="contentMenu1",t.style.display="none",t.dataset.parent="#contentMenu0",document.querySelector("body").append(t),document.querySelectorAll(".context-menu").forEach((e=>{new RegularEvent("mouseenter",(e=>{this.storeMousePosition(e)})).bindTo(e),new DebounceEvent("mouseleave",(e=>{const t=e.target,n=document.querySelector(selector`[data-parent="#${t.id}"]`);if(!ContextMenu.within(t,this.mousePos.X,this.mousePos.Y)&&(null===n||null===n.offsetParent)){let e;this.hide(t),void 0!==t.dataset.parent&&null!==(e=document.querySelector(t.dataset.parent))&&(ContextMenu.within(e,this.mousePos.X,this.mousePos.Y)||this.hide(document.querySelector(t.dataset.parent)))}}),500).bindTo(e)}))}}handleTriggerEvent(e){if(!(e.target instanceof Element))return;const t=e.target.closest("[data-contextmenu-trigger]");if(t instanceof HTMLElement)return void this.handleContextMenuEvent(e,t);e.target.closest(".context-menu")||this.hideAll()}handleContextMenuEvent(e,t){const n=t.dataset.contextmenuTrigger;"click"!==n&&n!==e.type||(e.preventDefault(),this.show(t.dataset.contextmenuTable??"",t.dataset.contextmenuUid??"",t.dataset.contextmenuContext??"","","",t,e))}fetch(e,t){const n=TYPO3.settings.ajaxUrls.contextmenu;new AjaxRequest(n).withQueryArguments(e).get().then((async e=>{const n=await e.resolve();void 0!==e&&Object.keys(e).length>0&&this.populateData(n,0,t)}))}populateData(e,t,n){this.initializeContextMenuContainer();const o=document.querySelector("#contentMenu"+t),s=document.querySelector("#contentMenu"+(t-1));if(null!==o&&null!==s?.offsetParent){const s=document.createElement("ul");s.classList.add("context-menu-group"),s.role="menu",this.drawMenu(e,t).forEach((e=>{s.appendChild(e)})),o.innerHTML="",o.appendChild(s),o.style.display=null,n??(n=this.getPosition(o));const i=this.toPixel(n);o.style.top=i.top,o.style.insetInlineStart=i.start,o.querySelector('.context-menu-item[tabindex="-1"]').focus(),this.initializeEvents(o,t)}}initializeEvents(e,t){e.querySelectorAll("li.context-menu-item").forEach((e=>{e.addEventListener("click",(e=>{e.preventDefault();const n=e.currentTarget;if(n.classList.contains("context-menu-item-submenu"))return void this.openSubmenu(t,n);const{callbackAction:o,callbackModule:s,...i}=n.dataset;n.dataset.callbackModule?import(s+".js").then((({default:e})=>{e[o](this.record.table,this.record.uid,i)})):ContextMenuActions&&"function"==typeof ContextMenuActions[o]?ContextMenuActions[o](this.record.table,this.record.uid,i):console.error("action: "+o+" not found"),this.hideAll()})),e.addEventListener("keydown",(e=>{e.preventDefault();const n=e.target;switch(e.key){case"Down":case"ArrowDown":this.setFocusToNextItem(n);break;case"Up":case"ArrowUp":this.setFocusToPreviousItem(n);break;case"Right":case"ArrowRight":if(!n.classList.contains("context-menu-item-submenu"))return;this.openSubmenu(t,n);break;case"Home":this.setFocusToFirstItem(n);break;case"End":this.setFocusToLastItem(n);break;case"Enter":case"Space":n.click();break;case"Esc":case"Escape":case"Left":case"ArrowLeft":this.hide(n.closest(".context-menu"));break;case"Tab":this.hideAll();break;default:return}}))}))}setFocusToPreviousItem(e){let t=this.getItemBackward(e.previousElementSibling);t||(t=this.getLastItem(e)),t.focus()}setFocusToNextItem(e){let t=this.getItemForward(e.nextElementSibling);t||(t=this.getFirstItem(e)),t.focus()}setFocusToFirstItem(e){const t=this.getFirstItem(e);t&&t.focus()}setFocusToLastItem(e){const t=this.getLastItem(e);t&&t.focus()}getItemBackward(e){for(;e&&(!e.classList.contains("context-menu-item")||"-1"!==e.getAttribute("tabindex"));)e=e.previousElementSibling;return e}getItemForward(e){for(;e&&(!e.classList.contains("context-menu-item")||"-1"!==e.getAttribute("tabindex"));)e=e.nextElementSibling;return e}getFirstItem(e){return this.getItemForward(e.parentElement.firstElementChild)}getLastItem(e){return this.getItemBackward(e.parentElement.lastElementChild)}openSubmenu(e,t){!1===this.eventSources.includes(t)&&this.eventSources.push(t);const n=document.querySelector("#contentMenu"+(e+1));n.innerHTML="",n.appendChild(t.nextElementSibling.querySelector(".context-menu-group").cloneNode(!0)),n.style.display=null;const o=this.toPixel(this.getPosition(n));n.style.top=o.top,n.style.insetInlineStart=o.start,n.querySelector('.context-menu-item[tabindex="-1"]').focus(),this.initializeEvents(n,e)}toPixel(e){return{start:Math.round(e.X)+"px",top:Math.round(e.Y)+"px"}}getPosition(e){const t="rtl"===document.querySelector("html").dir?"rtl":"ltr",n=this.eventSources?.[this.eventSources.length-1],o=e.offsetWidth,s=e.offsetHeight,i=window.innerWidth,c=window.innerHeight;let a=0,r=0;if(null!=n){const e=n.getBoundingClientRect();r=e.y,a="ltr"===t?e.x+e.width:i-e.x,n.classList.contains("context-menu-item-submenu")&&(r-=8)}else r=this.mousePos.Y,a="ltr"===t?this.mousePos.X:i-this.mousePos.X;return r+s+10+5<c?r+=5:r=c-s-10,a+o+10+5<i?a+=5:a=i-o-10,{X:a,Y:r}}drawMenu(e,t){const n=[];for(const o of Object.values(e))if("item"===o.type)n.push(ContextMenu.drawActionItem(o));else if("divider"===o.type){const e=document.createElement("li");e.role="separator",e.classList.add("context-menu-divider"),n.push(e)}else if("submenu"===o.type||o.childItems){const e=document.createElement("li");e.role="menuitem",e.ariaHasPopup="true",e.classList.add("context-menu-item","context-menu-item-submenu"),e.tabIndex=-1;const s=document.createElement("span");s.classList.add("context-menu-item-icon"),s.innerHTML=o.icon,e.appendChild(s);const i=document.createElement("span");i.classList.add("context-menu-item-label"),i.innerHTML=o.label,e.appendChild(i);const c=document.createElement("span");c.classList.add("context-menu-item-indicator"),c.innerHTML='<typo3-backend-icon identifier="actions-chevron-'+("rtl"===document.querySelector("html").dir?"left":"right")+'" size="small"></typo3-backend-icon>',e.appendChild(c),n.push(e);const a=document.createElement("div");a.classList.add("context-menu","contentMenu"+(t+1)),a.style.display="none";const r=document.createElement("ul");r.role="menu",r.classList.add("context-menu-group"),this.drawMenu(o.childItems,1).forEach((e=>{r.appendChild(e)})),a.appendChild(r),n.push(a)}return n}storeMousePosition(e){this.mousePos={X:e.pageX,Y:e.pageY}}hide(e){if(null===e)return;e.style.top=null,e.style.insetInlineStart=null,e.style.display="none";const t=this.eventSources.pop();t&&t.focus()}hideAll(){this.hide(document.querySelector("#contentMenu0")),this.hide(document.querySelector("#contentMenu1"))}}export default new ContextMenu; \ No newline at end of file +import AjaxRequest from"@typo3/core/ajax/ajax-request.js";import ContextMenuActions from"@typo3/backend/context-menu-actions.js";import DebounceEvent from"@typo3/core/event/debounce-event.js";import RegularEvent from"@typo3/core/event/regular-event.js";import{selector}from"@typo3/core/literals.js";import"@typo3/backend/element/spinner-element.js";class ContextMenu{constructor(){this.mousePos={X:null,Y:null},this.record={uid:null,table:null},this.eventSources=[],document.addEventListener("click",(e=>{this.handleTriggerEvent(e)})),document.addEventListener("contextmenu",(e=>{this.handleTriggerEvent(e)}))}static drawActionItem(e){const t=document.createElement("li");t.role="menuitem",t.classList.add("context-menu-item"),t.dataset.callbackAction=e.callbackAction,t.tabIndex=-1;const n=e.additionalAttributes||{};for(const e of Object.entries(n)){const[n,o]=e;t.setAttribute(n,o)}const o=document.createElement("span");o.classList.add("context-menu-item-icon"),o.innerHTML=e.icon;const s=document.createElement("span");return s.classList.add("context-menu-item-label"),s.innerHTML=e.label,t.append(o),t.append(s),t}static within(e,t,n){const o=e.getBoundingClientRect(),s=window.pageXOffset||document.documentElement.scrollLeft,i=window.pageYOffset||document.documentElement.scrollTop,c=t>=o.left+s&&t<=o.left+s+o.width,r=n>=o.top+i&&n<=o.top+i+o.height;return c&&r}show(e,t,n,o,s,i=null,c=null){this.hideAll(),this.initializeContextMenuContainer(),this.record={table:e,uid:t};const r=i.matches('a, button, [tabindex]:not([tabindex="-1"])')?i:i.closest('a, button, [tabindex]:not([tabindex="-1"])');!1===this.eventSources.includes(r)&&this.eventSources.push(r);const a=new URLSearchParams;void 0!==e&&a.set("table",e),void 0!==t&&a.set("uid",t.toString()),void 0!==n&&a.set("context",n);let l=null;null!==c&&(this.storeMousePosition(c),l=this.mousePos),this.fetch(a.toString(),l)}initializeContextMenuContainer(){if(null!==document.querySelector("#contentMenu0"))return;const e=document.createElement("div");e.classList.add("context-menu"),e.id="contentMenu0",e.style.display="none",document.querySelector("body").append(e);const t=document.createElement("div");t.classList.add("context-menu"),t.id="contentMenu1",t.style.display="none",t.dataset.parent="#contentMenu0",document.querySelector("body").append(t),document.querySelectorAll(".context-menu").forEach((e=>{new RegularEvent("mouseenter",(e=>{this.storeMousePosition(e)})).bindTo(e),new DebounceEvent("mouseleave",(e=>{const t=e.target,n=document.querySelector(selector`[data-parent="#${t.id}"]`);if(!ContextMenu.within(t,this.mousePos.X,this.mousePos.Y)&&(null===n||null===n.offsetParent)){let e;this.hide(t),void 0!==t.dataset.parent&&null!==(e=document.querySelector(t.dataset.parent))&&(ContextMenu.within(e,this.mousePos.X,this.mousePos.Y)||this.hide(document.querySelector(t.dataset.parent)))}}),500).bindTo(e)}))}handleTriggerEvent(e){if(!(e.target instanceof Element))return;const t=e.target.closest("[data-contextmenu-trigger]");if(t instanceof HTMLElement)return void this.handleContextMenuEvent(e,t);e.target.closest(".context-menu")||this.hideAll()}handleContextMenuEvent(e,t){const n=t.dataset.contextmenuTrigger;"click"!==n&&n!==e.type||(e.preventDefault(),this.show(t.dataset.contextmenuTable??"",t.dataset.contextmenuUid??"",t.dataset.contextmenuContext??"","","",t,e))}fetch(e,t){const n=this.renderStubMenu(0,t),o=TYPO3.settings.ajaxUrls.contextmenu;new AjaxRequest(o).withQueryArguments(e).get().then((async e=>{const t=await e.resolve();void 0!==e&&Object.keys(e).length>0&&this.populateData(t,0)})).catch((()=>{this.hide(n)}))}renderStubMenu(e,t){const n=document.querySelector("#contentMenu"+e);if(null!==n){n.replaceChildren(document.createRange().createContextualFragment('<typo3-backend-spinner size="medium"></typo3-backend-spinner>')),n.style.display=null,t??(t=this.getPosition(n));const e=this.toPixel(t);n.style.top=e.top,n.style.insetInlineStart=e.start}return n}populateData(e,t){const n=document.querySelector("#contentMenu"+t),o=document.querySelector("#contentMenu"+(t-1));if(null!==n&&null!==o?.offsetParent){const o=document.createElement("ul");o.classList.add("context-menu-group"),o.role="menu",this.drawMenu(e,t).forEach((e=>{o.appendChild(e)})),n.innerHTML="",n.appendChild(o),n.style.display=null,n.querySelector('.context-menu-item[tabindex="-1"]').focus(),this.initializeEvents(n,t)}}initializeEvents(e,t){e.querySelectorAll("li.context-menu-item").forEach((e=>{e.addEventListener("click",(e=>{e.preventDefault();const n=e.currentTarget;if(n.classList.contains("context-menu-item-submenu"))return void this.openSubmenu(t,n);const{callbackAction:o,callbackModule:s,...i}=n.dataset;n.dataset.callbackModule?import(s+".js").then((({default:e})=>{e[o](this.record.table,this.record.uid,i)})):ContextMenuActions&&"function"==typeof ContextMenuActions[o]?ContextMenuActions[o](this.record.table,this.record.uid,i):console.error("action: "+o+" not found"),this.hideAll()})),e.addEventListener("keydown",(e=>{e.preventDefault();const n=e.target;switch(e.key){case"Down":case"ArrowDown":this.setFocusToNextItem(n);break;case"Up":case"ArrowUp":this.setFocusToPreviousItem(n);break;case"Right":case"ArrowRight":if(!n.classList.contains("context-menu-item-submenu"))return;this.openSubmenu(t,n);break;case"Home":this.setFocusToFirstItem(n);break;case"End":this.setFocusToLastItem(n);break;case"Enter":case"Space":n.click();break;case"Esc":case"Escape":case"Left":case"ArrowLeft":this.hide(n.closest(".context-menu"));break;case"Tab":this.hideAll();break;default:return}}))}))}setFocusToPreviousItem(e){let t=this.getItemBackward(e.previousElementSibling);t||(t=this.getLastItem(e)),t.focus()}setFocusToNextItem(e){let t=this.getItemForward(e.nextElementSibling);t||(t=this.getFirstItem(e)),t.focus()}setFocusToFirstItem(e){const t=this.getFirstItem(e);t&&t.focus()}setFocusToLastItem(e){const t=this.getLastItem(e);t&&t.focus()}getItemBackward(e){for(;e&&(!e.classList.contains("context-menu-item")||"-1"!==e.getAttribute("tabindex"));)e=e.previousElementSibling;return e}getItemForward(e){for(;e&&(!e.classList.contains("context-menu-item")||"-1"!==e.getAttribute("tabindex"));)e=e.nextElementSibling;return e}getFirstItem(e){return this.getItemForward(e.parentElement.firstElementChild)}getLastItem(e){return this.getItemBackward(e.parentElement.lastElementChild)}openSubmenu(e,t){!1===this.eventSources.includes(t)&&this.eventSources.push(t);const n=document.querySelector("#contentMenu"+(e+1));n.innerHTML="",n.appendChild(t.nextElementSibling.querySelector(".context-menu-group").cloneNode(!0)),n.style.display=null;const o=this.toPixel(this.getPosition(n));n.style.top=o.top,n.style.insetInlineStart=o.start,n.querySelector('.context-menu-item[tabindex="-1"]').focus(),this.initializeEvents(n,e)}toPixel(e){return{start:Math.round(e.X)+"px",top:Math.round(e.Y)+"px"}}getPosition(e){const t="rtl"===document.querySelector("html").dir?"rtl":"ltr",n=this.eventSources?.[this.eventSources.length-1],o=e.offsetWidth,s=e.offsetHeight,i=window.innerWidth,c=window.innerHeight;let r=0,a=0;if(null!=n){const e=n.getBoundingClientRect();a=e.y,r="ltr"===t?e.x+e.width:i-e.x,n.classList.contains("context-menu-item-submenu")&&(a-=8)}else a=this.mousePos.Y,r="ltr"===t?this.mousePos.X:i-this.mousePos.X;return a+s+10+5<c?a+=5:a=c-s-10,r+o+10+5<i?r+=5:r=i-o-10,{X:r,Y:a}}drawMenu(e,t){const n=[];for(const o of Object.values(e))if("item"===o.type)n.push(ContextMenu.drawActionItem(o));else if("divider"===o.type){const e=document.createElement("li");e.role="separator",e.classList.add("context-menu-divider"),n.push(e)}else if("submenu"===o.type||o.childItems){const e=document.createElement("li");e.role="menuitem",e.ariaHasPopup="true",e.classList.add("context-menu-item","context-menu-item-submenu"),e.tabIndex=-1;const s=document.createElement("span");s.classList.add("context-menu-item-icon"),s.innerHTML=o.icon,e.appendChild(s);const i=document.createElement("span");i.classList.add("context-menu-item-label"),i.innerHTML=o.label,e.appendChild(i);const c=document.createElement("span");c.classList.add("context-menu-item-indicator"),c.innerHTML='<typo3-backend-icon identifier="actions-chevron-'+("rtl"===document.querySelector("html").dir?"left":"right")+'" size="small"></typo3-backend-icon>',e.appendChild(c),n.push(e);const r=document.createElement("div");r.classList.add("context-menu","contentMenu"+(t+1)),r.style.display="none";const a=document.createElement("ul");a.role="menu",a.classList.add("context-menu-group"),this.drawMenu(o.childItems,1).forEach((e=>{a.appendChild(e)})),r.appendChild(a),n.push(r)}return n}storeMousePosition(e){this.mousePos={X:e.pageX,Y:e.pageY}}hide(e){if(null===e)return;e.style.top=null,e.style.insetInlineStart=null,e.style.display="none";const t=this.eventSources.pop();t&&t.focus()}hideAll(){this.hide(document.querySelector("#contentMenu0")),this.hide(document.querySelector("#contentMenu1"))}}export default new ContextMenu; \ No newline at end of file diff --git a/typo3/sysext/filelist/Resources/Public/JavaScript/file-list-actions.js b/typo3/sysext/filelist/Resources/Public/JavaScript/file-list-actions.js index 32ffbc347d29..ff9aa17bb2e5 100644 --- a/typo3/sysext/filelist/Resources/Public/JavaScript/file-list-actions.js +++ b/typo3/sysext/filelist/Resources/Public/JavaScript/file-list-actions.js @@ -10,4 +10,4 @@ * * The TYPO3 project - inspiring people to share! */ -import RegularEvent from"@typo3/core/event/regular-event.js";export var FileListActionEvent;!function(e){e.primary="typo3:filelist:resource:action:primary",e.primaryContextmenu="typo3:filelist:resource:action:primaryContextmenu",e.show="typo3:filelist:resource:action:show",e.rename="typo3:filelist:resource:action:rename",e.select="typo3:filelist:resource:action:select",e.download="typo3:filelist:resource:action:download",e.updateOnlineMedia="typo3:filelist:resource:action:updateOnlineMedia"}(FileListActionEvent||(FileListActionEvent={}));export var FileListActionSelector;!function(e){e.elementSelector="[data-filelist-element]",e.actionSelector="[data-filelist-action]"}(FileListActionSelector||(FileListActionSelector={}));export class FileListActionUtility{static createResourceFromContextDataset(e){return{type:e.filecontextType,identifier:e.filecontextIdentifier,name:e.filecontextName,thumbnail:null,uid:e.filecontextUid?parseInt(e.filecontextUid,10):null,metaUid:e.filecontextMetaUid?parseInt(e.filecontextMetaUid,10):null}}static getResourceForElement(e){return{type:e.dataset.filelistType,identifier:e.dataset.filelistIdentifier,name:e.dataset.filelistName,thumbnail:"filelistThumbnail"in e.dataset&&""!==e.dataset.filelistThumbnail.trim()?e.dataset.filelistThumbnail:null,uid:e.dataset.filelistUid?parseInt(e.dataset.filelistUid,10):null,metaUid:e.dataset.filelistMetaUid?parseInt(e.dataset.filelistMetaUid,10):null}}}class FileListActions{constructor(){new RegularEvent("contextmenu",((e,t)=>{e.preventDefault();const i=this.getActionDetail(e,t);if("primary"===i.action)document.dispatchEvent(new CustomEvent(FileListActionEvent.primaryContextmenu,{detail:i}))})).delegateTo(document,FileListActionSelector.actionSelector),new RegularEvent("click",((e,t)=>{e.preventDefault();const i=this.getActionDetail(e,t);switch(i.action){case"primary":document.dispatchEvent(new CustomEvent(FileListActionEvent.primary,{detail:i}));break;case"show":document.dispatchEvent(new CustomEvent(FileListActionEvent.show,{detail:i}));break;case"select":document.dispatchEvent(new CustomEvent(FileListActionEvent.select,{detail:i}));break;case"rename":document.dispatchEvent(new CustomEvent(FileListActionEvent.rename,{detail:i}));break;case"download":document.dispatchEvent(new CustomEvent(FileListActionEvent.download,{detail:i}));break;case"updateOnlineMedia":document.dispatchEvent(new CustomEvent(FileListActionEvent.updateOnlineMedia,{detail:i}))}})).delegateTo(document,FileListActionSelector.actionSelector)}getActionDetail(e,t){const i=t.dataset.filelistAction,n=t.closest(FileListActionSelector.elementSelector);return{event:e,trigger:t,action:i,resources:[FileListActionUtility.getResourceForElement(n)],url:t.dataset.filelistActionUrl??null}}}export default new FileListActions; \ No newline at end of file +import RegularEvent from"@typo3/core/event/regular-event.js";export var FileListActionEvent;!function(e){e.primary="typo3:filelist:resource:action:primary",e.primaryContextmenu="typo3:filelist:resource:action:primaryContextmenu",e.show="typo3:filelist:resource:action:show",e.rename="typo3:filelist:resource:action:rename",e.select="typo3:filelist:resource:action:select",e.download="typo3:filelist:resource:action:download",e.updateOnlineMedia="typo3:filelist:resource:action:updateOnlineMedia"}(FileListActionEvent||(FileListActionEvent={}));export var FileListActionSelector;!function(e){e.elementSelector="[data-filelist-element]",e.actionSelector="[data-filelist-action]"}(FileListActionSelector||(FileListActionSelector={}));export class FileListActionUtility{static createResourceFromContextDataset(e){return{type:e.filecontextType,identifier:e.filecontextIdentifier,name:e.filecontextName,thumbnail:null,uid:e.filecontextUid?parseInt(e.filecontextUid,10):null,metaUid:e.filecontextMetaUid?parseInt(e.filecontextMetaUid,10):null}}static getResourceForElement(e){return{type:e.dataset.filelistType,identifier:e.dataset.filelistIdentifier,name:e.dataset.filelistName,thumbnail:"filelistThumbnail"in e.dataset&&""!==e.dataset.filelistThumbnail.trim()?e.dataset.filelistThumbnail:null,uid:e.dataset.filelistUid?parseInt(e.dataset.filelistUid,10):null,metaUid:e.dataset.filelistMetaUid?parseInt(e.dataset.filelistMetaUid,10):null}}}class FileListActions{constructor(){new RegularEvent("contextmenu",((e,t)=>{e.preventDefault(),e.stopImmediatePropagation();const i=this.getActionDetail(e,t);if("primary"===i.action)document.dispatchEvent(new CustomEvent(FileListActionEvent.primaryContextmenu,{detail:i}))})).delegateTo(document,FileListActionSelector.actionSelector),new RegularEvent("click",((e,t)=>{e.preventDefault();const i=this.getActionDetail(e,t);switch(i.action){case"primary":document.dispatchEvent(new CustomEvent(FileListActionEvent.primary,{detail:i}));break;case"show":document.dispatchEvent(new CustomEvent(FileListActionEvent.show,{detail:i}));break;case"select":document.dispatchEvent(new CustomEvent(FileListActionEvent.select,{detail:i}));break;case"rename":document.dispatchEvent(new CustomEvent(FileListActionEvent.rename,{detail:i}));break;case"download":document.dispatchEvent(new CustomEvent(FileListActionEvent.download,{detail:i}));break;case"updateOnlineMedia":document.dispatchEvent(new CustomEvent(FileListActionEvent.updateOnlineMedia,{detail:i}))}})).delegateTo(document,FileListActionSelector.actionSelector)}getActionDetail(e,t){const i=t.dataset.filelistAction,n=t.closest(FileListActionSelector.elementSelector);return{event:e,trigger:t,action:i,resources:[FileListActionUtility.getResourceForElement(n)],url:t.dataset.filelistActionUrl??null}}}export default new FileListActions; \ No newline at end of file -- GitLab