flexdesk 0.17.18 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +1 -0
  3. package/css/flexdesk.css +7544 -5916
  4. package/css/reset.css +1 -1
  5. package/css/tokens.css +23 -1
  6. package/dist/charts.js +10 -6
  7. package/dist/charts.js.map +1 -1
  8. package/dist/chunk-5W4EEKSK.js +778 -0
  9. package/dist/chunk-5W4EEKSK.js.map +7 -0
  10. package/dist/chunk-DNJ3N4T4.js +82 -0
  11. package/dist/chunk-DNJ3N4T4.js.map +7 -0
  12. package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
  13. package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
  14. package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
  15. package/dist/{chunk-7OL6F2AM.js → chunk-P74F6I6P.js} +6 -4
  16. package/dist/{chunk-7OL6F2AM.js.map → chunk-P74F6I6P.js.map} +1 -1
  17. package/dist/chunk-PAI2ZUWV.js +260 -0
  18. package/dist/chunk-PAI2ZUWV.js.map +7 -0
  19. package/dist/chunk-Q7BALEPX.js +527 -0
  20. package/dist/chunk-Q7BALEPX.js.map +7 -0
  21. package/dist/chunk-QXAOTGCC.js +40 -0
  22. package/dist/chunk-QXAOTGCC.js.map +7 -0
  23. package/dist/{chunk-XJNADRGR.js → chunk-RKVYIXDC.js} +664 -91
  24. package/dist/chunk-RKVYIXDC.js.map +7 -0
  25. package/dist/{chunk-2Z2ENHR3.js → chunk-U5GKJUOC.js} +3 -3
  26. package/dist/{chunk-2Z2ENHR3.js.map → chunk-U5GKJUOC.js.map} +1 -1
  27. package/dist/chunk-UKP22RUG.js +219 -0
  28. package/dist/chunk-UKP22RUG.js.map +7 -0
  29. package/dist/chunk-VLOAI3HS.js +43 -0
  30. package/dist/chunk-VLOAI3HS.js.map +7 -0
  31. package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
  32. package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
  33. package/dist/{chunk-PGZ4EHSE.js → chunk-ZL3VKINP.js} +41 -390
  34. package/dist/chunk-ZL3VKINP.js.map +7 -0
  35. package/dist/editor.js +3 -2
  36. package/dist/editor.js.map +1 -1
  37. package/dist/flexdesk.css +7544 -5916
  38. package/dist/flow.js +29 -97
  39. package/dist/flow.js.map +3 -3
  40. package/dist/git.js +1356 -0
  41. package/dist/git.js.map +7 -0
  42. package/dist/reset.css +1 -1
  43. package/dist/tiles.js +20 -15
  44. package/dist/tiles.js.map +2 -2
  45. package/dist/tokens.css +23 -1
  46. package/dist/widgets.js +3967 -1188
  47. package/dist/widgets.js.map +4 -4
  48. package/dist/wm.js +120 -32
  49. package/dist/wm.js.map +2 -2
  50. package/docs/README.md +9 -4
  51. package/docs/best-practices.md +2 -2
  52. package/docs/components/chat-shell.md +198 -0
  53. package/docs/components/conversation.md +10 -0
  54. package/docs/components/filter-builder.md +246 -0
  55. package/docs/components/forms-and-dialogs.md +22 -0
  56. package/docs/components/git.md +355 -0
  57. package/docs/components/input-fields.md +167 -2
  58. package/docs/components/keymap.md +8 -0
  59. package/docs/components/menus.md +82 -2
  60. package/docs/components/panels-and-desktops.md +16 -5
  61. package/docs/components/shell.md +104 -1
  62. package/docs/components/workspace-picker.md +149 -0
  63. package/docs/concepts/building-and-packaging.md +2 -2
  64. package/docs/concepts/packages-and-entry-points.md +14 -13
  65. package/docs/concepts/state-guard.md +3 -3
  66. package/docs/quickstart.md +1 -1
  67. package/git.js +13 -0
  68. package/package.json +6 -1
  69. package/src/flow/kit/dom.js +28 -119
  70. package/src/flow/kit/step_picker.js +1 -1
  71. package/src/flow/kit/value_picker.js +1 -1
  72. package/src/flow/outline/menu.js +1 -1
  73. package/src/git/diff_viewer.js +212 -0
  74. package/src/git/diff_window.js +51 -0
  75. package/src/git/git_graph.js +401 -0
  76. package/src/git/git_ops.js +61 -0
  77. package/src/git/git_panel.js +755 -0
  78. package/src/git/index.js +9 -0
  79. package/src/git/line_diff.js +202 -0
  80. package/src/tiles/tile_base.js +5 -4
  81. package/src/tiling/desktops.js +10 -8
  82. package/src/tiling/keymap.js +6 -5
  83. package/src/tiling/kind_taxonomy.js +15 -2
  84. package/src/tiling/shell.js +124 -17
  85. package/src/tiling/tile_renderer.js +2 -3
  86. package/src/ui/components/action_dropdown.js +4 -78
  87. package/src/ui/components/anchored_layer.js +255 -0
  88. package/src/ui/components/chat_shell.js +920 -0
  89. package/src/ui/components/chat_state_machine.js +223 -0
  90. package/src/ui/components/choice_controls.js +45 -2
  91. package/src/ui/components/color_field.js +3 -2
  92. package/src/ui/components/color_list_field.js +3 -2
  93. package/src/ui/components/combobox.js +422 -0
  94. package/src/ui/components/conversation_view.js +17 -2
  95. package/src/ui/components/data_table.js +2 -2
  96. package/src/ui/components/filter_builder.js +766 -0
  97. package/src/ui/components/filter_model.js +614 -0
  98. package/src/ui/components/managed_window.js +24 -47
  99. package/src/ui/components/modal.js +79 -109
  100. package/src/ui/components/option_list.js +160 -0
  101. package/src/ui/components/popover.js +94 -0
  102. package/src/ui/components/recent_items.js +169 -0
  103. package/src/ui/components/select.js +322 -0
  104. package/src/ui/components/tag_input.js +77 -8
  105. package/src/ui/components/workspace_picker.js +1155 -0
  106. package/src/ui/utils/fire_event.js +17 -0
  107. package/src/ui/utils/focusable.js +25 -0
  108. package/src/ui/utils/modal_host.js +27 -0
  109. package/widgets.js +12 -2
  110. package/css/base.css +0 -10547
  111. package/css/overrides.css +0 -2596
  112. package/dist/chunk-7JI7HHSY.js +0 -107
  113. package/dist/chunk-7JI7HHSY.js.map +0 -7
  114. package/dist/chunk-MIW7D6WC.js +0 -526
  115. package/dist/chunk-MIW7D6WC.js.map +0 -7
  116. package/dist/chunk-PGZ4EHSE.js.map +0 -7
  117. package/dist/chunk-XJNADRGR.js.map +0 -7
  118. /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
