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