diff --git a/CHANGELOG.md b/CHANGELOG.md index ead457e..b2ec62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,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)) - **ThemeModeButton** — renders both mode icons and lets CSS pick the visible one, so server and client markup are identical. The wrong glyph no longer sticks after a reload in dark mode. The accessible name is now a mode-independent `Toggle theme`. ([#224](https://github.com/ndlabdev/sv5ui/issues/224)) - **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)) 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, {