puppertino-table 0.4.0 → 0.5.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 +13 -1
- package/README.md +128 -3
- package/dist/column-browser-layout.d.ts +20 -0
- package/dist/column-browser-layout.js +42 -0
- package/dist/column-browser.d.ts +34 -0
- package/dist/column-browser.js +76 -0
- package/dist/context-menu.d.ts +34 -0
- package/dist/context-menu.js +117 -0
- package/dist/density.d.ts +15 -0
- package/dist/density.js +23 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +6 -0
- package/dist/selection-gestures.d.ts +14 -0
- package/dist/selection-gestures.js +21 -0
- package/dist/state-ownership.d.ts +6 -0
- package/dist/state-ownership.js +18 -0
- package/dist/style.css +187 -0
- package/dist/table-options.d.ts +6 -2
- package/dist/table-types.d.ts +3 -0
- package/dist/table.d.ts +1 -1
- package/dist/table.js +5 -3
- package/dist/use-column-resize.d.ts +7 -7
- package/dist/use-column-resize.js +2 -54
- package/dist/use-column-sizing-persistence.d.ts +10 -0
- package/dist/use-column-sizing-persistence.js +29 -0
- package/dist/use-puppertino-table.d.ts +1 -1
- package/dist/use-puppertino-table.js +31 -33
- package/dist/use-resize-gesture.d.ts +16 -0
- package/dist/use-resize-gesture.js +58 -0
- package/dist/use-row-selection-gestures.d.ts +19 -0
- package/dist/use-row-selection-gestures.js +36 -0
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.5.0 — 2026-10-12
|
|
4
|
+
|
|
5
|
+
- Add scoped native/compact/comfortable density and explicit row/padding/icon metrics across tables and custom layouts; preserve native defaults.
|
|
6
|
+
- Add a caller-rendered, independently resizable column browser with whole-panel fitting, selected/path states and controlled/read-only widths.
|
|
7
|
+
- Share resize lifecycle/event adaptation between table columns and browser panels instead of duplicating gesture handling.
|
|
8
|
+
- Add right-click and keyboard context-menu primitives with viewport fitting, disabled-item navigation, dismissal and focus restoration, plus realistic light/dark resource-browser Storybook workflows.
|
|
9
|
+
|
|
10
|
+
- Add opt-in anchored row-selection gestures for custom layouts, with mixed leading/data items, Command/Ctrl toggles, shrinking Shift ranges and controlled/atom/disabled ownership guards.
|
|
11
|
+
- Export a React-independent selection reducer and whole-column browser geometry helper, with unit, Storybook, browser and packed-consumer checks. Existing checkbox selection remains unchanged.
|
|
12
|
+
|
|
13
|
+
- Extract hydration-safe preference restoration into a dedicated persistence lifecycle hook.
|
|
14
|
+
- Add typed external atom ownership for sorting, selection and sizing; reject conflicting owners and changed bindings without a remount.
|
|
15
|
+
- Add external-atom integration docs, Storybook coverage and production pointer profiling with cell-render regression checks.
|
|
4
16
|
|
|
5
17
|
## 0.4.0 — 2026-10-12
|
|
6
18
|
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ 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.
|
|
20
|
+
pnpm add puppertino-table@0.5.0 @tanstack/react-table@9.2.8 @codedgar/puppertino@2 react@19.3.0 react-dom@19.3.0
|
|
21
21
|
```
|
|
22
22
|
|
|
23
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.
|
|
@@ -84,6 +84,127 @@ const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({ name: 240
|
|
|
84
84
|
|
|
85
85
|
The callback receives a value or functional updater, like a React state setter. Supplying widths without a callback makes sizing read-only. The callback is used only with controlled widths; omit both props for TanStack-owned sizing and its fine-grained geometry subscription. Controlled widths ignore saved preferences: the app owns restoration and persistence, so omit `storageKey` from controlled resize handles. Uncontrolled sorting and selection also stay in TanStack’s internal atoms; callbacks can observe their changes without owning the state. React-controlled sizing rerenders its state owner; for high-frequency custom layouts, TanStack v9 also supports external atoms through `useTable`.
|
|
86
86
|
|
|
87
|
+
### External atom ownership
|
|
88
|
+
|
|
89
|
+
For shared app-owned state with fine-grained subscriptions, pass `atoms` to the complete table or hook. Install `@tanstack/react-store@0.11.2` in the app to create and subscribe to the atoms:
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
import { useCreateAtom, useSelector } from '@tanstack/react-store';
|
|
93
|
+
import type { ColumnSizingState, RowSelectionState, SortingState } from '@tanstack/react-table';
|
|
94
|
+
|
|
95
|
+
function ItemsTable() {
|
|
96
|
+
const columnSizing = useCreateAtom<ColumnSizingState>({ name: 240 });
|
|
97
|
+
const sorting = useCreateAtom<SortingState>([]);
|
|
98
|
+
const rowSelection = useCreateAtom<RowSelectionState>({});
|
|
99
|
+
return <PuppertinoTable data={items} columns={columns} getRowId={(row) => row.id}
|
|
100
|
+
ariaLabel="Items" selectable atoms={{ columnSizing, sorting, rowSelection }} />;
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`PuppertinoTableAtoms` is exported for typed custom integrations. A state slice has one owner: supplying an atom together with that slice's value or callback throws a clear error. Different slices may use different ownership modes. Atom bindings must stay identical for the lifetime of the table, as required by the installed v9 adapter; changing bindings requires remounting with a new React `key`. You may recreate the map when it contains the same atom instances. Initial sorting/selection values apply only to internally owned slices; initialize app atoms yourself. Atom-owned sizing bypasses automatic preference restoration, and the complete component also disables automatic saving. In primitive layouts, omit `storageKey` from resize options when the app owns sizing restoration and saving. Read with `atom.get()` for a snapshot, or use `useSelector(atom)` in the smallest component that needs reactive state. No callback is needed for atom-owned slices. Width changes retain the existing geometry subscription rather than rerendering the table's row body.
|
|
105
|
+
|
|
106
|
+
### Shared density
|
|
107
|
+
|
|
108
|
+
The density, column-browser and context-menu APIs below are available starting in version 0.5.0.
|
|
109
|
+
|
|
110
|
+
Complete tables accept `density="native" | "compact" | "comfortable"`. Native is the default and preserves the existing Puppertino table geometry. Compact uses 24px minimum rows, 3px block padding, 8px inline padding and 16px icons. Comfortable uses 36px rows, 8px block padding, 12px inline padding and 20px icons. Content can make a row taller; text and icons are never clipped to force a height.
|
|
111
|
+
|
|
112
|
+
Use `DensityScope` around layouts built with the table primitives, or pass `densityMetrics` to `PuppertinoTable` and `ColumnBrowser` for explicit overrides. Metrics are CSS pixels: `rowHeight`, `rowPaddingBlock`, `rowPaddingInline` and `iconSize`. `DensityScope` calls the same object `metrics`. Heights and icon sizes must be positive; padding may be zero. Styles stay scoped to each wrapper, so adjacent components can use different densities. Put caller-owned artwork in a `.pt-icon` span to share sizing; the library does not replace your icons.
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
<PuppertinoTable {...tableProps} density="compact" densityMetrics={{ iconSize: 18 }} />
|
|
116
|
+
<DensityScope density="comfortable" metrics={{ rowPaddingInline: 16 }}>
|
|
117
|
+
<MyCustomLayout />
|
|
118
|
+
</DensityScope>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The corresponding CSS properties are `--pt-row-height`, `--pt-row-padding-block`, `--pt-row-padding-inline` and `--pt-icon-size`. Explicit metrics are applied on the scope; ordinary host CSS can customize them. Native density leaves default table header/cell styling intact unless metrics are explicitly supplied. Density changes layout only, not sorting, selection or saved widths.
|
|
122
|
+
|
|
123
|
+
### Column-browser component
|
|
124
|
+
|
|
125
|
+
`ColumnBrowser` renders caller-owned panels in order; the last is the current panel. Ancestors scroll vertically on their own. The surface scrolls horizontally and reveals the current panel beside whole ancestors after navigation, resizing or container changes. Each ancestor can be resized independently with a pointer or the keyboard, using the same cancel/reset lifecycle as table columns. The current panel fills spare room and fits containers narrower than its preferred minimum.
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
import { ColumnBrowser, ColumnBrowserItem } from 'puppertino-table';
|
|
129
|
+
|
|
130
|
+
<ColumnBrowser ariaLabel="Projects" density="compact" panels={[
|
|
131
|
+
{ id: 'workspace', label: 'Workspace', content:
|
|
132
|
+
<ColumnBrowserItem pathSelected onClick={showProjects}>Projects</ColumnBrowserItem> },
|
|
133
|
+
{ id: 'projects', label: 'Projects', width: 260, minWidth: 160, maxWidth: 600,
|
|
134
|
+
content: projectButtons },
|
|
135
|
+
{ id: 'current', label: 'Selected project', content: resourceButtons },
|
|
136
|
+
]} />
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Panels require unique stable `id`, an accessible `label` for their region, and caller-rendered `content`. Ancestors default to 224px, bounded to 120–640px. `minimumCurrentWidth` defaults to 288px. Give the component or its parent a bounded height; it fills that height. The default density is compact. Optional `widths` and `onWidthsChange` provide controlled ancestor sizing. Widths without a callback are read-only and omit resize handles. `resizable={false}` or `resizeDisabled` cancels active gestures; `cancelMediaQuery` can cancel when a responsive layout removes the controls. Panel removal and changed bounds also cancel. Completed gestures remain in local state until unmount; the component does not write preferences. Change callbacks include previews and cancellations, so they are not approval or persistence receipts.
|
|
140
|
+
|
|
141
|
+
`ColumnBrowserItem` is a native button with `icon`, `trailing`, `selected` and `pathSelected` slots/states. It shares density metrics, ellipsis, hover and keyboard-focus styling. Selection and path indication are separate. Standard button props provide actions, labels, disabled state and context-menu triggers. The browser does not fetch records, enter folders, select items or open documents. Compose your navigation, `useRowSelectionGestures`, and custom content around it. Its geometry is measured only after hydration; initial server markup uses deterministic panel widths.
|
|
142
|
+
|
|
143
|
+
### Right-click and keyboard context menus
|
|
144
|
+
|
|
145
|
+
`useContextMenu<T>()` supplies `state`, `open(context, event)`, `close()` and `getTriggerProps(context)`. The trigger props open on right-click, Shift+F10 or the Context Menu key and advertise a menu popup. They are opt-in: items without them keep the browser's native context menu. If you already handle key events, compose that handler with the returned `onKeyDown` instead of overwriting either one.
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
const menu = useContextMenu<string>();
|
|
149
|
+
<ColumnBrowserItem {...menu.getTriggerProps(item.id)}>{item.name}</ColumnBrowserItem>
|
|
150
|
+
<ContextMenu ariaLabel="Item commands" position={menu.state?.position ?? null}
|
|
151
|
+
returnFocus={menu.state?.returnFocus} onClose={menu.close}
|
|
152
|
+
actions={menu.state ? commandsFor(menu.state.context) : []} />
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
For tables, spread trigger props in `getRowProps` and make the target keyboard-focusable, or attach them to a button inside a cell. `ContextMenu` accepts actions with stable `id`, `label`, optional `disabled`, and `onSelect`. It fits the viewport, focuses the first enabled action, supports arrows/Home/End, skips disabled actions, closes on Escape/Tab/outside pointer/scroll/resize/blur, and restores the invoking target's focus. It uses a manual popover where supported so a table or panel's overflow does not clip it. Place it in the same themed DOM subtree as its trigger. Commands, right-click selection policy and authorization remain caller-owned; the component never invents file operations.
|
|
156
|
+
|
|
157
|
+
### Anchored item selection in custom layouts
|
|
158
|
+
|
|
159
|
+
`useRowSelectionGestures(table, options)` adds opt-in desktop item selection to a custom list, card grid or table. A normal click replaces the selection, Command/Ctrl-click toggles one item, and Shift-click extends or shrinks a range from its original anchor. Shift with a toggle modifier adds a range. This policy is separate from the existing checkbox range behavior.
|
|
160
|
+
|
|
161
|
+
```tsx
|
|
162
|
+
import { useRef } from 'react';
|
|
163
|
+
import { usePuppertinoTable, useRowSelectionGestures } from 'puppertino-table';
|
|
164
|
+
|
|
165
|
+
function ItemList() {
|
|
166
|
+
const table = usePuppertinoTable({ data: items, columns, getRowId: item => item.id, selectable: true });
|
|
167
|
+
const anchor = useRef<string | null>(null);
|
|
168
|
+
const { select, clearSelection } = useRowSelectionGestures(table, { anchor });
|
|
169
|
+
return (
|
|
170
|
+
<table.Subscribe source={table.atoms.rowSelection}>
|
|
171
|
+
{selected => <>
|
|
172
|
+
{table.getRowModel().rows.map(row => <button
|
|
173
|
+
key={row.id} type="button" aria-pressed={!!selected[row.id]}
|
|
174
|
+
disabled={!row.getCanSelect()}
|
|
175
|
+
onClick={event => select(row.id, { shift: event.shiftKey, toggle: event.metaKey || event.ctrlKey })}
|
|
176
|
+
>{row.original.name}</button>)}
|
|
177
|
+
<button type="button" onClick={clearSelection}>Clear selection</button>
|
|
178
|
+
</>}
|
|
179
|
+
</table.Subscribe>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The hook works with uncontrolled state, React-controlled state and external selection atoms. Disabled or read-only tables reject gestures without moving the anchor; `options.disabled` also disables just this hook. Nonselectable rows are excluded from ranges. `select` accepts an item ID and `{ shift?, toggle? }`, rather than a DOM event. Call it from native button activation or your own keyboard controller; the hook does not install keyboard listeners, move focus, open items or choose ARIA roles.
|
|
185
|
+
|
|
186
|
+
`options.orderedIds` supplies a caller-owned visible order, including leading items outside the table row model. The default is the current rendered row order. Extra IDs are supported with boolean row-selection eligibility; a table's row predicate cannot authorize an item outside its data model. The caller still owns rendering and selected-state announcements. Share `options.anchor` between layouts to retain the original range anchor. `clearSelection()` clears selection and anchor; `resetAnchor()` only clears the anchor, useful when replacing a collection or changing its order. Without an external ref, the anchor lasts for this hook's mount.
|
|
187
|
+
|
|
188
|
+
`applySelectionGesture(orderedIds, selection, anchor, id, modifiers)` exports the same pure reducer without React. It returns `{ selection, anchor }`, never mutates caller inputs and ignores unknown target IDs. A stale anchor becomes the clicked item. Toggle gestures preserve selected IDs outside the supplied order; replacing gestures clear them. Only the caller-supplied IDs participate in a range; the package never assumes a loaded page is the entire dataset. Selection does not authorize application actions.
|
|
189
|
+
|
|
190
|
+
### Whole-column browser geometry
|
|
191
|
+
|
|
192
|
+
`fitColumnBrowser({ availableWidth, ancestorWidth, ancestorCount, minimumCurrentWidth })` is a pure helper for a caller-rendered hierarchical column browser. It returns `{ currentWidth, scrollLeft, visibleAncestors }`. Use the current width for the final panel and the scroll offset to reveal that panel beside whole ancestors, avoiding a cut-off filename at the left edge after resizing.
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { fitColumnBrowser } from 'puppertino-table';
|
|
196
|
+
|
|
197
|
+
const layout = fitColumnBrowser({
|
|
198
|
+
availableWidth: 724,
|
|
199
|
+
ancestorWidth: 224,
|
|
200
|
+
ancestorCount: 3,
|
|
201
|
+
minimumCurrentWidth: 288,
|
|
202
|
+
});
|
|
203
|
+
// { currentWidth: 500, scrollLeft: 448, visibleAncestors: 1 }
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Widths are outer panel widths in CSS pixels; use border-box panels and measure the available scroll surface. The final panel takes spare room and fits surfaces narrower than its preferred minimum. A root with no ancestors takes the entire width. Dimensions must be finite and nonnegative, the ancestor width positive, and the count an integer; invalid inputs throw `RangeError`. The helper remains available for manual layouts. `ColumnBrowser` owns resize observation and scrolling for caller-rendered panels; hierarchy, loading and data access remain app-owned.
|
|
207
|
+
|
|
87
208
|
### Resizing
|
|
88
209
|
|
|
89
210
|
Drag a header's right edge. Double-click or Enter resets that column to its definition's initial size. Left/Right adjusts 16 pixels; Shift uses 40 pixels. Home/End reaches its limits, and Escape cancels an unfinished gesture. Pointer cancellation, losing window focus, unmounting, or disabling resizing also cancels.
|
|
@@ -251,7 +372,7 @@ pnpm format # apply formatting, import organization and safe lint fixes
|
|
|
251
372
|
pnpm check
|
|
252
373
|
```
|
|
253
374
|
|
|
254
|
-
Checks include TypeScript, independent controller ownership/cancellation/persistence tests, preference/schema unit tests, real SSR hydration and controlled-sizing browser 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
|
|
375
|
+
Checks include TypeScript, independent controller ownership/cancellation/persistence tests, preference/schema unit tests, real SSR hydration and controlled-sizing browser 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 twenty 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`.
|
|
255
376
|
|
|
256
377
|
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.
|
|
257
378
|
|
|
@@ -263,7 +384,7 @@ The unscoped **`puppertino-table`** package is public on npm. Stable releases us
|
|
|
263
384
|
|
|
264
385
|
```sh
|
|
265
386
|
pnpm check
|
|
266
|
-
npm publish test-results/package/puppertino-table-0.
|
|
387
|
+
npm publish test-results/package/puppertino-table-0.5.0.tgz --dry-run --ignore-scripts --access public
|
|
267
388
|
```
|
|
268
389
|
|
|
269
390
|
`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.
|
|
@@ -279,3 +400,7 @@ MIT. Puppertino, React and TanStack Table remain independently maintained projec
|
|
|
279
400
|
Dependabot checks npm dependencies and GitHub Actions every Monday at 08:00 America/Los_Angeles. It groups compatible npm patch/minor updates and proposes major changes separately. React and React DOM, including their type packages, stay on the supported React 19 line; versions 20 and later are excluded.
|
|
280
401
|
|
|
281
402
|
Dependency updates must pass verification on the current PR head before merging. Review major migrations separately. Releases remain explicitly authorized actions; dependency maintenance does not publish npm versions or alter trusted-publisher settings.
|
|
403
|
+
|
|
404
|
+
### Pointer profiling
|
|
405
|
+
|
|
406
|
+
Run `pnpm profile:pointer` to measure the production table with 100 rows and 10, 40 and 100 columns, under 4× Chromium CPU throttling. The harness uses one warmup and three measured drags per size, records dispatch timings and browser task/layout totals, and asserts that pointer resizing does not rerender cells. Results are written to `test-results/pointer-profile/results.json`. These synthetic samples guide optimization; they do not guarantee real-device latency. Use the existing browser executable environment variable when needed. The [recorded profile](https://github.com/kahwee/puppertino-table/blob/main/docs/pointer-performance.md) shows zero cell rerenders and increasing browser task cost for very wide tables. Immediate updates remain in place until a burst-workload comparison demonstrates that frame batching helps without weakening gesture semantics.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface ColumnBrowserLayoutOptions {
|
|
2
|
+
availableWidth: number;
|
|
3
|
+
ancestorWidth: number;
|
|
4
|
+
ancestorCount: number;
|
|
5
|
+
minimumCurrentWidth: number;
|
|
6
|
+
}
|
|
7
|
+
/** Variable-width equivalent used by independently resizable browser panels. */
|
|
8
|
+
export declare function fitColumnPanels(availableWidth: number, ancestorWidths: readonly number[], minimumCurrentWidth: number): {
|
|
9
|
+
currentWidth: number;
|
|
10
|
+
scrollLeft: number;
|
|
11
|
+
visibleAncestors: number;
|
|
12
|
+
};
|
|
13
|
+
/** Fit whole ancestor panels beside a current panel, independent of data and rendering.
|
|
14
|
+
* Apply currentWidth to the final panel and scrollLeft to the horizontal scroll surface.
|
|
15
|
+
*/
|
|
16
|
+
export declare function fitColumnBrowser({ availableWidth, ancestorWidth, ancestorCount, minimumCurrentWidth, }: ColumnBrowserLayoutOptions): {
|
|
17
|
+
currentWidth: number;
|
|
18
|
+
scrollLeft: number;
|
|
19
|
+
visibleAncestors: number;
|
|
20
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Variable-width equivalent used by independently resizable browser panels. */
|
|
2
|
+
export function fitColumnPanels(availableWidth, ancestorWidths, minimumCurrentWidth) {
|
|
3
|
+
if (!Number.isFinite(availableWidth) ||
|
|
4
|
+
availableWidth < 0 ||
|
|
5
|
+
!Number.isFinite(minimumCurrentWidth) ||
|
|
6
|
+
minimumCurrentWidth < 0 ||
|
|
7
|
+
ancestorWidths.some((width) => !Number.isFinite(width) || width <= 0))
|
|
8
|
+
throw new RangeError('Panel dimensions must be finite and nonnegative, with positive ancestor widths.');
|
|
9
|
+
let visibleWidth = 0, visibleAncestors = 0;
|
|
10
|
+
for (let index = ancestorWidths.length - 1; index >= 0; index--) {
|
|
11
|
+
const width = ancestorWidths[index];
|
|
12
|
+
if (visibleWidth + width + minimumCurrentWidth > availableWidth)
|
|
13
|
+
break;
|
|
14
|
+
visibleWidth += width;
|
|
15
|
+
visibleAncestors++;
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
currentWidth: availableWidth - visibleWidth,
|
|
19
|
+
scrollLeft: ancestorWidths.reduce((sum, width) => sum + width, 0) - visibleWidth,
|
|
20
|
+
visibleAncestors,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Fit whole ancestor panels beside a current panel, independent of data and rendering.
|
|
24
|
+
* Apply currentWidth to the final panel and scrollLeft to the horizontal scroll surface.
|
|
25
|
+
*/
|
|
26
|
+
export function fitColumnBrowser({ availableWidth, ancestorWidth, ancestorCount, minimumCurrentWidth, }) {
|
|
27
|
+
if (!Number.isFinite(availableWidth) ||
|
|
28
|
+
availableWidth < 0 ||
|
|
29
|
+
!Number.isFinite(ancestorWidth) ||
|
|
30
|
+
ancestorWidth <= 0 ||
|
|
31
|
+
!Number.isInteger(ancestorCount) ||
|
|
32
|
+
ancestorCount < 0 ||
|
|
33
|
+
!Number.isFinite(minimumCurrentWidth) ||
|
|
34
|
+
minimumCurrentWidth < 0)
|
|
35
|
+
throw new RangeError('Column dimensions must be finite and nonnegative, with a positive ancestor width and an integer ancestor count.');
|
|
36
|
+
const visibleAncestors = Math.min(ancestorCount, Math.floor(Math.max(0, availableWidth - minimumCurrentWidth) / ancestorWidth));
|
|
37
|
+
return {
|
|
38
|
+
currentWidth: availableWidth - visibleAncestors * ancestorWidth,
|
|
39
|
+
scrollLeft: (ancestorCount - visibleAncestors) * ancestorWidth,
|
|
40
|
+
visibleAncestors,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
|
2
|
+
import { type DensityMetrics, type TableDensity } from './density.js';
|
|
3
|
+
export interface ColumnBrowserPanel {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
content: ReactNode;
|
|
7
|
+
width?: number;
|
|
8
|
+
minWidth?: number;
|
|
9
|
+
maxWidth?: number;
|
|
10
|
+
}
|
|
11
|
+
export type ColumnBrowserWidths = Record<string, number>;
|
|
12
|
+
export interface ColumnBrowserProps {
|
|
13
|
+
panels: readonly ColumnBrowserPanel[];
|
|
14
|
+
ariaLabel: string;
|
|
15
|
+
density?: TableDensity;
|
|
16
|
+
densityMetrics?: DensityMetrics;
|
|
17
|
+
minimumCurrentWidth?: number;
|
|
18
|
+
resizable?: boolean;
|
|
19
|
+
resizeDisabled?: boolean;
|
|
20
|
+
cancelMediaQuery?: string;
|
|
21
|
+
widths?: ColumnBrowserWidths;
|
|
22
|
+
onWidthsChange?: (widths: ColumnBrowserWidths) => void;
|
|
23
|
+
className?: string;
|
|
24
|
+
style?: ComponentPropsWithoutRef<'div'>['style'];
|
|
25
|
+
}
|
|
26
|
+
/** Caller-rendered hierarchy. No fetching, navigation, selection or application commands. */
|
|
27
|
+
export declare function ColumnBrowser({ panels, ariaLabel, density, densityMetrics, minimumCurrentWidth, resizable, resizeDisabled: disabled, cancelMediaQuery, widths: controlled, onWidthsChange, className, style, }: ColumnBrowserProps): import("react").JSX.Element;
|
|
28
|
+
export interface ColumnBrowserItemProps extends ComponentPropsWithoutRef<'button'> {
|
|
29
|
+
icon?: ReactNode;
|
|
30
|
+
trailing?: ReactNode;
|
|
31
|
+
selected?: boolean;
|
|
32
|
+
pathSelected?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export declare function ColumnBrowserItem({ icon, trailing, selected, pathSelected, className, children, ...props }: ColumnBrowserItemProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState, } from 'react';
|
|
4
|
+
import { fitColumnPanels } from './column-browser-layout.js';
|
|
5
|
+
import { DensityScope } from './density.js';
|
|
6
|
+
import { clampSize, columnSizeBounds } from './sizing.js';
|
|
7
|
+
import { useResizeGesture } from './use-resize-gesture.js';
|
|
8
|
+
/** Caller-rendered hierarchy. No fetching, navigation, selection or application commands. */
|
|
9
|
+
export function ColumnBrowser({ panels, ariaLabel, density = 'compact', densityMetrics, minimumCurrentWidth = 288, resizable = true, resizeDisabled: disabled = false, cancelMediaQuery, widths: controlled, onWidthsChange, className = '', style, }) {
|
|
10
|
+
const ids = panels.map((panel) => panel.id);
|
|
11
|
+
if (ids.some((id) => !id) || new Set(ids).size !== ids.length)
|
|
12
|
+
throw new Error('Browser panels need unique stable IDs.');
|
|
13
|
+
if (!Number.isFinite(minimumCurrentWidth) || minimumCurrentWidth <= 0)
|
|
14
|
+
throw new RangeError('The current panel minimum must be positive and finite.');
|
|
15
|
+
const bounds = columnSizeBounds(panels.slice(0, -1).map((panel) => ({
|
|
16
|
+
id: panel.id,
|
|
17
|
+
size: panel.width ?? 224,
|
|
18
|
+
minSize: panel.minWidth ?? 120,
|
|
19
|
+
maxSize: panel.maxWidth ?? 640,
|
|
20
|
+
})));
|
|
21
|
+
const [localWidths, setLocalWidths] = useState({});
|
|
22
|
+
const widths = Object.fromEntries(bounds.map((bound) => [bound.id, clampSize((controlled ?? localWidths)[bound.id], bound)]));
|
|
23
|
+
const snapshot = useRef(widths);
|
|
24
|
+
const surface = useRef(null);
|
|
25
|
+
const owner = useRef({});
|
|
26
|
+
const apply = (next) => {
|
|
27
|
+
const normalized = Object.fromEntries(bounds.map((bound) => [bound.id, clampSize(next[bound.id], bound)]));
|
|
28
|
+
if (controlled === undefined)
|
|
29
|
+
setLocalWidths(normalized);
|
|
30
|
+
onWidthsChange?.(normalized);
|
|
31
|
+
};
|
|
32
|
+
const resize = useResizeGesture(owner.current, {
|
|
33
|
+
snapshot: () => snapshot.current,
|
|
34
|
+
bounds: (id) => {
|
|
35
|
+
const bound = bounds.find((bound) => bound.id === id);
|
|
36
|
+
if (!bound)
|
|
37
|
+
throw new Error(`Unknown browser panel: ${id}`);
|
|
38
|
+
return bound;
|
|
39
|
+
},
|
|
40
|
+
apply,
|
|
41
|
+
reset: (id) => apply({ ...snapshot.current, [id]: bounds.find((bound) => bound.id === id)?.size ?? 224 }),
|
|
42
|
+
}, {
|
|
43
|
+
enabled: resizable && !disabled && (controlled === undefined || !!onWidthsChange),
|
|
44
|
+
cancelMediaQuery,
|
|
45
|
+
});
|
|
46
|
+
const schema = JSON.stringify(bounds);
|
|
47
|
+
useLayoutEffect(() => {
|
|
48
|
+
snapshot.current = widths;
|
|
49
|
+
});
|
|
50
|
+
// Panel removal or changed bounds cancel a drag just as table schema changes do.
|
|
51
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: Cancellation is keyed to stable schema values, not newly created handler objects.
|
|
52
|
+
useEffect(() => () => resize.cancel(), [schema]);
|
|
53
|
+
const geometry = JSON.stringify(bounds.map((bound) => widths[bound.id]));
|
|
54
|
+
useLayoutEffect(() => {
|
|
55
|
+
const element = surface.current;
|
|
56
|
+
if (!element)
|
|
57
|
+
return;
|
|
58
|
+
const ancestorWidths = JSON.parse(geometry);
|
|
59
|
+
const fit = () => {
|
|
60
|
+
const layout = fitColumnPanels(element.clientWidth, ancestorWidths, minimumCurrentWidth);
|
|
61
|
+
element.style.setProperty('--pt-current-panel-width', `${layout.currentWidth}px`);
|
|
62
|
+
element.scrollLeft = layout.scrollLeft;
|
|
63
|
+
};
|
|
64
|
+
fit();
|
|
65
|
+
const observer = new ResizeObserver(fit);
|
|
66
|
+
observer.observe(element);
|
|
67
|
+
return () => observer.disconnect();
|
|
68
|
+
}, [geometry, minimumCurrentWidth]);
|
|
69
|
+
return (_jsx(DensityScope, { ref: surface, density: density, metrics: densityMetrics, className: `pt-column-browser ${className}`, style: style, role: "region", tabIndex: 0, "aria-label": ariaLabel, children: panels.map((panel, index) => {
|
|
70
|
+
const bound = bounds[index];
|
|
71
|
+
return (_jsxs("section", { className: "pt-browser-panel", "aria-label": panel.label, style: { flexBasis: bound ? widths[panel.id] : 'var(--pt-current-panel-width, 100%)' }, children: [_jsx("div", { className: "pt-browser-panel-content", children: panel.content }), bound && resizable && !disabled && (controlled === undefined || !!onWidthsChange) && (_jsx("span", { className: "pt-panel-resizer", role: "separator", tabIndex: 0, "aria-orientation": "vertical", "aria-label": `Resize ${panel.label} panel`, "aria-valuemin": bound.minSize, "aria-valuemax": bound.maxSize, "aria-valuenow": widths[panel.id], title: "Drag to resize. Arrow keys adjust width; Enter resets and Escape cancels.", ...resize.handle(panel.id) }))] }, panel.id));
|
|
72
|
+
}) }));
|
|
73
|
+
}
|
|
74
|
+
export function ColumnBrowserItem({ icon, trailing, selected = false, pathSelected = false, className = '', children, ...props }) {
|
|
75
|
+
return (_jsxs("button", { type: "button", ...props, className: `pt-browser-item ${selected ? 'p-selected' : ''} ${pathSelected ? 'pt-path-selected' : ''} ${className}`, "aria-pressed": selected, "aria-current": pathSelected ? 'location' : undefined, children: [icon && (_jsx("span", { className: "pt-icon", "aria-hidden": "true", children: icon })), _jsx("span", { className: "pt-item-label", children: children }), trailing && (_jsx("span", { className: "pt-item-trailing", "aria-hidden": "true", children: trailing }))] }));
|
|
76
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type KeyboardEvent, type MouseEvent } from 'react';
|
|
2
|
+
export interface ContextMenuPosition {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
}
|
|
6
|
+
export interface ContextMenuAction {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
onSelect(): void;
|
|
11
|
+
}
|
|
12
|
+
export interface ContextMenuProps {
|
|
13
|
+
position: ContextMenuPosition | null;
|
|
14
|
+
actions: readonly ContextMenuAction[];
|
|
15
|
+
ariaLabel: string;
|
|
16
|
+
onClose(): void;
|
|
17
|
+
returnFocus?: HTMLElement | null;
|
|
18
|
+
}
|
|
19
|
+
export declare function useContextMenu<T>(): {
|
|
20
|
+
state: {
|
|
21
|
+
context: T;
|
|
22
|
+
position: ContextMenuPosition;
|
|
23
|
+
returnFocus: HTMLElement;
|
|
24
|
+
} | null;
|
|
25
|
+
close: () => void;
|
|
26
|
+
open: (context: T, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void;
|
|
27
|
+
getTriggerProps: (context: T) => {
|
|
28
|
+
'aria-haspopup': 'menu';
|
|
29
|
+
onContextMenu: (event: MouseEvent<HTMLElement>) => void;
|
|
30
|
+
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/** Caller-owned commands; popover placement, keyboard navigation and focus stay shared. */
|
|
34
|
+
export declare function ContextMenu({ position, actions, ariaLabel, onClose, returnFocus, }: ContextMenuProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from 'react';
|
|
4
|
+
export function useContextMenu() {
|
|
5
|
+
const [state, setState] = useState(null);
|
|
6
|
+
const close = useCallback(() => setState(null), []);
|
|
7
|
+
const open = useCallback((context, event) => {
|
|
8
|
+
event.preventDefault();
|
|
9
|
+
event.stopPropagation();
|
|
10
|
+
const target = event.currentTarget;
|
|
11
|
+
const candidate = event.target instanceof Element
|
|
12
|
+
? event.target.closest('button,a[href],input,select,textarea,[tabindex]')
|
|
13
|
+
: null;
|
|
14
|
+
const focusTarget = candidate instanceof HTMLElement && target.contains(candidate) ? candidate : target;
|
|
15
|
+
const rect = focusTarget.getBoundingClientRect();
|
|
16
|
+
const pointer = 'clientX' in event && (event.clientX !== 0 || event.clientY !== 0);
|
|
17
|
+
setState({
|
|
18
|
+
context,
|
|
19
|
+
position: pointer
|
|
20
|
+
? { x: event.clientX, y: event.clientY }
|
|
21
|
+
: { x: rect.left + 8, y: rect.bottom },
|
|
22
|
+
returnFocus: focusTarget,
|
|
23
|
+
});
|
|
24
|
+
}, []);
|
|
25
|
+
return {
|
|
26
|
+
state,
|
|
27
|
+
close,
|
|
28
|
+
open,
|
|
29
|
+
getTriggerProps: (context) => ({
|
|
30
|
+
'aria-haspopup': 'menu',
|
|
31
|
+
onContextMenu: (event) => open(context, event),
|
|
32
|
+
onKeyDown: (event) => {
|
|
33
|
+
if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10'))
|
|
34
|
+
open(context, event);
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Caller-owned commands; popover placement, keyboard navigation and focus stay shared. */
|
|
40
|
+
export function ContextMenu({ position, actions, ariaLabel, onClose, returnFocus, }) {
|
|
41
|
+
const menu = useRef(null);
|
|
42
|
+
const close = useRef(onClose);
|
|
43
|
+
close.current = onClose;
|
|
44
|
+
useLayoutEffect(() => {
|
|
45
|
+
const element = menu.current;
|
|
46
|
+
if (!position || !element)
|
|
47
|
+
return;
|
|
48
|
+
element.showPopover?.();
|
|
49
|
+
const rect = element.getBoundingClientRect();
|
|
50
|
+
element.style.left = `${Math.max(8, Math.min(position.x, innerWidth - rect.width - 8))}px`;
|
|
51
|
+
element.style.top = `${Math.max(8, Math.min(position.y, innerHeight - rect.height - 8))}px`;
|
|
52
|
+
(element.querySelector('button:not(:disabled)') ?? element).focus();
|
|
53
|
+
return () => {
|
|
54
|
+
if (returnFocus?.isConnected)
|
|
55
|
+
returnFocus.focus({ preventScroll: true });
|
|
56
|
+
};
|
|
57
|
+
}, [position, returnFocus]);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!position)
|
|
60
|
+
return;
|
|
61
|
+
const outside = (event) => {
|
|
62
|
+
if (!menu.current?.contains(event.target))
|
|
63
|
+
close.current();
|
|
64
|
+
};
|
|
65
|
+
const dismiss = () => close.current();
|
|
66
|
+
const outsideScroll = (event) => {
|
|
67
|
+
if (!menu.current?.contains(event.target))
|
|
68
|
+
close.current();
|
|
69
|
+
};
|
|
70
|
+
const onEscape = (event) => {
|
|
71
|
+
if (event.key === 'Escape') {
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
event.stopPropagation();
|
|
74
|
+
close.current();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
document.addEventListener('pointerdown', outside, true);
|
|
78
|
+
document.addEventListener('keydown', onEscape, true);
|
|
79
|
+
document.addEventListener('scroll', outsideScroll, true);
|
|
80
|
+
window.addEventListener('resize', dismiss);
|
|
81
|
+
window.addEventListener('blur', dismiss);
|
|
82
|
+
return () => {
|
|
83
|
+
document.removeEventListener('pointerdown', outside, true);
|
|
84
|
+
document.removeEventListener('keydown', onEscape, true);
|
|
85
|
+
document.removeEventListener('scroll', outsideScroll, true);
|
|
86
|
+
window.removeEventListener('resize', dismiss);
|
|
87
|
+
window.removeEventListener('blur', dismiss);
|
|
88
|
+
};
|
|
89
|
+
}, [position]);
|
|
90
|
+
if (!position)
|
|
91
|
+
return null;
|
|
92
|
+
const navigate = (event) => {
|
|
93
|
+
const buttons = Array.from(menu.current?.querySelectorAll('button:not(:disabled)') ?? []);
|
|
94
|
+
const current = buttons.indexOf(document.activeElement);
|
|
95
|
+
let next = current;
|
|
96
|
+
if (event.key === 'ArrowDown')
|
|
97
|
+
next = (current + 1) % buttons.length;
|
|
98
|
+
else if (event.key === 'ArrowUp')
|
|
99
|
+
next = (current - 1 + buttons.length) % buttons.length;
|
|
100
|
+
else if (event.key === 'Home')
|
|
101
|
+
next = 0;
|
|
102
|
+
else if (event.key === 'End')
|
|
103
|
+
next = buttons.length - 1;
|
|
104
|
+
else if (event.key === 'Tab') {
|
|
105
|
+
close.current();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
else
|
|
109
|
+
return;
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
buttons[next]?.focus();
|
|
112
|
+
};
|
|
113
|
+
return (_jsx("div", { ref: menu, popover: "manual", className: "pt-context-menu", role: "menu", tabIndex: -1, "aria-label": ariaLabel, onKeyDown: navigate, onContextMenu: (event) => event.preventDefault(), style: { left: position.x, top: position.y }, children: actions.map((action) => (_jsx("button", { type: "button", role: "menuitem", disabled: action.disabled, onClick: () => {
|
|
114
|
+
onClose();
|
|
115
|
+
action.onSelect();
|
|
116
|
+
}, children: action.label }, action.id))) }));
|
|
117
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, CSSProperties } from 'react';
|
|
2
|
+
export type TableDensity = 'native' | 'compact' | 'comfortable';
|
|
3
|
+
export interface DensityMetrics {
|
|
4
|
+
rowHeight?: number;
|
|
5
|
+
rowPaddingBlock?: number;
|
|
6
|
+
rowPaddingInline?: number;
|
|
7
|
+
iconSize?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface DensityScopeProps extends ComponentPropsWithRef<'div'> {
|
|
10
|
+
density?: TableDensity;
|
|
11
|
+
metrics?: DensityMetrics;
|
|
12
|
+
}
|
|
13
|
+
export declare function densityStyle(metrics?: DensityMetrics): CSSProperties;
|
|
14
|
+
/** The same scoped metrics apply to complete tables, custom layouts and browser items. */
|
|
15
|
+
export declare function DensityScope({ density, metrics, className, style, ...props }: DensityScopeProps): import("react").JSX.Element;
|
package/dist/density.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
export function densityStyle(metrics = {}) {
|
|
3
|
+
const properties = {
|
|
4
|
+
rowHeight: '--pt-row-height',
|
|
5
|
+
rowPaddingBlock: '--pt-row-padding-block',
|
|
6
|
+
rowPaddingInline: '--pt-row-padding-inline',
|
|
7
|
+
iconSize: '--pt-icon-size',
|
|
8
|
+
};
|
|
9
|
+
return Object.fromEntries(Object.entries(properties).flatMap(([key, property]) => {
|
|
10
|
+
const value = metrics[key];
|
|
11
|
+
if (value === undefined)
|
|
12
|
+
return [];
|
|
13
|
+
if (!Number.isFinite(value) ||
|
|
14
|
+
value < 0 ||
|
|
15
|
+
((key === 'rowHeight' || key === 'iconSize') && value === 0))
|
|
16
|
+
throw new RangeError(`Invalid density metric: ${key}.`);
|
|
17
|
+
return [[property, `${value}px`]];
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
/** The same scoped metrics apply to complete tables, custom layouts and browser items. */
|
|
21
|
+
export function DensityScope({ density = 'native', metrics, className = '', style, ...props }) {
|
|
22
|
+
return (_jsx("div", { ...props, className: `pt-density ${className}`, "data-pt-density": density, "data-pt-metrics": metrics ? 'custom' : undefined, style: { ...densityStyle(metrics), ...style } }));
|
|
23
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
export { ColumnBrowser, ColumnBrowserItem, type ColumnBrowserItemProps, type ColumnBrowserPanel, type ColumnBrowserProps, type ColumnBrowserWidths, } from './column-browser.js';
|
|
2
|
+
export { type ColumnBrowserLayoutOptions, fitColumnBrowser } from './column-browser-layout.js';
|
|
3
|
+
export { ContextMenu, type ContextMenuAction, type ContextMenuPosition, type ContextMenuProps, useContextMenu, } from './context-menu.js';
|
|
4
|
+
export { type DensityMetrics, DensityScope, type DensityScopeProps, type TableDensity, } from './density.js';
|
|
1
5
|
export { createPuppertinoColumnHelper, type PuppertinoColumnDef, type PuppertinoTableInstance, puppertinoTableFeatures, } from './model.js';
|
|
2
6
|
export { ColumnResizeHandle } from './resize-handle.js';
|
|
3
7
|
export type { ColumnResizeHandleProps, ColumnResizeOptions } from './resize-types.js';
|
|
4
8
|
export { RowSelectionCheckbox } from './row-selection-checkbox.js';
|
|
9
|
+
export { applySelectionGesture, type SelectionGestureResult, type SelectionModifiers, } from './selection-gestures.js';
|
|
5
10
|
export { type ColumnSizeBounds, clampSize, columnSizeBounds, readColumnSizing } from './sizing.js';
|
|
6
11
|
export { PuppertinoTable } from './table.js';
|
|
7
12
|
export { TableBody } from './table-body.js';
|
|
8
13
|
export { TableHeader } from './table-header.js';
|
|
9
|
-
export type { PuppertinoTableOptions } from './table-options.js';
|
|
14
|
+
export type { PuppertinoTableAtoms, PuppertinoTableOptions } from './table-options.js';
|
|
10
15
|
export type { PuppertinoBodyContext, PuppertinoBodyOptions, PuppertinoCell, PuppertinoCellProps, PuppertinoDataAttributes, PuppertinoRow, PuppertinoRowProps, PuppertinoTableProps, RowSelectionCheckboxProps, TableBodyProps, TableHeaderProps, } from './table-types.js';
|
|
11
16
|
export { useColumnResize } from './use-column-resize.js';
|
|
12
17
|
export { usePuppertinoTable } from './use-puppertino-table.js';
|
|
18
|
+
export { type RowSelectionGestureOptions, useRowSelectionGestures, } from './use-row-selection-gestures.js';
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
export { ColumnBrowser, ColumnBrowserItem, } from './column-browser.js';
|
|
2
|
+
export { fitColumnBrowser } from './column-browser-layout.js';
|
|
3
|
+
export { ContextMenu, useContextMenu, } from './context-menu.js';
|
|
4
|
+
export { DensityScope, } from './density.js';
|
|
1
5
|
export { createPuppertinoColumnHelper, puppertinoTableFeatures, } from './model.js';
|
|
2
6
|
export { ColumnResizeHandle } from './resize-handle.js';
|
|
3
7
|
export { RowSelectionCheckbox } from './row-selection-checkbox.js';
|
|
8
|
+
export { applySelectionGesture, } from './selection-gestures.js';
|
|
4
9
|
export { clampSize, columnSizeBounds, readColumnSizing } from './sizing.js';
|
|
5
10
|
export { PuppertinoTable } from './table.js';
|
|
6
11
|
export { TableBody } from './table-body.js';
|
|
7
12
|
export { TableHeader } from './table-header.js';
|
|
8
13
|
export { useColumnResize } from './use-column-resize.js';
|
|
9
14
|
export { usePuppertinoTable } from './use-puppertino-table.js';
|
|
15
|
+
export { useRowSelectionGestures, } from './use-row-selection-gestures.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RowSelectionState } from '@tanstack/react-table';
|
|
2
|
+
/** Normal click replaces, toggle-click toggles, and Shift extends a fixed anchor. */
|
|
3
|
+
export interface SelectionModifiers {
|
|
4
|
+
shift?: boolean;
|
|
5
|
+
toggle?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface SelectionGestureResult {
|
|
8
|
+
selection: RowSelectionState;
|
|
9
|
+
anchor: string | null;
|
|
10
|
+
}
|
|
11
|
+
/** Caller-owned order can include leading items and custom layouts, not only table rows.
|
|
12
|
+
* Toggle gestures preserve selections outside that order; replacing gestures clear them.
|
|
13
|
+
*/
|
|
14
|
+
export declare function applySelectionGesture(orderedIds: readonly string[], selection: RowSelectionState, anchor: string | null, id: string, { shift, toggle }?: SelectionModifiers): SelectionGestureResult;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Caller-owned order can include leading items and custom layouts, not only table rows.
|
|
2
|
+
* Toggle gestures preserve selections outside that order; replacing gestures clear them.
|
|
3
|
+
*/
|
|
4
|
+
export function applySelectionGesture(orderedIds, selection, anchor, id, { shift = false, toggle = false } = {}) {
|
|
5
|
+
const ids = [...new Set(orderedIds)];
|
|
6
|
+
const end = ids.indexOf(id);
|
|
7
|
+
if (end === -1)
|
|
8
|
+
return { selection, anchor };
|
|
9
|
+
const start = anchor === null ? -1 : ids.indexOf(anchor);
|
|
10
|
+
if (shift && start !== -1) {
|
|
11
|
+
const range = Object.fromEntries(ids.slice(Math.min(start, end), Math.max(start, end) + 1).map((key) => [key, true]));
|
|
12
|
+
return { selection: toggle ? { ...selection, ...range } : range, anchor };
|
|
13
|
+
}
|
|
14
|
+
if (toggle) {
|
|
15
|
+
const next = { ...selection, [id]: true };
|
|
16
|
+
if (selection[id] === true)
|
|
17
|
+
delete next[id];
|
|
18
|
+
return { selection: next, anchor: id };
|
|
19
|
+
}
|
|
20
|
+
return { selection: { [id]: true }, anchor: id };
|
|
21
|
+
}
|