puppertino-table 0.0.0-stage → 0.1.1
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 +19 -0
- package/LICENSE +21 -0
- package/README.md +121 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/model.d.ts +19 -0
- package/dist/model.js +3 -0
- package/dist/resize-handle.d.ts +11 -0
- package/dist/resize-handle.js +9 -0
- package/dist/sizing.d.ts +21 -0
- package/dist/sizing.js +31 -0
- package/dist/style.css +26 -0
- package/dist/table.d.ts +27 -0
- package/dist/table.js +52 -0
- package/dist/use-column-resize.d.ts +21 -0
- package/dist/use-column-resize.js +115 -0
- package/package.json +83 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1 — 2026-10-12
|
|
4
|
+
|
|
5
|
+
- Correct npm installation and publication-status documentation.
|
|
6
|
+
- Add a guarded GitHub Release publishing workflow with exact tag, commit, version and tarball validation.
|
|
7
|
+
- Run adversarial release-guard tests in CI and verify registry consumers after publishing.
|
|
8
|
+
|
|
9
|
+
## 0.1.0 — 2026-10-12
|
|
10
|
+
|
|
11
|
+
- Publish the requested unscoped `puppertino-table` package.
|
|
12
|
+
- Restore pre-gesture widths when standalone resize controls unmount or their cancellation settings change.
|
|
13
|
+
- Show standalone resize controls only on actual leaf headers, including grouped-header layouts.
|
|
14
|
+
|
|
15
|
+
- Generic React table and typed column helpers powered by TanStack Table.
|
|
16
|
+
- Controlled sorting and selection, optional local sorting, and Shift-click selection ranges.
|
|
17
|
+
- Pointer and keyboard column resizing with bounds, cancellation and optional persistence.
|
|
18
|
+
- Unbundled ESM, TypeScript declarations and a scoped Puppertino stylesheet.
|
|
19
|
+
- Storybook 10 examples for grouped headers, alternate schemas, dark mode and table states.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KahWee
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,122 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Puppertino Table
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A generic React table styled with [Puppertino](https://puppertino.com/) and powered by [TanStack Table](https://tanstack.com/table). Bring your own data, columns and cell renderers.
|
|
4
|
+
|
|
5
|
+
- Typed column definitions for arbitrary row shapes, including grouped headers.
|
|
6
|
+
- Host-controlled sorting by default, with optional local sorting.
|
|
7
|
+
- Checkbox selection with Shift-click ranges and keyboard toggling.
|
|
8
|
+
- Precise, bounded column resizing with optional browser-local persistence.
|
|
9
|
+
- Sizing subscriptions isolated from the memoized row body.
|
|
10
|
+
- Semantic tables, accessible resize separators and horizontal scrolling in narrow containers.
|
|
11
|
+
- Storybook **10.6.1** with interactive examples and accessibility checks.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
The package includes built ESM and TypeScript declarations. Install it from npm:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pnpm add puppertino-table@0.1.1 @tanstack/react-table@9.2.8 @codedgar/puppertino@2 react react-dom
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
## Use
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
'use client';
|
|
27
|
+
import { useState } from 'react';
|
|
28
|
+
import type { SortingState } from '@tanstack/react-table';
|
|
29
|
+
import { PuppertinoTable, createPuppertinoColumnHelper } from 'puppertino-table';
|
|
30
|
+
import '@codedgar/puppertino/colors';
|
|
31
|
+
import '@codedgar/puppertino/lists';
|
|
32
|
+
import '@codedgar/puppertino/forms';
|
|
33
|
+
import 'puppertino-table/style.css';
|
|
34
|
+
|
|
35
|
+
type Item = { id: string; name: string; score: number };
|
|
36
|
+
const helper = createPuppertinoColumnHelper<Item>();
|
|
37
|
+
const columns = helper.columns([
|
|
38
|
+
helper.accessor('name', { header: 'Name', size: 240, minSize: 140, maxSize: 600 }),
|
|
39
|
+
helper.accessor('score', { header: 'Score', size: 100, cell: cell => cell.getValue() }),
|
|
40
|
+
]);
|
|
41
|
+
const items: Item[] = [{ id: 'a', name: 'Example', score: 82 }];
|
|
42
|
+
|
|
43
|
+
export function Example() {
|
|
44
|
+
const [sorting, setSorting] = useState<SortingState>([]);
|
|
45
|
+
return (
|
|
46
|
+
<PuppertinoTable
|
|
47
|
+
data={items}
|
|
48
|
+
columns={columns}
|
|
49
|
+
getRowId={item => item.id}
|
|
50
|
+
getRowLabel={item => item.name}
|
|
51
|
+
ariaLabel="Items"
|
|
52
|
+
sorting={sorting}
|
|
53
|
+
onSortingChange={setSorting}
|
|
54
|
+
manualSorting={false}
|
|
55
|
+
selectable
|
|
56
|
+
storageKey="my-app.items.columns.v1"
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The example uses local sorting and uncontrolled checkbox selection. Pass both `rowSelection` and `onRowSelectionChange` for controlled selection. Controlled selection without a change callback is read-only. Keep column definitions and data references stable when possible.
|
|
63
|
+
|
|
64
|
+
### Sorting and pagination
|
|
65
|
+
|
|
66
|
+
`manualSorting` defaults to `true`: the table reports sort changes and renders rows in the order provided. Your caller can sort/filter/page locally or request data from any backend. The component makes no network calls, has no endpoint conventions, and does not treat a loaded page as the full dataset. Pagination controls belong to the caller. Use `manualSorting={false}` to let TanStack sort all supplied rows locally.
|
|
67
|
+
|
|
68
|
+
### Resizing
|
|
69
|
+
|
|
70
|
+
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.
|
|
71
|
+
|
|
72
|
+
`storageKey` enables persistence; without it no preferences are stored. Supply `storage` to use a compatible storage adapter. Malformed values, unknown column IDs and blocked browser storage fall back safely. Use stable column IDs or string accessor keys. Change the component's React `key` when switching schemas or persistence keys so initialization reads the new configuration.
|
|
73
|
+
|
|
74
|
+
`cancelMediaQuery` is optional: set it when your layout removes the table at a responsive breakpoint. A narrow table that remains visible can simply scroll horizontally.
|
|
75
|
+
|
|
76
|
+
### Custom layouts
|
|
77
|
+
|
|
78
|
+
Use `puppertinoTableFeatures`, `PuppertinoTableInstance`, `useColumnResize`, `ColumnResizeHandle`, and the sizing helpers with your own `useTable` instance. The features enable sorting, sizing and row selection. Subscribe to `table.atoms.columnSizing` around table geometry and keep the row body memoized. Optional hook settings let you choose persistence, a cancellation media query and a drag CSS class.
|
|
79
|
+
|
|
80
|
+
The package adds scoped `.pt-*` enhancements to Puppertino's `p-table` styles. Import Puppertino's CSS in your application, then the package stylesheet. Theme tokens remain owned by Puppertino and your application. Include Puppertino's dark-mode stylesheet when using its dark theme.
|
|
81
|
+
|
|
82
|
+
## Storybook and demo
|
|
83
|
+
|
|
84
|
+
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.
|
|
85
|
+
|
|
86
|
+
Use Node 20.19+ or 22.12+ and pnpm 11.25.0.
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
pnpm install
|
|
90
|
+
pnpm storybook # http://localhost:6006
|
|
91
|
+
pnpm dev # standalone 267-row fictional-data demo
|
|
92
|
+
pnpm build:storybook # static Storybook in storybook-static/
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Stories cover local sorting, host-owned order, range selection, keyboard resizing, dark mode, narrow layouts, empty/disabled/read-only states, grouped headers, and a different row schema with currency cells. Story controls expose selection, resizing, disabled state and sorting ownership.
|
|
96
|
+
|
|
97
|
+
## Verification
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
pnpm check
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Checks include TypeScript, preference/schema unit tests, library/demo/Storybook builds, and Chromium regression tests. The browser suite exercises 512 repeated pointer drags, width limits, reset/cancellation/persistence, selection, unmount/disabled recovery, standalone-control removal and grouped-header resizing, all 267 demo rows across six pages, six light/dark accessibility layouts, all twelve Storybook stories, and tarball installation with typed imports, server rendering and CSS exports in fresh React 18 and React 19 consumers. Set `PLAYWRIGHT_CHROMIUM_EXECUTABLE` to a system Chromium path, or install a browser with `pnpm exec playwright install chromium`.
|
|
104
|
+
|
|
105
|
+
GitHub Actions runs the same checks and uploads Storybook plus browser evidence as an artifact. Browser tests use fictional data and do not claim Safari, physical-device or latency coverage.
|
|
106
|
+
|
|
107
|
+
## npm releases
|
|
108
|
+
|
|
109
|
+
The unscoped **`puppertino-table`** package is public on npm. Stable releases use the guarded GitHub Release workflow described in [RELEASING.md](https://github.com/kahwee/puppertino-table/blob/main/RELEASING.md), including verified package preparation and clean registry consumers.
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
pnpm check
|
|
113
|
+
npm publish test-results/package/puppertino-table-0.1.1.tgz --dry-run --ignore-scripts --access public
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`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.
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT. Puppertino, React and TanStack Table remain independently maintained projects under their own licenses.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { PuppertinoTable, type PuppertinoTableProps } from './table.js';
|
|
2
|
+
export { createPuppertinoColumnHelper, puppertinoTableFeatures, type PuppertinoTableInstance, type PuppertinoColumnDef } from './model.js';
|
|
3
|
+
export { useColumnResize, type ColumnResizeOptions } from './use-column-resize.js';
|
|
4
|
+
export { ColumnResizeHandle } from './resize-handle.js';
|
|
5
|
+
export { readColumnSizing, columnSizeBounds, clampSize, type ColumnSizeBounds } from './sizing.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { PuppertinoTable } from './table.js';
|
|
2
|
+
export { createPuppertinoColumnHelper, puppertinoTableFeatures } from './model.js';
|
|
3
|
+
export { useColumnResize } from './use-column-resize.js';
|
|
4
|
+
export { ColumnResizeHandle } from './resize-handle.js';
|
|
5
|
+
export { readColumnSizing, columnSizeBounds, clampSize } from './sizing.js';
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { RowData } from '@tanstack/react-table';
|
|
2
|
+
import { type ReactTable, type SortingState, type RowSelectionState, type ColumnDef } from '@tanstack/react-table';
|
|
3
|
+
export declare const puppertinoTableFeatures: {
|
|
4
|
+
rowSortingFeature: import("@tanstack/react-table").TableFeature;
|
|
5
|
+
columnSizingFeature: import("@tanstack/react-table").TableFeature;
|
|
6
|
+
rowSelectionFeature: import("@tanstack/react-table").TableFeature;
|
|
7
|
+
sortedRowModel: (table: import("@tanstack/react-table").Table<any, any>) => () => import("@tanstack/react-table").RowModel<any, any>;
|
|
8
|
+
};
|
|
9
|
+
export type PuppertinoTableInstance<T extends RowData> = ReactTable<typeof puppertinoTableFeatures, T, {
|
|
10
|
+
sorting: SortingState;
|
|
11
|
+
rowSelection: RowSelectionState;
|
|
12
|
+
}>;
|
|
13
|
+
export type PuppertinoColumnDef<T extends RowData> = ColumnDef<typeof puppertinoTableFeatures, T>;
|
|
14
|
+
export declare function createPuppertinoColumnHelper<T extends RowData>(): import("@tanstack/react-table").ColumnHelper<{
|
|
15
|
+
rowSortingFeature: import("@tanstack/react-table").TableFeature;
|
|
16
|
+
columnSizingFeature: import("@tanstack/react-table").TableFeature;
|
|
17
|
+
rowSelectionFeature: import("@tanstack/react-table").TableFeature;
|
|
18
|
+
sortedRowModel: (table: import("@tanstack/react-table").Table<any, any>) => () => import("@tanstack/react-table").RowModel<any, any>;
|
|
19
|
+
}, T>;
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { createColumnHelper, createSortedRowModel, tableFeatures, rowSortingFeature, columnSizingFeature, rowSelectionFeature } from '@tanstack/react-table';
|
|
2
|
+
export const puppertinoTableFeatures = tableFeatures({ rowSortingFeature, columnSizingFeature, rowSelectionFeature, sortedRowModel: createSortedRowModel() });
|
|
3
|
+
export function createPuppertinoColumnHelper() { return createColumnHelper(); }
|
|
@@ -0,0 +1,11 @@
|
|
|
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 declare function ColumnResizeHandle<T extends RowData>({ table, header, label, options }: {
|
|
7
|
+
table: PuppertinoTableInstance<T>;
|
|
8
|
+
header: Header<typeof puppertinoTableFeatures, T>;
|
|
9
|
+
label: string;
|
|
10
|
+
options?: ColumnResizeOptions;
|
|
11
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useColumnResize } from './use-column-resize.js';
|
|
4
|
+
export function ColumnResizeHandle({ table, header, label, options }) {
|
|
5
|
+
const { handle } = useColumnResize(table, options);
|
|
6
|
+
if (options?.enabled === false || header.isPlaceholder || header.column.columns.length > 0)
|
|
7
|
+
return null;
|
|
8
|
+
return _jsx("span", { className: "pt-resizer", role: "separator", tabIndex: 0, "aria-orientation": "vertical", "aria-label": `Resize ${label} column`, "aria-valuemin": header.column.columnDef.minSize ?? 20, "aria-valuemax": header.column.columnDef.maxSize ?? 1200, "aria-valuenow": header.getSize(), "aria-valuetext": `${header.getSize()} pixels`, title: "Drag to resize. Double-click or Enter to reset. Arrow keys adjust width; Escape cancels a drag.", ...handle(header.column.id) });
|
|
9
|
+
}
|
package/dist/sizing.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ColumnSizingState } from '@tanstack/react-table';
|
|
2
|
+
export interface ColumnSizeBounds {
|
|
3
|
+
id: string;
|
|
4
|
+
size: number;
|
|
5
|
+
minSize: number;
|
|
6
|
+
maxSize: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function clampSize(value: unknown, bounds: ColumnSizeBounds): number;
|
|
9
|
+
/** Unknown/stale column IDs and nonnumeric preferences never enter table state. */
|
|
10
|
+
export declare function readColumnSizing(bounds: readonly ColumnSizeBounds[], key?: string, storage?: Pick<Storage, 'getItem'>): ColumnSizingState;
|
|
11
|
+
/** Stable IDs keep stored widths independent of translated header labels. */
|
|
12
|
+
interface SizingDefinition {
|
|
13
|
+
id?: string;
|
|
14
|
+
accessorKey?: unknown;
|
|
15
|
+
size?: number;
|
|
16
|
+
minSize?: number;
|
|
17
|
+
maxSize?: number;
|
|
18
|
+
columns?: readonly SizingDefinition[];
|
|
19
|
+
}
|
|
20
|
+
export declare function columnSizeBounds(columns: readonly SizingDefinition[]): ColumnSizeBounds[];
|
|
21
|
+
export {};
|
package/dist/sizing.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function clampSize(value, bounds) {
|
|
2
|
+
return typeof value === 'number' && Number.isFinite(value) ? Math.max(bounds.minSize, Math.min(bounds.maxSize, Math.round(value))) : bounds.size;
|
|
3
|
+
}
|
|
4
|
+
/** Unknown/stale column IDs and nonnumeric preferences never enter table state. */
|
|
5
|
+
export function readColumnSizing(bounds, key, storage) {
|
|
6
|
+
let saved = {};
|
|
7
|
+
try {
|
|
8
|
+
const value = key ? JSON.parse(storage?.getItem(key) || '{}') : {};
|
|
9
|
+
if (value && typeof value === 'object' && !Array.isArray(value))
|
|
10
|
+
saved = value;
|
|
11
|
+
}
|
|
12
|
+
catch { /* Unavailable or malformed storage uses defaults. */ }
|
|
13
|
+
return Object.fromEntries(bounds.map(bound => [bound.id, clampSize(saved[bound.id], bound)]));
|
|
14
|
+
}
|
|
15
|
+
export function columnSizeBounds(columns) {
|
|
16
|
+
const flatten = (defs) => defs.flatMap(column => column.columns ? flatten(column.columns) : [column]);
|
|
17
|
+
const leaves = flatten(columns);
|
|
18
|
+
const ids = new Set();
|
|
19
|
+
return leaves.map(column => {
|
|
20
|
+
const id = column.id ?? (typeof column.accessorKey === 'string' ? column.accessorKey.replaceAll('.', '_') : undefined);
|
|
21
|
+
if (!id || ids.has(id))
|
|
22
|
+
throw new Error('Every leaf column needs a unique stable id or accessorKey.');
|
|
23
|
+
ids.add(id);
|
|
24
|
+
const minSize = column.minSize ?? 20;
|
|
25
|
+
const maxSize = column.maxSize ?? 1200;
|
|
26
|
+
if (!Number.isFinite(minSize) || !Number.isFinite(maxSize) || minSize <= 0 || maxSize < minSize)
|
|
27
|
+
throw new Error(`Invalid width bounds for ${id}.`);
|
|
28
|
+
const size = clampSize(column.size ?? 150, { id, size: Math.max(minSize, Math.min(maxSize, 150)), minSize, maxSize });
|
|
29
|
+
return { id, size, minSize, maxSize };
|
|
30
|
+
});
|
|
31
|
+
}
|
package/dist/style.css
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* Scoped enhancements; import Puppertino's table/color/control styles separately. */
|
|
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); }
|
|
6
|
+
.pt-table th { position: relative; }
|
|
7
|
+
.pt-table th[aria-sort]::before { display: none; }
|
|
8
|
+
.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; }
|
|
12
|
+
.pt-sort-arrow[data-direction=asc] { transform: rotate(180deg); }
|
|
13
|
+
.pt-sort-arrow[data-direction=none] { visibility: hidden; }
|
|
14
|
+
.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-resizing, .pt-resizing * { cursor: col-resize !important; user-select: none !important; }
|
|
19
|
+
.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; } }
|
|
24
|
+
|
|
25
|
+
.pt-table .pt-selection { padding: 0; text-align: center; overflow: visible; }
|
|
26
|
+
.pt-table .pt-selection input { margin: 0; vertical-align: middle; }
|
package/dist/table.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { RowData } from '@tanstack/react-table';
|
|
2
|
+
import { type ReactNode } from 'react';
|
|
3
|
+
import { type SortingState, type RowSelectionState, type OnChangeFn } from '@tanstack/react-table';
|
|
4
|
+
import { type PuppertinoColumnDef } from './model.js';
|
|
5
|
+
export interface PuppertinoTableProps<T extends RowData> {
|
|
6
|
+
data: readonly T[];
|
|
7
|
+
columns: readonly PuppertinoColumnDef<T>[];
|
|
8
|
+
getRowId: (row: T, index: number) => string;
|
|
9
|
+
sorting: SortingState;
|
|
10
|
+
onSortingChange: OnChangeFn<SortingState>;
|
|
11
|
+
/** True for server-owned order/pagination; false enables local TanStack sorting. */
|
|
12
|
+
manualSorting?: boolean;
|
|
13
|
+
rowSelection?: RowSelectionState;
|
|
14
|
+
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
|
|
15
|
+
getRowLabel?: (row: T) => string;
|
|
16
|
+
selectable?: boolean;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
resizable?: boolean;
|
|
19
|
+
storageKey?: string;
|
|
20
|
+
storage?: Pick<Storage, 'getItem' | 'setItem'>;
|
|
21
|
+
cancelMediaQuery?: string;
|
|
22
|
+
ariaLabel: string;
|
|
23
|
+
caption?: ReactNode;
|
|
24
|
+
emptyContent?: ReactNode;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function PuppertinoTable<T extends RowData>({ data, columns, getRowId, sorting, onSortingChange, manualSorting, rowSelection: controlledSelection, onRowSelectionChange, getRowLabel, selectable, disabled, resizable, storageKey, storage, cancelMediaQuery, ariaLabel, caption, emptyContent, className }: PuppertinoTableProps<T>): import("react").JSX.Element;
|
package/dist/table.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { memo, useMemo, useState } from 'react';
|
|
4
|
+
import { useTable } from '@tanstack/react-table';
|
|
5
|
+
import { puppertinoTableFeatures } from './model.js';
|
|
6
|
+
import { columnSizeBounds, readColumnSizing } from './sizing.js';
|
|
7
|
+
import { useColumnResize } from './use-column-resize.js';
|
|
8
|
+
export function PuppertinoTable({ data, columns, getRowId, sorting, onSortingChange, manualSorting = true, rowSelection: controlledSelection, onRowSelectionChange, getRowLabel, selectable = false, disabled = false, resizable = true, storageKey, storage, cancelMediaQuery, ariaLabel, caption, emptyContent = 'No rows', className = '' }) {
|
|
9
|
+
const [localSelection, setLocalSelection] = useState({});
|
|
10
|
+
const rowSelection = controlledSelection ?? localSelection;
|
|
11
|
+
const canSelect = selectable && !disabled && (controlledSelection === undefined || !!onRowSelectionChange);
|
|
12
|
+
const bounds = useMemo(() => columnSizeBounds(columns), [columns]);
|
|
13
|
+
const sizedColumns = useMemo(() => {
|
|
14
|
+
const byId = new Map(bounds.map(bound => [bound.id, bound]));
|
|
15
|
+
const decorate = (defs) => defs.map(def => {
|
|
16
|
+
if ('columns' in def && def.columns)
|
|
17
|
+
return { ...def, columns: decorate(def.columns) };
|
|
18
|
+
const id = def.id ?? ('accessorKey' in def ? String(def.accessorKey).replaceAll('.', '_') : '');
|
|
19
|
+
return { ...def, ...byId.get(id) };
|
|
20
|
+
});
|
|
21
|
+
return decorate(columns);
|
|
22
|
+
}, [columns, bounds]);
|
|
23
|
+
const [initialWidths] = useState(() => {
|
|
24
|
+
try {
|
|
25
|
+
return readColumnSizing(bounds, storageKey, storage || localStorage);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return readColumnSizing(bounds);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
const table = useTable({ features: puppertinoTableFeatures, columns: sizedColumns, data, getRowId,
|
|
32
|
+
manualSorting, enableMultiSort: false, enableSortingRemoval: false, enableRowSelection: canSelect,
|
|
33
|
+
enableSubRowSelection: false, initialState: { columnSizing: initialWidths },
|
|
34
|
+
state: { sorting, rowSelection }, onSortingChange, onRowSelectionChange: onRowSelectionChange ?? setLocalSelection,
|
|
35
|
+
}, state => ({ sorting: state.sorting, rowSelection: state.rowSelection }));
|
|
36
|
+
const options = { enabled: resizable && !disabled, storageKey, storage, cancelMediaQuery };
|
|
37
|
+
const { handle } = useColumnResize(table, options);
|
|
38
|
+
const groups = table.getHeaderGroups();
|
|
39
|
+
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 => {
|
|
40
|
+
const column = header.column;
|
|
41
|
+
const label = typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
|
|
42
|
+
const sorted = column.getIsSorted();
|
|
43
|
+
const leaf = column.columns.length === 0;
|
|
44
|
+
return _jsxs("th", { colSpan: header.colSpan, scope: leaf ? 'col' : 'colgroup', "aria-sort": sorted ? sorted === 'asc' ? 'ascending' : 'descending' : undefined, children: [!header.isPlaceholder && (column.getCanSort() ? _jsxs("button", { type: "button", className: "pt-sort", "aria-label": `Sort by ${label}`, disabled: disabled, onClick: () => column.toggleSorting(), children: [_jsx(table.FlexRender, { header: header }), _jsx("span", { className: "pt-sort-arrow", "aria-hidden": "true", "data-direction": sorted || 'none', children: "\u2304" })] }) : _jsx(table.FlexRender, { header: header })), leaf && !header.isPlaceholder && resizable && !disabled && _jsx("span", { className: "pt-resizer", role: "separator", tabIndex: 0, "aria-orientation": "vertical", "aria-label": `Resize ${label} column`, "aria-valuemin": column.columnDef.minSize, "aria-valuemax": column.columnDef.maxSize, "aria-valuenow": header.getSize(), "aria-valuetext": `${header.getSize()} pixels`, title: "Drag to resize. Double-click or Enter to reset. Arrows adjust width; Escape cancels a drag.", ...handle(column.id) })] }, header.id);
|
|
45
|
+
})] }, group.id)) }), _jsx(MemoTableBody, { table: table, selectable: selectable, getRowLabel: getRowLabel, emptyContent: emptyContent })] }) }) });
|
|
46
|
+
}
|
|
47
|
+
/** Geometry updates do not rerender cells. State and data changes still do. */
|
|
48
|
+
function TableBody({ table, selectable, getRowLabel, emptyContent }) {
|
|
49
|
+
const rows = table.getRowModel().rows;
|
|
50
|
+
return _jsx("tbody", { children: rows.length ? rows.map(row => _jsxs("tr", { className: row.getIsSelected() ? 'p-selected' : undefined, children: [selectable && _jsx("td", { className: "pt-selection", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${getRowLabel?.(row.original) ?? row.id}`, checked: row.getIsSelected(), disabled: !row.getCanSelect(), onClick: event => { event.stopPropagation(); row.getToggleSelectedHandler()(event); }, onChange: () => { } }) }), row.getAllCells().map(cell => _jsx("td", { children: _jsx(table.FlexRender, { cell: cell }) }, cell.id))] }, row.id)) : _jsx("tr", { children: _jsx("td", { colSpan: table.getAllLeafColumns().length + (selectable ? 1 : 0), children: emptyContent }) }) });
|
|
51
|
+
}
|
|
52
|
+
const MemoTableBody = memo(TableBody);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RowData } from '@tanstack/react-table';
|
|
2
|
+
import { type PointerEvent, type KeyboardEvent } from 'react';
|
|
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
|
+
}
|
|
11
|
+
export declare function useColumnResize<T extends RowData>(table: PuppertinoTableInstance<T>, { enabled, storageKey, storage, cancelMediaQuery, dragClassName }?: ColumnResizeOptions): {
|
|
12
|
+
handle: (id: string) => {
|
|
13
|
+
onPointerDown: (event: PointerEvent<HTMLElement>) => void;
|
|
14
|
+
onPointerMove: (event: PointerEvent<HTMLElement>) => void;
|
|
15
|
+
onPointerUp: (event: PointerEvent<HTMLElement>) => void;
|
|
16
|
+
onPointerCancel: (event: PointerEvent<HTMLElement>) => void;
|
|
17
|
+
onLostPointerCapture: (event: PointerEvent<HTMLElement>) => void;
|
|
18
|
+
onDoubleClick: () => void;
|
|
19
|
+
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use client";
|
|
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
|
+
export function useColumnResize(table, { enabled = true, storageKey, storage, cancelMediaQuery, dragClassName = "pt-resizing" } = {}) {
|
|
14
|
+
const bounds = (id) => {
|
|
15
|
+
const column = table.getColumn(id);
|
|
16
|
+
return { id, size: column.columnDef.size ?? 150, minSize: column.columnDef.minSize ?? 20, maxSize: column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER };
|
|
17
|
+
};
|
|
18
|
+
const clamp = (id, value) => clampSize(value, bounds(id));
|
|
19
|
+
const snapshot = () => Object.fromEntries(table.getAllLeafColumns().map(column => [column.id, column.getSize()]));
|
|
20
|
+
const save = () => { try {
|
|
21
|
+
if (storageKey)
|
|
22
|
+
(storage || localStorage).setItem(storageKey, JSON.stringify(snapshot()));
|
|
23
|
+
}
|
|
24
|
+
catch { /* Resizing still works without storage. */ } };
|
|
25
|
+
const reset = (id) => { table.getColumn(id).resetSize(); save(); };
|
|
26
|
+
const drag = useRef(null);
|
|
27
|
+
const apply = (next, persist = false) => {
|
|
28
|
+
table.setColumnSizing(next);
|
|
29
|
+
if (persist)
|
|
30
|
+
save();
|
|
31
|
+
};
|
|
32
|
+
const finish = (cancel = false) => {
|
|
33
|
+
const active = drag.current;
|
|
34
|
+
if (!active)
|
|
35
|
+
return;
|
|
36
|
+
drag.current = null;
|
|
37
|
+
markDrag(dragClassName, -1);
|
|
38
|
+
if (active.target.hasPointerCapture(active.pointer))
|
|
39
|
+
active.target.releasePointerCapture(active.pointer);
|
|
40
|
+
apply(cancel ? active.before : snapshot(), !cancel);
|
|
41
|
+
};
|
|
42
|
+
useEffect(() => { if (!enabled)
|
|
43
|
+
finish(true); }, [enabled]);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const cancel = (event) => { if (event.key === 'Escape' && drag.current) {
|
|
46
|
+
event.preventDefault();
|
|
47
|
+
finish(true);
|
|
48
|
+
} };
|
|
49
|
+
const blur = () => finish(true);
|
|
50
|
+
const compact = cancelMediaQuery ? window.matchMedia(cancelMediaQuery) : undefined;
|
|
51
|
+
const modeChange = () => { if (compact?.matches)
|
|
52
|
+
finish(true); };
|
|
53
|
+
compact?.addEventListener("change", modeChange);
|
|
54
|
+
window.addEventListener('keydown', cancel);
|
|
55
|
+
window.addEventListener('blur', blur);
|
|
56
|
+
return () => {
|
|
57
|
+
compact?.removeEventListener("change", modeChange);
|
|
58
|
+
window.removeEventListener('keydown', cancel);
|
|
59
|
+
window.removeEventListener('blur', blur);
|
|
60
|
+
// Never save a gesture interrupted by route or display-mode changes.
|
|
61
|
+
const active = drag.current;
|
|
62
|
+
drag.current = null;
|
|
63
|
+
if (active)
|
|
64
|
+
markDrag(dragClassName, -1);
|
|
65
|
+
if (active?.target.hasPointerCapture(active.pointer))
|
|
66
|
+
active.target.releasePointerCapture(active.pointer);
|
|
67
|
+
if (active)
|
|
68
|
+
table.setColumnSizing(active.before);
|
|
69
|
+
};
|
|
70
|
+
}, [table.atoms.columnSizing, cancelMediaQuery, dragClassName]);
|
|
71
|
+
return { handle: (id) => ({
|
|
72
|
+
onPointerDown: (event) => {
|
|
73
|
+
if (!enabled || event.button !== 0 || drag.current)
|
|
74
|
+
return;
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
event.stopPropagation();
|
|
77
|
+
event.currentTarget.focus();
|
|
78
|
+
const before = snapshot();
|
|
79
|
+
drag.current = { id, x: event.clientX, width: before[id], before, target: event.currentTarget, pointer: event.pointerId };
|
|
80
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
81
|
+
markDrag(dragClassName, 1);
|
|
82
|
+
},
|
|
83
|
+
onPointerMove: (event) => {
|
|
84
|
+
const active = drag.current;
|
|
85
|
+
if (active && active.pointer === event.pointerId)
|
|
86
|
+
apply({ ...snapshot(), [active.id]: clamp(active.id, active.width + event.clientX - active.x) });
|
|
87
|
+
},
|
|
88
|
+
onPointerUp: (event) => { if (event.pointerId === drag.current?.pointer)
|
|
89
|
+
finish(!enabled || !!(cancelMediaQuery && window.matchMedia(cancelMediaQuery).matches)); },
|
|
90
|
+
onPointerCancel: (event) => { if (event.pointerId === drag.current?.pointer)
|
|
91
|
+
finish(true); },
|
|
92
|
+
onLostPointerCapture: (event) => { if (event.pointerId === drag.current?.pointer)
|
|
93
|
+
finish(true); },
|
|
94
|
+
onDoubleClick: () => { if (enabled)
|
|
95
|
+
reset(id); },
|
|
96
|
+
onKeyDown: (event) => {
|
|
97
|
+
if (!enabled || drag.current)
|
|
98
|
+
return;
|
|
99
|
+
if (event.key === "Enter") {
|
|
100
|
+
event.preventDefault();
|
|
101
|
+
event.stopPropagation();
|
|
102
|
+
reset(id);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const step = event.shiftKey ? 40 : 16;
|
|
106
|
+
const widths = snapshot();
|
|
107
|
+
const next = event.key === 'ArrowLeft' ? widths[id] - step : event.key === 'ArrowRight' ? widths[id] + step : event.key === 'Home' ? bounds(id).minSize : event.key === 'End' ? bounds(id).maxSize : undefined;
|
|
108
|
+
if (next === undefined)
|
|
109
|
+
return;
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
event.stopPropagation();
|
|
112
|
+
apply({ ...snapshot(), [id]: clamp(id, next) }, true);
|
|
113
|
+
},
|
|
114
|
+
}) };
|
|
115
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,85 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "puppertino-table",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public",
|
|
7
|
+
"registry": "https://registry.npmjs.org/"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"description": "Accessible Puppertino React tables backed by TanStack Table sizing, sorting and selection.",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/kahwee/puppertino-table.git"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./style.css": "./dist/style.css"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"CHANGELOG.md"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": [
|
|
30
|
+
"**/*.css"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"dev": "vite --host 127.0.0.1",
|
|
34
|
+
"build": "node scripts/build.mjs",
|
|
35
|
+
"build:demo": "vite build",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"test": "tsx --test tests/*.test.ts && node --test tests/release.test.mjs",
|
|
38
|
+
"test:browser": "tsx scripts/check-browser.ts",
|
|
39
|
+
"check": "pnpm typecheck && pnpm test && pnpm build && pnpm build:demo && pnpm build:storybook && pnpm test:browser && pnpm test:package",
|
|
40
|
+
"storybook": "storybook dev -p 6006 --no-open",
|
|
41
|
+
"build:storybook": "storybook build --disable-telemetry",
|
|
42
|
+
"prepack": "node scripts/build.mjs",
|
|
43
|
+
"test:package": "node scripts/check-package.mjs"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@codedgar/puppertino": "^2.0.0",
|
|
47
|
+
"@tanstack/react-table": "9.2.8",
|
|
48
|
+
"react": ">=18"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@codedgar/puppertino": "2.0.0",
|
|
52
|
+
"@storybook/addon-a11y": "10.6.1",
|
|
53
|
+
"@storybook/addon-docs": "10.6.1",
|
|
54
|
+
"@storybook/react-vite": "10.6.1",
|
|
55
|
+
"@tanstack/react-table": "9.2.8",
|
|
56
|
+
"@types/node": "^26.6.5",
|
|
57
|
+
"@types/react": "^19.3.0",
|
|
58
|
+
"@types/react-dom": "^19.3.0",
|
|
59
|
+
"axe-core": "4.14.0",
|
|
60
|
+
"playwright": "^1.64.0",
|
|
61
|
+
"react": "19.3.0",
|
|
62
|
+
"react-dom": "19.3.0",
|
|
63
|
+
"storybook": "10.6.1",
|
|
64
|
+
"tsx": "^4.23.15",
|
|
65
|
+
"typescript": "^7.0.2",
|
|
66
|
+
"vite": "^8.3.4"
|
|
67
|
+
},
|
|
68
|
+
"engines": {
|
|
69
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
70
|
+
},
|
|
71
|
+
"packageManager": "pnpm@11.25.0",
|
|
72
|
+
"homepage": "https://kahwee.github.io/puppertino-table/",
|
|
73
|
+
"bugs": {
|
|
74
|
+
"url": "https://github.com/kahwee/puppertino-table/issues"
|
|
75
|
+
},
|
|
76
|
+
"keywords": [
|
|
77
|
+
"react",
|
|
78
|
+
"table",
|
|
79
|
+
"tanstack",
|
|
80
|
+
"puppertino",
|
|
81
|
+
"accessible",
|
|
82
|
+
"resizable",
|
|
83
|
+
"typescript"
|
|
84
|
+
]
|
|
85
|
+
}
|