From 5e295af3d7f7ee65f22a61f520e8ce9bb5b216e7 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Fri, 25 Sep 2026 13:59:19 +0700 Subject: [PATCH] fix(table): make the column resize handle keyboard and touch accessible The handle was a bare span carrying only onmousedown, with no role, no tabindex and no key handler. Keyboard users could not reach it at all, let alone resize a column. A probe measured the column staying at 150px after three arrow presses while a mouse drag moved it to 230px, and one Tab from a button above the table jumped straight past the whole table. The drag also listened for mousemove and mouseup on document, so touch and pen never started a resize, even though the element already carried touch-none and therefore blocked scrolling that began on it. It is now a focusable separator with aria-orientation, aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext, resized by the arrow keys with a larger step while shift is held, and by Home and End for the bounds. The drag moves onto usePointerDrag, which ColorPicker and Resizable already use, so every pointer type works. The svelte-ignore for static element interactions is gone, since the element is now interactive. One remains for the noninteractive tabindex rule, which misreads a separator carrying aria-valuenow as passive. That is the same false positive Resizable already suppresses. Closes #226 --- CHANGELOG.md | 1 + src/lib/components/Table/Table.svelte | 116 ++++++++++++++---- src/lib/components/Table/Table.svelte.spec.ts | 97 +++++++++++++++ 3 files changed, 188 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bff6dfa..6680970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Table** — the column resize handle is a focusable `separator` with `aria-valuenow`, arrow key resizing and a larger step while shift is held, plus `Home` and `End` for the bounds. It was mouse only before: no role, no tabindex and no key handler, so keyboard users could not resize a column at all. The drag also moves to the shared pointer hook, so touch and pen work rather than mouse alone. ([#226](https://github.com/ndlabdev/sv5ui/issues/226)) - **Modal**, **Slideover**, **Drawer**, **Popover** — with `portal={false}`, nested floating layers (Select, DatePicker, DropdownMenu, Tooltip, ...) no longer hide behind the container when an ancestor has a `z-index` above 50. ([#217](https://github.com/ndlabdev/sv5ui/issues/217)) - **DatePicker**, **DateRangePicker** — the calendar is anchored to the field instead of the calendar icon, so it opens under the field's start edge. ([#218](https://github.com/ndlabdev/sv5ui/issues/218)) - **NavigationMenu** — the dropdown opens under the trigger that opened it, slides between triggers and stays inside the menu. The 8px gap under the items is now reserved only while the list overflows. ([#220](https://github.com/ndlabdev/sv5ui/issues/220)) diff --git a/src/lib/components/Table/Table.svelte b/src/lib/components/Table/Table.svelte index 7bab206..deb8b1f 100644 --- a/src/lib/components/Table/Table.svelte +++ b/src/lib/components/Table/Table.svelte @@ -14,6 +14,7 @@ import Checkbox from '../Checkbox/Checkbox.svelte' import ScrollArea from '../ScrollArea/ScrollArea.svelte' import { useThrottle } from '../../hooks/useThrottle/index.js' + import { usePointerDrag } from '../../hooks/usePointerDrag/index.js' import { autoGenerateColumns, getRowKey, @@ -353,35 +354,84 @@ // ========================================================================= // Column Resizing // ========================================================================= - let resizing = $state<{ key: string; startX: number; startWidth: number } | null>(null) + let resizing: { key: string; startWidth: number } | null = null const resizeThrottle = useThrottle({ delay: 16 }) + const resizeKeyStep = 16 - function onResizeStart(e: MouseEvent, col: TableColumn) { - e.preventDefault() - const currentWidth = columnSizing[col.key] ?? col.width ?? 150 - resizing = { key: col.key, startX: e.clientX, startWidth: currentWidth } - - const onMove = (ev: MouseEvent) => { - if (!resizing) return - const { key, startX, startWidth } = resizing - const diff = ev.clientX - startX - const min = col.minWidth ?? 50 - const max = col.maxWidth ?? Infinity - const newWidth = Math.max(min, Math.min(max, startWidth + diff)) - resizeThrottle.run(() => { - columnSizing = { ...columnSizing, [key]: newWidth } - onColumnSizingChange?.(columnSizing) - }) - } + function columnByKey(key: string): TableColumn | undefined { + return visibleColumns.find((col) => String(col.key) === key) + } + + function columnWidth(col: TableColumn): number { + return columnSizing[col.key] ?? col.width ?? 150 + } + + function columnBounds(col: TableColumn) { + return { min: col.minWidth ?? 50, max: col.maxWidth ?? Infinity } + } + + function applyColumnWidth(col: TableColumn, width: number) { + const { min, max } = columnBounds(col) + const next = Math.max(min, Math.min(max, Math.round(width))) + if (next === columnWidth(col)) return + + columnSizing = { ...columnSizing, [col.key]: next } + onColumnSizingChange?.(columnSizing) + } + + const resize = usePointerDrag({ + axis: 'x', + onStart: ({ event }) => { + const key = (event.currentTarget as HTMLElement).dataset.columnKey + if (!key) return false + + const col = columnByKey(key) + if (!col) return false + + resizing = { key, startWidth: columnWidth(col) } + }, + onMove: ({ dx }) => { + const current = resizing + if (!current) return + + const col = columnByKey(current.key) + if (!col) return - const onUp = () => { + resizeThrottle.run(() => applyColumnWidth(col, current.startWidth + dx)) + }, + onEnd: () => { resizing = null - document.removeEventListener('mousemove', onMove) - document.removeEventListener('mouseup', onUp) } + }) + + function resizeKeyDelta(event: KeyboardEvent): number | null { + const step = event.shiftKey ? resizeKeyStep * 4 : resizeKeyStep + if (event.key === 'ArrowRight') return step + if (event.key === 'ArrowLeft') return -step + + return null + } + + function handleResizeKeydown(event: KeyboardEvent, col: TableColumn) { + const { min, max } = columnBounds(col) + + if (event.key === 'Home') { + event.preventDefault() + applyColumnWidth(col, min) + return + } + + if (event.key === 'End' && Number.isFinite(max)) { + event.preventDefault() + applyColumnWidth(col, max) + return + } + + const delta = resizeKeyDelta(event) + if (delta === null) return - document.addEventListener('mousemove', onMove) - document.addEventListener('mouseup', onUp) + event.preventDefault() + applyColumnWidth(col, columnWidth(col) + delta) } // ========================================================================= @@ -665,10 +715,24 @@ {/if} {#if col.resizable} - + {@const width = columnWidth(col)} + {@const bounds = columnBounds(col)} + onResizeStart(e, col)} + role="separator" + tabindex="0" + data-column-key={col.key} + aria-orientation="vertical" + aria-label="Resize {col.label ?? col.key}" + aria-valuenow={width} + aria-valuemin={bounds.min} + aria-valuemax={Number.isFinite(bounds.max) + ? bounds.max + : undefined} + aria-valuetext="{width}px" + class="group/resize absolute top-0 -right-px flex h-full w-4 cursor-col-resize touch-none items-center justify-center select-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary" + onkeydown={(event) => handleResizeKeydown(event, col)} + {...resize.handlers} > { // ==================== GLOBAL FILTER ==================== + // ==================== COLUMN RESIZE ==================== + + describe('column resize', () => { + const resizableColumns = [ + { key: 'name', label: 'Name', resizable: true, width: 200, minWidth: 80 }, + { key: 'age', label: 'Age' } + ] as any + + const rows = [{ id: 1, name: 'Ada', email: 'a@b.c', age: 36 }] + + const setup = () => { + const { container } = render(Table, { columns: resizableColumns, data: rows }) + const handle = container.querySelector('th [role="separator"]')! + const width = () => Number(handle.getAttribute('aria-valuenow')) + return { handle, width } + } + + const press = async (handle: HTMLElement, key: string, shiftKey = false) => { + handle.dispatchEvent(new KeyboardEvent('keydown', { key, shiftKey, bubbles: true })) + await vi.waitFor(() => expect(handle.getAttribute('aria-valuenow')).toBeTruthy()) + } + + it('should expose the handle as a focusable separator', () => { + const { handle } = setup() + + expect(handle.getAttribute('role')).toBe('separator') + expect(handle.getAttribute('tabindex')).toBe('0') + expect(handle.getAttribute('aria-orientation')).toBe('vertical') + expect(handle.getAttribute('aria-label')).toBe('Resize Name') + expect(handle.getAttribute('aria-valuenow')).toBe('200') + expect(handle.getAttribute('aria-valuemin')).toBe('80') + expect(handle.getAttribute('aria-valuetext')).toBe('200px') + }) + + it('should be reachable with the keyboard', () => { + const { handle } = setup() + handle.focus() + + expect(document.activeElement).toBe(handle) + }) + + it('should resize with the arrow keys', async () => { + const { handle, width } = setup() + const start = width() + + await press(handle, 'ArrowRight') + const grown = width() + await press(handle, 'ArrowLeft') + + expect(grown).toBeGreaterThan(start) + expect(width()).toBe(start) + }) + + it('should take a larger step while shift is held', async () => { + const { handle, width } = setup() + const start = width() + + await press(handle, 'ArrowRight') + const small = width() - start + + await press(handle, 'ArrowRight', true) + const large = width() - start - small + + expect(large).toBeGreaterThan(small) + }) + + it('should clamp to minWidth and not go below it', async () => { + const { handle, width } = setup() + + await press(handle, 'Home') + expect(width()).toBe(80) + + await press(handle, 'ArrowLeft') + expect(width()).toBe(80) + }) + + it('should resize from a touch pointer, not just a mouse', async () => { + const { handle, width } = setup() + const start = width() + const opts = { bubbles: true, pointerType: 'touch', pointerId: 7, isPrimary: true } + + handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: 0 })) + handle.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: 70 })) + handle.dispatchEvent(new PointerEvent('pointerup', { ...opts, clientX: 70 })) + + await vi.waitFor(() => expect(width()).toBeGreaterThan(start)) + }) + + it('should render no handle for a column that is not resizable', () => { + const { container } = render(Table, { columns: resizableColumns, data: rows }) + const headers = Array.from(container.querySelectorAll('th')) + const withHandle = headers.filter((th) => th.querySelector('[role="separator"]')) + + expect(withHandle).toHaveLength(1) + }) + }) + describe('global filter', () => { it('should filter data by global filter', () => { render(Table, {