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
15 changes: 15 additions & 0 deletions .changeset/resize-max-size-and-header-ellipsis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@zvndev/yable-core': minor
'@zvndev/yable-react': patch
'@zvndev/yable-themes': patch
---

Add `resizeMaxSize` and stop the header ellipsis from clipping label-less headers

- **core:** New per-column `resizeMaxSize` caps USER drag-resize independently of
`maxSize` (defaults to `maxSize`, fully back-compatible). `maxSize` still caps
auto-sizing/stretch; set `resizeMaxSize` (e.g. `Infinity`, or app-wide via
`defaultColumnDef`) to let a human drag a column past its auto-size cap.
- **react/themes:** The header-label ellipsis now only applies to string headers
(marked `.yable-th-label`). Component/empty headers such as a selection-column
checkbox are no longer collapsed and cropped.
22 changes: 21 additions & 1 deletion apps/docs/content/docs/features/column-resizing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,33 @@ columnHelper.accessor('name', {
header: 'Name',
size: 200, // Default width in px
minSize: 100, // Minimum width
maxSize: 400, // Maximum width
maxSize: 400, // Maximum width (also caps auto-sizing/stretch)
resizeMaxSize: Infinity, // Upper bound for USER drag-resize (defaults to maxSize)
enableResizing: true,
})
```

### Separating the resize cap from `maxSize`

`maxSize` caps **both** auto-sizing/stretch **and** user drag-resize, so a column
given a `maxSize` for auto-size discipline feels non-resizable past that width.
Use `resizeMaxSize` to raise the ceiling for user drags only — `maxSize` keeps
capping auto-sizing/stretch, while a human can drag the column up to
`resizeMaxSize`. It defaults to `maxSize` (fully back-compatible).

```typescript
// App-wide: let users drag any column past its auto-size cap.
const table = useTable({
data,
columns,
enableColumnResizing: true,
defaultColumnDef: { resizeMaxSize: Infinity },
})
```

### Notes

- `columnResizeMode: 'onChange'` updates widths as the user drags
- `columnResizeMode: 'onEnd'` only updates when the user releases the mouse
- The `table.getTotalSize()` method returns the total width of all visible columns
- `resizeMaxSize` only affects user drag-resize; auto-sizing/stretch always honor `maxSize`
54 changes: 54 additions & 0 deletions e2e/interactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,3 +667,57 @@ test.describe('resize handle alignment', () => {
expect(handleBox.x + handleBox.width).toBeGreaterThan(boundary)
})
})

// The header-label ellipsis clip (added in 0.6.1) must only apply to STRING
// headers. A `selectColumn()` renders a checkbox `<label>` with no text; the
// clip previously collapsed its content span and cropped the 16px checkbox to
// 0px even at the column's natural width.
test.describe('label-less header is not clipped by the label ellipsis', () => {
test('selection checkbox header stays fully visible at size 40', async ({ page }) => {
await page.goto('/e2e/column-sizing')
const root = grid(page, 'grid-select-header')
await expect(root.locator('thead th').first()).toBeVisible()

const hitbox = root.locator('thead .yable-checkbox-hitbox')
await expect(hitbox).toBeVisible()

// The hitbox (and the checkbox it wraps) must not be collapsed by ellipsis.
const hitboxBox = (await hitbox.boundingBox())!
expect(hitboxBox.width).toBeGreaterThanOrEqual(16)

const checkbox = root.locator('thead input[type="checkbox"]')
const checkboxBox = (await checkbox.boundingBox())!
expect(checkboxBox.width).toBeGreaterThanOrEqual(12)
})
})

// `resizeMaxSize` (defaults to `maxSize`) caps USER drag-resize independently of
// `maxSize`. With `resizeMaxSize: Infinity` set app-wide via `defaultColumnDef`,
// a human can drag a `maxSize`-capped column wider than its cap.
test.describe('resizeMaxSize lets user drag past maxSize', () => {
test('dragging a maxSize-capped column grows it past maxSize', async ({ page }) => {
await page.goto('/e2e/column-sizing')
const root = grid(page, 'grid-resize-max')
const nameTh = root.locator('thead th[data-column-id="name"]')
await expect(nameTh).toBeVisible()

const handle = nameTh.locator('.yable-resize-handle')
const hb = (await handle.boundingBox())!
const before = (await nameTh.boundingBox())!.width

// Grab just inside the divider and drag well past maxSize (180).
const thb = (await nameTh.boundingBox())!
const boundary = thb.x + thb.width
const grabX = boundary - 4
const grabY = hb.y + hb.height / 2
await page.mouse.move(grabX, grabY)
await page.mouse.down()
await page.mouse.move(grabX + 200, grabY, { steps: 8 })
await page.mouse.up()

const after = (await nameTh.boundingBox())!.width
expect(after).toBeGreaterThan(before)
// maxSize is 180; without resizeMaxSize the width would clamp there.
expect(after).toBeGreaterThan(200)
})
})
79 changes: 79 additions & 0 deletions examples/react-demo/src/app/e2e/column-sizing/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use client'

// E2E fixture: column sizing edge cases.
//
// 1. Label-less (component) header must NOT be clipped by the header-label
// ellipsis. A `selectColumn()` renders a checkbox `<label>` with no text; the
// 0.6.1 ellipsis clip cropped it to 0px. e2e/interactions.spec.ts asserts the
// header checkbox/hitbox stays fully visible (≥ ~16px) even at size 40.
// 2. `resizeMaxSize` lets a user drag a `maxSize`-capped column past its cap.
// Set app-wide via `defaultColumnDef`. The spec drags the header wider than
// `maxSize` and asserts the rendered header width exceeds `maxSize`.

import { useTable, Table, createColumnHelper, selectColumn } from '@zvndev/yable-react'

interface Item {
id: number
name: string
}

const ROWS: Item[] = Array.from({ length: 8 }, (_, i) => ({
id: i + 1,
name: `Item ${i + 1}`,
}))

const col = createColumnHelper<Item>()

// --- Label-less header (selection checkbox) --------------------------------

function SelectHeaderGrid() {
const table = useTable<Item>({
data: ROWS,
columns: [
selectColumn<Item>({ size: 40 }),
col.accessor('name', { header: 'Name', size: 160 }),
],
getRowId: (row) => String(row.id),
enableRowSelection: true,
initialState: { pagination: { pageIndex: 0, pageSize: 100 } },
})
return <Table table={table} bordered />
}

// --- resizeMaxSize: drag past maxSize --------------------------------------

const RESIZE_MAX_SIZE = 180

function ResizePastMaxGrid() {
const table = useTable<Item>({
data: ROWS,
columns: [
// maxSize caps auto-sizing, but resizeMaxSize (Infinity, app-wide) lets a
// human drag past it.
col.accessor('name', { header: 'Name', size: 120, minSize: 100, maxSize: RESIZE_MAX_SIZE }),
col.accessor('id', { header: 'ID', size: 120 }),
],
getRowId: (row) => String(row.id),
defaultColumnDef: { resizeMaxSize: Number.POSITIVE_INFINITY },
enableColumnResizing: true,
columnResizeMode: 'onChange',
initialState: { pagination: { pageIndex: 0, pageSize: 100 } },
})
return <Table table={table} bordered />
}

export default function ColumnSizingFixturePage() {
return (
<main style={{ padding: 24 }}>
<h1>Column sizing fixture</h1>
<section data-testid="grid-select-header" style={{ width: 400, marginBottom: 48 }}>
<h2>Label-less header (selection checkbox)</h2>
<SelectHeaderGrid />
</section>
<section data-testid="grid-resize-max" style={{ width: 600 }}>
<h2>resizeMaxSize: drag past maxSize</h2>
<ResizePastMaxGrid />
</section>
</main>
)
}
25 changes: 23 additions & 2 deletions examples/react-demo/src/content/docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -559,19 +559,40 @@ columnHelper.accessor('name', {
header: 'Name',
size: 200, // Default width in px
minSize: 100, // Minimum width
maxSize: 400, // Maximum width
maxSize: 400, // Maximum width (also caps auto-sizing/stretch)
resizeMaxSize: Infinity, // Upper bound for USER drag-resize (defaults to maxSize)
enableResizing: true,
})
```

