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
31 changes: 31 additions & 0 deletions .changeset/olive-buttons-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@zvndev/yable-core': minor
'@zvndev/yable-react': patch
'@zvndev/yable-themes': minor
---

Symmetric select-all handlers and token-driven header/cell typography.

**core:** `table.getToggleAllRowsSelectedHandler()` and
`table.getToggleAllPageRowsSelectedHandler()` mirror `row.getToggleSelectedHandler()`.
Both ignore their event argument, so wiring one straight to `onChange` toggles
instead of reading the event object as a truthy `value` flag (the trap in the old
`onChange={table.toggleAllPageRowsSelected}` docs example).

**themes:** new typography tokens, all inheriting existing defaults so nothing
renders differently out of the box:

- `--yable-font-family-header`, `--yable-font-family-cell` (both default to
`--yable-font-family`), so mono data under sans headers is a one-line override
- `--yable-font-weight-header`, `--yable-header-text-transform`,
`--yable-header-letter-spacing`

Every bundled theme now declares its header typography through these tokens
instead of hardcoding it on its own `.yable-th` rule. That rule outranked any
token set on the grid root, so killing a theme's uppercase headers previously
required class overrides; setting `--yable-header-text-transform` is now enough.
Density utilities that set `--yable-font-size-header` also reach the header for
the first time. `createTheme()` gains matching `fontFamilyHeader`,
`fontFamilyCell`, `fontWeightHeader`, `headerTextTransform`, and
`headerLetterSpacing` keys, and the Tailwind preset gains `font-yable-header` /
`font-yable-cell`.
10 changes: 10 additions & 0 deletions .changeset/olive-moons-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@zvndev/yable-core': patch
'@zvndev/yable-react': patch
'@zvndev/yable-themes': patch
'@zvndev/yable-vanilla': patch
---

Export `./package.json` from every package. `require.resolve('@zvndev/yable-react/package.json')`
previously threw `ERR_PACKAGE_PATH_NOT_EXPORTED`, which breaks common
version-introspection tooling.
29 changes: 29 additions & 0 deletions .changeset/quiet-planets-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@zvndev/yable-react': minor
---

Fix container underflow on fully sized tables, and stop `clickableRows` gating `onRowClick`.

**Underflow.** `autoColumnWidth`'s `distribute` / `stretch` policies only grew
auto-sized columns, so a table where every column has an explicit `size` had
nothing to grow: it pinned to its sized total and left a dead band on the right
that no policy could close. When a table has no auto columns, every visible
column now participates instead. New `underflow: 'stretch-last'` hands the whole
remainder to the last visible column (ignoring its `maxSize`) and leaves the rest
at their exact widths.

Resolved widths flow through `columnSizing`, so the sticky header, the body, the
virtualized inner table, and pinned offsets all read one set of numbers. The CSS
workaround for this (`.yable-table { min-width: 100% }`) let the header and the
virtualized body resolve the slack independently and drift out of column
alignment. Widths an underflow policy grows are tracked against their declared
base, so shrinking the container un-stretches instead of ratcheting upward.

**`clickableRows`.** `row:click` was only emitted when the rendered `<Table>` had
`clickableRows` set, so passing `onRowClick` to `useTable` without also setting
that prop silently did nothing (`row:dblclick` and `row:contextmenu` always
emitted unconditionally). `row:click` now emits unconditionally, and
`clickableRows` is purely the visual affordance: it defaults on when the table
has an `onRowClick` handler, and `clickableRows={false}` drops the affordance
while keeping the handler. `clickableRows` can also be set on `YableProvider`'s
`tableProps` now, like every other visual default.
34 changes: 34 additions & 0 deletions .changeset/tidy-hoops-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@zvndev/yable-core': minor
'@zvndev/yable-react': minor
'@zvndev/yable-vanilla': minor
'@zvndev/yable-themes': minor
---

Column alignment, a `data-sorted` hook, and tokens for frosted headers and detail accents.

**`align` on a column def** (`'left' | 'center' | 'right'`) emits `data-align` on
that column's header, body, and footer cells, so one declaration replaces a
per-cell style. Right-aligned body cells also get `font-variant-numeric:
tabular-nums`, which is what numeric columns want. Header labels live in a flex
wrapper, so the alignment is applied there too rather than through `text-align`
alone. Supported by both the React and vanilla renderers.

