@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.
- package/LICENSE +21 -0
- package/README.md +361 -2
- package/dist/anchoring.d.ts +61 -0
- package/dist/anchoring.js +151 -0
- package/dist/async-action.svelte.d.ts +37 -0
- package/dist/async-action.svelte.js +64 -0
- package/dist/attachments/index.d.ts +2 -0
- package/dist/attachments/index.js +2 -0
- package/dist/attachments/sortable.d.ts +6 -0
- package/dist/attachments/sortable.js +227 -0
- package/dist/attachments/tooltip.d.ts +37 -0
- package/dist/attachments/tooltip.js +166 -0
- package/dist/components/AsyncButton.svelte +49 -0
- package/dist/components/AsyncButton.svelte.d.ts +32 -0
- package/dist/components/Combobox.svelte +380 -0
- package/dist/components/Combobox.svelte.d.ts +74 -0
- package/dist/components/Copyable.svelte +116 -0
- package/dist/components/Copyable.svelte.d.ts +37 -0
- package/dist/components/DateRangePicker.svelte +266 -0
- package/dist/components/DateRangePicker.svelte.d.ts +66 -0
- package/dist/components/Dialog.svelte +102 -0
- package/dist/components/Dialog.svelte.d.ts +38 -0
- package/dist/components/DragHandle.svelte +26 -0
- package/dist/components/DragHandle.svelte.d.ts +7 -0
- package/dist/components/Dropdown.svelte +204 -0
- package/dist/components/Dropdown.svelte.d.ts +49 -0
- package/dist/components/ErrorDisplay.svelte +59 -0
- package/dist/components/ErrorDisplay.svelte.d.ts +24 -0
- package/dist/components/ErrorPage.svelte +119 -0
- package/dist/components/ErrorPage.svelte.d.ts +52 -0
- package/dist/components/JsonInput.svelte +106 -0
- package/dist/components/JsonInput.svelte.d.ts +40 -0
- package/dist/components/Listbox.svelte +257 -0
- package/dist/components/Listbox.svelte.d.ts +78 -0
- package/dist/components/LoadingButton.svelte +36 -0
- package/dist/components/LoadingButton.svelte.d.ts +22 -0
- package/dist/components/Logo.svelte +112 -0
- package/dist/components/Logo.svelte.d.ts +29 -0
- package/dist/components/Modal.svelte +187 -0
- package/dist/components/Modal.svelte.d.ts +62 -0
- package/dist/components/MultiSelect.svelte +256 -0
- package/dist/components/MultiSelect.svelte.d.ts +89 -0
- package/dist/components/OptionPanel.svelte +241 -0
- package/dist/components/OptionPanel.svelte.d.ts +51 -0
- package/dist/components/Paginator.svelte +205 -0
- package/dist/components/Paginator.svelte.d.ts +48 -0
- package/dist/components/RefreshButton.svelte +246 -0
- package/dist/components/RefreshButton.svelte.d.ts +46 -0
- package/dist/components/SegmentedControl.svelte +147 -0
- package/dist/components/SegmentedControl.svelte.d.ts +71 -0
- package/dist/components/Select.svelte +312 -0
- package/dist/components/Select.svelte.d.ts +68 -0
- package/dist/components/Skeleton.svelte +97 -0
- package/dist/components/Skeleton.svelte.d.ts +32 -0
- package/dist/components/Tooltip.svelte +60 -0
- package/dist/components/Tooltip.svelte.d.ts +58 -0
- package/dist/components/date-range.d.ts +101 -0
- package/dist/components/date-range.js +144 -0
- package/dist/components/index.d.ts +26 -0
- package/dist/components/index.js +38 -0
- package/dist/components/json-text.d.ts +22 -0
- package/dist/components/json-text.js +22 -0
- package/dist/components/pick.d.ts +18 -0
- package/dist/components/pick.js +18 -0
- package/dist/components/segmented.d.ts +7 -0
- package/dist/components/segmented.js +1 -0
- package/dist/components/select.d.ts +66 -0
- package/dist/components/select.js +90 -0
- package/dist/confirm/Confirmer.svelte +194 -0
- package/dist/confirm/Confirmer.svelte.d.ts +45 -0
- package/dist/confirm/challenge.d.ts +16 -0
- package/dist/confirm/challenge.js +16 -0
- package/dist/confirm/confirm-manager.svelte.d.ts +91 -0
- package/dist/confirm/confirm-manager.svelte.js +149 -0
- package/dist/confirm/index.d.ts +3 -0
- package/dist/confirm/index.js +3 -0
- package/dist/error.d.ts +25 -0
- package/dist/error.js +39 -0
- package/dist/formatters.d.ts +95 -0
- package/dist/formatters.js +146 -0
- package/dist/http.d.ts +68 -0
- package/dist/http.js +138 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +14 -0
- package/dist/routing.svelte.d.ts +169 -0
- package/dist/routing.svelte.js +208 -0
- package/dist/shell/AppShell.svelte +874 -0
- package/dist/shell/AppShell.svelte.d.ts +93 -0
- package/dist/shell/Breadcrumbs.svelte +78 -0
- package/dist/shell/Breadcrumbs.svelte.d.ts +25 -0
- package/dist/shell/crumbs.d.ts +23 -0
- package/dist/shell/crumbs.js +1 -0
- package/dist/shell/index.d.ts +3 -0
- package/dist/shell/index.js +6 -0
- package/dist/table/ColumnMenu.svelte +249 -0
- package/dist/table/ColumnMenu.svelte.d.ts +47 -0
- package/dist/table/DataTable.svelte +643 -0
- package/dist/table/DataTable.svelte.d.ts +153 -0
- package/dist/table/columns.d.ts +55 -0
- package/dist/table/columns.js +153 -0
- package/dist/table/index.d.ts +4 -0
- package/dist/table/index.js +8 -0
- package/dist/table/sort.d.ts +48 -0
- package/dist/table/sort.js +139 -0
- package/dist/table/types.d.ts +93 -0
- package/dist/table/types.js +1 -0
- package/dist/theme/ThemeController.svelte +54 -0
- package/dist/theme/ThemeController.svelte.d.ts +27 -0
- package/dist/theme/ThemeToggle.svelte +119 -0
- package/dist/theme/ThemeToggle.svelte.d.ts +52 -0
- package/dist/theme/css-color.d.ts +23 -0
- package/dist/theme/css-color.js +31 -0
- package/dist/theme/index.d.ts +4 -0
- package/dist/theme/index.js +5 -0
- package/dist/theme/theme.css +410 -0
- package/dist/theme/theme.svelte.d.ts +54 -0
- package/dist/theme/theme.svelte.js +58 -0
- package/dist/toast/Toaster.svelte +257 -0
- package/dist/toast/Toaster.svelte.d.ts +35 -0
- package/dist/toast/index.d.ts +2 -0
- package/dist/toast/index.js +2 -0
- package/dist/toast/toast-manager.svelte.d.ts +99 -0
- package/dist/toast/toast-manager.svelte.js +176 -0
- package/dist/user/ScopedComponent.svelte +47 -0
- package/dist/user/ScopedComponent.svelte.d.ts +29 -0
- package/dist/user/anonymous.d.ts +21 -0
- package/dist/user/anonymous.js +26 -0
- package/dist/user/context.d.ts +55 -0
- package/dist/user/context.js +13 -0
- package/dist/user/index.d.ts +3 -0
- package/dist/user/index.js +6 -0
- 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>
|