@substrat-run/elements 0.0.0-stage → 0.1.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 (47) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +58 -2
  3. package/dist/avatar.d.ts +56 -0
  4. package/dist/avatar.d.ts.map +1 -0
  5. package/dist/avatar.js +107 -0
  6. package/dist/avatar.js.map +1 -0
  7. package/dist/controls.d.ts +21 -0
  8. package/dist/controls.d.ts.map +1 -0
  9. package/dist/controls.js +21 -0
  10. package/dist/controls.js.map +1 -0
  11. package/dist/entity-table-model.d.ts +269 -0
  12. package/dist/entity-table-model.d.ts.map +1 -0
  13. package/dist/entity-table-model.js +490 -0
  14. package/dist/entity-table-model.js.map +1 -0
  15. package/dist/entity-table.d.ts +187 -0
  16. package/dist/entity-table.d.ts.map +1 -0
  17. package/dist/entity-table.js +1004 -0
  18. package/dist/entity-table.js.map +1 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +6 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/internal/session-controller.d.ts +25 -0
  24. package/dist/internal/session-controller.d.ts.map +1 -0
  25. package/dist/internal/session-controller.js +46 -0
  26. package/dist/internal/session-controller.js.map +1 -0
  27. package/dist/internal/substrat-element.d.ts +36 -0
  28. package/dist/internal/substrat-element.d.ts.map +1 -0
  29. package/dist/internal/substrat-element.js +195 -0
  30. package/dist/internal/substrat-element.js.map +1 -0
  31. package/dist/jsx.d.ts +8 -0
  32. package/dist/jsx.d.ts.map +1 -0
  33. package/dist/jsx.js +2 -0
  34. package/dist/jsx.js.map +1 -0
  35. package/dist/session.d.ts +84 -0
  36. package/dist/session.d.ts.map +1 -0
  37. package/dist/session.js +158 -0
  38. package/dist/session.js.map +1 -0
  39. package/dist/themes/atelier.css +59 -0
  40. package/dist/themes/base.css +7 -0
  41. package/dist/themes/ledger.css +59 -0
  42. package/dist/themes/native.css +11 -0
  43. package/dist/user-menu.d.ts +95 -0
  44. package/dist/user-menu.d.ts.map +1 -0
  45. package/dist/user-menu.js +212 -0
  46. package/dist/user-menu.js.map +1 -0
  47. package/package.json +47 -3