### Separating the resize cap from `maxSize`

`maxSize` caps **both** auto-sizing/stretch **and** user drag-resize. To let a
human drag a column past its auto-size cap, set `resizeMaxSize` — it caps user
drag-resize independently and defaults to `maxSize` (fully back-compatible).
`maxSize` keeps capping auto-sizing/stretch. Set it app-wide via
`defaultColumnDef`:

```typescript
const table = useTable({
data,
columns,
enableColumnResizing: true,
defaultColumnDef: { resizeMaxSize: Infinity },
})
```

### Notes

- `columnResizeMode: 'onChange'` updates widths as the user drags
- `columnResizeMode: 'onEnd'` only updates when the user releases the mouse
- The `table.getTotalSize()` method returns the total width of all visible columns
- `resizeMaxSize` only affects user drag-resize; auto-sizing/stretch always honor `maxSize`
- Header labels only truncate with an ellipsis for **string** headers; component headers
(e.g. a selection checkbox) are never clipped.
- Header and body columns share the same internal `<colgroup>`, so wide tables and
horizontal scrolling do not require custom CSS or ResizeObserver width-sync code.
- Prefer column definition options (`size`, `minSize`, `maxSize`, `enableResizing`)
- Prefer column definition options (`size`, `minSize`, `maxSize`, `resizeMaxSize`, `enableResizing`)
or `defaultColumnDef` for sizing policy. Use CSS only for visual theming.

