puppertino-table 0.2.0 → 0.3.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-10-12
4
+
5
+ - Require the latest stable React 19 at release time (19.3.0, peer range `^19.3.0`); remove React 18 compatibility coverage.
6
+
7
+ - Extract a React-independent resize gesture controller, browser-effects adapter and persistence module; keep the hook focused on lifecycle and event adaptation.
8
+ - Compose the complete table from exported `TableHeader`, memoized `TableBody`, `RowSelectionCheckbox` and `ColumnResizeHandle` primitives.
9
+ - Separate public state, row/cell customization, body context and resize type contracts from rendering modules; preserve existing root exports.
10
+ - Replace duplicated custom-layout examples with shared primitives, document the contracts, and add controller unit tests plus checkbox Storybook coverage.
11
+ - Preserve Puppertino's separated borders, rounded stripes, native header emphasis and numeric alignment.
12
+ - Derive secondary text, focus rings and resize dividers from Puppertino theme tokens; keep selected-row colors through hover.
13
+ - Match the compact desktop header band and group the selection cell with its first data column.
14
+
3
15
  ## 0.2.0 — 2026-10-12
4
16
 
5
17
  - Add typed row/cell DOM customization callbacks and a leading-rows slot to the complete table.
package/README.md CHANGED
@@ -17,10 +17,10 @@ A generic React table styled with [Puppertino](https://puppertino.com/) and powe
17
17
  The package includes built ESM and TypeScript declarations. Install it from npm:
18
18
 
19
19
  ```sh
20
- pnpm add puppertino-table@0.2.0 @tanstack/react-table@9.2.8 @codedgar/puppertino@2 react react-dom
20
+ pnpm add puppertino-table@0.3.0 @tanstack/react-table@9.2.8 @codedgar/puppertino@2 react@19.3.0 react-dom@19.3.0
21
21
  ```
22
22
 
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.
23
+ React 19.3.0 or a compatible newer React 19 release is required (`^19.3.0`). React 18 and React 20 are outside the supported range. 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.
24
24
 
25
25
  ## Use
26
26
 
@@ -104,48 +104,56 @@ Callbacks receive typed TanStack row/cell objects, including `original`, IDs and
104
104
 
105
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
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`.
107
+ Use **`usePuppertinoTable` and the primitives** when you own the table wrapper, selection controls or header layout. `TableHeader` and memoized `TableBody` are the same components used by `PuppertinoTable`. They preserve grouped-header semantics, sort accessibility, resize controls, row customization and selection. 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
108
 
109
109
  ```tsx
110
110
  import { useState } from 'react';
111
111
  import type { SortingState } from '@tanstack/react-table';
112
- import { usePuppertinoTable, ColumnResizeHandle } from 'puppertino-table';
112
+ import { usePuppertinoTable, TableHeader, TableBody } from 'puppertino-table';
113
113
 
114
114
  function CustomItems() {
115
115
  const [sorting, setSorting] = useState<SortingState>([]);
116
+ const storageKey = 'my-app.custom-items.columns.v1';
116
117
  const table = usePuppertinoTable({
117
118
  data: items, columns, getRowId: item => item.id,
118
- sorting, onSortingChange: setSorting, manualSorting: false, selectable: true,
119
- storageKey: 'my-app.custom-items.columns.v1',
119
+ sorting, onSortingChange: setSorting, manualSorting: false, selectable: true, storageKey,
120
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>;
121
+ return <div className="pt-scroll" tabIndex={0} role="region" aria-label="Items scroll area">
122
+ <table.Subscribe source={table.atoms.columnSizing}>{() => (
123
+ <table className="p-table pt-table" aria-label="Items" style={{ width: table.getTotalSize() + 36 }}>
124
+ <colgroup><col style={{ width: 36 }} />{table.getAllLeafColumns().map(column => <col key={column.id} style={{ width: column.getSize() }} />)}</colgroup>
125
+ <TableHeader table={table} selectable resizeOptions={{ storageKey }} />
126
+ <TableBody table={table} selectable getRowLabel={item => item.name}
127
+ leadingRows={(_table, context) => <tr><td colSpan={context.columnCount}>Folder</td></tr>} />
128
+ </table>
129
+ )}</table.Subscribe>
130
+ </div>;
143
131
  }
144
132
  ```
145
133
 
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.
134
+ Pass matching `selectable` values to the hook, header and body; include a 36-pixel selection `<col>` when it is enabled. Pass `disabled` to all three to disable selection, sorting controls and resizing consistently. Keep the geometry subscription around the table; `TableBody` is already memoized separately. It supplies the empty-state row and accepts the same `getRowProps`, `getCellProps`, `leadingRows` and `getRowLabel` options as the complete component.
147
135
 
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.
136
+ For a fully custom header or body, use `ColumnResizeHandle` and `RowSelectionCheckbox` individually. The checkbox handles Shift ranges, keyboard toggling and event isolation from row clicks. Give it `row`, an optional row-description `label`, and `disabled` when appropriate. Keep your `<thead>`/`<tbody>` and `<th>`/`<td>` markup semantic and supply accessible sort state.
137
+
138
+ ### Public type contracts
139
+
140
+ All existing root imports remain supported. Rendering modules consume dedicated type contracts: table state options, body/customization types and resize options are defined separately from component implementations. Import these contracts with `import type`:
141
+
142
+ ```tsx
143
+ import type {
144
+ PuppertinoTableOptions, PuppertinoTableProps,
145
+ PuppertinoRow, PuppertinoCell, PuppertinoBodyContext,
146
+ PuppertinoRowProps, PuppertinoCellProps,
147
+ TableHeaderProps, TableBodyProps, RowSelectionCheckboxProps,
148
+ ColumnResizeOptions, ColumnResizeHandleProps,
149
+ } from 'puppertino-table';
150
+ ```
151
+
152
+ The controller's gesture engine, browser effects and persistence are internal modules. `useColumnResize` manages React lifecycle and adapts events; it continues to provide the existing `handle(columnId)` API.
153
+
154
+ The hook initializes sizing but does not install resize listeners; `TableHeader` installs them through its shared resize handles. `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.
155
+
156
+ For more specialized state wiring, the existing `puppertinoTableFeatures`, `PuppertinoTableInstance`, sizing helpers and TanStack `useTable` remain available. In custom layouts, use `TableBody` or memoize your custom row body separately from the `columnSizing` subscription to avoid rerendering cells during drags.
149
157
 
150
158
  ### Server-paginated complete table
151
159
 
@@ -192,6 +200,8 @@ Keep `loadPage` stable (for example, a module-level function or `useCallback`).
192
200
 
193
201
  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.
194
202
 
203
+ Puppertino owns separated borders, rounded row stripes, numeric alignment and active-header weight. The package adds scroll/sticky geometry, sort-button and resize-target behavior, plus contrast-safe text. Override `--pt-table-secondary` for secondary/header text and `--pt-table-text` for data-cell text using your application's Puppertino theme tokens. Selection reads `--p-list-selection` and `--p-list-selection-text` in every hover state.
204
+
195
205
  ## Storybook and demo
196
206
 
197
207
  GitHub Pages is configured to publish [Storybook 10](https://kahwee.github.io/puppertino-table/) from `storybook-static/` after the checks on main pass. The Pages artifact contains only Storybook and its examples. Pull requests run verification without deploying. For repository setup, select **Settings → Pages → Source → GitHub Actions**; the workflow also attempts to enable Pages automatically.
@@ -205,7 +215,7 @@ pnpm dev # standalone 267-row fictional-data demo
205
215
  pnpm build:storybook # static Storybook in storybook-static/
206
216
  ```
207
217
 
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.
218
+ 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, a shared-primitive hook layout, and standalone checkbox selection. Story controls expose selection, resizing, disabled state and sorting ownership.
209
219
 
210
220
  ## Verification
211
221
 
@@ -213,7 +223,7 @@ Stories cover local sorting, host-owned order, range selection, keyboard resizin
213
223
  pnpm check
214
224
  ```
215
225
 
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`.
226
+ Checks include TypeScript, independent controller ownership/cancellation/persistence tests, 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 eighteen Storybook stories, and tarball installation with typed imports, server rendering and CSS exports in fresh React 19.3.0 consumers. Set `PLAYWRIGHT_CHROMIUM_EXECUTABLE` to a system Chromium path, or install a browser with `pnpm exec playwright install chromium`.
217
227
 
218
228
  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.
219
229
 
@@ -223,10 +233,10 @@ The unscoped **`puppertino-table`** package is public on npm. Stable releases us
223
233
 
224
234
  ```sh
225
235
  pnpm check
226
- npm publish test-results/package/puppertino-table-0.2.0.tgz --dry-run --ignore-scripts --access public
236
+ npm publish test-results/package/puppertino-table-0.3.0.tgz --dry-run --ignore-scripts --access public
227
237
  ```
228
238
 
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.
239
+ `pnpm check` builds and packs the library, checks the tarball file list, and installs it in isolated React 19.3.0 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.
230
240
 
231
241
  The manually dispatched **Prepare npm package** GitHub Actions workflow performs these checks and uploads `npm-release-candidate`, containing the verified tarball and `pack.json`. It never publishes to npm. See [RELEASING.md](https://github.com/kahwee/puppertino-table/blob/main/RELEASING.md) for the final publishing steps.
232
242
 
package/dist/index.d.ts CHANGED
@@ -1,6 +1,12 @@
1
- export { PuppertinoTable, type PuppertinoTableProps, type PuppertinoRow, type PuppertinoCell, type PuppertinoRowProps, type PuppertinoCellProps, type PuppertinoDataAttributes, type PuppertinoBodyContext } from './table.js';
1
+ export { PuppertinoTable } from './table.js';
2
+ export type { PuppertinoTableProps, PuppertinoRow, PuppertinoCell, PuppertinoRowProps, PuppertinoCellProps, PuppertinoDataAttributes, PuppertinoBodyContext, PuppertinoBodyOptions, TableHeaderProps, TableBodyProps, RowSelectionCheckboxProps } from './table-types.js';
3
+ export { TableHeader } from './table-header.js';
4
+ export { TableBody } from './table-body.js';
5
+ export { RowSelectionCheckbox } from './row-selection-checkbox.js';
2
6
  export { createPuppertinoColumnHelper, puppertinoTableFeatures, type PuppertinoTableInstance, type PuppertinoColumnDef } from './model.js';
3
- export { useColumnResize, type ColumnResizeOptions } from './use-column-resize.js';
4
- export { ColumnResizeHandle, type ColumnResizeHandleProps } from './resize-handle.js';
7
+ export { useColumnResize } from './use-column-resize.js';
8
+ export { ColumnResizeHandle } from './resize-handle.js';
9
+ export type { ColumnResizeOptions, ColumnResizeHandleProps } from './resize-types.js';
5
10
  export { readColumnSizing, columnSizeBounds, clampSize, type ColumnSizeBounds } from './sizing.js';
6
- export { usePuppertinoTable, type PuppertinoTableOptions } from './use-puppertino-table.js';
11
+ export { usePuppertinoTable } from './use-puppertino-table.js';
12
+ export type { PuppertinoTableOptions } from './table-options.js';
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  export { PuppertinoTable } from './table.js';
2
+ export { TableHeader } from './table-header.js';
3
+ export { TableBody } from './table-body.js';
4
+ export { RowSelectionCheckbox } from './row-selection-checkbox.js';
2
5
  export { createPuppertinoColumnHelper, puppertinoTableFeatures } from './model.js';
3
6
  export { useColumnResize } from './use-column-resize.js';
4
7
  export { ColumnResizeHandle } from './resize-handle.js';
@@ -0,0 +1,35 @@
1
+ import { type ColumnSizeBounds } from './sizing.js';
2
+ import type { ColumnResizeOptions } from './resize-types.js';
3
+ export type ResizeWidths = Record<string, number>;
4
+ export interface ResizeAdapter {
5
+ snapshot(): ResizeWidths;
6
+ bounds(id: string): ColumnSizeBounds;
7
+ apply(widths: ResizeWidths): void;
8
+ reset(id: string): void;
9
+ }
10
+ export interface ResizeMember {
11
+ adapter: ResizeAdapter;
12
+ options: ColumnResizeOptions;
13
+ }
14
+ export interface ResizeTarget {
15
+ focus(): void;
16
+ setPointerCapture(pointer: number): void;
17
+ hasPointerCapture(pointer: number): boolean;
18
+ releasePointerCapture(pointer: number): void;
19
+ }
20
+ export interface ResizeEnvironment {
21
+ listenCancel(cancel: () => boolean): () => void;
22
+ isCompact(query?: string): boolean;
23
+ startGesture(target: ResizeTarget, pointer: number, className: string, query: string | undefined, cancel: () => void): () => void;
24
+ }
25
+ /** Gesture ownership and width transitions. No React or browser globals. */
26
+ export declare function createResizeController(environment: ResizeEnvironment, persist: (member: ResizeMember) => void): {
27
+ cancelMember: (member: ResizeMember) => void;
28
+ retain(member: ResizeMember): () => void;
29
+ begin(member: ResizeMember, id: string, x: number, pointer: number, target: ResizeTarget, button: number): boolean;
30
+ move(member: ResizeMember, pointer: number, x: number): void;
31
+ end(member: ResizeMember, pointer: number): void;
32
+ cancel(member: ResizeMember, pointer: number): void;
33
+ reset(member: ResizeMember, id: string): boolean;
34
+ key(member: ResizeMember, id: string, key: string, shift?: boolean): boolean;
35
+ };
@@ -0,0 +1,94 @@
1
+ import { clampSize } from './sizing.js';
2
+ /** Gesture ownership and width transitions. No React or browser globals. */
3
+ export function createResizeController(environment, persist) {
4
+ const members = new Set();
5
+ let stopListening;
6
+ let drag = null;
7
+ const finish = (cancel) => {
8
+ const active = drag;
9
+ if (!active)
10
+ return false;
11
+ drag = null;
12
+ active.cleanup();
13
+ active.member.adapter.apply(cancel ? active.before : active.member.adapter.snapshot());
14
+ if (!cancel)
15
+ persist(active.member);
16
+ return true;
17
+ };
18
+ const owns = (member, pointer) => drag?.member === member && drag.pointer === pointer;
19
+ const cancelMember = (member) => { if (drag?.member === member)
20
+ finish(true); };
21
+ return {
22
+ cancelMember,
23
+ retain(member) {
24
+ if (!members.size)
25
+ stopListening = environment.listenCancel(() => finish(true));
26
+ members.add(member);
27
+ let released = false;
28
+ return () => {
29
+ if (released)
30
+ return;
31
+ released = true;
32
+ cancelMember(member);
33
+ members.delete(member);
34
+ if (!members.size) {
35
+ stopListening?.();
36
+ stopListening = undefined;
37
+ }
38
+ };
39
+ },
40
+ begin(member, id, x, pointer, target, button) {
41
+ if (button !== 0 || member.options.enabled === false || drag || !members.has(member) || environment.isCompact(member.options.cancelMediaQuery))
42
+ return false;
43
+ const before = member.adapter.snapshot();
44
+ if (!Number.isFinite(before[id]))
45
+ return false;
46
+ const active = { member, id, x, width: before[id], before, pointer, query: member.options.cancelMediaQuery, cleanup: () => { } };
47
+ drag = active;
48
+ try {
49
+ active.cleanup = environment.startGesture(target, pointer, member.options.dragClassName ?? 'pt-resizing', active.query, () => finish(true));
50
+ }
51
+ catch {
52
+ finish(true);
53
+ return false;
54
+ }
55
+ return true;
56
+ },
57
+ move(member, pointer, x) {
58
+ if (!owns(member, pointer) || !drag)
59
+ return;
60
+ member.adapter.apply({ ...member.adapter.snapshot(), [drag.id]: clampSize(drag.width + x - drag.x, member.adapter.bounds(drag.id)) });
61
+ },
62
+ end(member, pointer) {
63
+ if (owns(member, pointer))
64
+ finish(member.options.enabled === false || environment.isCompact(drag?.query));
65
+ },
66
+ cancel(member, pointer) { if (owns(member, pointer))
67
+ finish(true); },
68
+ reset(member, id) {
69
+ if (!members.has(member) || member.options.enabled === false || drag)
70
+ return false;
71
+ member.adapter.reset(id);
72
+ persist(member);
73
+ return true;
74
+ },
75
+ key(member, id, key, shift = false) {
76
+ if (!members.has(member) || member.options.enabled === false || drag)
77
+ return false;
78
+ if (key === 'Enter') {
79
+ member.adapter.reset(id);
80
+ persist(member);
81
+ return true;
82
+ }
83
+ const widths = member.adapter.snapshot();
84
+ const bounds = member.adapter.bounds(id);
85
+ const step = shift ? 40 : 16;
86
+ const next = key === 'ArrowLeft' ? widths[id] - step : key === 'ArrowRight' ? widths[id] + step : key === 'Home' ? bounds.minSize : key === 'End' ? bounds.maxSize : undefined;
87
+ if (next === undefined)
88
+ return false;
89
+ member.adapter.apply({ ...widths, [id]: clampSize(next, bounds) });
90
+ persist(member);
91
+ return true;
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,3 @@
1
+ import type { ResizeEnvironment } from './resize-controller.js';
2
+ /** Browser effects are installed only when a mounted hook retains a controller. */
3
+ export declare const resizeEnvironment: ResizeEnvironment;
@@ -0,0 +1,40 @@
1
+ const activeClasses = new Map();
2
+ function markDrag(name, delta) {
3
+ const count = Math.max(0, (activeClasses.get(name) || 0) + delta);
4
+ if (count)
5
+ activeClasses.set(name, count);
6
+ else
7
+ activeClasses.delete(name);
8
+ document.documentElement.classList.toggle(name, count > 0);
9
+ }
10
+ /** Browser effects are installed only when a mounted hook retains a controller. */
11
+ export const resizeEnvironment = {
12
+ listenCancel(cancel) {
13
+ const escape = (event) => { if (event.key === 'Escape' && cancel())
14
+ event.preventDefault(); };
15
+ const blur = () => { cancel(); };
16
+ window.addEventListener('keydown', escape);
17
+ window.addEventListener('blur', blur);
18
+ return () => { window.removeEventListener('keydown', escape); window.removeEventListener('blur', blur); };
19
+ },
20
+ isCompact: query => !!(query && window.matchMedia(query).matches),
21
+ startGesture(target, pointer, className, query, cancel) {
22
+ target.focus();
23
+ target.setPointerCapture(pointer);
24
+ markDrag(className, 1);
25
+ const compact = query ? window.matchMedia(query) : undefined;
26
+ const change = () => { if (compact?.matches)
27
+ cancel(); };
28
+ compact?.addEventListener('change', change);
29
+ let released = false;
30
+ return () => {
31
+ if (released)
32
+ return;
33
+ released = true;
34
+ compact?.removeEventListener('change', change);
35
+ markDrag(className, -1);
36
+ if (target.hasPointerCapture(pointer))
37
+ target.releasePointerCapture(pointer);
38
+ };
39
+ },
40
+ };
@@ -1,13 +1,4 @@
1
1
  import type { RowData } from '@tanstack/react-table';
2
- import type { ColumnResizeOptions } from './use-column-resize.js';
3
- import type { PuppertinoTableInstance } from './model.js';
4
- import type { Header } from '@tanstack/react-table';
5
- import { puppertinoTableFeatures } from './model.js';
6
- export interface ColumnResizeHandleProps<T extends RowData> {
7
- table: PuppertinoTableInstance<T>;
8
- header: Header<typeof puppertinoTableFeatures, T>;
9
- /** Defaults to a string header or the stable column ID. */
10
- label?: string;
11
- options?: ColumnResizeOptions;
12
- }
2
+ import type { ColumnResizeHandleProps } from './resize-types.js';
3
+ export type { ColumnResizeHandleProps } from './resize-types.js';
13
4
  export declare function ColumnResizeHandle<T extends RowData>({ table, header, label, options }: ColumnResizeHandleProps<T>): import("react").JSX.Element | null;
@@ -0,0 +1,3 @@
1
+ import type { ResizeMember } from './resize-controller.js';
2
+ /** Persist only completed operations; storage failure never prevents resizing. */
3
+ export declare function persistResize({ adapter, options }: ResizeMember): void;
@@ -0,0 +1,8 @@
1
+ /** Persist only completed operations; storage failure never prevents resizing. */
2
+ export function persistResize({ adapter, options }) {
3
+ try {
4
+ if (options.storageKey)
5
+ (options.storage || localStorage).setItem(options.storageKey, JSON.stringify(adapter.snapshot()));
6
+ }
7
+ catch { /* Storage is optional. */ }
8
+ }
@@ -0,0 +1,16 @@
1
+ import type { RowData, Header } from '@tanstack/react-table';
2
+ import type { PuppertinoTableInstance, puppertinoTableFeatures } from './model.js';
3
+ export interface ColumnResizeOptions {
4
+ enabled?: boolean;
5
+ storageKey?: string;
6
+ storage?: Pick<Storage, 'setItem'>;
7
+ cancelMediaQuery?: string;
8
+ dragClassName?: string;
9
+ }
10
+ export interface ColumnResizeHandleProps<T extends RowData> {
11
+ table: PuppertinoTableInstance<T>;
12
+ header: Header<typeof puppertinoTableFeatures, T>;
13
+ /** Defaults to a string header or the stable column ID. */
14
+ label?: string;
15
+ options?: ColumnResizeOptions;
16
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import type { RowData } from '@tanstack/react-table';
2
+ import type { RowSelectionCheckboxProps } from './table-types.js';
3
+ export type { RowSelectionCheckboxProps } from './table-types.js';
4
+ /** Shared click/keyboard semantics, including Shift selection and row-event isolation. */
5
+ export declare function RowSelectionCheckbox<T extends RowData>({ row, label, disabled }: RowSelectionCheckboxProps<T>): import("react").JSX.Element;
@@ -0,0 +1,6 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ /** Shared click/keyboard semantics, including Shift selection and row-event isolation. */
4
+ export function RowSelectionCheckbox({ row, label, disabled = false }) {
5
+ return _jsx("input", { type: "checkbox", "aria-label": `Select ${label ?? row.id}`, checked: row.getIsSelected(), disabled: disabled || !row.getCanSelect(), onClick: event => { event.stopPropagation(); row.getToggleSelectedHandler()(event); }, onChange: () => { } });
6
+ }
package/dist/style.css CHANGED
@@ -1,26 +1,32 @@
1
1
  /* Scoped enhancements; import Puppertino's table/color/control styles separately. */
2
2
  .pt-scroll { max-width: 100%; min-width: 0; overflow: auto; }
3
- .pt-scroll:focus-visible { outline: 2px solid var(--p-control-accent, #0069d9); outline-offset: 2px; }
4
- .pt-table { table-layout: fixed; padding: 0; border-collapse: collapse; }
5
- .pt-table thead { position: sticky; top: 0; z-index: 1; background: var(--p-window-bg, #fff); }
3
+ .pt-scroll:focus-visible { outline: 2px solid var(--p-control-accent); outline-offset: 2px; }
4
+ /* Puppertino owns separated borders, rounded stripes, font and cell spacing.
5
+ Keep secondary text contrast-safe while deriving it from the active theme. */
6
+ .pt-table { --p-list-text-dim: var(--pt-table-secondary, color-mix(in srgb, var(--p-list-text) 85%, var(--p-window-bg, var(--p-bg-primary)))); table-layout: fixed; padding: 0; }
7
+ .pt-table thead { position: sticky; top: 0; z-index: 1; background: var(--p-window-bg, var(--p-bg-primary)); }
6
8
  .pt-table th { position: relative; }
7
9
  .pt-table th[aria-sort]::before { display: none; }
8
10
  .pt-table :is(th, td) { box-sizing: border-box; overflow: hidden; text-overflow: ellipsis; overflow-wrap: anywhere; }
9
- .pt-sort { display: flex; align-items: center; justify-content: space-between; width: 100%; gap: 4px; min-height: 28px; border: 0; background: none; padding: 0 10px 0 0; font: inherit; color: inherit; text-align: left; cursor: pointer; }
10
- .pt-sort:focus-visible { outline: 2px solid var(--p-control-accent, #0069d9); outline-offset: -2px; border-radius: 3px; }
11
- .pt-sort-arrow { flex: 0 0 10px; }
11
+ .pt-sort { display: flex; align-items: center; justify-content: space-between; width: 100%; gap: 4px; min-height: 19px; border: 0; background: none; padding: 0 10px 0 0; font: inherit; color: inherit; text-align: inherit; cursor: pointer; }
12
+ .pt-sort:focus-visible { outline: 2px solid var(--p-control-accent); outline-offset: -2px; border-radius: 3px; }
13
+ .pt-sort-arrow { flex: 0 0 9px; color: var(--p-list-text-dim); font-size: 9px; font-weight: 510; }
14
+ .p-table-num .pt-sort { justify-content: flex-end; }
12
15
  .pt-sort-arrow[data-direction=asc] { transform: rotate(180deg); }
13
16
  .pt-sort-arrow[data-direction=none] { visibility: hidden; }
14
17
  .pt-resizer { position: absolute; right: -5px; top: 0; bottom: 0; width: 12px; cursor: col-resize; touch-action: none; z-index: 2; }
15
- .pt-resizer::after { content: ''; position: absolute; left: 5px; top: 6px; bottom: 6px; width: 1px; background: var(--p-text-secondary, #666); opacity: .3; }
16
- .pt-resizer:hover::after, .pt-resizer:focus-visible::after { width: 2px; background: var(--p-control-accent, #0069d9); opacity: 1; }
17
- .pt-resizer:focus-visible { outline: 2px solid var(--p-control-accent, #0069d9); outline-offset: -2px; }
18
+ .pt-resizer::after { content: ''; position: absolute; left: 5px; top: 4px; bottom: 8px; width: 1px; background: var(--p-table-divider); }
19
+ .pt-resizer:hover::after, .pt-resizer:focus-visible::after { width: 2px; background: var(--p-control-accent); opacity: 1; }
20
+ .pt-resizer:focus-visible { outline: 2px solid var(--p-control-accent); outline-offset: -2px; }
18
21
  .pt-resizing, .pt-resizing * { cursor: col-resize !important; user-select: none !important; }
19
22
  .pt-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
20
- /* Primary text contrast applies to every data column, including after selection. */
21
- .pt-table :is(th, td) { color: var(--pt-table-text, var(--p-list-text, #252525)); }
22
- .pt-table tr.p-selected td { background: var(--p-list-selection, #0069d9); color: var(--p-list-selection-text, #fff); }
23
- @media (forced-colors: active) { .pt-table tr.p-selected td { background: Highlight; color: HighlightText; } }
23
+ /* Keep data text readable; native header colors indicate the active sort.
24
+ Match the hover selector so selected rows retain their Puppertino tint. */
25
+ .pt-table td { color: var(--pt-table-text, var(--p-list-text)); }
26
+ .pt-table td.pt-selection + td { color: var(--p-list-text); }
27
+ .pt-table th.pt-selection + th::after { content: none; }
28
+ .pt-table tbody tr.p-selected td { background: var(--p-list-selection); color: var(--p-list-selection-text); }
29
+ @media (forced-colors: active) { .pt-table tbody tr.p-selected td { background: Highlight; color: HighlightText; } }
24
30
 
25
31
  .pt-table .pt-selection { padding: 0; text-align: center; overflow: visible; }
26
32
  .pt-table .pt-selection input { margin: 0; vertical-align: middle; }
@@ -0,0 +1,6 @@
1
+ import type { RowData } from '@tanstack/react-table';
2
+ import type { TableBodyProps } from './table-types.js';
3
+ export type { TableBodyProps } from './table-types.js';
4
+ declare function RenderTableBody<T extends RowData>({ table, selectable, disabled, getRowLabel, emptyContent, getRowProps, getCellProps, leadingRows }: TableBodyProps<T>): import("react").JSX.Element;
5
+ /** Geometry updates do not rerender cells; data and table state changes still do. */
6
+ export declare const TableBody: typeof RenderTableBody;
@@ -0,0 +1,19 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { createElement as _createElement } from "react";
4
+ import { memo } from 'react';
5
+ import { RowSelectionCheckbox } from './row-selection-checkbox.js';
6
+ function RenderTableBody({ table, selectable = false, disabled = false, getRowLabel, emptyContent = 'No rows', getRowProps, getCellProps, leadingRows }) {
7
+ const rows = table.getRowModel().rows;
8
+ const context = { table, selectable, disabled, columnCount: table.getAllLeafColumns().length + (selectable ? 1 : 0) };
9
+ return _jsxs("tbody", { children: [typeof leadingRows === 'function' ? leadingRows(table, context) : leadingRows, rows.length ? rows.map(row => {
10
+ const props = getRowProps?.(row, context);
11
+ const className = [row.getIsSelected() ? 'p-selected' : '', props?.className].filter(Boolean).join(' ') || undefined;
12
+ return _createElement("tr", { ...props, key: row.id, className: className },
13
+ selectable && _jsx("td", { className: "pt-selection", children: _jsx(RowSelectionCheckbox, { row: row, label: getRowLabel?.(row.original), disabled: disabled }) }),
14
+ row.getAllCells().map(cell => _createElement("td", { ...getCellProps?.(cell, context), key: cell.id },
15
+ _jsx(table.FlexRender, { cell: cell }))));
16
+ }) : _jsx("tr", { children: _jsx("td", { colSpan: context.columnCount, children: emptyContent }) })] });
17
+ }
18
+ /** Geometry updates do not rerender cells; data and table state changes still do. */
19
+ export const TableBody = memo(RenderTableBody);
@@ -0,0 +1,4 @@
1
+ import type { RowData } from '@tanstack/react-table';
2
+ import type { TableHeaderProps } from './table-types.js';
3
+ export type { TableHeaderProps } from './table-types.js';
4
+ export declare function TableHeader<T extends RowData>({ table, selectable, disabled, resizable, resizeOptions }: TableHeaderProps<T>): import("react").JSX.Element;
@@ -0,0 +1,13 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ColumnResizeHandle } from './resize-handle.js';
4
+ export function TableHeader({ table, selectable = false, disabled = false, resizable = true, resizeOptions }) {
5
+ const groups = table.getHeaderGroups();
6
+ const options = { ...resizeOptions, enabled: resizable && !disabled && resizeOptions?.enabled !== false };
7
+ return _jsx("thead", { children: groups.map((group, index) => _jsxs("tr", { children: [selectable && index === 0 && _jsx("th", { className: "pt-selection", rowSpan: groups.length, scope: "col", children: _jsx("span", { className: "pt-sr-only", children: "Select rows" }) }), group.headers.map(header => {
8
+ const column = header.column;
9
+ const label = typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
10
+ const sorted = column.getIsSorted();
11
+ return _jsxs("th", { colSpan: header.colSpan, scope: column.columns.length === 0 ? '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 })), options.enabled && _jsx(ColumnResizeHandle, { table: table, header: header, label: label, options: options })] }, header.id);
12
+ })] }, group.id)) });
13
+ }
@@ -0,0 +1,22 @@
1
+ import type { RowData, SortingState, RowSelectionState, OnChangeFn } from '@tanstack/react-table';
2
+ import type { PuppertinoColumnDef } from './model.js';
3
+ /** Shared state contract for complete tables and custom layouts. */
4
+ export interface PuppertinoTableOptions<T extends RowData> {
5
+ data: readonly T[];
6
+ columns: readonly PuppertinoColumnDef<T>[];
7
+ getRowId: (row: T, index: number) => string;
8
+ /** Omit for local state. Controlled state without a callback is read-only. */
9
+ sorting?: SortingState;
10
+ onSortingChange?: OnChangeFn<SortingState>;
11
+ /** Initial values apply only on mount to uncontrolled state. */
12
+ initialSorting?: SortingState;
13
+ initialRowSelection?: RowSelectionState;
14
+ /** Preserve host-owned order by default, including server-paginated data. */
15
+ manualSorting?: boolean;
16
+ rowSelection?: RowSelectionState;
17
+ onRowSelectionChange?: OnChangeFn<RowSelectionState>;
18
+ selectable?: boolean;
19
+ disabled?: boolean;
20
+ storageKey?: string;
21
+ storage?: Pick<Storage, 'getItem' | 'setItem'>;
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ import type { RowData, Row, Cell } from '@tanstack/react-table';
2
+ import type { ReactNode, ComponentPropsWithoutRef } from 'react';
3
+ import type { puppertinoTableFeatures, PuppertinoTableInstance } from './model.js';
4
+ import type { PuppertinoTableOptions } from './table-options.js';
5
+ import type { ColumnResizeOptions } from './resize-types.js';
6
+ export type PuppertinoRow<T extends RowData> = Row<typeof puppertinoTableFeatures, T>;
7
+ export type PuppertinoCell<T extends RowData> = Cell<typeof puppertinoTableFeatures, T>;
8
+ export type PuppertinoDataAttributes = {
9
+ [key: `data-${string}`]: string | number | boolean | null | undefined;
10
+ };
11
+ export interface PuppertinoBodyContext<T extends RowData> {
12
+ table: PuppertinoTableInstance<T>;
13
+ /** Leaf columns plus the optional selection column. */
14
+ columnCount: number;
15
+ selectable: boolean;
16
+ disabled: boolean;
17
+ }
18
+ export type PuppertinoRowProps = PuppertinoDataAttributes & Omit<ComponentPropsWithoutRef<'tr'>, 'children' | 'dangerouslySetInnerHTML'>;
19
+ export type PuppertinoCellProps = PuppertinoDataAttributes & Omit<ComponentPropsWithoutRef<'td'>, 'children' | 'dangerouslySetInnerHTML'>;
20
+ export interface PuppertinoBodyOptions<T extends RowData> {
21
+ selectable?: boolean;
22
+ disabled?: boolean;
23
+ getRowLabel?: (row: T) => string;
24
+ emptyContent?: ReactNode;
25
+ getRowProps?: (row: PuppertinoRow<T>, context: PuppertinoBodyContext<T>) => PuppertinoRowProps | undefined;
26
+ getCellProps?: (cell: PuppertinoCell<T>, context: PuppertinoBodyContext<T>) => PuppertinoCellProps | undefined;
27
+ /** Semantic <tr> elements before data rows, including when data is empty. */
28
+ leadingRows?: ReactNode | ((table: PuppertinoTableInstance<T>, context: PuppertinoBodyContext<T>) => ReactNode);
29
+ }
30
+ export interface PuppertinoTableProps<T extends RowData> extends PuppertinoTableOptions<T>, PuppertinoBodyOptions<T> {
31
+ resizable?: boolean;
32
+ cancelMediaQuery?: string;
33
+ ariaLabel: string;
34
+ caption?: ReactNode;
35
+ className?: string;
36
+ }
37
+ export interface TableHeaderProps<T extends RowData> {
38
+ table: PuppertinoTableInstance<T>;
39
+ selectable?: boolean;
40
+ disabled?: boolean;
41
+ resizable?: boolean;
42
+ resizeOptions?: ColumnResizeOptions;
43
+ }
44
+ export interface TableBodyProps<T extends RowData> extends PuppertinoBodyOptions<T> {
45
+ table: PuppertinoTableInstance<T>;
46
+ }
47
+ export interface RowSelectionCheckboxProps<T extends RowData> {
48
+ row: PuppertinoRow<T>;
49
+ /** Row description without the "Select" prefix. Defaults to the stable ID. */
50
+ label?: string;
51
+ disabled?: boolean;
52
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/table.d.ts CHANGED
@@ -1,32 +1,4 @@
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> {
20
- getRowLabel?: (row: T) => string;
21
- resizable?: boolean;
22
- cancelMediaQuery?: string;
23
- ariaLabel: string;
24
- caption?: ReactNode;
25
- emptyContent?: ReactNode;
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);
31
- }
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;
1
+ import type { RowData } from '@tanstack/react-table';
2
+ import type { PuppertinoTableProps } from './table-types.js';
3
+ export type { PuppertinoTableProps, PuppertinoRow, PuppertinoCell, PuppertinoRowProps, PuppertinoCellProps, PuppertinoDataAttributes, PuppertinoBodyContext } from './table-types.js';
4
+ export declare function PuppertinoTable<T extends RowData>({ data, columns, getRowId, sorting, onSortingChange, initialSorting, initialRowSelection, manualSorting, rowSelection, 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,34 +1,10 @@
1
1
  "use client";
2
- import { createElement as _createElement } from "react";
3
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
- import { memo } from 'react';
5
3
  import { usePuppertinoTable } from './use-puppertino-table.js';
6
- import { useColumnResize } from './use-column-resize.js';
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 }) {
4
+ import { TableHeader } from './table-header.js';
5
+ import { TableBody } from './table-body.js';
6
+ export function PuppertinoTable({ data, columns, getRowId, sorting, onSortingChange, initialSorting, initialRowSelection, manualSorting = true, rowSelection, onRowSelectionChange, getRowLabel, selectable = false, disabled = false, resizable = true, storageKey, storage, cancelMediaQuery, ariaLabel, caption, emptyContent = 'No rows', className = '', getRowProps, getCellProps, leadingRows }) {
8
7
  const table = usePuppertinoTable({ data, columns, getRowId, sorting, onSortingChange, initialSorting, initialRowSelection, manualSorting,
9
- rowSelection: controlledSelection, onRowSelectionChange, selectable, disabled, storageKey, storage });
10
- const options = { enabled: resizable && !disabled, storageKey, storage, cancelMediaQuery };
11
- const { handle } = useColumnResize(table, options);
12
- const groups = table.getHeaderGroups();
13
- return _jsx("div", { className: `pt-scroll ${className}`, tabIndex: 0, role: "region", "aria-label": `${ariaLabel} scroll area`, children: _jsx(table.Subscribe, { source: table.atoms.columnSizing, children: () => _jsxs("table", { className: "p-table pt-table", "aria-label": ariaLabel, style: { width: table.getTotalSize() + (selectable ? 36 : 0) }, children: [caption && _jsx("caption", { children: caption }), _jsxs("colgroup", { children: [selectable && _jsx("col", { style: { width: 36 } }), table.getAllLeafColumns().map(column => _jsx("col", { style: { width: column.getSize() } }, column.id))] }), _jsx("thead", { children: groups.map((group, index) => _jsxs("tr", { children: [selectable && index === 0 && _jsx("th", { className: "pt-selection", rowSpan: groups.length, scope: "col", children: _jsx("span", { className: "pt-sr-only", children: "Select rows" }) }), group.headers.map(header => {
14
- const column = header.column;
15
- const label = typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
16
- const sorted = column.getIsSorted();
17
- const leaf = column.columns.length === 0;
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);
19
- })] }, group.id)) }), _jsx(MemoTableBody, { table: table, selectable: selectable, disabled: disabled, getRowLabel: getRowLabel, emptyContent: emptyContent, getRowProps: getRowProps, getCellProps: getCellProps, leadingRows: leadingRows })] }) }) });
8
+ rowSelection, onRowSelectionChange, selectable, disabled, storageKey, storage });
9
+ return _jsx("div", { className: `pt-scroll ${className}`, tabIndex: 0, role: "region", "aria-label": `${ariaLabel} scroll area`, children: _jsx(table.Subscribe, { source: table.atoms.columnSizing, children: () => _jsxs("table", { className: "p-table pt-table", "aria-label": ariaLabel, style: { width: table.getTotalSize() + (selectable ? 36 : 0) }, children: [caption && _jsx("caption", { children: caption }), _jsxs("colgroup", { children: [selectable && _jsx("col", { style: { width: 36 } }), table.getAllLeafColumns().map(column => _jsx("col", { style: { width: column.getSize() } }, column.id))] }), _jsx(TableHeader, { table: table, selectable: selectable, disabled: disabled, resizable: resizable, resizeOptions: { storageKey, storage, cancelMediaQuery } }), _jsx(TableBody, { table: table, selectable: selectable, disabled: disabled, getRowLabel: getRowLabel, emptyContent: emptyContent, getRowProps: getRowProps, getCellProps: getCellProps, leadingRows: leadingRows })] }) }) });
20
10
  }
21
- /** Geometry updates do not rerender cells. State and data changes still do. */
22
- function TableBody({ table, selectable, disabled, getRowLabel, emptyContent, getRowProps, getCellProps, leadingRows }) {
23
- const rows = table.getRowModel().rows;
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 }) })] });
33
- }
34
- const MemoTableBody = memo(TableBody);
@@ -1,13 +1,8 @@
1
1
  import type { RowData } from '@tanstack/react-table';
2
2
  import { type PointerEvent, type KeyboardEvent } from 'react';
3
3
  import type { PuppertinoTableInstance } from './model.js';
4
- export interface ColumnResizeOptions {
5
- enabled?: boolean;
6
- storageKey?: string;
7
- storage?: Pick<Storage, 'setItem'>;
8
- cancelMediaQuery?: string;
9
- dragClassName?: string;
10
- }
4
+ import type { ColumnResizeOptions } from './resize-types.js';
5
+ export type { ColumnResizeOptions } from './resize-types.js';
11
6
  export declare function useColumnResize<T extends RowData>(table: PuppertinoTableInstance<T>, options?: ColumnResizeOptions): {
12
7
  handle: (id: string) => {
13
8
  onPointerDown: (event: PointerEvent<HTMLElement>) => void;
@@ -1,149 +1,44 @@
1
1
  "use client";
2
2
  import { useEffect, useRef } from 'react';
3
- import { clampSize } from './sizing.js';
4
- const activeClasses = new Map();
5
- function markDrag(name, delta) {
6
- const count = Math.max(0, (activeClasses.get(name) || 0) + delta);
7
- if (count)
8
- activeClasses.set(name, count);
9
- else
10
- activeClasses.delete(name);
11
- document.documentElement.classList.toggle(name, count > 0);
12
- }
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) => {
19
- const column = table.getColumn(id);
20
- return { id, size: column.columnDef.size ?? 150, minSize: column.columnDef.minSize ?? 20, maxSize: column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER };
21
- };
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. */ }
28
- };
29
- const finish = (cancel = false) => {
30
- const active = drag;
31
- if (!active)
32
- return;
33
- drag = null;
34
- active.removeMedia();
35
- markDrag(active.className, -1);
36
- if (active.target.hasPointerCapture(active.pointer))
37
- active.target.releasePointerCapture(active.pointer);
38
- active.member.table.setColumnSizing(cancel ? active.before : snapshot(active.member.table));
39
- if (!cancel)
40
- save(active.member);
41
- };
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;
76
- event.preventDefault();
77
- event.stopPropagation();
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.
3
+ import { createResizeController } from './resize-controller.js';
4
+ import { resizeEnvironment } from './resize-dom.js';
5
+ import { persistResize } from './resize-persistence.js';
131
6
  const controllers = new WeakMap();
132
7
  export function useColumnResize(table, options = {}) {
133
8
  const key = table.atoms.columnSizing;
134
9
  let controller = controllers.get(key);
135
10
  if (!controller) {
136
- controller = createController();
11
+ controller = createResizeController(resizeEnvironment, persistResize);
137
12
  controllers.set(key, controller);
138
13
  }
139
- const member = useRef({ table, options });
140
- member.current.table = table;
141
- member.current.options = options;
14
+ const adapter = {
15
+ snapshot: () => Object.fromEntries(table.getAllLeafColumns().map(column => [column.id, column.getSize()])),
16
+ bounds: (id) => {
17
+ const column = table.getColumn(id);
18
+ return { id, size: column.columnDef.size ?? 150, minSize: column.columnDef.minSize ?? 20, maxSize: column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER };
19
+ },
20
+ apply: (widths) => table.setColumnSizing(widths),
21
+ reset: (id) => table.getColumn(id).resetSize(),
22
+ };
23
+ const member = useRef({ adapter, options });
24
+ // Update committed configuration before lifecycle registration or cancellation.
25
+ useEffect(() => { member.current.adapter = adapter; member.current.options = options; });
142
26
  useEffect(() => controller.retain(member.current), [controller]);
143
27
  useEffect(() => {
144
28
  if (options.enabled === false)
145
29
  controller.cancelMember(member.current);
146
30
  return () => controller.cancelMember(member.current);
147
31
  }, [controller, options.enabled, options.storageKey, options.storage, options.cancelMediaQuery, options.dragClassName]);
148
- return { handle: (id) => controller.handle(id, member.current) };
32
+ const consume = (event) => { event.preventDefault(); event.stopPropagation(); };
33
+ return { handle: (id) => ({
34
+ onPointerDown: (event) => { if (controller.begin(member.current, id, event.clientX, event.pointerId, event.currentTarget, event.button))
35
+ consume(event); },
36
+ onPointerMove: (event) => controller.move(member.current, event.pointerId, event.clientX),
37
+ onPointerUp: (event) => controller.end(member.current, event.pointerId),
38
+ onPointerCancel: (event) => controller.cancel(member.current, event.pointerId),
39
+ onLostPointerCapture: (event) => controller.cancel(member.current, event.pointerId),
40
+ onDoubleClick: () => { controller.reset(member.current, id); },
41
+ onKeyDown: (event) => { if (controller.key(member.current, id, event.key, event.shiftKey))
42
+ consume(event); },
43
+ }) };
149
44
  }
@@ -1,22 +1,6 @@
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
- }
1
+ import type { RowData } from '@tanstack/react-table';
2
+ import { type PuppertinoTableInstance } from './model.js';
3
+ import type { PuppertinoTableOptions } from './table-options.js';
4
+ export type { PuppertinoTableOptions } from './table-options.js';
21
5
  /** Shared state wiring for complete tables and custom semantic layouts. */
22
6
  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>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "puppertino-table",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -45,7 +45,7 @@
45
45
  "peerDependencies": {
46
46
  "@codedgar/puppertino": "^2.0.0",
47
47
  "@tanstack/react-table": "9.2.8",
48
- "react": ">=18"
48
+ "react": "^19.3.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@codedgar/puppertino": "2.0.0",