@@ -0,0 +1,766 @@
1
+ /**
2
+ * The filter editor: `createFilterBuilder`, mountable anywhere, and
3
+ * `openFilterDialog`, the same builder in a modal with a name, an icon, a
4
+ * consumer's live preview and an async submit.
5
+ *
6
+ * It never decides what a filter MEANS. That is the bound model's
7
+ * (`filter_model.js`); this file only edits the expression and asks the model.
8
+ *
9
+ * Every row closes over the node it edits, so nothing about the expression is
10
+ * kept in the DOM. A STRUCTURAL change (add, remove, field, operator, AND/OR, NOT)
11
+ * redraws the tree and puts the focus back; typing in a value control writes to
12
+ * the node and redraws nothing, so the caret survives.
13
+ *
14
+ * WHAT IT DOES NOT REWRITE. A condition on a field the catalogue does not know, an
15
+ * operator it does not know and a node it cannot read stay exactly as they are
16
+ * until someone changes or removes them: the selects show them as their own marked
17
+ * entry and the value is read-only.
18
+ */
19
+
20
+ import { cloneFilter, coerceFilterValue, emptyFilter, filterOptions } from './filter_model.js';
21
+ import { selectBox } from './choice_controls.js';
22
+ import { createTagInput } from './tag_input.js';
23
+ import { openConfirm, openModal } from './modal.js';
24
+
25
+ const PREVIEW_DEBOUNCE_MS = 120;
26
+ /** The icon grid's columns; `.twm-filter-dialog__icon-grid` draws the same number. */
27
+ const ICON_COLUMNS = 8;
28
+ const SUBMIT = 'submit';
29
+ const CLEAR = 'clear';
30
+
31
+ function el(tag, className, text) {
32
+ const node = document.createElement(tag);
33
+ if (className) node.className = className;
34
+ if (text !== undefined && text !== null) node.textContent = String(text);
35
+ return node;
36
+ }
37
+
38
+ function button(className, text, onClick, { title, label } = {}) {
39
+ const node = el('button', className, text);
40
+ node.type = 'button';
41
+ if (title) node.title = title;
42
+ if (label) node.setAttribute('aria-label', label);
43
+ node.addEventListener('click', onClick);
44
+ return node;
45
+ }
46
+
47
+ const SELECT_CLASS = 'twm-filter-builder__input twm-filter-builder__select';
48
+ const INPUT_CLASS = 'twm-filter-builder__input';
49
+ const SMALL_BUTTON = 'twm-btn twm-filter-builder__add';
50
+ const REMOVE_BUTTON = 'twm-btn-icon twm-filter-builder__remove';
51
+
52
+ /** The root is always a group: an expression may legitimately be a bare clause. */
53
+ function asRoot(expr) {
54
+ if (!expr || typeof expr !== 'object') return emptyFilter();
55
+ const copy = cloneFilter(expr);
56
+ return copy.kind === 'group' ? copy : { kind: 'group', op: 'AND', children: [copy] };
57
+ }
58
+
59
+ /**
60
+ * Mount a filter builder.
61
+ *
62
+ * @param {HTMLElement} host
63
+ * @param {object} options
64
+ * @param {object} options.model from `createFilterModel`
65
+ * @param {object} [options.value] the expression to edit (copied)
66
+ * @param {(expr: object) => void} [options.onChange] a copy, after each change
67
+ * @returns {{ el: HTMLElement, getValue(): object, setValue(expr: object): void,
68
+ * validate(): {ok: boolean, errors: string[]}, focus(): void, destroy(): void }}
69
+ */
70
+ export function createFilterBuilder(host, { model, value = null, onChange = null } = {}) {
71
+ if (!model) throw new Error('[FilterBuilder] needs a filter model');
72
+ const rootEl = el('div', 'twm-filter-builder');
73
+ let root = asRoot(value);
74
+ /** Node to its parent group, for removal; rebuilt by each redraw. */
75
+ let parents = new WeakMap();
76
+ /** Node to its focusable controls by role; rebuilt by each redraw. */
77
+ let controls = new WeakMap();
78
+ /** Each value cell's disposer: a redraw releases what the cells mounted. */
79
+ const cells = new Set();
80
+
81
+ const changed = () => onChange?.(cloneFilter(root));
82
+
83
+ const register = (node, role, control) => {
84
+ if (!controls.has(node)) controls.set(node, {});
85
+ controls.get(node)[role] = control;
86
+ };
87
+
88
+ function redraw(focusNode = null, focusRole = null) {
89
+ for (const cell of cells) cell.dispose();
90
+ cells.clear();
91
+ parents = new WeakMap();
92
+ controls = new WeakMap();
93
+ rootEl.replaceChildren(groupEl(root, true));
94
+ if (focusNode) controls.get(focusNode)?.[focusRole]?.focus();
95
+ }
96
+
97
+ /** A structural edit: the expression changed shape, so the tree is redrawn. */
98
+ function restructure(focusNode, focusRole) {
99
+ redraw(focusNode, focusRole);
100
+ changed();
101
+ }
102
+
103
+ /** Remove `node`, and any nested group that leaves empty. Returns the group that remains. */
104
+ function removeNode(node) {
105
+ let gone = node;
106
+ for (;;) {
107
+ const parent = parents.get(gone);
108
+ parent.children.splice(parent.children.indexOf(gone), 1);
109
+ if (parent.children.length || !parents.has(parent)) return parent;
110
+ gone = parent;
111
+ }
112
+ }
113
+
114
+ const removeButton = (node, label) => button(REMOVE_BUTTON, '×', () => {
115
+ restructure(removeNode(node), 'addClause');
116
+ }, { title: label, label });
117
+
118
+ function groupEl(group, isRoot) {
119
+ if (!Array.isArray(group.children)) group.children = [];
120
+ const box = el('div', `twm-filter-builder__group${isRoot ? ' twm-filter-builder__group--root' : ''}`);
121
+ const noun = model.noun;
122
+
123
+ const head = el('div', 'twm-filter-builder__group-head');
124
+ const seg = el('div', 'twm-filter-builder__seg');
125
+ seg.setAttribute('role', 'group');
126
+ seg.setAttribute('aria-label', 'Match mode');
127
+ for (const op of ['AND', 'OR']) {
128
+ const on = group.op === op;
129
+ const toggle = button(`twm-filter-builder__seg-btn${on ? ' twm-filter-builder__seg-btn--on' : ''}`, op, () => {
130
+ if (group.op === op) return;
131
+ group.op = op;
132
+ restructure(group, op);
133
+ });
134
+ toggle.setAttribute('aria-pressed', String(on));
135
+ register(group, op, toggle);
136
+ seg.appendChild(toggle);
137
+ }
138
+ const not = button(`twm-filter-builder__not${group.negate ? ' twm-filter-builder__not--on' : ''}`, 'NOT', () => {
139
+ if (group.negate) delete group.negate;
140
+ else group.negate = true;
141
+ restructure(group, 'not');
142
+ }, { title: 'Invert this group' });
143
+ not.setAttribute('aria-pressed', String(!!group.negate));
144
+ register(group, 'not', not);
145
+ head.append(seg, not, el('span', 'twm-filter-builder__hint',
146
+ group.op === 'OR' ? 'any condition matches' : 'all conditions match'));
147
+ if (!isRoot) head.appendChild(removeButton(group, 'Remove group'));
148
+ box.appendChild(head);
149
+
150
+ const children = el('div', 'twm-filter-builder__children');
151
+ for (const child of group.children) {
152
+ parents.set(child, group);
153
+ if (child?.kind === 'group') children.appendChild(groupEl(child, false));
154
+ else if (child?.kind === 'clause') children.appendChild(clauseEl(child));
155
+ else children.appendChild(carriedEl(child));
156
+ }
157
+ if (!group.children.length) {
158
+ const matches = model.emptyMatches(group) !== !!group.negate;
159
+ children.appendChild(el('div', 'twm-filter-builder__empty', matches
160
+ ? `No conditions: this group matches every ${noun}.`
161
+ : `No conditions: this group matches no ${noun}.`));
162
+ }
163
+ box.appendChild(children);
164
+
165
+ const foot = el('div', 'twm-filter-builder__group-foot');
166
+ const add = (factory, focusRole) => {
167
+ const node = factory();
168
+ if (!node) return;
169
+ group.children.push(node);
170
+ restructure(node, focusRole);
171
+ };
172
+ const offerable = !!model.newClause();
173
+ const addClause = button(SMALL_BUTTON, '+ Condition', () => add(() => model.newClause(), 'field'));
174
+ addClause.disabled = !offerable;
175
+ register(group, 'addClause', addClause);
176
+ // Seeded with one condition: an empty group matches everything, which
177
+ // reads as a mistake the moment it appears.
178
+ const addGroup = button(SMALL_BUTTON, '+ Group', () => add(
179
+ () => ({ kind: 'group', op: 'AND', children: [model.newClause()] }), 'addClause',
180
+ ));
181
+ addGroup.disabled = !offerable;
182
+ foot.append(addClause, addGroup);
183
+ box.appendChild(foot);
184
+ return box;
185
+ }
186
+
187
+ /** A node the model cannot read: shown as it is and kept as it is. */
188
+ function carriedEl(node) {
189
+ const row = el('div', 'twm-filter-builder__row twm-filter-builder__row--carried');
190
+ const text = el('span', 'twm-filter-builder__carried', model.describeFilter(node));
191
+ text.title = 'This editor cannot draw this condition. It is kept exactly as it is; remove it to drop it.';
192
+ row.append(text, removeButton(node, 'Remove condition'));
193
+ return row;
194
+ }
195
+
196
+ function fieldChoices(current) {
197
+ const list = [];
198
+ const groups = new Map();
199
+ for (const field of model.fields) {
200
+ if (field.hidden && field.key !== current) continue;
201
+ const option = { value: field.key, label: field.label };
202
+ if (!field.group) {
203
+ list.push(option);
204
+ } else if (groups.has(field.group)) {
205
+ groups.get(field.group).options.push(option);
206
+ } else {
207
+ const entry = { group: field.group, options: [option] };
208
+ groups.set(field.group, entry);
209
+ list.push(entry);
210
+ }
211
+ }
212
+ return list;
213
+ }
214
+
215
+ function operatorChoices(clause) {
216
+ const offered = model.operatorsFor(clause.field);
217
+ const held = model.operatorById(clause.op);
218
+ const list = held && model.fieldByKey(clause.field) && !offered.includes(held)
219
+ ? [...offered, held] : offered;
220
+ return list.map((op) => ({ value: op.id, label: op.label }));
221
+ }
222
+
223
+ function clauseEl(clause) {
224
+ const row = el('div', 'twm-filter-builder__row');
225
+
226
+ const fieldSelect = selectBox(fieldChoices(clause.field), clause.field, {
227
+ label: 'Field',
228
+ className: `${SELECT_CLASS} twm-filter-builder__select--field`,
229
+ notFound: () => `⚠ unknown field “${model.unknownName(String(clause.field ?? ''))}”`,
230
+ });
231
+ fieldSelect.box.addEventListener('change', () => {
232
+ const next = model.newClause(fieldSelect.valueOf());
233
+ for (const key of ['op', 'value']) delete clause[key];
234
+ Object.assign(clause, next);
235
+ restructure(clause, 'field');
236
+ });
237
+ register(clause, 'field', fieldSelect.box);
238
+
239
+ const opSelect = selectBox(operatorChoices(clause), clause.op, {
240
+ label: 'Operator',
241
+ className: `${SELECT_CLASS} twm-filter-builder__select--op`,
242
+ notFound: (id) => `${id} (unknown)`,
243
+ });
244
+ opSelect.box.addEventListener('change', () => {
245
+ clause.op = opSelect.valueOf();
246
+ const fresh = model.defaultValue(model.fieldByKey(clause.field), model.operatorById(clause.op));
247
+ if (fresh === undefined) delete clause.value;
248
+ else clause.value = fresh;
249
+ restructure(clause, 'op');
250
+ });
251
+ register(clause, 'op', opSelect.box);
252
+
253
+ const cellEl = el('div', 'twm-filter-builder__value');
254
+ const cell = { dispose() {} };
255
+ cells.add(cell);
256
+ const drawCell = (focus = false) => {
257
+ cell.dispose();
258
+ cell.dispose = () => {};
259
+ cellEl.replaceChildren();
260
+ drawValue(clause, cellEl, cell, drawCell);
261
+ if (focus) cellEl.querySelector('select, input')?.focus();
262
+ };
263
+ drawCell();
264
+
265
+ row.append(fieldSelect.box, opSelect.box, cellEl, removeButton(clause, 'Remove condition'));
266
+ return row;
267
+ }
268
+
269
+ function drawValue(clause, cellEl, cell, drawCell) {
270
+ const field = model.fieldByKey(clause.field);
271
+ const op = model.operatorById(clause.op);
272
+ if (!field || !op) {
273
+ if (clause.value !== undefined) {
274
+ cellEl.appendChild(el('span', 'twm-filter-builder__carried', JSON.stringify(clause.value)));
275
+ }
276
+ return;
277
+ }
278
+ if (!op.arity) return;
279
+
280
+ const write = (next) => {
281
+ clause.value = next;
282
+ changed();
283
+ };
284
+ const custom = field.editor?.({ field, op, value: clause.value, label: field.label, onChange: write });
285
+ if (custom) {
286
+ cellEl.appendChild(custom.el);
287
+ cell.dispose = () => custom.destroy?.();
288
+ return;
289
+ }
290
+
291
+ if (op.multi) {
292
+ if (filterOptions(field).length) drawChosenOptions(clause, field, cellEl, drawCell, write);
293
+ else drawTags(clause, field, cellEl, cell, write);
294
+ return;
295
+ }
296
+ if (op.arity === 2) {
297
+ const pair = Array.isArray(clause.value) ? [...clause.value] : ['', ''];
298
+ const box = (i) => scalarInput(field, op, pair[i], (v) => {
299
+ pair[i] = v;
300
+ write([...pair]);
301
+ });
302
+ cellEl.append(box(0), el('span', 'twm-filter-builder__and', 'and'), box(1));
303
+ return;
304
+ }
305
+ const options = field.type === 'enum' ? filterOptions(field) : [];
306
+ if (options.length) {
307
+ const choice = selectBox(options, clause.value, {
308
+ label: field.label,
309
+ className: `${SELECT_CLASS} twm-filter-builder__select--value`,
310
+ notFound: (v) => `${v} (unknown)`,
311
+ });
312
+ // A select cannot show "nothing": what it holds is the honest value.
313
+ if (clause.value === undefined || clause.value === null || clause.value === '') {
314
+ clause.value = choice.valueOf();
315
+ }
316
+ choice.box.addEventListener('change', () => write(choice.valueOf()));
317
+ cellEl.appendChild(choice.box);
318
+ return;
319
+ }
320
+ cellEl.appendChild(scalarInput(field, op, Array.isArray(clause.value) ? clause.value[0] : clause.value, write));
321
+ if (op.input === 'days') cellEl.appendChild(el('span', 'twm-filter-builder__and', 'days'));
322
+ }
323
+
324
+ function scalarInput(field, op, current, write) {
325
+ const input = el('input', INPUT_CLASS);
326
+ const numeric = field.type === 'number' || op.input === 'days';
327
+ if (numeric) {
328
+ input.type = 'number';
329
+ input.step = 'any';
330
+ } else if (field.type === 'date') {
331
+ input.type = field.time ? 'datetime-local' : 'date';
332
+ } else {
333
+ input.type = 'text';
334
+ input.placeholder = 'value…';
335
+ }
336
+ input.setAttribute('aria-label', field.label);
337
+ input.value = current ?? '';
338
+ const type = numeric ? { type: 'number' } : field;
339
+ input.addEventListener('input', () => write(coerceFilterValue(type, input.value)));
340
+ return input;
341
+ }
342
+
343
+ function asList(value) {
344
+ if (Array.isArray(value)) return value;
345
+ return value === '' || value === null || value === undefined ? [] : [value];
346
+ }
347
+
348
+ /** Free values, as tags. */
349
+ function drawTags(clause, field, cellEl, cell, write) {
350
+ const tags = createTagInput(cellEl, {
351
+ value: asList(clause.value).map(String),
352
+ placeholder: 'value…',
353
+ label: field.label,
354
+ className: 'twm-filter-builder__tags',
355
+ onChange: (list) => write(list.map((v) => coerceFilterValue(field, v))),
356
+ });
357
+ cell.dispose = () => tags.dispose();
358
+ }
359
+
360
+ /** Values from a fixed list: the chosen as chips, and a select of what is left. */
361
+ function drawChosenOptions(clause, field, cellEl, drawCell, write) {
362
+ const options = filterOptions(field);
363
+ const chosen = asList(clause.value);
364
+ const labelOf = (v) => options.find((o) => o.value === v)?.label ?? String(v);
365
+ const box = el('div', 'twm-filter-builder__chosen');
366
+ chosen.forEach((v, i) => {
367
+ const chip = el('span', 'twm-filter-builder__chip', labelOf(v));
368
+ chip.appendChild(button('twm-filter-builder__chip-remove', '×', () => {
369
+ write(chosen.filter((_, at) => at !== i));
370
+ drawCell(true);
371
+ }, { label: `Remove ${labelOf(v)}` }));
372
+ box.appendChild(chip);
373
+ });
374
+ const rest = options.filter((o) => !chosen.includes(o.value));
375
+ if (rest.length) {
376
+ const adder = selectBox(rest, undefined, {
377
+ empty: '+ value',
378
+ keepMissing: false,
379
+ label: `Add a value to ${field.label}`,
380
+ className: `${SELECT_CLASS} twm-filter-builder__select--add`,
381
+ });
382
+ adder.box.addEventListener('change', () => {
383
+ const next = adder.valueOf();
384
+ if (next === undefined) return;
385
+ write([...chosen, next]);
386
+ drawCell(true);
387
+ });
388
+ box.appendChild(adder.box);
389
+ }
390
+ cellEl.appendChild(box);
391
+ }
392
+
393
+ redraw();
394
+ host.appendChild(rootEl);
395
+
396
+ return {
397
+ el: rootEl,
398
+ getValue: () => cloneFilter(root),
399
+ /** Replaces the expression. Does not call `onChange`. */
400
+ setValue(expr) {
401
+ root = asRoot(expr);
402
+ redraw();
403
+ },
404
+ validate: () => model.validateFilter(root),
405
+ focus() { rootEl.querySelector('select, input, button')?.focus(); },
406
+ destroy() {
407
+ for (const cell of cells) cell.dispose();
408
+ cells.clear();
409
+ rootEl.remove();
410
+ },
411
+ };
412
+ }
413
+
414
+ /** The dialog's icon choice: a button over a searchable grid. */
415
+ function createIconPicker({ value, options, onChange }) {
416
+ let current = value;
417
+ const root = el('div', 'twm-filter-dialog__icon');
418
+ const glyph = (name) => el('span', 'material-symbols-outlined', name);
419
+ const trigger = button('twm-btn twm-filter-dialog__icon-trigger', null, () => (pop.hidden ? open() : close()), {
420
+ title: 'Icon', label: 'Icon',
421
+ });
422
+ trigger.setAttribute('aria-haspopup', 'listbox');
423
+ trigger.setAttribute('aria-expanded', 'false');
424
+ const face = glyph(current);
425
+ trigger.appendChild(face);
426
+
427
+ const pop = el('div', 'twm-filter-dialog__icon-pop');
428
+ pop.hidden = true;
429
+ const search = el('input', `${INPUT_CLASS} twm-filter-dialog__icon-search`);
430
+ search.type = 'text';
431
+ search.placeholder = 'Search icons…';
432
+ search.setAttribute('aria-label', 'Search icons');
433
+ const grid = el('div', 'twm-filter-dialog__icon-grid');
434
+ grid.setAttribute('role', 'listbox');
435
+ grid.setAttribute('aria-label', 'Icon');
436
+ const none = el('div', 'twm-filter-dialog__icon-none', 'No icon matches.');
437
+ pop.append(search, grid, none);
438
+ root.append(trigger, pop);
439
+
440
+ const pick = (name) => {
441
+ current = name;
442
+ face.textContent = name;
443
+ close();
444
+ onChange(name);
445
+ };
446
+ const drawGrid = () => {
447
+ const query = search.value.trim().toLowerCase();
448
+ const list = query ? options.filter((n) => n.toLowerCase().includes(query)) : options;
449
+ none.hidden = list.length > 0;
450
+ grid.replaceChildren(...list.map((name) => {
451
+ const on = name === current;
452
+ const option = button(`twm-filter-dialog__icon-option${on ? ' twm-filter-dialog__icon-option--on' : ''}`,
453
+ null, () => pick(name), { title: name });
454
+ option.setAttribute('role', 'option');
455
+ option.setAttribute('aria-selected', String(on));
456
+ option.appendChild(glyph(name));
457
+ return option;
458
+ }));
459
+ };
460
+ function open() {
461
+ search.value = '';
462
+ drawGrid();
463
+ pop.hidden = false;
464
+ trigger.setAttribute('aria-expanded', 'true');
465
+ search.focus();
466
+ }
467
+ function close(refocus = true) {
468
+ if (pop.hidden) return;
469
+ pop.hidden = true;
470
+ trigger.setAttribute('aria-expanded', 'false');
471
+ if (refocus) trigger.focus();
472
+ }
473
+
474
+ search.addEventListener('input', drawGrid);
475
+ search.addEventListener('keydown', (e) => {
476
+ if (e.key === 'ArrowDown') {
477
+ e.preventDefault();
478
+ grid.firstElementChild?.focus();
479
+ } else if (e.key === 'Enter') {
480
+ e.preventDefault();
481
+ grid.firstElementChild?.click();
482
+ }
483
+ });
484
+ grid.addEventListener('keydown', (e) => {
485
+ const items = [...grid.children];
486
+ const at = items.indexOf(e.target.closest('button'));
487
+ if (at < 0) return;
488
+ const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: ICON_COLUMNS, ArrowUp: -ICON_COLUMNS }[e.key];
489
+ if (step === undefined) return;
490
+ e.preventDefault();
491
+ if (at + step < 0 && e.key === 'ArrowUp') search.focus();
492
+ else items[at + step]?.focus();
493
+ });
494
+ // The window answers an Esc nobody else has: this one closes only the grid.
495
+ pop.addEventListener('keydown', (e) => {
496
+ if (e.key !== 'Escape') return;
497
+ e.preventDefault();
498
+ close();
499
+ });
500
+
501
+ return { el: root, close, value: () => current };
502
+ }
503
+
504
+ /**
505
+ * Open the builder in a modal.
506
+ *
507
+ * @param {object} options
508
+ * @param {object} options.model from `createFilterModel`
509
+ * @param {object} [options.value] the expression to edit (copied; Cancel is lossless)
510
+ * @param {string} [options.seedField] a field key: a new empty condition on it is
511
+ * added, so "filter by this" lands on a form about that field
512
+ * @param {string} [options.title]
513
+ * @param {string} [options.icon] the window's icon
514
+ * @param {HTMLElement} [options.head] drawn above the conditions
515
+ * @param {{value?: string, label?: string, placeholder?: string, required?: boolean}} [options.name]
516
+ * adds a name box; the result carries `name`
517
+ * @param {{value?: string, options: string[]}} [options.iconChoice]
518
+ * adds an icon picker over those Material Symbols names; the result carries `icon`
519
+ * @param {(host: HTMLElement, state: {expr: object, sentence: string, signal: AbortSignal}) => void} [options.preview]
520
+ * the live preview: draws into `host` on open and, debounced, after each change
521
+ * while the expression can be submitted. `signal` aborts when this drawing is
522
+ * superseded or the dialog closes, for a preview that asks a server.
523
+ * @param {boolean} [options.requireValid] submit only a valid expression (default
524
+ * true); its errors are listed. False lets one with an unknown field through.
525
+ * @param {boolean} [options.prune] drop half-built rows from what is validated,
526
+ * previewed and submitted (`model.pruneFilter`); `expr` is null when nothing is left
527
+ * @param {boolean} [options.confirmDiscard] ask before closing with unsaved changes
528
+ * @param {string} [options.submitLabel]
529
+ * @param {(result: object) => any} [options.onSubmit] may be async: until it settles
530
+ * every button is off and Esc and the X do nothing, so the answer is what happened.
531
+ * If it throws, the dialog shows the error and stays.
532
+ * @param {string} [options.clearLabel] adds a button answering `{ action: 'clear', expr: null }`
533
+ * @param {Array<{label: string, value: string, danger?: boolean, icon?: string,
534
+ * confirm?: object, run?: (result: object) => any}>} [options.actions]
535
+ * more buttons, before Cancel. `confirm` is `openConfirm`'s options, asked first;
536
+ * `run` is like `onSubmit`.
537
+ * @param {number} [options.width]
538
+ * @param {number} [options.height]
539
+ * @returns {Promise<{action: string, expr: object|null, name?: string, icon?: string}|null>}
540
+ * null when dismissed
541
+ */
542
+ export async function openFilterDialog({
543
+ model, value = null, seedField = null, title = 'Filter', icon = 'filter_alt', head = null,
544
+ name = null, iconChoice = null, preview = null,
545
+ requireValid = true, prune = false, confirmDiscard = false,
546
+ submitLabel = 'Apply', onSubmit = null, clearLabel = null, actions = [],
547
+ width = preview ? 920 : 760, height = 560,
548
+ } = {}) {
549
+ if (!model) throw new Error('[FilterDialog] needs a filter model');
550
+
551
+ const start = asRoot(value);
552
+ if (seedField) {
553
+ const seeded = model.newClause(seedField);
554
+ if (seeded) start.children.push(seeded);
555
+ }
556
+
557
+ const content = el('div', 'twm-filter-dialog');
558
+ if (head) content.appendChild(head);
559
+
560
+ let picker = null;
561
+ let nameInput = null;
562
+ if (name || iconChoice) {
563
+ const row = el('div', 'twm-filter-dialog__identity');
564
+ if (name) {
565
+ const label = el('label', 'twm-filter-dialog__name');
566
+ label.appendChild(el('span', 'twm-filter-dialog__name-label', name.label ?? 'Name'));
567
+ nameInput = el('input', INPUT_CLASS);
568
+ nameInput.type = 'text';
569
+ nameInput.autocomplete = 'off';
570
+ nameInput.value = name.value ?? '';
571
+ if (name.placeholder) nameInput.placeholder = name.placeholder;
572
+ nameInput.addEventListener('input', refresh);
573
+ label.appendChild(nameInput);
574
+ row.appendChild(label);
575
+ }
576
+ if (iconChoice) {
577
+ picker = createIconPicker({
578
+ value: iconChoice.value ?? iconChoice.options[0],
579
+ options: iconChoice.options,
580
+ onChange: refresh,
581
+ });
582
+ row.appendChild(picker.el);
583
+ content.addEventListener('mousedown', (e) => {
584
+ if (!picker.el.contains(e.target)) picker.close(false);
585
+ });
586
+ }
587
+ content.appendChild(row);
588
+ }
589
+
590
+ const columns = el('div', 'twm-filter-dialog__columns');
591
+ const buildPane = el('section', 'twm-filter-dialog__pane twm-filter-dialog__pane--build');
592
+ columns.appendChild(buildPane);
593
+ content.appendChild(columns);
594
+
595
+ const errorsEl = el('ul', 'twm-filter-dialog__errors');
596
+ errorsEl.hidden = true;
597
+ const failureEl = el('div', 'twm-filter-dialog__failure');
598
+ failureEl.setAttribute('role', 'alert');
599
+ failureEl.hidden = true;
600
+
601
+ let sentenceEl = null;
602
+ let slot = null;
603
+ if (preview) {
604
+ const pane = el('section', 'twm-filter-dialog__pane twm-filter-dialog__pane--preview');
605
+ sentenceEl = el('div', 'twm-filter-dialog__sentence');
606
+ slot = el('div', 'twm-filter-dialog__preview');
607
+ pane.append(sentenceEl, errorsEl, slot);
608
+ columns.appendChild(pane);
609
+ }
610
+
611
+ let previewTimer = 0;
612
+ let previewRun = null;
613
+ let controls = null;
614
+ let busy = false;
615
+
616
+ const builder = createFilterBuilder(buildPane, {
617
+ model,
618
+ value: start,
619
+ onChange: () => {
620
+ refresh();
621
+ clearTimeout(previewTimer);
622
+ previewTimer = setTimeout(drawPreview, PREVIEW_DEBOUNCE_MS);
623
+ },
624
+ });
625
+ if (!preview) buildPane.appendChild(errorsEl);
626
+ content.appendChild(failureEl);
627
+
628
+ /** The expression as it would be submitted. */
629
+ function current() {
630
+ const expr = builder.getValue();
631
+ return prune ? model.pruneFilter(expr) : expr;
632
+ }
633
+ const checkOf = (expr) => (expr ? model.validateFilter(expr) : { ok: true, errors: [] });
634
+
635
+ const nameMissing = () => !!nameInput && name.required !== false && !nameInput.value.trim();
636
+ const canSubmit = () => !busy && !nameMissing() && (!requireValid || checkOf(current()).ok);
637
+
638
+ function refresh() {
639
+ if (!controls) return;
640
+ for (const { value } of modalActions) {
641
+ controls.setActionEnabled(value, value === SUBMIT ? canSubmit() : !busy);
642
+ }
643
+ }
644
+
645
+ function showErrors(errors) {
646
+ errorsEl.replaceChildren(...errors.map((message) => el('li', '', message)));
647
+ errorsEl.hidden = errors.length === 0;
648
+ }
649
+ function showFailure(message) {
650
+ failureEl.textContent = message;
651
+ failureEl.hidden = !message;
652
+ }
653
+
654
+ function drawPreview() {
655
+ const expr = current();
656
+ const { ok, errors } = checkOf(expr);
657
+ previewRun?.abort();
658
+ previewRun = null;
659
+ const blocked = requireValid && !ok;
660
+ showErrors(blocked ? errors : []);
661
+ if (!preview) return;
662
+ slot.hidden = blocked;
663
+ if (blocked) {
664
+ sentenceEl.textContent = '';
665
+ slot.replaceChildren();
666
+ return;
667
+ }
668
+ const shown = expr ?? emptyFilter();
669
+ const sentence = model.describeFilter(shown);
670
+ sentenceEl.textContent = sentence;
671
+ previewRun = new AbortController();
672
+ try {
673
+ preview(slot, { expr: shown, sentence, signal: previewRun.signal });
674
+ } catch (err) {
675
+ showFailure(`Preview failed: ${err?.message ?? err}`);
676
+ }
677
+ }
678
+
679
+ const snapshot = () => JSON.stringify([builder.getValue(), nameInput?.value ?? null, picker?.value() ?? null]);
680
+ const initial = snapshot();
681
+
682
+ /**
683
+ * May the dialog close without an answer? Not while an action is in flight:
684
+ * the caller would hear "dismissed" about a save that then completes, or
685
+ * never see the error of one that fails. Otherwise, not without asking if
686
+ * that loses what was typed.
687
+ */
688
+ function mayDiscard() {
689
+ if (busy) return false;
690
+ if (!confirmDiscard || snapshot() === initial) return true;
691
+ return openConfirm({
692
+ title: 'Discard changes?',
693
+ message: 'This filter has unsaved changes. Closing now throws them away.',
694
+ confirmLabel: 'Discard',
695
+ cancelLabel: 'Keep editing',
696
+ danger: true,
697
+ });
698
+ }
699
+
700
+ async function runAction(action, close) {
701
+ if (busy || (action.value === SUBMIT && !canSubmit())) return;
702
+ showFailure('');
703
+ busy = true;
704
+ try {
705
+ // Its question stacks over this window, so the buttons stay as they
706
+ // are until it is answered and focus has a button to come back to.
707
+ if (action.confirm && !await openConfirm(action.confirm)) return;
708
+ refresh();
709
+ const result = { action: action.value, expr: action.value === CLEAR ? null : current() };
710
+ if (nameInput) result.name = nameInput.value.trim();
711
+ if (picker) result.icon = picker.value();
712
+ await action.run?.(result);
713
+ close(result);
714
+ } catch (err) {
715
+ showFailure(err?.message ?? String(err));
716
+ } finally {
717
+ busy = false;
718
+ refresh();
719
+ }
720
+ }
721
+ const asModalAction = (action) => ({
722
+ ...action,
723
+ keepOpen: true,
724
+ onClick: (close) => runAction(action, close),
725
+ });
726
+
727
+ const modalActions = [
728
+ ...actions.map(asModalAction),
729
+ {
730
+ label: 'Cancel',
731
+ value: 'cancel',
732
+ keepOpen: true,
733
+ onClick: async (close) => { if (await mayDiscard()) close(null); },
734
+ },
735
+ ];
736
+ if (clearLabel) modalActions.push(asModalAction({ label: clearLabel, value: CLEAR }));
737
+ modalActions.push({
738
+ ...asModalAction({ label: submitLabel, value: SUBMIT, run: onSubmit }),
739
+ primary: true,
740
+ disabled: !canSubmit(),
741
+ });
742
+
743
+ const closed = openModal({
744
+ title,
745
+ icon,
746
+ content,
747
+ actions: modalActions,
748
+ width,
749
+ height,
750
+ maximizable: true,
751
+ beforeClose: mayDiscard,
752
+ onMount: (_body, modalControls) => {
753
+ controls = modalControls;
754
+ refresh();
755
+ },
756
+ });
757
+ drawPreview();
758
+
759
+ try {
760
+ return await closed;
761
+ } finally {
762
+ clearTimeout(previewTimer);
763
+ previewRun?.abort();
764
+ builder.destroy();
765
+ }
766
+ }