### Sizing All Columns
Expand Down
53 changes: 53 additions & 0 deletions packages/core/src/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,59 @@ describe('Column Resizing', () => {
expect(getState().columnSizingInfo.isResizingColumn).toBe(false)
})

it('lets user drag-resize past maxSize when resizeMaxSize is higher', () => {
const { table, getState } = createTestTable({
columns: [
columnHelper.accessor('firstName', {
header: 'First Name',
size: 120,
minSize: 100,
maxSize: 180,
resizeMaxSize: Number.POSITIVE_INFINITY,
}),
],
})
const column = table.getColumn('firstName')!
const header = table.getHeaderGroups()[0]!.headers[0]!
const resize = header.getResizeHandler()!

withMockDocument((dispatch) => {
resize({ clientX: 0 })
dispatch('mousemove', { clientX: 200, preventDefault: vi.fn() })
dispatch('mouseup', { clientX: 200 })
})

// clampColumnSize honored resizeMax: width committed past maxSize (120 + 200).
expect(getState().columnSizing.firstName).toBe(320)
// getSize RENDERS the committed width instead of clamping back to maxSize.
expect(column.getSize()).toBe(320)
})

it('still clamps user drag-resize to maxSize when resizeMaxSize is unset (back-compat)', () => {
const { table, getState } = createTestTable({
columns: [
columnHelper.accessor('firstName', {
header: 'First Name',
size: 120,
minSize: 100,
maxSize: 180,
}),
],
})
const column = table.getColumn('firstName')!
const header = table.getHeaderGroups()[0]!.headers[0]!
const resize = header.getResizeHandler()!

withMockDocument((dispatch) => {
resize({ clientX: 0 })
dispatch('mousemove', { clientX: 200, preventDefault: vi.fn() })
dispatch('mouseup', { clientX: 200 })
})

expect(getState().columnSizing.firstName).toBe(180)
expect(column.getSize()).toBe(180)
})

