puppertino-table 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 — 2026-10-12
4
+
5
+ - Add typed row/cell DOM customization callbacks and a leading-rows slot to the complete table.
6
+ - Expose `usePuppertinoTable` for shared sorting, selection and persisted-sizing initialization in custom layouts.
7
+ - Share resize gesture state and global listeners per table, with safe active-handle removal and coordinated columns.
8
+ - Support uncontrolled sorting/selection with initial values and change notifications; controlled state without callbacks is read-only.
9
+ - Supply typed body context and data attributes to customization callbacks; infer primitive resize labels from headers.
10
+ - Document complete-component and primitive integrations, including server-owned pagination.
11
+
3
12
  ## 0.1.1 — 2026-10-12
4
13
 
5
14
  - Correct npm installation and publication-status documentation.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  A generic React table styled with [Puppertino](https://puppertino.com/) and powered by [TanStack Table](https://tanstack.com/table). Bring your own data, columns and cell renderers.
4
4
 
5
+ - Complete component customization and a reusable state hook for custom layouts.
6
+ - One shared resize controller per table, including standalone handles.
5
7
  - Typed column definitions for arbitrary row shapes, including grouped headers.
6
8
  - Host-controlled sorting by default, with optional local sorting.
7
9
  - Checkbox selection with Shift-click ranges and keyboard toggling.
@@ -15,7 +17,7 @@ A generic React table styled with [Puppertino](https://puppertino.com/) and powe
15
17
  The package includes built ESM and TypeScript declarations. Install it from npm:
16
18
 
17
19
  ```sh
18
- pnpm add puppertino-table@0.1.1 @tanstack/react-table@9.2.8 @codedgar/puppertino@2 react react-dom
20
+ pnpm add puppertino-table@0.2.0 @tanstack/react-table@9.2.8 @codedgar/puppertino@2 react react-dom
19
21
  ```
20
22
 
21
23
  React, TanStack Table and Puppertino are peer dependencies; the library does not bundle another copy of them. For Git-based installation, use `github:kahwee/puppertino-table` and pin a commit or tag for reproducibility.
@@ -59,6 +61,8 @@ export function Example() {
59
61
  }
60
62
  ```
61
63
 
64
+ For simpler tables, omit `sorting` and `onSortingChange` to let the library own sort state; set `manualSorting={false}` for local ordering. `initialSorting` and `initialRowSelection` seed uncontrolled state on mount. You can provide change callbacks as notifications while leaving state uncontrolled. If you provide controlled `sorting` or `rowSelection` without its matching callback, that state is read-only. Do not switch between controlled and uncontrolled ownership while a table is mounted.
65
+
62
66
  The example uses local sorting and uncontrolled checkbox selection. Pass both `rowSelection` and `onRowSelectionChange` for controlled selection. Controlled selection without a change callback is read-only. Keep column definitions and data references stable when possible.
63
67
 
64
68
  ### Sorting and pagination
@@ -73,9 +77,118 @@ Drag a header's right edge. Double-click or Enter resets that column to its defi
73
77
 
74
78
  `cancelMediaQuery` is optional: set it when your layout removes the table at a responsive breakpoint. A narrow table that remains visible can simply scroll horizontally.
75
79
 
76
- ### Custom layouts
80
+ ### Choose an integration path
81
+
82
+ Use **`PuppertinoTable`** for the complete semantic table: headers, sort buttons, optional selection, bounded resize handles, scrolling and memoized data rows. Customize it with `getRowProps(row)`, `getCellProps(cell)` and `leadingRows` before building a separate layout.
83
+
84
+ ```tsx
85
+ <PuppertinoTable
86
+ data={items} columns={columns} getRowId={item => item.id}
87
+ sorting={sorting} onSortingChange={setSorting} ariaLabel="Items" selectable
88
+ getRowProps={row => ({
89
+ className: row.id === activeId ? 'active-row' : undefined,
90
+ 'aria-current': row.id === activeId ? 'true' : undefined,
91
+ onClick: () => setActiveId(row.id),
92
+ onContextMenu: event => { event.preventDefault(); openMenu(row.original, event.clientX, event.clientY); },
93
+ })}
94
+ getCellProps={cell => ({ title: String(cell.getValue() ?? '') })}
95
+ leadingRows={(_table, context) => (
96
+ <tr><td colSpan={context.columnCount}>
97
+ <button type="button" onClick={openFolder}>Open folder</button>
98
+ </td></tr>
99
+ )}
100
+ />
101
+ ```
102
+
103
+ Callbacks receive typed TanStack row/cell objects, including `original`, IDs and state methods. Their second argument is `PuppertinoBodyContext`: `{ table, columnCount, selectable, disabled }`. Typed `data-*` attributes are supported on row and cell props; return `undefined` to leave a row or cell unchanged. Row props are applied to data `<tr>` elements and cell props to data `<td>` elements. The selection cell stays library-owned. Custom row classes are combined with `p-selected`; selection checkbox clicks do not bubble to the row. The library retains row/cell children and React keys. Keep callbacks stable when possible so sizing updates can reuse the memoized body. Callbacks can attach events, styles and DOM attributes; the app owns context-menu rendering and keyboard access to its actions.
104
+
105
+ `leadingRows` accepts React content or a function receiving the table. Its second argument is the same body context, with `columnCount` including the selection column. Return semantic `<tr>` elements with `<th>`/`<td>` children. It renders inside `<tbody>` before data rows, including when there are no data rows; the empty-state row still renders. Use `context.columnCount` for full-width row `colSpan`. Leading rows are app-owned: they do not enter the data row model, sorting, selection ranges or the row/cell callbacks.
106
+
107
+ Use **`usePuppertinoTable` and the primitives** when you own the table markup, selection controls or header layout. The hook returns a `PuppertinoTableInstance` and shares the complete component's column bounds, persisted initial sizing, sorting ownership and controlled/uncontrolled selection rules. It accepts the state/data options from `PuppertinoTable`, without rendering options such as `ariaLabel` or `leadingRows`.
108
+
109
+ ```tsx
110
+ import { useState } from 'react';
111
+ import type { SortingState } from '@tanstack/react-table';
112
+ import { usePuppertinoTable, ColumnResizeHandle } from 'puppertino-table';
113
+
114
+ function CustomItems() {
115
+ const [sorting, setSorting] = useState<SortingState>([]);
116
+ const table = usePuppertinoTable({
117
+ data: items, columns, getRowId: item => item.id,
118
+ sorting, onSortingChange: setSorting, manualSorting: false, selectable: true,
119
+ storageKey: 'my-app.custom-items.columns.v1',
120
+ });
121
+ return <table.Subscribe source={table.atoms.columnSizing}>{() => (
122
+ <table className="p-table pt-table" aria-label="Items" style={{ width: table.getTotalSize() }}>
123
+ <colgroup>{table.getAllLeafColumns().map(column => <col key={column.id} style={{ width: column.getSize() }} />)}</colgroup>
124
+ <thead>{table.getHeaderGroups().map(group => <tr key={group.id}>{group.headers.map(header => (
125
+ <th key={header.id} colSpan={header.colSpan} scope={header.column.columns.length ? 'colgroup' : 'col'}
126
+ aria-sort={header.column.getIsSorted() === 'asc' ? 'ascending' : header.column.getIsSorted() === 'desc' ? 'descending' : undefined}>
127
+ {!header.isPlaceholder && (header.column.getCanSort()
128
+ ? <button type="button" onClick={() => header.column.toggleSorting()}><table.FlexRender header={header} /></button>
129
+ : <table.FlexRender header={header} />)}
130
+ <ColumnResizeHandle table={table} header={header} label={header.column.id}
131
+ options={{ storageKey: 'my-app.custom-items.columns.v1' }} />
132
+ </th>
133
+ ))}</tr>)}</thead>
134
+ <tbody>{table.getRowModel().rows.map(row => <tr key={row.id}>
135
+ {row.getAllCells().map((cell, index) => <td key={cell.id}>
136
+ {index === 0 && <input type="checkbox" aria-label={`Select ${row.original.name}`}
137
+ checked={row.getIsSelected()} disabled={!row.getCanSelect()} onChange={row.getToggleSelectedHandler()} />}
138
+ <table.FlexRender cell={cell} />
139
+ </td>)}
140
+ </tr>)}</tbody>
141
+ </table>
142
+ )}</table.Subscribe>;
143
+ }
144
+ ```
145
+
146
+ The hook initializes sizing but does not install resize listeners. `ColumnResizeHandle` infers its accessible label from a string header or column ID; pass `label` for a custom or translated name. Its props type is exported as `ColumnResizeHandleProps`. `ColumnResizeHandle` and `useColumnResize` automatically share a controller keyed by the table's sizing store, with one pair of global keydown/blur listeners and one active gesture. The initiating handle's options govern that gesture; pass consistent storage and responsive settings to all handles. Removing the active handle, disabling it or changing its resize settings cancels its preview. Removing other handles leaves the gesture running. Separate tables remain independent. Pass `options={{ enabled: false }}` to hide a primitive handle when your layout is disabled; the hook's `disabled` controls selection, while primitive UI controls remain yours to disable.
147
+
148
+ For more specialized state wiring, the existing `puppertinoTableFeatures`, `PuppertinoTableInstance`, sizing helpers and TanStack `useTable` remain available. In custom layouts, memoize your row body separately from the `columnSizing` subscription to avoid rerendering cells during drags.
149
+
150
+ ### Server-paginated complete table
151
+
152
+ The server owns pagination and order. This example receives one page and its total from the host's loader; it makes no assumption about an API URL. `loadPage` must honor its abort signal. Updating the sort resets the page; stale requests cannot replace newer results. Use stable IDs across pages and keep selection state in the host if selections must survive page changes.
153
+
154
+ ```tsx
155
+ import { useEffect, useState } from 'react';
156
+ import type { SortingState, RowSelectionState } from '@tanstack/react-table';
157
+ import { PuppertinoTable } from 'puppertino-table';
158
+
159
+ type PageLoader = (query: { page: number; pageSize: number; sorting: SortingState; signal: AbortSignal }) => Promise<{ rows: Item[]; total: number }>;
160
+
161
+ function ServerItems({ loadPage }: { loadPage: PageLoader }) {
162
+ const [query, setQuery] = useState({ page: 0, sorting: [] as SortingState });
163
+ const [selection, setSelection] = useState<RowSelectionState>({});
164
+ const [result, setResult] = useState<{ rows: Item[]; total: number }>({ rows: [], total: 0 });
165
+ const [loading, setLoading] = useState(true);
166
+ const [error, setError] = useState('');
167
+ const pageSize = 25;
168
+ useEffect(() => {
169
+ const controller = new AbortController();
170
+ setLoading(true); setError('');
171
+ loadPage({ ...query, pageSize, signal: controller.signal })
172
+ .then(value => { if (!controller.signal.aborted) setResult(value); })
173
+ .catch(() => { if (!controller.signal.aborted) setError('Could not load items.'); })
174
+ .finally(() => { if (!controller.signal.aborted) setLoading(false); });
175
+ return () => controller.abort();
176
+ }, [loadPage, query]);
177
+ return <>
178
+ {error && <p role="alert">{error}</p>}
179
+ <PuppertinoTable data={result.rows} columns={columns} getRowId={item => item.id} ariaLabel="Items"
180
+ manualSorting sorting={query.sorting}
181
+ onSortingChange={update => setQuery(previous => ({ page: 0, sorting: typeof update === 'function' ? update(previous.sorting) : update }))}
182
+ selectable rowSelection={selection} onRowSelectionChange={setSelection} disabled={loading}
183
+ emptyContent={loading ? 'Loading items…' : 'No items'} />
184
+ <p role="status">{loading ? 'Loading items…' : `${result.total} items`}</p>
185
+ <button type="button" disabled={loading || query.page === 0} onClick={() => setQuery(previous => ({ ...previous, page: previous.page - 1 }))}>Previous page</button>
186
+ <button type="button" disabled={loading || (query.page + 1) * pageSize >= result.total} onClick={() => setQuery(previous => ({ ...previous, page: previous.page + 1 }))}>Next page</button>
187
+ </>;
188
+ }
189
+ ```
77
190
 
78
- Use `puppertinoTableFeatures`, `PuppertinoTableInstance`, `useColumnResize`, `ColumnResizeHandle`, and the sizing helpers with your own `useTable` instance. The features enable sorting, sizing and row selection. Subscribe to `table.atoms.columnSizing` around table geometry and keep the row body memoized. Optional hook settings let you choose persistence, a cancellation media query and a drag CSS class.
191
+ Keep `loadPage` stable (for example, a module-level function or `useCallback`). Selection IDs may include unloaded pages, so counts and bulk actions should use the host's selection state, not just the current row model. Use the same data, query and selection options with `usePuppertinoTable` for a server-paginated custom layout. Leave `manualSorting` enabled in both paths so a loaded page never gets re-sorted as if it were the complete dataset.
79
192
 
80
193
  The package adds scoped `.pt-*` enhancements to Puppertino's `p-table` styles. Import Puppertino's CSS in your application, then the package stylesheet. Theme tokens remain owned by Puppertino and your application. Include Puppertino's dark-mode stylesheet when using its dark theme.
81
194
 
@@ -92,7 +205,7 @@ pnpm dev # standalone 267-row fictional-data demo
92
205
  pnpm build:storybook # static Storybook in storybook-static/
93
206
  ```
94
207
 
95
- Stories cover local sorting, host-owned order, range selection, keyboard resizing, dark mode, narrow layouts, empty/disabled/read-only states, grouped headers, and a different row schema with currency cells. Story controls expose selection, resizing, disabled state and sorting ownership.
208
+ Stories cover local sorting, host-owned order, range selection, keyboard resizing, dark mode, narrow layouts, empty/disabled/read-only states, grouped headers, a different row schema with currency cells, customized/leading rows, and a custom hook layout. Story controls expose selection, resizing, disabled state and sorting ownership.
96
209
 
97
210
  ## Verification
98
211
 
@@ -100,7 +213,7 @@ Stories cover local sorting, host-owned order, range selection, keyboard resizin
100
213
  pnpm check
101
214
  ```
102
215
 
103
- Checks include TypeScript, preference/schema unit tests, library/demo/Storybook builds, and Chromium regression tests. The browser suite exercises 512 repeated pointer drags, width limits, reset/cancellation/persistence, selection, unmount/disabled recovery, standalone-control removal and grouped-header resizing, all 267 demo rows across six pages, six light/dark accessibility layouts, all twelve Storybook stories, and tarball installation with typed imports, server rendering and CSS exports in fresh React 18 and React 19 consumers. Set `PLAYWRIGHT_CHROMIUM_EXECUTABLE` to a system Chromium path, or install a browser with `pnpm exec playwright install chromium`.
216
+ Checks include TypeScript, preference/schema unit tests, library/demo/Storybook builds, and Chromium regression tests. The browser suite exercises 512 repeated pointer drags, width limits, reset/cancellation/persistence, selection, unmount/disabled recovery, standalone-control removal and grouped-header resizing, all 267 demo rows across six pages, six light/dark accessibility layouts, all seventeen Storybook stories, and tarball installation with typed imports, server rendering and CSS exports in fresh React 18 and React 19 consumers. Set `PLAYWRIGHT_CHROMIUM_EXECUTABLE` to a system Chromium path, or install a browser with `pnpm exec playwright install chromium`.
104
217
 
105
218
  GitHub Actions runs the same checks and uploads Storybook plus browser evidence as an artifact. Browser tests use fictional data and do not claim Safari, physical-device or latency coverage.
106
219
 
@@ -110,7 +223,7 @@ The unscoped **`puppertino-table`** package is public on npm. Stable releases us
110
223
 
111
224
  ```sh
112
225
  pnpm check
113
- npm publish test-results/package/puppertino-table-0.1.1.tgz --dry-run --ignore-scripts --access public
226
+ npm publish test-results/package/puppertino-table-0.2.0.tgz --dry-run --ignore-scripts --access public
114
227
  ```
115
228
 
116
229
  `pnpm check` builds and packs the library, checks the tarball file list, and installs it in isolated React 18 and React 19 consumers. `prepack` rebuilds JavaScript and declarations so a locally packed release cannot silently use stale output. Only the compiled library, CSS, package metadata, README, changelog and MIT license ship; Storybook, the demo and tests stay out of the npm package.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- export { PuppertinoTable, type PuppertinoTableProps } from './table.js';
1
+ export { PuppertinoTable, type PuppertinoTableProps, type PuppertinoRow, type PuppertinoCell, type PuppertinoRowProps, type PuppertinoCellProps, type PuppertinoDataAttributes, type PuppertinoBodyContext } from './table.js';
2
2
  export { createPuppertinoColumnHelper, puppertinoTableFeatures, type PuppertinoTableInstance, type PuppertinoColumnDef } from './model.js';
3
3
  export { useColumnResize, type ColumnResizeOptions } from './use-column-resize.js';
4
- export { ColumnResizeHandle } from './resize-handle.js';
4
+ export { ColumnResizeHandle, type ColumnResizeHandleProps } from './resize-handle.js';
5
5
  export { readColumnSizing, columnSizeBounds, clampSize, type ColumnSizeBounds } from './sizing.js';
6
+ export { usePuppertinoTable, type PuppertinoTableOptions } from './use-puppertino-table.js';
package/dist/index.js CHANGED
@@ -3,3 +3,4 @@ export { createPuppertinoColumnHelper, puppertinoTableFeatures } from './model.j
3
3
  export { useColumnResize } from './use-column-resize.js';
4
4
  export { ColumnResizeHandle } from './resize-handle.js';
5
5
  export { readColumnSizing, columnSizeBounds, clampSize } from './sizing.js';
6
+ export { usePuppertinoTable } from './use-puppertino-table.js';
@@ -3,9 +3,11 @@ import type { ColumnResizeOptions } from './use-column-resize.js';
3
3
  import type { PuppertinoTableInstance } from './model.js';
4
4
  import type { Header } from '@tanstack/react-table';
5
5
  import { puppertinoTableFeatures } from './model.js';
6
- export declare function ColumnResizeHandle<T extends RowData>({ table, header, label, options }: {
6
+ export interface ColumnResizeHandleProps<T extends RowData> {
7
7
  table: PuppertinoTableInstance<T>;
8
8
  header: Header<typeof puppertinoTableFeatures, T>;
9
- label: string;
9
+ /** Defaults to a string header or the stable column ID. */
10
+ label?: string;
10
11
  options?: ColumnResizeOptions;
11
- }): import("react").JSX.Element | null;
12
+ }
13
+ export declare function ColumnResizeHandle<T extends RowData>({ table, header, label, options }: ColumnResizeHandleProps<T>): import("react").JSX.Element | null;
@@ -2,8 +2,9 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useColumnResize } from './use-column-resize.js';
4
4
  export function ColumnResizeHandle({ table, header, label, options }) {
5
+ const resolvedLabel = label ?? (typeof header.column.columnDef.header === 'string' ? header.column.columnDef.header : header.column.id);
5
6
  const { handle } = useColumnResize(table, options);
6
7
  if (options?.enabled === false || header.isPlaceholder || header.column.columns.length > 0)
7
8
  return null;
8
- return _jsx("span", { className: "pt-resizer", role: "separator", tabIndex: 0, "aria-orientation": "vertical", "aria-label": `Resize ${label} column`, "aria-valuemin": header.column.columnDef.minSize ?? 20, "aria-valuemax": header.column.columnDef.maxSize ?? 1200, "aria-valuenow": header.getSize(), "aria-valuetext": `${header.getSize()} pixels`, title: "Drag to resize. Double-click or Enter to reset. Arrow keys adjust width; Escape cancels a drag.", ...handle(header.column.id) });
9
+ return _jsx("span", { className: "pt-resizer", role: "separator", tabIndex: 0, "aria-orientation": "vertical", "aria-label": `Resize ${resolvedLabel} column`, "aria-valuemin": header.column.columnDef.minSize ?? 20, "aria-valuemax": header.column.columnDef.maxSize ?? 1200, "aria-valuenow": header.getSize(), "aria-valuetext": `${header.getSize()} pixels`, title: "Drag to resize. Double-click or Enter to reset. Arrow keys adjust width; Escape cancels a drag.", ...handle(header.column.id) });
9
10
  }
package/dist/table.d.ts CHANGED
@@ -1,27 +1,32 @@
1
- import type { RowData } from '@tanstack/react-table';
2
- import { type ReactNode } from 'react';
3
- import { type SortingState, type RowSelectionState, type OnChangeFn } from '@tanstack/react-table';
4
- import { type PuppertinoColumnDef } from './model.js';
5
- export interface PuppertinoTableProps<T extends RowData> {
6
- data: readonly T[];
7
- columns: readonly PuppertinoColumnDef<T>[];
8
- getRowId: (row: T, index: number) => string;
9
- sorting: SortingState;
10
- onSortingChange: OnChangeFn<SortingState>;
11
- /** True for server-owned order/pagination; false enables local TanStack sorting. */
12
- manualSorting?: boolean;
13
- rowSelection?: RowSelectionState;
14
- onRowSelectionChange?: OnChangeFn<RowSelectionState>;
1
+ import type { RowData, Row, Cell } from '@tanstack/react-table';
2
+ import { type ReactNode, type ComponentPropsWithoutRef } from 'react';
3
+ import { puppertinoTableFeatures, type PuppertinoTableInstance } from './model.js';
4
+ import { type PuppertinoTableOptions } from './use-puppertino-table.js';
5
+ export type PuppertinoRow<T extends RowData> = Row<typeof puppertinoTableFeatures, T>;
6
+ export type PuppertinoCell<T extends RowData> = Cell<typeof puppertinoTableFeatures, T>;
7
+ export type PuppertinoDataAttributes = {
8
+ [key: `data-${string}`]: string | number | boolean | null | undefined;
9
+ };
10
+ export interface PuppertinoBodyContext<T extends RowData> {
11
+ table: PuppertinoTableInstance<T>;
12
+ /** Leaf columns plus the optional selection column. */
13
+ columnCount: number;
14
+ selectable: boolean;
15
+ disabled: boolean;
16
+ }
17
+ export type PuppertinoRowProps = PuppertinoDataAttributes & Omit<ComponentPropsWithoutRef<'tr'>, 'children' | 'dangerouslySetInnerHTML'>;
18
+ export type PuppertinoCellProps = PuppertinoDataAttributes & Omit<ComponentPropsWithoutRef<'td'>, 'children' | 'dangerouslySetInnerHTML'>;
19
+ export interface PuppertinoTableProps<T extends RowData> extends PuppertinoTableOptions<T> {
15
20
  getRowLabel?: (row: T) => string;
16
- selectable?: boolean;
17
- disabled?: boolean;
18
21
  resizable?: boolean;
19
- storageKey?: string;
20
- storage?: Pick<Storage, 'getItem' | 'setItem'>;
21
22
  cancelMediaQuery?: string;
22
23
  ariaLabel: string;
23
24
  caption?: ReactNode;
24
25
  emptyContent?: ReactNode;
25
26
  className?: string;
27
+ getRowProps?: (row: PuppertinoRow<T>, context: PuppertinoBodyContext<T>) => PuppertinoRowProps | undefined;
28
+ getCellProps?: (cell: PuppertinoCell<T>, context: PuppertinoBodyContext<T>) => PuppertinoCellProps | undefined;
29
+ /** Render semantic <tr> elements before data rows, including when data is empty. */
30
+ leadingRows?: ReactNode | ((table: PuppertinoTableInstance<T>, context: PuppertinoBodyContext<T>) => ReactNode);
26
31
  }
27
- export declare function PuppertinoTable<T extends RowData>({ data, columns, getRowId, sorting, onSortingChange, manualSorting, rowSelection: controlledSelection, onRowSelectionChange, getRowLabel, selectable, disabled, resizable, storageKey, storage, cancelMediaQuery, ariaLabel, caption, emptyContent, className }: PuppertinoTableProps<T>): import("react").JSX.Element;
32
+ export declare function PuppertinoTable<T extends RowData>({ data, columns, getRowId, sorting, onSortingChange, initialSorting, initialRowSelection, manualSorting, rowSelection: controlledSelection, onRowSelectionChange, getRowLabel, selectable, disabled, resizable, storageKey, storage, cancelMediaQuery, ariaLabel, caption, emptyContent, className, getRowProps, getCellProps, leadingRows }: PuppertinoTableProps<T>): import("react").JSX.Element;
package/dist/table.js CHANGED
@@ -1,38 +1,12 @@
1
1
  "use client";
2
+ import { createElement as _createElement } from "react";
2
3
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { memo, useMemo, useState } from 'react';
4
- import { useTable } from '@tanstack/react-table';
5
- import { puppertinoTableFeatures } from './model.js';
6
- import { columnSizeBounds, readColumnSizing } from './sizing.js';
4
+ import { memo } from 'react';
5
+ import { usePuppertinoTable } from './use-puppertino-table.js';
7
6
  import { useColumnResize } from './use-column-resize.js';
8
- export function PuppertinoTable({ data, columns, getRowId, sorting, onSortingChange, manualSorting = true, rowSelection: controlledSelection, onRowSelectionChange, getRowLabel, selectable = false, disabled = false, resizable = true, storageKey, storage, cancelMediaQuery, ariaLabel, caption, emptyContent = 'No rows', className = '' }) {
9
- const [localSelection, setLocalSelection] = useState({});
10
- const rowSelection = controlledSelection ?? localSelection;
11
- const canSelect = selectable && !disabled && (controlledSelection === undefined || !!onRowSelectionChange);
12
- const bounds = useMemo(() => columnSizeBounds(columns), [columns]);
13
- const sizedColumns = useMemo(() => {
14
- const byId = new Map(bounds.map(bound => [bound.id, bound]));
15
- const decorate = (defs) => defs.map(def => {
16
- if ('columns' in def && def.columns)
17
- return { ...def, columns: decorate(def.columns) };
18
- const id = def.id ?? ('accessorKey' in def ? String(def.accessorKey).replaceAll('.', '_') : '');
19
- return { ...def, ...byId.get(id) };
20
- });
21
- return decorate(columns);
22
- }, [columns, bounds]);
23
- const [initialWidths] = useState(() => {
24
- try {
25
- return readColumnSizing(bounds, storageKey, storage || localStorage);
26
- }
27
- catch {
28
- return readColumnSizing(bounds);
29
- }
30
- });
31
- const table = useTable({ features: puppertinoTableFeatures, columns: sizedColumns, data, getRowId,
32
- manualSorting, enableMultiSort: false, enableSortingRemoval: false, enableRowSelection: canSelect,
33
- enableSubRowSelection: false, initialState: { columnSizing: initialWidths },
34
- state: { sorting, rowSelection }, onSortingChange, onRowSelectionChange: onRowSelectionChange ?? setLocalSelection,
35
- }, state => ({ sorting: state.sorting, rowSelection: state.rowSelection }));
7
+ export function PuppertinoTable({ data, columns, getRowId, sorting, onSortingChange, initialSorting, initialRowSelection, manualSorting = true, rowSelection: controlledSelection, onRowSelectionChange, getRowLabel, selectable = false, disabled = false, resizable = true, storageKey, storage, cancelMediaQuery, ariaLabel, caption, emptyContent = 'No rows', className = '', getRowProps, getCellProps, leadingRows }) {
8
+ const table = usePuppertinoTable({ data, columns, getRowId, sorting, onSortingChange, initialSorting, initialRowSelection, manualSorting,
9
+ rowSelection: controlledSelection, onRowSelectionChange, selectable, disabled, storageKey, storage });
36
10
  const options = { enabled: resizable && !disabled, storageKey, storage, cancelMediaQuery };
37
11
  const { handle } = useColumnResize(table, options);
38
12
  const groups = table.getHeaderGroups();
@@ -42,11 +16,19 @@ export function PuppertinoTable({ data, columns, getRowId, sorting, onSortingCha
42
16
  const sorted = column.getIsSorted();
43
17
  const leaf = column.columns.length === 0;
44
18
  return _jsxs("th", { colSpan: header.colSpan, scope: leaf ? 'col' : 'colgroup', "aria-sort": sorted ? sorted === 'asc' ? 'ascending' : 'descending' : undefined, children: [!header.isPlaceholder && (column.getCanSort() ? _jsxs("button", { type: "button", className: "pt-sort", "aria-label": `Sort by ${label}`, disabled: disabled, onClick: () => column.toggleSorting(), children: [_jsx(table.FlexRender, { header: header }), _jsx("span", { className: "pt-sort-arrow", "aria-hidden": "true", "data-direction": sorted || 'none', children: "\u2304" })] }) : _jsx(table.FlexRender, { header: header })), leaf && !header.isPlaceholder && resizable && !disabled && _jsx("span", { className: "pt-resizer", role: "separator", tabIndex: 0, "aria-orientation": "vertical", "aria-label": `Resize ${label} column`, "aria-valuemin": column.columnDef.minSize, "aria-valuemax": column.columnDef.maxSize, "aria-valuenow": header.getSize(), "aria-valuetext": `${header.getSize()} pixels`, title: "Drag to resize. Double-click or Enter to reset. Arrows adjust width; Escape cancels a drag.", ...handle(column.id) })] }, header.id);
45
- })] }, group.id)) }), _jsx(MemoTableBody, { table: table, selectable: selectable, getRowLabel: getRowLabel, emptyContent: emptyContent })] }) }) });
19
+ })] }, group.id)) }), _jsx(MemoTableBody, { table: table, selectable: selectable, disabled: disabled, getRowLabel: getRowLabel, emptyContent: emptyContent, getRowProps: getRowProps, getCellProps: getCellProps, leadingRows: leadingRows })] }) }) });
46
20
  }
47
21
  /** Geometry updates do not rerender cells. State and data changes still do. */
48
- function TableBody({ table, selectable, getRowLabel, emptyContent }) {
22
+ function TableBody({ table, selectable, disabled, getRowLabel, emptyContent, getRowProps, getCellProps, leadingRows }) {
49
23
  const rows = table.getRowModel().rows;
50
- return _jsx("tbody", { children: rows.length ? rows.map(row => _jsxs("tr", { className: row.getIsSelected() ? 'p-selected' : undefined, children: [selectable && _jsx("td", { className: "pt-selection", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${getRowLabel?.(row.original) ?? row.id}`, checked: row.getIsSelected(), disabled: !row.getCanSelect(), onClick: event => { event.stopPropagation(); row.getToggleSelectedHandler()(event); }, onChange: () => { } }) }), row.getAllCells().map(cell => _jsx("td", { children: _jsx(table.FlexRender, { cell: cell }) }, cell.id))] }, row.id)) : _jsx("tr", { children: _jsx("td", { colSpan: table.getAllLeafColumns().length + (selectable ? 1 : 0), children: emptyContent }) }) });
24
+ const context = { table, selectable, disabled, columnCount: table.getAllLeafColumns().length + (selectable ? 1 : 0) };
25
+ return _jsxs("tbody", { children: [typeof leadingRows === 'function' ? leadingRows(table, context) : leadingRows, rows.length ? rows.map(row => {
26
+ const props = getRowProps?.(row, context);
27
+ const className = [row.getIsSelected() ? 'p-selected' : '', props?.className].filter(Boolean).join(' ') || undefined;
28
+ return _createElement("tr", { ...props, key: row.id, className: className },
29
+ selectable && _jsx("td", { className: "pt-selection", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${getRowLabel?.(row.original) ?? row.id}`, checked: row.getIsSelected(), disabled: !row.getCanSelect(), onClick: event => { event.stopPropagation(); row.getToggleSelectedHandler()(event); }, onChange: () => { } }) }),
30
+ row.getAllCells().map(cell => _createElement("td", { ...getCellProps?.(cell, context), key: cell.id },
31
+ _jsx(table.FlexRender, { cell: cell }))));
32
+ }) : _jsx("tr", { children: _jsx("td", { colSpan: context.columnCount, children: emptyContent }) })] });
51
33
  }
52
34
  const MemoTableBody = memo(TableBody);
@@ -1,6 +1,6 @@
1
1
  import type { RowData } from '@tanstack/react-table';
2
2
  import { type PointerEvent, type KeyboardEvent } from 'react';
3
- import type { PuppertinoTableInstance } from "./model.js";
3
+ import type { PuppertinoTableInstance } from './model.js';
4
4
  export interface ColumnResizeOptions {
5
5
  enabled?: boolean;
6
6
  storageKey?: string;
@@ -8,7 +8,7 @@ export interface ColumnResizeOptions {
8
8
  cancelMediaQuery?: string;
9
9
  dragClassName?: string;
10
10
  }
11
- export declare function useColumnResize<T extends RowData>(table: PuppertinoTableInstance<T>, { enabled, storageKey, storage, cancelMediaQuery, dragClassName }?: ColumnResizeOptions): {
11
+ export declare function useColumnResize<T extends RowData>(table: PuppertinoTableInstance<T>, options?: ColumnResizeOptions): {
12
12
  handle: (id: string) => {
13
13
  onPointerDown: (event: PointerEvent<HTMLElement>) => void;
14
14
  onPointerMove: (event: PointerEvent<HTMLElement>) => void;
@@ -10,106 +10,140 @@ function markDrag(name, delta) {
10
10
  activeClasses.delete(name);
11
11
  document.documentElement.classList.toggle(name, count > 0);
12
12
  }
13
- export function useColumnResize(table, { enabled = true, storageKey, storage, cancelMediaQuery, dragClassName = "pt-resizing" } = {}) {
14
- const bounds = (id) => {
13
+ /** One gesture and one set of global listeners for each table's sizing store. */
14
+ function createController() {
15
+ const members = new Set();
16
+ let drag = null;
17
+ const snapshot = (table) => Object.fromEntries(table.getAllLeafColumns().map(column => [column.id, column.getSize()]));
18
+ const bounds = (table, id) => {
15
19
  const column = table.getColumn(id);
16
20
  return { id, size: column.columnDef.size ?? 150, minSize: column.columnDef.minSize ?? 20, maxSize: column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER };
17
21
  };
18
- const clamp = (id, value) => clampSize(value, bounds(id));
19
- const snapshot = () => Object.fromEntries(table.getAllLeafColumns().map(column => [column.id, column.getSize()]));
20
- const save = () => { try {
21
- if (storageKey)
22
- (storage || localStorage).setItem(storageKey, JSON.stringify(snapshot()));
23
- }
24
- catch { /* Resizing still works without storage. */ } };
25
- const reset = (id) => { table.getColumn(id).resetSize(); save(); };
26
- const drag = useRef(null);
27
- const apply = (next, persist = false) => {
28
- table.setColumnSizing(next);
29
- if (persist)
30
- save();
22
+ const save = ({ table, options }) => {
23
+ try {
24
+ if (options.storageKey)
25
+ (options.storage || localStorage).setItem(options.storageKey, JSON.stringify(snapshot(table)));
26
+ }
27
+ catch { /* Storage is optional. */ }
31
28
  };
32
29
  const finish = (cancel = false) => {
33
- const active = drag.current;
30
+ const active = drag;
34
31
  if (!active)
35
32
  return;
36
- drag.current = null;
37
- markDrag(dragClassName, -1);
33
+ drag = null;
34
+ active.removeMedia();
35
+ markDrag(active.className, -1);
38
36
  if (active.target.hasPointerCapture(active.pointer))
39
37
  active.target.releasePointerCapture(active.pointer);
40
- apply(cancel ? active.before : snapshot(), !cancel);
38
+ active.member.table.setColumnSizing(cancel ? active.before : snapshot(active.member.table));
39
+ if (!cancel)
40
+ save(active.member);
41
41
  };
42
- useEffect(() => { if (!enabled)
43
- finish(true); }, [enabled]);
44
- useEffect(() => {
45
- const cancel = (event) => { if (event.key === 'Escape' && drag.current) {
46
- event.preventDefault();
47
- finish(true);
48
- } };
49
- const blur = () => finish(true);
50
- const compact = cancelMediaQuery ? window.matchMedia(cancelMediaQuery) : undefined;
51
- const modeChange = () => { if (compact?.matches)
52
- finish(true); };
53
- compact?.addEventListener("change", modeChange);
54
- window.addEventListener('keydown', cancel);
55
- window.addEventListener('blur', blur);
56
- return () => {
57
- compact?.removeEventListener("change", modeChange);
58
- window.removeEventListener('keydown', cancel);
59
- window.removeEventListener('blur', blur);
60
- // Never save a gesture interrupted by route or display-mode changes.
61
- const active = drag.current;
62
- drag.current = null;
63
- if (active)
64
- markDrag(dragClassName, -1);
65
- if (active?.target.hasPointerCapture(active.pointer))
66
- active.target.releasePointerCapture(active.pointer);
67
- if (active)
68
- table.setColumnSizing(active.before);
69
- };
70
- }, [table.atoms.columnSizing, cancelMediaQuery, dragClassName]);
71
- return { handle: (id) => ({
72
- onPointerDown: (event) => {
73
- if (!enabled || event.button !== 0 || drag.current)
74
- return;
75
- event.preventDefault();
76
- event.stopPropagation();
77
- event.currentTarget.focus();
78
- const before = snapshot();
79
- drag.current = { id, x: event.clientX, width: before[id], before, target: event.currentTarget, pointer: event.pointerId };
80
- event.currentTarget.setPointerCapture(event.pointerId);
81
- markDrag(dragClassName, 1);
82
- },
83
- onPointerMove: (event) => {
84
- const active = drag.current;
85
- if (active && active.pointer === event.pointerId)
86
- apply({ ...snapshot(), [active.id]: clamp(active.id, active.width + event.clientX - active.x) });
87
- },
88
- onPointerUp: (event) => { if (event.pointerId === drag.current?.pointer)
89
- finish(!enabled || !!(cancelMediaQuery && window.matchMedia(cancelMediaQuery).matches)); },
90
- onPointerCancel: (event) => { if (event.pointerId === drag.current?.pointer)
91
- finish(true); },
92
- onLostPointerCapture: (event) => { if (event.pointerId === drag.current?.pointer)
93
- finish(true); },
94
- onDoubleClick: () => { if (enabled)
95
- reset(id); },
96
- onKeyDown: (event) => {
97
- if (!enabled || drag.current)
98
- return;
99
- if (event.key === "Enter") {
42
+ const cancel = (event) => { if (event.key === 'Escape' && drag) {
43
+ event.preventDefault();
44
+ finish(true);
45
+ } };
46
+ const blur = () => finish(true);
47
+ const cancelMember = (member) => { if (drag?.member === member)
48
+ finish(true); };
49
+ return {
50
+ cancelMember,
51
+ retain(member) {
52
+ if (!members.size) {
53
+ window.addEventListener('keydown', cancel);
54
+ window.addEventListener('blur', blur);
55
+ }
56
+ members.add(member);
57
+ return () => {
58
+ cancelMember(member);
59
+ members.delete(member);
60
+ if (!members.size) {
61
+ window.removeEventListener('keydown', cancel);
62
+ window.removeEventListener('blur', blur);
63
+ }
64
+ };
65
+ },
66
+ handle(id, member) {
67
+ const reset = () => { member.table.getColumn(id).resetSize(); save(member); };
68
+ return {
69
+ onPointerDown: (event) => {
70
+ const { table, options } = member;
71
+ if (options.enabled === false || event.button !== 0 || drag || !members.has(member))
72
+ return;
73
+ const media = options.cancelMediaQuery ? window.matchMedia(options.cancelMediaQuery) : undefined;
74
+ if (media?.matches)
75
+ return;
100
76
  event.preventDefault();
101
77
  event.stopPropagation();
102
- reset(id);
103
- return;
104
- }
105
- const step = event.shiftKey ? 40 : 16;
106
- const widths = snapshot();
107
- const next = event.key === 'ArrowLeft' ? widths[id] - step : event.key === 'ArrowRight' ? widths[id] + step : event.key === 'Home' ? bounds(id).minSize : event.key === 'End' ? bounds(id).maxSize : undefined;
108
- if (next === undefined)
109
- return;
110
- event.preventDefault();
111
- event.stopPropagation();
112
- apply({ ...snapshot(), [id]: clamp(id, next) }, true);
113
- },
114
- }) };
78
+ event.currentTarget.focus();
79
+ const before = snapshot(table);
80
+ const modeChange = () => { if (media?.matches)
81
+ finish(true); };
82
+ drag = { member, id, x: event.clientX, width: before[id], before, target: event.currentTarget,
83
+ pointer: event.pointerId, className: options.dragClassName ?? 'pt-resizing', media,
84
+ removeMedia: () => media?.removeEventListener('change', modeChange) };
85
+ media?.addEventListener('change', modeChange);
86
+ event.currentTarget.setPointerCapture(event.pointerId);
87
+ markDrag(drag.className, 1);
88
+ },
89
+ onPointerMove: (event) => {
90
+ const active = drag;
91
+ if (active?.member === member && active.pointer === event.pointerId) {
92
+ const { table } = member;
93
+ table.setColumnSizing({ ...snapshot(table), [active.id]: clampSize(active.width + event.clientX - active.x, bounds(table, active.id)) });
94
+ }
95
+ },
96
+ onPointerUp: (event) => {
97
+ if (drag?.member === member && event.pointerId === drag.pointer)
98
+ finish(member.options.enabled === false || !!drag.media?.matches);
99
+ },
100
+ onPointerCancel: (event) => { if (drag?.member === member && event.pointerId === drag.pointer)
101
+ finish(true); },
102
+ onLostPointerCapture: (event) => { if (drag?.member === member && event.pointerId === drag.pointer)
103
+ finish(true); },
104
+ onDoubleClick: () => { if (member.options.enabled !== false && !drag)
105
+ reset(); },
106
+ onKeyDown: (event) => {
107
+ if (member.options.enabled === false || drag)
108
+ return;
109
+ if (event.key === 'Enter') {
110
+ event.preventDefault();
111
+ event.stopPropagation();
112
+ reset();
113
+ return;
114
+ }
115
+ const { table } = member;
116
+ const step = event.shiftKey ? 40 : 16;
117
+ const widths = snapshot(table);
118
+ const next = event.key === 'ArrowLeft' ? widths[id] - step : event.key === 'ArrowRight' ? widths[id] + step : event.key === 'Home' ? bounds(table, id).minSize : event.key === 'End' ? bounds(table, id).maxSize : undefined;
119
+ if (next === undefined)
120
+ return;
121
+ event.preventDefault();
122
+ event.stopPropagation();
123
+ table.setColumnSizing({ ...widths, [id]: clampSize(next, bounds(table, id)) });
124
+ save(member);
125
+ },
126
+ };
127
+ },
128
+ };
129
+ }
130
+ // The atom survives table wrapper rerenders. Weak keys allow discarded tables to be collected.
131
+ const controllers = new WeakMap();
132
+ export function useColumnResize(table, options = {}) {
133
+ const key = table.atoms.columnSizing;
134
+ let controller = controllers.get(key);
135
+ if (!controller) {
136
+ controller = createController();
137
+ controllers.set(key, controller);
138
+ }
139
+ const member = useRef({ table, options });
140
+ member.current.table = table;
141
+ member.current.options = options;
142
+ useEffect(() => controller.retain(member.current), [controller]);
143
+ useEffect(() => {
144
+ if (options.enabled === false)
145
+ controller.cancelMember(member.current);
146
+ return () => controller.cancelMember(member.current);
147
+ }, [controller, options.enabled, options.storageKey, options.storage, options.cancelMediaQuery, options.dragClassName]);
148
+ return { handle: (id) => controller.handle(id, member.current) };
115
149
  }
@@ -0,0 +1,22 @@
1
+ import type { RowData, SortingState, RowSelectionState, OnChangeFn } from '@tanstack/react-table';
2
+ import { type PuppertinoColumnDef, type PuppertinoTableInstance } from './model.js';
3
+ export interface PuppertinoTableOptions<T extends RowData> {
4
+ data: readonly T[];
5
+ columns: readonly PuppertinoColumnDef<T>[];
6
+ getRowId: (row: T, index: number) => string;
7
+ /** Omit for local state. Supply a callback alongside controlled sorting to allow edits. */
8
+ sorting?: SortingState;
9
+ onSortingChange?: OnChangeFn<SortingState>;
10
+ initialSorting?: SortingState;
11
+ initialRowSelection?: RowSelectionState;
12
+ /** Preserve host-owned order by default, including server-paginated data. */
13
+ manualSorting?: boolean;
14
+ rowSelection?: RowSelectionState;
15
+ onRowSelectionChange?: OnChangeFn<RowSelectionState>;
16
+ selectable?: boolean;
17
+ disabled?: boolean;
18
+ storageKey?: string;
19
+ storage?: Pick<Storage, 'getItem' | 'setItem'>;
20
+ }
21
+ /** Shared state wiring for complete tables and custom semantic layouts. */
22
+ export declare function usePuppertinoTable<T extends RowData>({ data, columns, getRowId, sorting: controlledSorting, onSortingChange, initialSorting, initialRowSelection, manualSorting, rowSelection: controlledSelection, onRowSelectionChange, selectable, disabled, storageKey, storage }: PuppertinoTableOptions<T>): PuppertinoTableInstance<T>;
@@ -0,0 +1,48 @@
1
+ "use client";
2
+ import { useTable } from '@tanstack/react-table';
3
+ import { useCallback, useMemo, useState } from 'react';
4
+ import { puppertinoTableFeatures } from './model.js';
5
+ import { columnSizeBounds, readColumnSizing } from './sizing.js';
6
+ /** Shared state wiring for complete tables and custom semantic layouts. */
7
+ export function usePuppertinoTable({ data, columns, getRowId, sorting: controlledSorting, onSortingChange, initialSorting = [], initialRowSelection = {}, manualSorting = true, rowSelection: controlledSelection, onRowSelectionChange, selectable = false, disabled = false, storageKey, storage }) {
8
+ const [localSelection, setLocalSelection] = useState(() => initialRowSelection);
9
+ const [localSorting, setLocalSorting] = useState(() => initialSorting);
10
+ const sorting = controlledSorting ?? localSorting;
11
+ const changeSorting = useCallback(update => {
12
+ if (controlledSorting === undefined)
13
+ setLocalSorting(update);
14
+ onSortingChange?.(update);
15
+ }, [controlledSorting, onSortingChange]);
16
+ const changeSelection = useCallback(update => {
17
+ if (controlledSelection === undefined)
18
+ setLocalSelection(update);
19
+ onRowSelectionChange?.(update);
20
+ }, [controlledSelection, onRowSelectionChange]);
21
+ const rowSelection = controlledSelection ?? localSelection;
22
+ const canSelect = selectable && !disabled && (controlledSelection === undefined || !!onRowSelectionChange);
23
+ const bounds = useMemo(() => columnSizeBounds(columns), [columns]);
24
+ const sizedColumns = useMemo(() => {
25
+ const byId = new Map(bounds.map(bound => [bound.id, bound]));
26
+ const decorate = (defs) => defs.map(def => {
27
+ if ('columns' in def && def.columns)
28
+ return { ...def, columns: decorate(def.columns) };
29
+ const id = def.id ?? ('accessorKey' in def ? String(def.accessorKey).replaceAll('.', '_') : '');
30
+ return { ...def, ...byId.get(id) };
31
+ });
32
+ return decorate(columns);
33
+ }, [columns, bounds]);
34
+ const [initialWidths] = useState(() => {
35
+ try {
36
+ return readColumnSizing(bounds, storageKey, storage || localStorage);
37
+ }
38
+ catch {
39
+ return readColumnSizing(bounds);
40
+ }
41
+ });
42
+ const table = useTable({ features: puppertinoTableFeatures, columns: sizedColumns, data, getRowId,
43
+ manualSorting, enableSorting: controlledSorting === undefined || !!onSortingChange, enableMultiSort: false, enableSortingRemoval: false, enableRowSelection: canSelect,
44
+ enableSubRowSelection: false, initialState: { columnSizing: initialWidths },
45
+ state: { sorting, rowSelection }, onSortingChange: changeSorting, onRowSelectionChange: changeSelection,
46
+ }, state => ({ sorting: state.sorting, rowSelection: state.rowSelection }));
47
+ return table;
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "puppertino-table",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",