Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
116 changes: 90 additions & 26 deletions src/lib/components/Table/Table.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<T>) {
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<T> | undefined {
return visibleColumns.find((col) => String(col.key) === key)
}

function columnWidth(col: TableColumn<T>): number {
return columnSizing[col.key] ?? col.width ?? 150
}

function columnBounds(col: TableColumn<T>) {
return { min: col.minWidth ?? 50, max: col.maxWidth ?? Infinity }
}

function applyColumnWidth(col: TableColumn<T>, 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<T>) {
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)
}

// =========================================================================
Expand Down Expand Up @@ -665,10 +715,24 @@
{/if}

{#if col.resizable}
<!-- svelte-ignore a11y_no_static_element_interactions -->
{@const width = columnWidth(col)}
{@const bounds = columnBounds(col)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<span
class="group/resize absolute top-0 -right-px flex h-full w-4 cursor-col-resize touch-none items-center justify-center select-none"
onmousedown={(e) => 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}
>
<span
class="h-4 w-0.5 rounded-full bg-outline-variant/50 transition-all group-hover/resize:h-5 group-hover/resize:bg-primary group-active/resize:bg-primary"
Expand Down
97 changes: 97 additions & 0 deletions src/lib/components/Table/Table.svelte.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,103 @@ describe('Table', () => {

// ==================== 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<HTMLElement>('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, {
Expand Down
Loading