Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion apps/settings/src/components/UserList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@
</template>

<template #header>
<UserListHeader />
<UserListHeader
@resize-column="onColumnResize"
@resize-column-end="onColumnResizeEnd"
@reset-column="onColumnReset" />
</template>

<template #footer>
Expand All @@ -81,6 +84,7 @@ import UserListHeader from './Users/UserListHeader.vue'
import UserRow from './Users/UserRow.vue'
import VirtualList from './Users/VirtualList.vue'
import logger from '../logger.ts'
import { loadColumnWidths, saveColumnWidths } from '../utils/userListColumns.ts'
import { defaultQuota, unlimitedQuota } from '../utils/userUtils.ts'

const newUser = Object.freeze({
Expand Down Expand Up @@ -145,6 +149,7 @@ export default {

newUser: { ...newUser },
editingUser: null,
columnWidths: loadColumnWidths(),
}
},

Expand All @@ -162,8 +167,11 @@ export default {
},

style() {
const columnWidths = Object.fromEntries(Object.entries(this.columnWidths)
.map(([column, width]) => [`--user-list-column-${column}`, `${width}px`]))
return {
'--row-height': `${this.rowHeight}px`,
...columnWidths,
}
},

Expand Down Expand Up @@ -277,6 +285,19 @@ export default {
this.editingUser = user
},

onColumnResize(column, width) {
this.$set(this.columnWidths, column, width)
},

onColumnResizeEnd() {
saveColumnWidths(this.columnWidths)
},

onColumnReset(column) {
this.$delete(this.columnWidths, column)
saveColumnWidths(this.columnWidths)
},

async handleScrollEnd() {
await this.loadUsers()
},
Expand Down
97 changes: 97 additions & 0 deletions apps/settings/src/components/Users/UserColumnResizer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import UserColumnResizer from './UserColumnResizer.vue'
import { COLUMN_MIN_WIDTH, COLUMN_RESIZE_STEP } from '../../utils/userListColumns.ts'

const CELL_WIDTH = 300

// jsdom has no PointerEvent and wrapper.trigger() cannot set the read-only
// clientX, so dispatch a MouseEvent carrying the pointer event type instead
function firePointerEvent(element: Element, type: string, clientX: number) {
element.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX }))
}

function mountInHeaderCell() {
const wrapper = mount({
components: { UserColumnResizer },
template: `
<table>
<tr>
<th>
<UserColumnResizer column="email" label="Email" />
</th>
</tr>
</table>
`,
})
wrapper.find('th').element.getBoundingClientRect = () => ({ width: CELL_WIDTH } as DOMRect)
return wrapper
}

