tablo-ui 0.3.0

Beautiful Topcoat UI primitives for Tablo — owned library over topcoat-ui-registry.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
// Confirmed destructive submits, applied without a navigation.
//
// A form marked `data-mutation-submit` — the row-delete confirm, the bulk form
// (its delete confirm and its custom actions) and a row's custom action — POSTs through `fetch`, follows the 303, and applies the response
// in place:
//
// - the flash toast the handler set (`set_notification`) is inserted into the
//   shell's toaster, because following the redirect consumed the cookie that
//   carries it;
// - the table is refreshed through the seam that already owns it. A live
//   table renders a revision control inside its region (`[data-table-revision]`):
//   writing it re-runs the `table_search` shard, which morphs and
//   re-hydrates the region with the live query state intact. A static table
//   has no shard and its region is inert markup, so the response's region
//   content replaces it wholesale.
//
// The mutation response is a whole list page, and the client never morphs it
// into the live document. The response renders the bare list URL while a live
// table's state lives in its signals and its page-owned toolbar, so a region
// taken from it would show a different result set than the controls around it;
// and markup a plain script inserts carries no bindings until the next shard
// rerun, which re-hydrates the range it morphs. Driving the shard keeps one
// renderer — the server — for both halves.
//
// Failure paths are the browser's. A mutation whose response the client
// fetched is never posted again: a POST the server answers itself (4xx/5xx)
// may already have committed the write — a failure after the commit is still a
// failure response — so the fetched response is rendered into the document,
// the same page a no-JS POST would have shown, and what to do next is the
// reader's to decide. A request that never completes leaves the outcome
// unknown, and a redirect that was followed is a write that landed even when
// the list render behind it failed: both reload the page rather than post the
// delete a second time.
//
// Without JavaScript none of this runs: the same form POSTs and 303s.
//
// Document-level delegation (like bulk.js) so a table a shard re-rendered
// needs no re-installation.
(() => {
// The selection-wire codec lives in `wire.js`, which the document loads before
// this script: the browser global in the browser, `require`d in the
// Node test (there is no JS test runner in this workspace, and this file must
// stay a plain browser script loaded by `asset!`, so it cannot be an ES module).
const { wireOf, wireFrom } =
  typeof module !== 'undefined' && module.exports
    ? require('./wire.js')
    : window.TabloWire;

// The record key a row-delete action URL names, or null when the URL is not
// one (`/admin/users/<key>/delete`, `delete_action_url`). Percent-encoded
// segments are decoded: the wire and the action carry the same raw key.
function deletedKey(action) {
  let url;
  try {
    url = new URL(action, 'http://localhost');
  } catch {
    return null;
  }
  const segments = url.pathname.split('/').filter((part) => part !== '');
  if (segments.length < 2 || segments[segments.length - 1] !== 'delete') {
    return null;
  }
  try {
    return decodeURIComponent(segments[segments.length - 2]);
  } catch {
    return null;
  }
}

// The keys a form's write removes: the batch a bulk delete carried, or the one
// record a row-delete form's action names. Empty when the form says neither,
// so an unreadable target prunes nothing rather than clearing the selection. A
// bulk custom action shares the bulk form but posts elsewhere: its rows stay,
// so it removes nothing and the selection survives it.
function removedKeys(form, action) {
  const ids = form.querySelector('input[name="ids"]');
  if (ids) return isBulkDelete(action) ? wireOf(ids.value) : [];
  const key = deletedKey(action);
  return key === null ? [] : [key];
}

// Whether `action` is the bulk-delete route (`/admin/users/bulk-delete`).
function isBulkDelete(action) {
  try {
    return new URL(action, 'http://localhost').pathname.endsWith('/bulk-delete');
  } catch {
    return false;
  }
}

// The selection wire minus the keys this mutation removed. A bulk delete
// removes everything the form carried, so its wire empties; a row delete
// removes one key and leaves the rest of the selection standing.
function pruneWire(wire, removed) {
  const gone = new Set(removed);
  return wireFrom(wireOf(wire).filter((key) => !gone.has(key)));
}

// What a mutation response offers the live page: the table region to replace
// (a static table only) and the toast surfaces to mount.
//
// The region arrives inside a streamed swap envelope (`live!`): the response's
// own `[data-boundary="table"]` is the loading skeleton, and the rendered
// table rides in a `<template data-topcoat-swap>` at the end of the body that
// the page's inline `topcoat.swap` moves into the region at parse time. This
// reads the same content out of the parsed response, preferring the envelope
// and never taking the busy placeholder for a table.
//
// A page can hold several tables — a record page's relations — so a region
// that carries a `data-table` name takes the response region of that name,
// never merely the first one.
function swapTargets(doc, name) {
  const named = name
    ? `[data-boundary="table"][data-table="${name.replace(/["\\]/g, '\\$&')}"]`
    : '[data-boundary="table"]';
  const streamed = Array.from(doc.querySelectorAll('template[data-topcoat-swap]'))
    .map((template) => template.content)
    .find((content) => content.querySelector(named));
  const root = streamed || doc;
  return {
    table: root.querySelector(`${named}:not([aria-busy])`),
    toasts: Array.from(
      doc.querySelectorAll('[data-sonner-toaster] > [data-sonner-toast]'),
    ),
  };
}

// The control that opened a row confirm form's dialog, delete or
// confirmatory action: it carries this record's POST target, which dialog.js
// copies onto the form, so the target is what names the control back.
function triggerFor(action) {
  if (!action) return null;
  return (
    Array.from(document.querySelectorAll('[data-row-delete-action]')).find(
      (el) => el.getAttribute('data-row-delete-action') === action,
    ) || null
  );
}

// The table a mutation form belongs to. The bulk form lives inside its table;
// the row confirm lives in the dialog the page owns, outside every table, so
// its table is the one holding the control that opened it. Scoping here is
// what keeps a page rendering two tables from reading the other one's region,
// refresh control or bulk wire.
function tableRootFor(form, action) {
  const own = form.closest('[data-table-root]');
  if (own) return own;
  const trigger = triggerFor(action);
  return trigger ? trigger.closest('[data-table-root]') : null;
}

// The row a row-delete form targets, found through the same control, so a page
// rendering the same resource twice cannot match the other table's row.
function rowOf(form, region) {
  const trigger = triggerFor(form.getAttribute('action'));
  if (!trigger || !region || !region.contains(trigger)) return null;
  return trigger.closest('tr');
}

// The next revision token. Monotonic per page load, so the write always
// changes the signal — a same-value write is a no-op in the runtime.
let revisionToken = 0;

function bumpRevision(input) {
  revisionToken += 1;
  input.value = `r${revisionToken}`;
  input.dispatchEvent(new Event('change', { bubbles: true }));
}

// Where a submit posts: the submitting button's `formaction` when it carries
// one — a bulk custom action shares the bulk form and its selection, but posts
// to its own route — else the form's own action.
function submitTarget(form, submitter) {
  const own = submitter && submitter.getAttribute && submitter.getAttribute('formaction');
  return own || form.getAttribute('action');
}

// Everything below only makes sense with a document. It lives in a function so
// this file can also be `require`d by its Node unit test, which has no DOM:
// loading the script must not touch one.
function install() {
  document.addEventListener('submit', (event) => {
    const form = event.target.closest('form[data-mutation-submit]');
    if (!form) return;
    // No action: the row dialog is retargeted by dialog.js from the control
    // that opens it, so this is markup the page cannot serve. The browser's
    // own submit is the honest fallback.
    const action = submitTarget(form, event.submitter);
    if (!action) return;
    event.preventDefault();
    send(form, action, event.submitter || form.querySelector('button[type="submit"]'));
  });
}

async function send(form, action, submitter) {
  // The confirm dialog the submit came from: the row form lives inside its
  // dialog, while the bulk form carries its dialogs as children and holds one
  // per confirmation — the delete's, then the confirmatory actions' — so the
  // open one is the one being submitted. Closing it here is what returns focus
  // to the page — a modal dialog left for the response's markup to close (by
  // dropping `open`) strands the document inert, so nothing can be focused at
  // all.
  const dialog = form.closest('dialog') || form.querySelector('dialog[open]');
  // The table this form belongs to (bulk.js scopes itself per table the same
  // way), its region, and the row the delete came from.
  const root = tableRootFor(form, action);
  const region = root ? root.closest('[data-boundary="table"]') : null;
  const row = rowOf(form, region);
  const index = row ? Array.from(row.parentElement.children).indexOf(row) : -1;
  if (submitter) submitter.disabled = true;
  // The confirm dialog belongs to the write until its response lands
  // `dialog.js` reads this marker, so a backdrop click or Escape
  // cannot close it and leave the response to close whatever dialog the next
  // click opened in its place.
  if (dialog) dialog.dataset.dialogBusy = 'true';

  let response;
  try {
    response = await fetch(action, {
      method: 'POST',
      body: new FormData(form),
      credentials: 'same-origin',
      redirect: 'follow',
    });
  } catch {
    // The request never completed, so whether the write landed is unknown.
    // Reloading shows the server's truth instead of guessing at it.
    window.location.reload();
    return;
  }

  // No redirect was followed, so the POST answered itself. Whether the server
  // committed the write before answering is not knowable here, so the response
  // is shown where the browser would have shown it and the mutation is not
  // sent again: a delete that already committed must not be repeated because
  // the render behind it failed.
  if (!response.redirected) {
    if (submitter) submitter.disabled = false;
    clearDialogBusy(dialog);
    showResponse(await response.text());
    return;
  }
  // The write is over, so the button that started it is usable again: on a
  // live page the dialog survives the rerun, and a disabled Delete would make
  // the next row's delete a dead click.
  if (submitter) submitter.disabled = false;
  // The redirect was followed, so the delete committed. A list render that
  // failed behind it is not a reason to post the delete again: reload and let
  // the reader see the state the server actually holds.
  if (!response.ok) {
    window.location.reload();
    return;
  }

  const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
  const { table, toasts } = swapTargets(doc, region && region.getAttribute('data-table'));
  const revision = root ? root.querySelector('[data-table-revision]') : null;
  // The write landed and its response is in hand: the dialog is dismissible
  // again before the page starts applying it.
  clearDialogBusy(dialog);
  // Nothing to update in place: a response the page cannot place is a page
  // the reader should be looking at.
  if (!region || (!revision && !table)) {
    window.location.assign(response.url);
    return;
  }
  // The selection the write removed, and the wire it leaves behind. Read now,
  // not at submit time: every submit listener has run by the time the response
  // lands, so this is the batch the form actually carried.
  const transport = region.querySelector('form[data-bulk-form] input[name="ids"]');
  const removed = removedKeys(form, action);
  const wire =
    transport && removed.length > 0
      ? pruneWire(transport.value, removed)
      : transport
        ? transport.value
        : '';

  insertToasts(toasts);
  dismiss(dialog);

  if (revision) {
    // The shard re-renders the region in place (fresh rows, same query,
    // hydrated markup) and cancels any rerun still in flight. The URL keeps
    // the state the table still holds; only the dialog's own parameters go.
    writeSelection(transport, wire);
    bumpRevision(revision);
    const url = new URL(window.location.href);
    url.searchParams.delete('delete');
    url.searchParams.delete('open');
    window.history.replaceState(window.history.state, '', url);
    afterRegionChange(region, () => focusAfter(region, index));
  } else {
    region.replaceChildren(
      ...Array.from(table.childNodes).map((node) => document.importNode(node, true)),
    );
    // The response's transport carries the server's empty wire: re-apply the
    // pruned one so a row delete does not clear the rest of the selection.
    writeSelection(
      region.querySelector('form[data-bulk-form] input[name="ids"]'),
      wire,
    );
    window.history.replaceState(window.history.state, '', response.url);
    focusAfter(region, index);
  }
}

// Write a selection wire into the bulk transport. The runtime writes the bound
// selection signal; a static table has nothing listening and bulk.js re-applies
// the boxes from the wire.
function writeSelection(transport, wire) {
  if (!transport || transport.value === wire) return;
  transport.value = wire;
  transport.dispatchEvent(new Event('change', { bubbles: true }));
}

// A toast surface is inserted, never morphed: morphing one re-syncs
// `data-mounted` from the response (`false`) on a surface notifications.js
// has already mounted, and the arming observer only sees added nodes — the
// toast would stay hidden. `notifications.js` arms what this inserts.
function insertToasts(toasts) {
  const toaster = document.querySelector('[data-sonner-toaster]');
  if (!toaster || toasts.length === 0) return;
  toaster.prepend(...toasts.map((toast) => document.importNode(toast, true)));
}

function dismiss(dialog) {
  if (dialog && dialog.open) dialog.close();
}

// Hand the dialog back to `dialog.js` once the mutation is over, whatever the
// response holds: the marker only holds it while the write is outstanding.
function clearDialogBusy(dialog) {
  if (dialog) delete dialog.dataset.dialogBusy;
}

// Show a response the client already fetched, without issuing the request
// again. The browser's own submit renders the server's answer, but repeating a
// mutation to get that rendering is what must not happen; writing the fetched
// markup into the document shows the same page the no-JS POST would have.
function showResponse(html) {
  document.open();
  document.write(html);
  document.close();
}

// Focus where the mutated row stood: the row that took its place, else the
// last row, else the bulk bar's first control — its delete trigger, or its first
// custom action when the bar has no delete. The table is the reader's context, and the
// control that opened the dialog is usually the element that just left. The
// region is the element captured at submit time — both paths keep it and
// replace or morph what is inside it.
function focusAfter(region, index) {
  const tbody = region.querySelector('tbody');
  const rows = tbody ? Array.from(tbody.children) : [];
  const target = index >= 0 ? rows[Math.min(index, rows.length - 1)] : null;
  const control =
    (target &&
      // The row's own Delete first (the control this flow is driven from),
      // then its confirmatory trigger, then anything else a reader can land
      // on. A disabled control — a row the policy refuses — cannot take focus,
      // so it does not count.
      (target.querySelector('a[data-row-delete-action]') ||
        target.querySelector('button[data-row-delete-action]') ||
        target.querySelector(
          'a[href], button:not([disabled]), input:not([type="hidden"]):not([disabled])',
        ))) ||
    region.querySelector('[data-bulk-confirm-trigger]') ||
    region.querySelector('[data-bulk-action-confirm-trigger]') ||
    region.querySelector('[data-bulk-action]');
  if (control) control.focus();
}

// Runs `run` once the region's content has changed — the shard's re-render
// replaces the rows without a page event, and it is the re-render (not the
// response) that puts the new rows in the document. The timeout only drops the
// observer: a rerun that never lands must not focus anything later.
function afterRegionChange(region, run) {
  let done = false;
  const finish = (focus) => {
    if (done) return;
    done = true;
    observer.disconnect();
    window.clearTimeout(timer);
    if (focus) run();
  };
  const observer = new MutationObserver(() => finish(true));
  observer.observe(region, { childList: true, subtree: true });
  const timer = window.setTimeout(() => finish(false), 3000);
}

if (typeof document !== 'undefined') install();

// Exposed for the Node unit test (`mutation-submit.test.js`); see `bulk.js`
// for the guard.
if (typeof module !== 'undefined' && module.exports) {
  module.exports = {
    deletedKey,
    pruneWire,
    removedKeys,
    submitTarget,
    swapTargets,
    tableRootFor,
  };
}
})();