**`data-sorted="asc|desc"`** on `.yable-th` mirrors `aria-sort` as a plain
attribute. Highlighting the actively sorted column previously meant
`:has(.yable-sort-indicator[data-active='true'])`, which reaches into the sort
indicator's internals. Vanilla footers/headers also gained `data-column-id` on
footer cells for targeting.

**New theme tokens**, all defaulting to visual no-ops:

- `--yable-header-backdrop-filter` (with the `-webkit-` prefix applied for
Safari) makes a frosted sticky header declarative: pair it with a translucent
`--yable-bg-header`.
- `--yable-detail-accent-width` / `--yable-detail-accent-color` draw an inset
edge on an expanded master-detail panel, tying it to its parent row.

`createTheme()` gained `headerBackdropFilter`, `detailAccentWidth`, and
`detailAccentColor`. The themes README now documents the density presets
(`<Table density>` / `.yable--density-*`), which already replace hand-setting six
spacing tokens, plus the data-attribute styling hooks.
10 changes: 10 additions & 0 deletions apps/docs/content/docs/api/column-definition-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,22 @@ interface ColumnDefExtensions<TData, TValue> {
editConfig?: CellEditConfig

// Styling
align?: 'left' | 'center' | 'right'
cellClassName?: string | ((ctx: CellContext) => string | undefined)
headerClassName?: string
footerClassName?: string
}
```

`align` sets the horizontal alignment for the column's header, body, and footer
cells at once, emitting `data-align` on each. Right-aligned body cells also get
`font-variant-numeric: tabular-nums`, so numeric columns line up digit by digit
without a per-cell style:

```tsx
columnHelper.accessor('amount', { header: 'Amount', align: 'right' })
```

### CellEditConfig

```typescript
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/docs/api/table-instance.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ The object returned by `createTable()` or `useTable()`. All methods are grouped
| `getIsSomePageRowsSelected()` | `boolean` | Some current page rows selected? |
| `toggleAllRowsSelected(value?)` | `void` | Select/deselect all rows |
| `toggleAllPageRowsSelected(value?)` | `void` | Select/deselect current page rows |
| `getToggleAllRowsSelectedHandler()` | `(event) => void` | Event handler that toggles all rows |
| `getToggleAllPageRowsSelectedHandler()` | `(event) => void` | Event handler that toggles current page rows |
| `setRowSelection(updater)` | `void` | Set row selection state |
| `resetRowSelection(defaultState?)` | `void` | Reset row selection |

Expand Down
19 changes: 18 additions & 1 deletion apps/docs/content/docs/features/column-sizing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ policy, or an object to tune it.
autoColumnWidth={{
sampleSize: 100, // rows sampled for measurement (default 100)
overflow: 'fit', // 'fit' | 'scroll' (default 'fit')
underflow: 'leave', // 'leave' | 'distribute' | 'stretch' (default 'leave')
underflow: 'leave', // 'leave' | 'distribute' | 'stretch' | 'stretch-last' (default 'leave')
fitThreshold: 0.15, // compress instead of scroll when overflow ≤ 15% (default 0 = off)
}}
/>
Expand Down Expand Up @@ -74,12 +74,29 @@ When natural widths sum to **less** than the container:
proportionally so the container fills **exactly** — never a gutter. Use this on
modal/settings tables with a few capped columns where a trailing dead gutter
looks broken.
- **`stretch-last`** — the entire remainder goes to the **last visible column**,
ignoring its `maxSize`. Every other column keeps its exact width. Use this when
one trailing column (a title, a notes field) should absorb the slack.

Both `distribute` and `stretch` are total-preserving: final widths sum to the
container width (with the ±1px rounding remainder folded into the last grown
column), except `distribute` when every column is capped, which intentionally
leaves the remainder as a gutter.

<Callout type="info">
**Fully sized tables fill too.** Growth normally targets auto-sized columns. A table where **every** column has an
explicit `size` has none, so `distribute` and `stretch` used to have nothing to
grow and left a dead band on the right no matter what you set. When there are no
auto columns, every visible column participates instead, so a fully-sized table
fills its container. `stretch-last` always targets the last column, auto or not.