describe('UserColumnResizer', () => {
it('widens the column with the right arrow key', async () => {
const wrapper = mountInHeaderCell()
const resizer = wrapper.findComponent(UserColumnResizer)

await resizer.trigger('keydown', { key: 'ArrowRight' })

expect(resizer.emitted('resize')).toEqual([[CELL_WIDTH + COLUMN_RESIZE_STEP]])
expect(resizer.emitted('resize-end')).toHaveLength(1)
})

it('narrows the column with the left arrow key', async () => {
const wrapper = mountInHeaderCell()
const resizer = wrapper.findComponent(UserColumnResizer)

await resizer.trigger('keydown', { key: 'ArrowLeft' })

expect(resizer.emitted('resize')).toEqual([[CELL_WIDTH - COLUMN_RESIZE_STEP]])
})

it('ignores unrelated keys', async () => {
const wrapper = mountInHeaderCell()
const resizer = wrapper.findComponent(UserColumnResizer)

await resizer.trigger('keydown', { key: 'Enter' })

expect(resizer.emitted('resize')).toBeUndefined()
})

it('resizes on pointer drag and clamps to the minimum width', async () => {
const wrapper = mountInHeaderCell()
const resizer = wrapper.findComponent(UserColumnResizer)

firePointerEvent(resizer.element, 'pointerdown', 500)
firePointerEvent(resizer.element, 'pointermove', 550)
firePointerEvent(resizer.element, 'pointermove', 100)
firePointerEvent(resizer.element, 'pointerup', 100)
await resizer.vm.$nextTick()

expect(resizer.emitted('resize')).toEqual([[CELL_WIDTH + 50], [COLUMN_MIN_WIDTH]])
expect(resizer.emitted('resize-end')).toHaveLength(1)
})

it('does not resize on pointer move without a preceding pointer down', async () => {
const wrapper = mountInHeaderCell()
const resizer = wrapper.findComponent(UserColumnResizer)

firePointerEvent(resizer.element, 'pointermove', 550)
await resizer.vm.$nextTick()

expect(resizer.emitted('resize')).toBeUndefined()
})

it('emits a reset on double click', async () => {
const wrapper = mountInHeaderCell()
const resizer = wrapper.findComponent(UserColumnResizer)

await resizer.trigger('dblclick')

expect(resizer.emitted('reset')).toHaveLength(1)
})
})
128 changes: 128 additions & 0 deletions apps/settings/src/components/Users/UserColumnResizer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<span
class="column-resizer"
role="separator"
aria-orientation="vertical"
:aria-label="t('settings', 'Drag or use the arrow keys to resize the {label} column. Double click to reset its width.', { label })"
tabindex="0"
:data-cy-column-resizer="column"
@pointerdown.prevent="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@keydown="onKeydown"
@dblclick="$emit('reset')" />
</template>

<script lang="ts">
import { translate as t } from '@nextcloud/l10n'
import Vue from 'vue'
import { clampColumnWidth, COLUMN_RESIZE_STEP } from '../../utils/userListColumns.ts'

export default Vue.extend({
name: 'UserColumnResizer',

props: {
/** Column key, used as CSS variable suffix */
column: {
type: String,
required: true,
},

/** Human readable column label */
label: {
type: String,
required: true,
},
},

data() {
return {
dragging: false,
startX: 0,
startWidth: 0,
}
},

methods: {
t,

cellWidth(): number {
const cell = (this.$el as HTMLElement).closest('th')
return cell?.getBoundingClientRect().width ?? 0
},

resizeDirection(): number {
return document.dir === 'rtl' ? -1 : 1
},

onPointerDown(event: PointerEvent) {
this.dragging = true
this.startX = event.clientX
this.startWidth = this.cellWidth()
const handle = event.target as HTMLElement
handle.setPointerCapture?.(event.pointerId)
},

onPointerMove(event: PointerEvent) {
if (!this.dragging) {
return
}
const delta = (event.clientX - this.startX) * this.resizeDirection()
this.$emit('resize', clampColumnWidth(this.startWidth + delta))
},

onPointerUp() {
if (!this.dragging) {
return
}
this.dragging = false
this.$emit('resize-end')
},

onKeydown(event: KeyboardEvent) {
const direction = { ArrowLeft: -1, ArrowRight: 1 }[event.key]
if (direction === undefined) {
return
}
event.preventDefault()
const step = direction * COLUMN_RESIZE_STEP * this.resizeDirection()
this.$emit('resize', clampColumnWidth(this.cellWidth() + step))
this.$emit('resize-end')
},
},
})
</script>

<style lang="scss" scoped>
.column-resizer {
position: absolute;
z-index: 2;
inset-block: 0;
inset-inline-end: 0;
width: calc(var(--default-grid-baseline) * 2);
cursor: col-resize;
touch-action: none;

&::after {
content: '';
position: absolute;
inset-block: var(--default-grid-baseline);
inset-inline-end: calc(var(--default-grid-baseline) / 2);
width: 2px;
border-radius: 1px;
background-color: var(--color-primary-element);
opacity: 0;
}

&:hover::after,
&:focus-visible::after {
opacity: 1;
}
}
</style>
Loading