it('honors columnResizeMode="onEnd"', () => {
const { table, getState } = createTestTable({
columnResizeMode: 'onEnd',
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/core/__tests__/column.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,27 @@ describe('Column sizing min/max bounds', () => {
expect(table.getColumn('name')!.getSize()).toBe(300)
})

it('renders a size up to resizeMaxSize when set above maxSize', () => {
const { table } = makeTable([
{ accessorKey: 'name', header: 'Name', size: 500, maxSize: 300, resizeMaxSize: 600 },
])
// getSize's upper clamp is resizeMaxSize (600), not maxSize (300).
expect(table.getColumn('name')!.getSize()).toBe(500)
})

it('applies no upper clamp when resizeMaxSize is Infinity', () => {
const { table } = makeTable([
{
accessorKey: 'name',
header: 'Name',
size: 5000,
maxSize: 300,
resizeMaxSize: Number.POSITIVE_INFINITY,
},
])
expect(table.getColumn('name')!.getSize()).toBe(5000)
})

it('warns once when minSize > maxSize and resolves to a sane size (min wins)', () => {
const { table } = makeTable([
// minSize 400, maxSize 200 — invalid; min wins per documented policy.
Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/core/column.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ export function createColumn<TData extends RowData, TValue = unknown>(
const min = ext.minSize
const max = ext.maxSize

// A user-set width may render up to `resizeMaxSize` (defaults to `maxSize`).
// Auto/stretch widths are already clamped to `maxSize` upstream (in
// `computeAutoColumnWidths`), so raising this render ceiling only affects
// widths a human dragged — it never lets auto-sizing exceed `maxSize`.
const resizeMax = typeof ext.resizeMaxSize === 'number' ? ext.resizeMaxSize : max

// Validate bounds: if a user accidentally sets minSize > maxSize the
// resulting clamp would silently flip the size. Warn once and resolve
// by treating minSize as the floor (i.e. min wins). This is documented
Expand All @@ -138,13 +144,16 @@ export function createColumn<TData extends RowData, TValue = unknown>(
warnedInvalidSizeBounds = true
console.warn(`[yable] column "${id}" has minSize (${min}) > maxSize (${max})`)
}
// Min wins: clamp the raw size up to min, ignoring the broken max.
}

// Min wins on inversion against the resolved upper bound.
if (typeof min === 'number' && typeof resizeMax === 'number' && min > resizeMax) {
return Math.max(raw, min)
}

let resolved = raw
if (typeof min === 'number') resolved = Math.max(resolved, min)
if (typeof max === 'number') resolved = Math.min(resolved, max)
if (typeof resizeMax === 'number') resolved = Math.min(resolved, resizeMax)
return resolved
},
getStart: (position?: ColumnPinningPosition) => {
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/core/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@ function clampColumnSize<TData extends RowData, TValue>(
column: Column<TData, TValue>,
size: number,
): number {
const { minSize, maxSize } = column.columnDef
const { minSize, maxSize, resizeMaxSize } = column.columnDef
let next = Math.max(size, 30)

// User drag-resize is capped by `resizeMaxSize` (defaults to `maxSize`), so a
// column can be given a `maxSize` for auto-size discipline while still being
// draggable past it. `maxSize` continues to cap auto-sizing/stretch elsewhere.
const resizeMax = typeof resizeMaxSize === 'number' ? resizeMaxSize : maxSize

if (typeof minSize === 'number') next = Math.max(next, minSize)
if (typeof maxSize === 'number' && !(typeof minSize === 'number' && minSize > maxSize)) {
next = Math.min(next, maxSize)
if (typeof resizeMax === 'number' && !(typeof minSize === 'number' && minSize > resizeMax)) {
next = Math.min(next, resizeMax)
}

return next
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ export interface ColumnDefExtensions<TData extends RowData, TValue = unknown> {
size?: number
minSize?: number
maxSize?: number
/**
* Upper bound for USER drag-resize (defaults to `maxSize`). `maxSize` still
* caps auto-sizing/stretch; set this (e.g. `Number.POSITIVE_INFINITY`, or via
* `defaultColumnDef` for app-wide) to let a human drag a column past its
* auto-size cap.
*/
resizeMaxSize?: number
flex?: number
/**
* Opt out of the React `autoColumnWidth` feature for this column. When `false`
Expand Down
Loading
Loading