refactor(files): handle file list width break points in one central point

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-01-20 11:52:32 +01:00
parent 3f9849d921
commit 871f037dda
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
14 changed files with 164 additions and 196 deletions

View file

@ -16,7 +16,7 @@
v-bind="section"
dir="auto"
:to="section.to"
:force-icon-text="index === 0 && fileListWidth >= 486"
:force-icon-text="index === 0 && !isNarrow"
:title="titleForSection(index, section)"
:aria-description="ariaForSection(section)"
@click.native="onClick(section.to)"
@ -76,6 +76,8 @@ export default defineComponent({
},
},
emits: ['reload'],
setup() {
const activeStore = useActiveStore()
const filesStore = useFilesStore()
@ -84,7 +86,7 @@ export default defineComponent({
const selectionStore = useSelectionStore()
const uploaderStore = useUploaderStore()
const fileListWidth = useFileListWidth()
const { isNarrow } = useFileListWidth()
const views = useViews()
return {
@ -95,7 +97,7 @@ export default defineComponent({
selectionStore,
uploaderStore,
fileListWidth,
isNarrow,
views,
}
},
@ -132,7 +134,7 @@ export default defineComponent({
wrapUploadProgressBar(): boolean {
// if an upload is ongoing, and on small screens / mobile, then
// show the progress bar for the upload below breadcrumbs
return this.isUploadInProgress && this.fileListWidth < 512
return this.isUploadInProgress && this.isNarrow
},
// used to show the views icon for the first breadcrumb

View file

@ -160,7 +160,7 @@ export default defineComponent({
const filesStore = useFilesStore()
const renamingStore = useRenamingStore()
const selectionStore = useSelectionStore()
const filesListWidth = useFileListWidth()
const { isNarrow } = useFileListWidth()
const {
fileId: currentRouteFileId,
} = useRouteParameters()
@ -178,7 +178,7 @@ export default defineComponent({
activeView,
currentRouteFileId,
draggingStore,
filesListWidth,
isNarrow,
filesStore,
renamingStore,
selectionStore,
@ -209,10 +209,10 @@ export default defineComponent({
columns() {
// Hide columns if the list is too small
if (this.filesListWidth < 512 || this.compact) {
if (this.isNarrow || this.compact) {
return []
}
return this.activeView.columns || []
return this.activeView?.columns || []
},
mime() {

View file

@ -173,15 +173,17 @@ export default defineComponent({
},
},
emits: ['update:opened'],
setup() {
// The file list is guaranteed to be shown with active view - thus we can set the `loaded` flag
const activeStore = useActiveStore()
const filesListWidth = useFileListWidth()
const { isNarrow } = useFileListWidth()
const enabledFileActions = inject<FileAction[]>('enabledFileActions', [])
return {
activeStore,
enabledFileActions,
filesListWidth,
isNarrow,
t,
}
},
@ -206,7 +208,7 @@ export default defineComponent({
// Enabled action that are displayed inline
enabledInlineActions() {
if (this.filesListWidth < 768 || this.gridMode) {
if (this.isNarrow || this.gridMode) {
return []
}
return this.enabledFileActions.filter((action) => {
@ -302,7 +304,7 @@ export default defineComponent({
methods: {
actionDisplayName(action: FileAction) {
try {
if ((this.gridMode || (this.filesListWidth < 768 && action.inline)) && typeof action.title === 'function') {
if ((this.gridMode || (this.isNarrow && action.inline)) && typeof action.title === 'function') {
// if an inline action is rendered in the menu for
// lack of space we use the title first if defined
const title = action.title(this.actionContext)

View file

@ -39,7 +39,7 @@
</template>
<script lang="ts">
import type { FileAction, Node } from '@nextcloud/files'
import type { FileAction, Node, TFileType } from '@nextcloud/files'
import type { PropType } from 'vue'
import { showError } from '@nextcloud/dialogs'
@ -96,7 +96,7 @@ export default defineComponent({
setup() {
// The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
const filesListWidth = useFileListWidth()
const { isNarrow } = useFileListWidth()
const renamingStore = useRenamingStore()
const userConfigStore = useUserConfigStore()
const { activeFolder, activeView } = useActiveStore()
@ -107,7 +107,7 @@ export default defineComponent({
activeFolder,
activeView,
defaultFileAction,
filesListWidth,
isNarrow,
renamingStore,
userConfigStore,
}
@ -119,7 +119,7 @@ export default defineComponent({
},
isRenamingSmallScreen() {
return this.isRenaming && this.filesListWidth < 512
return this.isRenaming && this.isNarrow
},
newName: {
@ -133,7 +133,7 @@ export default defineComponent({
},
renameLabel() {
const matchLabel: Record<FileType, string> = {
const matchLabel: Record<TFileType, string> = {
[FileType.File]: t('files', 'Filename'),
[FileType.Folder]: t('files', 'Folder name'),
}

View file

@ -113,7 +113,7 @@ export default defineComponent({
const filesStore = useFilesStore()
const renamingStore = useRenamingStore()
const selectionStore = useSelectionStore()
const filesListWidth = useFileListWidth()
const { isNarrow } = useFileListWidth()
const {
fileId: currentRouteFileId,
} = useRouteParameters()
@ -131,7 +131,7 @@ export default defineComponent({
activeView,
currentRouteFileId,
draggingStore,
filesListWidth,
isNarrow,
filesStore,
renamingStore,
selectionStore,

View file

@ -36,10 +36,6 @@ export default defineComponent({
type: Array as PropType<Node[]>,
required: true,
},
filesListWidth: {
type: Number,
default: 0,
},
isMtimeAvailable: {
type: Boolean,
default: false,
@ -119,7 +115,7 @@ export default defineComponent({
return this.renamingStore.renamingNode === this.source
},
isRenamingSmallScreen() {
return this.isRenaming && this.filesListWidth < 512
return this.isRenaming && this.isNarrow
},
isActive() {

View file

@ -48,111 +48,69 @@
</tr>
</template>
<script lang="ts">
import type { Node } from '@nextcloud/files'
import type { PropType } from 'vue'
<script setup lang="ts">
import type { IColumn, INode, IView } from '@nextcloud/files'
import { formatFileSize, View } from '@nextcloud/files'
import { translate } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useFilesStore } from '../store/files.ts'
import { usePathsStore } from '../store/paths.ts'
import { formatFileSize } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useActiveStore } from '../store/active.ts'
export default defineComponent({
name: 'FilesListTableFooter',
const props = defineProps<{
/** The current view */
currentView: IView
props: {
currentView: {
type: View,
required: true,
},
/** Whether the mime column is available */
isMimeAvailable: boolean
isMimeAvailable: {
type: Boolean,
default: false,
},
/** Whether the mtime column is available */
isMtimeAvailable: boolean
isMtimeAvailable: {
type: Boolean,
default: false,
},
/** Whether the size column is available */
isSizeAvailable: boolean
isSizeAvailable: {
type: Boolean,
default: false,
},
/** The nodes to summarize */
nodes: INode[]
nodes: {
type: Array as PropType<Node[]>,
required: true,
},
/** Summary text */
summary: string
}>()
summary: {
type: String,
default: '',
},
const activeStore = useActiveStore()
const { isNarrow } = useFileListWidth()
filesListWidth: {
type: Number,
default: 0,
},
},
const currentFolder = computed(() => activeStore.activeFolder)
setup() {
const pathsStore = usePathsStore()
const filesStore = useFilesStore()
const { directory } = useRouteParameters()
return {
filesStore,
pathsStore,
directory,
}
},
computed: {
currentFolder() {
if (!this.currentView?.id) {
return
}
if (this.directory === '/') {
return this.filesStore.getRoot(this.currentView.id)
}
const fileId = this.pathsStore.getPath(this.currentView.id, this.directory)!
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,
},
const columns = computed(() => {
// Hide columns if the list is too small
if (isNarrow.value) {
return []
}
return props.currentView?.columns || []
})
const totalSize = computed(() => {
// If we have the size already, let's use it
if (currentFolder.value?.size) {
return formatFileSize(currentFolder.value.size, true)
}
// Otherwise let's compute it
return formatFileSize(props.nodes.reduce((total, node) => total + (node.size ?? 0), 0), true)
})
/**
* Get the CSS classes for a custom column
*
* @param column - The column
*/
function classForColumn(column: IColumn) {
return {
'files-list__row-column-custom': true,
[`files-list__row-${props.currentView.id}-${column.id}`]: true,
}
}
</script>
<style scoped lang="scss">

View file

@ -10,7 +10,7 @@
<NcCheckboxRadioSwitch
v-bind="selectAllBind"
data-cy-files-list-selection-checkbox
@update:modelValue="onToggleAll" />
@update:model-value="onToggleAll" />
</th>
<!-- Columns display -->
@ -80,6 +80,7 @@ import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { defineComponent } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import logger from '../logger.ts'
import filesSortingMixin from '../mixins/filesSorting.ts'
@ -119,11 +120,6 @@ export default defineComponent({
type: Array as PropType<Node[]>,
required: true,
},
filesListWidth: {
type: Number,
default: 0,
},
},
setup() {
@ -132,19 +128,22 @@ export default defineComponent({
const selectionStore = useSelectionStore()
const { directory } = useRouteParameters()
const { isNarrow } = useFileListWidth()
return {
activeStore,
filesStore,
selectionStore,
directory,
isNarrow,
}
},
computed: {
columns() {
// Hide columns if the list is too small
if (this.filesListWidth < 512) {
if (this.isNarrow) {
return []
}
return this.activeStore.activeView?.columns || []

View file

@ -77,7 +77,7 @@ import type { FileSource } from '../types.ts'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { DefaultType, getFileActions, NodeStatus } from '@nextcloud/files'
import { translate } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import { computed, defineComponent } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
@ -126,18 +126,28 @@ export default defineComponent({
const actionsMenuStore = useActionsMenuStore()
const filesStore = useFilesStore()
const selectionStore = useSelectionStore()
const fileListWidth = useFileListWidth()
const { isMedium, isNarrow } = useFileListWidth()
const boundariesElement = document.getElementById('app-content-vue')
const inlineActions = computed(() => {
if (isNarrow.value) {
return 0
}
if (isMedium.value) {
return 1
}
return 3
})
return {
actionsMenuStore,
activeFolder,
fileListWidth,
filesStore,
selectionStore,
boundariesElement,
inlineActions,
}
},
@ -256,19 +266,6 @@ export default defineComponent({
this.actionsMenuStore.opened = opened ? 'global' : null
},
},
inlineActions() {
if (this.fileListWidth < 512) {
return 0
}
if (this.fileListWidth < 768) {
return 1
}
if (this.fileListWidth < 1024) {
return 2
}
return 3
},
},
methods: {

View file

@ -45,7 +45,6 @@
<!-- Table header and sort buttons -->
<FilesListTableHeader
ref="thead"
:files-list-width="fileListWidth"
:is-mime-available="isMimeAvailable"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
@ -61,7 +60,6 @@
<template #footer>
<FilesListTableFooter
:current-view="currentView"
:files-list-width="fileListWidth"
:is-mime-available="isMimeAvailable"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
@ -72,7 +70,7 @@
</template>
<script lang="ts">
import type { Node as NcNode } from '@nextcloud/files'
import type { INode } from '@nextcloud/files'
import type { ComponentPublicInstance, PropType } from 'vue'
import type { UserConfig } from '../types.ts'
@ -80,7 +78,7 @@ import { showError } from '@nextcloud/dialogs'
import { FileType, Folder, getFileActions, getSidebar, Permission, View } from '@nextcloud/files'
import { n, t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { defineComponent } from 'vue'
import { computed, defineComponent } from 'vue'
import FileEntry from './FileEntry.vue'
import FileEntryGrid from './FileEntryGrid.vue'
import FileListFilters from './FileListFilters.vue'
@ -94,7 +92,7 @@ import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import logger from '../logger.ts'
import { useActiveStore } from '../store/active.ts'
import { useSelectionStore } from '../store/selection.js'
import { useSelectionStore } from '../store/selection.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
export default defineComponent({
@ -121,7 +119,7 @@ export default defineComponent({
},
nodes: {
type: Array as PropType<NcNode[]>,
type: Array as PropType<INode[]>,
required: true,
},
@ -131,19 +129,48 @@ export default defineComponent({
},
},
setup() {
setup(props) {
const sidebar = getSidebar()
const activeStore = useActiveStore()
const selectionStore = useSelectionStore()
const userConfigStore = useUserConfigStore()
const fileListWidth = useFileListWidth()
const { isNarrow, isWide } = useFileListWidth()
const { fileId, openDetails, openFile } = useRouteParameters()
const isMimeAvailable = computed(() => {
if (!userConfigStore.userConfig.show_mime_column) {
return false
}
if (!isWide.value) {
return false // only show on wide screens
}
return props.nodes
.some((node: INode) => node.mime !== undefined || node.mime !== 'application/octet-stream')
})
const isMtimeAvailable = computed(() => {
// Hide mtime column on narrow screens
if (isNarrow.value) {
return false // hide on narrow screens
}
return props.nodes.some((node: INode) => node.mtime !== undefined)
})
const isSizeAvailable = computed(() => {
// Hide size column on narrow screens
if (isNarrow.value) {
return false // hide on narrow screens
}
return props.nodes.some((node: INode) => node.size !== undefined)
})
return {
fileId,
fileListWidth,
headers: useFileListHeaders(),
isSizeAvailable,
isMtimeAvailable,
isMimeAvailable,
openDetails,
openFile,
@ -170,33 +197,6 @@ export default defineComponent({
return this.userConfigStore.userConfig
},
isMimeAvailable() {
if (!this.userConfig.show_mime_column) {
return false
}
// Hide mime column on narrow screens
if (this.fileListWidth < 1024) {
return false
}
return this.nodes.some((node) => node.mime !== undefined || node.mime !== 'application/octet-stream')
},
isMtimeAvailable() {
// Hide mtime column on narrow screens
if (this.fileListWidth < 768) {
return false
}
return this.nodes.some((node) => node.mtime !== undefined)
},
isSizeAvailable() {
// Hide size column on narrow screens
if (this.fileListWidth < 768) {
return false
}
return this.nodes.some((node) => node.size !== undefined)
},
cantUpload() {
return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) === 0
},
@ -312,7 +312,7 @@ export default defineComponent({
openSidebarForFile(fileId) {
// Open the sidebar for the given URL fileid
// iif we just loaded the app.
const node = this.nodes.find((n) => n.fileid === fileId) as NcNode
const node = this.nodes.find((n) => n.fileid === fileId) as INode
if (node && this.sidebar.available) {
logger.debug('Opening sidebar on file ' + node.path, { node })
this.sidebar.open(node)
@ -355,7 +355,7 @@ export default defineComponent({
* @param fileId File to open
*/
async handleOpenFile(fileId: number) {
const node = this.nodes.find((n) => n.fileid === fileId) as NcNode
const node = this.nodes.find((n) => n.fileid === fileId) as INode
if (node === undefined) {
return
}
@ -445,7 +445,7 @@ export default defineComponent({
const index = event.key === 'ArrowUp' || event.key === 'ArrowLeft'
? this.nodes.length - 1
: 0
this.setActiveNode(this.nodes[index] as NcNode & { fileid: number })
this.setActiveNode(this.nodes[index] as INode & { fileid: number })
}
const index = this.nodes.findIndex((node) => node.fileid === this.fileId) ?? 0
@ -481,7 +481,7 @@ export default defineComponent({
}
},
async setActiveNode(node: NcNode & { fileid: number }) {
async setActiveNode(node: INode & { fileid: number }) {
logger.debug('Navigating to file ' + node.path, { node, fileid: node.fileid })
this.scrollToFile(node.fileid)

View file

@ -131,7 +131,7 @@ export default defineComponent({
},
setup() {
const fileListWidth = useFileListWidth()
const { width: fileListWidth } = useFileListWidth()
return {
fileListWidth,

View file

@ -51,7 +51,7 @@ async function getFileList() {
template: '<div data-testid="component" style="width: 100%;background: white;">{{ fileListWidth }}</div>',
setup() {
return {
fileListWidth: useFileListWidth(),
fileListWidth: useFileListWidth().width,
}
},
})

View file

@ -2,9 +2,8 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Ref } from 'vue'
import { onMounted, readonly, ref } from 'vue'
import { computed, onMounted, readonly, ref } from 'vue'
/** The element we observe */
let element: HTMLElement | undefined
@ -12,13 +11,22 @@ let element: HTMLElement | undefined
/** The current width of the element */
const width = ref(0)
const observer = new ResizeObserver((elements) => {
if (elements[0].contentBoxSize) {
const isWide = computed(() => width.value >= 1024)
const isMedium = computed(() => width.value >= 512 && width.value < 1024)
const isNarrow = computed(() => width.value < 512)
const observer = new ResizeObserver(([element]) => {
if (!element) {
return
}
const contentBoxSize = element.contentBoxSize?.[0]
if (contentBoxSize) {
// use the newer `contentBoxSize` property if available
width.value = elements[0].contentBoxSize[0].inlineSize
width.value = contentBoxSize.inlineSize
} else {
// fall back to `contentRect`
width.value = elements[0].contentRect.width
width.value = element.contentRect.width
}
})
@ -41,11 +49,17 @@ function updateObserver() {
/**
* Get the reactive width of the file list
*/
export function useFileListWidth(): Readonly<Ref<number>> {
export function useFileListWidth() {
// Update the observer when the component is mounted (e.g. because this is the files app)
onMounted(updateObserver)
// Update the observer also in setup context, so we already have an initial value
updateObserver()
return readonly(width)
return {
width: readonly(width),
isWide,
isMedium,
isNarrow,
}
}

View file

@ -10,7 +10,7 @@
<template #actions>
<!-- Sharing button -->
<NcButton
v-if="canShare && fileListWidth >= 512"
v-if="canShare && !isNarrow"
:aria-label="shareButtonLabel"
:class="{ 'files-list__header-share-button--shared': shareButtonType }"
:title="shareButtonLabel"
@ -27,7 +27,7 @@
<UploadPicker
v-if="canUpload && !isQuotaExceeded && currentFolder"
allow-folders
:no-label="fileListWidth <= 511"
:no-label="isNarrow"
class="files-list__header-upload-button"
:content="getContent"
:destination="currentFolder"
@ -259,7 +259,7 @@ export default defineComponent({
const userConfigStore = useUserConfigStore()
const viewConfigStore = useViewConfigStore()
const fileListWidth = useFileListWidth()
const { isNarrow } = useFileListWidth()
const { directory, fileId } = useRouteParameters()
const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true)
@ -271,7 +271,7 @@ export default defineComponent({
currentView,
directory,
fileId,
fileListWidth,
isNarrow,
t,
sidebar,
@ -848,7 +848,7 @@ export default defineComponent({
max-width: 100%;
// Align with the navigation toggle icon
margin-block: var(--app-navigation-padding, 4px);
margin-inline: calc(var(--default-clickable-area, 44px) + 2 * var(--app-navigation-padding, 4px)) var(--app-navigation-padding, 4px);
margin-inline: calc(var(--default-clickable-area) + 2 * var(--app-navigation-padding, 4px)) var(--app-navigation-padding, 4px);
&--public {
// There is no navigation toggle on public shares