@@ -0,0 +1,1004 @@
1
+ /**
2
+ * `<sub-entity-table>` — the rows of one paged read: its views, its sort, its filters and
3
+ * search, a selection, and the actions a row allows.
4
+ *
5
+ * A renderer over `EntityTableModel`: it draws the state and forwards clicks, and decides
6
+ * nothing the model does not. Styled only through Web Awesome's `--wa-*` tokens, so it
7
+ * follows whatever theme the page sets — Web Awesome's own, or a Substrat one — with no
8
+ * theme code in here. Uses Web Awesome's free controls inside.
9
+ *
10
+ * Properties (not attributes — they are objects and functions):
11
+ * `source` — `{ read, follow, search? }`: `read(query)` is the generated client's read
12
+ * given the query's `sort`, `order`, filters and view
13
+ * `columns` — `Column[]`; `sort` makes a header a sort button, `kind` draws the value
14
+ * (`badge`, `money`, `relative`, …), and `render` / `mount` replace it
15
+ * `actions` — `RowAction[]`, run through the model (403 / 409 / 404 handled there);
16
+ * `views` scopes one to some views, `bulk` offers it for a selection
17
+ * `views` — `{ id, label }[]`, drawn as a switch above the rows; the first is the default
18
+ * `filters` — `{ key, label, options }[]`, equality filters on declared filterable columns
19
+ * `query` — the first read's query, for a screen restoring one from its URL
20
+ * `layout` — `{ order, hidden }` by column key: which columns show, in what order
21
+ * Attributes: `label` (the table's accessible name), `empty`, `forbidden-text`,
22
+ * `search-label`, `selectable`, `density="compact"`, `paging` (`pages`, the default —
23
+ * Previous / Next with "21–40 of 132" — or `more`, one "Load more" that appends), and
24
+ * `page-size` (25). `pageSizes` is the property behind the rows-per-page picker.
25
+ * `column-picker` adds a "Columns" button: show, hide and reorder the columns. Opt-in;
26
+ * without it the columns are exactly the ones given. `layout-key` keeps the chosen layout
27
+ * in this browser under that key; a screen that would rather keep it on the account
28
+ * listens for `sub-layout-change` and hands it back through `layout`.
29
+ * Slot: `empty` — what an empty view says, in place of `empty` (a "Create" button, say).
30
+ * Events: `sub-action-done` `{ action, rows }` after an action succeeded on some rows;
31
+ * `sub-query-change` `{ query }` whenever the person changes the view, sort, filters or search;
32
+ * `sub-layout-change` `{ layout }` whenever they show, hide or move a column.
33
+ *
34
+ * ## Cells a vertical draws itself
35
+ *
36
+ * `render(row)` returns a Lit template or a string, drawn in the table's shadow root. A
37
+ * framework that is not Lit uses `mount(cell, row)` instead: `cell` is an element in the
38
+ * LIGHT DOM, slotted into its place, so React or Vue can render into it and the page's own
39
+ * stylesheet applies to whatever it draws. `mount` may return `{ update(row), unmount() }`;
40
+ * without `update`, a changed row is unmounted and mounted again.
41
+ *
42
+ * ## Styling, outside in
43
+ *
44
+ * The theme's `--wa-*` tokens; then `--sub-table-*` (row height, header and selected
45
+ * backgrounds, cell padding, `--sub-table-max-height` — set it and the header sticks);
46
+ * then `::part()` — `frame`, `toolbar`, `bulk-bar`, `table`, `header-cell`, `row`
47
+ * (plus `selected`), `cell`, `footer`, `empty`.
48
+ */
49
+ import '@awesome.me/webawesome/dist/components/badge/badge.js';
50
+ import '@awesome.me/webawesome/dist/components/button/button.js';
51
+ import '@awesome.me/webawesome/dist/components/callout/callout.js';
52
+ import '@awesome.me/webawesome/dist/components/checkbox/checkbox.js';
53
+ import '@awesome.me/webawesome/dist/components/dropdown/dropdown.js';
54
+ import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
55
+ import '@awesome.me/webawesome/dist/components/input/input.js';
56
+ import '@awesome.me/webawesome/dist/components/option/option.js';
57
+ import '@awesome.me/webawesome/dist/components/popover/popover.js';
58
+ import '@awesome.me/webawesome/dist/components/radio/radio.js';
59
+ import '@awesome.me/webawesome/dist/components/radio-group/radio-group.js';
60
+ import '@awesome.me/webawesome/dist/components/select/select.js';
61
+ import '@awesome.me/webawesome/dist/components/skeleton/skeleton.js';
62
+ import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
63
+ import { css, html, nothing, svg } from 'lit';
64
+ import { repeat } from 'lit/directives/repeat.js';
65
+ import SubstratElement from './internal/substrat-element.js';
66
+ import { BULK, EntityTableModel, actionsIn, arrange, cellText, defaultLayout, emptyReason, layoutEntries, moveColumn, toggleColumn, } from './entity-table-model.js';
67
+ /** How long a typed term waits before it is searched, so a word is one read, not five. */
68
+ const SEARCH_DEBOUNCE_MS = 250;
69
+ /** At most this many actions show on a row; past that, the first stays and the rest go in "More". */
70
+ const INLINE_ACTIONS = 2;
71
+ /** Where `layout-key` keeps a layout in this browser. */
72
+ const LAYOUT_STORE = 'sub-entity-table:layout:';
73
+ /** A stored layout as read back, or null for anything that is not one. */
74
+ function parseLayout(raw) {
75
+ try {
76
+ const v = raw === null ? null : JSON.parse(raw);
77
+ const strings = (x) => Array.isArray(x) && x.every((k) => typeof k === 'string');
78
+ if (v && typeof v === 'object' && strings(v.order) && strings(v.hidden)) {
79
+ return { order: v.order, hidden: v.hidden };
80
+ }
81
+ }
82
+ catch {
83
+ // Not JSON: a layout nobody can read is no layout.
84
+ }
85
+ return null;
86
+ }
87
+ /** A slot name is an attribute value; a row key could be anything. */
88
+ const slotOf = (column, key) => `cell-${column}-${key}`.replace(/[^\w-]/g, '_');
89
+ const arrow = (order) => svg `<svg class="arrow" viewBox="0 0 12 12" aria-hidden="true" data-order=${order ?? 'none'}>
90
+ <path d="M6 2.5 9 6H3z" class="up"/><path d="M6 9.5 3 6h6z" class="down"/>
91
+ </svg>`;
92
+ export class SubEntityTable extends SubstratElement {
93
+ static properties = {
94
+ source: { attribute: false },
95
+ columns: { attribute: false },
96
+ actions: { attribute: false },
97
+ views: { attribute: false },
98
+ filters: { attribute: false },
99
+ query: { attribute: false },
100
+ layout: { attribute: false },
101
+ columnPicker: { type: Boolean, attribute: 'column-picker', reflect: true },
102
+ layoutKey: { attribute: 'layout-key' },
103
+ label: {},
104
+ empty: {},
105
+ forbiddenText: { attribute: 'forbidden-text' },
106
+ searchLabel: { attribute: 'search-label' },
107
+ selectable: { type: Boolean, reflect: true },
108
+ density: { reflect: true },
109
+ paging: { reflect: true },
110
+ pageSize: { type: Number, attribute: 'page-size' },
111
+ pageSizes: { attribute: false },
112
+ view: { state: true },
113
+ };
114
+ #model;
115
+ #resize = new ResizeObserver(() => this.#fit());
116
+ #searchTimer;
117
+ #mounted = new Map();
118
+ /** The priority level `#fit` is dropping, so the picker can say why a column is not there. */
119
+ #drop = null;
120
+ #dragging = null;
121
+ #announce = '';
122
+ constructor() {
123
+ super();
124
+ this.columns = [];
125
+ this.actions = [];
126
+ this.views = [];
127
+ this.filters = [];
128
+ this.label = '';
129
+ this.empty = 'Nothing here.';
130
+ this.forbiddenText = 'You do not have access to this.';
131
+ this.searchLabel = 'Search';
132
+ this.selectable = false;
133
+ this.columnPicker = false;
134
+ this.density = 'comfortable';
135
+ this.paging = 'pages';
136
+ this.pageSize = 25;
137
+ this.pageSizes = [10, 25, 50, 100];
138
+ }
139
+ connectedCallback() {
140
+ super.connectedCallback();
141
+ this.#resize.observe(this);
142
+ // A table moved in the DOM was disconnected, which unmounted its framework cells.
143
+ this.requestUpdate();
144
+ }
145
+ disconnectedCallback() {
146
+ super.disconnectedCallback();
147
+ this.#resize.disconnect();
148
+ clearTimeout(this.#searchTimer);
149
+ for (const slot of [...this.#mounted.keys()])
150
+ this.#unmount(slot);
151
+ }
152
+ /** The columns on screen: the given ones, arranged by the layout. */
153
+ get #shown() {
154
+ return arrange(this.columns, this.layout);
155
+ }
156
+ #stored() {
157
+ try {
158
+ return parseLayout(localStorage.getItem(LAYOUT_STORE + this.layoutKey));
159
+ }
160
+ catch {
161
+ return null; // Storage refused (a private window, blocked site data): the declared layout.
162
+ }
163
+ }
164
+ #setLayout(next, said) {
165
+ this.layout = next;
166
+ this.#announce = said;
167
+ if (this.layoutKey) {
168
+ try {
169
+ localStorage.setItem(LAYOUT_STORE + this.layoutKey, JSON.stringify(next));
170
+ }
171
+ catch {
172
+ // Not kept past this page, which is all a refused store can mean.
173
+ }
174
+ }
175
+ this.dispatchEvent(new CustomEvent('sub-layout-change', { detail: { layout: next }, bubbles: true, composed: true }));
176
+ }
177
+ /** Read again with the current query — after the host created a row, say. */
178
+ reload() {
179
+ return this.#model?.load() ?? Promise.resolve();
180
+ }
181
+ /** The query the rows on screen answer, for a screen that keeps it in its URL. */
182
+ get currentQuery() {
183
+ return this.#model?.state.query;
184
+ }
185
+ willUpdate(changed) {
186
+ super.willUpdate(changed);
187
+ if (changed.has('layoutKey') && this.layoutKey && this.layout === undefined) {
188
+ this.layout = this.#stored() ?? undefined;
189
+ }
190
+ if (changed.has('source') && this.source) {
191
+ const view = this.query?.view ?? this.views[0]?.id ?? null;
192
+ this.#model = new EntityTableModel(this.source, (state) => (this.view = state), {
193
+ query: { limit: this.pageSize, ...this.query, view },
194
+ });
195
+ this.view = this.#model.state;
196
+ void this.#model.load();
197
+ }
198
+ }
199
+ updated(changed) {
200
+ super.updated(changed);
201
+ this.#fit();
202
+ this.#syncMounts();
203
+ }
204
+ /**
205
+ * Drop priority-3 columns, then priority-2, only while the table is wider than its
206
+ * frame. Measured rather than a fixed breakpoint: how wide the columns are is the
207
+ * theme's business (Atelier needs more room than Ledger), so no width in here can be
208
+ * right for every theme. Reading `scrollWidth` forces layout, so this settles before
209
+ * the frame paints.
210
+ */
211
+ #fit() {
212
+ const scroll = this.renderRoot.querySelector('.scroll');
213
+ const before = this.#drop;
214
+ this.removeAttribute('drop');
215
+ this.#drop = null;
216
+ if (scroll) {
217
+ for (const level of ['3', '2']) {
218
+ if (scroll.scrollWidth <= scroll.clientWidth)
219
+ break;
220
+ this.setAttribute('drop', level);
221
+ this.#drop = level;
222
+ }
223
+ }
224
+ // The picker says which columns are out for lack of room; it settles in one more pass.
225
+ if (this.#drop !== before && this.columnPicker)
226
+ this.requestUpdate();
227
+ }
228
+ /** Keep one light-DOM cell per (mounted column, row on screen), and no others. */
229
+ #syncMounts() {
230
+ const model = this.#model;
231
+ const mounting = this.#shown.filter((c) => c.mount);
232
+ const rows = this.view?.phase === 'ready' ? this.view.rows : [];
233
+ const wanted = new Set();
234
+ if (model) {
235
+ for (const row of rows) {
236
+ for (const column of mounting) {
237
+ const slot = slotOf(column.key, model.keyOf(row));
238
+ wanted.add(slot);
239
+ const known = this.#mounted.get(slot);
240
+ if (known && known.row === row)
241
+ continue;
242
+ if (known?.handle?.update) {
243
+ known.handle.update(row);
244
+ known.row = row;
245
+ continue;
246
+ }
247
+ if (known)
248
+ this.#unmount(slot);
249
+ const el = document.createElement('div');
250
+ el.slot = slot;
251
+ el.dataset['subCell'] = column.key;
252
+ this.append(el);
253
+ this.#mounted.set(slot, { el, row, handle: column.mount(el, row) });
254
+ }
255
+ }
256
+ }
257
+ for (const slot of [...this.#mounted.keys()])
258
+ if (!wanted.has(slot))
259
+ this.#unmount(slot);
260
+ }
261
+ #unmount(slot) {
262
+ const known = this.#mounted.get(slot);
263
+ if (!known)
264
+ return;
265
+ known.handle?.unmount?.();
266
+ known.el.remove();
267
+ this.#mounted.delete(slot);
268
+ }
269
+ #changed(work) {
270
+ this.dispatchEvent(new CustomEvent('sub-query-change', { detail: { query: this.#model.state.query }, bubbles: true, composed: true }));
271
+ return work;
272
+ }
273
+ #onSearch(e) {
274
+ const q = e.target.value ?? '';
275
+ clearTimeout(this.#searchTimer);
276
+ this.#searchTimer = setTimeout(() => void this.#changed(this.#model.search(q)), SEARCH_DEBOUNCE_MS);
277
+ }
278
+ static css = css `
279
+ :host {
280
+ --sub-table-row-height: calc(var(--wa-form-control-height) + var(--wa-space-2xs));
281
+ --sub-table-cell-padding-inline: var(--wa-space-m);
282
+ --sub-table-cell-padding-block: var(--wa-space-xs);
283
+ --sub-table-header-background: var(--wa-color-surface-lowered);
284
+ --sub-table-row-hover: var(--wa-color-neutral-fill-quiet);
285
+ --sub-table-row-selected: var(--wa-color-brand-fill-quiet);
286
+ --sub-table-max-height: none;
287
+ display: block;
288
+ font-family: var(--wa-font-family-body);
289
+ color: var(--wa-color-text-normal);
290
+ }
291
+ :host([density='compact']) {
292
+ --sub-table-row-height: var(--wa-form-control-height);
293
+ --sub-table-cell-padding-block: var(--wa-space-3xs);
294
+ --sub-table-cell-padding-inline: var(--wa-space-s);
295
+ }
296
+ .frame {
297
+ background: var(--wa-color-surface-default);
298
+ border: var(--wa-panel-border-width) var(--wa-panel-border-style) var(--wa-color-surface-border);
299
+ border-radius: var(--wa-panel-border-radius);
300
+ overflow: hidden;
301
+ }
302
+ .toolbar {
303
+ display: flex;
304
+ flex-wrap: wrap;
305
+ align-items: center;
306
+ gap: var(--wa-space-s);
307
+ padding: var(--wa-space-s) var(--sub-table-cell-padding-inline);
308
+ border-bottom: var(--wa-border-width-s) solid var(--wa-color-surface-border);
309
+ min-height: calc(var(--wa-form-control-height) + 2 * var(--wa-space-s));
310
+ }
311
+ .toolbar .grow {
312
+ flex: 1 1 12rem;
313
+ max-width: 22rem;
314
+ }
315
+ /* The Columns button keeps to the end of the toolbar, whatever is before it. */
316
+ .columns-button {
317
+ margin-inline-start: auto;
318
+ }
319
+ .picker::part(body) {
320
+ padding: var(--wa-space-xs);
321
+ min-width: 16rem;
322
+ }
323
+ .picker-list {
324
+ list-style: none;
325
+ margin: 0;
326
+ padding: 0;
327
+ }
328
+ .picker-row {
329
+ display: grid;
330
+ grid-template-columns: 1rem 1fr auto auto;
331
+ align-items: center;
332
+ gap: var(--wa-space-2xs);
333
+ padding: var(--wa-space-3xs) var(--wa-space-2xs);
334
+ border-radius: var(--wa-border-radius-s);
335
+ font-size: var(--wa-font-size-s);
336
+ }
337
+ .picker-row.fixed {
338
+ grid-template-columns: 1rem 1fr auto;
339
+ border-bottom: var(--wa-border-width-s) solid var(--wa-color-surface-border);
340
+ border-radius: 0;
341
+ margin-bottom: var(--wa-space-3xs);
342
+ padding-bottom: var(--wa-space-2xs);
343
+ }
344
+ li.picker-row:hover {
345
+ background: var(--sub-table-row-hover);
346
+ }
347
+ .picker-row.dragging {
348
+ opacity: 0.45;
349
+ }
350
+ .grip {
351
+ color: var(--wa-color-text-quiet);
352
+ cursor: grab;
353
+ text-align: center;
354
+ user-select: none;
355
+ }
356
+ .hint {
357
+ color: var(--wa-color-text-quiet);
358
+ font-size: var(--wa-font-size-xs);
359
+ }
360
+ .moves {
361
+ display: inline-flex;
362
+ }
363
+ .picker-foot {
364
+ display: flex;
365
+ justify-content: flex-end;
366
+ border-top: var(--wa-border-width-s) solid var(--wa-color-surface-border);
367
+ margin-top: var(--wa-space-3xs);
368
+ padding-top: var(--wa-space-3xs);
369
+ }
370
+ .toolbar wa-select {
371
+ width: 12rem;
372
+ }
373
+ /* "Status Open": a filter keeps its name once it has a value, the way a chip does. */
374
+ .filter-name {
375
+ color: var(--wa-color-text-quiet);
376
+ font-size: var(--wa-font-size-xs);
377
+ margin-inline-end: var(--wa-space-2xs);
378
+ }
379
+ .bulk {
380
+ background: var(--sub-table-row-selected);
381
+ }
382
+ .bulk strong {
383
+ font-size: var(--wa-font-size-s);
384
+ margin-inline-end: auto;
385
+ }
386
+ wa-callout {
387
+ margin: var(--wa-space-s);
388
+ }
389
+ table {
390
+ width: 100%;
391
+ border-collapse: separate;
392
+ border-spacing: 0;
393
+ font-size: var(--wa-font-size-s);
394
+ font-variant-numeric: tabular-nums;
395
+ transition: opacity var(--wa-transition-fast) var(--wa-transition-easing);
396
+ }
397
+ table.refreshing {
398
+ opacity: 0.55;
399
+ }
400
+ th {
401
+ position: sticky;
402
+ top: 0;
403
+ z-index: 1;
404
+ text-align: start;
405
+ font-size: var(--wa-font-size-xs);
406
+ font-weight: var(--wa-font-weight-semibold);
407
+ color: var(--wa-color-text-quiet);
408
+ letter-spacing: 0.02em;
409
+ padding: var(--sub-table-cell-padding-block) var(--sub-table-cell-padding-inline);
410
+ background: var(--sub-table-header-background);
411
+ border-bottom: var(--wa-border-width-s) solid var(--wa-color-surface-border);
412
+ }
413
+ td {
414
+ padding: var(--sub-table-cell-padding-block) var(--sub-table-cell-padding-inline);
415
+ border-top: var(--wa-border-width-s) solid var(--wa-color-surface-border);
416
+ }
417
+ /* The header takes the rows' height too: on its own it shrinks to its text, and its labels
418
+ then sit closer to its edges than a row's values do to theirs. */
419
+ th,
420
+ td {
421
+ height: var(--sub-table-row-height);
422
+ }
423
+ /* Values stay on one line; the first data column takes the slack and is the one that wraps. */
424
+ th,
425
+ td {
426
+ white-space: nowrap;
427
+ }
428
+ .primary {
429
+ white-space: normal;
430
+ width: 100%;
431
+ min-width: 9rem;
432
+ }
433
+ .pick {
434
+ width: 1%;
435
+ padding-inline-end: 0;
436
+ }
437
+ /* A block, not inline: on the text's baseline the checkbox keeps a descender's room under
438
+ it and sits above the middle of the row. */
439
+ .pick wa-checkbox {
440
+ display: flex;
441
+ }
442
+ /* The last resort when the columns still left are wider than the frame: scroll the
443
+ table, never clip the actions off its edge. A max height makes the header stick. */
444
+ .scroll {
445
+ overflow: auto;
446
+ max-height: var(--sub-table-max-height);
447
+ }
448
+ tbody tr:first-child td {
449
+ border-top: 0;
450
+ }
451
+ tbody tr:hover td {
452
+ background: var(--sub-table-row-hover);
453
+ }
454
+ tbody tr.selected td {
455
+ background: var(--sub-table-row-selected);
456
+ }
457
+ .end {
458
+ text-align: end;
459
+ }
460
+ /* A mounted cell is a flex row, so a component in it is centred on the row's middle
461
+ rather than sat on a text baseline with a descender's room under it. */
462
+ slot::slotted([data-sub-cell]) {
463
+ display: flex;
464
+ align-items: center;
465
+ }
466
+ .end slot::slotted([data-sub-cell]) {
467
+ justify-content: flex-end;
468
+ }
469
+ .sort {
470
+ all: unset;
471
+ cursor: pointer;
472
+ display: inline-flex;
473
+ align-items: center;
474
+ gap: var(--wa-space-3xs);
475
+ border-radius: var(--wa-border-radius-s);
476
+ }
477
+ .sort:hover,
478
+ th[aria-sort='ascending'] .sort,
479
+ th[aria-sort='descending'] .sort {
480
+ color: var(--wa-color-text-normal);
481
+ }
482
+ .sort:focus-visible {
483
+ outline: var(--wa-focus-ring);
484
+ outline-offset: var(--wa-focus-ring-offset);
485
+ }
486
+ .arrow {
487
+ width: 0.75em;
488
+ height: 0.75em;
489
+ fill: currentColor;
490
+ }
491
+ .arrow .up,
492
+ .arrow .down {
493
+ opacity: 0.3;
494
+ }
495
+ .arrow[data-order='asc'] .up,
496
+ .arrow[data-order='desc'] .down {
497
+ opacity: 1;
498
+ }
499
+ .arrow[data-order='asc'] .down,
500
+ .arrow[data-order='desc'] .up {
501
+ opacity: 0;
502
+ }
503
+ /* No block padding: the buttons are already a control's height, and padded they would make
504
+ every row taller than the row height the header and the other cells keep. */
505
+ .actions {
506
+ text-align: end;
507
+ white-space: nowrap;
508
+ width: 1%;
509
+ padding-block: 0;
510
+ }
511
+ /* With a pointer that hovers, a row's actions wait to be wanted; on touch, or held by
512
+ the keyboard, or while one runs, they show. */
513
+ @media (hover: hover) {
514
+ tbody tr:not(:hover):not(:focus-within):not(.selected) .actions > :not([loading]) {
515
+ opacity: 0;
516
+ }
517
+ }
518
+ a {
519
+ color: inherit;
520
+ font-weight: var(--wa-font-weight-semibold);
521
+ text-decoration: none;
522
+ }
523
+ a:hover {
524
+ color: var(--wa-color-text-link);
525
+ text-decoration: underline;
526
+ }
527
+ a:focus-visible {
528
+ outline: var(--wa-focus-ring);
529
+ outline-offset: var(--wa-focus-ring-offset);
530
+ }
531
+ .blank {
532
+ color: var(--wa-color-text-quiet);
533
+ }
534
+ code {
535
+ font-family: var(--wa-font-family-code);
536
+ font-size: 0.92em;
537
+ color: var(--wa-color-text-quiet);
538
+ }
539
+ wa-skeleton {
540
+ height: 0.8em;
541
+ width: 70%;
542
+ }
543
+ .note {
544
+ padding: var(--wa-space-xl) var(--wa-space-m);
545
+ text-align: center;
546
+ color: var(--wa-color-text-quiet);
547
+ font-size: var(--wa-font-size-s);
548
+ }
549
+ .note wa-button {
550
+ margin-top: var(--wa-space-s);
551
+ }
552
+ footer .pages {
553
+ display: inline-flex;
554
+ align-items: center;
555
+ gap: var(--wa-space-xs);
556
+ }
557
+ footer wa-select.size {
558
+ width: 8.5rem;
559
+ }
560
+ /* Every labelled control in here labels itself for a screen reader only (.vh), and its
561
+ label part keeps a margin regardless, which makes it taller than a button of the same
562
+ size and sits it lower beside one — the toolbar's Columns button, the pager's buttons. */
563
+ wa-input::part(form-control-label),
564
+ wa-select::part(form-control-label),
565
+ wa-radio-group::part(form-control-label) {
566
+ margin: 0;
567
+ }
568
+ footer {
569
+ display: flex;
570
+ align-items: center;
571
+ justify-content: space-between;
572
+ gap: var(--wa-space-s);
573
+ padding: var(--wa-space-xs) var(--sub-table-cell-padding-inline);
574
+ border-top: var(--wa-border-width-s) solid var(--wa-color-surface-border);
575
+ color: var(--wa-color-text-quiet);
576
+ font-size: var(--wa-font-size-xs);
577
+ }
578
+ .vh {
579
+ position: absolute;
580
+ width: 1px;
581
+ height: 1px;
582
+ overflow: hidden;
583
+ clip-path: inset(50%);
584
+ white-space: nowrap;
585
+ }
586
+ :host([drop='3']) .p3,
587
+ :host([drop='2']) .p3,
588
+ :host([drop='2']) .p2 {
589
+ display: none;
590
+ }
591
+ `;
592
+ render() {
593
+ const state = this.view;
594
+ if (!state)
595
+ return nothing;
596
+ return html `<div part="frame" class="frame" aria-busy=${state.phase === 'loading' || state.refreshing ? 'true' : 'false'}>
597
+ ${this.#toolbar(state)} ${this.#problem(state)} ${this.#body(state)}
598
+ </div>`;
599
+ }
600
+ #toolbar(state) {
601
+ const model = this.#model;
602
+ if (this.selectable && state.selected.size > 0) {
603
+ const bulk = actionsIn(this.actions, state.query.view, true);
604
+ return html `<div part="toolbar bulk-bar" class="toolbar bulk" role="toolbar" aria-label="Selection">
605
+ <strong>${state.selected.size} selected</strong>
606
+ ${bulk.map((a) => html `<wa-button
607
+ size="s"
608
+ appearance="outlined"
609
+ variant=${a.tone === 'danger' ? 'danger' : 'neutral'}
610
+ ?loading=${state.busy === BULK}
611
+ ?disabled=${state.busy !== null}
612
+ @click=${() => this.#runBulk(a)}
613
+ >${a.label}</wa-button
614
+ >`)}
615
+ <wa-button size="s" appearance="plain" ?disabled=${state.busy !== null} @click=${() => model.clearSelection()}
616
+ >Clear</wa-button
617
+ >
618
+ </div>`;
619
+ }
620
+ const views = this.views.length > 1;
621
+ const search = model.searchable;
622
+ if (!views && !search && this.filters.length === 0 && !this.columnPicker)
623
+ return nothing;
624
+ return html `<div part="toolbar" class="toolbar">
625
+ ${views
626
+ ? html `<wa-radio-group
627
+ size="s"
628
+ orientation="horizontal"
629
+ .value=${state.query.view}
630
+ @change=${(e) => this.#changed(model.setQuery({ view: e.target.value }))}
631
+ >
632
+ <span slot="label" class="vh">View</span>
633
+ ${this.views.map((v) => html `<wa-radio appearance="button" value=${v.id}>${v.label}</wa-radio>`)}
634
+ </wa-radio-group>`
635
+ : nothing}
636
+ ${search
637
+ ? html `<wa-input
638
+ class="grow"
639
+ size="s"
640
+ type="search"
641
+ with-clear
642
+ placeholder=${this.searchLabel}
643
+ .value=${state.query.q}
644
+ @input=${(e) => this.#onSearch(e)}
645
+ >
646
+ <span slot="label" class="vh">${this.searchLabel}</span>
647
+ </wa-input>`
648
+ : nothing}
649
+ ${this.filters.map((f) => html `<wa-select
650
+ size="s"
651
+ with-clear
652
+ placeholder="Any"
653
+ .value=${state.query.filters[f.key] ?? ''}
654
+ ?disabled=${state.mode === 'search'}
655
+ title=${state.mode === 'search' ? 'A search is ranked, not filtered: clear it to filter' : nothing}
656
+ @change=${(e) => this.#changed(model.filter(f.key, e.target.value || null))}
657
+ >
658
+ <span slot="label" class="vh">${f.label}</span>
659
+ <span slot="start" class="filter-name" aria-hidden="true">${f.label}</span>
660
+ ${f.options.map((o) => html `<wa-option value=${o.value}>${o.label}</wa-option>`)}
661
+ </wa-select>`)}
662
+ ${this.columnPicker ? this.#picker() : nothing}
663
+ </div>`;
664
+ }
665
+ /**
666
+ * Show, hide and reorder the columns. Dragging is for a pointer; every row also has Move up
667
+ * and Move down, so a keyboard or a screen reader can do everything a drag can. The first
668
+ * column names the row and is listed as always shown.
669
+ */
670
+ #picker() {
671
+ const first = this.columns[0];
672
+ if (!first)
673
+ return nothing;
674
+ const entries = layoutEntries(this.columns, this.layout);
675
+ const plain = JSON.stringify(arrange(this.columns, this.layout).map((c) => c.key)) ===
676
+ JSON.stringify(this.columns.map((c) => c.key));
677
+ const narrow = (c) => (this.#drop === '3' && c.priority === 3) || (this.#drop === '2' && (c.priority === 2 || c.priority === 3));
678
+ const move = (key, label, by) => {
679
+ const next = moveColumn(this.columns, this.layout, key, { by });
680
+ const at = layoutEntries(this.columns, next).findIndex((e) => e.column.key === key);
681
+ this.#setLayout(next, `${label} moved ${by < 0 ? 'up' : 'down'}, ${at + 1} of ${entries.length}.`);
682
+ // Focus follows the column. At an edge the button pressed is disabled now, and focus
683
+ // would fall to the page; the other one is where a keyboard wants to be.
684
+ void this.updateComplete.then(() => {
685
+ const moves = this.renderRoot.querySelectorAll(`li[data-key="${CSS.escape(key)}"] .moves wa-button`);
686
+ const pressed = moves[by < 0 ? 0 : 1];
687
+ (pressed && !pressed.hasAttribute('disabled') ? pressed : moves[by < 0 ? 1 : 0])?.focus();
688
+ });
689
+ };
690
+ return html `<wa-button id="columns" class="columns-button" size="s" appearance="outlined">Columns</wa-button>
691
+ <wa-popover for="columns" placement="bottom-end" class="picker">
692
+ <div class="picker-body" role="group" aria-label="Columns">
693
+ <div class="picker-row fixed">
694
+ <span class="grip" aria-hidden="true"></span>
695
+ <wa-checkbox size="s" checked disabled>${first.label}</wa-checkbox>
696
+ <span class="hint">Always shown</span>
697
+ </div>
698
+ <ol class="picker-list">
699
+ ${repeat(entries, (e) => e.column.key, ({ column: c, shown }, i) => html `<li
700
+ class=${['picker-row', this.#dragging === c.key ? 'dragging' : ''].join(' ')}
701
+ data-key=${c.key}
702
+ draggable="true"
703
+ @dragstart=${(e) => {
704
+ this.#dragging = c.key;
705
+ e.dataTransfer?.setData('text/plain', c.key);
706
+ if (e.dataTransfer)
707
+ e.dataTransfer.effectAllowed = 'move';
708
+ this.requestUpdate();
709
+ }}
710
+ @dragover=${(e) => {
711
+ if (this.#dragging && this.#dragging !== c.key)
712
+ e.preventDefault();
713
+ }}
714
+ @drop=${(e) => {
715
+ e.preventDefault();
716
+ const key = this.#dragging;
717
+ this.#dragging = null;
718
+ if (key && key !== c.key) {
719
+ const label = this.columns.find((x) => x.key === key)?.label ?? key;
720
+ this.#setLayout(moveColumn(this.columns, this.layout, key, { to: c.key }), `${label} moved.`);
721
+ }
722
+ else
723
+ this.requestUpdate();
724
+ }}
725
+ @dragend=${() => {
726
+ this.#dragging = null;
727
+ this.requestUpdate();
728
+ }}
729
+ >
730
+ <span class="grip" aria-hidden="true">⠿</span>
731
+ <wa-checkbox
732
+ size="s"
733
+ .checked=${shown}
734
+ @change=${() => this.#setLayout(toggleColumn(this.columns, this.layout, c.key), `${c.label} ${shown ? 'hidden' : 'shown'}.`)}
735
+ >${c.label}</wa-checkbox
736
+ >
737
+ ${shown && narrow(c) ? html `<span class="hint">Hidden while narrow</span>` : html `<span></span>`}
738
+ <span class="moves">
739
+ <wa-button size="xs" appearance="plain" ?disabled=${i === 0} @click=${() => move(c.key, c.label, -1)}
740
+ >↑<span class="vh">Move ${c.label} up</span></wa-button
741
+ >
742
+ <wa-button
743
+ size="xs"
744
+ appearance="plain"
745
+ ?disabled=${i === entries.length - 1}
746
+ @click=${() => move(c.key, c.label, 1)}
747
+ >↓<span class="vh">Move ${c.label} down</span></wa-button
748
+ >
749
+ </span>
750
+ </li>`)}
751
+ </ol>
752
+ <div class="picker-foot">
753
+ <wa-button
754
+ size="s"
755
+ appearance="plain"
756
+ ?disabled=${plain && !this.layout?.hidden.length}
757
+ @click=${() => this.#setLayout(defaultLayout(this.columns), 'Columns reset.')}
758
+ >Reset to default</wa-button
759
+ >
760
+ </div>
761
+ <span class="vh" aria-live="polite">${this.#announce}</span>
762
+ </div>
763
+ </wa-popover>`;
764
+ }
765
+ #problem(state) {
766
+ const p = state.problem;
767
+ if (!p || state.phase === 'error')
768
+ return nothing;
769
+ const variant = p.kind === 'refused' ? 'warning' : p.kind === 'stale' ? 'neutral' : 'danger';
770
+ const lead = p.kind === 'refused' ? 'Not allowed.' : p.kind === 'stale' ? '' : 'Something went wrong.';
771
+ return html `<wa-callout variant=${variant} size="s" role="status">
772
+ ${lead ? html `<strong>${lead}</strong> ` : nothing}${p.message}
773
+ </wa-callout>`;
774
+ }
775
+ #body(state) {
776
+ switch (state.phase) {
777
+ case 'loading':
778
+ return this.#skeleton();
779
+ case 'forbidden':
780
+ return html `<wa-callout variant="warning" size="s">${this.forbiddenText}</wa-callout>`;
781
+ case 'error':
782
+ return html `<wa-callout variant="danger" size="s">
783
+ <strong>Could not load.</strong> ${state.problem?.message ?? ''}
784
+ <wa-button size="s" appearance="outlined" @click=${() => this.reload()}>Try again</wa-button>
785
+ </wa-callout>`;
786
+ case 'ready':
787
+ return state.rows.length === 0 ? this.#empty(state) : this.#table(state);
788
+ }
789
+ }
790
+ /** Rows the height of real ones, so the first answer lands without the page jumping. */
791
+ #skeleton() {
792
+ return html `<div class="scroll">
793
+ <table aria-hidden="true">
794
+ <tbody>
795
+ ${[0, 1, 2].map(() => html `<tr>
796
+ ${this.#shown.map((c, i) => html `<td class=${this.#cellClass(c, i)}><wa-skeleton effect="sheen"></wa-skeleton></td>`)}
797
+ </tr>`)}
798
+ </tbody>
799
+ </table>
800
+ </div>`;
801
+ }
802
+ #empty(state) {
803
+ if (emptyReason(state) === 'no-matches') {
804
+ return html `<div part="empty" class="note">
805
+ <div>${state.mode === 'search' ? `Nothing matches “${state.query.q.trim()}”.` : 'Nothing matches these filters.'}</div>
806
+ <wa-button size="s" appearance="outlined" @click=${() => this.#clear()}>Clear</wa-button>
807
+ </div>`;
808
+ }
809
+ return html `<div part="empty" class="note"><slot name="empty">${this.empty}</slot></div>`;
810
+ }
811
+ #clear() {
812
+ clearTimeout(this.#searchTimer);
813
+ return this.#changed(this.#model.clear());
814
+ }
815
+ #cellClass(c, index) {
816
+ const end = c.align === 'end' || (c.align === undefined && (c.kind === 'number' || c.kind === 'money'));
817
+ return [`p${c.priority ?? 1}`, end ? 'end' : '', index === 0 ? 'primary' : ''].filter(Boolean).join(' ');
818
+ }
819
+ #header(c, index, state) {
820
+ // A search is ranked, not sorted: its headers are labels until the term is cleared.
821
+ const sortable = c.sort !== undefined && state.mode === 'page';
822
+ const order = sortable && state.query.sort === c.sort ? state.query.order : null;
823
+ const ariaSort = !sortable ? nothing : order === 'asc' ? 'ascending' : order === 'desc' ? 'descending' : 'none';
824
+ return html `<th scope="col" part="header-cell" class=${this.#cellClass(c, index)} aria-sort=${ariaSort}>
825
+ ${sortable
826
+ ? html `<button class="sort" @click=${() => this.#changed(this.#model.sortBy(c.sort))}>
827
+ ${c.label}${arrow(order)}
828
+ </button>`
829
+ : c.label}
830
+ </th>`;
831
+ }
832
+ #cell(c, row, key, state, now) {
833
+ if (c.mount)
834
+ return html `<slot name=${slotOf(c.key, key)}></slot>`;
835
+ if (c.render)
836
+ return c.render(row);
837
+ const cell = cellText(c, row, now);
838
+ const href = cell.blank ? null : c.href?.(row, state.query);
839
+ const body = c.kind === 'badge' && !cell.blank
840
+ ? html `<wa-badge variant=${cell.tone ?? 'neutral'} appearance="filled">${cell.text}</wa-badge>`
841
+ : c.kind === 'code' && !cell.blank
842
+ ? html `<code>${cell.text}</code>`
843
+ : cell.text;
844
+ if (href)
845
+ return html `<a href=${href} title=${cell.title ?? nothing}>${body}</a>`;
846
+ return html `<span class=${cell.blank ? 'blank' : nothing} title=${cell.title ?? nothing}>${body}</span>`;
847
+ }
848
+ #table(state) {
849
+ const model = this.#model;
850
+ const rowActions = actionsIn(this.actions, state.query.view);
851
+ const keys = state.rows.map(model.keyOf);
852
+ const picked = keys.filter((k) => state.selected.has(k)).length;
853
+ const now = new Date();
854
+ const columns = this.#shown;
855
+ return html `<div class="scroll">
856
+ <table part="table" class=${state.refreshing ? 'refreshing' : ''}>
857
+ ${this.label ? html `<caption class="vh">${this.label}</caption>` : nothing}
858
+ <thead>
859
+ <tr>
860
+ ${this.selectable
861
+ ? html `<th scope="col" class="pick">
862
+ <wa-checkbox
863
+ size="s"
864
+ .checked=${picked > 0 && picked === keys.length}
865
+ .indeterminate=${picked > 0 && picked < keys.length}
866
+ @change=${() => model.toggleAll()}
867
+ ><span class="vh">Select all shown</span></wa-checkbox
868
+ >
869
+ </th>`
870
+ : nothing}
871
+ ${columns.map((c, i) => this.#header(c, i, state))}
872
+ ${rowActions.length ? html `<th scope="col" class="actions"><span class="vh">Actions</span></th>` : nothing}
873
+ </tr>
874
+ </thead>
875
+ <tbody>
876
+ ${state.rows.map((row, r) => {
877
+ const key = keys[r];
878
+ const selected = state.selected.has(key);
879
+ const name = this.columns[0] ? cellText(this.columns[0], row, now).text : key;
880
+ return html `<tr part=${selected ? 'row selected' : 'row'} class=${selected ? 'selected' : ''}>
881
+ ${this.selectable
882
+ ? html `<td class="pick">
883
+ <wa-checkbox size="s" .checked=${selected} @change=${() => model.toggle(key)}
884
+ ><span class="vh">Select ${name}</span></wa-checkbox
885
+ >
886
+ </td>`
887
+ : nothing}
888
+ ${columns.map((c, i) => html `<td part="cell" class=${this.#cellClass(c, i)}>${this.#cell(c, row, key, state, now)}</td>`)}
889
+ ${rowActions.length ? html `<td class="actions">${this.#rowActions(rowActions, row, key, name, state)}</td>` : nothing}
890
+ </tr>`;
891
+ })}
892
+ </tbody>
893
+ </table>
894
+ </div>
895
+ ${this.#footer(state)}`;
896
+ }
897
+ #rowActions(actions, row, key, name, state) {
898
+ const inline = actions.length <= INLINE_ACTIONS ? actions : actions.slice(0, 1);
899
+ const more = actions.slice(inline.length);
900
+ const busy = state.busy === key;
901
+ const blocked = state.busy !== null && !busy;
902
+ return html `${inline.map((a) => html `<wa-button
903
+ size="s"
904
+ appearance="plain"
905
+ variant=${a.tone === 'danger' ? 'danger' : 'neutral'}
906
+ ?loading=${busy}
907
+ ?disabled=${blocked}
908
+ @click=${() => this.#run(a, row)}
909
+ >${a.label}<span class="vh"> ${name}</span></wa-button
910
+ >`)}${more.length
911
+ ? html `<wa-dropdown
912
+ @wa-select=${(e) => {
913
+ const action = more.find((a) => a.id === e.detail.item.value);
914
+ if (action)
915
+ void this.#run(action, row);
916
+ }}
917
+ >
918
+ <wa-button slot="trigger" size="s" appearance="plain" ?disabled=${blocked}
919
+ >⋯<span class="vh">More actions for ${name}</span></wa-button
920
+ >
921
+ ${more.map((a) => html `<wa-dropdown-item value=${a.id} variant=${a.tone === 'danger' ? 'danger' : 'default'}
922
+ >${a.label}</wa-dropdown-item
923
+ >`)}
924
+ </wa-dropdown>`
925
+ : nothing}`;
926
+ }
927
+ #footer(state) {
928
+ if (state.mode === 'search') {
929
+ const n = state.rows.length;
930
+ const said = state.capped
931
+ ? `The first ${n} matches. Add a word to narrow the search.`
932
+ : `${n} ${n === 1 ? 'match' : 'matches'}`;
933
+ return html `<footer part="footer"><span>${said}</span></footer>`;
934
+ }
935
+ return this.paging === 'more' ? this.#more(state) : this.#pager(state);
936
+ }
937
+ #more(state) {
938
+ if (!state.next && state.total === null)
939
+ return nothing;
940
+ const shown = state.total === null ? `${state.rows.length} shown` : `${state.rows.length} of ${state.total}`;
941
+ return html `<footer part="footer">
942
+ <span>${shown}</span>
943
+ ${state.next
944
+ ? html `<wa-button size="s" appearance="outlined" ?loading=${state.loadingMore} @click=${() => this.#model.more()}
945
+ >Load more</wa-button
946
+ >`
947
+ : nothing}
948
+ </footer>`;
949
+ }
950
+ /**
951
+ * "21–40 of 132", rows per page, Previous and Next. No page numbers: a keyset walk cannot
952
+ * jump to page 7, and pretending it can means an offset, which drops and repeats rows on a
953
+ * table someone is writing to.
954
+ */
955
+ #pager(state) {
956
+ const model = this.#model;
957
+ const first = state.page === 0;
958
+ // A single short first page needs no pager — unless its size is the reason it is short.
959
+ if (first && !state.next && state.rows.length < Math.min(...this.pageSizes)) {
960
+ return state.total === null ? nothing : html `<footer part="footer"><span>${state.total} in all</span></footer>`;
961
+ }
962
+ const from = state.offset + 1;
963
+ const to = state.offset + state.rows.length;
964
+ const range = `${from}–${to}${state.total === null ? '' : ` of ${state.total}`}`;
965
+ const busy = state.refreshing;
966
+ return html `<footer part="footer" class="pager">
967
+ <span aria-live="polite">${range}</span>
968
+ <span class="pages">
969
+ <wa-select
970
+ size="s"
971
+ class="size"
972
+ .value=${String(state.query.limit ?? this.pageSize)}
973
+ @change=${(e) => this.#changed(model.setPageSize(Number(e.target.value)))}
974
+ >
975
+ <span slot="label" class="vh">Rows per page</span>
976
+ ${this.pageSizes.map((n) => html `<wa-option value=${String(n)}>${n} per page</wa-option>`)}
977
+ </wa-select>
978
+ <wa-button size="s" appearance="outlined" ?disabled=${first || busy} @click=${() => model.prevPage()}
979
+ >‹ Previous</wa-button
980
+ >
981
+ <wa-button size="s" appearance="outlined" ?disabled=${!state.next || busy} @click=${() => model.nextPage()}
982
+ >Next ›</wa-button
983
+ >
984
+ </span>
985
+ </footer>`;
986
+ }
987
+ async #run(action, row) {
988
+ const model = this.#model;
989
+ await model.run(action, row);
990
+ if (!model.state.problem)
991
+ this.#done(action, [row]);
992
+ }
993
+ async #runBulk(action) {
994
+ const done = await this.#model.runBulk(action);
995
+ if (done.length)
996
+ this.#done(action, done);
997
+ }
998
+ #done(action, rows) {
999
+ this.dispatchEvent(new CustomEvent('sub-action-done', { detail: { action: action.id, rows }, bubbles: true, composed: true }));
1000
+ }
1001
+ }
1002
+ if (!customElements.get('sub-entity-table'))
1003
+ customElements.define('sub-entity-table', SubEntityTable);
1004
+ //# sourceMappingURL=entity-table.js.map