@viniaraujo68/plinth 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +361 -2
  3. package/dist/anchoring.d.ts +61 -0
  4. package/dist/anchoring.js +151 -0
  5. package/dist/async-action.svelte.d.ts +37 -0
  6. package/dist/async-action.svelte.js +64 -0
  7. package/dist/attachments/index.d.ts +2 -0
  8. package/dist/attachments/index.js +2 -0
  9. package/dist/attachments/sortable.d.ts +6 -0
  10. package/dist/attachments/sortable.js +227 -0
  11. package/dist/attachments/tooltip.d.ts +37 -0
  12. package/dist/attachments/tooltip.js +166 -0
  13. package/dist/components/AsyncButton.svelte +49 -0
  14. package/dist/components/AsyncButton.svelte.d.ts +32 -0
  15. package/dist/components/Combobox.svelte +380 -0
  16. package/dist/components/Combobox.svelte.d.ts +74 -0
  17. package/dist/components/Copyable.svelte +116 -0
  18. package/dist/components/Copyable.svelte.d.ts +37 -0
  19. package/dist/components/DateRangePicker.svelte +266 -0
  20. package/dist/components/DateRangePicker.svelte.d.ts +66 -0
  21. package/dist/components/Dialog.svelte +102 -0
  22. package/dist/components/Dialog.svelte.d.ts +38 -0
  23. package/dist/components/DragHandle.svelte +26 -0
  24. package/dist/components/DragHandle.svelte.d.ts +7 -0
  25. package/dist/components/Dropdown.svelte +204 -0
  26. package/dist/components/Dropdown.svelte.d.ts +49 -0
  27. package/dist/components/ErrorDisplay.svelte +59 -0
  28. package/dist/components/ErrorDisplay.svelte.d.ts +24 -0
  29. package/dist/components/ErrorPage.svelte +119 -0
  30. package/dist/components/ErrorPage.svelte.d.ts +52 -0
  31. package/dist/components/JsonInput.svelte +106 -0
  32. package/dist/components/JsonInput.svelte.d.ts +40 -0
  33. package/dist/components/Listbox.svelte +257 -0
  34. package/dist/components/Listbox.svelte.d.ts +78 -0
  35. package/dist/components/LoadingButton.svelte +36 -0
  36. package/dist/components/LoadingButton.svelte.d.ts +22 -0
  37. package/dist/components/Logo.svelte +112 -0
  38. package/dist/components/Logo.svelte.d.ts +29 -0
  39. package/dist/components/Modal.svelte +187 -0
  40. package/dist/components/Modal.svelte.d.ts +62 -0
  41. package/dist/components/MultiSelect.svelte +256 -0
  42. package/dist/components/MultiSelect.svelte.d.ts +89 -0
  43. package/dist/components/OptionPanel.svelte +241 -0
  44. package/dist/components/OptionPanel.svelte.d.ts +51 -0
  45. package/dist/components/Paginator.svelte +205 -0
  46. package/dist/components/Paginator.svelte.d.ts +48 -0
  47. package/dist/components/RefreshButton.svelte +246 -0
  48. package/dist/components/RefreshButton.svelte.d.ts +46 -0
  49. package/dist/components/SegmentedControl.svelte +147 -0
  50. package/dist/components/SegmentedControl.svelte.d.ts +71 -0
  51. package/dist/components/Select.svelte +312 -0
  52. package/dist/components/Select.svelte.d.ts +68 -0
  53. package/dist/components/Skeleton.svelte +97 -0
  54. package/dist/components/Skeleton.svelte.d.ts +32 -0
  55. package/dist/components/Tooltip.svelte +60 -0
  56. package/dist/components/Tooltip.svelte.d.ts +58 -0
  57. package/dist/components/date-range.d.ts +101 -0
  58. package/dist/components/date-range.js +144 -0
  59. package/dist/components/index.d.ts +26 -0
  60. package/dist/components/index.js +38 -0
  61. package/dist/components/json-text.d.ts +22 -0
  62. package/dist/components/json-text.js +22 -0
  63. package/dist/components/pick.d.ts +18 -0
  64. package/dist/components/pick.js +18 -0
  65. package/dist/components/segmented.d.ts +7 -0
  66. package/dist/components/segmented.js +1 -0
  67. package/dist/components/select.d.ts +66 -0
  68. package/dist/components/select.js +90 -0
  69. package/dist/confirm/Confirmer.svelte +194 -0
  70. package/dist/confirm/Confirmer.svelte.d.ts +45 -0
  71. package/dist/confirm/challenge.d.ts +16 -0
  72. package/dist/confirm/challenge.js +16 -0
  73. package/dist/confirm/confirm-manager.svelte.d.ts +91 -0
  74. package/dist/confirm/confirm-manager.svelte.js +149 -0
  75. package/dist/confirm/index.d.ts +3 -0
  76. package/dist/confirm/index.js +3 -0
  77. package/dist/error.d.ts +25 -0
  78. package/dist/error.js +39 -0
  79. package/dist/formatters.d.ts +95 -0
  80. package/dist/formatters.js +146 -0
  81. package/dist/http.d.ts +68 -0
  82. package/dist/http.js +138 -0
  83. package/dist/index.d.ts +10 -0
  84. package/dist/index.js +14 -0
  85. package/dist/routing.svelte.d.ts +169 -0
  86. package/dist/routing.svelte.js +208 -0
  87. package/dist/shell/AppShell.svelte +874 -0
  88. package/dist/shell/AppShell.svelte.d.ts +93 -0
  89. package/dist/shell/Breadcrumbs.svelte +78 -0
  90. package/dist/shell/Breadcrumbs.svelte.d.ts +25 -0
  91. package/dist/shell/crumbs.d.ts +23 -0
  92. package/dist/shell/crumbs.js +1 -0
  93. package/dist/shell/index.d.ts +3 -0
  94. package/dist/shell/index.js +6 -0
  95. package/dist/table/ColumnMenu.svelte +249 -0
  96. package/dist/table/ColumnMenu.svelte.d.ts +47 -0
  97. package/dist/table/DataTable.svelte +643 -0
  98. package/dist/table/DataTable.svelte.d.ts +153 -0
  99. package/dist/table/columns.d.ts +55 -0
  100. package/dist/table/columns.js +153 -0
  101. package/dist/table/index.d.ts +4 -0
  102. package/dist/table/index.js +8 -0
  103. package/dist/table/sort.d.ts +48 -0
  104. package/dist/table/sort.js +139 -0
  105. package/dist/table/types.d.ts +93 -0
  106. package/dist/table/types.js +1 -0
  107. package/dist/theme/ThemeController.svelte +54 -0
  108. package/dist/theme/ThemeController.svelte.d.ts +27 -0
  109. package/dist/theme/ThemeToggle.svelte +119 -0
  110. package/dist/theme/ThemeToggle.svelte.d.ts +52 -0
  111. package/dist/theme/css-color.d.ts +23 -0
  112. package/dist/theme/css-color.js +31 -0
  113. package/dist/theme/index.d.ts +4 -0
  114. package/dist/theme/index.js +5 -0
  115. package/dist/theme/theme.css +410 -0
  116. package/dist/theme/theme.svelte.d.ts +54 -0
  117. package/dist/theme/theme.svelte.js +58 -0
  118. package/dist/toast/Toaster.svelte +257 -0
  119. package/dist/toast/Toaster.svelte.d.ts +35 -0
  120. package/dist/toast/index.d.ts +2 -0
  121. package/dist/toast/index.js +2 -0
  122. package/dist/toast/toast-manager.svelte.d.ts +99 -0
  123. package/dist/toast/toast-manager.svelte.js +176 -0
  124. package/dist/user/ScopedComponent.svelte +47 -0
  125. package/dist/user/ScopedComponent.svelte.d.ts +29 -0
  126. package/dist/user/anonymous.d.ts +21 -0
  127. package/dist/user/anonymous.js +26 -0
  128. package/dist/user/context.d.ts +55 -0
  129. package/dist/user/context.js +13 -0
  130. package/dist/user/index.d.ts +3 -0
  131. package/dist/user/index.js +6 -0
  132. package/package.json +133 -4
