@peter.prib/renderengine 0.2.7 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +66 -9
  2. package/REFERENCE.md +129 -0
  3. package/dist/renderengine-workspace.umd.js +5068 -2892
  4. package/dist/renderengine.umd.js +4874 -2888
  5. package/examples/data-model.html +40 -18
  6. package/package.json +1 -1
  7. package/src/charting/FormDialog.js +398 -385
  8. package/src/charting/RowsField.js +127 -0
  9. package/src/charting/datamodel/ClassificationLens.js +33 -0
  10. package/src/charting/datamodel/ClassificationScheme.js +98 -0
  11. package/src/charting/datamodel/DataModel.js +220 -4
  12. package/src/charting/datamodel/DataModelChecker.js +244 -2
  13. package/src/charting/datamodel/DataModelContext.js +40 -10
  14. package/src/charting/datamodel/DataModelEditCommand.js +47 -0
  15. package/src/charting/datamodel/DataModelPerspective.js +12 -2
  16. package/src/charting/datamodel/EnumerationType.js +64 -51
  17. package/src/charting/datamodel/LevelSchema.js +23 -0
  18. package/src/charting/datamodel/ServiceDomain.js +9 -0
  19. package/src/charting/datamodel/SqlDdl.js +85 -0
  20. package/src/charting/datamodel/SqlDdlFileFormat.js +23 -0
  21. package/src/charting/datamodel/index.js +21 -15
  22. package/src/charting/datamodel/naming/CamelCaseStyle.js +13 -0
  23. package/src/charting/datamodel/naming/LowerWordsStyle.js +13 -0
  24. package/src/charting/datamodel/naming/NameStyle.js +57 -0
  25. package/src/charting/datamodel/naming/NamingStandard.js +132 -0
  26. package/src/charting/datamodel/naming/PascalCaseStyle.js +13 -0
  27. package/src/charting/datamodel/naming/SnakeCaseStyle.js +13 -0
  28. package/src/charting/datamodel/naming/TitleCaseStyle.js +25 -0
  29. package/src/charting/datamodel/naming/UpperSnakeStyle.js +13 -0
  30. package/src/charting/datamodel/naming/index.js +8 -0
  31. package/src/charting/datamodel/scorecard/CompletenessCategory.js +30 -0
  32. package/src/charting/datamodel/scorecard/ConsistencyCategory.js +33 -0
  33. package/src/charting/datamodel/scorecard/DefinitionsCategory.js +33 -0
  34. package/src/charting/datamodel/scorecard/ModelScorecard.js +113 -0
  35. package/src/charting/datamodel/scorecard/ReadabilityCategory.js +37 -0
  36. package/src/charting/datamodel/scorecard/ScorecardCategory.js +46 -0
  37. package/src/charting/datamodel/scorecard/StandardsCategory.js +29 -0
  38. package/src/charting/datamodel/scorecard/StructureCategory.js +32 -0
  39. package/src/charting/datamodel/scorecard/index.js +8 -0
  40. package/src/charting/diagrams/DataModelDiagram.js +4 -11
  41. package/src/charting/diagrams/DataModelRelationshipStrategy.js +7 -1
  42. package/src/charting/diagrams/NodeGeometry.js +6 -2
  43. package/src/charting/toolbar/ToolbarSelect.js +6 -0
  44. package/src/datamodel.js +402 -4
  45. package/src/index.js +2666 -2666
  46. package/src/workspace/parts/DataModelPart.js +197 -6