Solving this in the sizing layer rather than with CSS matters: the widths flow
through `columnSizing`, so the sticky header, the body, the virtualized inner
table, and pinned offsets all read the same numbers. A blanket
`.yable-table { min-width: 100% }` lets the header and the virtualized body
resolve the slack independently and drift out of alignment.
</Callout>

### Per-column opt-out

A column keeps its width and is excluded from measurement and squishing when it
Expand Down
5 changes: 5 additions & 0 deletions apps/docs/content/docs/features/event-system.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,8 @@ const table = useTable({
onHeaderClick: (event) => console.log('Header clicked:', event),
})
```

`onRowClick` fires on every row click, whether or not the `<Table>` element sets
`clickableRows`. That prop is a purely visual affordance (pointer cursor, hover
treatment) and defaults on when the table has an `onRowClick` handler; set
`clickableRows={false}` to keep the handler but drop the affordance.
2 changes: 1 addition & 1 deletion apps/docs/content/docs/features/row-selection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ columnHelper.display({
<input
type="checkbox"
checked={table.getIsAllPageRowsSelected()}
onChange={table.toggleAllPageRowsSelected}
onChange={table.getToggleAllPageRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
Expand Down
38 changes: 25 additions & 13 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,19 +532,21 @@ The object returned by `createTable()` or `useTable()`. All methods are grouped

### Selection API

| Method | Return | Description |
| ----------------------------------- | ---------- | --------------------------------- |
| `getSelectedRowModel()` | `RowModel` | Model of selected rows |
| `getFilteredSelectedRowModel()` | `RowModel` | Selected rows (filtered) |
| `getGroupedSelectedRowModel()` | `RowModel` | Selected rows (grouped) |
| `getIsAllRowsSelected()` | `boolean` | All rows selected? |
| `getIsSomeRowsSelected()` | `boolean` | Some (but not all) rows selected? |
| `getIsAllPageRowsSelected()` | `boolean` | All current page rows selected? |
| `getIsSomePageRowsSelected()` | `boolean` | Some current page rows selected? |
| `toggleAllRowsSelected(value?)` | `void` | Select/deselect all rows |
| `toggleAllPageRowsSelected(value?)` | `void` | Select/deselect current page rows |
| `setRowSelection(updater)` | `void` | Set row selection state |
| `resetRowSelection(defaultState?)` | `void` | Reset row selection |
| Method | Return | Description |
| --------------------------------------- | ----------------- | -------------------------------------------- |
| `getSelectedRowModel()` | `RowModel` | Model of selected rows |
| `getFilteredSelectedRowModel()` | `RowModel` | Selected rows (filtered) |
| `getGroupedSelectedRowModel()` | `RowModel` | Selected rows (grouped) |
| `getIsAllRowsSelected()` | `boolean` | All rows selected? |
| `getIsSomeRowsSelected()` | `boolean` | Some (but not all) rows selected? |
| `getIsAllPageRowsSelected()` | `boolean` | All current page rows selected? |
| `getIsSomePageRowsSelected()` | `boolean` | Some current page rows selected? |
| `toggleAllRowsSelected(value?)` | `void` | Select/deselect all rows |
| `toggleAllPageRowsSelected(value?)` | `void` | Select/deselect current page rows |
| `getToggleAllRowsSelectedHandler()` | `(event) => void` | Event handler that toggles all rows |
| `getToggleAllPageRowsSelectedHandler()` | `(event) => void` | Event handler that toggles current page rows |
| `setRowSelection(updater)` | `void` | Set row selection state |
| `resetRowSelection(defaultState?)` | `void` | Reset row selection |

### Visibility API

Expand Down Expand Up @@ -1145,12 +1147,22 @@ interface ColumnDefExtensions<TData, TValue> {
rowSpan?: (row: Row, rows: Row[], rowIndex: number) => number | undefined

// Styling
align?: 'left' | 'center' | 'right'
cellClassName?: string | ((ctx: CellContext) => string | undefined)
headerClassName?: string
footerClassName?: string
}
```

`align` sets the horizontal alignment for the column's header, body, and footer
cells at once, emitting `data-align` on each. Right-aligned body cells also get
`font-variant-numeric: tabular-nums`, so numeric columns line up digit by digit
without a per-cell style:

