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)
})
})
141 changes: 141 additions & 0 deletions apps/settings/src/components/Users/UserColumnResizer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

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

defineProps<{
/** Column key, used as CSS variable suffix */
column: string
/** Human readable column label */
label: string
}>()

const emit = defineEmits<{
reset: []
resize: [width: number]
'resize-end': []
}>()

const handle = ref<HTMLElement>()

const dragging = ref(false)
const startX = ref(0)
const startWidth = ref(0)

/**
* Current width of the header cell containing the handle
*/
function cellWidth(): number {
const cell = handle.value?.closest('th')
return cell?.getBoundingClientRect().width ?? 0
}

/**
* Horizontal factor turning a pointer delta into a width delta (-1 in RTL)
*/
function resizeDirection(): number {
return document.dir === 'rtl' ? -1 : 1
}

/**
* Start dragging and remember the initial pointer position and column width
*
* @param event The pointerdown event
*/
function onPointerDown(event: PointerEvent) {
dragging.value = true
startX.value = event.clientX
startWidth.value = cellWidth()
handle.value?.setPointerCapture?.(event.pointerId)
}

/**
* Emit the new column width while dragging
*
* @param event The pointermove event
*/
function onPointerMove(event: PointerEvent) {
if (!dragging.value) {
return
}
const delta = (event.clientX - startX.value) * resizeDirection()
emit('resize', clampColumnWidth(startWidth.value + delta))
}

/**
* Stop dragging and notify that resizing ended
*/
function onPointerUp() {
if (!dragging.value) {
return
}
dragging.value = false
emit('resize-end')
}

/**
* Resize the column with the arrow keys
*
* @param event The keydown event
*/
function onKeydown(event: KeyboardEvent) {
const direction = { ArrowLeft: -1, ArrowRight: 1 }[event.key]
if (direction === undefined) {
return
}
event.preventDefault()
const step = direction * COLUMN_RESIZE_STEP * resizeDirection()
emit('resize', clampColumnWidth(cellWidth() + step))
emit('resize-end')
}
</script>

<template>
<span
ref="handle"
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>

<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