@ketvietlab/ketsuite 0.1.17 → 0.1.19
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/dist/cli.js +1 -1
- package/dist/modules/backend/design/client/backend-shell.d.mts +2 -2
- package/dist/modules/backend/design/client/backend-shell.d.mts.map +1 -1
- package/dist/modules/backend/design/client/backend-shell.mjs +1 -1
- package/dist/modules/backend/design/client/backend-shell.mjs.map +3 -3
- package/dist/modules/backend/design/content.css +5 -0
- package/dist/scaffold/index.js +1 -1
- package/dist/ui/client/record-modal.d.ts +150 -0
- package/dist/ui/client/record-modal.d.ts.map +1 -0
- package/dist/ui/client/record-modal.js +725 -0
- package/dist/ui/client/record-modal.js.map +1 -0
- package/dist/ui/client/table-selection-view.d.ts.map +1 -1
- package/dist/ui/client/table-selection-view.js +14 -0
- package/dist/ui/client/table-selection-view.js.map +1 -1
- package/dist/ui/hooks.d.ts.map +1 -1
- package/dist/ui/hooks.js +3 -0
- package/dist/ui/hooks.js.map +1 -1
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +2 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/record-modal.d.ts +41 -0
- package/dist/ui/record-modal.d.ts.map +1 -0
- package/dist/ui/record-modal.js +65 -0
- package/dist/ui/record-modal.js.map +1 -0
- package/package.json +4 -4
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "@ketvietlab/ketjs-view/jsx-runtime";
|
|
2
|
+
// The record-modal contract, browser half.
|
|
3
|
+
//
|
|
4
|
+
// A module describes a record — how to read it, its tabs, dialogs and commands —
|
|
5
|
+
// and this runtime owns everything that must behave the same in every module:
|
|
6
|
+
// opening from a link or deep link, history, focus, Escape, the inert background,
|
|
7
|
+
// loading and failure states, submitting through `/_ket/fn`, showing a refusal on
|
|
8
|
+
// the field that caused it without losing what was typed, the unsaved-input guard,
|
|
9
|
+
// and telling the collection behind the modal that its records changed.
|
|
10
|
+
//
|
|
11
|
+
// Views are render-pure: they read the context and return design-system markup.
|
|
12
|
+
// They never fetch, never touch history and never query the document.
|
|
13
|
+
import { signal } from '@ketvietlab/ketjs-view';
|
|
14
|
+
import { LoadingState, ModalSheet, Notice, Tabs, Button } from '@ketvietlab/design-system';
|
|
15
|
+
import { RECORD_PARAM, RECORD_TAB_PARAM, readRecordModalTarget, recordModalClosedHref, recordModalHost, recordModalHref, } from '../record-modal.js';
|
|
16
|
+
const issuesOf = (source) => {
|
|
17
|
+
const holder = (source ?? {});
|
|
18
|
+
const list = Array.isArray(holder.errors)
|
|
19
|
+
? holder.errors
|
|
20
|
+
: Array.isArray(holder.issues)
|
|
21
|
+
? holder.issues
|
|
22
|
+
: [];
|
|
23
|
+
return list.map((raw) => {
|
|
24
|
+
const item = (raw ?? {});
|
|
25
|
+
const path = Array.isArray(item.path) ? item.path.join('.') : null;
|
|
26
|
+
return {
|
|
27
|
+
field: typeof item.field === 'string' ? item.field : path,
|
|
28
|
+
code: String(item.code ?? 'invalid'),
|
|
29
|
+
message: typeof item.message === 'string' ? item.message : null,
|
|
30
|
+
params: (item.params && typeof item.params === 'object' ? item.params : {}),
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Call a server function as the signed-in viewer.
|
|
36
|
+
*
|
|
37
|
+
* A domain refusal (`{ ok: false, errors }` inside the value) and a transport or
|
|
38
|
+
* permission refusal (`ok: false` on the envelope) come back in one shape, so a
|
|
39
|
+
* view never has to know which layer said no.
|
|
40
|
+
*/
|
|
41
|
+
export const callRecordFunction = async (name, input, options = {}) => {
|
|
42
|
+
const response = await fetch(`/_ket/fn/${encodeURIComponent(name)}`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
credentials: 'same-origin',
|
|
45
|
+
headers: {
|
|
46
|
+
'content-type': 'application/json',
|
|
47
|
+
...(options.idempotencyKey ? { 'idempotency-key': options.idempotencyKey } : {}),
|
|
48
|
+
},
|
|
49
|
+
body: JSON.stringify(input),
|
|
50
|
+
signal: options.signal,
|
|
51
|
+
});
|
|
52
|
+
const payload = (await response.json().catch(() => ({})));
|
|
53
|
+
if (!response.ok || payload.ok === false)
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
issues: issuesOf(payload.error ?? payload),
|
|
57
|
+
message: payload.error?.message ?? payload.message ?? null,
|
|
58
|
+
status: response.status,
|
|
59
|
+
};
|
|
60
|
+
const value = payload.value;
|
|
61
|
+
if (value && typeof value === 'object' && value.ok === false)
|
|
62
|
+
return { ok: false, issues: issuesOf(value), message: null, status: response.status };
|
|
63
|
+
return { ok: true, value: payload.value };
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* English defaults for every label the runtime itself shows. A module passes its
|
|
67
|
+
* own language through `labels`; these only guarantee a reader never sees a key.
|
|
68
|
+
*/
|
|
69
|
+
export const RECORD_MODAL_LABELS = Object.freeze({
|
|
70
|
+
'recordModal.close': 'Close',
|
|
71
|
+
'recordModal.loading': 'Loading…',
|
|
72
|
+
'recordModal.loadFailed': 'The record could not be read.',
|
|
73
|
+
'recordModal.notFound': 'The record is gone or you cannot see it.',
|
|
74
|
+
'recordModal.retry': 'Retry',
|
|
75
|
+
'recordModal.errorTitle': 'Not saved',
|
|
76
|
+
'recordModal.saveFailed': 'That did not work. Try again.',
|
|
77
|
+
'recordModal.unsaved': 'Discard what you typed?',
|
|
78
|
+
'recordModal.uploadFailed': 'The file could not be uploaded. Try again.',
|
|
79
|
+
});
|
|
80
|
+
/**
|
|
81
|
+
* The text for a key: the loaded context's messages, then the messages of the
|
|
82
|
+
* last record this modal opened, then the module's labels, then the defaults.
|
|
83
|
+
* Only an unknown key falls through to itself.
|
|
84
|
+
*/
|
|
85
|
+
export const resolveRecordModalLabel = (key, sources) => sources.messages?.[key] ??
|
|
86
|
+
sources.previous?.[key] ??
|
|
87
|
+
sources.labels?.[key] ??
|
|
88
|
+
RECORD_MODAL_LABELS[key] ??
|
|
89
|
+
key;
|
|
90
|
+
/** Field a form (or its submitter) uses to name the command it runs. */
|
|
91
|
+
export const RECORD_COMMAND_FIELD = '__command';
|
|
92
|
+
/** Field a link or button uses to open a dialog of the same record. */
|
|
93
|
+
export const RECORD_DIALOG_ATTRIBUTE = 'data-record-dialog';
|
|
94
|
+
// ── Runtime helpers ───────────────────────────────────────────────────────────
|
|
95
|
+
const interpolate = (text, params = {}) => text.replace(/\{([a-zA-Z0-9_]+)\}/gu, (whole, name) => name in params ? String(params[name] ?? '') : whole);
|
|
96
|
+
const focusable = [
|
|
97
|
+
'a[href]',
|
|
98
|
+
'button:not(:disabled)',
|
|
99
|
+
'input:not(:disabled):not([type="hidden"])',
|
|
100
|
+
'select:not(:disabled)',
|
|
101
|
+
'textarea:not(:disabled)',
|
|
102
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
103
|
+
].join(', ');
|
|
104
|
+
const focusablesIn = (element) => [...element.querySelectorAll(focusable)].filter((item) => !item.hidden &&
|
|
105
|
+
item.getAttribute('aria-hidden') !== 'true' &&
|
|
106
|
+
!item.matches('[data-ui="modal-backdrop"]'));
|
|
107
|
+
/** Whether anything in a layer was typed into since it rendered. Same rule as route modals. */
|
|
108
|
+
export const recordLayerHasDraft = (layer) => {
|
|
109
|
+
for (const field of layer.querySelectorAll('input:not([type="hidden"])')) {
|
|
110
|
+
if (field.disabled)
|
|
111
|
+
continue;
|
|
112
|
+
if (field.type === 'checkbox' || field.type === 'radio') {
|
|
113
|
+
if (field.checked !== field.defaultChecked)
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
else if (field.value !== field.defaultValue)
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
for (const area of layer.querySelectorAll('textarea'))
|
|
120
|
+
if (!area.disabled && area.value !== area.defaultValue)
|
|
121
|
+
return true;
|
|
122
|
+
for (const select of layer.querySelectorAll('select')) {
|
|
123
|
+
if (select.disabled)
|
|
124
|
+
continue;
|
|
125
|
+
for (const option of select.options)
|
|
126
|
+
if (option.selected !== option.defaultSelected)
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
};
|
|
131
|
+
/** Make everything outside `keep` inert, walking up to the body; returns the undo. */
|
|
132
|
+
const inertOutside = (keep) => {
|
|
133
|
+
const changed = [];
|
|
134
|
+
let node = keep;
|
|
135
|
+
while (node && node !== document.body) {
|
|
136
|
+
const parent = node.parentElement;
|
|
137
|
+
if (!parent)
|
|
138
|
+
break;
|
|
139
|
+
for (const sibling of parent.children)
|
|
140
|
+
if (sibling instanceof HTMLElement && sibling !== node && !sibling.matches('script, style, template')) {
|
|
141
|
+
changed.push({ element: sibling, inert: sibling.inert });
|
|
142
|
+
sibling.inert = true;
|
|
143
|
+
}
|
|
144
|
+
node = parent;
|
|
145
|
+
}
|
|
146
|
+
return () => {
|
|
147
|
+
for (const { element, inert } of changed)
|
|
148
|
+
element.inert = inert;
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
const uuid = () => typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
152
|
+
? crypto.randomUUID()
|
|
153
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
154
|
+
/**
|
|
155
|
+
* Create the island controller for one record kind.
|
|
156
|
+
*
|
|
157
|
+
* The returned factory is what a module exports as its island client:
|
|
158
|
+
* `export const followupModal = createRecordModal(definition)`.
|
|
159
|
+
*/
|
|
160
|
+
export const createRecordModal = (definition) => () => {
|
|
161
|
+
const open = signal(null);
|
|
162
|
+
const status = signal('idle');
|
|
163
|
+
const envelope = signal(null);
|
|
164
|
+
const failure = signal(null);
|
|
165
|
+
const issues = signal([]);
|
|
166
|
+
const drafts = signal({});
|
|
167
|
+
const busy = signal(false);
|
|
168
|
+
const dialog = signal(null);
|
|
169
|
+
const version = signal(0);
|
|
170
|
+
const viewState = signal({});
|
|
171
|
+
let root = null;
|
|
172
|
+
let request = null;
|
|
173
|
+
let pushed = false;
|
|
174
|
+
let returnFocus = null;
|
|
175
|
+
let releaseInert = null;
|
|
176
|
+
// Messages of the last record this modal loaded: opening the next record clears
|
|
177
|
+
// the envelope, and its loading state still needs words rather than keys.
|
|
178
|
+
let previousMessages = null;
|
|
179
|
+
const labels = () => typeof definition.labels === 'function' ? definition.labels() : (definition.labels ?? {});
|
|
180
|
+
const t = (key, params) => interpolate(resolveRecordModalLabel(key, {
|
|
181
|
+
messages: envelope()?.messages,
|
|
182
|
+
previous: previousMessages,
|
|
183
|
+
labels: labels(),
|
|
184
|
+
}), params);
|
|
185
|
+
const visibleTabs = (context) => (definition.tabs ?? []).filter((tab) => tab.visible?.(context) ?? true);
|
|
186
|
+
const contextFor = (current, data) => {
|
|
187
|
+
const base = {
|
|
188
|
+
kind: definition.kind,
|
|
189
|
+
id: current.id,
|
|
190
|
+
tab: current.tab,
|
|
191
|
+
data,
|
|
192
|
+
t,
|
|
193
|
+
fieldError: (name) => {
|
|
194
|
+
const hit = issues().find((issue) => issue.field === name);
|
|
195
|
+
return hit ? (hit.message ?? t(hit.code, hit.params)) : null;
|
|
196
|
+
},
|
|
197
|
+
draft: (name, fallback = '') => drafts()[name] ?? fallback,
|
|
198
|
+
busy: busy(),
|
|
199
|
+
dialog: dialog(),
|
|
200
|
+
href: (tab) => recordModalHref(location.href, { kind: definition.kind, id: current.id, tab }),
|
|
201
|
+
state: (key, fallback = '') => viewState()[key] ?? fallback,
|
|
202
|
+
};
|
|
203
|
+
const tabs = visibleTabs(base);
|
|
204
|
+
if (tabs.length && !tabs.some((tab) => tab.id === current.tab))
|
|
205
|
+
base.tab = tabs[0].id;
|
|
206
|
+
return base;
|
|
207
|
+
};
|
|
208
|
+
const load = async (id, quiet = false) => {
|
|
209
|
+
request?.abort();
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
request = controller;
|
|
212
|
+
if (!quiet)
|
|
213
|
+
status.set('loading');
|
|
214
|
+
failure.set(null);
|
|
215
|
+
try {
|
|
216
|
+
const input = definition.context.input ? definition.context.input(id) : { id };
|
|
217
|
+
const result = await callRecordFunction(definition.context.fn, input, {
|
|
218
|
+
signal: controller.signal,
|
|
219
|
+
});
|
|
220
|
+
if (controller.signal.aborted)
|
|
221
|
+
return;
|
|
222
|
+
if (!result.ok || !result.value) {
|
|
223
|
+
failure.set(result.ok ? 'recordModal.notFound' : (result.message ?? 'recordModal.loadFailed'));
|
|
224
|
+
status.set('error');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
previousMessages = result.value.messages ?? previousMessages;
|
|
228
|
+
envelope.set(result.value);
|
|
229
|
+
status.set('ready');
|
|
230
|
+
}
|
|
231
|
+
catch (caught) {
|
|
232
|
+
if (controller.signal.aborted || caught?.name === 'AbortError')
|
|
233
|
+
return;
|
|
234
|
+
failure.set('recordModal.loadFailed');
|
|
235
|
+
status.set('error');
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const layer = () => root?.querySelector('[data-ui="modal-layer"][data-client-modal="true"]') ?? null;
|
|
239
|
+
const keepDrafts = () => {
|
|
240
|
+
const current = layer();
|
|
241
|
+
if (!current || !recordLayerHasDraft(current))
|
|
242
|
+
return;
|
|
243
|
+
const kept = {};
|
|
244
|
+
for (const form of current.querySelectorAll('form'))
|
|
245
|
+
for (const [key, value] of new FormData(form))
|
|
246
|
+
if (typeof value === 'string' && key !== RECORD_COMMAND_FIELD)
|
|
247
|
+
kept[key] = value;
|
|
248
|
+
drafts.set({ ...drafts(), ...kept });
|
|
249
|
+
};
|
|
250
|
+
const mayDiscard = () => {
|
|
251
|
+
const current = layer();
|
|
252
|
+
if (!current || !recordLayerHasDraft(current))
|
|
253
|
+
return true;
|
|
254
|
+
return globalThis.confirm(t('recordModal.unsaved'));
|
|
255
|
+
};
|
|
256
|
+
const afterRender = () => {
|
|
257
|
+
requestAnimationFrame(() => {
|
|
258
|
+
const layers = root?.querySelectorAll('[data-ui="modal-layer"][data-client-modal="true"]');
|
|
259
|
+
const top = layers?.[layers.length - 1];
|
|
260
|
+
if (!top)
|
|
261
|
+
return;
|
|
262
|
+
if (top.contains(document.activeElement))
|
|
263
|
+
return;
|
|
264
|
+
const sheet = top.querySelector('[data-ui="modal-sheet"]');
|
|
265
|
+
const first = focusablesIn(top).find((item) => !item.matches('[data-ui="modal-close"]'));
|
|
266
|
+
(first ?? sheet)?.focus();
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
const show = (id, tab, how) => {
|
|
270
|
+
const current = open();
|
|
271
|
+
const sameRecord = current?.id === id;
|
|
272
|
+
const nextTab = tab ?? (sameRecord ? current.tab : '');
|
|
273
|
+
if (!sameRecord) {
|
|
274
|
+
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
275
|
+
issues.set([]);
|
|
276
|
+
drafts.set({});
|
|
277
|
+
viewState.set({});
|
|
278
|
+
dialog.set(null);
|
|
279
|
+
envelope.set(null);
|
|
280
|
+
void load(id);
|
|
281
|
+
}
|
|
282
|
+
open.set({ id, tab: nextTab });
|
|
283
|
+
const href = recordModalHref(location.href, { kind: definition.kind, id, tab: nextTab || null });
|
|
284
|
+
if (how === 'push') {
|
|
285
|
+
history.pushState({ ...(history.state ?? {}), __ketRecordModal: definition.kind }, '', href);
|
|
286
|
+
pushed = true;
|
|
287
|
+
}
|
|
288
|
+
else if (how === 'replace')
|
|
289
|
+
history.replaceState(history.state ?? {}, '', href);
|
|
290
|
+
if (root && !releaseInert)
|
|
291
|
+
releaseInert = inertOutside(root);
|
|
292
|
+
afterRender();
|
|
293
|
+
};
|
|
294
|
+
const hide = (how) => {
|
|
295
|
+
request?.abort();
|
|
296
|
+
open.set(null);
|
|
297
|
+
dialog.set(null);
|
|
298
|
+
issues.set([]);
|
|
299
|
+
drafts.set({});
|
|
300
|
+
viewState.set({});
|
|
301
|
+
status.set('idle');
|
|
302
|
+
envelope.set(null);
|
|
303
|
+
releaseInert?.();
|
|
304
|
+
releaseInert = null;
|
|
305
|
+
if (how === 'history' && pushed) {
|
|
306
|
+
pushed = false;
|
|
307
|
+
history.back();
|
|
308
|
+
}
|
|
309
|
+
else if (how !== 'none') {
|
|
310
|
+
pushed = false;
|
|
311
|
+
history.replaceState(history.state ?? {}, '', recordModalClosedHref(location.href));
|
|
312
|
+
}
|
|
313
|
+
const target = returnFocus;
|
|
314
|
+
returnFocus = null;
|
|
315
|
+
if (target?.isConnected)
|
|
316
|
+
requestAnimationFrame(() => target.focus());
|
|
317
|
+
};
|
|
318
|
+
const close = () => {
|
|
319
|
+
if (dialog()) {
|
|
320
|
+
if (!mayDiscard())
|
|
321
|
+
return;
|
|
322
|
+
dialog.set(null);
|
|
323
|
+
issues.set([]);
|
|
324
|
+
afterRender();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (!mayDiscard())
|
|
328
|
+
return;
|
|
329
|
+
hide('history');
|
|
330
|
+
};
|
|
331
|
+
const run = async (name, form, submitter) => {
|
|
332
|
+
const command = definition.commands?.[name];
|
|
333
|
+
const current = open();
|
|
334
|
+
const data = envelope()?.data;
|
|
335
|
+
if (!command || !current || data === undefined || busy())
|
|
336
|
+
return;
|
|
337
|
+
const formData = new FormData(form, submitter instanceof HTMLButtonElement ? submitter : null);
|
|
338
|
+
const context = contextFor(current, data);
|
|
339
|
+
busy.set(true);
|
|
340
|
+
issues.set([]);
|
|
341
|
+
try {
|
|
342
|
+
const uploads = {};
|
|
343
|
+
for (const [field, target] of Object.entries(command.upload ?? {})) {
|
|
344
|
+
const file = formData.get(field);
|
|
345
|
+
if (!(file instanceof File) || file.size === 0)
|
|
346
|
+
continue;
|
|
347
|
+
const body = new FormData();
|
|
348
|
+
for (const [key, value] of Object.entries(target(formData, context)))
|
|
349
|
+
body.append(key, value);
|
|
350
|
+
body.append('file', file, file.name);
|
|
351
|
+
const response = await fetch('/files', { method: 'POST', credentials: 'same-origin', body });
|
|
352
|
+
const stored = (await response.json().catch(() => null));
|
|
353
|
+
if (!response.ok || !stored?.id) {
|
|
354
|
+
issues.set([
|
|
355
|
+
{
|
|
356
|
+
field: null,
|
|
357
|
+
code: 'recordModal.uploadFailed',
|
|
358
|
+
message: typeof stored?.message === 'string' ? stored.message : null,
|
|
359
|
+
params: {},
|
|
360
|
+
},
|
|
361
|
+
]);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
uploads[field] = {
|
|
365
|
+
id: String(stored.id),
|
|
366
|
+
name: typeof stored.name === 'string' ? stored.name : null,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const result = await callRecordFunction(command.fn, command.input(formData, context, uploads), {
|
|
370
|
+
idempotencyKey: uuid(),
|
|
371
|
+
});
|
|
372
|
+
if (!result.ok) {
|
|
373
|
+
// Keep what was typed: the view reads it back through `draft`.
|
|
374
|
+
const kept = {};
|
|
375
|
+
for (const [key, value] of formData)
|
|
376
|
+
if (typeof value === 'string')
|
|
377
|
+
kept[key] = value;
|
|
378
|
+
drafts.set({ ...drafts(), ...kept });
|
|
379
|
+
issues.set(result.issues.length
|
|
380
|
+
? result.issues
|
|
381
|
+
: [
|
|
382
|
+
{
|
|
383
|
+
field: null,
|
|
384
|
+
code: result.message ?? 'recordModal.saveFailed',
|
|
385
|
+
message: result.message,
|
|
386
|
+
params: {},
|
|
387
|
+
},
|
|
388
|
+
]);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
drafts.set({});
|
|
392
|
+
form.reset();
|
|
393
|
+
document.dispatchEvent(new CustomEvent('ket:records-changed', { detail: { kind: definition.kind, ids: [current.id] } }));
|
|
394
|
+
const after = command.after ?? 'close';
|
|
395
|
+
if (after === 'close')
|
|
396
|
+
hide('history');
|
|
397
|
+
else if (after === 'reload') {
|
|
398
|
+
dialog.set(null);
|
|
399
|
+
await load(current.id);
|
|
400
|
+
}
|
|
401
|
+
else if (after === 'refresh')
|
|
402
|
+
await load(current.id, true);
|
|
403
|
+
else if (after === 'stay')
|
|
404
|
+
version.set(version() + 1);
|
|
405
|
+
else if ('tab' in after) {
|
|
406
|
+
dialog.set(null);
|
|
407
|
+
show(current.id, after.tab, 'replace');
|
|
408
|
+
await load(current.id);
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
dialog.set(after.dialog ? { name: after.dialog, params: {} } : null);
|
|
412
|
+
await load(current.id, true);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
issues.set([{ field: null, code: 'recordModal.saveFailed', message: null, params: {} }]);
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
busy.set(false);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
const formIssues = (context) => {
|
|
423
|
+
const general = issues().filter((issue) => !issue.field);
|
|
424
|
+
const fieldless = issues().filter((issue) => issue.field && !root?.querySelector(`[name="${CSS.escape(issue.field)}"]`));
|
|
425
|
+
const all = [...general, ...fieldless];
|
|
426
|
+
if (!all.length)
|
|
427
|
+
return '';
|
|
428
|
+
return Notice({
|
|
429
|
+
title: context.t('recordModal.errorTitle'),
|
|
430
|
+
message: all.map((issue) => issue.message ?? context.t(issue.code, issue.params)).join(' · '),
|
|
431
|
+
tone: 'danger',
|
|
432
|
+
});
|
|
433
|
+
};
|
|
434
|
+
const recordBody = () => {
|
|
435
|
+
const current = open();
|
|
436
|
+
if (!current)
|
|
437
|
+
return '';
|
|
438
|
+
if (status() === 'error')
|
|
439
|
+
return (_jsxs(_Fragment, { children: [Notice({
|
|
440
|
+
title: t('recordModal.errorTitle'),
|
|
441
|
+
message: t(failure() ?? 'recordModal.loadFailed'),
|
|
442
|
+
tone: 'danger',
|
|
443
|
+
}), Button({ label: t('recordModal.retry'), variant: 'secondary', name: 'intent', value: 'retry' })] }));
|
|
444
|
+
const data = envelope()?.data;
|
|
445
|
+
if (status() !== 'ready' || data === undefined)
|
|
446
|
+
return LoadingState({ label: t('recordModal.loading') });
|
|
447
|
+
const context = contextFor(current, data);
|
|
448
|
+
const tabs = visibleTabs(context);
|
|
449
|
+
const active = tabs.find((tab) => tab.id === context.tab);
|
|
450
|
+
return (_jsxs(_Fragment, { children: [definition.header?.(context) ?? '', formIssues(context), tabs.length
|
|
451
|
+
? Tabs({
|
|
452
|
+
label: definition.title(context),
|
|
453
|
+
items: tabs.map((tab) => ({
|
|
454
|
+
id: tab.id,
|
|
455
|
+
label: tab.label(context),
|
|
456
|
+
href: context.href(tab.id),
|
|
457
|
+
active: tab.id === context.tab,
|
|
458
|
+
})),
|
|
459
|
+
})
|
|
460
|
+
: '', active ? active.view(context) : (definition.body?.(context) ?? '')] }));
|
|
461
|
+
};
|
|
462
|
+
const dialogLayer = () => {
|
|
463
|
+
const current = open();
|
|
464
|
+
const opened = dialog();
|
|
465
|
+
const data = envelope()?.data;
|
|
466
|
+
if (!current || !opened || data === undefined)
|
|
467
|
+
return '';
|
|
468
|
+
const spec = definition.dialogs?.[opened.name];
|
|
469
|
+
if (!spec)
|
|
470
|
+
return '';
|
|
471
|
+
const context = contextFor(current, data);
|
|
472
|
+
return ModalSheet({
|
|
473
|
+
id: `record-dialog-${definition.kind.replaceAll('.', '-')}-${opened.name}`,
|
|
474
|
+
mode: 'client',
|
|
475
|
+
presentation: 'dialog',
|
|
476
|
+
size: spec.size ?? 'default',
|
|
477
|
+
title: spec.title(context),
|
|
478
|
+
closeLabel: t('recordModal.close'),
|
|
479
|
+
body: (_jsxs(_Fragment, { children: [formIssues(context), spec.view(context)] })),
|
|
480
|
+
});
|
|
481
|
+
};
|
|
482
|
+
return {
|
|
483
|
+
view: () => {
|
|
484
|
+
version();
|
|
485
|
+
const current = open();
|
|
486
|
+
if (!current)
|
|
487
|
+
return recordModalHost(definition.kind);
|
|
488
|
+
const data = envelope()?.data;
|
|
489
|
+
const context = data === undefined ? null : contextFor(current, data);
|
|
490
|
+
return (_jsxs(_Fragment, { children: [recordModalHost(definition.kind), ModalSheet({
|
|
491
|
+
id: `record-modal-${definition.kind.replaceAll('.', '-')}`,
|
|
492
|
+
mode: 'client',
|
|
493
|
+
presentation: 'dialog',
|
|
494
|
+
size: definition.size ?? 'default',
|
|
495
|
+
title: context ? definition.title(context) : t('recordModal.loading'),
|
|
496
|
+
description: context ? (definition.description?.(context) ?? null) : null,
|
|
497
|
+
closeLabel: t('recordModal.close'),
|
|
498
|
+
body: recordBody(),
|
|
499
|
+
}), dialogLayer()] }));
|
|
500
|
+
},
|
|
501
|
+
mount: ({ root: host, lifetime }) => {
|
|
502
|
+
root = host;
|
|
503
|
+
// Links anywhere on the page that name this kind open it; a link naming the
|
|
504
|
+
// record already open switches its tab. Runs before the navigation layer.
|
|
505
|
+
document.addEventListener('click', (event) => {
|
|
506
|
+
if (event.defaultPrevented ||
|
|
507
|
+
event.button !== 0 ||
|
|
508
|
+
event.metaKey ||
|
|
509
|
+
event.ctrlKey ||
|
|
510
|
+
event.shiftKey ||
|
|
511
|
+
event.altKey)
|
|
512
|
+
return;
|
|
513
|
+
const element = event.target instanceof Element ? event.target : null;
|
|
514
|
+
const inModal = element?.closest('[data-ui="modal-layer"][data-client-modal="true"]');
|
|
515
|
+
if (inModal && root?.contains(inModal)) {
|
|
516
|
+
if (element?.closest('[data-ui="modal-close"], [data-ui="modal-backdrop"]')) {
|
|
517
|
+
event.preventDefault();
|
|
518
|
+
close();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const stateControl = element?.closest('button[data-record-state], a[data-record-state]');
|
|
522
|
+
if (stateControl) {
|
|
523
|
+
event.preventDefault();
|
|
524
|
+
viewState.set({
|
|
525
|
+
...viewState(),
|
|
526
|
+
[stateControl.getAttribute('data-record-state') ?? '']: stateControl.getAttribute('data-record-value') ?? '',
|
|
527
|
+
});
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const opener = element?.closest(`[${RECORD_DIALOG_ATTRIBUTE}]`);
|
|
531
|
+
if (opener) {
|
|
532
|
+
event.preventDefault();
|
|
533
|
+
const params = {};
|
|
534
|
+
for (const [key, value] of Object.entries(opener.dataset))
|
|
535
|
+
if (key.startsWith('recordParam') && value !== undefined)
|
|
536
|
+
params[key.slice('recordParam'.length).replace(/^./u, (c) => c.toLowerCase())] = value;
|
|
537
|
+
issues.set([]);
|
|
538
|
+
dialog.set({ name: opener.getAttribute(RECORD_DIALOG_ATTRIBUTE) ?? '', params });
|
|
539
|
+
afterRender();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (element?.closest('button[name="intent"][value="retry"]')) {
|
|
543
|
+
event.preventDefault();
|
|
544
|
+
const current = open();
|
|
545
|
+
if (current)
|
|
546
|
+
void load(current.id);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const anchor = element?.closest('a[href]');
|
|
551
|
+
if (!anchor || (anchor.target && anchor.target !== '_self'))
|
|
552
|
+
return;
|
|
553
|
+
const url = new URL(anchor.href, location.href);
|
|
554
|
+
if (url.origin !== location.origin || url.pathname !== location.pathname)
|
|
555
|
+
return;
|
|
556
|
+
const target = readRecordModalTarget(url);
|
|
557
|
+
if (!target || target.kind !== definition.kind)
|
|
558
|
+
return;
|
|
559
|
+
event.preventDefault();
|
|
560
|
+
const current = open();
|
|
561
|
+
if (current?.id === target.id) {
|
|
562
|
+
if ((target.tab ?? '') === current.tab)
|
|
563
|
+
return;
|
|
564
|
+
// Switching tab is not discarding: what was typed rides along as drafts,
|
|
565
|
+
// which the views read back, so no prompt stands between two tabs.
|
|
566
|
+
keepDrafts();
|
|
567
|
+
issues.set([]);
|
|
568
|
+
show(target.id, target.tab ?? null, 'replace');
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
show(target.id, target.tab ?? null, 'push');
|
|
572
|
+
},
|
|
573
|
+
// Capture: the navigation layer listens on the document too, and was
|
|
574
|
+
// installed first, so a bubbling listener would see the page fetched.
|
|
575
|
+
{ signal: lifetime, capture: true });
|
|
576
|
+
document.addEventListener('submit', (event) => {
|
|
577
|
+
const form = event.target instanceof HTMLFormElement ? event.target : null;
|
|
578
|
+
if (!form || !root?.contains(form))
|
|
579
|
+
return;
|
|
580
|
+
event.preventDefault();
|
|
581
|
+
const submitter = event.submitter ?? null;
|
|
582
|
+
const name = (submitter instanceof HTMLButtonElement && submitter.name === RECORD_COMMAND_FIELD
|
|
583
|
+
? submitter.value
|
|
584
|
+
: null) ?? String(new FormData(form).get(RECORD_COMMAND_FIELD) ?? '');
|
|
585
|
+
void run(name, form, submitter);
|
|
586
|
+
}, { signal: lifetime });
|
|
587
|
+
document.addEventListener('keydown', (event) => {
|
|
588
|
+
if (!open())
|
|
589
|
+
return;
|
|
590
|
+
const layers = root?.querySelectorAll('[data-ui="modal-layer"][data-client-modal="true"]');
|
|
591
|
+
const top = layers?.[layers.length - 1];
|
|
592
|
+
if (!top)
|
|
593
|
+
return;
|
|
594
|
+
if (event.key === 'Escape') {
|
|
595
|
+
event.preventDefault();
|
|
596
|
+
close();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (event.key !== 'Tab')
|
|
600
|
+
return;
|
|
601
|
+
const items = focusablesIn(top);
|
|
602
|
+
if (!items.length) {
|
|
603
|
+
event.preventDefault();
|
|
604
|
+
top.querySelector('[data-ui="modal-sheet"]')?.focus();
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const first = items[0];
|
|
608
|
+
const last = items.at(-1);
|
|
609
|
+
if (event.shiftKey &&
|
|
610
|
+
(document.activeElement === first || !top.contains(document.activeElement))) {
|
|
611
|
+
event.preventDefault();
|
|
612
|
+
last.focus();
|
|
613
|
+
}
|
|
614
|
+
else if (!event.shiftKey &&
|
|
615
|
+
(document.activeElement === last || !top.contains(document.activeElement))) {
|
|
616
|
+
event.preventDefault();
|
|
617
|
+
first.focus();
|
|
618
|
+
}
|
|
619
|
+
}, { signal: lifetime });
|
|
620
|
+
// An input or select carrying `data-record-state` feeds view state as it
|
|
621
|
+
// changes; a file chosen in a `data-record-submit` input submits its form.
|
|
622
|
+
document.addEventListener('change', (event) => {
|
|
623
|
+
const control = event.target instanceof Element ? event.target : null;
|
|
624
|
+
if (!control || !root?.contains(control))
|
|
625
|
+
return;
|
|
626
|
+
if ((control instanceof HTMLSelectElement || control instanceof HTMLInputElement) &&
|
|
627
|
+
control.hasAttribute('data-record-state')) {
|
|
628
|
+
viewState.set({
|
|
629
|
+
...viewState(),
|
|
630
|
+
[control.getAttribute('data-record-state') ?? '']: control.value,
|
|
631
|
+
});
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (control instanceof HTMLInputElement &&
|
|
635
|
+
control.type === 'file' &&
|
|
636
|
+
control.hasAttribute('data-record-submit') &&
|
|
637
|
+
control.files?.length)
|
|
638
|
+
control.form?.requestSubmit();
|
|
639
|
+
}, { signal: lifetime });
|
|
640
|
+
// A form marked `data-record-dropzone` takes a dropped file into its file
|
|
641
|
+
// input and submits, so dragging a photo in is the same command as choosing it.
|
|
642
|
+
const dropzoneOf = (event) => {
|
|
643
|
+
const element = event.target instanceof Element ? event.target : null;
|
|
644
|
+
const zone = element?.closest('form[data-record-dropzone]') ?? null;
|
|
645
|
+
return zone && root?.contains(zone) ? zone : null;
|
|
646
|
+
};
|
|
647
|
+
document.addEventListener('dragover', (event) => {
|
|
648
|
+
const zone = dropzoneOf(event);
|
|
649
|
+
if (!zone)
|
|
650
|
+
return;
|
|
651
|
+
event.preventDefault();
|
|
652
|
+
zone.dataset.drag = '1';
|
|
653
|
+
}, { signal: lifetime });
|
|
654
|
+
document.addEventListener('dragleave', (event) => {
|
|
655
|
+
const zone = dropzoneOf(event);
|
|
656
|
+
if (!zone)
|
|
657
|
+
return;
|
|
658
|
+
const related = event.relatedTarget;
|
|
659
|
+
if (related instanceof Node && zone.contains(related))
|
|
660
|
+
return;
|
|
661
|
+
delete zone.dataset.drag;
|
|
662
|
+
}, { signal: lifetime });
|
|
663
|
+
document.addEventListener('drop', (event) => {
|
|
664
|
+
const zone = dropzoneOf(event);
|
|
665
|
+
if (!zone)
|
|
666
|
+
return;
|
|
667
|
+
event.preventDefault();
|
|
668
|
+
delete zone.dataset.drag;
|
|
669
|
+
const files = event.dataTransfer?.files;
|
|
670
|
+
const input = zone.querySelector('input[type="file"]');
|
|
671
|
+
if (!input || !files?.length)
|
|
672
|
+
return;
|
|
673
|
+
const transfer = new DataTransfer();
|
|
674
|
+
transfer.items.add(files[0]);
|
|
675
|
+
input.files = transfer.files;
|
|
676
|
+
zone.requestSubmit();
|
|
677
|
+
}, { signal: lifetime });
|
|
678
|
+
// Islands a view places with `recordIsland` start when their host appears
|
|
679
|
+
// and stop when it leaves, through the page's own island manager.
|
|
680
|
+
const islandSelector = 'ket-island, div[data-ket-island]';
|
|
681
|
+
const holdsIsland = (node) => node instanceof Element &&
|
|
682
|
+
(node.matches(islandSelector) || node.querySelector(islandSelector) !== null);
|
|
683
|
+
const observer = new MutationObserver((records) => {
|
|
684
|
+
for (const record of records) {
|
|
685
|
+
for (const node of record.removedNodes)
|
|
686
|
+
if (holdsIsland(node))
|
|
687
|
+
document.dispatchEvent(new CustomEvent('ket:islands-detach', { detail: { root: node } }));
|
|
688
|
+
for (const node of record.addedNodes)
|
|
689
|
+
if (holdsIsland(node) && node.isConnected)
|
|
690
|
+
document.dispatchEvent(new CustomEvent('ket:islands-attach', { detail: { root: node } }));
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
694
|
+
lifetime.addEventListener('abort', () => observer.disconnect());
|
|
695
|
+
// Back and forward over entries this modal created change only the modal.
|
|
696
|
+
document.addEventListener('ket:popstate', (event) => {
|
|
697
|
+
const target = readRecordModalTarget(location.href);
|
|
698
|
+
const mine = target?.kind === definition.kind;
|
|
699
|
+
if (!mine && !open())
|
|
700
|
+
return;
|
|
701
|
+
event.preventDefault();
|
|
702
|
+
if (mine && target)
|
|
703
|
+
show(target.id, target.tab ?? null, 'none');
|
|
704
|
+
else
|
|
705
|
+
hide('none');
|
|
706
|
+
}, { signal: lifetime });
|
|
707
|
+
const initial = readRecordModalTarget(location.href);
|
|
708
|
+
if (initial?.kind === definition.kind)
|
|
709
|
+
show(initial.id, initial.tab ?? null, 'none');
|
|
710
|
+
lifetime.addEventListener('abort', () => {
|
|
711
|
+
request?.abort();
|
|
712
|
+
releaseInert?.();
|
|
713
|
+
releaseInert = null;
|
|
714
|
+
root = null;
|
|
715
|
+
});
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
};
|
|
719
|
+
/**
|
|
720
|
+
* Place an island inside a record view. The runtime starts it when the host
|
|
721
|
+
* appears and disposes it when the host leaves; props must be JSON.
|
|
722
|
+
*/
|
|
723
|
+
export const recordIsland = (name, props) => (_jsx("ket-island", { "data-island": name, "data-props": JSON.stringify(props) }));
|
|
724
|
+
export { RECORD_PARAM, RECORD_TAB_PARAM, recordModalHref, recordModalClosedHref, readRecordModalTarget };
|
|
725
|
+
//# sourceMappingURL=record-modal.js.map
|