@@ -1,385 +1,398 @@
1
- import { el } from '../workspace/dom.js';
2
- import { ColorField } from './ColorField.js';
3
-
4
- const INHERIT = '__inherit__';
5
-
6
- /**
7
- * FormDialog — the one modal form, built from field descriptions, so every
8
- * editor dialog shares one layout, keyboard handling and value reading.
9
- *
10
- * Field: `{ key, label, type, value?, choices?, min?, max?, step?, rows?, placeholder?,
11
- * suggestions?, accept?, hint?, readOnly?, allowEmpty?, inherit?, element?, read? }`
12
- * - type: 'text' | 'textarea' | 'number' | 'color' | 'select' | 'checkbox' | 'checklist' | 'file' | 'custom'
13
- * | 'heading' (a section title; no value)
14
- * - checklist: a box per choice, `choices` `[[value, text, group?], ...]` (grouped under their group's
15
- * name), `value` the chosen values; reads as the chosen values, in choice order
16
- * - choices (select): `[[value, text], ...]`. A current value that is not a
17
- * choice is kept as its own option (never silently blanked); `allowEmpty`
18
- * adds a "(none)" option for an empty value.
19
- * - suggestions (text): values offered as you type (a datalist). The box empties
20
- * on focus so every suggestion shows, and restores its value if left empty.
21
- * - readOnly: shown but not editable, and not read back
22
- * - accept (file): file types; reads as the chosen File or null
23
- * - custom: `element` (a Node to place) and `read()` (its value)
24
- * - color: a ColorField (the value as text, `#rrggbb` or `none`, a picker and preset
25
- * swatches); `presets` (`[{value, name}]`) replaces ColorField.PRESETS, and
26
- * `allowNone: false` leaves out "No colour"
27
- * - hint: small explanatory text under the field
28
- * - inherit: `{ text, short?, value }` — the field may be left unset. Selects get
29
- * a first option labelled `text`; other types (checkboxes too) get an "Inherit"
30
- * checkbox (`short` renames it) that greys the control out showing `value`. An
31
- * inherited field reads as `undefined`. `FormDialog.many` uses it for a form
32
- * that edits several items at once.
33
- *
34
- * The dialog fits the window: its fields scroll between the title and the
35
- * buttons, which stay in view, and it moves by dragging its title. A textarea
36
- * scrolls inside its own box and can be resized taller.
37
- *
38
- * `validate(values)` may return a message to show inline instead of closing.
39
- * After open(), `controls[key]` is each field's control, for wiring fields that
40
- * depend on one another.
41
- */
42
- export class FormDialog {
43
- /**
44
- * @param {Object} options
45
- * @param {string} options.title
46
- * @param {string} [options.description] - Text under the title
47
- * @param {Array<Object>} options.fields
48
- * @param {(values: Object) => void} options.onApply
49
- * @param {(values: Object) => (string|null|undefined)} [options.validate]
50
- * @param {string} [options.submitLabel]
51
- * @param {string} [options.cancelLabel]
52
- * @param {string} [options.idPrefix] - Field element ids are `${idPrefix}${key}`
53
- * @param {Document} [options.document]
54
- * @param {(applied: boolean) => void} [options.onClose] - Once, when the dialog closes: whether it was
55
- * applied (false for Cancel, Escape or a click outside)
56
- */
57
- constructor({ title, description, fields, onApply, validate, submitLabel = 'Apply', cancelLabel = 'Cancel', idPrefix = 'form-dialog-', document: doc = document, onClose = null }) {
58
- this.title = title;
59
- this.description = description;
60
- this.fields = fields;
61
- this.onApply = onApply;
62
- this.validate = validate;
63
- this.submitLabel = submitLabel;
64
- this.cancelLabel = cancelLabel;
65
- this.idPrefix = idPrefix;
66
- this.doc = doc;
67
- this.onClose = onClose;
68
- this._applied = false;
69
- this.overlay = null;
70
- /** @type {Object<string, HTMLElement>} */
71
- this.controls = {};
72
- this._readers = [];
73
- this._error = null;
74
- }
75
-
76
- open() {
77
- this._readers = [];
78
- this.controls = {};
79
- const rows = this.fields.map(f => this._row(f));
80
- this.overlay = el({
81
- style: {
82
- position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.25)', zIndex: '10001',
83
- display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'sans-serif',
84
- },
85
- on: { mousedown: (e) => { if (e.target === this.overlay) this.close(); } },
86
- children: [{
87
- tag: 'form',
88
- attrs: { role: 'dialog', 'aria-label': this.title },
89
- style: {
90
- background: '#fff', color: '#222', border: '1px solid #bbb', borderRadius: '6px', padding: '16px 18px',
91
- minWidth: '360px', maxWidth: '90vw', maxHeight: '90vh', boxSizing: 'border-box',
92
- display: 'flex', flexDirection: 'column',
93
- boxShadow: '0 6px 24px rgba(0,0,0,0.2)', fontSize: '12px',
94
- },
95
- ref: (n) => { this.form = n; },
96
- on: {
97
- submit: (e) => { e.preventDefault(); this._apply(); },
98
- keydown: (e) => { if (e.key === 'Escape') this.close(); },
99
- },
100
- children: [
101
- {
102
- // The title is the handle that moves the dialog.
103
- tag: 'h3', text: this.title, attrs: { title: 'Drag to move' },
104
- style: { margin: '0 0 8px', fontSize: '14px', cursor: 'move', userSelect: 'none', flex: '0 0 auto' },
105
- on: { mousedown: (e) => this._startMove(e) },
106
- },
107
- {
108
- // The fields scroll; the title and the buttons stay in view.
109
- style: { overflowY: 'auto', flex: '1 1 auto', minHeight: '0', paddingRight: '4px' },
110
- ref: (n) => { this.body = n; },
111
- children: [
112
- this.description && { text: this.description, style: { fontSize: '11px', color: '#666', margin: '0 0 10px', whiteSpace: 'pre-line' } },
113
- { style: { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px 12px', alignItems: 'center' }, children: rows.flat() },
114
- { attrs: { role: 'alert' }, style: { color: '#c0392b', fontSize: '12px', marginTop: '8px', display: 'none' }, ref: (n) => { this._error = n; } },
115
- ].filter(Boolean),
116
- },
117
- {
118
- style: { display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '16px', flex: '0 0 auto' },
119
- children: [
120
- { tag: 'button', attrs: { type: 'button' }, text: this.cancelLabel, on: { click: () => this.close() } },
121
- {
122
- tag: 'button', attrs: { type: 'submit' }, text: this.submitLabel,
123
- style: { background: '#0066cc', color: '#fff', border: '1px solid #0066cc', borderRadius: '3px', padding: '4px 14px' },
124
- },
125
- ],
126
- },
127
- ].filter(Boolean),
128
- }],
129
- }, this.doc);
130
- this.doc.body.appendChild(this.overlay);
131
- this.overlay.querySelector('input:not([type=checkbox]), select, textarea')?.focus();
132
- return this;
133
- }
134
-
135
- /**
136
- * Move the dialog by its title: from then on it sits where it was dropped
137
- * (fixed position), kept inside the window.
138
- * @protected
139
- */
140
- _startMove(e) {
141
- if (e.button !== 0 || !this.form) return;
142
- e.preventDefault();
143
- const view = this.doc.defaultView;
144
- const box = this.form.getBoundingClientRect();
145
- const grab = { x: e.clientX - box.left, y: e.clientY - box.top };
146
- Object.assign(this.form.style, { position: 'fixed', margin: '0', left: `${box.left}px`, top: `${box.top}px` });
147
- const move = (ev) => {
148
- const width = view?.innerWidth ?? Infinity, height = view?.innerHeight ?? Infinity;
149
- const left = Math.min(Math.max(0, ev.clientX - grab.x), Math.max(0, width - box.width));
150
- const top = Math.min(Math.max(0, ev.clientY - grab.y), Math.max(0, height - 40));
151
- this.form.style.left = `${left}px`;
152
- this.form.style.top = `${top}px`;
153
- };
154
- const end = () => {
155
- this.doc.removeEventListener('mousemove', move);
156
- this.doc.removeEventListener('mouseup', end);
157
- };
158
- this.doc.addEventListener('mousemove', move);
159
- this.doc.addEventListener('mouseup', end);
160
- }
161
-
162
- close() {
163
- if (!this.overlay) return;
164
- this.overlay.remove();
165
- this.overlay = null;
166
- this.onClose?.(this._applied);
167
- }
168
-
169
- values() {
170
- return Object.fromEntries(this._readers.map(read => read()));
171
- }
172
-
173
- _apply() {
174
- const values = this.values();
175
- const problem = this.validate?.(values);
176
- if (problem) {
177
- this._error.textContent = problem;
178
- this._error.style.display = 'block';
179
- return;
180
- }
181
- this._applied = true;
182
- this.close();
183
- this.onApply(values);
184
- }
185
-
186
- _id(f) {
187
- return `${this.idPrefix}${f.key}`;
188
- }
189
-
190
- _keep(f) {
191
- return (n) => { this.controls[f.key] = n; };
192
- }
193
-
194
- _row(f) {
195
- if (f.type === 'heading') {
196
- return [{
197
- text: f.label, style: {
198
- gridColumn: '1 / -1', fontSize: '11px', fontWeight: 'bold', textTransform: 'uppercase',
199
- color: '#999', letterSpacing: '0.04em', marginTop: '6px',
200
- },
201
- }];
202
- }
203
- const label = { tag: 'label', text: f.label, attrs: { for: this._id(f) }, style: { color: '#333' } };
204
- let control;
205
- if (f.type === 'select') control = this._select(f);
206
- else if (f.type === 'checkbox') control = this._checkbox(f);
207
- else if (f.type === 'checklist') control = this._checklist(f);
208
- else if (f.type === 'custom') control = this._custom(f);
209
- else control = f.inherit ? this._inheritableInput(f) : this._input(f);
210
- // No label: the control takes the whole row (e.g. an editable list).
211
- const row = f.label ? [label, control] : [{ style: { gridColumn: '1 / -1' }, children: [control] }];
212
- if (f.hint) row.push({}, { text: f.hint, style: { fontSize: '11px', color: '#888', marginTop: '-4px' } });
213
- return row;
214
- }
215
-
216
- _select(f) {
217
- let select;
218
- if (!f.readOnly) this._readers.push(() => [f.key, select.value === INHERIT ? undefined : select.value]);
219
- const unset = f.inherit && f.value === undefined;
220
- const current = f.value == null ? '' : String(f.value);
221
- const known = f.choices.some(([v]) => v === current);
222
- const extra = unset ? []
223
- : current && !known ? [[current, current]]
224
- : !current && f.allowEmpty ? [['', '(none)']]
225
- : [];
226
- return {
227
- tag: 'select', attrs: { id: this._id(f), disabled: !!f.readOnly }, ref: (n) => { select = n; this._keep(f)(n); },
228
- children: [
229
- ...(f.inherit ? [{ tag: 'option', attrs: { value: INHERIT, selected: unset }, text: f.inherit.text }] : []),
230
- ...[...extra, ...f.choices].map(([v, text]) => ({ tag: 'option', attrs: { value: v, selected: !unset && v === current }, text })),
231
- ],
232
- };
233
- }
234
-
235
- _checkbox(f) {
236
- if (f.inherit) return this._inheritableInput(f);
237
- let box;
238
- if (!f.readOnly) this._readers.push(() => [f.key, box.checked]);
239
- return {
240
- tag: 'input', attrs: { id: this._id(f), type: 'checkbox', checked: !!f.value, disabled: !!f.readOnly }, style: { justifySelf: 'start' },
241
- ref: (n) => { box = n; this._keep(f)(n); },
242
- };
243
- }
244
-
245
- _checklist(f) {
246
- const boxes = [];
247
- const chosen = new Set(f.value ?? []);
248
- if (!f.readOnly) this._readers.push(() => [f.key, boxes.filter(b => b.checked).map(b => b.value)]);
249
- const groups = new Map();
250
- for (const [value, text, group = ''] of f.choices) {
251
- if (!groups.has(group)) groups.set(group, []);
252
- groups.get(group).push([value, text]);
253
- }
254
- return {
255
- attrs: { id: this._id(f) }, ref: this._keep(f),
256
- style: { maxHeight: '220px', overflowY: 'auto', border: '1px solid #ccc', borderRadius: '3px', padding: '4px 8px', background: '#fff' },
257
- children: [...groups].map(([group, items]) => ({
258
- children: [
259
- ...(group ? [{ text: group, style: { fontSize: '11px', fontWeight: 'bold', color: '#777', marginTop: '4px' } }] : []),
260
- ...items.map(([value, text]) => ({
261
- tag: 'label', style: { display: 'block', whiteSpace: 'nowrap' },
262
- children: [
263
- { tag: 'input', attrs: { type: 'checkbox', value, checked: chosen.has(value), disabled: !!f.readOnly }, ref: (n) => boxes.push(n) },
264
- ` ${text}`,
265
- ],
266
- })),
267
- ],
268
- })),
269
- };
270
- }
271
-
272
- _custom(f) {
273
- this._readers.push(() => [f.key, f.read()]);
274
- if (f.element && !f.element.id) f.element.id = this._id(f);
275
- this.controls[f.key] = f.element;
276
- return f.element;
277
- }
278
-
279
- _controlSpec(f, ref) {
280
- const keep = (n) => { ref(n); this._keep(f)(n); };
281
- const readOnlyStyle = f.readOnly ? { background: '#f2f2f2', color: '#888' } : {};
282
- if (f.type === 'textarea') {
283
- return {
284
- tag: 'textarea', attrs: { id: this._id(f), rows: f.rows ?? 4, placeholder: f.placeholder, readonly: !!f.readOnly },
285
- text: f.value ?? '',
286
- // Long text scrolls inside the box, which can be dragged taller.
287
- style: { width: '100%', minWidth: '260px', boxSizing: 'border-box', maxHeight: '40vh', overflowY: 'auto', resize: 'vertical', ...readOnlyStyle },
288
- ref: keep,
289
- };
290
- }
291
- // A colour: its value as text (#rrggbb or none), a picker and preset swatches.
292
- if (f.type === 'color') return ColorField.spec(f, { id: this._id(f), ref: keep, style: readOnlyStyle });
293
- const value = f.type === 'file' ? null : (f.value == null ? '' : (Array.isArray(f.value) ? JSON.stringify(f.value) : String(f.value)));
294
- const listId = f.suggestions ? `${this._id(f)}-suggestions` : null;
295
- const input = {
296
- tag: 'input',
297
- attrs: {
298
- id: this._id(f), type: f.type ?? 'text', value,
299
- min: f.min, max: f.max, step: f.step, placeholder: f.placeholder, accept: f.accept, list: listId,
300
- readonly: !!f.readOnly,
301
- },
302
- style: { ...(f.type === 'number' ? { width: '70px' } : { width: '100%', boxSizing: 'border-box' }), ...readOnlyStyle },
303
- on: f.suggestions ? {
304
- // A datalist filters against the text already typed, so an existing
305
- // value can look like the only option. Emptying on focus shows them all.
306
- focus: (e) => { e.target.dataset.prevValue = e.target.value; e.target.value = ''; },
307
- blur: (e) => { if (e.target.value === '' && e.target.dataset.prevValue) e.target.value = e.target.dataset.prevValue; },
308
- } : undefined,
309
- ref: keep,
310
- };
311
- if (!listId) return input;
312
- return {
313
- children: [input, { tag: 'datalist', attrs: { id: listId }, children: f.suggestions.map(v => ({ tag: 'option', attrs: { value: v } })) }],
314
- };
315
- }
316
-
317
- _read(f, input) {
318
- if (f.type === 'file') return input.files?.[0] ?? null;
319
- if (f.type === 'checkbox') return input.checked;
320
- return f.type === 'number' ? Number(input.value) : input.value;
321
- }
322
-
323
- _input(f) {
324
- let input;
325
- if (!f.readOnly) this._readers.push(() => [f.key, this._read(f, input)]);
326
- return this._controlSpec(f, (n) => { input = n; });
327
- }
328
-
329
- /** The control plus an "Inherit" checkbox that greys it out showing the inherited value. */
330
- _inheritableInput(f) {
331
- let input, inherit;
332
- const sync = () => {
333
- input.disabled = inherit.checked;
334
- if (inherit.checked && f.type === 'checkbox') input.checked = !!f.inherit.value;
335
- else if (inherit.checked) input.value = f.inherit.value;
336
- };
337
- this._readers.push(() => [f.key, inherit.checked ? undefined : this._read(f, input)]);
338
- return {
339
- style: { display: 'flex', alignItems: 'center', gap: '10px' },
340
- children: [
341
- f.type === 'checkbox'
342
- ? { tag: 'input', attrs: { id: this._id(f), type: 'checkbox', checked: !!(f.value ?? f.inherit.value) }, ref: (n) => { input = n; this._keep(f)(n); } }
343
- : this._controlSpec({ ...f, value: f.value ?? f.inherit.value }, (n) => { input = n; }),
344
- {
345
- tag: 'label', style: { display: 'flex', alignItems: 'center', gap: '4px', color: '#555' },
346
- attrs: { title: f.inherit.text },
347
- children: [
348
- { tag: 'input', attrs: { type: 'checkbox', checked: f.value === undefined }, on: { change: sync }, ref: (n) => { inherit = n; } },
349
- f.inherit.short ?? 'Inherit',
350
- ],
351
- },
352
- ],
353
- // el() runs a parent's ref after its children's, so input/inherit are bound here.
354
- ref: () => sync(),
355
- };
356
- }
357
-
358
- /**
359
- * One form for several items: each item's fields (the same keys, in the
360
- * same order) merged into one list in which every field starts as "keep
361
- * each item's own" (`inherit`, reading as undefined) showing the value
362
- * they share, or empty with a hint when they differ. Set a field and it
363
- * applies to them all; read-only fields are left out.
364
- * @param {Array<Array<Object>>} fieldLists - One list of fields per item
365
- * @param {Object} [options]
366
- * @param {string} [options.short] - The keep checkbox's label
367
- * @param {string} [options.text] - Its tooltip (and a select's first option)
368
- * @returns {Array<Object>} fields
369
- */
370
- static many(fieldLists, { short = 'Keep', text = 'As each one is' } = {}) {
371
- const [first = [], ...rest] = fieldLists;
372
- const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
373
- return first.filter(f => !f.readOnly).map((f, i) => {
374
- if (f.type === 'heading' || !f.key) return f;
375
- const values = [f.value, ...rest.map(list => list.find(g => g.key === f.key)?.value)];
376
- const shared = values.every(v => same(v, values[0]));
377
- const blank = f.type === 'checkbox' ? false : '';
378
- return {
379
- ...f, value: undefined,
380
- inherit: { short, text, value: shared ? (values[0] ?? blank) : blank },
381
- ...(!shared && { hint: [f.hint, 'Differs between them'].filter(Boolean).join('. ') }),
382
- };
383
- });
384
- }
385
- }
1
+ import { el } from '../workspace/dom.js';
2
+ import { ColorField } from './ColorField.js';
3
+ import { RowsField } from './RowsField.js';
4
+
5
+ const INHERIT = '__inherit__';
6
+
7
+ /**
8
+ * FormDialog — the one modal form, built from field descriptions, so every
9
+ * editor dialog shares one layout, keyboard handling and value reading.
10
+ *
11
+ * Field: `{ key, label, type, value?, choices?, min?, max?, step?, rows?, placeholder?,
12
+ * suggestions?, accept?, hint?, readOnly?, allowEmpty?, inherit?, element?, read? }`
13
+ * - type: 'text' | 'textarea' | 'number' | 'color' | 'select' | 'checkbox' | 'checklist' | 'file' | 'custom'
14
+ * | 'rows' | 'heading' (a section title; no value)
15
+ * - rows: a list edited as a table (RowsField): `columns` `[{ key, label, type?, choices?, suggestions? }]`,
16
+ * `value` the items, `addLabel`; reads as the items in order, each keeping what no column shows
17
+ * - checklist: a box per choice, `choices` `[[value, text, group?], ...]` (grouped under their group's
18
+ * name), `value` the chosen values; reads as the chosen values, in choice order
19
+ * - choices (select): `[[value, text], ...]`. A current value that is not a
20
+ * choice is kept as its own option (never silently blanked); `allowEmpty`
21
+ * adds a "(none)" option for an empty value.
22
+ * - suggestions (text): values offered as you type (a datalist). The box empties
23
+ * on focus so every suggestion shows, and restores its value if left empty.
24
+ * - readOnly: shown but not editable, and not read back
25
+ * - accept (file): file types; reads as the chosen File or null
26
+ * - custom: `element` (a Node to place) and `read()` (its value)
27
+ * - color: a ColorField (the value as text, `#rrggbb` or `none`, a picker and preset
28
+ * swatches); `presets` (`[{value, name}]`) replaces ColorField.PRESETS, and
29
+ * `allowNone: false` leaves out "No colour"
30
+ * - hint: small explanatory text under the field
31
+ * - inherit: `{ text, short?, value }` — the field may be left unset. Selects get
32
+ * a first option labelled `text`; other types (checkboxes too) get an "Inherit"
33
+ * checkbox (`short` renames it) that greys the control out showing `value`. An
34
+ * inherited field reads as `undefined`. `FormDialog.many` uses it for a form
35
+ * that edits several items at once.
36
+ *
37
+ * The dialog fits the window: its fields scroll between the title and the
38
+ * buttons, which stay in view, and it moves by dragging its title. A textarea
39
+ * scrolls inside its own box and can be resized taller.
40
+ *
41
+ * `validate(values)` may return a message to show inline instead of closing.
42
+ * After open(), `controls[key]` is each field's control, for wiring fields that
43
+ * depend on one another.
44
+ */
45
+ export class FormDialog {
46
+ /**
47
+ * @param {Object} options
48
+ * @param {string} options.title
49
+ * @param {string} [options.description] - Text under the title
50
+ * @param {Array<Object>} options.fields
51
+ * @param {(values: Object) => void} options.onApply
52
+ * @param {(values: Object) => (string|null|undefined)} [options.validate]
53
+ * @param {string} [options.submitLabel]
54
+ * @param {string} [options.cancelLabel]
55
+ * @param {string} [options.idPrefix] - Field element ids are `${idPrefix}${key}`
56
+ * @param {Document} [options.document]
57
+ * @param {(applied: boolean) => void} [options.onClose] - Once, when the dialog closes: whether it was
58
+ * applied (false for Cancel, Escape or a click outside)
59
+ */
60
+ constructor({ title, description, fields, onApply, validate, submitLabel = 'Apply', cancelLabel = 'Cancel', idPrefix = 'form-dialog-', document: doc = document, onClose = null }) {
61
+ this.title = title;
62
+ this.description = description;
63
+ this.fields = fields;
64
+ this.onApply = onApply;
65
+ this.validate = validate;
66
+ this.submitLabel = submitLabel;
67
+ this.cancelLabel = cancelLabel;
68
+ this.idPrefix = idPrefix;
69
+ this.doc = doc;
70
+ this.onClose = onClose;
71
+ this._applied = false;
72
+ this.overlay = null;
73
+ /** @type {Object<string, HTMLElement>} */
74
+ this.controls = {};
75
+ this._readers = [];
76
+ this._error = null;
77
+ }
78
+
79
+ open() {
80
+ this._readers = [];
81
+ this.controls = {};
82
+ const rows = this.fields.map(f => this._row(f));
83
+ this.overlay = el({
84
+ style: {
85
+ position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.25)', zIndex: '10001',
86
+ display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'sans-serif',
87
+ },
88
+ on: { mousedown: (e) => { if (e.target === this.overlay) this.close(); } },
89
+ children: [{
90
+ tag: 'form',
91
+ attrs: { role: 'dialog', 'aria-label': this.title },
92
+ style: {
93
+ background: '#fff', color: '#222', border: '1px solid #bbb', borderRadius: '6px', padding: '16px 18px',
94
+ minWidth: '360px', maxWidth: '90vw', maxHeight: '90vh', boxSizing: 'border-box',
95
+ display: 'flex', flexDirection: 'column',
96
+ boxShadow: '0 6px 24px rgba(0,0,0,0.2)', fontSize: '12px',
97
+ },
98
+ ref: (n) => { this.form = n; },
99
+ on: {
100
+ submit: (e) => { e.preventDefault(); this._apply(); },
101
+ keydown: (e) => { if (e.key === 'Escape') this.close(); },
102
+ },
103
+ children: [
104
+ {
105
+ // The title is the handle that moves the dialog.
106
+ tag: 'h3', text: this.title, attrs: { title: 'Drag to move' },
107
+ style: { margin: '0 0 8px', fontSize: '14px', cursor: 'move', userSelect: 'none', flex: '0 0 auto' },
108
+ on: { mousedown: (e) => this._startMove(e) },
109
+ },
110
+ {
111
+ // The fields scroll; the title and the buttons stay in view.
112
+ style: { overflowY: 'auto', flex: '1 1 auto', minHeight: '0', paddingRight: '4px' },
113
+ ref: (n) => { this.body = n; },
114
+ children: [
115
+ this.description && { text: this.description, style: { fontSize: '11px', color: '#666', margin: '0 0 10px', whiteSpace: 'pre-line' } },
116
+ { style: { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px 12px', alignItems: 'center' }, children: rows.flat() },
117
+ { attrs: { role: 'alert' }, style: { color: '#c0392b', fontSize: '12px', marginTop: '8px', display: 'none' }, ref: (n) => { this._error = n; } },
118
+ ].filter(Boolean),
119
+ },
120
+ {
121
+ style: { display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '16px', flex: '0 0 auto' },
122
+ children: [
123
+ { tag: 'button', attrs: { type: 'button' }, text: this.cancelLabel, on: { click: () => this.close() } },
124
+ {
125
+ tag: 'button', attrs: { type: 'submit' }, text: this.submitLabel,
126
+ style: { background: '#0066cc', color: '#fff', border: '1px solid #0066cc', borderRadius: '3px', padding: '4px 14px' },
127
+ },
128
+ ],
129
+ },
130
+ ].filter(Boolean),
131
+ }],
132
+ }, this.doc);
133
+ this.doc.body.appendChild(this.overlay);
134
+ this.overlay.querySelector('input:not([type=checkbox]), select, textarea')?.focus();
135
+ return this;
136
+ }
137
+
138
+ /**
139
+ * Move the dialog by its title: from then on it sits where it was dropped
140
+ * (fixed position), kept inside the window.
141
+ * @protected
142
+ */
143
+ _startMove(e) {
144
+ if (e.button !== 0 || !this.form) return;
145
+ e.preventDefault();
146
+ const view = this.doc.defaultView;
147
+ const box = this.form.getBoundingClientRect();
148
+ const grab = { x: e.clientX - box.left, y: e.clientY - box.top };
149
+ Object.assign(this.form.style, { position: 'fixed', margin: '0', left: `${box.left}px`, top: `${box.top}px` });
150
+ const move = (ev) => {
151
+ const width = view?.innerWidth ?? Infinity, height = view?.innerHeight ?? Infinity;
152
+ const left = Math.min(Math.max(0, ev.clientX - grab.x), Math.max(0, width - box.width));
153
+ const top = Math.min(Math.max(0, ev.clientY - grab.y), Math.max(0, height - 40));
154
+ this.form.style.left = `${left}px`;
155
+ this.form.style.top = `${top}px`;
156
+ };
157
+ const end = () => {
158
+ this.doc.removeEventListener('mousemove', move);
159
+ this.doc.removeEventListener('mouseup', end);
160
+ };
161
+ this.doc.addEventListener('mousemove', move);
162
+ this.doc.addEventListener('mouseup', end);
163
+ }
164
+
165
+ close() {
166
+ if (!this.overlay) return;
167
+ this.overlay.remove();
168
+ this.overlay = null;
169
+ this.onClose?.(this._applied);
170
+ }
171
+
172
+ values() {
173
+ return Object.fromEntries(this._readers.map(read => read()));
174
+ }
175
+
176
+ _apply() {
177
+ const values = this.values();
178
+ const problem = this.validate?.(values);
179
+ if (problem) {
180
+ this._error.textContent = problem;
181
+ this._error.style.display = 'block';
182
+ return;
183
+ }
184
+ this._applied = true;
185
+ this.close();
186
+ this.onApply(values);
187
+ }
188
+
189
+ _id(f) {
190
+ return `${this.idPrefix}${f.key}`;
191
+ }
192
+
193
+ _keep(f) {
194
+ return (n) => { this.controls[f.key] = n; };
195
+ }
196
+
197
+ _row(f) {
198
+ if (f.type === 'heading') {
199
+ return [{
200
+ text: f.label, style: {
201
+ gridColumn: '1 / -1', fontSize: '11px', fontWeight: 'bold', textTransform: 'uppercase',
202
+ color: '#999', letterSpacing: '0.04em', marginTop: '6px',
203
+ },
204
+ }];
205
+ }
206
+ const label = { tag: 'label', text: f.label, attrs: { for: this._id(f) }, style: { color: '#333' } };
207
+ let control;
208
+ if (f.type === 'select') control = this._select(f);
209
+ else if (f.type === 'checkbox') control = this._checkbox(f);
210
+ else if (f.type === 'checklist') control = this._checklist(f);
211
+ else if (f.type === 'custom') control = this._custom(f);
212
+ else if (f.type === 'rows') control = this._rowsField(f);
213
+ else control = f.inherit ? this._inheritableInput(f) : this._input(f);
214
+ // No label, or a table of rows: the control takes the whole row, its label above it.
215
+ const row = f.label && f.type !== 'rows' ? [label, control]
216
+ : [{ style: { gridColumn: '1 / -1' }, children: [...(f.label ? [{ ...label, style: { color: '#333', display: 'block', marginBottom: '2px' } }] : []), control] }];
217
+ if (f.hint) row.push({}, { text: f.hint, style: { fontSize: '11px', color: '#888', marginTop: '-4px' } });
218
+ return row;
219
+ }
220
+
221
+ _select(f) {
222
+ let select;
223
+ if (!f.readOnly) this._readers.push(() => [f.key, select.value === INHERIT ? undefined : select.value]);
224
+ const unset = f.inherit && f.value === undefined;
225
+ const current = f.value == null ? '' : String(f.value);
226
+ const known = f.choices.some(([v]) => v === current);
227
+ const extra = unset ? []
228
+ : current && !known ? [[current, current]]
229
+ : !current && f.allowEmpty ? [['', '(none)']]
230
+ : [];
231
+ return {
232
+ tag: 'select', attrs: { id: this._id(f), disabled: !!f.readOnly }, ref: (n) => { select = n; this._keep(f)(n); },
233
+ children: [
234
+ ...(f.inherit ? [{ tag: 'option', attrs: { value: INHERIT, selected: unset }, text: f.inherit.text }] : []),
235
+ ...[...extra, ...f.choices].map(([v, text]) => ({ tag: 'option', attrs: { value: v, selected: !unset && v === current }, text })),
236
+ ],
237
+ };
238
+ }
239
+
240
+ _checkbox(f) {
241
+ if (f.inherit) return this._inheritableInput(f);
242
+ let box;
243
+ if (!f.readOnly) this._readers.push(() => [f.key, box.checked]);
244
+ return {
245
+ tag: 'input', attrs: { id: this._id(f), type: 'checkbox', checked: !!f.value, disabled: !!f.readOnly }, style: { justifySelf: 'start' },
246
+ ref: (n) => { box = n; this._keep(f)(n); },
247
+ };
248
+ }
249
+
250
+ _checklist(f) {
251
+ const boxes = [];
252
+ const chosen = new Set(f.value ?? []);
253
+ if (!f.readOnly) this._readers.push(() => [f.key, boxes.filter(b => b.checked).map(b => b.value)]);
254
+ const groups = new Map();
255
+ for (const [value, text, group = ''] of f.choices) {
256
+ if (!groups.has(group)) groups.set(group, []);
257
+ groups.get(group).push([value, text]);
258
+ }
259
+ return {
260
+ attrs: { id: this._id(f) }, ref: this._keep(f),
261
+ style: { maxHeight: '220px', overflowY: 'auto', border: '1px solid #ccc', borderRadius: '3px', padding: '4px 8px', background: '#fff' },
262
+ children: [...groups].map(([group, items]) => ({
263
+ children: [
264
+ ...(group ? [{ text: group, style: { fontSize: '11px', fontWeight: 'bold', color: '#777', marginTop: '4px' } }] : []),
265
+ ...items.map(([value, text]) => ({
266
+ tag: 'label', style: { display: 'block', whiteSpace: 'nowrap' },
267
+ children: [
268
+ { tag: 'input', attrs: { type: 'checkbox', value, checked: chosen.has(value), disabled: !!f.readOnly }, ref: (n) => boxes.push(n) },
269
+ ` ${text}`,
270
+ ],
271
+ })),
272
+ ],
273
+ })),
274
+ };
275
+ }
276
+
277
+ _custom(f) {
278
+ this._readers.push(() => [f.key, f.read()]);
279
+ if (f.element && !f.element.id) f.element.id = this._id(f);
280
+ this.controls[f.key] = f.element;
281
+ return f.element;
282
+ }
283
+
284
+ _rowsField(f) {
285
+ const field = new RowsField(f, this.doc, this._id(f));
286
+ const node = field.element();
287
+ if (!f.readOnly) this._readers.push(() => [f.key, field.read()]);
288
+ this.controls[f.key] = node;
289
+ return node;
290
+ }
291
+
292
+ _controlSpec(f, ref) {
293
+ const keep = (n) => { ref(n); this._keep(f)(n); };
294
+ const readOnlyStyle = f.readOnly ? { background: '#f2f2f2', color: '#888' } : {};
295
+ if (f.type === 'textarea') {
296
+ return {
297
+ tag: 'textarea', attrs: { id: this._id(f), rows: f.rows ?? 4, placeholder: f.placeholder, readonly: !!f.readOnly },
298
+ text: f.value ?? '',
299
+ // Long text scrolls inside the box, which can be dragged taller.
300
+ style: { width: '100%', minWidth: '260px', boxSizing: 'border-box', maxHeight: '40vh', overflowY: 'auto', resize: 'vertical', ...readOnlyStyle },
301
+ ref: keep,
302
+ };
303
+ }
304
+ // A colour: its value as text (#rrggbb or none), a picker and preset swatches.
305
+ if (f.type === 'color') return ColorField.spec(f, { id: this._id(f), ref: keep, style: readOnlyStyle });
306
+ const value = f.type === 'file' ? null : (f.value == null ? '' : (Array.isArray(f.value) ? JSON.stringify(f.value) : String(f.value)));
307
+ const listId = f.suggestions ? `${this._id(f)}-suggestions` : null;
308
+ const input = {
309
+ tag: 'input',
310
+ attrs: {
311
+ id: this._id(f), type: f.type ?? 'text', value,
312
+ min: f.min, max: f.max, step: f.step, placeholder: f.placeholder, accept: f.accept, list: listId,
313
+ readonly: !!f.readOnly,
314
+ },
315
+ style: { ...(f.type === 'number' ? { width: '70px' } : { width: '100%', boxSizing: 'border-box' }), ...readOnlyStyle },
316
+ on: f.suggestions ? {
317
+ // A datalist filters against the text already typed, so an existing
318
+ // value can look like the only option. Emptying on focus shows them all.
319
+ focus: (e) => { e.target.dataset.prevValue = e.target.value; e.target.value = ''; },
320
+ blur: (e) => { if (e.target.value === '' && e.target.dataset.prevValue) e.target.value = e.target.dataset.prevValue; },
321
+ } : undefined,
322
+ ref: keep,
323
+ };
324
+ if (!listId) return input;
325
+ return {
326
+ children: [input, { tag: 'datalist', attrs: { id: listId }, children: f.suggestions.map(v => ({ tag: 'option', attrs: { value: v } })) }],
327
+ };
328
+ }
329
+
330
+ _read(f, input) {
331
+ if (f.type === 'file') return input.files?.[0] ?? null;
332
+ if (f.type === 'checkbox') return input.checked;
333
+ return f.type === 'number' ? Number(input.value) : input.value;
334
+ }
335
+
336
+ _input(f) {
337
+ let input;
338
+ if (!f.readOnly) this._readers.push(() => [f.key, this._read(f, input)]);
339
+ return this._controlSpec(f, (n) => { input = n; });
340
+ }
341
+
342
+ /** The control plus an "Inherit" checkbox that greys it out showing the inherited value. */
343
+ _inheritableInput(f) {
344
+ let input, inherit;
345
+ const sync = () => {
346
+ input.disabled = inherit.checked;
347
+ if (inherit.checked && f.type === 'checkbox') input.checked = !!f.inherit.value;
348
+ else if (inherit.checked) input.value = f.inherit.value;
349
+ };
350
+ this._readers.push(() => [f.key, inherit.checked ? undefined : this._read(f, input)]);
351
+ return {
352
+ style: { display: 'flex', alignItems: 'center', gap: '10px' },
353
+ children: [
354
+ f.type === 'checkbox'
355
+ ? { tag: 'input', attrs: { id: this._id(f), type: 'checkbox', checked: !!(f.value ?? f.inherit.value) }, ref: (n) => { input = n; this._keep(f)(n); } }
356
+ : this._controlSpec({ ...f, value: f.value ?? f.inherit.value }, (n) => { input = n; }),
357
+ {
358
+ tag: 'label', style: { display: 'flex', alignItems: 'center', gap: '4px', color: '#555' },
359
+ attrs: { title: f.inherit.text },
360
+ children: [
361
+ { tag: 'input', attrs: { type: 'checkbox', checked: f.value === undefined }, on: { change: sync }, ref: (n) => { inherit = n; } },
362
+ f.inherit.short ?? 'Inherit',
363
+ ],
364
+ },
365
+ ],
366
+ // el() runs a parent's ref after its children's, so input/inherit are bound here.
367
+ ref: () => sync(),
368
+ };
369
+ }
370
+
371
+ /**
372
+ * One form for several items: each item's fields (the same keys, in the
373
+ * same order) merged into one list in which every field starts as "keep
374
+ * each item's own" (`inherit`, reading as undefined) showing the value
375
+ * they share, or empty with a hint when they differ. Set a field and it
376
+ * applies to them all; read-only fields are left out.
377
+ * @param {Array<Array<Object>>} fieldLists - One list of fields per item
378
+ * @param {Object} [options]
379
+ * @param {string} [options.short] - The keep checkbox's label
380
+ * @param {string} [options.text] - Its tooltip (and a select's first option)
381
+ * @returns {Array<Object>} fields
382
+ */
383
+ static many(fieldLists, { short = 'Keep', text = 'As each one is' } = {}) {
384
+ const [first = [], ...rest] = fieldLists;
385
+ const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
386
+ return first.filter(f => !f.readOnly).map((f, i) => {
387
+ if (f.type === 'heading' || !f.key) return f;
388
+ const values = [f.value, ...rest.map(list => list.find(g => g.key === f.key)?.value)];
389
+ const shared = values.every(v => same(v, values[0]));
390
+ const blank = f.type === 'checkbox' ? false : '';
391
+ return {
392
+ ...f, value: undefined,
393
+ inherit: { short, text, value: shared ? (values[0] ?? blank) : blank },
394
+ ...(!shared && { hint: [f.hint, 'Differs between them'].filter(Boolean).join('. ') }),
395
+ };
396
+ });
397
+ }
398
+ }