Merge pull request #62482 from nextcloud/fix/multiple-file-selection-by-keyboard

fix: multiple file selection on tab navigation
pull/54488/merge
Ferdinand Thiessen 1 week ago committed by GitHub
commit e161c8a99e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 98
      apps/files/src/components/FilesListTableHeader.vue
  2. 33
      apps/files/src/components/FilesListTableHeaderActions.vue
  3. 70
      apps/files/src/components/FilesListVirtual.vue
  4. 5
      apps/files/src/components/VirtualList.vue
  5. 4
      dist/files-main.js
  6. 2
      dist/files-main.js.map

@ -3,23 +3,39 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<tr class="files-list__row-head">
<tr
class="files-list__row-head"
:class="{ 'files-list__row-head--selected': !isNoneSelected }">
<th
class="files-list__column files-list__row-checkbox"
@keyup.esc.exact="resetSelection">
<NcCheckboxRadioSwitch
v-bind="selectAllBind"
:id="FILES_LIST_HEADER_SELECT_ALL_CHECKBOX_ID"
data-cy-files-list-selection-checkbox
@update:model-value="onToggleAll" />
<!-- Batch selection UI lives in the select-all cell so column headers
stay valid and Tab order stays in document flow
(select-all actions rows). -->
<template v-if="!isNoneSelected">
<span class="files-list__selected">
{{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }}
</span>
<FilesListTableHeaderActions
:current-view="currentView"
:selected-nodes="selectedNodes" />
</template>
</th>
<!-- Columns display -->
<!-- Columns display (always present so headers match body cells;
visually hidden while a selection is active) -->
<!-- Link to file -->
<th
class="files-list__column files-list__row-name files-list__column--sortable"
:aria-sort="ariaSortForMode('basename')">
:class="{ 'hidden-visually': !isNoneSelected }"
:aria-sort="ariaSortForMode('basename')"
:inert="!isNoneSelected">
<!-- Icon or preview -->
<span class="files-list__row-icon" />
@ -28,7 +44,10 @@
</th>
<!-- Actions -->
<th class="files-list__row-actions">
<th
class="files-list__row-actions"
:class="{ 'hidden-visually': !isNoneSelected }"
:inert="!isNoneSelected">
<span class="hidden-visually">
{{ t('files', 'Actions') }}
</span>
@ -38,8 +57,12 @@
<th
v-if="isMimeAvailable"
class="files-list__column files-list__row-mime"
:class="{ 'files-list__column--sortable': isMimeAvailable }"
:aria-sort="ariaSortForMode('mime')">
:class="{
'files-list__column--sortable': isMimeAvailable,
'hidden-visually': !isNoneSelected,
}"
:aria-sort="ariaSortForMode('mime')"
:inert="!isNoneSelected">
<FilesListTableHeaderButton :name="t('files', 'File type')" mode="mime" />
</th>
@ -47,8 +70,12 @@
<th
v-if="isSizeAvailable"
class="files-list__column files-list__row-size"
:class="{ 'files-list__column--sortable': isSizeAvailable }"
:aria-sort="ariaSortForMode('size')">
:class="{
'files-list__column--sortable': isSizeAvailable,
'hidden-visually': !isNoneSelected,
}"
:aria-sort="ariaSortForMode('size')"
:inert="!isNoneSelected">
<FilesListTableHeaderButton :name="t('files', 'Size')" mode="size" />
</th>
@ -56,8 +83,12 @@
<th
v-if="isMtimeAvailable"
class="files-list__column files-list__row-mtime"
:class="{ 'files-list__column--sortable': isMtimeAvailable }"
:aria-sort="ariaSortForMode('mtime')">
:class="{
'files-list__column--sortable': isMtimeAvailable,
'hidden-visually': !isNoneSelected,
}"
:aria-sort="ariaSortForMode('mtime')"
:inert="!isNoneSelected">
<FilesListTableHeaderButton :name="t('files', 'Modified')" mode="mtime" />
</th>
@ -66,7 +97,8 @@
v-for="column in columns"
:key="column.id"
:class="classForColumn(column)"
:aria-sort="ariaSortForMode(column.id)">
:aria-sort="ariaSortForMode(column.id)"
:inert="!isNoneSelected">
<FilesListTableHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" />
<span v-else>
{{ column.title }}
@ -76,15 +108,15 @@
</template>
<script lang="ts">
import type { Node } from '@nextcloud/files'
import type { Node, View } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { FileSource } from '../types.ts'
import { t } from '@nextcloud/l10n'
import { n, t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { defineComponent } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import { FILE_LIST_HEAD_FIRST_BATCH_ACTION_ID } from './FilesListTableHeaderActions.vue'
import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
@ -94,12 +126,11 @@ import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
import { logger } from '../utils/logger.ts'
export const FILES_LIST_HEADER_SELECT_ALL_CHECKBOX_ID = 'files-list-header-select-all-checkbox'
export default defineComponent({
name: 'FilesListTableHeader',
components: {
FilesListTableHeaderActions,
FilesListTableHeaderButton,
NcCheckboxRadioSwitch,
},
@ -109,6 +140,11 @@ export default defineComponent({
],
props: {
currentView: {
type: Object as PropType<View>,
required: true,
},
isMimeAvailable: {
type: Boolean,
default: false,
@ -145,8 +181,6 @@ export default defineComponent({
directory,
isNarrow,
FILES_LIST_HEADER_SELECT_ALL_CHECKBOX_ID,
}
},
@ -206,11 +240,6 @@ export default defineComponent({
})
},
mounted() {
const selectAllCheckbox = document.getElementById(FILES_LIST_HEADER_SELECT_ALL_CHECKBOX_ID)
selectAllCheckbox?.addEventListener('keydown', this.onSelectAllCheckboxFocusOut)
},
methods: {
ariaSortForMode(mode: string): 'ascending' | 'descending' | undefined {
if (this.sortingMode === mode) {
@ -224,6 +253,7 @@ export default defineComponent({
'files-list__column--sortable': !!column.sort,
'files-list__row-column-custom': true,
[`files-list__row-${this.activeStore.activeView?.id}-${column.id}`]: true,
'hidden-visually': !this.isNoneSelected,
}
},
@ -246,18 +276,7 @@ export default defineComponent({
this.selectionStore.reset()
},
onSelectAllCheckboxFocusOut(event: KeyboardEvent) {
// If the user tabbed further and we have a batch action to tab to
const firstBatchActionButton = document.getElementById(FILE_LIST_HEAD_FIRST_BATCH_ACTION_ID)
if (event.code === 'Tab' && !event.shiftKey && !event.metaKey && firstBatchActionButton) {
event.preventDefault()
event.stopPropagation()
firstBatchActionButton.focus()
logger.debug('Focusing first batch action button')
}
},
n,
t,
},
})
@ -274,4 +293,11 @@ export default defineComponent({
}
}
.files-list__selected {
padding-inline-end: 12px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
color: var(--color-main-text);
}
</style>

@ -16,8 +16,7 @@
@close="openedSubmenu = null">
<!-- Default actions list-->
<NcActionButton
v-for="(action, idx) in enabledMenuActions"
:id="idx === 0 ? FILE_LIST_HEAD_FIRST_BATCH_ACTION_ID : undefined"
v-for="(action) in enabledMenuActions"
:key="action.id"
:ref="`action-batch-${action.id}`"
:class="{
@ -85,7 +84,6 @@ import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
import { FILES_LIST_HEADER_SELECT_ALL_CHECKBOX_ID } from './FilesListTableHeader.vue'
import { useFileActions } from '../composables/useFileActions.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import actionsMixins from '../mixins/actionsMixin.ts'
@ -95,8 +93,6 @@ import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
import { logger } from '../utils/logger.ts'
export const FILE_LIST_HEAD_FIRST_BATCH_ACTION_ID = 'files-list-head-first-batch-action'
export default defineComponent({
name: 'FilesListTableHeaderActions',
@ -153,8 +149,6 @@ export default defineComponent({
boundariesElement,
inlineActions,
FILE_LIST_HEAD_FIRST_BATCH_ACTION_ID,
}
},
@ -275,17 +269,6 @@ export default defineComponent({
},
},
mounted() {
const firstActionId = this.enabledMenuActions.at(0)?.id
const firstButton = this.$refs.actionsMenu?.$refs?.[`action-batch-${firstActionId}`]
if (firstButton) {
firstButton.$el.focus()
logger.debug('Focusing first batch action button')
firstButton.$el.addEventListener('focusout', this.onFirstButtonFocusOut)
}
},
methods: {
/**
* Get a cached note from the store
@ -360,20 +343,6 @@ export default defineComponent({
}
},
// When focusing out the first button outside the header actions
// we can return back to the select all checkbox
onFirstButtonFocusOut(event: FocusEvent) {
// If the focus is still within this component, do nothing
if (this.$el.contains(event.relatedTarget)) {
return
}
event.preventDefault()
event.stopPropagation()
document.getElementById(FILES_LIST_HEADER_SELECT_ALL_CHECKBOX_ID)?.focus()
logger.debug('Focusing select all checkbox again')
},
t: translate,
},
})

@ -20,15 +20,6 @@
<!-- eslint-disable-next-line vue/singleline-html-element-content-newline -- no space allowed as otherwise `:empty` css selector does not trigger! -->
<template #filters><FileListFilterToSearch /><FileListFilterChips /></template>
<template v-if="!isNoneSelected" #header-overlay>
<span class="files-list__selected">
{{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }}
</span>
<FilesListTableHeaderActions
:current-view="currentView"
:selected-nodes="selectedNodes" />
</template>
<template #before>
<!-- Headers -->
<FilesListHeader
@ -44,6 +35,7 @@
<!-- Table header and sort buttons -->
<FilesListTableHeader
ref="thead"
:current-view="currentView"
:is-mime-available="isMimeAvailable"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
@ -75,7 +67,7 @@ import type { UserConfig } from '../types.ts'
import { showError } from '@nextcloud/dialogs'
import { FileType, Folder, getSidebar, Permission, View } from '@nextcloud/files'
import { n, t } from '@nextcloud/l10n'
import { t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { computed, defineComponent } from 'vue'
import FileEntry from './FileEntry.vue'
@ -85,14 +77,12 @@ import FileListFilterToSearch from './FileListFilter/FileListFilterToSearch.vue'
import FilesListHeader from './FilesListHeader.vue'
import FilesListTableFooter from './FilesListTableFooter.vue'
import FilesListTableHeader from './FilesListTableHeader.vue'
import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
import VirtualList from './VirtualList.vue'
import { useEnabledFileActions } from '../composables/useFileActions.ts'
import { useFileListHeaders } from '../composables/useFileListHeaders.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useActiveStore } from '../store/active.ts'
import { useSelectionStore } from '../store/selection.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
import { logger } from '../utils/logger.ts'
@ -106,7 +96,6 @@ export default defineComponent({
FilesListTableFooter,
FilesListTableHeader,
VirtualList,
FilesListTableHeaderActions,
},
props: {
@ -134,7 +123,6 @@ export default defineComponent({
setup(props) {
const sidebar = getSidebar()
const activeStore = useActiveStore()
const selectionStore = useSelectionStore()
const userConfigStore = useUserConfigStore()
const { isNarrow, isWide } = useFileListWidth()
@ -178,10 +166,8 @@ export default defineComponent({
sidebar,
activeStore,
selectionStore,
userConfigStore,
n,
t,
}
},
@ -223,14 +209,6 @@ export default defineComponent({
].filter(Boolean).join('\n')
},
selectedNodes() {
return this.selectionStore.selected
},
isNoneSelected() {
return this.selectedNodes.length === 0
},
isEmpty() {
return this.nodes.length === 0
},
@ -542,20 +520,9 @@ export default defineComponent({
flex-direction: column;
}
.files-list__selected {
padding-inline-end: 12px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.files-list__table {
display: block;
&.files-list__table--with-thead-overlay {
// Hide the table header below the overlay
margin-block-start: calc(-1 * var(--row-height));
}
// Visually hide the table when there are no files
&--hidden {
visibility: hidden;
@ -585,25 +552,6 @@ export default defineComponent({
}
}
.files-list__thead-overlay {
// Pinned on top when scrolling
position: sticky;
top: var(--fixed-block-start-position);
// Save space for a row checkbox
margin-inline-start: var(--row-height);
// More than .files-list__thead
z-index: 20;
display: flex;
align-items: center;
// Reuse row styles
background-color: var(--color-main-background);
border-block-end: 1px solid var(--color-border);
height: var(--row-height);
flex: 0 0 var(--row-height);
}
.files-list__thead,
.files-list__tfoot {
display: flex;
@ -696,6 +644,20 @@ export default defineComponent({
}
}
// When files are selected, batch actions live in the select-all cell
// and other headers are visually hidden (kept in DOM for column association).
.files-list__row-head--selected {
.files-list__row-checkbox {
flex: 1 1 auto;
width: auto;
min-width: var(--row-height);
max-width: 100%;
justify-content: flex-start;
gap: 12px;
overflow: hidden;
}
}
.files-list__row {
&:hover, &:focus, &:active, &--active, &--dragover {
// WCAG AA compliant

@ -17,10 +17,6 @@
<slot name="filters" />
</div>
<div v-if="!!$scopedSlots['header-overlay']" class="files-list__thead-overlay">
<slot name="header-overlay" />
</div>
<div
v-if="dataSources.length === 0"
class="files-list__empty">
@ -32,7 +28,6 @@
:inert="dataSources.length === 0"
class="files-list__table"
:class="{
'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'],
'files-list__table--hidden': dataSources.length === 0,
}">
<!-- Accessibility table caption for screen readers -->

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save