diff --git a/.changeset/olive-buttons-shave.md b/.changeset/olive-buttons-shave.md new file mode 100644 index 0000000..e923fd1 --- /dev/null +++ b/.changeset/olive-buttons-shave.md @@ -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`. diff --git a/.changeset/olive-moons-count.md b/.changeset/olive-moons-count.md new file mode 100644 index 0000000..eb117e7 --- /dev/null +++ b/.changeset/olive-moons-count.md @@ -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. diff --git a/.changeset/quiet-planets-repeat.md b/.changeset/quiet-planets-repeat.md new file mode 100644 index 0000000..d4436f4 --- /dev/null +++ b/.changeset/quiet-planets-repeat.md @@ -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 `` 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. diff --git a/.changeset/tidy-hoops-tell.md b/.changeset/tidy-hoops-tell.md new file mode 100644 index 0000000..a2ca3fa --- /dev/null +++ b/.changeset/tidy-hoops-tell.md @@ -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 +(`
` / `.yable--density-*`), which already replace hand-setting six +spacing tokens, plus the data-attribute styling hooks. diff --git a/apps/docs/content/docs/api/column-definition-types.mdx b/apps/docs/content/docs/api/column-definition-types.mdx index 00e3238..d984790 100644 --- a/apps/docs/content/docs/api/column-definition-types.mdx +++ b/apps/docs/content/docs/api/column-definition-types.mdx @@ -62,12 +62,22 @@ interface ColumnDefExtensions { 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 diff --git a/apps/docs/content/docs/api/table-instance.mdx b/apps/docs/content/docs/api/table-instance.mdx index 5dfeaac..dfc05b3 100644 --- a/apps/docs/content/docs/api/table-instance.mdx +++ b/apps/docs/content/docs/api/table-instance.mdx @@ -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 | diff --git a/apps/docs/content/docs/features/column-sizing.mdx b/apps/docs/content/docs/features/column-sizing.mdx index 3e244bd..7ab7b64 100644 --- a/apps/docs/content/docs/features/column-sizing.mdx +++ b/apps/docs/content/docs/features/column-sizing.mdx @@ -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) }} /> @@ -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. + +**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. + + ### Per-column opt-out A column keeps its width and is excluded from measurement and squishing when it diff --git a/apps/docs/content/docs/features/event-system.mdx b/apps/docs/content/docs/features/event-system.mdx index 5ac72e5..ea07f89 100644 --- a/apps/docs/content/docs/features/event-system.mdx +++ b/apps/docs/content/docs/features/event-system.mdx @@ -79,3 +79,8 @@ const table = useTable({ onHeaderClick: (event) => console.log('Header clicked:', event), }) ``` + +`onRowClick` fires on every row click, whether or not the `
` 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. diff --git a/apps/docs/content/docs/features/row-selection.mdx b/apps/docs/content/docs/features/row-selection.mdx index 53b587c..0b58d51 100644 --- a/apps/docs/content/docs/features/row-selection.mdx +++ b/apps/docs/content/docs/features/row-selection.mdx @@ -57,7 +57,7 @@ columnHelper.display({ ), cell: ({ row }) => ( diff --git a/docs/API.md b/docs/API.md index 312d025..ec6cf5f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 @@ -1145,12 +1147,22 @@ interface ColumnDefExtensions { 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 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 8b946e8..e189bac 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -792,7 +792,7 @@ columnHelper.display({ ), cell: ({ row }) => ( @@ -1398,6 +1398,11 @@ const table = useTable({ }) ``` +`onRowClick` fires on every row click, whether or not the `
` 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 diff --git a/e2e/interactions.spec.ts b/e2e/interactions.spec.ts index dacb4d2..58c0587 100644 --- a/e2e/interactions.spec.ts +++ b/e2e/interactions.spec.ts @@ -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 diff --git a/examples/react-demo/src/app/e2e/auto-column-width/page.tsx b/examples/react-demo/src/app/e2e/auto-column-width/page.tsx index c4042d2..7513deb 100644 --- a/examples/react-demo/src/app/e2e/auto-column-width/page.tsx +++ b/examples/react-demo/src/app/e2e/auto-column-width/page.tsx @@ -179,6 +179,48 @@ function AsyncMergeGrid() { ) } +// --- Underflow on a FULLY SIZED table -------------------------------------- +// Every column has an explicit `size`, so there are no auto columns to grow and +// the sized total (480px) is far narrower than the 900px container. Before the +// no-auto-columns fallback this left a dead band on the right that no underflow +// policy could close. Row virtualization + sticky header are on because the +// CSS workaround for the dead band (`min-width:100%`) let the header and the +// virtualized body resolve the slack independently and drift out of alignment — +// the spec asserts header and body cells stay column-aligned. + +interface SizedRow { + id: number + code: string + region: string + notes: string +} + +const SIZED_ROWS: SizedRow[] = Array.from({ length: 60 }, (_, i) => ({ + id: i + 1, + code: `SKU-${1000 + i}`, + region: ['Marlborough', 'Casablanca', 'Willamette'][i % 3]!, + notes: 'Bin check pending', +})) + +const sizedCol = createColumnHelper() +const fullySizedColumns = [ + sizedCol.accessor('code', { header: 'Code', size: 120 }), + sizedCol.accessor('region', { header: 'Region', size: 140 }), + sizedCol.accessor('notes', { header: 'Notes', size: 220 }), +] + +function FullySizedGrid({ underflow }: { underflow: 'stretch' | 'stretch-last' | 'leave' }) { + const table = useTable({ + data: SIZED_ROWS, + columns: fullySizedColumns, + getRowId: (row) => String(row.id), + enableVirtualization: true, + virtualViewportHeight: 240, + rowHeight: 36, + }) + return
+} + export default function AutoColumnWidthFixturePage() { return (
@@ -199,10 +241,22 @@ export default function AutoColumnWidthFixturePage() {

overflow: scroll

-
+

autoSizeText (rendered-content measurement)

+
+

fully sized, underflow: leave

+ +
+
+

fully sized, underflow: stretch

+ +
+
+

fully sized, underflow: stretch-last

+ +
) } diff --git a/packages/core/package.json b/packages/core/package.json index f695288..72900ea 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -86,7 +86,8 @@ "types": "./dist/features/pivot.d.cts", "default": "./dist/features/pivot.cjs" } - } + }, + "./package.json": "./package.json" }, "files": [ "dist" diff --git a/packages/core/src/core/__tests__/selection-scope.test.ts b/packages/core/src/core/__tests__/selection-scope.test.ts index 397f0e3..b58c1c1 100644 --- a/packages/core/src/core/__tests__/selection-scope.test.ts +++ b/packages/core/src/core/__tests__/selection-scope.test.ts @@ -68,4 +68,27 @@ describe('Row selection scope', () => { table.toggleAllPageRowsSelected(true) expect(Object.keys(getState().rowSelection).sort()).toEqual(['1', '2']) }) + + // The handler form mirrors row.getToggleSelectedHandler(): it ignores its + // event argument, so wiring it straight to onChange still TOGGLES rather than + // reading the event object as the `value` flag (which is always truthy). + it('getToggleAllRowsSelectedHandler toggles instead of reading the event as value', () => { + const { table, getState } = makeTable(data, cols) + const handler = table.getToggleAllRowsSelectedHandler() + handler({ target: { checked: false } }) + expect(Object.keys(getState().rowSelection).sort()).toEqual(['1', '2', '3', '4']) + handler({ target: { checked: true } }) + expect(getState().rowSelection).toEqual({}) + }) + + it('getToggleAllPageRowsSelectedHandler toggles only the current page', () => { + const { table, getState } = makeTable(data, cols, { + initialState: { pagination: { pageIndex: 0, pageSize: 2 } }, + }) + const handler = table.getToggleAllPageRowsSelectedHandler() + handler(new Event('change')) + expect(Object.keys(getState().rowSelection).sort()).toEqual(['1', '2']) + handler(new Event('change')) + expect(getState().rowSelection).toEqual({}) + }) }) diff --git a/packages/core/src/core/table.ts b/packages/core/src/core/table.ts index b1e8280..4f58d66 100644 --- a/packages/core/src/core/table.ts +++ b/packages/core/src/core/table.ts @@ -473,6 +473,16 @@ export function createTable(options: TableOptions) return next }) }, + getToggleAllRowsSelectedHandler: () => { + return (_e: unknown) => { + table.toggleAllRowsSelected() + } + }, + getToggleAllPageRowsSelectedHandler: () => { + return (_e: unknown) => { + table.toggleAllPageRowsSelected() + } + }, setRowSelection: (_updater: Updater) => {}, resetRowSelection: (defaultState?: boolean) => { table.setRowSelection(defaultState ? {} : table.getState().rowSelection) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index db677ec..6d69bee 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -210,6 +210,13 @@ export interface ColumnDefExtensions { rowSpan?: (row: Row, rows: Row[], rowIndex: number) => number | undefined // Styling + /** + * Horizontal alignment for this column's header AND body cells. Emits + * `data-align` on both, so one declaration centralizes what would otherwise be + * a per-cell style. Right-aligned body cells also get tabular figures, which + * is what numeric columns want. + */ + align?: 'left' | 'center' | 'right' cellClassName?: string | ((ctx: CellContext) => string | undefined) cellStyle?: | React.CSSProperties @@ -930,6 +937,8 @@ export interface Table { getIsSomePageRowsSelected: () => boolean toggleAllRowsSelected: (value?: boolean) => void toggleAllPageRowsSelected: (value?: boolean) => void + getToggleAllRowsSelectedHandler: () => (event: unknown) => void + getToggleAllPageRowsSelectedHandler: () => (event: unknown) => void setRowSelection: (updater: Updater) => void resetRowSelection: (defaultState?: boolean) => void getCellSelectionRange: () => CellRange | null diff --git a/packages/react/package.json b/packages/react/package.json index 1a5e0b9..b1bccfa 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -44,7 +44,8 @@ "types": "./dist/pretext.d.cts", "default": "./dist/pretext.cjs" } - } + }, + "./package.json": "./package.json" }, "files": [ "dist" diff --git a/packages/react/src/YableProvider.tsx b/packages/react/src/YableProvider.tsx index 97e04eb..c22d798 100644 --- a/packages/react/src/YableProvider.tsx +++ b/packages/react/src/YableProvider.tsx @@ -35,6 +35,7 @@ export interface YableDefaults { | 'direction' | 'ariaLabel' | 'adaptiveLayout' + | 'clickableRows' > > /** Default column definition merged under every table's own `defaultColumnDef` */ diff --git a/packages/react/src/__tests__/Sorting.test.tsx b/packages/react/src/__tests__/Sorting.test.tsx index c85d04a..6838df2 100644 --- a/packages/react/src/__tests__/Sorting.test.tsx +++ b/packages/react/src/__tests__/Sorting.test.tsx @@ -79,6 +79,22 @@ describe('Sorting', () => { expect(nameHeader).toHaveAttribute('aria-sort', 'descending') }) + // `data-sorted` mirrors aria-sort so themes can target the active column + // without depending on the sort indicator's internal markup. + it('mirrors the sort direction onto data-sorted', async () => { + const user = userEvent.setup() + render() + + const nameHeader = screen.getByRole('columnheader', { name: /Name/ }) + expect(nameHeader).not.toHaveAttribute('data-sorted') + + await user.click(nameHeader) + expect(nameHeader).toHaveAttribute('data-sorted', 'asc') + + await user.click(nameHeader) + expect(nameHeader).toHaveAttribute('data-sorted', 'desc') + }) + it('sorts row data in ascending order after click', async () => { const user = userEvent.setup() render() @@ -104,9 +120,7 @@ describe('Sorting', () => { await user.click(nameHeader) const cells = screen.getAllByRole('gridcell') - const nameValues = cells - .filter((_cell, i) => i % 2 === 0) - .map((cell) => cell.textContent) + const nameValues = cells.filter((_cell, i) => i % 2 === 0).map((cell) => cell.textContent) expect(nameValues).toEqual(['Charlie', 'Bob', 'Alice']) }) diff --git a/packages/react/src/__tests__/Table.test.tsx b/packages/react/src/__tests__/Table.test.tsx index 4f9df56..b7b67cc 100644 --- a/packages/react/src/__tests__/Table.test.tsx +++ b/packages/react/src/__tests__/Table.test.tsx @@ -91,6 +91,36 @@ function SelectableTable() { return
} +function AlignedTable() { + const table = useTable({ + data: testData, + columns: [ + col.accessor('name', { header: 'Name' }), + col.accessor('age', { header: 'Age', align: 'right', footer: 'Total' }), + ], + getRowId: (row) => row.id, + }) + + return
+} + +function RowClickTable({ + onRowClick, + clickableRows, +}: { + onRowClick: NonNullable>[0]['onRowClick']> + clickableRows?: boolean +}) { + const table = useTable({ + data: testData, + columns, + getRowId: (row) => row.id, + onRowClick, + }) + + return
+} + function HeaderEventsTable({ onHeaderClick, onHeaderContextMenu, @@ -167,6 +197,63 @@ describe('Table', () => { ) }) + // One `align` on the column def has to reach the header, the body, and the + // footer — otherwise it is no better than a per-cell style. + it('emits data-align on the header, body, and footer cells of a column', () => { + const { container } = render() + + expect(container.querySelector('th[data-column-id="age"]')).toHaveAttribute( + 'data-align', + 'right', + ) + expect(container.querySelector('td[data-column-id="age"]')).toHaveAttribute( + 'data-align', + 'right', + ) + expect(container.querySelector('tfoot td[data-column-id="age"]')).toHaveAttribute( + 'data-align', + 'right', + ) + + // Unaligned columns stay clean rather than emitting a default. + expect(container.querySelector('th[data-column-id="name"]')).not.toHaveAttribute('data-align') + }) + + // `onRowClick` used to require a SECOND opt-in (`clickableRows` on the Table + // element) before the event fired at all, so wiring it through `useTable` + // alone did nothing. The flag is now purely visual. + it('fires onRowClick from useTable without a Table-level clickableRows flag', () => { + const onRowClick = vi.fn() + render() + + fireEvent.click(screen.getByText('Alice').closest('tr')!) + + expect(onRowClick).toHaveBeenCalledWith( + expect.objectContaining({ row: expect.objectContaining({ id: '1' }) }), + ) + }) + + it('marks rows clickable when an onRowClick handler is present', () => { + render() + + expect(screen.getByText('Alice').closest('tr')).toHaveAttribute('data-clickable', 'true') + }) + + it('leaves rows unmarked with no handler, and honours an explicit opt-out', () => { + const { unmount } = render() + expect(screen.getByText('Alice').closest('tr')).not.toHaveAttribute('data-clickable') + unmount() + + const onRowClick = vi.fn() + render() + const row = screen.getByText('Alice').closest('tr')! + expect(row).not.toHaveAttribute('data-clickable') + + // Opting out of the affordance must not opt out of the event. + fireEvent.click(row) + expect(onRowClick).toHaveBeenCalledTimes(1) + }) + it('renders NoRowsOverlay when data is an empty array', () => { render() diff --git a/packages/react/src/components/AdaptiveTableCards.tsx b/packages/react/src/components/AdaptiveTableCards.tsx index 80107c6..d83b64c 100644 --- a/packages/react/src/components/AdaptiveTableCards.tsx +++ b/packages/react/src/components/AdaptiveTableCards.tsx @@ -96,7 +96,9 @@ function AdaptiveTableCard({ ) { row.toggleSelected() } - if (clickable) emitRowEvent(table, 'row:click', row, e.nativeEvent) + // Unconditional, matching TableRow: `clickable` is a visual affordance, + // not an event gate. + emitRowEvent(table, 'row:click', row, e.nativeEvent) }} onDoubleClick={(e) => emitRowEvent(table, 'row:dblclick', row, e.nativeEvent)} onContextMenu={(e) => emitRowEvent(table, 'row:contextmenu', row, e.nativeEvent)} diff --git a/packages/react/src/components/Table.tsx b/packages/react/src/components/Table.tsx index f5eb465..1a05dcd 100644 --- a/packages/react/src/components/Table.tsx +++ b/packages/react/src/components/Table.tsx @@ -102,7 +102,16 @@ export function Table({ const theme = themeProp ?? profileTableProps?.theme ?? providerTableProps?.theme const direction = directionProp ?? profileTableProps?.direction ?? providerTableProps?.direction const ariaLabel = ariaLabelProp ?? profileTableProps?.ariaLabel ?? providerTableProps?.ariaLabel - const resolvedClickableRows = clickableRows ?? profileTableProps?.clickableRows + // `clickableRows` is a VISUAL affordance (pointer cursor, hover treatment). + // It defaults on when the table was given an `onRowClick` handler, so rows + // that do something on click look like it without a second opt-in at this + // layer. `clickableRows={false}` still wins, and the `row:click` event fires + // either way (see TableRow). + const resolvedClickableRows = + clickableRows ?? + profileTableProps?.clickableRows ?? + providerTableProps?.clickableRows ?? + Boolean(table.options.onRowClick) const resolvedStatusBar = statusBar ?? profileTableProps?.statusBar const resolvedSidebar = sidebar ?? profileTableProps?.sidebar const resolvedSidebarPanels = sidebarPanels ?? diff --git a/packages/react/src/components/TableBody.tsx b/packages/react/src/components/TableBody.tsx index be5ff24..ad283f2 100644 --- a/packages/react/src/components/TableBody.tsx +++ b/packages/react/src/components/TableBody.tsx @@ -508,14 +508,16 @@ function TableRowInner({ row.toggleSelected() } - if (clickable) { - table.events.emit('row:click', { - row, - event: e.nativeEvent, - } as any) - } + // Emitted regardless of `clickable`: that flag is a visual affordance, and + // gating the event on it silently swallowed `onRowClick` for anyone who + // wired the handler through `useTable` alone. `row:dblclick` and + // `row:contextmenu` below have always emitted unconditionally. + table.events.emit('row:click', { + row, + event: e.nativeEvent, + } as any) }, - [clickable, table, row], + [table, row], ) const handleDoubleClick = useCallback( diff --git a/packages/react/src/components/TableCell.tsx b/packages/react/src/components/TableCell.tsx index bdc1705..971c5d3 100644 --- a/packages/react/src/components/TableCell.tsx +++ b/packages/react/src/components/TableCell.tsx @@ -189,6 +189,7 @@ export function TableCell({ data-pinned={pinned || undefined} data-cell-status={cellStatus !== 'idle' ? cellStatus : undefined} data-column-id={column.id} + data-align={column.columnDef.align || undefined} data-grouped={isGroupRow || undefined} data-row-index={rowIndex} data-column-index={columnIndex} diff --git a/packages/react/src/components/TableFooter.tsx b/packages/react/src/components/TableFooter.tsx index fb1b16a..4517e19 100644 --- a/packages/react/src/components/TableFooter.tsx +++ b/packages/react/src/components/TableFooter.tsx @@ -26,7 +26,13 @@ export function TableFooter({ table }: TableFooterProps + ) diff --git a/packages/react/src/components/TableHeader.tsx b/packages/react/src/components/TableHeader.tsx index 686824e..cae626c 100644 --- a/packages/react/src/components/TableHeader.tsx +++ b/packages/react/src/components/TableHeader.tsx @@ -399,7 +399,12 @@ function HeaderCell({ className={thClassName} style={style} data-column-id={column.id} + data-align={column.columnDef.align || undefined} data-sortable={canSort || undefined} + // Mirrors `aria-sort` as a plain attribute so themes can target the + // actively sorted header without reaching into the indicator's internals + // (`:has(.yable-sort-indicator[data-active='true'])`). + data-sorted={sortDirection || undefined} data-pinned={pinned || undefined} data-reorderable={(canReorder && !pinned) || undefined} data-reordering={(dragActive && !pinned) || undefined} diff --git a/packages/react/src/hooks/__tests__/useAutoColumnSizing.test.ts b/packages/react/src/hooks/__tests__/useAutoColumnSizing.test.ts index 032503b..d949300 100644 --- a/packages/react/src/hooks/__tests__/useAutoColumnSizing.test.ts +++ b/packages/react/src/hooks/__tests__/useAutoColumnSizing.test.ts @@ -154,6 +154,125 @@ describe('computeAutoColumnWidths', () => { }) }) + // A fully-sized table has no auto columns, so the old distribute/stretch + // waterfall had nothing to grow and left a dead band on the right no matter + // which policy was set. + describe('underflow — table with no auto columns', () => { + it('stretch: falls back to all columns and fills the container exactly', () => { + const { widths, grownFixedIds } = computeAutoColumnWidths({ + columns: [fixed('a', 100), fixed('b', 300)], + containerWidth: 800, + overflow: 'fit', + underflow: 'stretch', + canSquish: true, + }) + expect(widths.a! + widths.b!).toBe(800) + expect(widths).toEqual({ a: 200, b: 600 }) + expect(grownFixedIds.sort()).toEqual(['a', 'b']) + }) + + it('distribute: fills the container and honours maxSize as a hard cap', () => { + const { widths, grownFixedIds } = computeAutoColumnWidths({ + columns: [ + { id: 'a', natural: 100, auto: false, maxSize: 150 }, + { id: 'b', natural: 100, auto: false }, + ], + containerWidth: 600, + overflow: 'fit', + underflow: 'distribute', + canSquish: true, + }) + expect(widths.a).toBe(150) + expect(widths.a! + widths.b!).toBe(600) + expect(grownFixedIds.sort()).toEqual(['a', 'b']) + }) + + it('leave: still leaves the gutter (opt-in policies only)', () => { + const { widths, grownFixedIds } = computeAutoColumnWidths({ + columns: [fixed('a', 100), fixed('b', 300)], + containerWidth: 800, + overflow: 'fit', + underflow: 'leave', + canSquish: true, + }) + expect(widths).toEqual({ a: 100, b: 300 }) + expect(grownFixedIds).toEqual([]) + }) + }) + + describe('underflow — stretch-last', () => { + it('gives the whole remainder to the last column, others untouched', () => { + const { widths, grownFixedIds } = computeAutoColumnWidths({ + columns: [fixed('a', 100), fixed('b', 200), fixed('c', 100)], + containerWidth: 800, + overflow: 'fit', + underflow: 'stretch-last', + canSquish: true, + }) + expect(widths).toEqual({ a: 100, b: 200, c: 500 }) + expect(grownFixedIds).toEqual(['c']) + }) + + it('targets the visually last column even when auto columns exist', () => { + const { widths, grownFixedIds } = computeAutoColumnWidths({ + columns: [auto('a', 100), fixed('z', 100)], + containerWidth: 500, + overflow: 'fit', + underflow: 'stretch-last', + canSquish: true, + }) + expect(widths).toEqual({ a: 100, z: 400 }) + expect(grownFixedIds).toEqual(['z']) + }) + + it('ignores maxSize on the last column (the policy guarantees a fill)', () => { + const { widths } = computeAutoColumnWidths({ + columns: [fixed('a', 100), { id: 'b', natural: 100, auto: false, maxSize: 150 }], + containerWidth: 600, + overflow: 'fit', + underflow: 'stretch-last', + canSquish: true, + }) + expect(widths).toEqual({ a: 100, b: 500 }) + }) + + it('does nothing when content already fills the container', () => { + const { widths, grownFixedIds } = computeAutoColumnWidths({ + columns: [fixed('a', 250), fixed('b', 250)], + containerWidth: 500, + overflow: 'fit', + underflow: 'stretch-last', + canSquish: true, + }) + expect(widths).toEqual({ a: 250, b: 250 }) + expect(grownFixedIds).toEqual([]) + }) + }) + + describe('grownFixedIds', () => { + it('is empty when auto columns absorb the slack themselves', () => { + const { grownFixedIds } = computeAutoColumnWidths({ + columns: [auto('a', 100), fixed('b', 100)], + containerWidth: 500, + overflow: 'fit', + underflow: 'stretch', + canSquish: true, + }) + expect(grownFixedIds).toEqual([]) + }) + + it('is empty on overflow, where no column grows', () => { + const { grownFixedIds } = computeAutoColumnWidths({ + columns: [fixed('a', 400), fixed('b', 400)], + containerWidth: 500, + overflow: 'scroll', + underflow: 'stretch', + canSquish: true, + }) + expect(grownFixedIds).toEqual([]) + }) + }) + describe('overflow — natural total exceeds container', () => { it('scroll: keeps natural widths (grid scrolls), no wrap', () => { const { widths, wrapColumnIds } = computeAutoColumnWidths({ diff --git a/packages/react/src/hooks/useAutoColumnSizing.ts b/packages/react/src/hooks/useAutoColumnSizing.ts index 2e96cdf..35888cb 100644 --- a/packages/react/src/hooks/useAutoColumnSizing.ts +++ b/packages/react/src/hooks/useAutoColumnSizing.ts @@ -30,7 +30,7 @@ export interface ComputeAutoColumnWidthsOptions { columns: AutoSizeColumnInput[] containerWidth: number overflow: 'fit' | 'scroll' - underflow: 'leave' | 'distribute' | 'stretch' + underflow: 'leave' | 'distribute' | 'stretch' | 'stretch-last' /** Absolute floor a squished column may never drop below. Default 48. */ hardMinWidth?: number /** @@ -55,6 +55,13 @@ export interface ComputeAutoColumnWidthsResult { widths: Record /** Auto columns that were squished below natural and must wrap their cells. */ wrapColumnIds: string[] + /** + * Ids of NON-auto (explicitly sized / user-resized) columns that an underflow + * policy grew past their declared width. The caller must apply these too, + * otherwise a fully-sized table can never fill its container. Empty unless a + * no-auto-columns table ran a stretch policy. + */ + grownFixedIds: string[] /** * True only when a real `fit` downgrade occurred: `overflow: 'fit'` was * requested, squish was disabled (`canSquish: false`, e.g. row virtualization), @@ -110,14 +117,29 @@ export function computeAutoColumnWidths( // --- Underflow: natural content already fits the container ----------------- if (naturalTotal <= containerWidth) { - if ( - (underflow === 'distribute' || underflow === 'stretch') && - autoColumns.length > 0 && - containerWidth > naturalTotal - ) { - distributeExtraSpace(autoColumns, base, widths, containerWidth - naturalTotal, underflow) + const extra = containerWidth - naturalTotal + if (underflow === 'leave' || extra <= 0 || columns.length === 0) { + return { widths, wrapColumnIds: [], downgradedFit, grownFixedIds: [] } } - return { widths, wrapColumnIds: [], downgradedFit } + + if (underflow === 'stretch-last') { + // The visually last column absorbs the whole remainder, so every other + // column keeps its exact width. Deliberately ignores `maxSize`: the policy + // exists to guarantee a filled container. + const last = columns[columns.length - 1]! + widths[last.id] = base.get(last.id)! + extra + } else { + // Growth normally targets auto columns. A table with none (every column + // explicitly sized) falls back to all columns — otherwise it could never + // fill its container and would keep a dead band on the right. + const participants = autoColumns.length > 0 ? autoColumns : columns + distributeExtraSpace(participants, base, widths, extra, underflow) + } + + const grownFixedIds = columns + .filter((c) => !c.auto && widths[c.id] !== base.get(c.id)) + .map((c) => c.id) + return { widths, wrapColumnIds: [], downgradedFit, grownFixedIds } } // --- Small-overflow compression (`fitThreshold`) --------------------------- @@ -128,12 +150,12 @@ export function computeAutoColumnWidths( const overflowPx = naturalTotal - containerWidth if (fitThreshold > 0 && overflowPx <= fitThreshold * containerWidth) { squishAutoColumns(autoColumns, base, widths, overflowPx, hardMinWidth) - return { widths, wrapColumnIds: [], downgradedFit: false } + return { widths, wrapColumnIds: [], downgradedFit: false, grownFixedIds: [] } } // --- Overflow: `scroll` (or squish disabled) keeps natural widths ---------- if (overflow === 'scroll' || !canSquish) { - return { widths, wrapColumnIds: [], downgradedFit } + return { widths, wrapColumnIds: [], downgradedFit, grownFixedIds: [] } } // --- Overflow: `fit` — squish auto columns to fit, taking from slack ------- @@ -146,7 +168,7 @@ export function computeAutoColumnWidths( hardMinWidth, ) - return { widths, wrapColumnIds, downgradedFit } + return { widths, wrapColumnIds, downgradedFit, grownFixedIds: [] } } /** @@ -444,6 +466,12 @@ export function useAutoColumnSizing({ // or a persisted/restored `columnSizing` value) so we never overwrite a width // we did not set — even a persisted one present on the very first mount. const autoWrittenRef = useRef>(new Map()) + // Widths an underflow policy grew on NON-auto columns, as + // `id -> { applied, base }`. `base` is the column's declared width before the + // stretch. Without this, the next pass would read the stretched width back as + // the column's natural width and ratchet it upward on every resize, so the + // grid could never shrink back down. + const stretchWrittenRef = useRef>(new Map()) // One-shot guard so the `fit`-under-virtualization downgrade warns at most once. const warnedDowngradeRef = useRef(false) @@ -466,6 +494,9 @@ export function useAutoColumnSizing({ // scroller inside TableBody with a private ref, so resolve it by class // lookup. The scroller usually mounts AFTER this effect (with the first // rows), so it is observed lazily on each update. + // Assigned below, past an early-return path that must not construct one; + // `update` closes over it before that point. + // eslint-disable-next-line prefer-const -- see above let observer: ResizeObserver | undefined let observedScroller: HTMLElement | null = null const update = () => { @@ -589,9 +620,17 @@ export function useAutoColumnSizing({ !isExternallyProvenanced if (!isAuto) { + // Read through any stretch WE applied so the base stays the declared + // width. If the value no longer matches, something else (a user drag, + // restored state) owns it now and becomes the new base. + const size = column.getSize() + const stretched = stretchWrittenRef.current.get(column.id) + if (stretched && stretched.applied !== size) { + stretchWrittenRef.current.delete(column.id) + } return { id: column.id, - natural: column.getSize(), + natural: stretched && stretched.applied === size ? stretched.base : size, minSize: def.minSize, maxSize: def.maxSize, auto: false, @@ -619,6 +658,7 @@ export function useAutoColumnSizing({ widths, wrapColumnIds: nextWrap, downgradedFit, + grownFixedIds, } = computeAutoColumnWidths({ columns: inputs, containerWidth: fitWidth, @@ -652,6 +692,34 @@ export function useAutoColumnSizing({ changed = true } } + + // Non-auto columns an underflow policy grew. Applying them through + // `columnSizing` (rather than a CSS width override) keeps the sticky header, + // the body, the virtualized inner table, and pinned offsets on one set of + // numbers — they all read `getSize()` and the same shared colgroup. + const grown = new Set(grownFixedIds) + for (const input of inputs) { + if (input.auto) continue + const previous = stretchWrittenRef.current.get(input.id) + if (grown.has(input.id)) { + const width = widths[input.id] + if (typeof width !== 'number') continue + stretchWrittenRef.current.set(input.id, { applied: width, base: input.natural }) + if (current[input.id] !== width) { + next[input.id] = width + changed = true + } + } else if (previous) { + // Stretched on an earlier pass, not any more (the container shrank or + // the policy changed): hand the column its declared width back. + stretchWrittenRef.current.delete(input.id) + if (current[input.id] !== previous.base) { + next[input.id] = previous.base + changed = true + } + } + } + if (changed) { table.setColumnSizing(next) } diff --git a/packages/react/src/presets/selectColumn.tsx b/packages/react/src/presets/selectColumn.tsx index 9e63775..e1cc526 100644 --- a/packages/react/src/presets/selectColumn.tsx +++ b/packages/react/src/presets/selectColumn.tsx @@ -23,7 +23,7 @@ export function selectColumn( el.indeterminate = table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected() }} - onChange={() => table.toggleAllPageRowsSelected()} + onChange={table.getToggleAllPageRowsSelectedHandler()} aria-label={headerAriaLabel} /> diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index f0ceb4d..3c9799f 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -21,14 +21,22 @@ export interface AutoColumnWidthOptions { /** * When natural total ≤ container, how to spend the leftover space: * - `'leave'` (default) — keep columns at natural width, leave a gutter. - * - `'distribute'` — grow auto columns proportionally in a waterfall that + * - `'distribute'` — grow columns proportionally in a waterfall that * respects each `maxSize` as a HARD cap; a gutter remains only if EVERY - * auto column hits its `maxSize`. + * column hits its `maxSize`. * - `'stretch'` — same waterfall, but `maxSize` is a SOFT cap: if space is * still left after every column caps, grow past `maxSize` so the container * fills exactly and never gutters. + * - `'stretch-last'` — give the entire remainder to the last visible column, + * ignoring its `maxSize`. Useful when one trailing column should absorb the + * slack and the rest must keep their exact widths. + * + * Growth normally targets only auto columns (no explicit `size`, not + * user-resized). When a table has NO auto columns at all, every visible column + * participates instead — otherwise a fully-sized table could never fill its + * container and would keep a dead band on the right. */ - underflow?: 'leave' | 'distribute' | 'stretch' + underflow?: 'leave' | 'distribute' | 'stretch' | 'stretch-last' /** * Small-overflow compression, as a fraction of the container width (e.g. * `0.15` = 15%). When natural widths overflow the container by ≤ this amount, diff --git a/packages/themes/README.md b/packages/themes/README.md index c0f4267..65e90d4 100644 --- a/packages/themes/README.md +++ b/packages/themes/README.md @@ -17,17 +17,17 @@ npm install @zvndev/yable-themes import '@zvndev/yable-themes' // Or import individual pieces -import '@zvndev/yable-themes/tokens.css' // Design tokens only -import '@zvndev/yable-themes/default.css' // Default theme -import '@zvndev/yable-themes/stripe.css' // Stripe theme -import '@zvndev/yable-themes/compact.css' // Compact theme +import '@zvndev/yable-themes/tokens.css' // Design tokens only +import '@zvndev/yable-themes/default.css' // Default theme +import '@zvndev/yable-themes/stripe.css' // Stripe theme +import '@zvndev/yable-themes/compact.css' // Compact theme ``` Then apply a theme via the `theme` prop (React) or `theme` option (vanilla): ```tsx // React -
{content}
+;
// Vanilla renderTable(table, { theme: 'stripe' }) @@ -35,11 +35,11 @@ renderTable(table, { theme: 'stripe' }) ## Available Themes -| Theme | Class | Description | -|---|---|---| +| Theme | Class | Description | +| ----------- | --------------------- | ----------------------------------------------------------- | | **Default** | `yable-theme-default` | Clean, neutral table with subtle hover and selection states | -| **Stripe** | `yable-theme-stripe` | Alternating row backgrounds for easy scanning | -| **Compact** | `yable-theme-compact` | Reduced padding for dense data displays | +| **Stripe** | `yable-theme-stripe` | Alternating row backgrounds for easy scanning | +| **Compact** | `yable-theme-compact` | Reduced padding for dense data displays | All themes support **automatic dark mode** via `prefers-color-scheme: dark`. You can also force dark mode by adding `data-yable-theme="dark"` to any parent element, or lock to light mode with `data-yable-theme="light"`. @@ -47,93 +47,167 @@ All themes support **automatic dark mode** via `prefers-color-scheme: dark`. You In addition to themes, the `
` component (and `renderTable` in vanilla) accepts variant modifiers: -| Prop | CSS Class | Effect | -|---|---|---| +| Prop | CSS Class | Effect | +| -------------- | ---------------------- | --------------------------------- | | `stickyHeader` | `yable--sticky-header` | Pin the header row when scrolling | -| `striped` | `yable--striped` | Alternate row backgrounds | -| `bordered` | `yable--bordered` | Add cell borders | -| `compact` | `yable--compact` | Reduce cell padding | +| `striped` | `yable--striped` | Alternate row backgrounds | +| `bordered` | `yable--bordered` | Add cell borders | +| `compact` | `yable--compact` | Reduce cell padding | These can be combined with any theme. +### Density presets + +Don't hand-set the six spacing tokens to change density: there is a preset scale. +`
` (also `"regular"`, `"spacious"`) applies +`--yable-cell-padding-x/y`, `--yable-header-padding-x/y`, `--yable-row-min-height`, +`--yable-header-min-height`, and the font sizes as one step. Each preset declares +the full set explicitly, so a grid nested inside a differently scaled ancestor +still renders its own density. + +Without React, the same presets are plain classes: + +| Class | Rhythm | +| -------------------------- | --------------------------------------------------- | +| `yable--density-condensed` | 32px rows, 8px padding, 12px text | +| `yable--density-regular` | 40px rows, 14px padding, 13px text (the default) | +| `yable--density-spacious` | 48px rows, 20px padding, 14px text | +| `yable--dense` | 24px rows, 6px padding, 11px text (maximum density) | + +Reach for the individual tokens only when you want a rhythm none of these hit. + ## Design Tokens (CSS Custom Properties) All visual properties are controlled via CSS custom properties defined on `:root`. Override any token to customize the appearance. ### Surface Colors -| Token | Default (Light) | Description | -|---|---|---| -| `--yable-bg` | `#ffffff` | Table background | -| `--yable-bg-header` | `#fafafa` | Header row background | -| `--yable-bg-footer` | `#fafafa` | Footer background | -| `--yable-bg-row` | `transparent` | Default row background | -| `--yable-bg-row-alt` | `rgba(0,0,0,0.015)` | Alternating row background | -| `--yable-bg-row-hover` | `rgba(0,0,0,0.025)` | Row hover background | -| `--yable-bg-row-selected` | `rgba(59,130,246,0.06)` | Selected row background | -| `--yable-bg-cell-editing` | `#ffffff` | Editing cell background | -| `--yable-bg-pinned` | `#fdfdfd` | Pinned column/row background | +| Token | Default (Light) | Description | +| ------------------------- | ----------------------- | ---------------------------- | +| `--yable-bg` | `#ffffff` | Table background | +| `--yable-bg-header` | `#fafafa` | Header row background | +| `--yable-bg-footer` | `#fafafa` | Footer background | +| `--yable-bg-row` | `transparent` | Default row background | +| `--yable-bg-row-alt` | `rgba(0,0,0,0.015)` | Alternating row background | +| `--yable-bg-row-hover` | `rgba(0,0,0,0.025)` | Row hover background | +| `--yable-bg-row-selected` | `rgba(59,130,246,0.06)` | Selected row background | +| `--yable-bg-cell-editing` | `#ffffff` | Editing cell background | +| `--yable-bg-pinned` | `#fdfdfd` | Pinned column/row background | ### Text Colors -| Token | Default | Description | -|---|---|---| -| `--yable-text-primary` | `#111827` | Primary text | -| `--yable-text-secondary` | `#6b7280` | Secondary/muted text | -| `--yable-text-tertiary` | `#9ca3af` | Tertiary/disabled text | -| `--yable-text-header` | `#374151` | Header text | +| Token | Default | Description | +| -------------------------- | --------- | ---------------------- | +| `--yable-text-primary` | `#111827` | Primary text | +| `--yable-text-secondary` | `#6b7280` | Secondary/muted text | +| `--yable-text-tertiary` | `#9ca3af` | Tertiary/disabled text | +| `--yable-text-header` | `#374151` | Header text | | `--yable-text-placeholder` | `#9ca3af` | Input placeholder text | ### Borders -| Token | Default | Description | -|---|---|---| -| `--yable-border-color` | `#e5e7eb` | Standard border color | +| Token | Default | Description | +| ----------------------------- | --------- | --------------------------------- | +| `--yable-border-color` | `#e5e7eb` | Standard border color | | `--yable-border-color-strong` | `#d1d5db` | Emphasized border (header bottom) | -| `--yable-border-width` | `1px` | Border thickness | -| `--yable-border-radius` | `8px` | Container corner radius | +| `--yable-border-width` | `1px` | Border thickness | +| `--yable-border-radius` | `8px` | Container corner radius | ### Spacing -| Token | Default | Description | -|---|---|---| -| `--yable-cell-padding-x` | `16px` | Horizontal cell padding | -| `--yable-cell-padding-y` | `10px` | Vertical cell padding | -| `--yable-header-padding-x` | `16px` | Horizontal header padding | -| `--yable-header-padding-y` | `10px` | Vertical header padding | +| Token | Default | Description | +| -------------------------- | ------- | ------------------------- | +| `--yable-cell-padding-x` | `16px` | Horizontal cell padding | +| `--yable-cell-padding-y` | `10px` | Vertical cell padding | +| `--yable-header-padding-x` | `16px` | Horizontal header padding | +| `--yable-header-padding-y` | `10px` | Vertical header padding | ### Typography -| Token | Default | Description | -|---|---|---| -| `--yable-font-family` | System font stack | Font family | -| `--yable-font-size` | `13px` | Base font size | -| `--yable-font-size-sm` | `12px` | Small font size | -| `--yable-font-size-header` | `12px` | Header font size | +| Token | Default | Description | +| ------------------------------- | --------------------------------- | ----------------------------------------------------------- | +| `--yable-font-family` | System font stack | Font family for the whole grid | +| `--yable-font-family-header` | `var(--yable-font-family)` | Header-cell font family | +| `--yable-font-family-cell` | `var(--yable-font-family)` | Body-cell font family | +| `--yable-font-size` | `13px` | Base font size | +| `--yable-font-size-sm` | `12px` | Small font size | +| `--yable-font-size-header` | `12px` | Header font size | +| `--yable-font-weight-header` | `var(--yable-font-weight-medium)` | Header font weight | +| `--yable-header-text-transform` | `none` | Header `text-transform` (themes use this for the caps look) | +| `--yable-header-letter-spacing` | `0.02em` | Header letter spacing | + +The two scoped family tokens inherit the root family unless you set them, so +mono data under sans headers is a two-line override: + +```css +[data-yable-theme] { + --yable-font-family-cell: 'SF Mono', ui-monospace, monospace; +} +``` + +Header typography is token-driven in every bundled theme, so overriding +`--yable-header-text-transform` on the grid root is enough to kill a theme's +uppercase headers. No class overrides needed. ### Accent / Interactive -| Token | Default | Description | -|---|---|---| -| `--yable-accent` | `#2563eb` | Primary accent color (sort icons, selected states, pagination active) | -| `--yable-accent-hover` | `#1d4ed8` | Accent hover state | -| `--yable-accent-light` | `rgba(37,99,235,0.08)` | Accent background tint | +| Token | Default | Description | +| ---------------------- | ---------------------- | --------------------------------------------------------------------- | +| `--yable-accent` | `#2563eb` | Primary accent color (sort icons, selected states, pagination active) | +| `--yable-accent-hover` | `#1d4ed8` | Accent hover state | +| `--yable-accent-light` | `rgba(37,99,235,0.08)` | Accent background tint | ### Sizing -| Token | Default | Description | -|---|---|---| -| `--yable-row-min-height` | `40px` | Minimum row height | -| `--yable-header-min-height` | `40px` | Minimum header height | -| `--yable-input-height` | `28px` | In-cell form control height | +| Token | Default | Description | +| --------------------------- | ------- | --------------------------- | +| `--yable-row-min-height` | `40px` | Minimum row height | +| `--yable-header-min-height` | `40px` | Minimum header height | +| `--yable-input-height` | `28px` | In-cell form control height | + +### Sticky header & master-detail + +| Token | Default | Description | +| -------------------------------- | --------------------- | ---------------------------------------- | +| `--yable-header-backdrop-filter` | `none` | `backdrop-filter` on sticky header cells | +| `--yable-detail-accent-width` | `0px` | Inset edge on an expanded detail panel | +| `--yable-detail-accent-color` | `var(--yable-accent)` | Colour of that edge | + +A frosted sticky header is a translucent header background plus a backdrop +filter, both declarative: + +```css +[data-yable-theme] { + --yable-bg-header: rgba(255, 255, 255, 0.72); + --yable-header-backdrop-filter: blur(8px) saturate(140%); +} +``` + +### Styling hooks (data attributes) + +Some state is exposed as attributes rather than tokens, for selectors: + +| Attribute | On | Values | +| --------------- | ------------------------ | ------------------------------------------------------ | +| `data-sorted` | `.yable-th` | `asc` / `desc`, absent when unsorted | +| `data-align` | `.yable-th`, `.yable-td` | `left` / `center` / `right`, from the column's `align` | +| `data-sortable` | `.yable-th` | `true` when the column can sort | +| `data-pinned` | `.yable-th`, `.yable-td` | `left` / `right` | + +```css +/* Highlight the actively sorted column's header. */ +[data-yable-theme] .yable-th[data-sorted] { + color: var(--yable-text-primary); +} +``` ### Transitions -| Token | Default | Description | -|---|---|---| +| Token | Default | Description | +| ------------------------- | ------------ | ------------------------------- | | `--yable-transition-fast` | `100ms ease` | Fast transitions (hover, focus) | -| `--yable-transition` | `150ms ease` | Standard transitions | -| `--yable-transition-slow` | `250ms ease` | Slow transitions | +| `--yable-transition` | `150ms ease` | Standard transitions | +| `--yable-transition-slow` | `250ms ease` | Slow transitions | ## Custom Theme Example @@ -164,12 +238,12 @@ Dark mode activates automatically via `prefers-color-scheme: dark`. All tokens h ```html
-
+
-
+
``` diff --git a/packages/themes/package.json b/packages/themes/package.json index 82e6f7d..ffd72bc 100644 --- a/packages/themes/package.json +++ b/packages/themes/package.json @@ -32,7 +32,8 @@ "./mono.css": "./dist/themes/mono.css", "./tokens.css": "./dist/tokens.css", "./rtl.css": "./dist/rtl.css", - "./tailwind.css": "./dist/tailwind.css" + "./tailwind.css": "./dist/tailwind.css", + "./package.json": "./package.json" }, "files": [ "dist" diff --git a/packages/themes/src/__tests__/header-typography-tokens.test.ts b/packages/themes/src/__tests__/header-typography-tokens.test.ts new file mode 100644 index 0000000..b329bc0 --- /dev/null +++ b/packages/themes/src/__tests__/header-typography-tokens.test.ts @@ -0,0 +1,120 @@ +// Header/cell typography must be reachable from tokens alone. +// +// Bundled themes used to hardcode text-transform / letter-spacing / font-size / +// font-weight on their own `.yable-th` rule, which outranks any token set on the +// grid root, so consumers had to write class overrides to undo a theme's uppercase +// headers. These tests keep the token layer authoritative. + +import { describe, it, expect } from 'vitest' +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +const SRC = join(__dirname, '..') +const THEMES_DIR = join(SRC, 'themes') + +const read = (path: string) => readFileSync(path, 'utf8') +const themeFiles = readdirSync(THEMES_DIR).filter((f) => f.endsWith('.css')) + +describe('typography tokens', () => { + const tokens = read(join(SRC, 'tokens.css')) + + it.each([ + ['--yable-font-family-header', 'var(--yable-font-family)'], + ['--yable-font-family-cell', 'var(--yable-font-family)'], + ['--yable-font-weight-header', 'var(--yable-font-weight-medium)'], + ['--yable-header-text-transform', 'none'], + ['--yable-header-letter-spacing', '0.02em'], + ])('tokens.css declares %s with the inherited default', (token, value) => { + expect(tokens).toContain(`${token}: ${value};`) + }) + + // These exist so consumers stop reaching past the token layer for a frosted + // header or a detail-panel accent. Their defaults must be visual no-ops. + it.each([ + ['--yable-header-backdrop-filter', 'none'], + ['--yable-detail-accent-width', '0px'], + ['--yable-detail-accent-color', 'var(--yable-accent)'], + ])('tokens.css declares %s defaulting to a no-op', (token, value) => { + expect(tokens).toContain(`${token}: ${value};`) + }) +}) + +describe('styling hooks', () => { + const base = read(join(SRC, 'base.css')) + + it('sticky header cells read the backdrop-filter token, prefixed for Safari', () => { + const rule = base.slice( + base.indexOf('.yable--sticky-header .yable-thead .yable-th {'), + base.indexOf('/* Pinned + sticky header stacks z-index */'), + ) + expect(rule).toContain('backdrop-filter: var(--yable-header-backdrop-filter)') + expect(rule).toContain('-webkit-backdrop-filter: var(--yable-header-backdrop-filter)') + }) + + it('the detail panel reads both accent tokens', () => { + const rule = base.slice( + base.indexOf('.yable-detail-panel {'), + base.indexOf('.yable-detail-expand-icon'), + ) + expect(rule).toContain('var(--yable-detail-accent-width)') + expect(rule).toContain('var(--yable-detail-accent-color)') + }) + + it('aligns header, body, and header content off one data-align attribute', () => { + expect(base).toContain(".yable-th[data-align='right']") + expect(base).toContain(".yable-td[data-align='right']") + expect(base).toContain(".yable-th[data-align='right'] > .yable-th-content") + // Right-aligned columns are numeric in practice; digits must line up. + expect(base).toContain('font-variant-numeric: tabular-nums') + }) +}) + +describe('base.css consumes the typography tokens', () => { + const base = read(join(SRC, 'base.css')) + const thRule = base.slice(base.indexOf('.yable-th {'), base.indexOf('.yable-th[data-sortable')) + const tdRule = base.slice( + base.indexOf('.yable-td {'), + base.indexOf('.yable-td[data-cell-selected'), + ) + + it('header cells read every header typography token', () => { + expect(thRule).toContain('font-family: var(--yable-font-family-header)') + expect(thRule).toContain('font-size: var(--yable-font-size-header)') + expect(thRule).toContain('font-weight: var(--yable-font-weight-header)') + expect(thRule).toContain('text-transform: var(--yable-header-text-transform)') + expect(thRule).toContain('letter-spacing: var(--yable-header-letter-spacing)') + }) + + it('body cells read the cell font family', () => { + expect(tdRule).toContain('font-family: var(--yable-font-family-cell)') + }) +}) + +describe.each(themeFiles)('%s keeps header typography in tokens', (file) => { + const css = read(join(THEMES_DIR, file)) + const lines = css.split('\n') + + it('declares text-transform only as --yable-header-text-transform', () => { + const offenders = lines.filter( + (l) => l.includes('text-transform:') && !l.trim().startsWith('--yable-header-text-transform'), + ) + expect(offenders).toEqual([]) + }) + + it('declares letter-spacing only as --yable-header-letter-spacing', () => { + const offenders = lines.filter( + (l) => l.includes('letter-spacing:') && !l.trim().startsWith('--yable-header-letter-spacing'), + ) + expect(offenders).toEqual([]) + }) + + it('sets header font-size/weight via tokens, never on a .yable-th rule', () => { + const offenders = lines.filter( + (l) => + /^\s*(font-size|font-weight):/.test(l) && + !l.trim().startsWith('--yable-font-size-header') && + !l.trim().startsWith('--yable-font-weight-header'), + ) + expect(offenders).toEqual([]) + }) +}) diff --git a/packages/themes/src/base.css b/packages/themes/src/base.css index 0371157..91daf6c 100644 --- a/packages/themes/src/base.css +++ b/packages/themes/src/base.css @@ -123,8 +123,10 @@ position: relative; padding: var(--yable-header-padding-y) var(--yable-header-padding-x); min-height: var(--yable-header-min-height); + font-family: var(--yable-font-family-header); font-size: var(--yable-font-size-header); - font-weight: var(--yable-font-weight-medium); + font-weight: var(--yable-font-weight-header); + text-transform: var(--yable-header-text-transform); color: var(--yable-text-header); text-align: left; white-space: nowrap; @@ -134,7 +136,7 @@ border-bottom: calc(var(--yable-border-width) + 1px) solid var(--yable-border-color-strong); user-select: none; vertical-align: middle; - letter-spacing: 0.02em; + letter-spacing: var(--yable-header-letter-spacing); transition: background var(--yable-transition-fast); } @@ -371,9 +373,11 @@ align-items: center; box-sizing: border-box; padding: var(--yable-header-padding-y) var(--yable-header-padding-x); + font-family: var(--yable-font-family-header); font-size: var(--yable-font-size-header); - font-weight: var(--yable-font-weight-medium); - letter-spacing: 0.02em; + font-weight: var(--yable-font-weight-header); + text-transform: var(--yable-header-text-transform); + letter-spacing: var(--yable-header-letter-spacing); color: var(--yable-text-header); background: var(--yable-bg-header); border: 1px solid var(--yable-accent); @@ -486,6 +490,7 @@ .yable-td { padding: var(--yable-cell-padding-y) var(--yable-cell-padding-x); min-height: var(--yable-row-min-height); + font-family: var(--yable-font-family-cell); vertical-align: middle; white-space: nowrap; overflow: hidden; @@ -708,6 +713,33 @@ clip-path: inset(0 0 0 -10px); } +/* ── Column Alignment (columnDef `align`) ────────────────────────────────── */ + +.yable-th[data-align='center'], +.yable-td[data-align='center'] { + text-align: center; +} + +.yable-th[data-align='right'], +.yable-td[data-align='right'] { + text-align: right; +} + +/* The header label lives in a flex wrapper, so text-align alone can't move it. */ +.yable-th[data-align='center'] > .yable-th-content { + justify-content: center; +} + +.yable-th[data-align='right'] > .yable-th-content { + justify-content: flex-end; +} + +/* Right alignment is what numeric columns use; tabular figures keep the digits + in vertical columns, which is the whole point of aligning them right. */ +.yable-td[data-align='right'] { + font-variant-numeric: tabular-nums; +} + /* ── Sticky Header ───────────────────────────────────────────────────────── */ .yable--sticky-header .yable-thead .yable-th { @@ -715,6 +747,10 @@ top: 0; z-index: var(--yable-z-header); background: var(--yable-bg-header); + /* `none` by default. Pair a translucent --yable-bg-header with e.g. + `blur(8px)` for a frosted sticky header, entirely through tokens. */ + backdrop-filter: var(--yable-header-backdrop-filter); + -webkit-backdrop-filter: var(--yable-header-backdrop-filter); } /* Pinned + sticky header stacks z-index */ @@ -1481,6 +1517,9 @@ .yable-detail-panel { padding: 16px var(--yable-cell-padding-x); + /* Inset edge tying the panel back to its parent row. Zero-width by default, + so set --yable-detail-accent-width to turn it on. */ + box-shadow: inset var(--yable-detail-accent-width) 0 0 0 var(--yable-detail-accent-color); } .yable-detail-expand-icon { diff --git a/packages/themes/src/createTheme.ts b/packages/themes/src/createTheme.ts index 2993dd8..322e62b 100644 --- a/packages/themes/src/createTheme.ts +++ b/packages/themes/src/createTheme.ts @@ -39,14 +39,27 @@ export interface ThemeTokens { // Typography fontFamily?: string + /** Header font family. Inherits `fontFamily` unless set. */ + fontFamilyHeader?: string + /** Body-cell font family. Inherits `fontFamily` unless set. */ + fontFamilyCell?: string fontSize?: string fontSizeSm?: string fontSizeHeader?: string fontWeightNormal?: string fontWeightMedium?: string fontWeightSemibold?: string + /** Header font weight. Inherits `fontWeightMedium` unless set. */ + fontWeightHeader?: string + headerTextTransform?: string + headerLetterSpacing?: string + headerBackdropFilter?: string lineHeight?: string + // Master-detail + detailAccentWidth?: string + detailAccentColor?: string + // Sizing rowMinHeight?: string headerMinHeight?: string @@ -106,12 +119,20 @@ const TOKEN_MAP: Record = { headerPaddingX: '--yable-header-padding-x', headerPaddingY: '--yable-header-padding-y', fontFamily: '--yable-font-family', + fontFamilyHeader: '--yable-font-family-header', + fontFamilyCell: '--yable-font-family-cell', fontSize: '--yable-font-size', fontSizeSm: '--yable-font-size-sm', fontSizeHeader: '--yable-font-size-header', fontWeightNormal: '--yable-font-weight-normal', fontWeightMedium: '--yable-font-weight-medium', fontWeightSemibold: '--yable-font-weight-semibold', + fontWeightHeader: '--yable-font-weight-header', + headerTextTransform: '--yable-header-text-transform', + headerLetterSpacing: '--yable-header-letter-spacing', + headerBackdropFilter: '--yable-header-backdrop-filter', + detailAccentWidth: '--yable-detail-accent-width', + detailAccentColor: '--yable-detail-accent-color', lineHeight: '--yable-line-height', rowMinHeight: '--yable-row-min-height', headerMinHeight: '--yable-header-min-height', diff --git a/packages/themes/src/tailwind-preset.ts b/packages/themes/src/tailwind-preset.ts index debf770..2c3dcbc 100644 --- a/packages/themes/src/tailwind-preset.ts +++ b/packages/themes/src/tailwind-preset.ts @@ -63,6 +63,8 @@ const yableTailwindPreset = { }, fontFamily: { yable: [cssVar('yable-font-family')], + 'yable-header': [cssVar('yable-font-family-header')], + 'yable-cell': [cssVar('yable-font-family-cell')], }, fontSize: { yable: [cssVar('yable-font-size'), { lineHeight: cssVar('yable-line-height') }], diff --git a/packages/themes/src/tailwind.css b/packages/themes/src/tailwind.css index 0303b51..bf95d94 100644 --- a/packages/themes/src/tailwind.css +++ b/packages/themes/src/tailwind.css @@ -63,6 +63,8 @@ /* ── Typography ─────────────────────────────────────────────────── */ --font-yable: var(--yable-font-family); + --font-yable-header: var(--yable-font-family-header); + --font-yable-cell: var(--yable-font-family-cell); --text-yable: var(--yable-font-size); --text-yable-sm: var(--yable-font-size-sm); --text-yable-header: var(--yable-font-size-header); diff --git a/packages/themes/src/themes/compact.css b/packages/themes/src/themes/compact.css index 5471073..1df6fab 100644 --- a/packages/themes/src/themes/compact.css +++ b/packages/themes/src/themes/compact.css @@ -10,7 +10,7 @@ ============================================================================ */ .yable-theme-compact, -.yable[data-theme="compact"] { +.yable[data-theme='compact'] { /* Surface — light gray background for data density */ --yable-bg: #ffffff; --yable-bg-header: #f3f4f6; @@ -74,43 +74,44 @@ /* Resize handle */ --yable-resize-handle-width: 3px; + + /* Header typography: bold, borderline industrial */ + --yable-header-text-transform: uppercase; + --yable-header-letter-spacing: 0.04em; + --yable-font-size-header: 10px; + --yable-font-weight-header: var(--yable-font-weight-semibold); } -/* Headers — bold, borderline industrial */ .yable-theme-compact .yable-th, -.yable[data-theme="compact"] .yable-th { - text-transform: uppercase; - letter-spacing: 0.04em; - font-size: 10px; - font-weight: var(--yable-font-weight-semibold); +.yable[data-theme='compact'] .yable-th { color: var(--yable-text-secondary); background: var(--yable-bg-header); } /* All rows get separators */ .yable-theme-compact .yable-tr:not(:last-child) .yable-td, -.yable[data-theme="compact"] .yable-tr:not(:last-child) .yable-td { +.yable[data-theme='compact'] .yable-tr:not(:last-child) .yable-td { border-bottom: var(--yable-border-width) solid var(--yable-border-color); } /* Also add vertical borders for the spreadsheet feel */ .yable-theme-compact .yable-th, -.yable[data-theme="compact"] .yable-th, +.yable[data-theme='compact'] .yable-th, .yable-theme-compact .yable-td, -.yable[data-theme="compact"] .yable-td { +.yable[data-theme='compact'] .yable-td { border-right: var(--yable-border-width) solid var(--yable-border-color); } .yable-theme-compact .yable-th:last-child, -.yable[data-theme="compact"] .yable-th:last-child, +.yable[data-theme='compact'] .yable-th:last-child, .yable-theme-compact .yable-td:last-child, -.yable[data-theme="compact"] .yable-td:last-child { +.yable[data-theme='compact'] .yable-td:last-child { border-right: none; } /* Number cells — monospace */ -.yable-theme-compact [data-type="number"], -.yable[data-theme="compact"] [data-type="number"] { +.yable-theme-compact [data-type='number'], +.yable[data-theme='compact'] [data-type='number'] { font-variant-numeric: tabular-nums; text-align: right; } @@ -118,8 +119,8 @@ /* ── Compact Dark Mode ───────────────────────────────────────────────────── */ @media (prefers-color-scheme: dark) { - .yable-theme-compact:not([data-yable-theme="light"]), - .yable[data-theme="compact"]:not([data-yable-theme="light"]) { + .yable-theme-compact:not([data-yable-theme='light']), + .yable[data-theme='compact']:not([data-yable-theme='light']) { --yable-bg: #0d0f14; --yable-bg-header: #141720; --yable-bg-footer: #141720; @@ -148,8 +149,8 @@ } } -[data-yable-theme="dark"] .yable-theme-compact, -[data-yable-theme="dark"] .yable[data-theme="compact"] { +[data-yable-theme='dark'] .yable-theme-compact, +[data-yable-theme='dark'] .yable[data-theme='compact'] { --yable-bg: #0d0f14; --yable-bg-header: #141720; --yable-bg-footer: #141720; diff --git a/packages/themes/src/themes/default.css b/packages/themes/src/themes/default.css index b1d23a0..03e757c 100644 --- a/packages/themes/src/themes/default.css +++ b/packages/themes/src/themes/default.css @@ -9,7 +9,7 @@ ============================================================================ */ .yable-theme-default, -.yable[data-theme="default"] { +.yable[data-theme='default'] { /* Surface — ultra-clean white with barely-there alternation */ --yable-bg: #ffffff; --yable-bg-header: #fafbfc; @@ -49,30 +49,30 @@ /* Pagination — pill-style */ --yable-pagination-button-radius: 8px; + + /* Header typography */ + --yable-header-letter-spacing: normal; + --yable-font-size-header: var(--yable-font-size-sm); } /* Default theme header — clean divider line only */ .yable-theme-default .yable-th, -.yable[data-theme="default"] .yable-th { +.yable[data-theme='default'] .yable-th { border-bottom-color: var(--yable-border-color-strong); - text-transform: none; - letter-spacing: normal; - font-size: var(--yable-font-size-sm); - font-weight: var(--yable-font-weight-medium); color: var(--yable-text-secondary); } /* Subtle row separator */ .yable-theme-default .yable-tr:not(:last-child) .yable-td, -.yable[data-theme="default"] .yable-tr:not(:last-child) .yable-td { +.yable[data-theme='default'] .yable-tr:not(:last-child) .yable-td { border-bottom-color: var(--yable-border-color); } /* ── Default Dark Mode ───────────────────────────────────────────────────── */ @media (prefers-color-scheme: dark) { - .yable-theme-default:not([data-yable-theme="light"]), - .yable[data-theme="default"]:not([data-yable-theme="light"]) { + .yable-theme-default:not([data-yable-theme='light']), + .yable[data-theme='default']:not([data-yable-theme='light']) { --yable-bg: #0c0e14; --yable-bg-header: #111318; --yable-bg-footer: #111318; @@ -99,8 +99,8 @@ } } -[data-yable-theme="dark"] .yable-theme-default, -[data-yable-theme="dark"] .yable[data-theme="default"] { +[data-yable-theme='dark'] .yable-theme-default, +[data-yable-theme='dark'] .yable[data-theme='default'] { --yable-bg: #0c0e14; --yable-bg-header: #111318; --yable-bg-footer: #111318; diff --git a/packages/themes/src/themes/forest.css b/packages/themes/src/themes/forest.css index 3b0c7fd..ffc4172 100644 --- a/packages/themes/src/themes/forest.css +++ b/packages/themes/src/themes/forest.css @@ -11,7 +11,7 @@ ============================================================================ */ .yable-theme-forest, -.yable[data-theme="forest"] { +.yable[data-theme='forest'] { /* Surface — warm off-white with the faintest green warmth */ --yable-bg: #fbfcf9; --yable-bg-header: #f3f5ef; @@ -84,28 +84,22 @@ rgba(90, 110, 78, 0.05) 37%, rgba(90, 110, 78, 0.025) 63% ); -} -/* Header — olive-sage, grounded authority */ -.yable-theme-forest .yable-th, -.yable[data-theme="forest"] .yable-th { - text-transform: none; - letter-spacing: normal; - font-size: var(--yable-font-size-sm); - font-weight: var(--yable-font-weight-medium); - color: var(--yable-text-header); + /* Header typography: olive-sage, grounded authority */ + --yable-header-letter-spacing: normal; + --yable-font-size-header: var(--yable-font-size-sm); } .yable-theme-forest .yable-tr:not(:last-child) .yable-td, -.yable[data-theme="forest"] .yable-tr:not(:last-child) .yable-td { +.yable[data-theme='forest'] .yable-tr:not(:last-child) .yable-td { border-bottom-color: var(--yable-border-color); } /* ── Forest Dark Mode ───────────────────────────────────────────────────── */ @media (prefers-color-scheme: dark) { - .yable-theme-forest:not([data-yable-theme="light"]), - .yable[data-theme="forest"]:not([data-yable-theme="light"]) { + .yable-theme-forest:not([data-yable-theme='light']), + .yable[data-theme='forest']:not([data-yable-theme='light']) { --yable-bg: #0c100b; --yable-bg-header: #131812; --yable-bg-footer: #131812; @@ -154,8 +148,8 @@ } } -[data-yable-theme="dark"] .yable-theme-forest, -[data-yable-theme="dark"] .yable[data-theme="forest"] { +[data-yable-theme='dark'] .yable-theme-forest, +[data-yable-theme='dark'] .yable[data-theme='forest'] { --yable-bg: #0c100b; --yable-bg-header: #131812; --yable-bg-footer: #131812; diff --git a/packages/themes/src/themes/midnight.css b/packages/themes/src/themes/midnight.css index 52b79f1..6725a56 100644 --- a/packages/themes/src/themes/midnight.css +++ b/packages/themes/src/themes/midnight.css @@ -11,7 +11,7 @@ ============================================================================ */ .yable-theme-midnight, -.yable[data-theme="midnight"] { +.yable[data-theme='midnight'] { /* Surface — truly deep navy, not just dark gray */ --yable-bg: #080b16; --yable-bg-header: #0d1025; @@ -106,32 +106,27 @@ /* Header — alive with blue tint, crisp and technical */ .yable-theme-midnight .yable-th, -.yable[data-theme="midnight"] .yable-th { - text-transform: none; - letter-spacing: 0.02em; - font-size: var(--yable-font-size-header); - font-weight: var(--yable-font-weight-medium); - color: var(--yable-text-header); +.yable[data-theme='midnight'] .yable-th { border-bottom: 1px solid var(--yable-border-color-strong); } /* Rows — razor-thin navy separators */ .yable-theme-midnight .yable-tr:not(:last-child) .yable-td, -.yable[data-theme="midnight"] .yable-tr:not(:last-child) .yable-td { +.yable[data-theme='midnight'] .yable-tr:not(:last-child) .yable-td { border-bottom: 1px solid var(--yable-border-color); } /* Outer border — defined but not heavy */ .yable-theme-midnight, -.yable[data-theme="midnight"] { +.yable[data-theme='midnight'] { border: 1px solid var(--yable-border-color-strong); } /* ── Midnight Light Variant ─────────────────────────────────────────────── */ /* Light midnight: steel blue surfaces, deep blue accents — still technical */ -[data-yable-theme="light"] .yable-theme-midnight, -[data-yable-theme="light"] .yable[data-theme="midnight"] { +[data-yable-theme='light'] .yable-theme-midnight, +[data-yable-theme='light'] .yable[data-theme='midnight'] { --yable-bg: #f8f9fc; --yable-bg-header: #eef1f8; --yable-bg-footer: #eef1f8; @@ -182,8 +177,8 @@ } @media (prefers-color-scheme: light) { - .yable-theme-midnight:not([data-yable-theme="dark"]), - .yable[data-theme="midnight"]:not([data-yable-theme="dark"]) { + .yable-theme-midnight:not([data-yable-theme='dark']), + .yable[data-theme='midnight']:not([data-yable-theme='dark']) { --yable-bg: #f8f9fc; --yable-bg-header: #eef1f8; --yable-bg-footer: #eef1f8; diff --git a/packages/themes/src/themes/mono.css b/packages/themes/src/themes/mono.css index 6bfe8c3..71e2ca4 100644 --- a/packages/themes/src/themes/mono.css +++ b/packages/themes/src/themes/mono.css @@ -11,7 +11,7 @@ ============================================================================ */ .yable-theme-mono, -.yable[data-theme="mono"] { +.yable[data-theme='mono'] { /* Surface — true white, no warmth, no coolness */ --yable-bg: #ffffff; --yable-bg-header: #f7f7f7; @@ -91,36 +91,36 @@ rgba(0, 0, 0, 0.04) 37%, rgba(0, 0, 0, 0.02) 63% ); + + /* Header typography: uppercase, tight tracking, Swiss typographic precision */ + --yable-header-text-transform: uppercase; + --yable-header-letter-spacing: 0.08em; + --yable-font-weight-header: var(--yable-font-weight-semibold); } -/* Header — uppercase, tight tracking, Swiss typographic precision */ .yable-theme-mono .yable-th, -.yable[data-theme="mono"] .yable-th { - text-transform: uppercase; - letter-spacing: 0.08em; - font-size: var(--yable-font-size-header); - font-weight: var(--yable-font-weight-semibold); +.yable[data-theme='mono'] .yable-th { color: var(--yable-text-secondary); border-bottom: 1px solid var(--yable-border-color-strong); } /* Rows — precise 1px rule */ .yable-theme-mono .yable-tr:not(:last-child) .yable-td, -.yable[data-theme="mono"] .yable-tr:not(:last-child) .yable-td { +.yable[data-theme='mono'] .yable-tr:not(:last-child) .yable-td { border-bottom: 1px solid var(--yable-border-color); } /* Outer border — clean frame */ .yable-theme-mono, -.yable[data-theme="mono"] { +.yable[data-theme='mono'] { border: 1px solid var(--yable-border-color-strong); } /* ── Mono Dark Mode ─────────────────────────────────────────────────────── */ @media (prefers-color-scheme: dark) { - .yable-theme-mono:not([data-yable-theme="light"]), - .yable[data-theme="mono"]:not([data-yable-theme="light"]) { + .yable-theme-mono:not([data-yable-theme='light']), + .yable[data-theme='mono']:not([data-yable-theme='light']) { --yable-bg: #0a0a0a; --yable-bg-header: #141414; --yable-bg-footer: #141414; @@ -173,8 +173,8 @@ } } -[data-yable-theme="dark"] .yable-theme-mono, -[data-yable-theme="dark"] .yable[data-theme="mono"] { +[data-yable-theme='dark'] .yable-theme-mono, +[data-yable-theme='dark'] .yable[data-theme='mono'] { --yable-bg: #0a0a0a; --yable-bg-header: #141414; --yable-bg-footer: #141414; diff --git a/packages/themes/src/themes/ocean.css b/packages/themes/src/themes/ocean.css index 3334952..0d84227 100644 --- a/packages/themes/src/themes/ocean.css +++ b/packages/themes/src/themes/ocean.css @@ -11,7 +11,7 @@ ============================================================================ */ .yable-theme-ocean, -.yable[data-theme="ocean"] { +.yable[data-theme='ocean'] { /* Surface — barely tinted white, calm */ --yable-bg: #fafcfb; --yable-bg-header: #f2f7f6; @@ -89,28 +89,22 @@ --yable-transition-fast: 120ms ease; --yable-transition: 180ms ease; --yable-transition-slow: 280ms ease; -} -/* Header — muted sage, understated authority */ -.yable-theme-ocean .yable-th, -.yable[data-theme="ocean"] .yable-th { - text-transform: none; - letter-spacing: normal; - font-size: var(--yable-font-size-sm); - font-weight: var(--yable-font-weight-medium); - color: var(--yable-text-header); + /* Header typography: muted sage, understated authority */ + --yable-header-letter-spacing: normal; + --yable-font-size-header: var(--yable-font-size-sm); } .yable-theme-ocean .yable-tr:not(:last-child) .yable-td, -.yable[data-theme="ocean"] .yable-tr:not(:last-child) .yable-td { +.yable[data-theme='ocean'] .yable-tr:not(:last-child) .yable-td { border-bottom-color: var(--yable-border-color); } /* ── Ocean Dark Mode ────────────────────────────────────────────────────── */ @media (prefers-color-scheme: dark) { - .yable-theme-ocean:not([data-yable-theme="light"]), - .yable[data-theme="ocean"]:not([data-yable-theme="light"]) { + .yable-theme-ocean:not([data-yable-theme='light']), + .yable[data-theme='ocean']:not([data-yable-theme='light']) { --yable-bg: #0b1210; --yable-bg-header: #0f1916; --yable-bg-footer: #0f1916; @@ -159,8 +153,8 @@ } } -[data-yable-theme="dark"] .yable-theme-ocean, -[data-yable-theme="dark"] .yable[data-theme="ocean"] { +[data-yable-theme='dark'] .yable-theme-ocean, +[data-yable-theme='dark'] .yable[data-theme='ocean'] { --yable-bg: #0b1210; --yable-bg-header: #0f1916; --yable-bg-footer: #0f1916; diff --git a/packages/themes/src/themes/rose.css b/packages/themes/src/themes/rose.css index e28e7b6..292caa9 100644 --- a/packages/themes/src/themes/rose.css +++ b/packages/themes/src/themes/rose.css @@ -11,7 +11,7 @@ ============================================================================ */ .yable-theme-rose, -.yable[data-theme="rose"] { +.yable[data-theme='rose'] { /* Surface — warm parchment, not pink */ --yable-bg: #fefcfb; --yable-bg-header: #faf5f3; @@ -84,28 +84,22 @@ rgba(168, 126, 118, 0.05) 37%, rgba(168, 126, 118, 0.025) 63% ); -} -/* Header — warm muted rose, editorial feel */ -.yable-theme-rose .yable-th, -.yable[data-theme="rose"] .yable-th { - text-transform: none; - letter-spacing: normal; - font-size: var(--yable-font-size-sm); - font-weight: var(--yable-font-weight-medium); - color: var(--yable-text-header); + /* Header typography: warm muted rose, editorial feel */ + --yable-header-letter-spacing: normal; + --yable-font-size-header: var(--yable-font-size-sm); } .yable-theme-rose .yable-tr:not(:last-child) .yable-td, -.yable[data-theme="rose"] .yable-tr:not(:last-child) .yable-td { +.yable[data-theme='rose'] .yable-tr:not(:last-child) .yable-td { border-bottom-color: var(--yable-border-color); } /* ── Rose Dark Mode ─────────────────────────────────────────────────────── */ @media (prefers-color-scheme: dark) { - .yable-theme-rose:not([data-yable-theme="light"]), - .yable[data-theme="rose"]:not([data-yable-theme="light"]) { + .yable-theme-rose:not([data-yable-theme='light']), + .yable[data-theme='rose']:not([data-yable-theme='light']) { --yable-bg: #110d0d; --yable-bg-header: #1a1414; --yable-bg-footer: #1a1414; @@ -154,8 +148,8 @@ } } -[data-yable-theme="dark"] .yable-theme-rose, -[data-yable-theme="dark"] .yable[data-theme="rose"] { +[data-yable-theme='dark'] .yable-theme-rose, +[data-yable-theme='dark'] .yable[data-theme='rose'] { --yable-bg: #110d0d; --yable-bg-header: #1a1414; --yable-bg-footer: #1a1414; diff --git a/packages/themes/src/themes/stripe.css b/packages/themes/src/themes/stripe.css index c83c5a1..6eef351 100644 --- a/packages/themes/src/themes/stripe.css +++ b/packages/themes/src/themes/stripe.css @@ -10,7 +10,7 @@ ============================================================================ */ .yable-theme-stripe, -.yable[data-theme="stripe"] { +.yable[data-theme='stripe'] { /* Surface — warm gray palette */ --yable-bg: #ffffff; --yable-bg-header: #f6f8fa; @@ -55,7 +55,6 @@ /* Typography — Stripe's characteristic weights */ --yable-font-size: 14px; --yable-font-size-sm: 13px; - --yable-font-size-header: 12px; /* Form controls — clean, precise */ --yable-input-border: #d8dee4; @@ -72,28 +71,29 @@ /* Pagination */ --yable-pagination-button-radius: 6px; --yable-pagination-button-bg-active: var(--yable-accent); + + /* Header typography: uppercase labels with Stripe's signature style */ + --yable-header-text-transform: uppercase; + --yable-header-letter-spacing: 0.05em; + --yable-font-size-header: 11px; + --yable-font-weight-header: var(--yable-font-weight-semibold); } -/* Header — uppercase labels with Stripe's signature style */ .yable-theme-stripe .yable-th, -.yable[data-theme="stripe"] .yable-th { - text-transform: uppercase; - letter-spacing: 0.05em; - font-size: 11px; - font-weight: var(--yable-font-weight-semibold); +.yable[data-theme='stripe'] .yable-th { color: var(--yable-text-secondary); border-bottom: var(--yable-border-width) solid var(--yable-border-color-strong); } /* Rows — clean separators */ .yable-theme-stripe .yable-tr:not(:last-child) .yable-td, -.yable[data-theme="stripe"] .yable-tr:not(:last-child) .yable-td { +.yable[data-theme='stripe'] .yable-tr:not(:last-child) .yable-td { border-bottom: var(--yable-border-width) solid var(--yable-border-color); } /* Outer border: crisp box-shadow instead of border for cleaner look */ .yable-theme-stripe, -.yable[data-theme="stripe"] { +.yable[data-theme='stripe'] { border: none; box-shadow: var(--yable-shadow); } @@ -101,8 +101,8 @@ /* ── Stripe Dark Mode ────────────────────────────────────────────────────── */ @media (prefers-color-scheme: dark) { - .yable-theme-stripe:not([data-yable-theme="light"]), - .yable[data-theme="stripe"]:not([data-yable-theme="light"]) { + .yable-theme-stripe:not([data-yable-theme='light']), + .yable[data-theme='stripe']:not([data-yable-theme='light']) { --yable-bg: #0a0d16; --yable-bg-header: #0f1221; --yable-bg-footer: #0f1221; @@ -134,8 +134,8 @@ } } -[data-yable-theme="dark"] .yable-theme-stripe, -[data-yable-theme="dark"] .yable[data-theme="stripe"] { +[data-yable-theme='dark'] .yable-theme-stripe, +[data-yable-theme='dark'] .yable[data-theme='stripe'] { --yable-bg: #0a0d16; --yable-bg-header: #0f1221; --yable-bg-footer: #0f1221; diff --git a/packages/themes/src/tokens.css b/packages/themes/src/tokens.css index 6f363ec..291e761 100644 --- a/packages/themes/src/tokens.css +++ b/packages/themes/src/tokens.css @@ -85,13 +85,28 @@ /* ── Typography ────────────────────────────────────────────────────────── */ --yable-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + /* Scoped family overrides: both inherit the root family unless set, so a + consumer can run e.g. mono data under sans headers without class hacks. */ + --yable-font-family-header: var(--yable-font-family); + --yable-font-family-cell: var(--yable-font-family); --yable-font-size: 13px; --yable-font-size-sm: 12px; --yable-font-size-header: 12px; --yable-font-weight-normal: 400; --yable-font-weight-medium: 500; --yable-font-weight-semibold: 600; + --yable-font-weight-header: var(--yable-font-weight-medium); + --yable-header-text-transform: none; + --yable-header-letter-spacing: 0.02em; --yable-line-height: 1.5; + /* Sticky-header backdrop. `none` by default; pair a translucent + --yable-bg-header with e.g. `blur(8px)` for a frosted header. */ + --yable-header-backdrop-filter: none; + + /* ── Master-detail panel ───────────────────────────────────────────────── */ + /* Inset edge tying an expanded panel to its parent row. Off (0) by default. */ + --yable-detail-accent-width: 0px; + --yable-detail-accent-color: var(--yable-accent); /* ── Sizing ────────────────────────────────────────────────────────────── */ --yable-row-height: auto; diff --git a/packages/vanilla/package.json b/packages/vanilla/package.json index 492c6af..d642881 100644 --- a/packages/vanilla/package.json +++ b/packages/vanilla/package.json @@ -33,7 +33,8 @@ "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } - } + }, + "./package.json": "./package.json" }, "files": [ "dist" diff --git a/packages/vanilla/src/renderer.ts b/packages/vanilla/src/renderer.ts index e94bd17..b66367d 100644 --- a/packages/vanilla/src/renderer.ts +++ b/packages/vanilla/src/renderer.ts @@ -99,6 +99,14 @@ function renderHeader(table: Table): string { return html } +/** + * `data-align` for a column's `align`, or '' when unset. Whitelisted rather than + * interpolated so an untrusted column def can never inject an attribute. + */ +function alignAttribute(align: unknown): string { + return align === 'left' || align === 'center' || align === 'right' ? ` data-align="${align}"` : '' +} + function renderHeaderCell(header: Header): string { if (header.isPlaceholder) { // SECURITY: Safe — colSpan is numeric from internal state @@ -134,6 +142,12 @@ function renderHeaderCell(header: Header) : undefined const ariaSortAttr = ariaSort ? ` aria-sort="${ariaSort}"` : '' + // SECURITY: Safe — align is a 'left' | 'center' | 'right' union from the column + // def; anything else is dropped rather than interpolated. + const alignAttr = alignAttribute(column.columnDef.align) + // SECURITY: Safe — sortDir is 'asc' | 'desc' | false from internal state + const sortedAttr = sortDir ? ` data-sorted="${sortDir}"` : '' + const headerDef = column.columnDef.header type HeaderRenderer = (ctx: HeaderContext) => unknown const content = @@ -159,7 +173,7 @@ function renderHeaderCell(header: Header) // SECURITY: User data — content escaped via esc(), column.id escaped via escAttr() for data-attributes // SECURITY: Safe — sortable is boolean, colSpan is numeric - return `` + return `` } // --------------------------------------------------------------------------- @@ -237,6 +251,7 @@ function renderCell( // SECURITY: Safe — getPinStyle returns internally computed CSS const style = pinned ? getPinStyle(column) : '' + const alignAttr = alignAttribute(column.columnDef.align) // Check if this cell should render a form element const editConfig = column.columnDef.editConfig @@ -244,14 +259,14 @@ function renderCell( if (editConfig) { const value = table.getPendingValue(cell.row.id, column.id) ?? cell.getValue() // SECURITY: User data — column.id and cell.row.id escaped via escAttr() for data-attributes - return `` + return `` } } // Regular cell value // SECURITY: User data — value escaped via esc(), column.id and cell.row.id escaped via escAttr() const value = cell.renderValue() - return `` + return `` } // ---------------------------------------------------------------------------
${esc(content)}${sortIndicator}${resizeHandle}${esc(content)}${sortIndicator}${resizeHandle}${renderFormElement(editConfig, value, cell.row.id, column.id)}${renderFormElement(editConfig, value, cell.row.id, column.id)}${esc(value)}${esc(value)}