Merge pull request #40893 from nextcloud/enh/a11y/files-header-sort
commit
7e2c51204b
@ -1,174 +0,0 @@ |
||||
<!-- |
||||
- @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> |
||||
- |
||||
- @author John Molakvoæ <skjnldsv@protonmail.com> |
||||
- |
||||
- @license GNU AGPL version 3 or any later version |
||||
- |
||||
- This program is free software: you can redistribute it and/or modify |
||||
- it under the terms of the GNU Affero General Public License as |
||||
- published by the Free Software Foundation, either version 3 of the |
||||
- License, or (at your option) any later version. |
||||
- |
||||
- This program is distributed in the hope that it will be useful, |
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
- GNU Affero General Public License for more details. |
||||
- |
||||
- You should have received a copy of the GNU Affero General Public License |
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>. |
||||
- |
||||
--> |
||||
<template> |
||||
<tr> |
||||
<th class="files-list__row-checkbox"> |
||||
<span class="hidden-visually">{{ t('files', 'Total rows summary') }}</span> |
||||
</th> |
||||
|
||||
<!-- Link to file --> |
||||
<td class="files-list__row-name"> |
||||
<!-- Icon or preview --> |
||||
<span class="files-list__row-icon" /> |
||||
|
||||
<!-- Summary --> |
||||
<span>{{ summary }}</span> |
||||
</td> |
||||
|
||||
<!-- Actions --> |
||||
<td class="files-list__row-actions" /> |
||||
|
||||
<!-- Size --> |
||||
<td v-if="isSizeAvailable" |
||||
class="files-list__column files-list__row-size"> |
||||
<span>{{ totalSize }}</span> |
||||
</td> |
||||
|
||||
<!-- Mtime --> |
||||
<td v-if="isMtimeAvailable" |
||||
class="files-list__column files-list__row-mtime" /> |
||||
|
||||
<!-- Custom views columns --> |
||||
<th v-for="column in columns" |
||||
:key="column.id" |
||||
:class="classForColumn(column)"> |
||||
<span>{{ column.summary?.(nodes, currentView) }}</span> |
||||
</th> |
||||
</tr> |
||||
</template> |
||||
|
||||
<script lang="ts"> |
||||
import Vue from 'vue' |
||||
import { formatFileSize } from '@nextcloud/files' |
||||
import { translate } from '@nextcloud/l10n' |
||||
|
||||
import { useFilesStore } from '../store/files.ts' |
||||
import { usePathsStore } from '../store/paths.ts' |
||||
|
||||
export default Vue.extend({ |
||||
name: 'FilesListFooter', |
||||
|
||||
components: { |
||||
}, |
||||
|
||||
props: { |
||||
isMtimeAvailable: { |
||||
type: Boolean, |
||||
default: false, |
||||
}, |
||||
isSizeAvailable: { |
||||
type: Boolean, |
||||
default: false, |
||||
}, |
||||
nodes: { |
||||
type: Array, |
||||
required: true, |
||||
}, |
||||
summary: { |
||||
type: String, |
||||
default: '', |
||||
}, |
||||
filesListWidth: { |
||||
type: Number, |
||||
default: 0, |
||||
}, |
||||
}, |
||||
|
||||
setup() { |
||||
const pathsStore = usePathsStore() |
||||
const filesStore = useFilesStore() |
||||
return { |
||||
filesStore, |
||||
pathsStore, |
||||
} |
||||
}, |
||||
|
||||
computed: { |
||||
currentView() { |
||||
return this.$navigation.active |
||||
}, |
||||
|
||||
dir() { |
||||
// Remove any trailing slash but leave root slash |
||||
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') |
||||
}, |
||||
|
||||
currentFolder() { |
||||
if (!this.currentView?.id) { |
||||
return |
||||
} |
||||
|
||||
if (this.dir === '/') { |
||||
return this.filesStore.getRoot(this.currentView.id) |
||||
} |
||||
const fileId = this.pathsStore.getPath(this.currentView.id, this.dir) |
||||
return this.filesStore.getNode(fileId) |
||||
}, |
||||
|
||||
columns() { |
||||
// Hide columns if the list is too small |
||||
if (this.filesListWidth < 512) { |
||||
return [] |
||||
} |
||||
return this.currentView?.columns || [] |
||||
}, |
||||
|
||||
totalSize() { |
||||
// If we have the size already, let's use it |
||||
if (this.currentFolder?.size) { |
||||
return formatFileSize(this.currentFolder.size, true) |
||||
} |
||||
|
||||
// Otherwise let's compute it |
||||
return formatFileSize(this.nodes.reduce((total, node) => total + node.size || 0, 0), true) |
||||
}, |
||||
}, |
||||
|
||||
methods: { |
||||
classForColumn(column) { |
||||
return { |
||||
'files-list__row-column-custom': true, |
||||
[`files-list__row-${this.currentView.id}-${column.id}`]: true, |
||||
} |
||||
}, |
||||
|
||||
t: translate, |
||||
}, |
||||
}) |
||||
</script> |
||||
|
||||
<style scoped lang="scss"> |
||||
// Scoped row |
||||
tr { |
||||
border-top: 1px solid var(--color-border); |
||||
// Prevent hover effect on the whole row |
||||
background-color: transparent !important; |
||||
border-bottom: none !important; |
||||
} |
||||
|
||||
td { |
||||
user-select: none; |
||||
// Make sure the cell colors don't apply to column headers |
||||
color: var(--color-text-maxcontrast) !important; |
||||
} |
||||
|
||||
</style> |
@ -1,226 +0,0 @@ |
||||
<!-- |
||||
- @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> |
||||
- |
||||
- @author John Molakvoæ <skjnldsv@protonmail.com> |
||||
- |
||||
- @license GNU AGPL version 3 or any later version |
||||
- |
||||
- This program is free software: you can redistribute it and/or modify |
||||
- it under the terms of the GNU Affero General Public License as |
||||
- published by the Free Software Foundation, either version 3 of the |
||||
- License, or (at your option) any later version. |
||||
- |
||||
- This program is distributed in the hope that it will be useful, |
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
- GNU Affero General Public License for more details. |
||||
- |
||||
- You should have received a copy of the GNU Affero General Public License |
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>. |
||||
- |
||||
--> |
||||
<template> |
||||
<th class="files-list__column files-list__row-actions-batch" colspan="2"> |
||||
<NcActions ref="actionsMenu" |
||||
:disabled="!!loading || areSomeNodesLoading" |
||||
:force-name="true" |
||||
:inline="inlineActions" |
||||
:menu-name="inlineActions <= 1 ? t('files', 'Actions') : null" |
||||
:open.sync="openedMenu"> |
||||
<NcActionButton v-for="action in enabledActions" |
||||
:key="action.id" |
||||
:class="'files-list__row-actions-batch-' + action.id" |
||||
@click="onActionClick(action)"> |
||||
<template #icon> |
||||
<NcLoadingIcon v-if="loading === action.id" :size="18" /> |
||||
<NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" /> |
||||
</template> |
||||
{{ action.displayName(nodes, currentView) }} |
||||
</NcActionButton> |
||||
</NcActions> |
||||
</th> |
||||
</template> |
||||
|
||||
<script lang="ts"> |
||||
import { showError, showSuccess } from '@nextcloud/dialogs' |
||||
import { translate } from '@nextcloud/l10n' |
||||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' |
||||
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' |
||||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' |
||||
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' |
||||
import Vue from 'vue' |
||||
|
||||
import { getFileActions, useActionsMenuStore } from '../store/actionsmenu.ts' |
||||
import { useFilesStore } from '../store/files.ts' |
||||
import { useSelectionStore } from '../store/selection.ts' |
||||
import filesListWidthMixin from '../mixins/filesListWidth.ts' |
||||
import logger from '../logger.js' |
||||
import { NodeStatus } from '@nextcloud/files' |
||||
|
||||
// The registered actions list |
||||
const actions = getFileActions() |
||||
|
||||
export default Vue.extend({ |
||||
name: 'FilesListHeaderActions', |
||||
|
||||
components: { |
||||
NcActions, |
||||
NcActionButton, |
||||
NcIconSvgWrapper, |
||||
NcLoadingIcon, |
||||
}, |
||||
|
||||
mixins: [ |
||||
filesListWidthMixin, |
||||
], |
||||
|
||||
props: { |
||||
currentView: { |
||||
type: Object, |
||||
required: true, |
||||
}, |
||||
selectedNodes: { |
||||
type: Array, |
||||
default: () => ([]), |
||||
}, |
||||
}, |
||||
|
||||
setup() { |
||||
const actionsMenuStore = useActionsMenuStore() |
||||
const filesStore = useFilesStore() |
||||
const selectionStore = useSelectionStore() |
||||
return { |
||||
actionsMenuStore, |
||||
filesStore, |
||||
selectionStore, |
||||
} |
||||
}, |
||||
|
||||
data() { |
||||
return { |
||||
loading: null, |
||||
} |
||||
}, |
||||
|
||||
computed: { |
||||
dir() { |
||||
// Remove any trailing slash but leave root slash |
||||
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') |
||||
}, |
||||
enabledActions() { |
||||
return actions |
||||
.filter(action => action.execBatch) |
||||
.filter(action => !action.enabled || action.enabled(this.nodes, this.currentView)) |
||||
.sort((a, b) => (a.order || 0) - (b.order || 0)) |
||||
}, |
||||
|
||||
nodes() { |
||||
return this.selectedNodes |
||||
.map(fileid => this.getNode(fileid)) |
||||
.filter(node => node) |
||||
}, |
||||
|
||||
areSomeNodesLoading() { |
||||
return this.nodes.some(node => node.status === NodeStatus.LOADING) |
||||
}, |
||||
|
||||
openedMenu: { |
||||
get() { |
||||
return this.actionsMenuStore.opened === 'global' |
||||
}, |
||||
set(opened) { |
||||
this.actionsMenuStore.opened = opened ? 'global' : null |
||||
}, |
||||
}, |
||||
|
||||
inlineActions() { |
||||
if (this.filesListWidth < 512) { |
||||
return 0 |
||||
} |
||||
if (this.filesListWidth < 768) { |
||||
return 1 |
||||
} |
||||
if (this.filesListWidth < 1024) { |
||||
return 2 |
||||
} |
||||
return 3 |
||||
}, |
||||
}, |
||||
|
||||
methods: { |
||||
/** |
||||
* Get a cached note from the store |
||||
* |
||||
* @param {number} fileId the file id to get |
||||
* @return {Folder|File} |
||||
*/ |
||||
getNode(fileId) { |
||||
return this.filesStore.getNode(fileId) |
||||
}, |
||||
|
||||
async onActionClick(action) { |
||||
const displayName = action.displayName(this.nodes, this.currentView) |
||||
const selectionIds = this.selectedNodes |
||||
try { |
||||
// Set loading markers |
||||
this.loading = action.id |
||||
this.nodes.forEach(node => { |
||||
Vue.set(node, 'status', NodeStatus.LOADING) |
||||
}) |
||||
|
||||
// Dispatch action execution |
||||
const results = await action.execBatch(this.nodes, this.currentView, this.dir) |
||||
|
||||
// Check if all actions returned null |
||||
if (!results.some(result => result !== null)) { |
||||
// If the actions returned null, we stay silent |
||||
this.selectionStore.reset() |
||||
return |
||||
} |
||||
|
||||
// Handle potential failures |
||||
if (results.some(result => result === false)) { |
||||
// Remove the failed ids from the selection |
||||
const failedIds = selectionIds |
||||
.filter((fileid, index) => results[index] === false) |
||||
this.selectionStore.set(failedIds) |
||||
|
||||
showError(this.t('files', '"{displayName}" failed on some elements ', { displayName })) |
||||
return |
||||
} |
||||
|
||||
// Show success message and clear selection |
||||
showSuccess(this.t('files', '"{displayName}" batch action executed successfully', { displayName })) |
||||
this.selectionStore.reset() |
||||
} catch (e) { |
||||
logger.error('Error while executing action', { action, e }) |
||||
showError(this.t('files', '"{displayName}" action failed', { displayName })) |
||||
} finally { |
||||
// Remove loading markers |
||||
this.loading = null |
||||
this.nodes.forEach(node => { |
||||
Vue.set(node, 'status', undefined) |
||||
}) |
||||
} |
||||
}, |
||||
|
||||
t: translate, |
||||
}, |
||||
}) |
||||
</script> |
||||
|
||||
<style scoped lang="scss"> |
||||
.files-list__row-actions-batch { |
||||
flex: 1 1 100% !important; |
||||
|
||||
// Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged |
||||
::v-deep .button-vue__wrapper { |
||||
width: 100%; |
||||
span.button-vue__text { |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
white-space: nowrap; |
||||
} |
||||
} |
||||
} |
||||
</style> |
@ -1,123 +0,0 @@ |
||||
<!-- |
||||
- @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> |
||||
- |
||||
- @author John Molakvoæ <skjnldsv@protonmail.com> |
||||
- |
||||
- @license GNU AGPL version 3 or any later version |
||||
- |
||||
- This program is free software: you can redistribute it and/or modify |
||||
- it under the terms of the GNU Affero General Public License as |
||||
- published by the Free Software Foundation, either version 3 of the |
||||
- License, or (at your option) any later version. |
||||
- |
||||
- This program is distributed in the hope that it will be useful, |
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
- GNU Affero General Public License for more details. |
||||
- |
||||
- You should have received a copy of the GNU Affero General Public License |
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>. |
||||
- |
||||
--> |
||||
<template> |
||||
<NcButton :aria-label="sortAriaLabel(name)" |
||||
:class="{'files-list__column-sort-button--active': sortingMode === mode}" |
||||
:alignment="mode !== 'size' ? 'start-reverse' : ''" |
||||
class="files-list__column-sort-button" |
||||
type="tertiary" |
||||
@click.stop.prevent="toggleSortBy(mode)"> |
||||
<!-- Sort icon before text as size is align right --> |
||||
<MenuUp v-if="sortingMode !== mode || isAscSorting" slot="icon" /> |
||||
<MenuDown v-else slot="icon" /> |
||||
{{ name }} |
||||
</NcButton> |
||||
</template> |
||||
|
||||
<script lang="ts"> |
||||
import { translate } from '@nextcloud/l10n' |
||||
import MenuDown from 'vue-material-design-icons/MenuDown.vue' |
||||
import MenuUp from 'vue-material-design-icons/MenuUp.vue' |
||||
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' |
||||
import Vue from 'vue' |
||||
|
||||
import filesSortingMixin from '../mixins/filesSorting.ts' |
||||
|
||||
export default Vue.extend({ |
||||
name: 'FilesListHeaderButton', |
||||
|
||||
components: { |
||||
MenuDown, |
||||
MenuUp, |
||||
NcButton, |
||||
}, |
||||
|
||||
mixins: [ |
||||
filesSortingMixin, |
||||
], |
||||
|
||||
props: { |
||||
name: { |
||||
type: String, |
||||
required: true, |
||||
}, |
||||
mode: { |
||||
type: String, |
||||
required: true, |
||||
}, |
||||
}, |
||||
|
||||
methods: { |
||||
sortAriaLabel(column) { |
||||
const direction = this.isAscSorting |
||||
? this.t('files', 'ascending') |
||||
: this.t('files', 'descending') |
||||
return this.t('files', 'Sort list by {column} ({direction})', { |
||||
column, |
||||
direction, |
||||
}) |
||||
}, |
||||
|
||||
t: translate, |
||||
}, |
||||
}) |
||||
</script> |
||||
|
||||
<style lang="scss"> |
||||
.files-list__column-sort-button { |
||||
// Compensate for cells margin |
||||
margin: 0 calc(var(--cell-margin) * -1); |
||||
// Reverse padding |
||||
padding: 0 4px 0 16px !important; |
||||
|
||||
// Icon after text |
||||
.button-vue__wrapper { |
||||
flex-direction: row-reverse; |
||||
// Take max inner width for text overflow ellipsis |
||||
// Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged |
||||
width: 100%; |
||||
} |
||||
|
||||
.button-vue__icon { |
||||
transition-timing-function: linear; |
||||
transition-duration: .1s; |
||||
transition-property: opacity; |
||||
opacity: 0; |
||||
} |
||||
|
||||
// Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged |
||||
.button-vue__text { |
||||
overflow: hidden; |
||||
white-space: nowrap; |
||||
text-overflow: ellipsis; |
||||
} |
||||
|
||||
&--active, |
||||
&:hover, |
||||
&:focus, |
||||
&:active { |
||||
.button-vue__icon { |
||||
opacity: 1 !important; |
||||
} |
||||
} |
||||
} |
||||
</style> |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in new issue