declarative-forms 2.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/dist/index.cjs +2508 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1235 -0
- package/dist/index.d.ts +1235 -0
- package/dist/index.js +2473 -0
- package/dist/index.js.map +1 -0
- package/package.json +89 -0
- package/styles/declarative-forms.css +740 -0
- package/styles/theme.css +1293 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2473 @@
|
|
|
1
|
+
// src/util/html.ts
|
|
2
|
+
var SAFE_HTML = /* @__PURE__ */ Symbol.for("declarative-forms.SafeHtml");
|
|
3
|
+
var SafeHtml = class {
|
|
4
|
+
constructor(value) {
|
|
5
|
+
this.value = value;
|
|
6
|
+
}
|
|
7
|
+
value;
|
|
8
|
+
/** Brand used by {@link isSafeHtml} so instances survive duplicated module copies. */
|
|
9
|
+
[SAFE_HTML] = true;
|
|
10
|
+
toString() {
|
|
11
|
+
return this.value;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
function isSafeHtml(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && SAFE_HTML in value;
|
|
16
|
+
}
|
|
17
|
+
var ESCAPES = {
|
|
18
|
+
"&": "&",
|
|
19
|
+
"<": "<",
|
|
20
|
+
">": ">",
|
|
21
|
+
'"': """,
|
|
22
|
+
"'": "'"
|
|
23
|
+
};
|
|
24
|
+
function escapeHtml(value) {
|
|
25
|
+
return String(value).replace(/[&<>"']/g, (char) => ESCAPES[char]);
|
|
26
|
+
}
|
|
27
|
+
function html(stringsOrRaw, ...values) {
|
|
28
|
+
if (typeof stringsOrRaw === "string") {
|
|
29
|
+
return new SafeHtml(stringsOrRaw);
|
|
30
|
+
}
|
|
31
|
+
let out = stringsOrRaw[0] ?? "";
|
|
32
|
+
for (let i = 0; i < values.length; i++) {
|
|
33
|
+
const value = values[i];
|
|
34
|
+
out += isSafeHtml(value) ? value.value : escapeHtml(value);
|
|
35
|
+
out += stringsOrRaw[i + 1] ?? "";
|
|
36
|
+
}
|
|
37
|
+
return new SafeHtml(out);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/util/dom.ts
|
|
41
|
+
function setContent(el, content) {
|
|
42
|
+
if (content == null) {
|
|
43
|
+
el.textContent = "";
|
|
44
|
+
} else if (isSafeHtml(content)) {
|
|
45
|
+
el.innerHTML = content.value;
|
|
46
|
+
} else {
|
|
47
|
+
el.textContent = content;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function contentToString(content) {
|
|
51
|
+
if (content == null) return "";
|
|
52
|
+
return isSafeHtml(content) ? content.value : content;
|
|
53
|
+
}
|
|
54
|
+
function createEl(tag, options = {}) {
|
|
55
|
+
const el = document.createElement(tag);
|
|
56
|
+
for (const name of options.classNames ?? []) {
|
|
57
|
+
if (name) el.classList.add(name);
|
|
58
|
+
}
|
|
59
|
+
for (const [key, value] of Object.entries(options.attrs ?? {})) {
|
|
60
|
+
if (value !== void 0) el.setAttribute(key, value);
|
|
61
|
+
}
|
|
62
|
+
if (options.content !== void 0) setContent(el, options.content);
|
|
63
|
+
return el;
|
|
64
|
+
}
|
|
65
|
+
function addClasses(el, classNames) {
|
|
66
|
+
if (!classNames) return;
|
|
67
|
+
for (const name of classNames.split(/\s+/)) {
|
|
68
|
+
if (name) el.classList.add(name);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function findByAttribute(root, selector, attribute, value) {
|
|
72
|
+
return [...root.querySelectorAll(selector)].filter(
|
|
73
|
+
(el) => el.getAttribute(attribute) === value
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
function tabClassName(tab) {
|
|
77
|
+
return tab.replace(/\s/g, "");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/fields/Field.ts
|
|
81
|
+
var Field = class {
|
|
82
|
+
constructor(descriptor, context) {
|
|
83
|
+
this.descriptor = descriptor;
|
|
84
|
+
this.context = context;
|
|
85
|
+
this.wrapper = createEl("div", {
|
|
86
|
+
attrs: { id: `dl-form-field-wrapper-for-${descriptor.name}` },
|
|
87
|
+
classNames: ["dl-form-field-wrapper"]
|
|
88
|
+
});
|
|
89
|
+
this.applyTabClasses();
|
|
90
|
+
}
|
|
91
|
+
descriptor;
|
|
92
|
+
context;
|
|
93
|
+
wrapper;
|
|
94
|
+
label;
|
|
95
|
+
/** The `.dl-tooltip` marker, when this field declares a tooltip. */
|
|
96
|
+
tooltipMarker;
|
|
97
|
+
control;
|
|
98
|
+
active = true;
|
|
99
|
+
/**
|
|
100
|
+
* Build the control and the surrounding label/tooltip.
|
|
101
|
+
*
|
|
102
|
+
* Deliberately separate from the constructor: subclass field initializers run
|
|
103
|
+
* *after* `super()` under ES2022 class-field semantics, so anything
|
|
104
|
+
* `createControl` assigned would be reset to `undefined` if it ran from the
|
|
105
|
+
* base constructor. {@link FieldRegistry} calls this immediately after
|
|
106
|
+
* construction.
|
|
107
|
+
*/
|
|
108
|
+
build() {
|
|
109
|
+
if (this.control) return this;
|
|
110
|
+
const element = this.createControl();
|
|
111
|
+
this.control = element;
|
|
112
|
+
element.name = this.descriptor.name;
|
|
113
|
+
element.setAttribute("name", this.descriptor.name);
|
|
114
|
+
if (this.wrapperExtraClass) this.wrapper.classList.add(this.wrapperExtraClass);
|
|
115
|
+
addClasses(this.wrapper, this.descriptor.className);
|
|
116
|
+
this.label = this.createLabel();
|
|
117
|
+
this.createTooltipMarker();
|
|
118
|
+
if (element.type === "hidden") {
|
|
119
|
+
this.wrapper.classList.add("dl-form-hidden-field");
|
|
120
|
+
}
|
|
121
|
+
this.wrapper.appendChild(element);
|
|
122
|
+
return this;
|
|
123
|
+
}
|
|
124
|
+
/** The control element — an `<input>`, `<textarea>`, `<dl-select>`, … */
|
|
125
|
+
get element() {
|
|
126
|
+
if (!this.control) {
|
|
127
|
+
throw new Error("declarative-forms: field.build() must run before use.");
|
|
128
|
+
}
|
|
129
|
+
return this.control;
|
|
130
|
+
}
|
|
131
|
+
get name() {
|
|
132
|
+
return this.descriptor.name;
|
|
133
|
+
}
|
|
134
|
+
/** Extra class the kind contributes to its wrapper, e.g. `dl-select-wrapper`. */
|
|
135
|
+
get wrapperExtraClass() {
|
|
136
|
+
return void 0;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Whether this kind holds a value at all.
|
|
140
|
+
*
|
|
141
|
+
* `message` fields are presentational and stay out of `getValues()`.
|
|
142
|
+
*/
|
|
143
|
+
get contributesValue() {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
focus() {
|
|
147
|
+
this.element.focus?.();
|
|
148
|
+
}
|
|
149
|
+
setLoading(_loading) {
|
|
150
|
+
}
|
|
151
|
+
/** Ask the owning form to recompute values and re-run the update cycle. */
|
|
152
|
+
requestUpdate(force = false) {
|
|
153
|
+
this.context.requestUpdate(this, force);
|
|
154
|
+
}
|
|
155
|
+
/** Resolve a value that may have been given as a function of the form context. */
|
|
156
|
+
resolve(value) {
|
|
157
|
+
if (typeof value === "function") {
|
|
158
|
+
return value(
|
|
159
|
+
this.context.contextFor(this.descriptor)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
isActive() {
|
|
165
|
+
return this.active;
|
|
166
|
+
}
|
|
167
|
+
setActive(active) {
|
|
168
|
+
this.active = active;
|
|
169
|
+
this.wrapper.classList.toggle("inactive", !active);
|
|
170
|
+
}
|
|
171
|
+
/** Show or hide the field because of the currently selected tab. */
|
|
172
|
+
setInTab(inTab) {
|
|
173
|
+
this.wrapper.classList.toggle("notInTab", !inTab);
|
|
174
|
+
}
|
|
175
|
+
/** Tabs this field currently belongs to. */
|
|
176
|
+
tabs() {
|
|
177
|
+
const spec = this.resolve(this.descriptor.tab);
|
|
178
|
+
if (!spec) return [];
|
|
179
|
+
return (Array.isArray(spec) ? spec : [spec]).filter(
|
|
180
|
+
(tab) => typeof tab === "string" && tab.length > 0
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Apply the default value. Awaited during form initialisation.
|
|
185
|
+
*
|
|
186
|
+
* A `defaultValue` function may return a promise, so the result is always
|
|
187
|
+
* resolved before being applied.
|
|
188
|
+
*/
|
|
189
|
+
async applyDefaultValue() {
|
|
190
|
+
if (this.descriptor.defaultValue === void 0) return;
|
|
191
|
+
const value = await Promise.resolve(
|
|
192
|
+
this.resolve(this.descriptor.defaultValue)
|
|
193
|
+
);
|
|
194
|
+
if (value !== void 0) this.setValue(value);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Hook for setup that cannot happen in {@link build} — loading select
|
|
198
|
+
* options, or a first render that needs the form context. May be synchronous.
|
|
199
|
+
*/
|
|
200
|
+
initialize() {
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Called on every form update, after values have settled.
|
|
204
|
+
*
|
|
205
|
+
* @param triggerName name of the field whose input started this update
|
|
206
|
+
*/
|
|
207
|
+
onFormUpdate(_ctx, _triggerName) {
|
|
208
|
+
}
|
|
209
|
+
destroy() {
|
|
210
|
+
this.wrapper.remove();
|
|
211
|
+
}
|
|
212
|
+
applyTabClasses() {
|
|
213
|
+
for (const tab of this.tabsFromStaticDescriptor()) {
|
|
214
|
+
this.wrapper.classList.add(tabClassName(tab));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Tab classes are stamped on at construction time, before the form has any
|
|
219
|
+
* values, so only a statically declared `tab` can contribute one.
|
|
220
|
+
*/
|
|
221
|
+
tabsFromStaticDescriptor() {
|
|
222
|
+
const spec = this.descriptor.tab;
|
|
223
|
+
if (!spec || typeof spec === "function") return [];
|
|
224
|
+
return (Array.isArray(spec) ? spec : [spec]).filter(
|
|
225
|
+
(tab) => typeof tab === "string" && tab.length > 0
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
createLabel() {
|
|
229
|
+
if (this.descriptor.displayName === void 0) {
|
|
230
|
+
this.wrapper.classList.add("withoutLabel");
|
|
231
|
+
return void 0;
|
|
232
|
+
}
|
|
233
|
+
const controlId = this.controlIdForLabel();
|
|
234
|
+
const label = createEl("label", {
|
|
235
|
+
attrs: { for: controlId },
|
|
236
|
+
content: this.descriptor.displayName
|
|
237
|
+
});
|
|
238
|
+
this.wrapper.appendChild(label);
|
|
239
|
+
return label;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* The id `label[for]` should target.
|
|
243
|
+
*
|
|
244
|
+
* Defaults to giving the control element an id; kinds whose control is a
|
|
245
|
+
* wrapper (checkbox, cards, file) override this to point at the real input.
|
|
246
|
+
*/
|
|
247
|
+
controlIdForLabel() {
|
|
248
|
+
const id = this.context.ids.for("control", this.descriptor.name);
|
|
249
|
+
if (!this.element.id) this.element.id = id;
|
|
250
|
+
return this.element.id;
|
|
251
|
+
}
|
|
252
|
+
createTooltipMarker() {
|
|
253
|
+
const tooltip = this.descriptor.tooltip;
|
|
254
|
+
if (tooltip === void 0) return;
|
|
255
|
+
const descriptor = typeof tooltip === "object" && tooltip !== null && "text" in tooltip ? tooltip : { text: tooltip, inInput: false };
|
|
256
|
+
const marker = this.context.tooltips.createMarker(descriptor.text);
|
|
257
|
+
this.tooltipMarker = marker;
|
|
258
|
+
if (this.label && !descriptor.inInput) {
|
|
259
|
+
this.label.appendChild(marker);
|
|
260
|
+
} else {
|
|
261
|
+
marker.classList.add("dl-tooltip-in-input");
|
|
262
|
+
this.element.classList.add("dl-tooltip-inside");
|
|
263
|
+
this.wrapper.appendChild(marker);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** Convenience for subclasses that render simple text content. */
|
|
267
|
+
setContent = setContent;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// src/fields/ComputedField.ts
|
|
271
|
+
var ComputedField = class extends Field {
|
|
272
|
+
value = "";
|
|
273
|
+
createControl() {
|
|
274
|
+
const input = createEl("input");
|
|
275
|
+
input.type = "hidden";
|
|
276
|
+
return input;
|
|
277
|
+
}
|
|
278
|
+
getValue() {
|
|
279
|
+
return this.value;
|
|
280
|
+
}
|
|
281
|
+
setValue(value) {
|
|
282
|
+
this.value = value;
|
|
283
|
+
}
|
|
284
|
+
/** Recompute. Awaited by `updateComputedFields()` before a button action runs. */
|
|
285
|
+
async recompute() {
|
|
286
|
+
const ctx = this.context.contextFor(this.descriptor);
|
|
287
|
+
this.value = await this.descriptor.compute(ctx);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// src/util/value.ts
|
|
292
|
+
function asText(value) {
|
|
293
|
+
if (value === null || value === void 0) return "";
|
|
294
|
+
if (typeof value === "string") return value;
|
|
295
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
296
|
+
return String(value);
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
return JSON.stringify(value) ?? "";
|
|
300
|
+
} catch {
|
|
301
|
+
return "";
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/fields/TextAreaField.ts
|
|
306
|
+
var TextAreaField = class extends Field {
|
|
307
|
+
textarea;
|
|
308
|
+
createControl() {
|
|
309
|
+
const textarea = createEl("textarea");
|
|
310
|
+
this.textarea = textarea;
|
|
311
|
+
const placeholder = this.descriptor.placeholder;
|
|
312
|
+
if (typeof placeholder === "string") textarea.placeholder = placeholder;
|
|
313
|
+
textarea.oninput = () => this.requestUpdate();
|
|
314
|
+
textarea.onchange = textarea.oninput;
|
|
315
|
+
return textarea;
|
|
316
|
+
}
|
|
317
|
+
/** Whether Enter inserts a newline rather than confirming the dialog. */
|
|
318
|
+
get allowsNewlines() {
|
|
319
|
+
return this.descriptor.allowNewlines === true;
|
|
320
|
+
}
|
|
321
|
+
getValue() {
|
|
322
|
+
return this.textarea.value;
|
|
323
|
+
}
|
|
324
|
+
setValue(value) {
|
|
325
|
+
this.textarea.value = asText(value);
|
|
326
|
+
}
|
|
327
|
+
onFormUpdate(_ctx) {
|
|
328
|
+
if (typeof this.descriptor.placeholder === "function") {
|
|
329
|
+
this.textarea.placeholder = this.resolve(this.descriptor.placeholder) ?? "";
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// src/fields/ArrayField.ts
|
|
335
|
+
var ArrayField = class extends Field {
|
|
336
|
+
entries = [];
|
|
337
|
+
/** Suggestions the user ticked, kept with their index so they can be un-ticked. */
|
|
338
|
+
acceptedSuggestions = [];
|
|
339
|
+
createControl() {
|
|
340
|
+
return createEl("div", { classNames: ["array-of"] });
|
|
341
|
+
}
|
|
342
|
+
initialize() {
|
|
343
|
+
this.render();
|
|
344
|
+
}
|
|
345
|
+
getValue() {
|
|
346
|
+
const result = [...this.entries];
|
|
347
|
+
for (const { entry } of this.acceptedSuggestions) {
|
|
348
|
+
const encoded = JSON.stringify(entry);
|
|
349
|
+
if (!result.some((existing) => JSON.stringify(existing) === encoded)) {
|
|
350
|
+
result.push(entry);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
setValue(value) {
|
|
356
|
+
this.entries = Array.isArray(value) ? [...value] : [];
|
|
357
|
+
this.render();
|
|
358
|
+
}
|
|
359
|
+
onFormUpdate(_ctx) {
|
|
360
|
+
this.render();
|
|
361
|
+
}
|
|
362
|
+
// ------------------------------------------------------------------ render
|
|
363
|
+
render() {
|
|
364
|
+
this.element.replaceChildren();
|
|
365
|
+
const suggestions = this.resolve(this.descriptor.suggested);
|
|
366
|
+
if (suggestions && suggestions.length > 0) {
|
|
367
|
+
this.element.appendChild(this.renderSuggestions(suggestions));
|
|
368
|
+
}
|
|
369
|
+
this.entries.forEach((entry, index) => {
|
|
370
|
+
this.element.appendChild(this.renderEntry(entry, index));
|
|
371
|
+
});
|
|
372
|
+
this.element.appendChild(this.renderAddButton());
|
|
373
|
+
}
|
|
374
|
+
renderSuggestions(suggestions) {
|
|
375
|
+
const container = createEl("div", {
|
|
376
|
+
classNames: ["dl-form-array-suggested-container"]
|
|
377
|
+
});
|
|
378
|
+
suggestions.forEach((suggestion, index) => {
|
|
379
|
+
const id = this.context.ids.for(
|
|
380
|
+
"suggestion",
|
|
381
|
+
`${this.descriptor.name}-${String(index)}`
|
|
382
|
+
);
|
|
383
|
+
const wrapper = createEl("span", {
|
|
384
|
+
classNames: ["check", "dl-form-array-of-suggestion"],
|
|
385
|
+
attrs: { value: "false" }
|
|
386
|
+
});
|
|
387
|
+
const checkbox = createEl("input", { attrs: { type: "checkbox", id } });
|
|
388
|
+
checkbox.checked = this.acceptedSuggestions.some((item) => item.index === index);
|
|
389
|
+
checkbox.oninput = checkbox.onchange = () => {
|
|
390
|
+
wrapper.setAttribute("value", String(checkbox.checked));
|
|
391
|
+
if (checkbox.checked) {
|
|
392
|
+
this.acceptedSuggestions.push({ entry: suggestion, index });
|
|
393
|
+
} else {
|
|
394
|
+
this.acceptedSuggestions = this.acceptedSuggestions.filter(
|
|
395
|
+
(item) => item.index !== index
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
this.requestUpdate();
|
|
399
|
+
};
|
|
400
|
+
const label = createEl("label", { attrs: { for: id } });
|
|
401
|
+
setContent(label, this.entrySummary(suggestion));
|
|
402
|
+
wrapper.appendChild(checkbox);
|
|
403
|
+
wrapper.appendChild(label);
|
|
404
|
+
container.appendChild(wrapper);
|
|
405
|
+
});
|
|
406
|
+
return container;
|
|
407
|
+
}
|
|
408
|
+
renderEntry(entry, index) {
|
|
409
|
+
const row = createEl("div", {
|
|
410
|
+
classNames: ["dl-form-array-of-entry"],
|
|
411
|
+
attrs: { "data-el-index": String(index) }
|
|
412
|
+
});
|
|
413
|
+
const summary = createEl("span");
|
|
414
|
+
setContent(summary, this.entrySummary(entry));
|
|
415
|
+
row.appendChild(summary);
|
|
416
|
+
const edit = createEl("button", {
|
|
417
|
+
classNames: ["edit-array-of-btn"],
|
|
418
|
+
attrs: { type: "button", "data-el-index": String(index) },
|
|
419
|
+
content: "Edit"
|
|
420
|
+
});
|
|
421
|
+
edit.onclick = (event) => {
|
|
422
|
+
event.preventDefault();
|
|
423
|
+
this.openEntryDialog(index);
|
|
424
|
+
};
|
|
425
|
+
const remove = createEl("button", {
|
|
426
|
+
classNames: ["delete-array-of-btn"],
|
|
427
|
+
attrs: { type: "button", "data-el-index": String(index) },
|
|
428
|
+
content: "Remove"
|
|
429
|
+
});
|
|
430
|
+
remove.onclick = (event) => {
|
|
431
|
+
event.preventDefault();
|
|
432
|
+
this.entries.splice(index, 1);
|
|
433
|
+
this.render();
|
|
434
|
+
this.notifyChange();
|
|
435
|
+
this.requestUpdate(true);
|
|
436
|
+
};
|
|
437
|
+
row.appendChild(edit);
|
|
438
|
+
row.appendChild(remove);
|
|
439
|
+
return row;
|
|
440
|
+
}
|
|
441
|
+
renderAddButton() {
|
|
442
|
+
const button = createEl("button", {
|
|
443
|
+
classNames: ["dl-form-array-of-add-entry"],
|
|
444
|
+
attrs: { type: "button" },
|
|
445
|
+
content: this.descriptor.newButtonLabel ?? "Add"
|
|
446
|
+
});
|
|
447
|
+
button.onclick = (event) => {
|
|
448
|
+
event.preventDefault();
|
|
449
|
+
this.openEntryDialog();
|
|
450
|
+
};
|
|
451
|
+
return button;
|
|
452
|
+
}
|
|
453
|
+
/** Default summary: every non-empty string value, comma separated. */
|
|
454
|
+
entrySummary(entry) {
|
|
455
|
+
if (this.descriptor.renderEntry) return this.descriptor.renderEntry(entry);
|
|
456
|
+
return Object.values(entry).filter(
|
|
457
|
+
(value) => typeof value === "string" && value.trim() !== ""
|
|
458
|
+
).join(", ");
|
|
459
|
+
}
|
|
460
|
+
// ------------------------------------------------------------ entry dialog
|
|
461
|
+
/** Open the add dialog, or the edit dialog when `index` is given. */
|
|
462
|
+
openEntryDialog(index) {
|
|
463
|
+
const editing = index !== void 0;
|
|
464
|
+
const existing = editing ? this.entries[index] : void 0;
|
|
465
|
+
const fields = editing ? this.editFields(existing ?? {}) : this.descriptor.of;
|
|
466
|
+
const confirmId = this.context.ids.for("array-confirm", this.descriptor.name);
|
|
467
|
+
const form = this.context.createSubForm({
|
|
468
|
+
classNames: [`form-for-array-of-${this.descriptor.name}`],
|
|
469
|
+
fields,
|
|
470
|
+
isEditingArrayEntry: editing,
|
|
471
|
+
buttons: {
|
|
472
|
+
OK: {
|
|
473
|
+
id: confirmId,
|
|
474
|
+
isActive: ({ data }) => this.validateEntry(data),
|
|
475
|
+
action: (values) => {
|
|
476
|
+
if (editing && index !== void 0) this.entries[index] = values;
|
|
477
|
+
else this.entries.push(values);
|
|
478
|
+
this.render();
|
|
479
|
+
this.notifyChange();
|
|
480
|
+
this.requestUpdate(true);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
form.openInModal();
|
|
486
|
+
}
|
|
487
|
+
/** Seed each field with the entry's current value, then apply `mapFieldsOnEdit`. */
|
|
488
|
+
editFields(entry) {
|
|
489
|
+
const seeded = this.descriptor.of.map((field) => ({
|
|
490
|
+
...field,
|
|
491
|
+
defaultValue: entry[field.name] ?? field.defaultValue ?? ""
|
|
492
|
+
}));
|
|
493
|
+
return this.descriptor.mapFieldsOnEdit?.(seeded, entry) ?? seeded;
|
|
494
|
+
}
|
|
495
|
+
validateEntry(entry) {
|
|
496
|
+
if (!this.descriptor.isValidRecord) return true;
|
|
497
|
+
return this.descriptor.isValidRecord(entry, this.context.contextFor(this.descriptor));
|
|
498
|
+
}
|
|
499
|
+
notifyChange() {
|
|
500
|
+
const onChange = this.descriptor.onChange;
|
|
501
|
+
if (!onChange) return;
|
|
502
|
+
void this.context.scheduler.whenSettled().then(() => onChange(this.context.contextFor(this.descriptor).data));
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
// src/fields/CardsField.ts
|
|
507
|
+
var CardsField = class extends Field {
|
|
508
|
+
value;
|
|
509
|
+
createControl() {
|
|
510
|
+
const container = createEl("div", { classNames: ["detailed-options"] });
|
|
511
|
+
for (const card of this.descriptor.cards) {
|
|
512
|
+
const cardEl = createEl("div", {
|
|
513
|
+
classNames: ["detailed-option"],
|
|
514
|
+
attrs: { "data-value": card.value },
|
|
515
|
+
content: card.content
|
|
516
|
+
});
|
|
517
|
+
cardEl.addEventListener("click", () => {
|
|
518
|
+
this.select(card.value);
|
|
519
|
+
this.requestUpdate();
|
|
520
|
+
});
|
|
521
|
+
container.appendChild(cardEl);
|
|
522
|
+
}
|
|
523
|
+
return container;
|
|
524
|
+
}
|
|
525
|
+
getValue() {
|
|
526
|
+
return this.value ?? "";
|
|
527
|
+
}
|
|
528
|
+
setValue(value) {
|
|
529
|
+
if (value == null) return;
|
|
530
|
+
this.select(asText(value));
|
|
531
|
+
}
|
|
532
|
+
select(value) {
|
|
533
|
+
const target = findByAttribute(
|
|
534
|
+
this.element,
|
|
535
|
+
".detailed-option",
|
|
536
|
+
"data-value",
|
|
537
|
+
value
|
|
538
|
+
)[0];
|
|
539
|
+
if (!target) return;
|
|
540
|
+
this.value = value;
|
|
541
|
+
this.element.setAttribute("value", value);
|
|
542
|
+
for (const card of this.element.querySelectorAll(".detailed-option")) {
|
|
543
|
+
card.classList.remove("active");
|
|
544
|
+
}
|
|
545
|
+
target.classList.add("active");
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// src/fields/CheckboxField.ts
|
|
550
|
+
var CheckboxField = class extends Field {
|
|
551
|
+
input;
|
|
552
|
+
createControl() {
|
|
553
|
+
const wrapper = createEl("span", { classNames: ["check"] });
|
|
554
|
+
const inputId = this.context.ids.for("check", this.descriptor.name);
|
|
555
|
+
const input = createEl("input", { attrs: { type: "checkbox", id: inputId } });
|
|
556
|
+
this.input = input;
|
|
557
|
+
input.oninput = input.onchange = () => {
|
|
558
|
+
wrapper.setAttribute("value", String(input.checked));
|
|
559
|
+
this.requestUpdate();
|
|
560
|
+
};
|
|
561
|
+
const caption = createEl("label", {
|
|
562
|
+
attrs: { for: inputId },
|
|
563
|
+
content: this.descriptor.label
|
|
564
|
+
});
|
|
565
|
+
wrapper.appendChild(input);
|
|
566
|
+
wrapper.appendChild(caption);
|
|
567
|
+
wrapper.setAttribute("value", "false");
|
|
568
|
+
return wrapper;
|
|
569
|
+
}
|
|
570
|
+
/** The outer `displayName` label points at the real input, not the wrapper. */
|
|
571
|
+
controlIdForLabel() {
|
|
572
|
+
return this.input.id;
|
|
573
|
+
}
|
|
574
|
+
getValue() {
|
|
575
|
+
return this.input.checked;
|
|
576
|
+
}
|
|
577
|
+
setValue(value) {
|
|
578
|
+
const checked = value === "false" ? false : Boolean(value);
|
|
579
|
+
this.input.checked = checked;
|
|
580
|
+
this.element.setAttribute("value", String(checked));
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
// src/fields/CustomField.ts
|
|
585
|
+
var CustomField = class extends Field {
|
|
586
|
+
value = "";
|
|
587
|
+
createControl() {
|
|
588
|
+
return createEl("p", { classNames: ["render"] });
|
|
589
|
+
}
|
|
590
|
+
getValue() {
|
|
591
|
+
return this.value;
|
|
592
|
+
}
|
|
593
|
+
setValue(value) {
|
|
594
|
+
this.value = value;
|
|
595
|
+
}
|
|
596
|
+
initialize() {
|
|
597
|
+
this.render();
|
|
598
|
+
}
|
|
599
|
+
onFormUpdate(_ctx) {
|
|
600
|
+
this.render();
|
|
601
|
+
}
|
|
602
|
+
render() {
|
|
603
|
+
this.descriptor.render(this.renderContext());
|
|
604
|
+
}
|
|
605
|
+
renderContext() {
|
|
606
|
+
const base = this.context.contextFor(this.descriptor);
|
|
607
|
+
return {
|
|
608
|
+
...base,
|
|
609
|
+
element: this.element,
|
|
610
|
+
requestUpdate: (force = false) => this.requestUpdate(force),
|
|
611
|
+
setValue: (value) => {
|
|
612
|
+
this.value = value;
|
|
613
|
+
this.requestUpdate();
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
// src/fields/FileField.ts
|
|
620
|
+
var FileField = class extends Field {
|
|
621
|
+
input;
|
|
622
|
+
preview;
|
|
623
|
+
url;
|
|
624
|
+
createControl() {
|
|
625
|
+
const container = createEl("div", { classNames: ["file-field"] });
|
|
626
|
+
const preview = createEl("div", { classNames: ["file-preview", "empty"] });
|
|
627
|
+
const input = createEl("input", { attrs: { type: "file" } });
|
|
628
|
+
this.preview = preview;
|
|
629
|
+
this.input = input;
|
|
630
|
+
if (this.descriptor.accept) input.setAttribute("accept", this.descriptor.accept);
|
|
631
|
+
input.id = this.context.ids.for("file", this.descriptor.name);
|
|
632
|
+
input.oninput = () => {
|
|
633
|
+
void this.handlePick();
|
|
634
|
+
};
|
|
635
|
+
container.appendChild(preview);
|
|
636
|
+
container.appendChild(input);
|
|
637
|
+
return container;
|
|
638
|
+
}
|
|
639
|
+
controlIdForLabel() {
|
|
640
|
+
return this.input.id;
|
|
641
|
+
}
|
|
642
|
+
getValue() {
|
|
643
|
+
return this.url ?? "";
|
|
644
|
+
}
|
|
645
|
+
setValue(value) {
|
|
646
|
+
const url = value == null || value === "" ? void 0 : asText(value);
|
|
647
|
+
this.url = url;
|
|
648
|
+
if (url) {
|
|
649
|
+
this.element.setAttribute("data-value", url);
|
|
650
|
+
this.renderPreview(url);
|
|
651
|
+
} else {
|
|
652
|
+
this.element.removeAttribute("data-value");
|
|
653
|
+
this.clearPreview();
|
|
654
|
+
this.input.value = "";
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
setLoading(loading) {
|
|
658
|
+
this.preview.classList.toggle("loading", loading);
|
|
659
|
+
}
|
|
660
|
+
async handlePick() {
|
|
661
|
+
const file = this.input.files?.[0];
|
|
662
|
+
if (!file) {
|
|
663
|
+
this.setValue(void 0);
|
|
664
|
+
this.requestUpdate();
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
this.clearPreview();
|
|
668
|
+
this.preview.classList.remove("empty");
|
|
669
|
+
this.setLoading(true);
|
|
670
|
+
try {
|
|
671
|
+
const url = await this.context.persistFile(file);
|
|
672
|
+
this.setValue(url);
|
|
673
|
+
} catch (error) {
|
|
674
|
+
this.setValue(void 0);
|
|
675
|
+
this.reportError(error);
|
|
676
|
+
} finally {
|
|
677
|
+
this.setLoading(false);
|
|
678
|
+
this.requestUpdate();
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Surface an upload failure.
|
|
683
|
+
*
|
|
684
|
+
* Dispatches a cancelable `dl-error` event on the field so the app can show
|
|
685
|
+
* its own message; if nothing calls `preventDefault()`, the error is logged
|
|
686
|
+
* rather than vanishing. Throwing here would only produce an unhandled
|
|
687
|
+
* rejection, since this runs from an event handler.
|
|
688
|
+
*/
|
|
689
|
+
reportError(error) {
|
|
690
|
+
const event = new CustomEvent("dl-error", {
|
|
691
|
+
bubbles: true,
|
|
692
|
+
cancelable: true,
|
|
693
|
+
detail: { field: this.descriptor.name, error }
|
|
694
|
+
});
|
|
695
|
+
if (this.element.dispatchEvent(event)) {
|
|
696
|
+
console.error(
|
|
697
|
+
`declarative-forms: persistFile failed for field "${this.descriptor.name}".`,
|
|
698
|
+
error
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
clearPreview() {
|
|
703
|
+
this.preview.replaceChildren();
|
|
704
|
+
this.preview.classList.add("empty");
|
|
705
|
+
}
|
|
706
|
+
renderPreview(url) {
|
|
707
|
+
this.preview.replaceChildren();
|
|
708
|
+
this.preview.classList.remove("empty");
|
|
709
|
+
const container = createEl("div", { classNames: ["file-selection-container"] });
|
|
710
|
+
const image = createEl("img", { classNames: ["file-preview"] });
|
|
711
|
+
image.src = url;
|
|
712
|
+
image.alt = "";
|
|
713
|
+
const deleteButton = createEl("button", {
|
|
714
|
+
classNames: ["file-selection-delete-btn"],
|
|
715
|
+
attrs: { type: "button", "aria-label": "Remove file" }
|
|
716
|
+
});
|
|
717
|
+
deleteButton.appendChild(
|
|
718
|
+
createEl("div", { classNames: ["file-selection-delete-btn-icon"] })
|
|
719
|
+
);
|
|
720
|
+
deleteButton.onclick = () => {
|
|
721
|
+
this.setValue(void 0);
|
|
722
|
+
this.requestUpdate();
|
|
723
|
+
};
|
|
724
|
+
container.appendChild(image);
|
|
725
|
+
container.appendChild(deleteButton);
|
|
726
|
+
this.preview.appendChild(container);
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
// src/fields/MessageField.ts
|
|
731
|
+
var MessageField = class extends Field {
|
|
732
|
+
createControl() {
|
|
733
|
+
const el = createEl("p", { classNames: ["message"] });
|
|
734
|
+
setContent(el, this.resolve(this.descriptor.message) ?? "");
|
|
735
|
+
return el;
|
|
736
|
+
}
|
|
737
|
+
get contributesValue() {
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
getValue() {
|
|
741
|
+
return void 0;
|
|
742
|
+
}
|
|
743
|
+
setValue() {
|
|
744
|
+
}
|
|
745
|
+
onFormUpdate(_ctx) {
|
|
746
|
+
if (typeof this.descriptor.message === "function") {
|
|
747
|
+
setContent(this.element, this.resolve(this.descriptor.message) ?? "");
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
// src/components/styles.ts
|
|
753
|
+
var DL_SELECT_STYLES = `
|
|
754
|
+
@keyframes dl-placeholder-shimmer {
|
|
755
|
+
0% { background-position: -468px 0 }
|
|
756
|
+
100% { background-position: 468px 0 }
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
dl-select {
|
|
760
|
+
display: block;
|
|
761
|
+
position: relative;
|
|
762
|
+
font-weight: 300;
|
|
763
|
+
font-family: var(--dl-font-family, 'Rubik');
|
|
764
|
+
--dl-select-input-width: 370px;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
dl-select .input-wrapper {
|
|
768
|
+
display: inline-block;
|
|
769
|
+
border-width: 2px;
|
|
770
|
+
border-style: solid;
|
|
771
|
+
border-color: var(--dl-line-color, #ddd);
|
|
772
|
+
background-color: var(--dl-options-background, #fff);
|
|
773
|
+
color: var(--dl-options-text-color, #545454);
|
|
774
|
+
border-radius: 4px;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
dl-select.dl-focused .input-wrapper {
|
|
778
|
+
border-color: var(--dl-focused-line-color, #bbb);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
dl-select.dl-focused .input-wrapper svg {
|
|
782
|
+
border-left: 1px solid var(--dl-focused-line-color, #bbb);
|
|
783
|
+
fill: var(--dl-focused-line-color, #bbb);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
dl-select .input-wrapper input {
|
|
787
|
+
width: var(--dl-select-input-width);
|
|
788
|
+
color: var(--dl-options-text-color, #545454);
|
|
789
|
+
outline-width: 0;
|
|
790
|
+
margin-top: 2px;
|
|
791
|
+
padding: 6px;
|
|
792
|
+
font-size: var(--dl-font-size, 0.9em);
|
|
793
|
+
font-family: var(--dl-font-family, 'Rubik');
|
|
794
|
+
font-weight: 300;
|
|
795
|
+
border: 0;
|
|
796
|
+
border-radius: 4px;
|
|
797
|
+
float: left;
|
|
798
|
+
cursor: pointer;
|
|
799
|
+
box-sizing: content-box;
|
|
800
|
+
background: transparent;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
dl-select .input-wrapper input::placeholder {
|
|
804
|
+
color: var(--dl-options-inactive-text-color, #545454);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
dl-select .input-wrapper svg {
|
|
808
|
+
width: 20px;
|
|
809
|
+
height: 20px;
|
|
810
|
+
margin-top: var(--dl-drop-down-icon-margin-top, 5px);
|
|
811
|
+
margin-right: 4px;
|
|
812
|
+
padding-left: 4px;
|
|
813
|
+
fill: var(--dl-line-color, #ddd);
|
|
814
|
+
float: right;
|
|
815
|
+
border-left: 1px solid var(--dl-line-color, #ddd);
|
|
816
|
+
cursor: pointer;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
.options-wrapper {
|
|
820
|
+
position: absolute;
|
|
821
|
+
left: 0;
|
|
822
|
+
top: 20px;
|
|
823
|
+
font-size: var(--dl-font-size, 0.9em);
|
|
824
|
+
width: 100%;
|
|
825
|
+
max-height: 230px;
|
|
826
|
+
overflow-y: auto;
|
|
827
|
+
overflow-x: hidden;
|
|
828
|
+
box-shadow: 0 0 10px -2px rgba(0, 0, 0, 0.4);
|
|
829
|
+
z-index: 100;
|
|
830
|
+
background-color: var(--dl-options-background, #fff);
|
|
831
|
+
font-family: var(--dl-font-family, 'Rubik');
|
|
832
|
+
border-top: 1px solid var(--dl-focused-line-color, #bbb);
|
|
833
|
+
border-bottom: 1px solid var(--dl-focused-line-color, #bbb);
|
|
834
|
+
border-radius: 4px;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
.options-wrapper dl-option {
|
|
838
|
+
display: block;
|
|
839
|
+
border-left: 1px solid var(--dl-focused-line-color, #bbb);
|
|
840
|
+
border-right: 1px solid var(--dl-focused-line-color, #bbb);
|
|
841
|
+
cursor: pointer;
|
|
842
|
+
padding-top: var(--dl-options-padding-top, 5px);
|
|
843
|
+
padding-right: var(--dl-options-padding-right, 5px);
|
|
844
|
+
padding-bottom: var(--dl-options-padding-bottom, 5px);
|
|
845
|
+
padding-left: var(--dl-options-padding-left, 5px);
|
|
846
|
+
color: var(--dl-options-text-color, #545454);
|
|
847
|
+
background-color: var(--dl-options-background, #fff);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
.options-wrapper dl-option[selected="true"] {
|
|
851
|
+
opacity: 0.5;
|
|
852
|
+
cursor: not-allowed;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
.options-wrapper dl-option:hover,
|
|
856
|
+
.options-wrapper dl-option.dl-focused {
|
|
857
|
+
background-color: var(--dl-options-option-hover-color, rgba(224, 240, 227, 0.4));
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
.options-wrapper .noMatchesHint {
|
|
861
|
+
border-left: 1px solid var(--dl-focused-line-color, #bbb);
|
|
862
|
+
border-right: 1px solid var(--dl-focused-line-color, #bbb);
|
|
863
|
+
padding: 5px;
|
|
864
|
+
font-style: italic;
|
|
865
|
+
color: var(--dl-options-text-color, #545454);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
dl-select .dl-option-tag {
|
|
869
|
+
float: right;
|
|
870
|
+
border: 1px solid #888;
|
|
871
|
+
font-size: 0.8em;
|
|
872
|
+
padding: 2px;
|
|
873
|
+
font-family: 'Source Code Pro', monospace;
|
|
874
|
+
border-radius: 2px;
|
|
875
|
+
margin-top: -1px;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
dl-select .dl-option-tag:last-child {
|
|
879
|
+
margin-right: 10px;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
.dl-select-no-options-available svg path { display: none; }
|
|
883
|
+
.dl-select-no-options-available svg { border-left: none !important; }
|
|
884
|
+
|
|
885
|
+
.dl-select-loading svg,
|
|
886
|
+
.dl-select-no-options-available svg,
|
|
887
|
+
.dl-select-loading input,
|
|
888
|
+
.dl-select-no-options-available input {
|
|
889
|
+
pointer-events: none;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
.dl-select-loading .input-wrapper {
|
|
893
|
+
animation: dl-placeholder-shimmer 1.25s linear infinite forwards;
|
|
894
|
+
background: linear-gradient(
|
|
895
|
+
to right,
|
|
896
|
+
var(--dl-select-loading-col1, #eee) 10%,
|
|
897
|
+
var(--dl-select-loading-col2, #ddd) 18%,
|
|
898
|
+
var(--dl-select-loading-col1, #eee) 33%
|
|
899
|
+
);
|
|
900
|
+
background-size: 800px 104px;
|
|
901
|
+
position: relative;
|
|
902
|
+
width: 412px;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
.dl-select-loading input { opacity: 0; }
|
|
906
|
+
|
|
907
|
+
.dl-field-one-third input,
|
|
908
|
+
.dl-field-two-third input {
|
|
909
|
+
--dl-select-input-width: 127px;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
body.dark-theme dl-select {
|
|
913
|
+
--dl-select-loading-col1: #282727;
|
|
914
|
+
--dl-select-loading-col2: #505656;
|
|
915
|
+
}
|
|
916
|
+
`;
|
|
917
|
+
var injected = false;
|
|
918
|
+
function injectStyles() {
|
|
919
|
+
if (injected || typeof document === "undefined") return;
|
|
920
|
+
injected = true;
|
|
921
|
+
const style = document.createElement("style");
|
|
922
|
+
style.setAttribute("data-declarative-forms", "dl-select");
|
|
923
|
+
style.textContent = DL_SELECT_STYLES;
|
|
924
|
+
(document.head ?? document.documentElement).appendChild(style);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// src/components/DlSelect.ts
|
|
928
|
+
var SVG_NS = "http://www.w3.org/2000/svg";
|
|
929
|
+
var ARROW_PATH = "M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z";
|
|
930
|
+
var MIN_LOADING_MS = 1500;
|
|
931
|
+
var LOADING_DELAY_MS = 100;
|
|
932
|
+
var CustomElementBase = typeof HTMLElement === "undefined" ? class {
|
|
933
|
+
} : HTMLElement;
|
|
934
|
+
var DlOption = class extends CustomElementBase {
|
|
935
|
+
/** The value this option contributes, falling back to its text. */
|
|
936
|
+
get optionValue() {
|
|
937
|
+
return this.getAttribute("value") ?? this.innerText;
|
|
938
|
+
}
|
|
939
|
+
/** Text shown once selected, which may differ from the option's own label. */
|
|
940
|
+
get displayText() {
|
|
941
|
+
return this.getAttribute("displayWhenSelected") ?? this.innerText;
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
var DlSelect = class extends CustomElementBase {
|
|
945
|
+
optionsWrapper;
|
|
946
|
+
inputWrapper;
|
|
947
|
+
noMatchesHint;
|
|
948
|
+
selectedContainer;
|
|
949
|
+
inputField;
|
|
950
|
+
arrow;
|
|
951
|
+
multiple = false;
|
|
952
|
+
isLoading = false;
|
|
953
|
+
initialized = false;
|
|
954
|
+
loadingTimeouts = [];
|
|
955
|
+
loadingStartedAt;
|
|
956
|
+
pendingValue;
|
|
957
|
+
selectedOption;
|
|
958
|
+
focusedOption;
|
|
959
|
+
repositionListener;
|
|
960
|
+
scrollTargets = [];
|
|
961
|
+
constructor() {
|
|
962
|
+
super();
|
|
963
|
+
this.optionsWrapper = document.createElement("div");
|
|
964
|
+
this.inputWrapper = document.createElement("span");
|
|
965
|
+
this.noMatchesHint = document.createElement("span");
|
|
966
|
+
this.selectedContainer = document.createElement("span");
|
|
967
|
+
this.inputField = document.createElement("input");
|
|
968
|
+
this.arrow = document.createElementNS(SVG_NS, "svg");
|
|
969
|
+
}
|
|
970
|
+
connectedCallback() {
|
|
971
|
+
if (this.initialized) return;
|
|
972
|
+
this.initialized = true;
|
|
973
|
+
injectStyles();
|
|
974
|
+
this.inputWrapper.classList.add("input-wrapper");
|
|
975
|
+
this.optionsWrapper.classList.add("options-wrapper");
|
|
976
|
+
this.optionsWrapper.setAttribute(
|
|
977
|
+
"data-for-dl-select",
|
|
978
|
+
this.getAttribute("name") ?? ""
|
|
979
|
+
);
|
|
980
|
+
this.selectedContainer.classList.add("selected-container");
|
|
981
|
+
this.optionsWrapper.style.display = "none";
|
|
982
|
+
this.noMatchesHint.classList.add("noMatchesHint");
|
|
983
|
+
this.noMatchesHint.textContent = "No Matches";
|
|
984
|
+
this.noMatchesHint.style.display = "none";
|
|
985
|
+
this.optionsWrapper.appendChild(this.noMatchesHint);
|
|
986
|
+
this.multiple = this.getAttribute("multiple") !== null;
|
|
987
|
+
this.inputField.placeholder = this.getAttribute("placeholder") ?? "Select ...";
|
|
988
|
+
this.inputField.onfocus = () => this.open();
|
|
989
|
+
this.inputField.onblur = () => this.close();
|
|
990
|
+
this.inputField.oninput = () => this.filterOptions(this.inputField.value);
|
|
991
|
+
const path = document.createElementNS(SVG_NS, "path");
|
|
992
|
+
path.setAttribute("d", ARROW_PATH);
|
|
993
|
+
this.arrow.appendChild(path);
|
|
994
|
+
this.arrow.onclick = () => this.inputField.focus();
|
|
995
|
+
this.classList.add("dl-select-no-options-available");
|
|
996
|
+
this.addEventListener("keydown", this.onKeyDown);
|
|
997
|
+
this.mount();
|
|
998
|
+
}
|
|
999
|
+
disconnectedCallback() {
|
|
1000
|
+
this.detachRepositionListeners();
|
|
1001
|
+
for (const timeout of this.loadingTimeouts) clearTimeout(timeout);
|
|
1002
|
+
this.loadingTimeouts = [];
|
|
1003
|
+
}
|
|
1004
|
+
/** Move any author-provided `<dl-option>` children into the popup. */
|
|
1005
|
+
mount() {
|
|
1006
|
+
while (this.firstElementChild) {
|
|
1007
|
+
this.adoptOption(this.firstElementChild);
|
|
1008
|
+
}
|
|
1009
|
+
this.inputWrapper.appendChild(this.selectedContainer);
|
|
1010
|
+
this.inputWrapper.appendChild(this.inputField);
|
|
1011
|
+
this.inputWrapper.appendChild(this.arrow);
|
|
1012
|
+
this.appendChild(this.inputWrapper);
|
|
1013
|
+
this.appendChild(this.optionsWrapper);
|
|
1014
|
+
this.setValue(this.getValue());
|
|
1015
|
+
this.updatePlaceholder();
|
|
1016
|
+
}
|
|
1017
|
+
adoptOption(option) {
|
|
1018
|
+
option.onmousedown = () => this.selectOption(option);
|
|
1019
|
+
option.onmouseover = () => this.clearFocusedOption();
|
|
1020
|
+
this.optionsWrapper.appendChild(option);
|
|
1021
|
+
}
|
|
1022
|
+
addOption(option) {
|
|
1023
|
+
this.adoptOption(option);
|
|
1024
|
+
this.classList.remove("dl-select-no-options-available");
|
|
1025
|
+
this.updatePlaceholder();
|
|
1026
|
+
}
|
|
1027
|
+
removeAllOptions() {
|
|
1028
|
+
this.classList.add("dl-select-no-options-available");
|
|
1029
|
+
for (const option of this.optionsWrapper.querySelectorAll("dl-option")) {
|
|
1030
|
+
option.remove();
|
|
1031
|
+
}
|
|
1032
|
+
this.selectedOption = void 0;
|
|
1033
|
+
this.updatePlaceholder();
|
|
1034
|
+
}
|
|
1035
|
+
/** All options currently in the popup. */
|
|
1036
|
+
get options() {
|
|
1037
|
+
return [...this.optionsWrapper.querySelectorAll("dl-option")];
|
|
1038
|
+
}
|
|
1039
|
+
// ---------------------------------------------------------------- loading
|
|
1040
|
+
/**
|
|
1041
|
+
* Enter the loading state.
|
|
1042
|
+
*
|
|
1043
|
+
* The skeleton only appears after a short delay, so options that resolve
|
|
1044
|
+
* quickly never flash one. Repeated calls are safe.
|
|
1045
|
+
*/
|
|
1046
|
+
setLoadingStatus() {
|
|
1047
|
+
this.isLoading = true;
|
|
1048
|
+
this.loadingTimeouts.push(
|
|
1049
|
+
setTimeout(() => {
|
|
1050
|
+
this.classList.add("dl-select-loading");
|
|
1051
|
+
this.optionsWrapper.remove();
|
|
1052
|
+
this.loadingStartedAt = Date.now();
|
|
1053
|
+
}, LOADING_DELAY_MS)
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
unsetLoadingStatus() {
|
|
1057
|
+
for (const timeout of this.loadingTimeouts) clearTimeout(timeout);
|
|
1058
|
+
this.loadingTimeouts = [];
|
|
1059
|
+
const elapsed = this.loadingStartedAt ? Date.now() - this.loadingStartedAt : 0;
|
|
1060
|
+
const delay = this.loadingStartedAt ? Math.max(0, MIN_LOADING_MS - elapsed) : 0;
|
|
1061
|
+
const finish = () => {
|
|
1062
|
+
this.appendChild(this.optionsWrapper);
|
|
1063
|
+
this.classList.remove("dl-select-loading");
|
|
1064
|
+
this.isLoading = false;
|
|
1065
|
+
this.loadingStartedAt = void 0;
|
|
1066
|
+
if (this.pendingValue !== void 0) {
|
|
1067
|
+
const value = this.pendingValue;
|
|
1068
|
+
this.pendingValue = void 0;
|
|
1069
|
+
this.setValue(value);
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
if (delay === 0) finish();
|
|
1073
|
+
else this.loadingTimeouts.push(setTimeout(finish, delay));
|
|
1074
|
+
}
|
|
1075
|
+
// ------------------------------------------------------------ value access
|
|
1076
|
+
getValue() {
|
|
1077
|
+
const raw = this.getAttribute("value");
|
|
1078
|
+
if (raw) {
|
|
1079
|
+
if (!this.multiple) return raw;
|
|
1080
|
+
try {
|
|
1081
|
+
return JSON.parse(raw);
|
|
1082
|
+
} catch {
|
|
1083
|
+
return void 0;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
const pending = this.getAttribute("tmp-value");
|
|
1087
|
+
if (!pending) return void 0;
|
|
1088
|
+
if (this.multiple) {
|
|
1089
|
+
try {
|
|
1090
|
+
return JSON.parse(pending).filter((v) => this.findOption(v));
|
|
1091
|
+
} catch {
|
|
1092
|
+
return void 0;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
return this.findOption(pending) ? pending : void 0;
|
|
1096
|
+
}
|
|
1097
|
+
setValue(value) {
|
|
1098
|
+
if (value === void 0 || value === null || value === "") return;
|
|
1099
|
+
if (this.isLoading) {
|
|
1100
|
+
this.pendingValue = value;
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (this.multiple) {
|
|
1104
|
+
const next = (Array.isArray(value) ? value : [value]).map(asText);
|
|
1105
|
+
for (const current of this.getValue() ?? []) {
|
|
1106
|
+
const option2 = this.findOption(current);
|
|
1107
|
+
if (option2) this.deselectOption(option2);
|
|
1108
|
+
}
|
|
1109
|
+
for (const entry of next) {
|
|
1110
|
+
const option2 = this.findOption(entry);
|
|
1111
|
+
if (option2) this.selectOption(option2);
|
|
1112
|
+
}
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
const single = asText(value);
|
|
1116
|
+
this.setAttribute("tmp-value", single);
|
|
1117
|
+
const option = this.findOption(single);
|
|
1118
|
+
if (option) this.selectOption(option);
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Find an option by its `value` attribute, falling back to its text.
|
|
1122
|
+
*
|
|
1123
|
+
* v1 concatenated the value straight into an attribute selector, which threw
|
|
1124
|
+
* on any value containing a quote or a backslash.
|
|
1125
|
+
*/
|
|
1126
|
+
findOption(value) {
|
|
1127
|
+
return this.options.find((option) => option.getAttribute("value") === value) ?? this.options.find((option) => option.innerText === value);
|
|
1128
|
+
}
|
|
1129
|
+
// ------------------------------------------------------------- selection
|
|
1130
|
+
selectOption(option) {
|
|
1131
|
+
if (!option) return;
|
|
1132
|
+
const value = option.optionValue;
|
|
1133
|
+
if (this.multiple) {
|
|
1134
|
+
const current = this.getValue() ?? [];
|
|
1135
|
+
if (current.includes(value)) return;
|
|
1136
|
+
this.selectedContainer.appendChild(this.createTag(option));
|
|
1137
|
+
this.markSelected(option, true);
|
|
1138
|
+
this.setAttribute("value", JSON.stringify([...current, value]));
|
|
1139
|
+
} else {
|
|
1140
|
+
this.selectedOption = option;
|
|
1141
|
+
this.inputField.value = option.displayText;
|
|
1142
|
+
this.setAttribute("value", value);
|
|
1143
|
+
}
|
|
1144
|
+
this.emitChange();
|
|
1145
|
+
}
|
|
1146
|
+
deselectOption(option) {
|
|
1147
|
+
const value = option.optionValue;
|
|
1148
|
+
const current = this.getValue() ?? [];
|
|
1149
|
+
this.tagFor(value)?.remove();
|
|
1150
|
+
this.markSelected(option, false);
|
|
1151
|
+
this.setAttribute("value", JSON.stringify(current.filter((v) => v !== value)));
|
|
1152
|
+
this.emitChange();
|
|
1153
|
+
}
|
|
1154
|
+
/** Mark every option carrying this value, since duplicates are allowed. */
|
|
1155
|
+
markSelected(option, selected) {
|
|
1156
|
+
const attribute = option.getAttribute("value");
|
|
1157
|
+
const matches = attribute ? findByAttribute(this, "dl-option", "value", attribute) : [option];
|
|
1158
|
+
for (const match of matches) match.setAttribute("selected", String(selected));
|
|
1159
|
+
}
|
|
1160
|
+
createTag(option) {
|
|
1161
|
+
const tag = document.createElement("span");
|
|
1162
|
+
tag.classList.add("multiselect-tag");
|
|
1163
|
+
tag.dataset["value"] = option.optionValue;
|
|
1164
|
+
tag.innerText = option.displayText;
|
|
1165
|
+
const removeContainer = document.createElement("span");
|
|
1166
|
+
removeContainer.classList.add("dl-muliselect-selected-remove-container");
|
|
1167
|
+
const remove = document.createElement("span");
|
|
1168
|
+
remove.classList.add("dl-muliselect-selected-remove");
|
|
1169
|
+
remove.onclick = () => this.deselectOption(option);
|
|
1170
|
+
removeContainer.appendChild(remove);
|
|
1171
|
+
tag.appendChild(removeContainer);
|
|
1172
|
+
return tag;
|
|
1173
|
+
}
|
|
1174
|
+
tagFor(value) {
|
|
1175
|
+
return findByAttribute(
|
|
1176
|
+
this.selectedContainer,
|
|
1177
|
+
".multiselect-tag",
|
|
1178
|
+
"data-value",
|
|
1179
|
+
value
|
|
1180
|
+
)[0];
|
|
1181
|
+
}
|
|
1182
|
+
emitChange() {
|
|
1183
|
+
this.dispatchEvent(new CustomEvent("change", { bubbles: false, cancelable: true }));
|
|
1184
|
+
}
|
|
1185
|
+
// -------------------------------------------------------------- filtering
|
|
1186
|
+
filterOptions(query) {
|
|
1187
|
+
const terms = query.toLowerCase().split(" ").filter(Boolean);
|
|
1188
|
+
let matched = false;
|
|
1189
|
+
for (const option of this.options) {
|
|
1190
|
+
const text = option.innerText.toLowerCase();
|
|
1191
|
+
const visible = terms.length === 0 || terms.every((term) => text.includes(term));
|
|
1192
|
+
option.style.display = visible ? "block" : "none";
|
|
1193
|
+
matched ||= visible;
|
|
1194
|
+
}
|
|
1195
|
+
this.noMatchesHint.style.display = matched ? "none" : "block";
|
|
1196
|
+
}
|
|
1197
|
+
visibleOptions() {
|
|
1198
|
+
return this.options.filter((option) => option.style.display !== "none");
|
|
1199
|
+
}
|
|
1200
|
+
clearFocusedOption() {
|
|
1201
|
+
this.focusedOption = void 0;
|
|
1202
|
+
for (const option of this.options) option.classList.remove("dl-focused");
|
|
1203
|
+
}
|
|
1204
|
+
focusOption(option) {
|
|
1205
|
+
this.clearFocusedOption();
|
|
1206
|
+
if (!option) return;
|
|
1207
|
+
this.focusedOption = option;
|
|
1208
|
+
option.classList.add("dl-focused");
|
|
1209
|
+
option.scrollIntoView?.({ block: "nearest" });
|
|
1210
|
+
}
|
|
1211
|
+
step(delta) {
|
|
1212
|
+
const visible = this.visibleOptions();
|
|
1213
|
+
if (visible.length === 0) return;
|
|
1214
|
+
const current = this.focusedOption ? visible.indexOf(this.focusedOption) : -1;
|
|
1215
|
+
const next = current < 0 ? delta === 1 ? 0 : visible.length - 1 : current + delta;
|
|
1216
|
+
if (next < 0 || next >= visible.length) return;
|
|
1217
|
+
this.focusOption(visible[next]);
|
|
1218
|
+
}
|
|
1219
|
+
onKeyDown = (event) => {
|
|
1220
|
+
const key = event.key;
|
|
1221
|
+
if (key === "ArrowDown") this.step(1);
|
|
1222
|
+
else if (key === "ArrowUp") this.step(-1);
|
|
1223
|
+
else if (key === "Enter") {
|
|
1224
|
+
this.selectOption(this.focusedOption);
|
|
1225
|
+
this.close();
|
|
1226
|
+
} else return;
|
|
1227
|
+
event.preventDefault();
|
|
1228
|
+
event.stopPropagation();
|
|
1229
|
+
};
|
|
1230
|
+
// ------------------------------------------------------------ open / close
|
|
1231
|
+
get placeholderText() {
|
|
1232
|
+
if (this.options.length === 0) return "No Options Available";
|
|
1233
|
+
if (this.selectedOption) return this.selectedOption.innerText;
|
|
1234
|
+
return this.getAttribute("placeholder") ?? "Select ...";
|
|
1235
|
+
}
|
|
1236
|
+
updatePlaceholder() {
|
|
1237
|
+
this.inputField.placeholder = this.placeholderText;
|
|
1238
|
+
}
|
|
1239
|
+
/** Open the popup. Named `open` because `focus()` is `HTMLElement`'s. */
|
|
1240
|
+
open() {
|
|
1241
|
+
this.classList.add("dl-focused");
|
|
1242
|
+
this.updatePlaceholder();
|
|
1243
|
+
this.inputField.value = "";
|
|
1244
|
+
this.optionsWrapper.style.display = "inline-block";
|
|
1245
|
+
this.filterOptions("");
|
|
1246
|
+
document.body.appendChild(this.optionsWrapper);
|
|
1247
|
+
this.reposition();
|
|
1248
|
+
this.attachRepositionListeners();
|
|
1249
|
+
}
|
|
1250
|
+
close() {
|
|
1251
|
+
this.classList.remove("dl-focused");
|
|
1252
|
+
this.optionsWrapper.style.display = "none";
|
|
1253
|
+
this.inputField.value = this.selectedOption?.displayText ?? "";
|
|
1254
|
+
this.inputField.blur();
|
|
1255
|
+
this.clearFocusedOption();
|
|
1256
|
+
this.appendChild(this.optionsWrapper);
|
|
1257
|
+
this.detachRepositionListeners();
|
|
1258
|
+
}
|
|
1259
|
+
reposition() {
|
|
1260
|
+
const anchor = this.inputWrapper.getBoundingClientRect();
|
|
1261
|
+
const style = this.optionsWrapper.style;
|
|
1262
|
+
style.position = "absolute";
|
|
1263
|
+
style.top = `${anchor.bottom + window.scrollY + 2}px`;
|
|
1264
|
+
style.left = `${anchor.left + window.scrollX}px`;
|
|
1265
|
+
style.width = `${anchor.width}px`;
|
|
1266
|
+
}
|
|
1267
|
+
attachRepositionListeners() {
|
|
1268
|
+
const listener = () => this.reposition();
|
|
1269
|
+
this.repositionListener = listener;
|
|
1270
|
+
this.scrollTargets = [];
|
|
1271
|
+
for (let el = this.inputWrapper; el; el = el.parentElement) {
|
|
1272
|
+
if (el.scrollHeight > el.clientHeight) {
|
|
1273
|
+
el.addEventListener("scroll", listener);
|
|
1274
|
+
this.scrollTargets.push(el);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
document.addEventListener("scroll", listener);
|
|
1278
|
+
window.addEventListener("resize", listener);
|
|
1279
|
+
this.scrollTargets.push(document, window);
|
|
1280
|
+
}
|
|
1281
|
+
detachRepositionListeners() {
|
|
1282
|
+
const listener = this.repositionListener;
|
|
1283
|
+
if (!listener) return;
|
|
1284
|
+
for (const target of this.scrollTargets) {
|
|
1285
|
+
target.removeEventListener("scroll", listener);
|
|
1286
|
+
target.removeEventListener("resize", listener);
|
|
1287
|
+
}
|
|
1288
|
+
this.scrollTargets = [];
|
|
1289
|
+
this.repositionListener = void 0;
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
function defineDlSelect() {
|
|
1293
|
+
if (typeof customElements === "undefined") return;
|
|
1294
|
+
if (!customElements.get("dl-option")) customElements.define("dl-option", DlOption);
|
|
1295
|
+
if (!customElements.get("dl-select")) customElements.define("dl-select", DlSelect);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// src/fields/SelectField.ts
|
|
1299
|
+
var SelectField = class extends Field {
|
|
1300
|
+
select;
|
|
1301
|
+
get wrapperExtraClass() {
|
|
1302
|
+
return "dl-select-wrapper";
|
|
1303
|
+
}
|
|
1304
|
+
createControl() {
|
|
1305
|
+
defineDlSelect();
|
|
1306
|
+
const select = document.createElement("dl-select");
|
|
1307
|
+
this.select = select;
|
|
1308
|
+
if (this.descriptor.multiple) select.setAttribute("multiple", "");
|
|
1309
|
+
const placeholder = this.descriptor.placeholder;
|
|
1310
|
+
if (typeof placeholder === "string") select.setAttribute("placeholder", placeholder);
|
|
1311
|
+
select.onchange = () => this.requestUpdate();
|
|
1312
|
+
return select;
|
|
1313
|
+
}
|
|
1314
|
+
async initialize() {
|
|
1315
|
+
await this.loadOptions();
|
|
1316
|
+
}
|
|
1317
|
+
getValue() {
|
|
1318
|
+
const value = this.select.getValue();
|
|
1319
|
+
if (value === void 0) return this.descriptor.multiple ? [] : "";
|
|
1320
|
+
return value;
|
|
1321
|
+
}
|
|
1322
|
+
setValue(value) {
|
|
1323
|
+
this.select.setValue(value);
|
|
1324
|
+
}
|
|
1325
|
+
setLoading(loading) {
|
|
1326
|
+
if (loading) this.select.setLoadingStatus();
|
|
1327
|
+
else this.select.unsetLoadingStatus();
|
|
1328
|
+
}
|
|
1329
|
+
onFormUpdate(_ctx, triggerName) {
|
|
1330
|
+
if (typeof this.descriptor.placeholder === "function") {
|
|
1331
|
+
this.select.setAttribute(
|
|
1332
|
+
"placeholder",
|
|
1333
|
+
this.resolve(this.descriptor.placeholder) ?? ""
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
const dependsOnTrigger = triggerName !== void 0 && this.descriptor.reloadOnChangeOf?.includes(triggerName) === true;
|
|
1337
|
+
if (dependsOnTrigger && typeof this.descriptor.options === "function") {
|
|
1338
|
+
void this.loadOptions({ reload: true });
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Populate the options.
|
|
1343
|
+
*
|
|
1344
|
+
* Each load claims a generation token; if a newer load starts while this one
|
|
1345
|
+
* is still in flight, the stale result is dropped instead of clobbering the
|
|
1346
|
+
* fresher options. v1 had no such guard, so a slow response from an earlier
|
|
1347
|
+
* keystroke could repopulate the select with outdated values.
|
|
1348
|
+
*/
|
|
1349
|
+
async loadOptions(opts = {}) {
|
|
1350
|
+
const isCurrent = this.context.scheduler.claim(`options:${this.descriptor.name}`);
|
|
1351
|
+
const source = this.descriptor.options;
|
|
1352
|
+
const requested = typeof source === "function" ? source(this.context.contextFor(this.descriptor)) : source;
|
|
1353
|
+
const isAsync = requested instanceof Promise;
|
|
1354
|
+
if (isAsync) this.select.setLoadingStatus();
|
|
1355
|
+
try {
|
|
1356
|
+
const options = await requested ?? [];
|
|
1357
|
+
if (!isCurrent()) return;
|
|
1358
|
+
if (opts.reload) this.context.tooltips.reset(this.descriptor.name);
|
|
1359
|
+
const previous = this.select.getValue();
|
|
1360
|
+
this.select.removeAllOptions();
|
|
1361
|
+
for (const option of options) this.select.addOption(this.createOption(option));
|
|
1362
|
+
if (isAsync) this.select.unsetLoadingStatus();
|
|
1363
|
+
if (previous !== void 0) this.select.setValue(previous);
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
if (!isCurrent()) return;
|
|
1366
|
+
if (isAsync) this.select.unsetLoadingStatus();
|
|
1367
|
+
this.reportOptionsError(error);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
createOption(option) {
|
|
1371
|
+
const el = document.createElement("dl-option");
|
|
1372
|
+
if (typeof option === "string") {
|
|
1373
|
+
el.textContent = option;
|
|
1374
|
+
return el;
|
|
1375
|
+
}
|
|
1376
|
+
el.setAttribute("value", option.value);
|
|
1377
|
+
setContent(el, option.label);
|
|
1378
|
+
if (option.displayWhenSelected) {
|
|
1379
|
+
el.setAttribute("displayWhenSelected", option.displayWhenSelected);
|
|
1380
|
+
}
|
|
1381
|
+
return el;
|
|
1382
|
+
}
|
|
1383
|
+
reportOptionsError(error) {
|
|
1384
|
+
const handler = this.descriptor.onOptionsError;
|
|
1385
|
+
if (!handler) return;
|
|
1386
|
+
const message = handler({
|
|
1387
|
+
...this.context.contextFor(this.descriptor),
|
|
1388
|
+
error
|
|
1389
|
+
});
|
|
1390
|
+
if (message) {
|
|
1391
|
+
this.context.tooltips.setMessage(this.descriptor.name, message.level, message.text);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
// src/fields/TextField.ts
|
|
1397
|
+
var TextField = class extends Field {
|
|
1398
|
+
input;
|
|
1399
|
+
createControl() {
|
|
1400
|
+
const input = createEl("input");
|
|
1401
|
+
this.input = input;
|
|
1402
|
+
if (this.descriptor.type) input.type = this.descriptor.type;
|
|
1403
|
+
if (this.descriptor.autocomplete) {
|
|
1404
|
+
input.setAttribute("autocomplete", this.descriptor.autocomplete);
|
|
1405
|
+
}
|
|
1406
|
+
const placeholder = this.descriptor.placeholder;
|
|
1407
|
+
if (typeof placeholder === "string") input.placeholder = placeholder;
|
|
1408
|
+
input.oninput = () => this.requestUpdate();
|
|
1409
|
+
input.onchange = input.oninput;
|
|
1410
|
+
return input;
|
|
1411
|
+
}
|
|
1412
|
+
getValue() {
|
|
1413
|
+
return this.input.value;
|
|
1414
|
+
}
|
|
1415
|
+
setValue(value) {
|
|
1416
|
+
this.input.value = asText(value);
|
|
1417
|
+
}
|
|
1418
|
+
onFormUpdate(_ctx) {
|
|
1419
|
+
if (typeof this.descriptor.placeholder === "function") {
|
|
1420
|
+
this.input.placeholder = this.resolve(this.descriptor.placeholder) ?? "";
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
|
|
1425
|
+
// src/core/FieldRegistry.ts
|
|
1426
|
+
var FieldRegistry = class _FieldRegistry {
|
|
1427
|
+
kinds = /* @__PURE__ */ new Map();
|
|
1428
|
+
constructor(entries) {
|
|
1429
|
+
for (const [kind, ctor] of entries ?? []) this.kinds.set(kind, ctor);
|
|
1430
|
+
}
|
|
1431
|
+
register(kind, ctor) {
|
|
1432
|
+
this.kinds.set(kind, ctor);
|
|
1433
|
+
return this;
|
|
1434
|
+
}
|
|
1435
|
+
has(kind) {
|
|
1436
|
+
return this.kinds.has(kind);
|
|
1437
|
+
}
|
|
1438
|
+
/** A copy, so callers can add kinds without mutating the shared default. */
|
|
1439
|
+
clone() {
|
|
1440
|
+
return new _FieldRegistry(this.kinds);
|
|
1441
|
+
}
|
|
1442
|
+
create(descriptor, context) {
|
|
1443
|
+
const kind = descriptor.kind ?? "text";
|
|
1444
|
+
const Ctor = this.kinds.get(kind);
|
|
1445
|
+
if (!Ctor) {
|
|
1446
|
+
throw new Error(
|
|
1447
|
+
`declarative-forms: unknown field kind "${kind}" on field "${descriptor.name}". Known kinds: ${[...this.kinds.keys()].sort().join(", ")}.`
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
return new Ctor(descriptor, context).build();
|
|
1451
|
+
}
|
|
1452
|
+
};
|
|
1453
|
+
var defaultFieldRegistry = new FieldRegistry([
|
|
1454
|
+
["text", TextField],
|
|
1455
|
+
["textarea", TextAreaField],
|
|
1456
|
+
["select", SelectField],
|
|
1457
|
+
["checkbox", CheckboxField],
|
|
1458
|
+
["message", MessageField],
|
|
1459
|
+
["file", FileField],
|
|
1460
|
+
["computed", ComputedField],
|
|
1461
|
+
["cards", CardsField],
|
|
1462
|
+
["custom", CustomField],
|
|
1463
|
+
["array", ArrayField]
|
|
1464
|
+
]);
|
|
1465
|
+
|
|
1466
|
+
// src/util/deepEqual.ts
|
|
1467
|
+
function deepEqual(a, b) {
|
|
1468
|
+
if (Object.is(a, b)) return true;
|
|
1469
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
|
|
1470
|
+
return false;
|
|
1471
|
+
}
|
|
1472
|
+
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
|
|
1473
|
+
if (a instanceof Date) return a.getTime() === b.getTime();
|
|
1474
|
+
if (typeof Blob !== "undefined" && a instanceof Blob) return false;
|
|
1475
|
+
if (typeof FileList !== "undefined" && a instanceof FileList) return false;
|
|
1476
|
+
if (Array.isArray(a)) {
|
|
1477
|
+
const other = b;
|
|
1478
|
+
if (a.length !== other.length) return false;
|
|
1479
|
+
return a.every((item, i) => deepEqual(item, other[i]));
|
|
1480
|
+
}
|
|
1481
|
+
if (a instanceof Map) {
|
|
1482
|
+
const other = b;
|
|
1483
|
+
if (a.size !== other.size) return false;
|
|
1484
|
+
for (const [key, value] of a) {
|
|
1485
|
+
if (!other.has(key) || !deepEqual(value, other.get(key))) return false;
|
|
1486
|
+
}
|
|
1487
|
+
return true;
|
|
1488
|
+
}
|
|
1489
|
+
if (a instanceof Set) {
|
|
1490
|
+
const other = b;
|
|
1491
|
+
if (a.size !== other.size) return false;
|
|
1492
|
+
for (const value of a) {
|
|
1493
|
+
if (!other.has(value)) return false;
|
|
1494
|
+
}
|
|
1495
|
+
return true;
|
|
1496
|
+
}
|
|
1497
|
+
const left = a;
|
|
1498
|
+
const right = b;
|
|
1499
|
+
const aKeys = Object.keys(left);
|
|
1500
|
+
if (aKeys.length !== Object.keys(right).length) return false;
|
|
1501
|
+
return aKeys.every(
|
|
1502
|
+
(key) => Object.prototype.hasOwnProperty.call(right, key) && deepEqual(left[key], right[key])
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// src/core/FormModel.ts
|
|
1507
|
+
var FormModel = class {
|
|
1508
|
+
last;
|
|
1509
|
+
lastWithoutTab;
|
|
1510
|
+
subscribers = [];
|
|
1511
|
+
/**
|
|
1512
|
+
* Report whether `values` differ from the last seen snapshot, and adopt it as
|
|
1513
|
+
* the new baseline.
|
|
1514
|
+
*
|
|
1515
|
+
* @param includeTab compare `activeTab` as well as the field values
|
|
1516
|
+
*/
|
|
1517
|
+
hasChanged(values, includeTab = false) {
|
|
1518
|
+
const snapshot = { ...values };
|
|
1519
|
+
const withoutTab = { ...values };
|
|
1520
|
+
delete withoutTab["activeTab"];
|
|
1521
|
+
if (this.last === void 0) {
|
|
1522
|
+
this.last = snapshot;
|
|
1523
|
+
this.lastWithoutTab = withoutTab;
|
|
1524
|
+
return true;
|
|
1525
|
+
}
|
|
1526
|
+
const baseline = includeTab ? this.last : this.lastWithoutTab;
|
|
1527
|
+
const candidate = includeTab ? snapshot : withoutTab;
|
|
1528
|
+
if (deepEqual(baseline, candidate)) return false;
|
|
1529
|
+
this.last = snapshot;
|
|
1530
|
+
this.lastWithoutTab = withoutTab;
|
|
1531
|
+
return true;
|
|
1532
|
+
}
|
|
1533
|
+
subscribe(subscriber) {
|
|
1534
|
+
this.subscribers.push(subscriber);
|
|
1535
|
+
return () => {
|
|
1536
|
+
const index = this.subscribers.indexOf(subscriber);
|
|
1537
|
+
if (index >= 0) this.subscribers.splice(index, 1);
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
notify(values) {
|
|
1541
|
+
for (const subscriber of [...this.subscribers]) {
|
|
1542
|
+
subscriber(values);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
/** Forget the baseline so the next comparison reports a change. */
|
|
1546
|
+
reset() {
|
|
1547
|
+
this.last = void 0;
|
|
1548
|
+
this.lastWithoutTab = void 0;
|
|
1549
|
+
}
|
|
1550
|
+
};
|
|
1551
|
+
|
|
1552
|
+
// src/core/IdGenerator.ts
|
|
1553
|
+
var formCounter = 0;
|
|
1554
|
+
var IdGenerator = class {
|
|
1555
|
+
prefix;
|
|
1556
|
+
counter = 0;
|
|
1557
|
+
constructor(prefix) {
|
|
1558
|
+
this.prefix = prefix ?? `dlf${++formCounter}`;
|
|
1559
|
+
}
|
|
1560
|
+
/** A unique id, e.g. `dlf3-field-2`. */
|
|
1561
|
+
next(kind = "field") {
|
|
1562
|
+
return `${this.prefix}-${kind}-${++this.counter}`;
|
|
1563
|
+
}
|
|
1564
|
+
/** A stable id derived from a name, e.g. `dlf3-control-email`. */
|
|
1565
|
+
for(kind, name) {
|
|
1566
|
+
return `${this.prefix}-${kind}-${name}`;
|
|
1567
|
+
}
|
|
1568
|
+
};
|
|
1569
|
+
|
|
1570
|
+
// src/core/UpdateScheduler.ts
|
|
1571
|
+
var UpdateScheduler = class {
|
|
1572
|
+
pending = /* @__PURE__ */ new Set();
|
|
1573
|
+
generations = /* @__PURE__ */ new Map();
|
|
1574
|
+
/** Register async work and drop it from the set once it settles. */
|
|
1575
|
+
track(work) {
|
|
1576
|
+
this.pending.add(work);
|
|
1577
|
+
const forget = () => {
|
|
1578
|
+
this.pending.delete(work);
|
|
1579
|
+
};
|
|
1580
|
+
work.then(forget, forget);
|
|
1581
|
+
return work;
|
|
1582
|
+
}
|
|
1583
|
+
/** Number of currently in-flight operations. Test/diagnostics only. */
|
|
1584
|
+
get pendingCount() {
|
|
1585
|
+
return this.pending.size;
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Resolve once nothing is in flight.
|
|
1589
|
+
*
|
|
1590
|
+
* Loops because settling one batch may schedule another (an options load
|
|
1591
|
+
* whose completion triggers a dependent field's reload).
|
|
1592
|
+
*/
|
|
1593
|
+
async whenSettled() {
|
|
1594
|
+
let guard = 0;
|
|
1595
|
+
while (this.pending.size > 0) {
|
|
1596
|
+
if (++guard > 100) {
|
|
1597
|
+
throw new Error(
|
|
1598
|
+
"declarative-forms: form updates did not settle after 100 rounds \u2014 a field callback is most likely scheduling work on every update."
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
await Promise.allSettled([...this.pending]);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Claim the next generation for `key` and return a predicate that reports
|
|
1606
|
+
* whether that claim is still the newest.
|
|
1607
|
+
*
|
|
1608
|
+
* ```ts
|
|
1609
|
+
* const isCurrent = scheduler.claim(`options:${field.name}`);
|
|
1610
|
+
* const values = await load();
|
|
1611
|
+
* if (!isCurrent()) return; // a newer load superseded this one
|
|
1612
|
+
* ```
|
|
1613
|
+
*/
|
|
1614
|
+
claim(key) {
|
|
1615
|
+
const generation = (this.generations.get(key) ?? 0) + 1;
|
|
1616
|
+
this.generations.set(key, generation);
|
|
1617
|
+
return () => this.generations.get(key) === generation;
|
|
1618
|
+
}
|
|
1619
|
+
/** Invalidate every outstanding claim and forget all pending work. */
|
|
1620
|
+
reset() {
|
|
1621
|
+
this.pending.clear();
|
|
1622
|
+
for (const key of this.generations.keys()) {
|
|
1623
|
+
this.generations.set(key, (this.generations.get(key) ?? 0) + 1);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1628
|
+
// src/ui/ButtonBar.ts
|
|
1629
|
+
var ButtonBar = class {
|
|
1630
|
+
constructor(config, callbacks) {
|
|
1631
|
+
this.config = config;
|
|
1632
|
+
this.callbacks = callbacks;
|
|
1633
|
+
this.element = createEl("div", { classNames: ["low-bar"] });
|
|
1634
|
+
for (const [label, descriptor] of Object.entries(config)) {
|
|
1635
|
+
const button = this.createButton(label, descriptor);
|
|
1636
|
+
this.buttons.push({ descriptor, element: button });
|
|
1637
|
+
this.element.appendChild(button);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
config;
|
|
1641
|
+
callbacks;
|
|
1642
|
+
element;
|
|
1643
|
+
buttons = [];
|
|
1644
|
+
get count() {
|
|
1645
|
+
return this.buttons.length;
|
|
1646
|
+
}
|
|
1647
|
+
/** The first button — the one Enter activates. */
|
|
1648
|
+
get primary() {
|
|
1649
|
+
return this.buttons[0]?.element;
|
|
1650
|
+
}
|
|
1651
|
+
/** Whether the primary button is currently disabled. */
|
|
1652
|
+
get primaryDisabled() {
|
|
1653
|
+
return this.primary?.classList.contains("disabled") ?? false;
|
|
1654
|
+
}
|
|
1655
|
+
createButton(label, descriptor) {
|
|
1656
|
+
const button = createEl("button", {
|
|
1657
|
+
classNames: ["btn"],
|
|
1658
|
+
attrs: { type: "button" },
|
|
1659
|
+
content: label
|
|
1660
|
+
});
|
|
1661
|
+
if (descriptor.id) button.id = descriptor.id;
|
|
1662
|
+
addClasses(button, descriptor.class);
|
|
1663
|
+
button.onclick = () => {
|
|
1664
|
+
void this.activate(descriptor, button);
|
|
1665
|
+
};
|
|
1666
|
+
return button;
|
|
1667
|
+
}
|
|
1668
|
+
async activate(descriptor, button) {
|
|
1669
|
+
const classes = button.classList;
|
|
1670
|
+
if (classes.contains("loading-btn") || classes.contains("disabled")) return;
|
|
1671
|
+
classes.add("loading-btn");
|
|
1672
|
+
try {
|
|
1673
|
+
await this.callbacks.whenSettled();
|
|
1674
|
+
if (classes.contains("disabled")) return;
|
|
1675
|
+
await this.callbacks.updateComputedFields();
|
|
1676
|
+
if (descriptor.doNotCloseModal) {
|
|
1677
|
+
await descriptor.action?.(this.callbacks.getValues());
|
|
1678
|
+
} else {
|
|
1679
|
+
await this.callbacks.closeWith(descriptor.action);
|
|
1680
|
+
}
|
|
1681
|
+
} finally {
|
|
1682
|
+
classes.remove("loading-btn");
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
/**
|
|
1686
|
+
* Re-evaluate every `isActive` / `isVisible` predicate.
|
|
1687
|
+
*
|
|
1688
|
+
* Buttons without an `id` are skipped, matching v1 — an id is what opts a
|
|
1689
|
+
* button into state management.
|
|
1690
|
+
*/
|
|
1691
|
+
async refresh(ctx) {
|
|
1692
|
+
const work = [];
|
|
1693
|
+
for (const { descriptor, element } of this.buttons) {
|
|
1694
|
+
if (!descriptor.id) continue;
|
|
1695
|
+
if (descriptor.isActive) {
|
|
1696
|
+
work.push(
|
|
1697
|
+
this.applyPredicate(descriptor.isActive(ctx), (active) => {
|
|
1698
|
+
element.classList.toggle("disabled", !active);
|
|
1699
|
+
element.toggleAttribute("disabled", !active);
|
|
1700
|
+
})
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
if (descriptor.isVisible) {
|
|
1704
|
+
work.push(
|
|
1705
|
+
this.applyPredicate(descriptor.isVisible(ctx), (visible) => {
|
|
1706
|
+
element.classList.toggle("invisible", !visible);
|
|
1707
|
+
})
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
await Promise.allSettled(work);
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* Apply a predicate result.
|
|
1715
|
+
*
|
|
1716
|
+
* A synchronous predicate is applied immediately. v1 always set the button
|
|
1717
|
+
* disabled first and cleared it in a `.then()`, which made every button flash
|
|
1718
|
+
* disabled on every keystroke and let a well-timed click be dropped.
|
|
1719
|
+
*/
|
|
1720
|
+
applyPredicate(result, apply) {
|
|
1721
|
+
if (typeof result === "boolean") {
|
|
1722
|
+
apply(result);
|
|
1723
|
+
return Promise.resolve();
|
|
1724
|
+
}
|
|
1725
|
+
return result.then(apply);
|
|
1726
|
+
}
|
|
1727
|
+
/** The configured buttons, for callers that want to invoke an action directly. */
|
|
1728
|
+
get descriptors() {
|
|
1729
|
+
return this.config;
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
|
|
1733
|
+
// src/ui/ModalView.ts
|
|
1734
|
+
var ModalView = class {
|
|
1735
|
+
wrapper;
|
|
1736
|
+
modal;
|
|
1737
|
+
content;
|
|
1738
|
+
upBar;
|
|
1739
|
+
constructor(options) {
|
|
1740
|
+
this.wrapper = createEl("div", { classNames: ["dl-modal"] });
|
|
1741
|
+
this.wrapper.style.display = "none";
|
|
1742
|
+
this.modal = createEl("div", { classNames: ["modal"] });
|
|
1743
|
+
this.content = createEl("div", { classNames: ["modal-content"] });
|
|
1744
|
+
this.upBar = createEl("div");
|
|
1745
|
+
if (options.onCancel) {
|
|
1746
|
+
this.upBar.classList.add("up-bar");
|
|
1747
|
+
const cancel = createEl("button", {
|
|
1748
|
+
classNames: ["cancelBtn", "secondary"],
|
|
1749
|
+
attrs: { type: "button", "aria-label": "Close dialog" }
|
|
1750
|
+
});
|
|
1751
|
+
cancel.onclick = () => options.onCancel?.();
|
|
1752
|
+
this.upBar.appendChild(cancel);
|
|
1753
|
+
}
|
|
1754
|
+
this.modal.appendChild(this.upBar);
|
|
1755
|
+
this.modal.appendChild(options.tabBar.element);
|
|
1756
|
+
this.modal.appendChild(this.content);
|
|
1757
|
+
this.modal.appendChild(options.buttonBar.element);
|
|
1758
|
+
this.wrapper.appendChild(this.modal);
|
|
1759
|
+
}
|
|
1760
|
+
/** Attach to the document body. Used by `openInModal`. */
|
|
1761
|
+
mount() {
|
|
1762
|
+
document.body.appendChild(this.wrapper);
|
|
1763
|
+
}
|
|
1764
|
+
setVisible(visible) {
|
|
1765
|
+
this.wrapper.style.display = visible ? "block" : "none";
|
|
1766
|
+
}
|
|
1767
|
+
hide() {
|
|
1768
|
+
this.wrapper.classList.add("dl-modal-hidden");
|
|
1769
|
+
}
|
|
1770
|
+
show() {
|
|
1771
|
+
this.wrapper.classList.remove("dl-modal-hidden");
|
|
1772
|
+
this.wrapper.classList.add("dl-modal-restored");
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Record how many dialogs this one was opened on top of.
|
|
1776
|
+
*
|
|
1777
|
+
* A stacked dialog inherits a backdrop that is already on screen: the dialog
|
|
1778
|
+
* below is hidden in the same frame this one appears. Fading this backdrop in
|
|
1779
|
+
* would leave the page uncovered for the length of the animation, so themes
|
|
1780
|
+
* suppress it on the `dl-modal-stacked` class. The depth also goes on the
|
|
1781
|
+
* wrapper as `--dl-stack-depth`, so a theme can show how deep the stack is.
|
|
1782
|
+
*/
|
|
1783
|
+
setStackDepth(depth) {
|
|
1784
|
+
this.wrapper.classList.toggle("dl-modal-stacked", depth > 0);
|
|
1785
|
+
if (depth > 0) this.wrapper.style.setProperty("--dl-stack-depth", String(depth));
|
|
1786
|
+
else this.wrapper.style.removeProperty("--dl-stack-depth");
|
|
1787
|
+
}
|
|
1788
|
+
/** Mark the dialog as embedded rather than floating. */
|
|
1789
|
+
setEmbedded() {
|
|
1790
|
+
this.wrapper.classList.add("noModalDialog");
|
|
1791
|
+
}
|
|
1792
|
+
addModalClasses(names) {
|
|
1793
|
+
for (const name of names ?? []) this.modal.classList.add(name);
|
|
1794
|
+
}
|
|
1795
|
+
addWrapperClasses(names) {
|
|
1796
|
+
for (const name of names ?? []) this.wrapper.classList.add(name);
|
|
1797
|
+
}
|
|
1798
|
+
remove() {
|
|
1799
|
+
this.wrapper.remove();
|
|
1800
|
+
}
|
|
1801
|
+
};
|
|
1802
|
+
|
|
1803
|
+
// src/ui/ModalStack.ts
|
|
1804
|
+
var ModalStack = class {
|
|
1805
|
+
dialogs = [];
|
|
1806
|
+
listening = false;
|
|
1807
|
+
get size() {
|
|
1808
|
+
return this.dialogs.length;
|
|
1809
|
+
}
|
|
1810
|
+
/** Values of every dialog on the stack, outermost first. */
|
|
1811
|
+
values() {
|
|
1812
|
+
return this.dialogs.map((dialog) => dialog.getValues());
|
|
1813
|
+
}
|
|
1814
|
+
/** The dialog currently on top, if any. */
|
|
1815
|
+
get top() {
|
|
1816
|
+
return this.dialogs[this.dialogs.length - 1];
|
|
1817
|
+
}
|
|
1818
|
+
push(dialog) {
|
|
1819
|
+
this.dialogs.push(dialog);
|
|
1820
|
+
this.startListening();
|
|
1821
|
+
if (this.dialogs.length >= 2) {
|
|
1822
|
+
this.dialogs[this.dialogs.length - 2]?.hide();
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
/** Remove a dialog and reveal whatever it was covering. */
|
|
1826
|
+
remove(dialog) {
|
|
1827
|
+
const index = this.dialogs.indexOf(dialog);
|
|
1828
|
+
if (index < 0) return;
|
|
1829
|
+
this.dialogs.splice(index, 1);
|
|
1830
|
+
const top = this.top;
|
|
1831
|
+
if (top) {
|
|
1832
|
+
top.show();
|
|
1833
|
+
top.refreshTabs();
|
|
1834
|
+
} else {
|
|
1835
|
+
this.stopListening();
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
contains(dialog) {
|
|
1839
|
+
return this.dialogs.includes(dialog);
|
|
1840
|
+
}
|
|
1841
|
+
onKeyDown = (event) => {
|
|
1842
|
+
const top = this.top;
|
|
1843
|
+
if (!top) return;
|
|
1844
|
+
if (event.key === "Escape") {
|
|
1845
|
+
top.requestCancel();
|
|
1846
|
+
} else if (event.key === "Enter" && top.buttonCount === 1) {
|
|
1847
|
+
top.requestConfirm(event);
|
|
1848
|
+
}
|
|
1849
|
+
};
|
|
1850
|
+
startListening() {
|
|
1851
|
+
if (this.listening || typeof document === "undefined") return;
|
|
1852
|
+
document.addEventListener("keydown", this.onKeyDown);
|
|
1853
|
+
this.listening = true;
|
|
1854
|
+
}
|
|
1855
|
+
stopListening() {
|
|
1856
|
+
if (!this.listening) return;
|
|
1857
|
+
document.removeEventListener("keydown", this.onKeyDown);
|
|
1858
|
+
this.listening = false;
|
|
1859
|
+
}
|
|
1860
|
+
};
|
|
1861
|
+
var globalModalStack = new ModalStack();
|
|
1862
|
+
|
|
1863
|
+
// src/ui/TabBar.ts
|
|
1864
|
+
var TabBar = class {
|
|
1865
|
+
constructor(onSelect) {
|
|
1866
|
+
this.onSelect = onSelect;
|
|
1867
|
+
this.element = createEl("div", { classNames: ["tabWrapper"] });
|
|
1868
|
+
}
|
|
1869
|
+
onSelect;
|
|
1870
|
+
element;
|
|
1871
|
+
buttons = /* @__PURE__ */ new Map();
|
|
1872
|
+
seen = /* @__PURE__ */ new Set();
|
|
1873
|
+
active;
|
|
1874
|
+
get activeTab() {
|
|
1875
|
+
return this.active;
|
|
1876
|
+
}
|
|
1877
|
+
/** Rebuild the strip from the tabs that currently have at least one visible field. */
|
|
1878
|
+
render(tabs) {
|
|
1879
|
+
this.element.replaceChildren();
|
|
1880
|
+
this.buttons = /* @__PURE__ */ new Map();
|
|
1881
|
+
for (const tab of tabs) {
|
|
1882
|
+
const button = createEl("div", {
|
|
1883
|
+
classNames: ["dl-tab-btn", tabClassName(tab), this.seen.has(tab) && "seen"],
|
|
1884
|
+
content: tab
|
|
1885
|
+
});
|
|
1886
|
+
button.onclick = () => this.onSelect(tab);
|
|
1887
|
+
this.buttons.set(tab, button);
|
|
1888
|
+
this.element.appendChild(button);
|
|
1889
|
+
}
|
|
1890
|
+
const next = this.active && this.buttons.has(this.active) ? this.active : tabs[0];
|
|
1891
|
+
if (next) this.select(next);
|
|
1892
|
+
else this.active = void 0;
|
|
1893
|
+
}
|
|
1894
|
+
/**
|
|
1895
|
+
* Mark a tab active.
|
|
1896
|
+
*
|
|
1897
|
+
* Selecting a tab that is not currently rendered is a no-op rather than an
|
|
1898
|
+
* error — a tab can disappear when the fields in it all become inactive.
|
|
1899
|
+
*/
|
|
1900
|
+
select(tab) {
|
|
1901
|
+
const button = this.buttons.get(tab);
|
|
1902
|
+
if (!button) return false;
|
|
1903
|
+
for (const other of this.buttons.values()) other.classList.remove("active");
|
|
1904
|
+
button.classList.add("active");
|
|
1905
|
+
button.classList.add("seen");
|
|
1906
|
+
this.seen.add(tab);
|
|
1907
|
+
this.active = tab;
|
|
1908
|
+
return true;
|
|
1909
|
+
}
|
|
1910
|
+
get tabs() {
|
|
1911
|
+
return [...this.buttons.keys()];
|
|
1912
|
+
}
|
|
1913
|
+
get isEmpty() {
|
|
1914
|
+
return this.buttons.size === 0;
|
|
1915
|
+
}
|
|
1916
|
+
};
|
|
1917
|
+
|
|
1918
|
+
// src/ui/tooltip/NativeTooltipProvider.ts
|
|
1919
|
+
var BUBBLE_CLASS = "dl-tooltip-bubble";
|
|
1920
|
+
var NativeTooltipProvider = class {
|
|
1921
|
+
attachments = /* @__PURE__ */ new WeakMap();
|
|
1922
|
+
attach(element, content) {
|
|
1923
|
+
const existing = this.attachments.get(element);
|
|
1924
|
+
if (existing) {
|
|
1925
|
+
existing.content = content;
|
|
1926
|
+
if (existing.bubble) existing.bubble.innerHTML = content;
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
const attachment = { content, bubble: void 0, listeners: [] };
|
|
1930
|
+
this.attachments.set(element, attachment);
|
|
1931
|
+
const show = () => this.show(element);
|
|
1932
|
+
const hide = () => this.hide(element);
|
|
1933
|
+
const bind = (type, handler) => {
|
|
1934
|
+
element.addEventListener(type, handler);
|
|
1935
|
+
attachment.listeners.push(() => element.removeEventListener(type, handler));
|
|
1936
|
+
};
|
|
1937
|
+
bind("mouseenter", show);
|
|
1938
|
+
bind("mouseleave", hide);
|
|
1939
|
+
bind("focus", show);
|
|
1940
|
+
bind("blur", hide);
|
|
1941
|
+
}
|
|
1942
|
+
detach(element) {
|
|
1943
|
+
const attachment = this.attachments.get(element);
|
|
1944
|
+
if (!attachment) return;
|
|
1945
|
+
for (const remove of attachment.listeners) remove();
|
|
1946
|
+
attachment.bubble?.remove();
|
|
1947
|
+
this.attachments.delete(element);
|
|
1948
|
+
}
|
|
1949
|
+
show(element) {
|
|
1950
|
+
const attachment = this.attachments.get(element);
|
|
1951
|
+
if (!attachment || attachment.bubble) return;
|
|
1952
|
+
const bubble = createEl("div", { classNames: [BUBBLE_CLASS] });
|
|
1953
|
+
bubble.innerHTML = attachment.content;
|
|
1954
|
+
bubble.setAttribute("role", "tooltip");
|
|
1955
|
+
document.body.appendChild(bubble);
|
|
1956
|
+
attachment.bubble = bubble;
|
|
1957
|
+
this.position(element, bubble);
|
|
1958
|
+
}
|
|
1959
|
+
hide(element) {
|
|
1960
|
+
const attachment = this.attachments.get(element);
|
|
1961
|
+
if (!attachment?.bubble) return;
|
|
1962
|
+
attachment.bubble.remove();
|
|
1963
|
+
attachment.bubble = void 0;
|
|
1964
|
+
}
|
|
1965
|
+
position(element, bubble) {
|
|
1966
|
+
const anchor = element.getBoundingClientRect();
|
|
1967
|
+
const box = bubble.getBoundingClientRect();
|
|
1968
|
+
const gap = 8;
|
|
1969
|
+
let left = anchor.right + gap;
|
|
1970
|
+
if (left + box.width > window.innerWidth) {
|
|
1971
|
+
left = Math.max(gap, anchor.left - box.width - gap);
|
|
1972
|
+
}
|
|
1973
|
+
const top = anchor.top + anchor.height / 2 - box.height / 2;
|
|
1974
|
+
bubble.style.left = `${left + window.scrollX}px`;
|
|
1975
|
+
bubble.style.top = `${Math.max(gap, top) + window.scrollY}px`;
|
|
1976
|
+
}
|
|
1977
|
+
};
|
|
1978
|
+
|
|
1979
|
+
// src/ui/tooltip/TooltipController.ts
|
|
1980
|
+
var LEVEL_CLASS = {
|
|
1981
|
+
warning: "tooltip-warning",
|
|
1982
|
+
error: "tooltip-error",
|
|
1983
|
+
success: "tooltip-success",
|
|
1984
|
+
loading: "tooltip-loading"
|
|
1985
|
+
};
|
|
1986
|
+
var TooltipController = class {
|
|
1987
|
+
markers = /* @__PURE__ */ new Map();
|
|
1988
|
+
provider;
|
|
1989
|
+
constructor(provider = new NativeTooltipProvider()) {
|
|
1990
|
+
this.provider = provider;
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* Create the `<span class="dl-tooltip">?</span>` marker for a field.
|
|
1994
|
+
*
|
|
1995
|
+
* Registered by field name so `setTooltip(name, …)` can find it later. The
|
|
1996
|
+
* `data-tippy-content` attribute is retained from v1 as the content store.
|
|
1997
|
+
*/
|
|
1998
|
+
createMarker(content) {
|
|
1999
|
+
const text = contentToString(content);
|
|
2000
|
+
const marker = createEl("span", {
|
|
2001
|
+
classNames: ["dl-tooltip"],
|
|
2002
|
+
attrs: {
|
|
2003
|
+
"data-tippy-content": text,
|
|
2004
|
+
"data-initial-tippy-content": text
|
|
2005
|
+
},
|
|
2006
|
+
content: "?"
|
|
2007
|
+
});
|
|
2008
|
+
this.provider.attach(marker, text);
|
|
2009
|
+
return marker;
|
|
2010
|
+
}
|
|
2011
|
+
/** Associate a marker with a field name. Called by the form after mounting. */
|
|
2012
|
+
register(fieldName, marker) {
|
|
2013
|
+
if (marker) this.markers.set(fieldName, marker);
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* Replace a field's tooltip text, icon and state class.
|
|
2017
|
+
*
|
|
2018
|
+
* A field without a tooltip is a silent no-op, matching v1.
|
|
2019
|
+
*/
|
|
2020
|
+
set(fieldName, text, icon = "?", className = "") {
|
|
2021
|
+
const marker = this.markers.get(fieldName);
|
|
2022
|
+
if (!marker) return;
|
|
2023
|
+
const base = marker.classList.contains("dl-tooltip-in-input") ? "dl-tooltip dl-tooltip-in-input" : "dl-tooltip";
|
|
2024
|
+
marker.className = className ? `${base} ${className}` : base;
|
|
2025
|
+
marker.setAttribute("data-tippy-content", text);
|
|
2026
|
+
marker.innerHTML = icon;
|
|
2027
|
+
this.provider.attach(marker, text);
|
|
2028
|
+
}
|
|
2029
|
+
setSuccess(fieldName, text) {
|
|
2030
|
+
this.set(fieldName, text, "✓", LEVEL_CLASS.success);
|
|
2031
|
+
}
|
|
2032
|
+
setWarning(fieldName, text) {
|
|
2033
|
+
this.set(fieldName, text, "!", LEVEL_CLASS.warning);
|
|
2034
|
+
}
|
|
2035
|
+
setError(fieldName, text) {
|
|
2036
|
+
this.set(fieldName, text, "!", LEVEL_CLASS.error);
|
|
2037
|
+
}
|
|
2038
|
+
setLoading(fieldName, text) {
|
|
2039
|
+
this.set(fieldName, text, "", LEVEL_CLASS.loading);
|
|
2040
|
+
}
|
|
2041
|
+
/** Apply a `{ level, text }` message from an options loader. */
|
|
2042
|
+
setMessage(fieldName, level, text) {
|
|
2043
|
+
if (level === "error") this.setError(fieldName, text);
|
|
2044
|
+
else this.setWarning(fieldName, text);
|
|
2045
|
+
}
|
|
2046
|
+
/** Restore a field's original tooltip text and `?` icon. */
|
|
2047
|
+
reset(fieldName) {
|
|
2048
|
+
const marker = this.markers.get(fieldName);
|
|
2049
|
+
if (!marker) return;
|
|
2050
|
+
this.set(fieldName, marker.getAttribute("data-initial-tippy-content") ?? "", "?", "");
|
|
2051
|
+
}
|
|
2052
|
+
resetAll(fieldNames) {
|
|
2053
|
+
for (const name of fieldNames) this.reset(name);
|
|
2054
|
+
}
|
|
2055
|
+
destroy() {
|
|
2056
|
+
for (const marker of this.markers.values()) this.provider.detach(marker);
|
|
2057
|
+
this.markers.clear();
|
|
2058
|
+
}
|
|
2059
|
+
};
|
|
2060
|
+
|
|
2061
|
+
// src/core/DeclarativeForm.ts
|
|
2062
|
+
var filePersistenceCache = /* @__PURE__ */ new WeakMap();
|
|
2063
|
+
var DeclarativeForm = class _DeclarativeForm {
|
|
2064
|
+
/** The `.dl-form` wrapper containing the `<form>`. */
|
|
2065
|
+
dom;
|
|
2066
|
+
/** The `<form>` element the fields are rendered into. */
|
|
2067
|
+
formElement;
|
|
2068
|
+
options;
|
|
2069
|
+
registry;
|
|
2070
|
+
stack;
|
|
2071
|
+
parent;
|
|
2072
|
+
fieldList = [];
|
|
2073
|
+
fieldsByName = /* @__PURE__ */ new Map();
|
|
2074
|
+
model = new FormModel();
|
|
2075
|
+
scheduler = new UpdateScheduler();
|
|
2076
|
+
ids = new IdGenerator();
|
|
2077
|
+
tooltips;
|
|
2078
|
+
tabBar;
|
|
2079
|
+
buttonBar;
|
|
2080
|
+
view;
|
|
2081
|
+
editingArrayEntry;
|
|
2082
|
+
embedded = false;
|
|
2083
|
+
ready;
|
|
2084
|
+
constructor(options, internal = {}) {
|
|
2085
|
+
this.options = options;
|
|
2086
|
+
this.registry = internal.registry ?? defaultFieldRegistry;
|
|
2087
|
+
this.stack = internal.stack ?? globalModalStack;
|
|
2088
|
+
this.parent = internal.parent;
|
|
2089
|
+
this.editingArrayEntry = internal.isEditingArrayEntry ?? false;
|
|
2090
|
+
this.tooltips = new TooltipController(options.tooltipProvider);
|
|
2091
|
+
this.dom = createEl("div", { classNames: ["dl-form"] });
|
|
2092
|
+
this.formElement = createEl("form", { attrs: { autocomplete: "off" } });
|
|
2093
|
+
for (const name of options.classNames ?? []) this.formElement.classList.add(name);
|
|
2094
|
+
this.formElement.onsubmit = (event) => {
|
|
2095
|
+
event.preventDefault();
|
|
2096
|
+
return false;
|
|
2097
|
+
};
|
|
2098
|
+
this.dom.appendChild(this.formElement);
|
|
2099
|
+
this.tabBar = new TabBar((tab) => this.setActiveTab(tab));
|
|
2100
|
+
this.buttonBar = new ButtonBar(this.resolveButtons(), {
|
|
2101
|
+
whenSettled: () => this.scheduler.whenSettled(),
|
|
2102
|
+
updateComputedFields: () => this.updateComputedFields(),
|
|
2103
|
+
getValues: () => this.getValues(),
|
|
2104
|
+
closeWith: (action) => this.close(action)
|
|
2105
|
+
});
|
|
2106
|
+
this.buildFields();
|
|
2107
|
+
this.ready = this.initialize();
|
|
2108
|
+
}
|
|
2109
|
+
// ------------------------------------------------------------------ setup
|
|
2110
|
+
resolveButtons() {
|
|
2111
|
+
if (this.options.buttons) return this.options.buttons;
|
|
2112
|
+
return {
|
|
2113
|
+
[this.options.confirmLabel ?? "OK"]: {
|
|
2114
|
+
id: this.ids.for("confirm", "default"),
|
|
2115
|
+
action: this.options.onConfirm
|
|
2116
|
+
}
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
buildFields() {
|
|
2120
|
+
const context = this.createFieldContext();
|
|
2121
|
+
for (const descriptor of this.options.fields) {
|
|
2122
|
+
if (this.fieldsByName.has(descriptor.name)) {
|
|
2123
|
+
throw new Error(
|
|
2124
|
+
`declarative-forms: duplicate field name "${descriptor.name}". Field names must be unique within a form.`
|
|
2125
|
+
);
|
|
2126
|
+
}
|
|
2127
|
+
const field = this.registry.create(descriptor, context);
|
|
2128
|
+
this.fieldList.push(field);
|
|
2129
|
+
this.fieldsByName.set(descriptor.name, field);
|
|
2130
|
+
this.tooltips.register(descriptor.name, field.tooltipMarker);
|
|
2131
|
+
this.formElement.appendChild(field.wrapper);
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
async initialize() {
|
|
2135
|
+
await Promise.allSettled(
|
|
2136
|
+
this.fieldList.map(
|
|
2137
|
+
(field) => this.scheduler.track(Promise.resolve(field.initialize()))
|
|
2138
|
+
)
|
|
2139
|
+
);
|
|
2140
|
+
await Promise.allSettled(
|
|
2141
|
+
this.fieldList.map((field) => this.scheduler.track(field.applyDefaultValue()))
|
|
2142
|
+
);
|
|
2143
|
+
await this.update();
|
|
2144
|
+
}
|
|
2145
|
+
/** Resolves once initial options and default values have been applied. */
|
|
2146
|
+
whenReady() {
|
|
2147
|
+
return this.ready.then(() => this.scheduler.whenSettled());
|
|
2148
|
+
}
|
|
2149
|
+
createFieldContext() {
|
|
2150
|
+
return {
|
|
2151
|
+
ids: this.ids,
|
|
2152
|
+
tooltips: this.tooltips,
|
|
2153
|
+
scheduler: this.scheduler,
|
|
2154
|
+
get parentData() {
|
|
2155
|
+
return void 0;
|
|
2156
|
+
},
|
|
2157
|
+
stackData: [],
|
|
2158
|
+
isEditingArrayEntry: this.editingArrayEntry,
|
|
2159
|
+
contextFor: (descriptor) => this.contextFor(descriptor),
|
|
2160
|
+
requestUpdate: (source, force) => {
|
|
2161
|
+
void this.update(source, force);
|
|
2162
|
+
},
|
|
2163
|
+
persistFile: (file) => this.persistFile(file),
|
|
2164
|
+
createSubForm: (options) => this.createSubForm(options)
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
contextFor(descriptor) {
|
|
2168
|
+
return {
|
|
2169
|
+
data: this.getValues(),
|
|
2170
|
+
form: this,
|
|
2171
|
+
field: descriptor,
|
|
2172
|
+
parentData: this.parent?.getValues(),
|
|
2173
|
+
stackData: this.stack.values(),
|
|
2174
|
+
isEditingArrayEntry: this.editingArrayEntry
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
createSubForm(options) {
|
|
2178
|
+
return new _DeclarativeForm(
|
|
2179
|
+
{
|
|
2180
|
+
fields: options.fields,
|
|
2181
|
+
classNames: options.classNames,
|
|
2182
|
+
buttons: options.buttons,
|
|
2183
|
+
onCancel: () => {
|
|
2184
|
+
},
|
|
2185
|
+
persistFile: this.options.persistFile,
|
|
2186
|
+
tooltipProvider: this.options.tooltipProvider
|
|
2187
|
+
},
|
|
2188
|
+
{
|
|
2189
|
+
parent: this,
|
|
2190
|
+
stack: this.stack,
|
|
2191
|
+
registry: this.registry,
|
|
2192
|
+
isEditingArrayEntry: options.isEditingArrayEntry
|
|
2193
|
+
}
|
|
2194
|
+
);
|
|
2195
|
+
}
|
|
2196
|
+
async persistFile(file) {
|
|
2197
|
+
const persist = this.options.persistFile;
|
|
2198
|
+
if (!persist) {
|
|
2199
|
+
throw new Error(
|
|
2200
|
+
"declarative-forms: a file field needs a `persistFile` handler on the form to turn the picked File into a storable URL."
|
|
2201
|
+
);
|
|
2202
|
+
}
|
|
2203
|
+
const cached = filePersistenceCache.get(file);
|
|
2204
|
+
if (cached) return cached;
|
|
2205
|
+
const pending = Promise.resolve(persist(file)).then(String);
|
|
2206
|
+
filePersistenceCache.set(file, pending);
|
|
2207
|
+
try {
|
|
2208
|
+
return await pending;
|
|
2209
|
+
} catch (error) {
|
|
2210
|
+
filePersistenceCache.delete(file);
|
|
2211
|
+
throw error;
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
// ------------------------------------------------------------------ values
|
|
2215
|
+
/**
|
|
2216
|
+
* Current values, keyed by field name.
|
|
2217
|
+
*
|
|
2218
|
+
* Inactive fields (`isActive` returned false) are omitted. The active tab is
|
|
2219
|
+
* always included under `activeTab`.
|
|
2220
|
+
*/
|
|
2221
|
+
getValues() {
|
|
2222
|
+
const values = {};
|
|
2223
|
+
for (const field of this.fieldList) {
|
|
2224
|
+
if (!field.contributesValue || !field.isActive()) continue;
|
|
2225
|
+
values[field.name] = field.getValue();
|
|
2226
|
+
}
|
|
2227
|
+
values["activeTab"] = this.tabBar.activeTab;
|
|
2228
|
+
return values;
|
|
2229
|
+
}
|
|
2230
|
+
/** Imperative handle for a field, or `undefined` if there is no such field. */
|
|
2231
|
+
field(name) {
|
|
2232
|
+
return this.fieldsByName.get(name);
|
|
2233
|
+
}
|
|
2234
|
+
/** Every field handle, in declaration order. */
|
|
2235
|
+
get fields() {
|
|
2236
|
+
return this.fieldList;
|
|
2237
|
+
}
|
|
2238
|
+
/** Notified whenever the form's values change. Returns an unsubscribe function. */
|
|
2239
|
+
subscribeOnInput(subscriber) {
|
|
2240
|
+
return this.model.subscribe(subscriber);
|
|
2241
|
+
}
|
|
2242
|
+
// ------------------------------------------------------------------ update
|
|
2243
|
+
/**
|
|
2244
|
+
* Recompute derived state: field visibility, tabs, reactive content and
|
|
2245
|
+
* button predicates.
|
|
2246
|
+
*
|
|
2247
|
+
* @param source the field whose input triggered this update
|
|
2248
|
+
* @param force run even when nothing changed
|
|
2249
|
+
* @param includeTab treat a tab switch as a change
|
|
2250
|
+
*/
|
|
2251
|
+
async update(source, force = false, includeTab = false) {
|
|
2252
|
+
const values = this.getValues();
|
|
2253
|
+
const changed = this.model.hasChanged(values, includeTab);
|
|
2254
|
+
if (!changed && !force) return;
|
|
2255
|
+
const triggerName = source?.name;
|
|
2256
|
+
const activeTabs = /* @__PURE__ */ new Set();
|
|
2257
|
+
for (const field of this.fieldList) {
|
|
2258
|
+
const ctx = {
|
|
2259
|
+
...this.contextFor(field.descriptor),
|
|
2260
|
+
data: values,
|
|
2261
|
+
trigger: source
|
|
2262
|
+
};
|
|
2263
|
+
if (field.descriptor.isActive) {
|
|
2264
|
+
const active = field.descriptor.isActive(ctx);
|
|
2265
|
+
field.setActive(active);
|
|
2266
|
+
if (active) for (const tab of field.tabs()) activeTabs.add(tab);
|
|
2267
|
+
} else {
|
|
2268
|
+
field.setActive(true);
|
|
2269
|
+
for (const tab of field.tabs()) activeTabs.add(tab);
|
|
2270
|
+
}
|
|
2271
|
+
field.descriptor.onFormChange?.(ctx);
|
|
2272
|
+
field.onFormUpdate(ctx, triggerName);
|
|
2273
|
+
}
|
|
2274
|
+
this.renderTabs(activeTabs);
|
|
2275
|
+
await this.scheduler.whenSettled();
|
|
2276
|
+
if (changed) this.model.notify(this.getValues());
|
|
2277
|
+
await this.buttonBar.refresh(this.buttonContext());
|
|
2278
|
+
}
|
|
2279
|
+
buttonContext() {
|
|
2280
|
+
return {
|
|
2281
|
+
data: this.getValues(),
|
|
2282
|
+
parentData: this.parent?.getValues(),
|
|
2283
|
+
stackData: this.stack.values()
|
|
2284
|
+
};
|
|
2285
|
+
}
|
|
2286
|
+
/** Recompute every `computed` field. Runs before a button action fires. */
|
|
2287
|
+
async updateComputedFields() {
|
|
2288
|
+
const computed = this.fieldList.filter(
|
|
2289
|
+
(field) => field instanceof ComputedField
|
|
2290
|
+
);
|
|
2291
|
+
await Promise.allSettled(
|
|
2292
|
+
computed.map((field) => this.scheduler.track(field.recompute()))
|
|
2293
|
+
);
|
|
2294
|
+
}
|
|
2295
|
+
// -------------------------------------------------------------------- tabs
|
|
2296
|
+
/** Tabs in declaration order, keeping only those with at least one active field. */
|
|
2297
|
+
renderTabs(activeTabs) {
|
|
2298
|
+
const ordered = [];
|
|
2299
|
+
for (const field of this.fieldList) {
|
|
2300
|
+
for (const tab of field.tabs()) {
|
|
2301
|
+
if (activeTabs.has(tab) && !ordered.includes(tab)) ordered.push(tab);
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
this.tabBar.render(ordered);
|
|
2305
|
+
this.applyActiveTab();
|
|
2306
|
+
}
|
|
2307
|
+
/**
|
|
2308
|
+
* Switch tabs.
|
|
2309
|
+
*
|
|
2310
|
+
* Called with no argument, it re-applies the current tab — useful after a
|
|
2311
|
+
* nested dialog closes and the fields underneath need re-syncing.
|
|
2312
|
+
*/
|
|
2313
|
+
setActiveTab(tab) {
|
|
2314
|
+
if (tab && !this.tabBar.select(tab)) return;
|
|
2315
|
+
this.applyActiveTab();
|
|
2316
|
+
void this.update(void 0, true, true);
|
|
2317
|
+
}
|
|
2318
|
+
applyActiveTab() {
|
|
2319
|
+
const active = this.tabBar.activeTab;
|
|
2320
|
+
if (!active) return;
|
|
2321
|
+
for (const field of this.fieldList) {
|
|
2322
|
+
const tabs = field.tabs();
|
|
2323
|
+
field.setInTab(tabs.length === 0 || tabs.includes(active));
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
/** Re-apply the active tab. Called by the stack when this dialog is revealed. */
|
|
2327
|
+
refreshTabs() {
|
|
2328
|
+
this.applyActiveTab();
|
|
2329
|
+
}
|
|
2330
|
+
// ----------------------------------------------------------------- display
|
|
2331
|
+
/**
|
|
2332
|
+
* Open the form as a modal dialog.
|
|
2333
|
+
*
|
|
2334
|
+
* Returns the outer `.dl-modal` element.
|
|
2335
|
+
*/
|
|
2336
|
+
openInModal(options) {
|
|
2337
|
+
this.embedded = false;
|
|
2338
|
+
const view = this.ensureView();
|
|
2339
|
+
view.mount();
|
|
2340
|
+
view.content.appendChild(this.dom);
|
|
2341
|
+
view.setStackDepth(this.stack.size);
|
|
2342
|
+
view.setVisible(true);
|
|
2343
|
+
view.addModalClasses(options?.classNames);
|
|
2344
|
+
view.addWrapperClasses(options?.wrapperClassNames);
|
|
2345
|
+
this.stack.push(this);
|
|
2346
|
+
this.applyActiveTab();
|
|
2347
|
+
return view.wrapper;
|
|
2348
|
+
}
|
|
2349
|
+
/** Render the form inline, inside `host`, instead of as a floating dialog. */
|
|
2350
|
+
appendInElement(host, options) {
|
|
2351
|
+
this.embedded = true;
|
|
2352
|
+
const view = this.ensureView();
|
|
2353
|
+
view.setEmbedded();
|
|
2354
|
+
view.setVisible(true);
|
|
2355
|
+
view.addWrapperClasses(options?.classNames);
|
|
2356
|
+
view.content.appendChild(this.dom);
|
|
2357
|
+
host.appendChild(view.wrapper);
|
|
2358
|
+
void this.whenReady().then(() => this.applyActiveTab());
|
|
2359
|
+
return view.wrapper;
|
|
2360
|
+
}
|
|
2361
|
+
ensureView() {
|
|
2362
|
+
if (this.view) return this.view;
|
|
2363
|
+
this.view = new ModalView({
|
|
2364
|
+
tabBar: this.tabBar,
|
|
2365
|
+
buttonBar: this.buttonBar,
|
|
2366
|
+
onCancel: this.options.onCancel ? () => this.cancel() : void 0
|
|
2367
|
+
});
|
|
2368
|
+
return this.view;
|
|
2369
|
+
}
|
|
2370
|
+
/** The outer `.dl-modal` element, once the form has been displayed. */
|
|
2371
|
+
get modalElement() {
|
|
2372
|
+
return this.view?.wrapper;
|
|
2373
|
+
}
|
|
2374
|
+
hide() {
|
|
2375
|
+
this.view?.hide();
|
|
2376
|
+
}
|
|
2377
|
+
show() {
|
|
2378
|
+
this.view?.show();
|
|
2379
|
+
}
|
|
2380
|
+
/** The form's HTML, for tests and snapshots. */
|
|
2381
|
+
getHTML() {
|
|
2382
|
+
return this.dom.outerHTML;
|
|
2383
|
+
}
|
|
2384
|
+
// ---------------------------------------------------------------- lifecycle
|
|
2385
|
+
get buttonCount() {
|
|
2386
|
+
return this.buttonBar.count;
|
|
2387
|
+
}
|
|
2388
|
+
/**
|
|
2389
|
+
* Run the confirm action and close.
|
|
2390
|
+
*
|
|
2391
|
+
* @param action defaults to the form's `onConfirm`
|
|
2392
|
+
*/
|
|
2393
|
+
async close(action) {
|
|
2394
|
+
const callback = action ?? this.options.onConfirm;
|
|
2395
|
+
if (callback) await callback(this.getValues());
|
|
2396
|
+
if (!this.embedded) this.teardownView();
|
|
2397
|
+
this.stack.remove(this);
|
|
2398
|
+
}
|
|
2399
|
+
/** Dismiss the dialog. A no-op when the form has no `onCancel`. */
|
|
2400
|
+
cancel() {
|
|
2401
|
+
if (!this.options.onCancel) return;
|
|
2402
|
+
this.teardownView();
|
|
2403
|
+
this.options.onCancel();
|
|
2404
|
+
this.stack.remove(this);
|
|
2405
|
+
}
|
|
2406
|
+
/** Close without running any callback. */
|
|
2407
|
+
remove() {
|
|
2408
|
+
this.teardownView();
|
|
2409
|
+
this.stack.remove(this);
|
|
2410
|
+
}
|
|
2411
|
+
/** Release tooltips and listeners. Call when discarding an embedded form. */
|
|
2412
|
+
destroy() {
|
|
2413
|
+
this.remove();
|
|
2414
|
+
this.tooltips.destroy();
|
|
2415
|
+
this.scheduler.reset();
|
|
2416
|
+
for (const field of this.fieldList) field.destroy();
|
|
2417
|
+
}
|
|
2418
|
+
teardownView() {
|
|
2419
|
+
this.view?.remove();
|
|
2420
|
+
this.view = void 0;
|
|
2421
|
+
}
|
|
2422
|
+
// -------------------------------------------------------- keyboard (stack)
|
|
2423
|
+
requestCancel() {
|
|
2424
|
+
this.cancel();
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* Enter pressed while this dialog is on top.
|
|
2428
|
+
*
|
|
2429
|
+
* Inside a textarea that allows newlines, Enter inserts one. Otherwise it
|
|
2430
|
+
* confirms — unless the confirm button is disabled.
|
|
2431
|
+
*/
|
|
2432
|
+
requestConfirm(event) {
|
|
2433
|
+
const target = event.target;
|
|
2434
|
+
const field = target?.getAttribute?.("name") ? this.fieldsByName.get(target.getAttribute("name")) : void 0;
|
|
2435
|
+
if (field instanceof TextAreaField) {
|
|
2436
|
+
if (!field.allowsNewlines) {
|
|
2437
|
+
event.preventDefault();
|
|
2438
|
+
event.stopPropagation();
|
|
2439
|
+
}
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
event.preventDefault();
|
|
2443
|
+
event.stopPropagation();
|
|
2444
|
+
if (this.buttonBar.primaryDisabled) return;
|
|
2445
|
+
this.buttonBar.primary?.click();
|
|
2446
|
+
}
|
|
2447
|
+
// ---------------------------------------------------------------- tooltips
|
|
2448
|
+
setTooltip(fieldName, text, icon, className) {
|
|
2449
|
+
this.tooltips.set(fieldName, text, icon, className);
|
|
2450
|
+
}
|
|
2451
|
+
setTooltipSuccess(fieldName, text) {
|
|
2452
|
+
this.tooltips.setSuccess(fieldName, text);
|
|
2453
|
+
}
|
|
2454
|
+
setTooltipWarning(fieldName, text) {
|
|
2455
|
+
this.tooltips.setWarning(fieldName, text);
|
|
2456
|
+
}
|
|
2457
|
+
setTooltipError(fieldName, text) {
|
|
2458
|
+
this.tooltips.setError(fieldName, text);
|
|
2459
|
+
}
|
|
2460
|
+
setTooltipLoading(fieldName, text) {
|
|
2461
|
+
this.tooltips.setLoading(fieldName, text);
|
|
2462
|
+
}
|
|
2463
|
+
resetTooltip(fieldName) {
|
|
2464
|
+
this.tooltips.reset(fieldName);
|
|
2465
|
+
}
|
|
2466
|
+
resetTooltips(fieldNames) {
|
|
2467
|
+
this.tooltips.resetAll(fieldNames);
|
|
2468
|
+
}
|
|
2469
|
+
};
|
|
2470
|
+
|
|
2471
|
+
export { ArrayField, ButtonBar, CardsField, CheckboxField, ComputedField, CustomField, DL_SELECT_STYLES, DeclarativeForm, DlOption, DlSelect, Field, FieldRegistry, FileField, FormModel, IdGenerator, MessageField, ModalStack, ModalView, NativeTooltipProvider, SafeHtml, SelectField, TabBar, TextAreaField, TextField, TooltipController, UpdateScheduler, deepEqual, defaultFieldRegistry, defineDlSelect, escapeHtml, globalModalStack, html, injectStyles, isSafeHtml };
|
|
2472
|
+
//# sourceMappingURL=index.js.map
|
|
2473
|
+
//# sourceMappingURL=index.js.map
|