```tsx
columnHelper.accessor('amount', { header: 'Amount', align: 'right' })
```

### CellEditConfig

```typescript
Expand Down
7 changes: 6 additions & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -792,7 +792,7 @@ columnHelper.display({
<input
type="checkbox"
checked={table.getIsAllPageRowsSelected()}
onChange={table.toggleAllPageRowsSelected}
onChange={table.getToggleAllPageRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
Expand Down Expand Up @@ -1398,6 +1398,11 @@ const table = useTable({
})
```

`onRowClick` fires on every row click, whether or not the `<Table>` element sets
`clickableRows`. That prop is a purely visual affordance (pointer cursor, hover
treatment) and defaults on when the table has an `onRowClick` handler; set
`clickableRows={false}` to keep the handler but drop the affordance.

---

## i18n
Expand Down
60 changes: 60 additions & 0 deletions e2e/interactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,66 @@ test.describe('smart column width', () => {
.poll(async () => (await amountHeader.boundingBox())?.width ?? 0, { timeout: 5000 })
.toBeGreaterThan(placeholderWidth + 40)
})

// A table where EVERY column has an explicit `size` has no auto columns, so
// the underflow waterfall had nothing to grow and left a dead band on the
// right that no policy could close.
test.describe('underflow on a fully sized table', () => {
const SIZED_TOTAL = 120 + 140 + 220

test('leave keeps the dead band (unchanged default)', async ({ page }) => {
const grid = page.getByTestId('auto-underflow-leave')
await expect(grid.locator('tbody tr').first()).toBeVisible()
const header = grid.locator('.yable-thead').first()
const width = (await header.boundingBox())?.width ?? 0
expect(Math.abs(width - SIZED_TOTAL)).toBeLessThanOrEqual(4)
})

for (const policy of ['stretch', 'stretch-last'] as const) {
test(`${policy} fills the container and keeps header and body aligned`, async ({ page }) => {
const grid = page.getByTestId(`auto-underflow-${policy}`)
const scroller = grid.locator('.yable-virtual-scroll-container')
await expect(scroller).toBeVisible()
await expect(grid.locator('tbody tr').first()).toBeVisible()

const { overflow, headerW, clientW } = await scroller.evaluate((n) => {
const header = n.querySelector('.yable-thead')
return {
overflow: n.scrollWidth - n.clientWidth,
headerW: header ? (header as HTMLElement).getBoundingClientRect().width : 0,
clientW: n.clientWidth,
}
})

// Fills the container: no dead band, and no horizontal scrollbar either.
expect(headerW).toBeGreaterThanOrEqual(clientW - 2)
expect(headerW).toBeGreaterThan(SIZED_TOTAL + 100)
expect(overflow).toBeLessThanOrEqual(2)

// The CSS workaround for this (`min-width:100%`) let the sticky header
// and the virtualized body resolve the slack independently and drift.
// Solving it through columnSizing keeps every layer on one colgroup.
for (const id of ['code', 'region', 'notes']) {
const headerBox = await grid.locator(`th[data-column-id="${id}"]`).first().boundingBox()
const cellBox = await grid.locator(`td[data-column-id="${id}"]`).first().boundingBox()
expect(Math.abs((headerBox?.x ?? 0) - (cellBox?.x ?? -1))).toBeLessThanOrEqual(1)
expect(Math.abs((headerBox?.width ?? 0) - (cellBox?.width ?? -1))).toBeLessThanOrEqual(1)
}
})
}

test('stretch-last gives the slack to the last column only', async ({ page }) => {
const grid = page.getByTestId('auto-underflow-stretch-last')
await expect(grid.locator('tbody tr').first()).toBeVisible()

const width = async (id: string) =>
(await grid.locator(`th[data-column-id="${id}"]`).first().boundingBox())?.width ?? 0

expect(Math.abs((await width('code')) - 120)).toBeLessThanOrEqual(2)
expect(Math.abs((await width('region')) - 140)).toBeLessThanOrEqual(2)
expect(await width('notes')).toBeGreaterThan(220 + 100)
})
})
})

// The resize handle (visual bar AND pointer hit zone) must sit ON the column
Expand Down
Loading
Loading