From a6cb7cb848e046b8cbbf83252132e718627b6cc6 Mon Sep 17 00:00:00 2001
From: Oliver Bartsch <bo@cedev.de>
Date: Fri, 20 Aug 2021 17:20:44 +0200
Subject: [PATCH] [FEATURE] Add keyboard shortcuts for multi record selection

The multi record selection, introduced in #94906,
is extended for keyboard shortcuts. They can be
used to achieve similar results as by using the
actions in the multi record selection dropdown.

* `shift` key: check / uncheck the range between the
  last checked checkbox and the current one

* `option` (Mac) or `ctrl` (Windows/Linux) key: Toggle
  the current selection

Resolves: #94944
Related: #94906
Releases: master
Change-Id: Ia5f6625e6b380d908bddfbf5849f2ea6233fe420
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/70688
Tested-by: core-ci <typo3@b13.com>
Tested-by: Benni Mack <benni@typo3.org>
Tested-by: Jochen <rothjochen@gmail.com>
Tested-by: Mathias Brodala <mbrodala@pagemachine.de>
Tested-by: Oliver Bartsch <bo@cedev.de>
Reviewed-by: Benni Mack <benni@typo3.org>
Reviewed-by: Jochen <rothjochen@gmail.com>
Reviewed-by: Oliver Bartsch <bo@cedev.de>
---
 .../Public/TypeScript/MultiRecordSelection.ts | 46 +++++++++++++++++++
 .../Public/JavaScript/MultiRecordSelection.js |  2 +-
 ...yboardShortcutsForMultiRecordSelection.rst | 32 +++++++++++++
 3 files changed, 79 insertions(+), 1 deletion(-)
 create mode 100644 typo3/sysext/core/Documentation/Changelog/master/Feature-94944-KeyboardShortcutsForMultiRecordSelection.rst

