@pindoba/astro-table 0.0.0-alpha-20260426220647

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.
@@ -0,0 +1,514 @@
1
+ ---
2
+ import {
3
+ connectTable,
4
+ createTableOptions,
5
+ getDefaultTableState,
6
+ type TableBaseProps,
7
+ } from "@pindoba/core-table";
8
+ import {
9
+ createTable,
10
+ type ColumnDef,
11
+ type Table,
12
+ type RowData,
13
+ type Header,
14
+ } from "@tanstack/table-core";
15
+ import type { HTMLAttributes } from "astro/types";
16
+ import Button from "@pindoba/astro-button";
17
+ import Input from "@pindoba/astro-input";
18
+ import Select from "@pindoba/astro-select";
19
+ import { Icon } from "astro-icon/components";
20
+
21
+ type Props<TData extends RowData> = Omit<
22
+ HTMLAttributes<"div">,
23
+ "class" | "data"
24
+ > &
25
+ TableBaseProps<TData, HTMLAttributes<"div">> & {
26
+ class?: string;
27
+ };
28
+
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ const props = Astro.props as Props<any>;
31
+ const {
32
+ data,
33
+ columns,
34
+ table: externalTable,
35
+ caption,
36
+ emptyMessage = "No data",
37
+ enableSorting = true,
38
+ enableFiltering = true,
39
+ enablePagination = true,
40
+ enableRowSelection,
41
+ enableMultiRowSelection,
42
+ enableColumnVisibility,
43
+ enableExpanding,
44
+ enableGrouping,
45
+ enableColumnResizing,
46
+ pageSize = 10,
47
+ pageSizeOptions = [10, 25, 50, 100],
48
+ globalFilter,
49
+ globalFilterPlaceholder = "Search…",
50
+ manualPagination,
51
+ manualSorting,
52
+ manualFiltering,
53
+ rowCount,
54
+ getSubRows,
55
+ getRowId,
56
+ initialState,
57
+ showPagination,
58
+ showGlobalFilter = false,
59
+ state: _state,
60
+ onStateChange: _onStateChange,
61
+ class: className,
62
+ ...rest
63
+ } = props;
64
+
65
+ if (!externalTable && (!data || !columns)) {
66
+ throw new Error(
67
+ "[@pindoba/astro-table] Either `table` (headless instance) or both `data` and `columns` must be provided.",
68
+ );
69
+ }
70
+
71
+ const api = connectTable({ ...rest, class: className });
72
+
73
+ // Strip function headers/cells for serialization — mountTable can't rehydrate
74
+ // them. Consumers that need custom cells should write an inline <script> and
75
+ // call mountTable themselves with `cellRenderers`.
76
+ function serializableColumns(
77
+ cols: ColumnDef<unknown, unknown>[] | undefined,
78
+ ): ColumnDef<unknown, unknown>[] {
79
+ if (!cols) return [];
80
+ return cols.map((c) => {
81
+ const copy: Record<string, unknown> = {};
82
+ for (const [k, v] of Object.entries(c)) {
83
+ if (typeof v === "function") continue;
84
+ copy[k] = v;
85
+ }
86
+ return copy as unknown as ColumnDef<unknown, unknown>;
87
+ });
88
+ }
89
+
90
+ // `getSubRows` is a function and can't cross the SSR→client boundary via data
91
+ // attributes. Bake the hierarchy into `subRows` on each row so the client's
92
+ // default sub-row accessor (`row.subRows`) finds it without needing the fn.
93
+ function normalizeSubRows(
94
+ rows: unknown[],
95
+ getSubRows: (row: unknown) => unknown[] | undefined,
96
+ ): unknown[] {
97
+ return rows.map((row) => {
98
+ const subs = getSubRows(row);
99
+ if (!subs || subs.length === 0) return row;
100
+ return {
101
+ ...(row as Record<string, unknown>),
102
+ subRows: normalizeSubRows(subs, getSubRows),
103
+ };
104
+ });
105
+ }
106
+
107
+ const serializedColumns = serializableColumns(
108
+ (columns ?? []) as ColumnDef<unknown, unknown>[],
109
+ );
110
+ const serializedData = getSubRows
111
+ ? normalizeSubRows(
112
+ (data ?? []) as unknown[],
113
+ getSubRows as (row: unknown) => unknown[] | undefined,
114
+ )
115
+ : (data ?? []);
116
+ const serializedOptions = {
117
+ enableSorting,
118
+ enableFiltering,
119
+ enablePagination,
120
+ enableRowSelection:
121
+ typeof enableRowSelection === "function" ? true : enableRowSelection,
122
+ enableMultiRowSelection,
123
+ enableColumnVisibility,
124
+ enableExpanding,
125
+ enableGrouping,
126
+ enableColumnResizing,
127
+ pageSize,
128
+ pageSizeOptions,
129
+ globalFilter,
130
+ manualPagination,
131
+ manualSorting,
132
+ manualFiltering,
133
+ rowCount,
134
+ initialState,
135
+ };
136
+
137
+ // Variant props that affect slot classes (bordered, size, striped, …). These
138
+ // MUST survive the SSR→client boundary so mountTable's post-hydration render
139
+ // produces identical `td`/`tr`/… class strings — otherwise e.g. the bordered
140
+ // column dividers disappear on first state change.
141
+ const {
142
+ size,
143
+ density,
144
+ striped,
145
+ bordered,
146
+ stickyHeader,
147
+ background,
148
+ feedback,
149
+ accent,
150
+ translucent,
151
+ padding,
152
+ radius,
153
+ border,
154
+ shadow,
155
+ emphasis,
156
+ } = rest as Record<string, unknown>;
157
+ const serializedVariants: Record<string, unknown> = {};
158
+ for (const [k, v] of Object.entries({
159
+ size,
160
+ density,
161
+ striped,
162
+ bordered,
163
+ stickyHeader,
164
+ background,
165
+ feedback,
166
+ accent,
167
+ translucent,
168
+ padding,
169
+ radius,
170
+ border,
171
+ shadow,
172
+ emphasis,
173
+ })) {
174
+ if (v !== undefined) serializedVariants[k] = v;
175
+ }
176
+
177
+ // Build a transient TanStack instance to produce the initial SSR markup so
178
+ // consumers without JS still see the first page.
179
+ let table: Table<unknown>;
180
+ if (externalTable) {
181
+ table = externalTable as Table<unknown>;
182
+ } else {
183
+ const options = createTableOptions<unknown>({
184
+ data: data as unknown[],
185
+ columns: columns as ColumnDef<unknown, unknown>[],
186
+ enableSorting,
187
+ enableFiltering,
188
+ enablePagination,
189
+ enableRowSelection,
190
+ enableMultiRowSelection,
191
+ enableColumnVisibility,
192
+ enableExpanding,
193
+ enableGrouping,
194
+ enableColumnResizing,
195
+ pageSize,
196
+ globalFilter,
197
+ manualPagination,
198
+ manualSorting,
199
+ manualFiltering,
200
+ rowCount,
201
+ getSubRows,
202
+ getRowId,
203
+ initialState,
204
+ });
205
+ table = createTable<unknown>({
206
+ ...options,
207
+ state: getDefaultTableState(options),
208
+ onStateChange: () => {
209
+ /* no-op; mountTable owns state on the client */
210
+ },
211
+ renderFallbackValue: null,
212
+ });
213
+ }
214
+
215
+ const headerGroups = table.getHeaderGroups();
216
+ const rows = table.getRowModel().rows;
217
+
218
+ function renderHeader(h: Header<unknown, unknown>): string {
219
+ if (h.isPlaceholder) return "";
220
+ const def = h.column.columnDef.header;
221
+ if (typeof def === "string") return def;
222
+ if (typeof def === "function") {
223
+ try {
224
+ const result = def(h.getContext()) as unknown;
225
+ if (typeof result === "string" || typeof result === "number") {
226
+ return String(result);
227
+ }
228
+ } catch {
229
+ /* fallthrough */
230
+ }
231
+ }
232
+ return h.column.id;
233
+ }
234
+
235
+ function renderCell(value: unknown): string {
236
+ if (value == null) return "";
237
+ if (typeof value === "string" || typeof value === "number")
238
+ return String(value);
239
+ return "";
240
+ }
241
+
242
+ const showPaginationFooter =
243
+ enablePagination && (showPagination ?? true) && !externalTable;
244
+ const paginationState = table.getState().pagination;
245
+ const pageCount = table.getPageCount();
246
+ const hydrate = !externalTable;
247
+ ---
248
+
249
+ <div
250
+ {...api.rootProps}
251
+ data-table-rows={hydrate ? JSON.stringify(serializedData) : undefined}
252
+ data-table-columns={hydrate ? JSON.stringify(serializedColumns) : undefined}
253
+ data-table-options={hydrate ? JSON.stringify(serializedOptions) : undefined}
254
+ data-table-variants={hydrate ? JSON.stringify(serializedVariants) : undefined}
255
+ data-table-hydrate={hydrate ? "true" : undefined}
256
+ >
257
+ {
258
+ showGlobalFilter && (
259
+ <div class={api.classes.toolbar}>
260
+ <Input
261
+ type="search"
262
+ size="sm"
263
+ fullWidth
264
+ placeholder={globalFilterPlaceholder}
265
+ aria-label="Filter rows"
266
+ passThrough={{
267
+ input: { props: { "data-table-global-filter": "" } },
268
+ }}
269
+ />
270
+ </div>
271
+ )
272
+ }
273
+ {
274
+ /* Consumer-provided toolbar. Lives inside the table root so mountTable's
275
+ delegated change/click handlers (e.g. `[data-table-column-toggle]`)
276
+ wire it up automatically. */
277
+ Astro.slots.has("toolbar") && (
278
+ <div class={api.classes.toolbar}>
279
+ <slot name="toolbar" />
280
+ </div>
281
+ )
282
+ }
283
+
284
+ <table class={api.classes.table}>
285
+ {caption && <caption class={api.classes.caption}>{caption}</caption>}
286
+ <thead class={api.classes.thead}>
287
+ {
288
+ headerGroups.map((hg) => (
289
+ <tr class={api.classes.tr}>
290
+ {hg.headers.map((header) => {
291
+ const sortDir = header.column.getIsSorted();
292
+ const canSort =
293
+ !header.isPlaceholder && header.column.getCanSort();
294
+ const columnId = header.column.id;
295
+ return (
296
+ <th
297
+ class={api.classes.th}
298
+ colspan={header.colSpan}
299
+ data-table-header={columnId}
300
+ aria-sort={
301
+ sortDir === "asc"
302
+ ? "ascending"
303
+ : sortDir === "desc"
304
+ ? "descending"
305
+ : canSort
306
+ ? "none"
307
+ : undefined
308
+ }
309
+ >
310
+ {header.isPlaceholder ? null : canSort ? (
311
+ <button
312
+ type="button"
313
+ class={api.classes.sortButton}
314
+ data-sort={sortDir || "none"}
315
+ data-table-sort={columnId}
316
+ data-table-sort-button
317
+ >
318
+ <span>{renderHeader(header)}</span>
319
+ <Icon
320
+ name="lucide:chevrons-up-down"
321
+ data-sort-icon="unsorted"
322
+ aria-hidden="true"
323
+ />
324
+ <Icon
325
+ name="lucide:chevron-up"
326
+ data-sort-icon="asc"
327
+ aria-hidden="true"
328
+ />
329
+ <Icon
330
+ name="lucide:chevron-down"
331
+ data-sort-icon="desc"
332
+ aria-hidden="true"
333
+ />
334
+ </button>
335
+ ) : (
336
+ renderHeader(header)
337
+ )}
338
+ </th>
339
+ );
340
+ })}
341
+ </tr>
342
+ ))
343
+ }
344
+ </thead>
345
+ <tbody class={api.classes.tbody} data-table-tbody>
346
+ {
347
+ rows.length === 0 ? (
348
+ <tr class={api.classes.tr}>
349
+ <td
350
+ class={api.classes.empty}
351
+ colspan={headerGroups[0]?.headers.length ?? 1}
352
+ >
353
+ {emptyMessage}
354
+ </td>
355
+ </tr>
356
+ ) : (
357
+ rows.map((row) => (
358
+ <tr class={api.classes.tr}>
359
+ {row.getVisibleCells().map((cell) => (
360
+ <td class={api.classes.td}>{renderCell(cell.getValue())}</td>
361
+ ))}
362
+ </tr>
363
+ ))
364
+ )
365
+ }
366
+ </tbody>
367
+ </table>
368
+ {
369
+ showPaginationFooter && (
370
+ <div class={api.classes.pagination}>
371
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
372
+ <Button
373
+ type="button"
374
+ size="sm"
375
+ emphasis="ghost"
376
+ shape="square"
377
+ aria-label="First page"
378
+ passThrough={{ root: { props: { "data-table-page-first": "" } } }}
379
+ >
380
+ «
381
+ </Button>
382
+ <Button
383
+ type="button"
384
+ size="sm"
385
+ emphasis="ghost"
386
+ aria-label="Previous page"
387
+ passThrough={{ root: { props: { "data-table-page-prev": "" } } }}
388
+ >
389
+ ‹ Prev
390
+ </Button>
391
+ <Button
392
+ type="button"
393
+ size="sm"
394
+ emphasis="ghost"
395
+ aria-label="Next page"
396
+ passThrough={{ root: { props: { "data-table-page-next": "" } } }}
397
+ >
398
+ Next ›
399
+ </Button>
400
+ <Button
401
+ type="button"
402
+ size="sm"
403
+ emphasis="ghost"
404
+ shape="square"
405
+ aria-label="Last page"
406
+ passThrough={{ root: { props: { "data-table-page-last": "" } } }}
407
+ >
408
+ »
409
+ </Button>
410
+ </div>
411
+ <span class={api.classes.pageInfo} data-table-page-info>
412
+ Page {paginationState.pageIndex + 1} of {Math.max(pageCount, 1)}
413
+ </span>
414
+ <label style="display: flex; align-items: center; gap: 0.5rem;">
415
+ <span>Rows per page</span>
416
+ <Select
417
+ size="sm"
418
+ value={String(paginationState.pageSize)}
419
+ items={pageSizeOptions.map((opt) => ({
420
+ id: `page-size-${opt}`,
421
+ label: String(opt),
422
+ value: String(opt),
423
+ }))}
424
+ passThrough={{ select: { props: { "data-table-page-size": "" } } }}
425
+ />
426
+ </label>
427
+ </div>
428
+ )
429
+ }
430
+ {
431
+ /* Default slot — consumers drop `<template data-table-cell-template="X">`
432
+ and `<template data-table-header-template="X">` elements here. Templates
433
+ are inert in the DOM (cost nothing for non-JS clients, the SSR rows
434
+ above stay visible) and mountTable picks them up by querying for
435
+ `data-table-cell-template` / `data-table-header-template` inside the
436
+ root, so column-specific naming doesn't need an Astro dynamic slot. */
437
+ }
438
+ <slot />
439
+ {
440
+ /* Built-in grouped-row expand template. Rendered with the real `<Button>`
441
+ component so mountTable can clone its DOM per grouped row instead of
442
+ hand-rolling markup that has to stay in sync with button styles. */
443
+ enableGrouping && (
444
+ <template data-table-group-expand>
445
+ <span data-table-group-expand-row class={api.classes.groupExpandRow}>
446
+ <span
447
+ data-table-group-expand-rotator
448
+ class={api.classes.groupExpandRotator}
449
+ >
450
+ <Button
451
+ type="button"
452
+ size="xs"
453
+ emphasis="ghost"
454
+ shape="square"
455
+ aria-label="Toggle"
456
+ passThrough={{ root: { props: { "data-table-expand": "" } } }}
457
+ >
458
+ <svg
459
+ xmlns="http://www.w3.org/2000/svg"
460
+ width="16"
461
+ height="16"
462
+ viewBox="0 0 24 24"
463
+ fill="none"
464
+ stroke="currentColor"
465
+ stroke-width="2"
466
+ stroke-linecap="round"
467
+ stroke-linejoin="round"
468
+ aria-hidden="true"
469
+ >
470
+ <path d="m9 18 6-6-6-6" />
471
+ </svg>
472
+ </Button>
473
+ </span>
474
+ <span data-table-group-expand-label />
475
+ </span>
476
+ </template>
477
+ )
478
+ }
479
+ </div>
480
+
481
+ <script>
482
+ import { mountTable } from "@pindoba/core-table";
483
+
484
+ function boot() {
485
+ const roots = document.querySelectorAll<HTMLElement>(
486
+ '[data-component="table"][data-table-hydrate]:not([data-table-mounted])',
487
+ );
488
+ for (const root of Array.from(roots)) {
489
+ root.setAttribute("data-table-mounted", "true");
490
+ let rows: unknown[];
491
+ let columns: unknown[];
492
+ let opts: Record<string, unknown>;
493
+ try {
494
+ rows = JSON.parse(root.getAttribute("data-table-rows") ?? "[]");
495
+ columns = JSON.parse(root.getAttribute("data-table-columns") ?? "[]");
496
+ opts = JSON.parse(root.getAttribute("data-table-options") ?? "{}");
497
+ } catch {
498
+ continue;
499
+ }
500
+ const mount = mountTable(root, {
501
+ data: rows as Record<string, unknown>[],
502
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
503
+ columns: columns as any,
504
+ ...opts,
505
+ });
506
+ const teardown = () => mount.destroy();
507
+ window.addEventListener("pagehide", teardown, { once: true });
508
+ document.addEventListener("astro:before-swap", teardown, { once: true });
509
+ }
510
+ }
511
+
512
+ boot();
513
+ document.addEventListener("astro:page-load", boot);
514
+ </script>