@@ -0,0 +1,643 @@
1
+ <script lang="ts" generics="T">
2
+ import type { Snippet } from "svelte";
3
+ import type { ClassValue } from "svelte/elements";
4
+ import ColumnMenu from "./ColumnMenu.svelte";
5
+ import {
6
+ columnWidth,
7
+ isColumnResizable,
8
+ minWidthOf,
9
+ readColumnState,
10
+ setColumnWidth,
11
+ visibleColumns,
12
+ writeColumnState,
13
+ } from "./columns.js";
14
+ import { ariaSort, cellText, nextSort, sortRows } from "./sort.js";
15
+ import type { Column, ColumnState, SortState } from "./types.js";
16
+
17
+ interface Props {
18
+ rows: readonly T[];
19
+ columns: Column<T>[];
20
+ /** Identity of a row, so re-sorting moves the existing markup instead of rebuilding it. */
21
+ rowKey: (row: T) => string | number;
22
+ /**
23
+ * The order the table is in. Bindable and defaulted, so it is uncontrolled when passed as a
24
+ * plain value — an initial sort — and controlled the moment a caller writes `bind:sort`.
25
+ */
26
+ sort?: SortState;
27
+ /** Collation locale for string columns. Defaults to the runtime's. */
28
+ locale?: string;
29
+ onRowClick?: (row: T) => void;
30
+ /** Shown in place of the rows when there are none. */
31
+ empty?: Snippet;
32
+ /**
33
+ * Replaces the label/value pairs in card form, for rows that read better as a shape than as a
34
+ * list. Never rendered in table form, so a cell snippet and this one do not compete.
35
+ *
36
+ * The second argument is the row's 0-based position in the order currently rendered, so a
37
+ * rank or a medal can follow the sort without the caller re-deriving it. A snippet declared
38
+ * with one parameter stays assignable and simply ignores it.
39
+ */
40
+ card?: Snippet<[T, number]>;
41
+ /** Accessible name of the table. A prop because the library ships no translations. */
42
+ label?: string;
43
+ /**
44
+ * Accessible name for a sortable header's control, given the column — `Sort by Elevation`
45
+ * rather than `Elevation`. Absent by default, which leaves the button named by its own label.
46
+ * A function, and not a string, because the wording is per column and the library ships no
47
+ * translations.
48
+ */
49
+ sortLabel?: (column: Column<T>) => string;
50
+ /**
51
+ * Goes on the `<table>` itself, next to daisyUI's `table`. Density and zebra striping are
52
+ * `table-sm` and `table-zebra` — classes the framework already has, so they are not re-exposed
53
+ * here as props that could only spell them differently.
54
+ */
55
+ class?: ClassValue;
56
+ /**
57
+ * Rendered in a bar above the table, at its start — the place for the controls that change
58
+ * what the table shows, so they sit with it instead of floating somewhere above it. The
59
+ * columns menu, when there is one, takes the other end of the same bar.
60
+ */
61
+ toolbar?: Snippet;
62
+ /**
63
+ * How the reader arranged the columns: order, visibility and dragged widths. Bindable and
64
+ * defaulted like `sort`: leave it out and the table keeps the arrangement to itself, bind it
65
+ * to read it back or to share it between tables. `undefined` means "as the columns declare".
66
+ */
67
+ columnState?: ColumnState;
68
+ /**
69
+ * Remembers `columnState` in `localStorage` under this key, so the reader's arrangement
70
+ * survives a reload. The stored state is read after the first render, not during it: a table
71
+ * rendered on the server has no storage to read, and changing the markup before hydration
72
+ * finished would tear it — so a stored layout lands one frame late instead. A stored state
73
+ * takes precedence over the `columnState` a parent passes in, since it is the reader's own
74
+ * later choice.
75
+ */
76
+ storageKey?: string | null;
77
+ /**
78
+ * Lets the reader drag a header's right edge to resize its column, double-click the edge to
79
+ * hand the column back to its content, or focus the edge and use the arrow keys. Columns opt
80
+ * out one by one with `resizable: false`; widths only apply to the table form, never to cards.
81
+ */
82
+ resizable?: boolean;
83
+ /**
84
+ * Turns on the columns menu, as the text of its trigger. The menu shows and hides columns and
85
+ * puts them in another order; a prop rather than a default because the library ships no
86
+ * translations, and the menu has nothing to be called without it.
87
+ */
88
+ columnsLabel?: string;
89
+ /** The menu's action that forgets the reader's arrangement, widths included. */
90
+ resetColumnsLabel?: string;
91
+ /** Names the menu's step buttons for a screen reader — "Move Year up" rather than "↑". */
92
+ moveColumnLabel?: (column: Column<T>, direction: "up" | "down") => string;
93
+ /** Names a column's resize handle for a screen reader. */
94
+ resizeLabel?: (column: Column<T>) => string;
95
+ }
96
+
97
+ let {
98
+ rows,
99
+ columns,
100
+ rowKey,
101
+ sort = $bindable(),
102
+ locale,
103
+ onRowClick,
104
+ empty,
105
+ card,
106
+ label,
107
+ sortLabel,
108
+ class: className,
109
+ toolbar,
110
+ columnState = $bindable(),
111
+ storageKey = null,
112
+ resizable = false,
113
+ columnsLabel,
114
+ resetColumnsLabel = "Reset columns",
115
+ moveColumnLabel = (column, direction) => `Move ${column.label} ${direction}`,
116
+ resizeLabel = (column) => `Resize ${column.label}`,
117
+ }: Props = $props();
118
+
119
+ const sorted = $derived.by(() => {
120
+ const column = columns.find((candidate) => candidate.key === sort?.key);
121
+ if (!sort || !column) return rows;
122
+ return sortRows(rows, column, sort.direction, locale);
123
+ });
124
+
125
+ const shown = $derived(visibleColumns(columns, columnState));
126
+ const sized = $derived(shown.some((column) => columnWidth(column, columnState) !== undefined));
127
+ const hasBar = $derived(toolbar !== undefined || columnsLabel !== undefined);
128
+
129
+ let restored = $state(false);
130
+
131
+ $effect(() => {
132
+ if (!storageKey) return;
133
+ const stored = readColumnState(storageKey);
134
+ if (stored) columnState = stored;
135
+ restored = true;
136
+ });
137
+
138
+ $effect(() => {
139
+ if (!storageKey || !restored) return;
140
+ writeColumnState(storageKey, columnState);
141
+ });
142
+
143
+ /** The box a width applies to: the header's content, not its padding. */
144
+ const contentWidth = (header: HTMLElement) => {
145
+ const style = getComputedStyle(header);
146
+ return header.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
147
+ };
148
+
149
+ const resizeTo = (column: Column<T>, width: number | null) => {
150
+ columnState = setColumnWidth(
151
+ columnState,
152
+ column.key,
153
+ width === null ? null : Math.max(width, minWidthOf(column)),
154
+ );
155
+ };
156
+
157
+ /* Pointer capture keeps the moves coming to the handle while the pointer wanders over other
158
+ headers, off the table, or outside the window; without it a fast drag drops the column the
159
+ moment the pointer overtakes the edge. */
160
+ const startResize = (event: PointerEvent, column: Column<T>) => {
161
+ if (event.button !== 0) return;
162
+ const handle = event.currentTarget as HTMLElement;
163
+ const header = handle.closest("th");
164
+ if (!header) return;
165
+ event.preventDefault();
166
+
167
+ const origin = event.clientX;
168
+ const start = contentWidth(header);
169
+ try {
170
+ handle.setPointerCapture(event.pointerId);
171
+ } catch {
172
+ // A pointer the browser no longer tracks cannot be captured; the drag still works for as
173
+ // long as the pointer stays over the handle.
174
+ }
175
+
176
+ const onMove = (move: PointerEvent) => resizeTo(column, start + move.clientX - origin);
177
+ const onEnd = () => {
178
+ handle.removeEventListener("pointermove", onMove);
179
+ handle.removeEventListener("pointerup", onEnd);
180
+ handle.removeEventListener("pointercancel", onEnd);
181
+ };
182
+ handle.addEventListener("pointermove", onMove);
183
+ handle.addEventListener("pointerup", onEnd);
184
+ handle.addEventListener("pointercancel", onEnd);
185
+ };
186
+
187
+ const RESIZE_STEP = 16;
188
+ const RESIZE_STEP_LARGE = 64;
189
+
190
+ const onResizeKey = (event: KeyboardEvent, column: Column<T>) => {
191
+ const header = (event.currentTarget as HTMLElement).closest("th");
192
+ if (!header) return;
193
+ const step = event.shiftKey ? RESIZE_STEP_LARGE : RESIZE_STEP;
194
+ const current = columnWidth(column, columnState) ?? contentWidth(header);
195
+
196
+ if (event.key === "ArrowLeft") resizeTo(column, current - step);
197
+ else if (event.key === "ArrowRight") resizeTo(column, current + step);
198
+ else if (event.key === "Home") resizeTo(column, null);
199
+ else return;
200
+ event.preventDefault();
201
+ };
202
+
203
+ const isSortable = (column: Column<T>) => column.sortable !== false;
204
+ const alignOf = (column: Column<T>) => column.align ?? (column.numeric ? "right" : "left");
205
+
206
+ const activate = (row: T) => onRowClick?.(row);
207
+
208
+ const onRowKey = (event: KeyboardEvent, row: T) => {
209
+ if (event.key !== "Enter" && event.key !== " ") return;
210
+ event.preventDefault();
211
+ activate(row);
212
+ };
213
+ </script>
214
+
215
+ <!--
216
+ @component
217
+ A sortable table that becomes a list of cards on a narrow screen. Columns are described once, as
218
+ data; the rows are ordered in the browser.
219
+
220
+ Which form renders is decided by a **container query on the table's own wrapper**, not by
221
+ JavaScript. There is no resize listener and no `matchMedia`, so nothing flashes the wrong layout on
222
+ the first paint — and because the wrapper is its own container, dropping the table into a narrow
223
+ box renders its card form on a desktop, which is how the showcase shows both at once.
224
+
225
+ It is one DOM either way. In card form the header row collapses into a strip of sort pills, each
226
+ cell prints its column's label beside its value, and no row is duplicated to be hidden.
227
+
228
+ Sorting is client-side and stable, so a second sort reads as a tiebreak on the first. Missing
229
+ values sort last in both directions. `sort` is bindable: pass a value for an initial order, bind
230
+ for a shared or persisted one.
231
+
232
+ A `cell` or `card` snippet is rendered with the row and, second, the row's 0-based position in the
233
+ order on screen — the rank column that has to follow the sort is the reason it is there, and
234
+ re-running `sortRows` outside the table is what it replaces. Snippets that take only the row keep
235
+ working unchanged.
236
+
237
+ `sortLabel` names the sort control for a screen reader, from the column — "Sort by Elevation"
238
+ rather than "Elevation" — on the header buttons and on the card-form pills alike, since they are
239
+ the same element. Left out, the button is named by its own label.
240
+
241
+ The reader can arrange the columns, when the table asks for it. `columnsLabel` adds a menu that
242
+ shows, hides and reorders them; `resizable` adds a drag handle to every header's edge; `storageKey`
243
+ remembers both across reloads. All three are off by default, and a column says whether it takes
244
+ part — `hideable`, `hiddenByDefault`, `resizable`, `minWidth`. The arrangement is one bindable
245
+ `columnState`, stored as overrides of what the columns declare, so adding a column in a later
246
+ release neither loses a stored layout nor ends up somewhere nobody put it. Sorting by a column the
247
+ reader hid keeps working: the order is about the rows, not about what is on screen.
248
+
249
+ It stays short of a data grid — no filtering, no virtualization, no saved views. A table that
250
+ needs those needs a different component.
251
+
252
+ ```svelte
253
+ <DataTable
254
+ {rows}
255
+ columns={[
256
+ { key: "name", label: "Name" },
257
+ { key: "size", label: "Size", numeric: true },
258
+ ]}
259
+ rowKey={(row) => row.id}
260
+ sort={{ key: "size", direction: "desc" }}
261
+ />
262
+ ```
263
+ -->
264
+
265
+ {#snippet headerBody(column: Column<T>)}
266
+ {#if isSortable(column)}
267
+ <button
268
+ type="button"
269
+ class="plinth-sort"
270
+ aria-label={sortLabel?.(column)}
271
+ onclick={() => (sort = nextSort(sort, column))}
272
+ >
273
+ {column.label}
274
+ <!-- The glyph is decoration; `aria-sort` on the header is what carries the state. -->
275
+ <span class="plinth-arrow" aria-hidden="true">
276
+ {#if sort?.key === column.key}{sort.direction === "asc" ? "↑" : "↓"}{/if}
277
+ </span>
278
+ </button>
279
+ {:else}
280
+ {column.label}
281
+ {/if}
282
+ {/snippet}
283
+
284
+ {#snippet cellBody(column: Column<T>, row: T, index: number)}
285
+ {#if column.cell}{@render column.cell(row, index)}{:else}{cellText(column, row)}{/if}
286
+ {/snippet}
287
+
288
+ {#if hasBar}
289
+ <div class="plinth-table-bar">
290
+ {#if toolbar}
291
+ <div class="plinth-table-toolbar">{@render toolbar()}</div>
292
+ {/if}
293
+ {#if columnsLabel !== undefined}
294
+ <div class="plinth-table-columns">
295
+ <ColumnMenu
296
+ {columns}
297
+ layout={columnState}
298
+ onchange={(next) => (columnState = next)}
299
+ label={columnsLabel}
300
+ resetLabel={resetColumnsLabel}
301
+ moveLabel={moveColumnLabel}
302
+ />
303
+ </div>
304
+ {/if}
305
+ </div>
306
+ {/if}
307
+
308
+ <div class="plinth-table-wrap">
309
+ <table
310
+ class={["plinth-table table", className]}
311
+ class:has-card={card !== undefined}
312
+ class:plinth-sized={sized}
313
+ aria-label={label}
314
+ >
315
+ <thead>
316
+ <tr>
317
+ {#each shown as column (column.key)}
318
+ {@const width = columnWidth(column, columnState)}
319
+ <th
320
+ scope="col"
321
+ data-align={alignOf(column)}
322
+ class={["plinth-head", isSortable(column) && "plinth-sortable", column.class]}
323
+ aria-sort={isSortable(column) ? ariaSort(sort, column) : undefined}
324
+ >
325
+ <!-- A dragged width is applied to a box inside the cell rather than to the cell: a table
326
+ cell treats its width as a minimum and grows to fit what it holds, where a block
327
+ with a width and hidden overflow really is that wide, and the column follows. -->
328
+ {#if width !== undefined}
329
+ <div class="plinth-fit" style:--plinth-column-width="{width}px">
330
+ {@render headerBody(column)}
331
+ </div>
332
+ {:else}
333
+ {@render headerBody(column)}
334
+ {/if}
335
+ {#if resizable && isColumnResizable(column)}
336
+ <!-- A focusable separator is the ARIA pattern for a splitter the keyboard can move;
337
+ its value is the width it sets, so a screen reader hears the change. -->
338
+ <span
339
+ class="plinth-resize"
340
+ role="separator"
341
+ aria-orientation="vertical"
342
+ aria-label={resizeLabel(column)}
343
+ aria-valuenow={width}
344
+ aria-valuemin={minWidthOf(column)}
345
+ tabindex="0"
346
+ onpointerdown={(event) => startResize(event, column)}
347
+ ondblclick={() => resizeTo(column, null)}
348
+ onkeydown={(event) => onResizeKey(event, column)}
349
+ ></span>
350
+ {/if}
351
+ </th>
352
+ {/each}
353
+ </tr>
354
+ </thead>
355
+
356
+ <tbody>
357
+ {#if sorted.length === 0}
358
+ <tr>
359
+ <td class="plinth-empty" colspan={shown.length}>
360
+ {#if empty}{@render empty()}{:else}No data{/if}
361
+ </td>
362
+ </tr>
363
+ {:else}
364
+ {#each sorted as row, index (rowKey(row))}
365
+ <!-- A row is not an interactive element, and dressing it as one would cost the grid
366
+ semantics that make the table navigable in the first place. When a click handler is
367
+ given the row takes focus and answers Enter and Space, which is the reachable part
368
+ of a button; the app is expected to keep a real control in a cell for anything the
369
+ row click is the only way to do. -->
370
+ <!-- eslint-disable-next-line svelte/a11y-no-noninteractive-element-interactions -->
371
+ <tr
372
+ class:plinth-clickable={onRowClick !== undefined}
373
+ tabindex={onRowClick ? 0 : undefined}
374
+ onclick={onRowClick ? () => activate(row) : undefined}
375
+ onkeydown={onRowClick ? (event) => onRowKey(event, row) : undefined}
376
+ >
377
+ {#if card}
378
+ <td class="plinth-card" colspan={shown.length}>{@render card(row, index)}</td>
379
+ {/if}
380
+ {#each shown as column (column.key)}
381
+ {@const width = columnWidth(column, columnState)}
382
+ <td
383
+ data-label={column.label}
384
+ data-align={alignOf(column)}
385
+ class={["plinth-cell", column.numeric && "tabular-nums", column.class]}
386
+ >
387
+ {#if width !== undefined}
388
+ <div class="plinth-fit" style:--plinth-column-width="{width}px">
389
+ {@render cellBody(column, row, index)}
390
+ </div>
391
+ {:else}
392
+ {@render cellBody(column, row, index)}
393
+ {/if}
394
+ </td>
395
+ {/each}
396
+ </tr>
397
+ {/each}
398
+ {/if}
399
+ </tbody>
400
+ </table>
401
+ </div>
402
+
403
+ <style>
404
+ .plinth-table-wrap {
405
+ /* The whole breakpoint. Querying the container rather than the viewport is also what lets the
406
+ table be dropped into a narrow box and render its card form on a desktop, which is how the
407
+ showcase demonstrates both at once and how a test reaches either. */
408
+ container: plinth-table / inline-size;
409
+ /* A table that does not fit scrolls inside its own box rather than widening the document. */
410
+ overflow-x: auto;
411
+ }
412
+
413
+ .plinth-table-bar {
414
+ display: flex;
415
+ flex-wrap: wrap;
416
+ align-items: center;
417
+ gap: 0.5rem;
418
+ margin-bottom: 0.5rem;
419
+ }
420
+
421
+ .plinth-table-toolbar {
422
+ min-width: 0;
423
+ }
424
+
425
+ /* The menu keeps the far end of the bar even when there is no toolbar to push it there. */
426
+ .plinth-table-columns {
427
+ margin-left: auto;
428
+ }
429
+
430
+ /* A table with a dragged column is as wide as its columns add up to, rather than as wide as its
431
+ box: stretched to the box, the browser would share the spare width out between the columns —
432
+ the dragged one included — and a drag would never land where the pointer let go. */
433
+ .plinth-sized {
434
+ width: max-content;
435
+ }
436
+
437
+ .plinth-head {
438
+ position: relative;
439
+ }
440
+
441
+ .plinth-fit {
442
+ width: var(--plinth-column-width);
443
+ overflow: hidden;
444
+ text-overflow: ellipsis;
445
+ white-space: nowrap;
446
+ }
447
+
448
+ .plinth-resize {
449
+ position: absolute;
450
+ top: 0;
451
+ right: 0;
452
+ bottom: 0;
453
+ width: 0.625rem;
454
+ cursor: col-resize;
455
+ /* Without it a touch drag on the handle scrolls the table instead of resizing the column. */
456
+ touch-action: none;
457
+ }
458
+
459
+ .plinth-resize::after {
460
+ position: absolute;
461
+ top: 25%;
462
+ right: 0.25rem;
463
+ bottom: 25%;
464
+ width: 2px;
465
+ border-radius: 1px;
466
+ background: color-mix(in oklch, var(--color-base-content) 25%, transparent);
467
+ content: "";
468
+ opacity: 0;
469
+ transition: opacity 0.12s ease;
470
+ }
471
+
472
+ /* The handle only shows where it is useful — under the pointer, or under the keyboard. */
473
+ .plinth-head:hover .plinth-resize::after,
474
+ .plinth-resize:focus-visible::after {
475
+ opacity: 1;
476
+ }
477
+
478
+ .plinth-resize:focus-visible {
479
+ outline: 2px solid var(--color-primary);
480
+ outline-offset: -2px;
481
+ }
482
+
483
+ [data-align="right"] {
484
+ text-align: right;
485
+ }
486
+
487
+ [data-align="center"] {
488
+ text-align: center;
489
+ }
490
+
491
+ .plinth-sort {
492
+ display: inline-flex;
493
+ align-items: center;
494
+ gap: 0.25rem;
495
+ color: inherit;
496
+ font: inherit;
497
+ text-align: inherit;
498
+ cursor: pointer;
499
+ }
500
+
501
+ .plinth-head[data-align="right"] .plinth-sort {
502
+ /* The button is inline, so aligning the header's text moves the whole button, not its label:
503
+ the arrow has to be pushed to the far edge from inside it. */
504
+ flex-direction: row-reverse;
505
+ }
506
+
507
+ .plinth-sort:hover {
508
+ color: var(--color-base-content);
509
+ }
510
+
511
+ /* Reserved whether or not this column is the sorted one, so adopting a sort does not shift every
512
+ other header sideways. */
513
+ .plinth-arrow {
514
+ display: inline-block;
515
+ width: 0.75em;
516
+ }
517
+
518
+ .plinth-clickable {
519
+ cursor: pointer;
520
+ }
521
+
522
+ .plinth-empty {
523
+ padding: 2rem 1rem;
524
+ color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
525
+ text-align: center;
526
+ }
527
+
528
+ .plinth-card {
529
+ display: none;
530
+ }
531
+
532
+ @container plinth-table (max-width: 40rem) {
533
+ /* Widths are a table-form arrangement. A card has no columns to size, so the dragged widths
534
+ step aside here and come back when the box widens again. */
535
+ .plinth-table.plinth-sized {
536
+ width: 100%;
537
+ }
538
+
539
+ .plinth-fit {
540
+ width: auto;
541
+ overflow: visible;
542
+ white-space: normal;
543
+ }
544
+
545
+ .plinth-resize {
546
+ display: none;
547
+ }
548
+
549
+ /* The cells are restyled by class further down, not here: an element selector carries the
550
+ component's scoping class too, which would outrank `.plinth-cell` and pin every cell to
551
+ `block`. */
552
+ .plinth-table,
553
+ .plinth-table thead,
554
+ .plinth-table tbody,
555
+ .plinth-table tr {
556
+ display: block;
557
+ }
558
+
559
+ /* Both daisyUI and the plinth theme rule `.table` header and body cells with a
560
+ `border-bottom` — a rule between the rows of a grid. There is no grid here, so it lands as
561
+ a hairline under each sort pill and a line through the middle of every card. The card's
562
+ outline is the row's own border, so the cell borders go. */
563
+ .plinth-head,
564
+ .plinth-cell,
565
+ .plinth-card {
566
+ border-bottom: 0;
567
+ }
568
+
569
+ /* The headers are the only way to re-sort once the columns are gone, so they stay — as a strip
570
+ of pills above the cards rather than as a header row. */
571
+ .plinth-table thead tr {
572
+ display: flex;
573
+ flex-wrap: wrap;
574
+ gap: 0.375rem;
575
+ padding-bottom: 0.75rem;
576
+ }
577
+
578
+ .plinth-head {
579
+ display: none;
580
+ }
581
+
582
+ .plinth-head.plinth-sortable {
583
+ display: block;
584
+ padding: 0;
585
+ background: none;
586
+ }
587
+
588
+ .plinth-sort {
589
+ /* 44px: on a phone these pills are the only sort control there is. */
590
+ min-height: 2.75rem;
591
+ padding: 0 0.875rem;
592
+ border: 1px solid color-mix(in oklch, var(--color-base-content) 15%, transparent);
593
+ /* The theme's own field radius, not a pill: a sort control is a control, and it should
594
+ match the buttons and inputs it sits among rather than inventing a second shape. */
595
+ border-radius: var(--radius-field, 0.5rem);
596
+ }
597
+
598
+ .plinth-head[aria-sort]:not([aria-sort="none"]) .plinth-sort {
599
+ border-color: var(--color-primary);
600
+ color: var(--color-primary);
601
+ }
602
+
603
+ .plinth-table tbody tr {
604
+ margin-bottom: 0.5rem;
605
+ padding: 0.25rem 0.875rem;
606
+ border: 1px solid color-mix(in oklch, var(--color-base-content) 12%, transparent);
607
+ border-radius: var(--radius-box, 1rem);
608
+ background-color: var(--color-base-100);
609
+ }
610
+
611
+ .plinth-cell {
612
+ display: flex;
613
+ align-items: baseline;
614
+ justify-content: space-between;
615
+ gap: 1rem;
616
+ text-align: end;
617
+ }
618
+
619
+ /* The column label travels with the value, because the header it used to sit under is gone.
620
+ `display: block` costs the table its grid semantics here, which is the trade this pairing
621
+ pays for: every cell still names itself, in the reading order. */
622
+ .plinth-cell::before {
623
+ content: attr(data-label);
624
+ color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
625
+ text-align: start;
626
+ white-space: nowrap;
627
+ }
628
+
629
+ .has-card .plinth-cell {
630
+ display: none;
631
+ }
632
+
633
+ .plinth-card {
634
+ display: block;
635
+ padding: 0.5rem 0;
636
+ }
637
+
638
+ .plinth-empty {
639
+ display: block;
640
+ border: 0;
641
+ }
642
+ }
643
+ </style>