diff --git a/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/MultiRecordSelection.ts b/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/MultiRecordSelection.ts
index a636382a3554..b4f44efd6835 100644
--- a/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/MultiRecordSelection.ts
+++ b/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/MultiRecordSelection.ts
@@ -56,6 +56,8 @@ interface EditActionConfiguration extends ActionConfiguration{
  * Module: TYPO3/CMS/Backend/MultiRecordSelection
  */
 class MultiRecordSelection {
+  private lastChecked: HTMLInputElement = null;
+
   private static getCheckboxes(state: CheckboxState = CheckboxState.any): NodeListOf<HTMLInputElement> {
     return document.querySelectorAll(Selectors.checkboxSelector + state);
   }
@@ -167,6 +169,7 @@ class MultiRecordSelection {
       this.registerActions();
       this.registerActionsEventHandlers();
       this.registerCheckboxActions();
+      this.registerCheckboxKeyboardActions();
       this.registerToggleCheckboxActions();
       this.registerDispatchCheckboxStateChangedEvent();
       this.registerCheckboxStateChangedEventHandler();
@@ -276,6 +279,49 @@ class MultiRecordSelection {
     }).delegateTo(document, [Selectors.checkboxActionsSelector, Buttons.checkboxActionButton].join(' '));
   }
 
+  private registerCheckboxKeyboardActions(): void {
+    new RegularEvent('click', (e: PointerEvent, target: HTMLInputElement): void => {
+      // If lastChecked is not set or does no longer exist in visible DOM (e.g. because the list is
+      // paginated and lastChecked is on a prev/next page), assign the current target and return.
+      if (!this.lastChecked || !document.body.contains(this.lastChecked)) {
+        this.lastChecked = target;
+        return;
+      }
+
+      // With the shift key, it's possible to check / uncheck a range of checkboxes
+      if (e.shiftKey) {
+        // To easily calculate the start and end position we need checkboxes as an array
+        const checkboxes: Array<HTMLInputElement> = Array.from(document.querySelectorAll(Selectors.checkboxSelector));
+        // The current target is the start position
+        const start = checkboxes.indexOf(target);
+        // The last manually clicked / checked checkbox is the end
+        const end = checkboxes.indexOf(this.lastChecked);
+        // Get the checkboxes which should be changed (we use min() and max() to allow ranges in both directions)
+        const checkboxesToChange = checkboxes.slice(Math.min(start, end), Math.max(start, end) + 1);
+        checkboxesToChange.forEach((checkbox: HTMLInputElement): void => {
+          // Change the state of each checkbox in question. Do not change the current target since we
+          // use it's current checked state, making both "check all" and "uncheck all" possible.
+          if (checkbox !== target) {
+            MultiRecordSelection.changeCheckboxState(checkbox, target.checked);
+          }
+        });
+      }
+
+      // We can now store the current target as lastChecked so it can be used in the next run
+      this.lastChecked = target;
+
+      // With the alt or ctrl key, it's possible to toggle the current selection
+      if (e.altKey || e.ctrlKey) {
+        document.querySelectorAll(Selectors.checkboxSelector).forEach((checkbox: HTMLInputElement): void => {
+          // Toggle all checkboxes except the current target as this was already done by clicking on it
+          if (checkbox !== target) {
+            MultiRecordSelection.changeCheckboxState(checkbox, !checkbox.checked);
+          }
+        })
+      }
+    }).delegateTo(document, Selectors.checkboxSelector);
+  }
+
   private registerDispatchCheckboxStateChangedEvent(): void {
     new RegularEvent('change', (e: Event, target: HTMLInputElement): void => {
       target.dispatchEvent(new Event('checkbox:state:changed', {bubbles: true, cancelable: false}));
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/MultiRecordSelection.js b/typo3/sysext/backend/Resources/Public/JavaScript/MultiRecordSelection.js
index 689be3c37b15..e76c9d28c023 100644
--- a/typo3/sysext/backend/Resources/Public/JavaScript/MultiRecordSelection.js
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/MultiRecordSelection.js
@@ -10,4 +10,4 @@
  *
  * The TYPO3 project - inspiring people to share!
  */
-define(["require","exports","TYPO3/CMS/Backend/Notification","TYPO3/CMS/Core/DocumentService","TYPO3/CMS/Core/Event/RegularEvent"],(function(e,t,c,o,n){"use strict";var i,a,s,l,r;!function(e){e.actionsSelector=".t3js-multi-record-selection-actions",e.checkboxSelector=".t3js-multi-record-selection-check",e.checkboxActionsSelector="#multi-record-selection-check-actions"}(i||(i={})),function(e){e.actionButton="button[data-multi-record-selection-action]",e.checkboxActionButton="button[data-multi-record-selection-check-action]",e.checkboxActionsToggleButton='button[data-bs-target="multi-record-selection-check-actions"]'}(a||(a={})),function(e){e.edit="edit"}(s||(s={})),function(e){e.checkAll="check-all",e.checkNone="check-none",e.toggle="toggle"}(l||(l={})),function(e){e.any="",e.checked=":checked",e.unchecked=":not(:checked)"}(r||(r={}));class d{static getCheckboxes(e=r.any){return document.querySelectorAll(i.checkboxSelector+e)}static changeCheckboxState(e,t){e.checked===t||e.dataset.manuallyChanged||(e.checked=t,e.dispatchEvent(new Event("checkbox:state:changed",{bubbles:!0,cancelable:!1})))}static getReturnUrl(e){return""===e&&(e=top.list_frame.document.location.pathname+top.list_frame.document.location.search),encodeURIComponent(e)}static restoreTemporaryState(){const e=d.getCheckboxes(r.checked);if(!e.length)return;e.forEach(e=>{e.closest("tr").classList.add("success")});const t=document.querySelectorAll(i.actionsSelector);t.length&&t.forEach(e=>e.classList.remove("hidden"))}static toggleActionsState(){const e=document.querySelectorAll(i.actionsSelector);if(!e.length)return;if(!d.getCheckboxes(r.checked).length)return void e.forEach(e=>e.classList.add("hidden"));e.forEach(e=>e.classList.remove("hidden"));const t=document.querySelectorAll([i.actionsSelector,a.actionButton].join(" "));t.length&&t.forEach(e=>{if(!e.dataset.multiRecordSelectionActionConfig)return;const t=JSON.parse(e.dataset.multiRecordSelectionActionConfig);if(!t.idField)return;e.classList.add("disabled");const c=d.getCheckboxes(r.checked);for(let o=0;o<c.length;o++)if(c[o].closest("tr").dataset[t.idField]){e.classList.remove("disabled");break}})}static unsetManuallyChangedAttribute(){d.getCheckboxes().forEach(e=>{e.removeAttribute("data-manually-changed")})}constructor(){o.ready().then(()=>{d.restoreTemporaryState(),this.registerActions(),this.registerActionsEventHandlers(),this.registerCheckboxActions(),this.registerToggleCheckboxActions(),this.registerDispatchCheckboxStateChangedEvent(),this.registerCheckboxStateChangedEventHandler()})}registerActions(){new n("click",(e,t)=>{const o=d.getCheckboxes(r.checked);if(t.dataset.multiRecordSelectionAction&&o.length)switch(t.dataset.multiRecordSelectionAction){case s.edit:e.preventDefault();const n=JSON.parse(t.dataset.multiRecordSelectionActionConfig||"");if(!n||!n.idField||!n.table)break;const i=[];o.forEach(e=>{const t=e.closest("tr");null!==t&&t.dataset[n.idField]&&i.push(t.dataset[n.idField])}),i.length?window.location.href=top.TYPO3.settings.FormEngine.moduleUrl+"&edit["+n.table+"]["+i.join(",")+"]=edit&returnUrl="+d.getReturnUrl(n.returnUrl||""):c.warning("The selected elements can not be edited.");break;default:t.dispatchEvent(new Event("multiRecordSelection:action:"+t.dataset.multiRecordSelectionAction,{bubbles:!0,cancelable:!1}))}}).delegateTo(document,[i.actionsSelector,a.actionButton].join(" ")),d.toggleActionsState()}registerActionsEventHandlers(){new n("multiRecordSelection:actions:show",()=>{const e=document.querySelectorAll(i.actionsSelector);e&&e.forEach(e=>e.classList.remove("hidden"))}).bindTo(document),new n("multiRecordSelection:actions:hide",()=>{const e=document.querySelectorAll(i.actionsSelector);e&&e.forEach(e=>e.classList.add("hidden"))}).bindTo(document)}registerCheckboxActions(){new n("click",(e,t)=>{e.preventDefault();const o=d.getCheckboxes();if(t.dataset.multiRecordSelectionCheckAction&&o.length){switch(d.unsetManuallyChangedAttribute(),t.dataset.multiRecordSelectionCheckAction){case l.checkAll:o.forEach(e=>{d.changeCheckboxState(e,!0)});break;case l.checkNone:o.forEach(e=>{d.changeCheckboxState(e,!1)});break;case l.toggle:o.forEach(e=>{d.changeCheckboxState(e,!e.checked)});break;default:c.warning("Unknown checkbox action")}d.unsetManuallyChangedAttribute()}}).delegateTo(document,[i.checkboxActionsSelector,a.checkboxActionButton].join(" "))}registerDispatchCheckboxStateChangedEvent(){new n("change",(e,t)=>{t.dispatchEvent(new Event("checkbox:state:changed",{bubbles:!0,cancelable:!1}))}).delegateTo(document,i.checkboxSelector)}registerCheckboxStateChangedEventHandler(){new n("checkbox:state:changed",e=>{const t=e.target;t.checked?t.closest("tr").classList.add("success"):t.closest("tr").classList.remove("success"),d.toggleActionsState()}).bindTo(document)}registerToggleCheckboxActions(){new n("click",()=>{const e=document.querySelector('button[data-multi-record-selection-check-action="'+l.checkAll+'"]');null!==e&&e.classList.toggle("disabled",!d.getCheckboxes(r.unchecked).length);const t=document.querySelector('button[data-multi-record-selection-check-action="'+l.checkNone+'"]');null!==t&&t.classList.toggle("disabled",!d.getCheckboxes(r.checked).length)}).delegateTo(document,a.checkboxActionsToggleButton)}}return new d}));
\ No newline at end of file
+define(["require","exports","TYPO3/CMS/Backend/Notification","TYPO3/CMS/Core/DocumentService","TYPO3/CMS/Core/Event/RegularEvent"],(function(e,t,c,o,n){"use strict";var i,a,s,l,r;!function(e){e.actionsSelector=".t3js-multi-record-selection-actions",e.checkboxSelector=".t3js-multi-record-selection-check",e.checkboxActionsSelector="#multi-record-selection-check-actions"}(i||(i={})),function(e){e.actionButton="button[data-multi-record-selection-action]",e.checkboxActionButton="button[data-multi-record-selection-check-action]",e.checkboxActionsToggleButton='button[data-bs-target="multi-record-selection-check-actions"]'}(a||(a={})),function(e){e.edit="edit"}(s||(s={})),function(e){e.checkAll="check-all",e.checkNone="check-none",e.toggle="toggle"}(l||(l={})),function(e){e.any="",e.checked=":checked",e.unchecked=":not(:checked)"}(r||(r={}));class d{constructor(){this.lastChecked=null,o.ready().then(()=>{d.restoreTemporaryState(),this.registerActions(),this.registerActionsEventHandlers(),this.registerCheckboxActions(),this.registerCheckboxKeyboardActions(),this.registerToggleCheckboxActions(),this.registerDispatchCheckboxStateChangedEvent(),this.registerCheckboxStateChangedEventHandler()})}static getCheckboxes(e=r.any){return document.querySelectorAll(i.checkboxSelector+e)}static changeCheckboxState(e,t){e.checked===t||e.dataset.manuallyChanged||(e.checked=t,e.dispatchEvent(new Event("checkbox:state:changed",{bubbles:!0,cancelable:!1})))}static getReturnUrl(e){return""===e&&(e=top.list_frame.document.location.pathname+top.list_frame.document.location.search),encodeURIComponent(e)}static restoreTemporaryState(){const e=d.getCheckboxes(r.checked);if(!e.length)return;e.forEach(e=>{e.closest("tr").classList.add("success")});const t=document.querySelectorAll(i.actionsSelector);t.length&&t.forEach(e=>e.classList.remove("hidden"))}static toggleActionsState(){const e=document.querySelectorAll(i.actionsSelector);if(!e.length)return;if(!d.getCheckboxes(r.checked).length)return void e.forEach(e=>e.classList.add("hidden"));e.forEach(e=>e.classList.remove("hidden"));const t=document.querySelectorAll([i.actionsSelector,a.actionButton].join(" "));t.length&&t.forEach(e=>{if(!e.dataset.multiRecordSelectionActionConfig)return;const t=JSON.parse(e.dataset.multiRecordSelectionActionConfig);if(!t.idField)return;e.classList.add("disabled");const c=d.getCheckboxes(r.checked);for(let o=0;o<c.length;o++)if(c[o].closest("tr").dataset[t.idField]){e.classList.remove("disabled");break}})}static unsetManuallyChangedAttribute(){d.getCheckboxes().forEach(e=>{e.removeAttribute("data-manually-changed")})}registerActions(){new n("click",(e,t)=>{const o=d.getCheckboxes(r.checked);if(t.dataset.multiRecordSelectionAction&&o.length)switch(t.dataset.multiRecordSelectionAction){case s.edit:e.preventDefault();const n=JSON.parse(t.dataset.multiRecordSelectionActionConfig||"");if(!n||!n.idField||!n.table)break;const i=[];o.forEach(e=>{const t=e.closest("tr");null!==t&&t.dataset[n.idField]&&i.push(t.dataset[n.idField])}),i.length?window.location.href=top.TYPO3.settings.FormEngine.moduleUrl+"&edit["+n.table+"]["+i.join(",")+"]=edit&returnUrl="+d.getReturnUrl(n.returnUrl||""):c.warning("The selected elements can not be edited.");break;default:t.dispatchEvent(new Event("multiRecordSelection:action:"+t.dataset.multiRecordSelectionAction,{bubbles:!0,cancelable:!1}))}}).delegateTo(document,[i.actionsSelector,a.actionButton].join(" ")),d.toggleActionsState()}registerActionsEventHandlers(){new n("multiRecordSelection:actions:show",()=>{const e=document.querySelectorAll(i.actionsSelector);e&&e.forEach(e=>e.classList.remove("hidden"))}).bindTo(document),new n("multiRecordSelection:actions:hide",()=>{const e=document.querySelectorAll(i.actionsSelector);e&&e.forEach(e=>e.classList.add("hidden"))}).bindTo(document)}registerCheckboxActions(){new n("click",(e,t)=>{e.preventDefault();const o=d.getCheckboxes();if(t.dataset.multiRecordSelectionCheckAction&&o.length){switch(d.unsetManuallyChangedAttribute(),t.dataset.multiRecordSelectionCheckAction){case l.checkAll:o.forEach(e=>{d.changeCheckboxState(e,!0)});break;case l.checkNone:o.forEach(e=>{d.changeCheckboxState(e,!1)});break;case l.toggle:o.forEach(e=>{d.changeCheckboxState(e,!e.checked)});break;default:c.warning("Unknown checkbox action")}d.unsetManuallyChangedAttribute()}}).delegateTo(document,[i.checkboxActionsSelector,a.checkboxActionButton].join(" "))}registerCheckboxKeyboardActions(){new n("click",(e,t)=>{if(this.lastChecked&&document.body.contains(this.lastChecked)){if(e.shiftKey){const e=Array.from(document.querySelectorAll(i.checkboxSelector)),c=e.indexOf(t),o=e.indexOf(this.lastChecked);e.slice(Math.min(c,o),Math.max(c,o)+1).forEach(e=>{e!==t&&d.changeCheckboxState(e,t.checked)})}this.lastChecked=t,(e.altKey||e.ctrlKey)&&document.querySelectorAll(i.checkboxSelector).forEach(e=>{e!==t&&d.changeCheckboxState(e,!e.checked)})}else this.lastChecked=t}).delegateTo(document,i.checkboxSelector)}registerDispatchCheckboxStateChangedEvent(){new n("change",(e,t)=>{t.dispatchEvent(new Event("checkbox:state:changed",{bubbles:!0,cancelable:!1}))}).delegateTo(document,i.checkboxSelector)}registerCheckboxStateChangedEventHandler(){new n("checkbox:state:changed",e=>{const t=e.target;t.checked?t.closest("tr").classList.add("success"):t.closest("tr").classList.remove("success"),d.toggleActionsState()}).bindTo(document)}registerToggleCheckboxActions(){new n("click",()=>{const e=document.querySelector('button[data-multi-record-selection-check-action="'+l.checkAll+'"]');null!==e&&e.classList.toggle("disabled",!d.getCheckboxes(r.unchecked).length);const t=document.querySelector('button[data-multi-record-selection-check-action="'+l.checkNone+'"]');null!==t&&t.classList.toggle("disabled",!d.getCheckboxes(r.checked).length)}).delegateTo(document,a.checkboxActionsToggleButton)}}return new d}));
\ No newline at end of file
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-94944-KeyboardShortcutsForMultiRecordSelection.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-94944-KeyboardShortcutsForMultiRecordSelection.rst
new file mode 100644
index 000000000000..8b21464c6577
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-94944-KeyboardShortcutsForMultiRecordSelection.rst
@@ -0,0 +1,32 @@
+.. include:: ../../Includes.txt
+
+===============================================================
+Feature: #94944 - Keyboard shortcuts for multi record selection
+===============================================================
+
+See :issue:`94944`
+
+Description
+===========
+
+To further increase the usability in the Backend, the multi record selection,
+introduced with :issue:`94906`, has been extended for keyboard shortcuts.
+
+The shortcuts can be used in every module, which implements the multi record
+selection. You can recognize this by the dropdown menu in the first header
+column of the record listing.
+
+In such module, when clicking on a checkbox while holding the
+
+* `shift` key: All records in the range of the last clicked checkbox and the current one are checked / unchecked
+
+* `option` (mac) or `ctrl` (windows / linux) key: The current selection is toggled (inverted)
+
+
+Impact
+======
+
+The multi record selection now also features keyboard shortcuts to further
+increase the usability of this component.
+
+.. index:: Backend, ext:backend
-- 
GitLab