flexdesk 0.17.18 → 0.18.1
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 +91 -0
- package/README.md +1 -0
- package/css/flexdesk.css +7547 -5891
- package/css/reset.css +1 -1
- package/css/tokens.css +30 -1
- package/dist/charts.js +83 -13
- package/dist/charts.js.map +2 -2
- package/dist/chunk-3BLIVOFD.js +778 -0
- package/dist/chunk-3BLIVOFD.js.map +7 -0
- package/dist/{chunk-7OL6F2AM.js → chunk-4X4SY3CP.js} +7 -5
- package/dist/chunk-4X4SY3CP.js.map +7 -0
- package/dist/chunk-DNJ3N4T4.js +82 -0
- package/dist/chunk-DNJ3N4T4.js.map +7 -0
- package/dist/{chunk-PGZ4EHSE.js → chunk-HL3IIHU3.js} +501 -359
- package/dist/chunk-HL3IIHU3.js.map +7 -0
- package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
- package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
- package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
- package/dist/{chunk-XJNADRGR.js → chunk-NFZJ6P42.js} +188 -96
- package/dist/chunk-NFZJ6P42.js.map +7 -0
- package/dist/chunk-PAI2ZUWV.js +260 -0
- package/dist/chunk-PAI2ZUWV.js.map +7 -0
- package/dist/chunk-Q7BALEPX.js +527 -0
- package/dist/chunk-Q7BALEPX.js.map +7 -0
- package/dist/{chunk-2Z2ENHR3.js → chunk-QRBOIUU3.js} +7 -8
- package/dist/chunk-QRBOIUU3.js.map +7 -0
- package/dist/chunk-QXAOTGCC.js +40 -0
- package/dist/chunk-QXAOTGCC.js.map +7 -0
- package/dist/chunk-UKP22RUG.js +219 -0
- package/dist/chunk-UKP22RUG.js.map +7 -0
- package/dist/chunk-VLOAI3HS.js +43 -0
- package/dist/chunk-VLOAI3HS.js.map +7 -0
- package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
- package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
- package/dist/editor.js +3 -2
- package/dist/editor.js.map +1 -1
- package/dist/flexdesk.css +7547 -5891
- package/dist/flow.js +29 -97
- package/dist/flow.js.map +3 -3
- package/dist/git.js +1356 -0
- package/dist/git.js.map +7 -0
- package/dist/reset.css +1 -1
- package/dist/tiles.js +20 -15
- package/dist/tiles.js.map +2 -2
- package/dist/tokens.css +30 -1
- package/dist/widgets.js +4146 -1199
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +120 -32
- package/dist/wm.js.map +2 -2
- package/docs/README.md +9 -4
- package/docs/best-practices.md +2 -2
- package/docs/components/charts.md +40 -0
- package/docs/components/chat-shell.md +204 -0
- package/docs/components/conversation.md +10 -0
- package/docs/components/filter-builder.md +292 -0
- package/docs/components/forms-and-dialogs.md +30 -0
- package/docs/components/git.md +355 -0
- package/docs/components/input-fields.md +191 -2
- package/docs/components/keymap.md +8 -0
- package/docs/components/menus.md +86 -2
- package/docs/components/panels-and-desktops.md +16 -5
- package/docs/components/shell.md +108 -1
- package/docs/components/workspace-picker.md +193 -0
- package/docs/concepts/building-and-packaging.md +2 -2
- package/docs/concepts/packages-and-entry-points.md +14 -13
- package/docs/concepts/state-guard.md +3 -3
- package/docs/quickstart.md +1 -1
- package/git.js +13 -0
- package/package.json +6 -1
- package/src/charts/plot_config_editor.js +92 -6
- package/src/charts/plot_grid.js +6 -1
- package/src/flow/kit/dom.js +28 -119
- package/src/flow/kit/step_picker.js +1 -1
- package/src/flow/kit/value_picker.js +1 -1
- package/src/flow/outline/menu.js +1 -1
- package/src/git/diff_viewer.js +212 -0
- package/src/git/diff_window.js +51 -0
- package/src/git/git_graph.js +401 -0
- package/src/git/git_ops.js +61 -0
- package/src/git/git_panel.js +755 -0
- package/src/git/index.js +9 -0
- package/src/git/line_diff.js +202 -0
- package/src/tiles/tile_base.js +5 -4
- package/src/tiling/desktops.js +10 -8
- package/src/tiling/keymap.js +6 -5
- package/src/tiling/kind_taxonomy.js +15 -2
- package/src/tiling/shell.js +124 -17
- package/src/tiling/tile_renderer.js +2 -3
- package/src/ui/components/action_dropdown.js +4 -78
- package/src/ui/components/anchored_layer.js +255 -0
- package/src/ui/components/chat_shell.js +926 -0
- package/src/ui/components/chat_state_machine.js +223 -0
- package/src/ui/components/choice_controls.js +45 -2
- package/src/ui/components/color_field.js +3 -2
- package/src/ui/components/color_list_field.js +3 -2
- package/src/ui/components/combobox.js +422 -0
- package/src/ui/components/context_menu.js +8 -2
- package/src/ui/components/conversation_view.js +17 -2
- package/src/ui/components/data_table.js +2 -2
- package/src/ui/components/filter_builder.js +791 -0
- package/src/ui/components/filter_model.js +682 -0
- package/src/ui/components/inline_renamer.js +10 -5
- package/src/ui/components/managed_window.js +24 -47
- package/src/ui/components/modal.js +104 -117
- package/src/ui/components/option_list.js +160 -0
- package/src/ui/components/popover.js +94 -0
- package/src/ui/components/recent_items.js +169 -0
- package/src/ui/components/select.js +322 -0
- package/src/ui/components/tag_input.js +77 -8
- package/src/ui/components/workspace_picker.js +1253 -0
- package/src/ui/utils/fire_event.js +17 -0
- package/src/ui/utils/focusable.js +25 -0
- package/src/ui/utils/modal_host.js +27 -0
- package/src/ui/utils/tooltip_service.js +81 -14
- package/widgets.js +12 -2
- package/css/base.css +0 -10547
- package/css/overrides.css +0 -2596
- package/dist/chunk-2Z2ENHR3.js.map +0 -7
- package/dist/chunk-7JI7HHSY.js +0 -107
- package/dist/chunk-7JI7HHSY.js.map +0 -7
- package/dist/chunk-7OL6F2AM.js.map +0 -7
- package/dist/chunk-MIW7D6WC.js +0 -526
- package/dist/chunk-MIW7D6WC.js.map +0 -7
- package/dist/chunk-PGZ4EHSE.js.map +0 -7
- package/dist/chunk-XJNADRGR.js.map +0 -7
- /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
|
@@ -0,0 +1,791 @@
|
|
|
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 or its field does not offer, and a node it cannot read
|
|
16
|
+
* stay exactly as they are until someone changes or removes them: the selects show
|
|
17
|
+
* them as their own 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 && !offered.includes(held) ? [...offered, held] : offered;
|
|
219
|
+
return list.map((op) => ({ value: op.id, label: op.label }));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function clauseEl(clause) {
|
|
223
|
+
const row = el('div', 'twm-filter-builder__row');
|
|
224
|
+
const known = !!model.fieldByKey(clause.field);
|
|
225
|
+
const unknownWords = `⚠ unknown field “${model.unknownName(String(clause.field ?? ''))}”`;
|
|
226
|
+
|
|
227
|
+
const fieldSelect = selectBox(fieldChoices(clause.field), clause.field, {
|
|
228
|
+
label: 'Field',
|
|
229
|
+
className: `${SELECT_CLASS} twm-filter-builder__select--field${known ? '' : ' twm-filter-builder__select--unknown'}`,
|
|
230
|
+
notFound: () => unknownWords,
|
|
231
|
+
});
|
|
232
|
+
// Its width follows the words, and when the row cannot spare that the
|
|
233
|
+
// tooltip still says the whole name.
|
|
234
|
+
if (!known) fieldSelect.box.title = unknownWords;
|
|
235
|
+
fieldSelect.box.addEventListener('change', () => {
|
|
236
|
+
const next = model.moveClause(clause, fieldSelect.valueOf());
|
|
237
|
+
for (const key of ['op', 'value']) delete clause[key];
|
|
238
|
+
Object.assign(clause, next);
|
|
239
|
+
restructure(clause, 'field');
|
|
240
|
+
});
|
|
241
|
+
register(clause, 'field', fieldSelect.box);
|
|
242
|
+
|
|
243
|
+
const opSelect = selectBox(operatorChoices(clause), clause.op, {
|
|
244
|
+
label: 'Operator',
|
|
245
|
+
className: `${SELECT_CLASS} twm-filter-builder__select--op`,
|
|
246
|
+
notFound: (id) => `${id} (unknown)`,
|
|
247
|
+
});
|
|
248
|
+
opSelect.box.addEventListener('change', () => {
|
|
249
|
+
clause.op = opSelect.valueOf();
|
|
250
|
+
const fresh = model.defaultValue(model.fieldByKey(clause.field), model.operatorById(clause.op));
|
|
251
|
+
if (fresh === undefined) delete clause.value;
|
|
252
|
+
else clause.value = fresh;
|
|
253
|
+
restructure(clause, 'op');
|
|
254
|
+
});
|
|
255
|
+
register(clause, 'op', opSelect.box);
|
|
256
|
+
|
|
257
|
+
const cellEl = el('div', 'twm-filter-builder__value');
|
|
258
|
+
const cell = { dispose() {} };
|
|
259
|
+
cells.add(cell);
|
|
260
|
+
const drawCell = (focus = false) => {
|
|
261
|
+
cell.dispose();
|
|
262
|
+
cell.dispose = () => {};
|
|
263
|
+
cellEl.replaceChildren();
|
|
264
|
+
drawValue(clause, cellEl, cell, drawCell);
|
|
265
|
+
if (focus) cellEl.querySelector('select, input')?.focus();
|
|
266
|
+
};
|
|
267
|
+
drawCell();
|
|
268
|
+
|
|
269
|
+
row.append(fieldSelect.box, opSelect.box, cellEl, removeButton(clause, 'Remove condition'));
|
|
270
|
+
return row;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function drawValue(clause, cellEl, cell, drawCell) {
|
|
274
|
+
const field = model.fieldByKey(clause.field);
|
|
275
|
+
const op = model.operatorById(clause.op);
|
|
276
|
+
// A box drawn for an operator it was not made for would show nothing, or
|
|
277
|
+
// a field's own type, over a value that is still what Apply returns.
|
|
278
|
+
if (!field || !op || (op.arity && !model.operatorsFor(clause.field).includes(op))) {
|
|
279
|
+
if (clause.value !== undefined) {
|
|
280
|
+
cellEl.appendChild(el('span', 'twm-filter-builder__carried', JSON.stringify(clause.value)));
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (!op.arity) return;
|
|
285
|
+
|
|
286
|
+
const write = (next) => {
|
|
287
|
+
clause.value = next;
|
|
288
|
+
changed();
|
|
289
|
+
};
|
|
290
|
+
const custom = field.editor?.({ field, op, value: clause.value, label: field.label, onChange: write });
|
|
291
|
+
if (custom) {
|
|
292
|
+
cellEl.appendChild(custom.el);
|
|
293
|
+
cell.dispose = () => custom.destroy?.();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (op.multi) {
|
|
298
|
+
if (filterOptions(field).length) drawChosenOptions(clause, field, cellEl, drawCell, write);
|
|
299
|
+
else drawTags(clause, field, cellEl, cell, write);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (op.arity === 2) {
|
|
303
|
+
const pair = Array.isArray(clause.value) ? [...clause.value] : ['', ''];
|
|
304
|
+
const box = (i) => scalarInput(field, op, pair[i], (v) => {
|
|
305
|
+
pair[i] = v;
|
|
306
|
+
write([...pair]);
|
|
307
|
+
});
|
|
308
|
+
cellEl.append(box(0), el('span', 'twm-filter-builder__and', 'and'), box(1));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const options = field.type === 'enum' ? filterOptions(field) : [];
|
|
312
|
+
if (options.length) {
|
|
313
|
+
const choice = selectBox(options, clause.value, {
|
|
314
|
+
label: field.label,
|
|
315
|
+
className: `${SELECT_CLASS} twm-filter-builder__select--value`,
|
|
316
|
+
notFound: (v) => `${v} (unknown)`,
|
|
317
|
+
});
|
|
318
|
+
// A select cannot show "nothing": what it holds is the honest value.
|
|
319
|
+
if (clause.value === undefined || clause.value === null || clause.value === '') {
|
|
320
|
+
clause.value = choice.valueOf();
|
|
321
|
+
}
|
|
322
|
+
choice.box.addEventListener('change', () => write(choice.valueOf()));
|
|
323
|
+
cellEl.appendChild(choice.box);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
cellEl.appendChild(scalarInput(field, op, Array.isArray(clause.value) ? clause.value[0] : clause.value, write));
|
|
327
|
+
if (op.input === 'days') cellEl.appendChild(el('span', 'twm-filter-builder__and', 'days'));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function scalarInput(field, op, current, write) {
|
|
331
|
+
const input = el('input', INPUT_CLASS);
|
|
332
|
+
const numeric = field.type === 'number' || op.input === 'days';
|
|
333
|
+
if (numeric) {
|
|
334
|
+
input.type = 'number';
|
|
335
|
+
input.step = 'any';
|
|
336
|
+
if (op.input === 'days') input.min = '0';
|
|
337
|
+
} else if (field.type === 'date') {
|
|
338
|
+
input.type = field.time ? 'datetime-local' : 'date';
|
|
339
|
+
} else {
|
|
340
|
+
input.type = 'text';
|
|
341
|
+
input.placeholder = 'value…';
|
|
342
|
+
}
|
|
343
|
+
input.setAttribute('aria-label', field.label);
|
|
344
|
+
input.value = current ?? '';
|
|
345
|
+
const type = numeric ? { type: 'number' } : field;
|
|
346
|
+
input.addEventListener('input', () => write(coerceFilterValue(type, input.value)));
|
|
347
|
+
return input;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function asList(value) {
|
|
351
|
+
if (Array.isArray(value)) return value;
|
|
352
|
+
return value === '' || value === null || value === undefined ? [] : [value];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Free values, as tags. */
|
|
356
|
+
function drawTags(clause, field, cellEl, cell, write) {
|
|
357
|
+
const tags = createTagInput(cellEl, {
|
|
358
|
+
value: asList(clause.value).map(String),
|
|
359
|
+
placeholder: 'value…',
|
|
360
|
+
label: field.label,
|
|
361
|
+
className: 'twm-filter-builder__tags',
|
|
362
|
+
onChange: (list) => write(list.map((v) => coerceFilterValue(field, v))),
|
|
363
|
+
});
|
|
364
|
+
cell.dispose = () => tags.dispose();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Values from a fixed list: the chosen as chips, and a select of what is left. */
|
|
368
|
+
function drawChosenOptions(clause, field, cellEl, drawCell, write) {
|
|
369
|
+
const options = filterOptions(field);
|
|
370
|
+
const chosen = asList(clause.value);
|
|
371
|
+
const labelOf = (v) => options.find((o) => o.value === v)?.label ?? String(v);
|
|
372
|
+
const box = el('div', 'twm-filter-builder__chosen');
|
|
373
|
+
chosen.forEach((v, i) => {
|
|
374
|
+
const chip = el('span', 'twm-filter-builder__chip', labelOf(v));
|
|
375
|
+
chip.appendChild(button('twm-filter-builder__chip-remove', '×', () => {
|
|
376
|
+
write(chosen.filter((_, at) => at !== i));
|
|
377
|
+
drawCell(true);
|
|
378
|
+
}, { label: `Remove ${labelOf(v)}` }));
|
|
379
|
+
box.appendChild(chip);
|
|
380
|
+
});
|
|
381
|
+
const rest = options.filter((o) => !chosen.includes(o.value));
|
|
382
|
+
if (rest.length) {
|
|
383
|
+
const adder = selectBox(rest, undefined, {
|
|
384
|
+
empty: '+ value',
|
|
385
|
+
keepMissing: false,
|
|
386
|
+
label: `Add a value to ${field.label}`,
|
|
387
|
+
className: `${SELECT_CLASS} twm-filter-builder__select--add`,
|
|
388
|
+
});
|
|
389
|
+
adder.box.addEventListener('change', () => {
|
|
390
|
+
const next = adder.valueOf();
|
|
391
|
+
if (next === undefined) return;
|
|
392
|
+
write([...chosen, next]);
|
|
393
|
+
drawCell(true);
|
|
394
|
+
});
|
|
395
|
+
box.appendChild(adder.box);
|
|
396
|
+
}
|
|
397
|
+
cellEl.appendChild(box);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
redraw();
|
|
401
|
+
host.appendChild(rootEl);
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
el: rootEl,
|
|
405
|
+
getValue: () => cloneFilter(root),
|
|
406
|
+
/** Replaces the expression. Does not call `onChange`. */
|
|
407
|
+
setValue(expr) {
|
|
408
|
+
root = asRoot(expr);
|
|
409
|
+
redraw();
|
|
410
|
+
},
|
|
411
|
+
validate: () => model.validateFilter(root),
|
|
412
|
+
focus() { rootEl.querySelector('select, input, button')?.focus(); },
|
|
413
|
+
destroy() {
|
|
414
|
+
for (const cell of cells) cell.dispose();
|
|
415
|
+
cells.clear();
|
|
416
|
+
rootEl.remove();
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The dialog's icon choice: a button over a searchable grid. */
|
|
422
|
+
function createIconPicker({ value, options, onChange }) {
|
|
423
|
+
let current = value;
|
|
424
|
+
const root = el('div', 'twm-filter-dialog__icon');
|
|
425
|
+
const glyph = (name) => el('span', 'material-symbols-outlined', name);
|
|
426
|
+
const trigger = button('twm-btn twm-filter-dialog__icon-trigger', null, () => (pop.hidden ? open() : close()), {
|
|
427
|
+
title: 'Icon', label: 'Icon',
|
|
428
|
+
});
|
|
429
|
+
trigger.setAttribute('aria-haspopup', 'listbox');
|
|
430
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
431
|
+
const face = glyph(current);
|
|
432
|
+
trigger.appendChild(face);
|
|
433
|
+
|
|
434
|
+
const pop = el('div', 'twm-filter-dialog__icon-pop');
|
|
435
|
+
pop.hidden = true;
|
|
436
|
+
const search = el('input', `${INPUT_CLASS} twm-filter-dialog__icon-search`);
|
|
437
|
+
search.type = 'text';
|
|
438
|
+
search.placeholder = 'Search icons…';
|
|
439
|
+
search.setAttribute('aria-label', 'Search icons');
|
|
440
|
+
const grid = el('div', 'twm-filter-dialog__icon-grid');
|
|
441
|
+
grid.setAttribute('role', 'listbox');
|
|
442
|
+
grid.setAttribute('aria-label', 'Icon');
|
|
443
|
+
const none = el('div', 'twm-filter-dialog__icon-none', 'No icon matches.');
|
|
444
|
+
pop.append(search, grid, none);
|
|
445
|
+
root.append(trigger, pop);
|
|
446
|
+
|
|
447
|
+
const pick = (name) => {
|
|
448
|
+
current = name;
|
|
449
|
+
face.textContent = name;
|
|
450
|
+
close();
|
|
451
|
+
onChange(name);
|
|
452
|
+
};
|
|
453
|
+
const drawGrid = () => {
|
|
454
|
+
const query = search.value.trim().toLowerCase();
|
|
455
|
+
const list = query ? options.filter((n) => n.toLowerCase().includes(query)) : options;
|
|
456
|
+
none.hidden = list.length > 0;
|
|
457
|
+
grid.replaceChildren(...list.map((name) => {
|
|
458
|
+
const on = name === current;
|
|
459
|
+
const option = button(`twm-filter-dialog__icon-option${on ? ' twm-filter-dialog__icon-option--on' : ''}`,
|
|
460
|
+
null, () => pick(name), { title: name });
|
|
461
|
+
option.setAttribute('role', 'option');
|
|
462
|
+
option.setAttribute('aria-selected', String(on));
|
|
463
|
+
option.appendChild(glyph(name));
|
|
464
|
+
return option;
|
|
465
|
+
}));
|
|
466
|
+
};
|
|
467
|
+
function open() {
|
|
468
|
+
search.value = '';
|
|
469
|
+
drawGrid();
|
|
470
|
+
pop.hidden = false;
|
|
471
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
472
|
+
search.focus();
|
|
473
|
+
}
|
|
474
|
+
function close(refocus = true) {
|
|
475
|
+
if (pop.hidden) return;
|
|
476
|
+
pop.hidden = true;
|
|
477
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
478
|
+
if (refocus) trigger.focus();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
search.addEventListener('input', drawGrid);
|
|
482
|
+
search.addEventListener('keydown', (e) => {
|
|
483
|
+
if (e.key === 'ArrowDown') {
|
|
484
|
+
e.preventDefault();
|
|
485
|
+
grid.firstElementChild?.focus();
|
|
486
|
+
} else if (e.key === 'Enter') {
|
|
487
|
+
e.preventDefault();
|
|
488
|
+
grid.firstElementChild?.click();
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
grid.addEventListener('keydown', (e) => {
|
|
492
|
+
const items = [...grid.children];
|
|
493
|
+
const at = items.indexOf(e.target.closest('button'));
|
|
494
|
+
if (at < 0) return;
|
|
495
|
+
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: ICON_COLUMNS, ArrowUp: -ICON_COLUMNS }[e.key];
|
|
496
|
+
if (step === undefined) return;
|
|
497
|
+
e.preventDefault();
|
|
498
|
+
if (at + step < 0 && e.key === 'ArrowUp') search.focus();
|
|
499
|
+
else items[at + step]?.focus();
|
|
500
|
+
});
|
|
501
|
+
// The window answers an Esc nobody else has: this one closes only the grid.
|
|
502
|
+
pop.addEventListener('keydown', (e) => {
|
|
503
|
+
if (e.key !== 'Escape') return;
|
|
504
|
+
e.preventDefault();
|
|
505
|
+
close();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
return { el: root, close, value: () => current };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Open the builder in a modal.
|
|
513
|
+
*
|
|
514
|
+
* @param {object} options
|
|
515
|
+
* @param {object} options.model from `createFilterModel`
|
|
516
|
+
* @param {object} [options.value] the expression to edit (copied; Cancel is lossless)
|
|
517
|
+
* @param {string} [options.seedField] a field key: a new empty condition on it is
|
|
518
|
+
* added, so "filter by this" lands on a form about that field. Beside a negated
|
|
519
|
+
* root, not inside it: a new condition narrows what the filter already says.
|
|
520
|
+
* @param {string} [options.title]
|
|
521
|
+
* @param {string} [options.icon] the window's icon
|
|
522
|
+
* @param {HTMLElement} [options.head] drawn above the conditions
|
|
523
|
+
* @param {{value?: string, label?: string, placeholder?: string, required?: boolean}} [options.name]
|
|
524
|
+
* adds a name box, which opens with its text selected; the result carries `name`
|
|
525
|
+
* @param {{value?: string, options: string[]}} [options.iconChoice]
|
|
526
|
+
* adds an icon picker over those Material Symbols names; the result carries `icon`
|
|
527
|
+
* @param {(host: HTMLElement, state: {expr: object, sentence: string, signal: AbortSignal}) => void} [options.preview]
|
|
528
|
+
* the live preview: draws into `host` on open and, debounced, after each change
|
|
529
|
+
* while the expression can be submitted. `signal` aborts when this drawing is
|
|
530
|
+
* superseded or the dialog closes, for a preview that asks a server.
|
|
531
|
+
* @param {boolean} [options.requireValid] submit only a valid expression (default
|
|
532
|
+
* true); its errors are listed. False lets one with an unknown field through. A
|
|
533
|
+
* range with one end filled is refused either way (`model.partialRanges`).
|
|
534
|
+
* @param {boolean} [options.prune] drop half-built rows from what is validated,
|
|
535
|
+
* previewed and submitted (`model.pruneFilter`); `expr` is null when nothing is left
|
|
536
|
+
* @param {boolean} [options.confirmDiscard] ask before closing with unsaved changes
|
|
537
|
+
* @param {boolean} [options.dismissOnBackdrop] a click on the dimmed page beside the
|
|
538
|
+
* dialog dismisses it, as Esc does (`confirmDiscard` still asks)
|
|
539
|
+
* @param {string} [options.submitLabel]
|
|
540
|
+
* @param {(result: object) => any} [options.onSubmit] may be async: until it settles
|
|
541
|
+
* every button is off and Esc and the X do nothing, so the answer is what happened.
|
|
542
|
+
* If it throws, the dialog shows the error and stays.
|
|
543
|
+
* @param {string} [options.clearLabel] adds a button answering `{ action: 'clear', expr: null }`
|
|
544
|
+
* @param {Array<{label: string, value: string, danger?: boolean, icon?: string,
|
|
545
|
+
* confirm?: object, run?: (result: object) => any}>} [options.actions]
|
|
546
|
+
* more buttons, before Cancel. `confirm` is `openConfirm`'s options, asked first; its
|
|
547
|
+
* `message` may be `(result) => string`, computed when it is asked, so it can quote
|
|
548
|
+
* what was typed. `run` is like `onSubmit`.
|
|
549
|
+
* @param {number} [options.width]
|
|
550
|
+
* @param {number} [options.height]
|
|
551
|
+
* @returns {Promise<{action: string, expr: object|null, name?: string, icon?: string}|null>}
|
|
552
|
+
* null when dismissed
|
|
553
|
+
*/
|
|
554
|
+
export async function openFilterDialog({
|
|
555
|
+
model, value = null, seedField = null, title = 'Filter', icon = 'filter_alt', head = null,
|
|
556
|
+
name = null, iconChoice = null, preview = null,
|
|
557
|
+
requireValid = true, prune = false, confirmDiscard = false, dismissOnBackdrop = false,
|
|
558
|
+
submitLabel = 'Apply', onSubmit = null, clearLabel = null, actions = [],
|
|
559
|
+
width = preview ? 920 : 760, height = 560,
|
|
560
|
+
} = {}) {
|
|
561
|
+
if (!model) throw new Error('[FilterDialog] needs a filter model');
|
|
562
|
+
|
|
563
|
+
let start = asRoot(value);
|
|
564
|
+
const seeded = seedField ? model.newClause(seedField) : null;
|
|
565
|
+
if (seeded) {
|
|
566
|
+
if (start.negate) start = { kind: 'group', op: 'AND', children: [start, seeded] };
|
|
567
|
+
else start.children.push(seeded);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const content = el('div', 'twm-filter-dialog');
|
|
571
|
+
if (head) content.appendChild(head);
|
|
572
|
+
|
|
573
|
+
let picker = null;
|
|
574
|
+
let nameInput = null;
|
|
575
|
+
if (name || iconChoice) {
|
|
576
|
+
const row = el('div', 'twm-filter-dialog__identity');
|
|
577
|
+
if (name) {
|
|
578
|
+
const label = el('label', 'twm-filter-dialog__name');
|
|
579
|
+
label.appendChild(el('span', 'twm-filter-dialog__name-label', name.label ?? 'Name'));
|
|
580
|
+
nameInput = el('input', INPUT_CLASS);
|
|
581
|
+
nameInput.type = 'text';
|
|
582
|
+
nameInput.autocomplete = 'off';
|
|
583
|
+
nameInput.value = name.value ?? '';
|
|
584
|
+
if (name.placeholder) nameInput.placeholder = name.placeholder;
|
|
585
|
+
nameInput.addEventListener('input', refresh);
|
|
586
|
+
label.appendChild(nameInput);
|
|
587
|
+
row.appendChild(label);
|
|
588
|
+
}
|
|
589
|
+
if (iconChoice) {
|
|
590
|
+
picker = createIconPicker({
|
|
591
|
+
value: iconChoice.value ?? iconChoice.options[0],
|
|
592
|
+
options: iconChoice.options,
|
|
593
|
+
onChange: refresh,
|
|
594
|
+
});
|
|
595
|
+
row.appendChild(picker.el);
|
|
596
|
+
content.addEventListener('mousedown', (e) => {
|
|
597
|
+
if (!picker.el.contains(e.target)) picker.close(false);
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
content.appendChild(row);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const columns = el('div', 'twm-filter-dialog__columns');
|
|
604
|
+
const buildPane = el('section', 'twm-filter-dialog__pane twm-filter-dialog__pane--build');
|
|
605
|
+
columns.appendChild(buildPane);
|
|
606
|
+
content.appendChild(columns);
|
|
607
|
+
|
|
608
|
+
const errorsEl = el('ul', 'twm-filter-dialog__errors');
|
|
609
|
+
errorsEl.hidden = true;
|
|
610
|
+
const failureEl = el('div', 'twm-filter-dialog__failure');
|
|
611
|
+
failureEl.setAttribute('role', 'alert');
|
|
612
|
+
failureEl.hidden = true;
|
|
613
|
+
|
|
614
|
+
let sentenceEl = null;
|
|
615
|
+
let slot = null;
|
|
616
|
+
if (preview) {
|
|
617
|
+
const pane = el('section', 'twm-filter-dialog__pane twm-filter-dialog__pane--preview');
|
|
618
|
+
sentenceEl = el('div', 'twm-filter-dialog__sentence');
|
|
619
|
+
slot = el('div', 'twm-filter-dialog__preview');
|
|
620
|
+
pane.append(sentenceEl, errorsEl, slot);
|
|
621
|
+
columns.appendChild(pane);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
let previewTimer = 0;
|
|
625
|
+
let previewRun = null;
|
|
626
|
+
let controls = null;
|
|
627
|
+
let busy = false;
|
|
628
|
+
|
|
629
|
+
const builder = createFilterBuilder(buildPane, {
|
|
630
|
+
model,
|
|
631
|
+
value: start,
|
|
632
|
+
onChange: () => {
|
|
633
|
+
refresh();
|
|
634
|
+
clearTimeout(previewTimer);
|
|
635
|
+
previewTimer = setTimeout(drawPreview, PREVIEW_DEBOUNCE_MS);
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
if (!preview) buildPane.appendChild(errorsEl);
|
|
639
|
+
content.appendChild(failureEl);
|
|
640
|
+
|
|
641
|
+
/** The expression as it would be submitted. */
|
|
642
|
+
function current() {
|
|
643
|
+
const expr = builder.getValue();
|
|
644
|
+
return prune ? model.pruneFilter(expr) : expr;
|
|
645
|
+
}
|
|
646
|
+
/** Why `expr` may not be submitted. */
|
|
647
|
+
const refusals = (expr) => {
|
|
648
|
+
if (!expr) return [];
|
|
649
|
+
return requireValid ? model.validateFilter(expr).errors : model.partialRanges(expr);
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
const nameMissing = () => !!nameInput && name.required !== false && !nameInput.value.trim();
|
|
653
|
+
const canSubmit = () => !busy && !nameMissing() && !refusals(current()).length;
|
|
654
|
+
|
|
655
|
+
function refresh() {
|
|
656
|
+
if (!controls) return;
|
|
657
|
+
for (const { value } of modalActions) {
|
|
658
|
+
controls.setActionEnabled(value, value === SUBMIT ? canSubmit() : !busy);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function showErrors(errors) {
|
|
663
|
+
errorsEl.replaceChildren(...errors.map((message) => el('li', '', message)));
|
|
664
|
+
errorsEl.hidden = errors.length === 0;
|
|
665
|
+
}
|
|
666
|
+
function showFailure(message) {
|
|
667
|
+
failureEl.textContent = message;
|
|
668
|
+
failureEl.hidden = !message;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function drawPreview() {
|
|
672
|
+
const expr = current();
|
|
673
|
+
const errors = refusals(expr);
|
|
674
|
+
previewRun?.abort();
|
|
675
|
+
previewRun = null;
|
|
676
|
+
const blocked = errors.length > 0;
|
|
677
|
+
showErrors(errors);
|
|
678
|
+
if (!preview) return;
|
|
679
|
+
slot.hidden = blocked;
|
|
680
|
+
if (blocked) {
|
|
681
|
+
sentenceEl.textContent = '';
|
|
682
|
+
slot.replaceChildren();
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const shown = expr ?? emptyFilter();
|
|
686
|
+
const sentence = model.describeFilter(shown);
|
|
687
|
+
sentenceEl.textContent = sentence;
|
|
688
|
+
previewRun = new AbortController();
|
|
689
|
+
try {
|
|
690
|
+
preview(slot, { expr: shown, sentence, signal: previewRun.signal });
|
|
691
|
+
} catch (err) {
|
|
692
|
+
showFailure(`Preview failed: ${err?.message ?? err}`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const snapshot = () => JSON.stringify([builder.getValue(), nameInput?.value ?? null, picker?.value() ?? null]);
|
|
697
|
+
const initial = snapshot();
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* May the dialog close without an answer? Not while an action is in flight:
|
|
701
|
+
* the caller would hear "dismissed" about a save that then completes, or
|
|
702
|
+
* never see the error of one that fails. Otherwise, not without asking if
|
|
703
|
+
* that loses what was typed.
|
|
704
|
+
*/
|
|
705
|
+
function mayDiscard() {
|
|
706
|
+
if (busy) return false;
|
|
707
|
+
if (!confirmDiscard || snapshot() === initial) return true;
|
|
708
|
+
return openConfirm({
|
|
709
|
+
title: 'Discard changes?',
|
|
710
|
+
message: 'This filter has unsaved changes. Closing now throws them away.',
|
|
711
|
+
confirmLabel: 'Discard',
|
|
712
|
+
cancelLabel: 'Keep editing',
|
|
713
|
+
danger: true,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function runAction(action, close) {
|
|
718
|
+
if (busy || (action.value === SUBMIT && !canSubmit())) return;
|
|
719
|
+
showFailure('');
|
|
720
|
+
busy = true;
|
|
721
|
+
try {
|
|
722
|
+
const result = { action: action.value, expr: action.value === CLEAR ? null : current() };
|
|
723
|
+
if (nameInput) result.name = nameInput.value.trim();
|
|
724
|
+
if (picker) result.icon = picker.value();
|
|
725
|
+
// Its question stacks over this window, so the buttons stay as they
|
|
726
|
+
// are until it is answered and focus has a button to come back to.
|
|
727
|
+
if (action.confirm) {
|
|
728
|
+
const { message } = action.confirm;
|
|
729
|
+
const asked = typeof message === 'function' ? { ...action.confirm, message: message(result) } : action.confirm;
|
|
730
|
+
if (!await openConfirm(asked)) return;
|
|
731
|
+
}
|
|
732
|
+
refresh();
|
|
733
|
+
await action.run?.(result);
|
|
734
|
+
close(result);
|
|
735
|
+
} catch (err) {
|
|
736
|
+
showFailure(err?.message ?? String(err));
|
|
737
|
+
} finally {
|
|
738
|
+
busy = false;
|
|
739
|
+
refresh();
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
const asModalAction = (action) => ({
|
|
743
|
+
...action,
|
|
744
|
+
keepOpen: true,
|
|
745
|
+
onClick: (close) => runAction(action, close),
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
const modalActions = [
|
|
749
|
+
...actions.map(asModalAction),
|
|
750
|
+
{
|
|
751
|
+
label: 'Cancel',
|
|
752
|
+
value: 'cancel',
|
|
753
|
+
keepOpen: true,
|
|
754
|
+
onClick: async (close) => { if (await mayDiscard()) close(null); },
|
|
755
|
+
},
|
|
756
|
+
];
|
|
757
|
+
if (clearLabel) modalActions.push(asModalAction({ label: clearLabel, value: CLEAR }));
|
|
758
|
+
modalActions.push({
|
|
759
|
+
...asModalAction({ label: submitLabel, value: SUBMIT, run: onSubmit }),
|
|
760
|
+
primary: true,
|
|
761
|
+
disabled: !canSubmit(),
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const closed = openModal({
|
|
765
|
+
title,
|
|
766
|
+
icon,
|
|
767
|
+
content,
|
|
768
|
+
actions: modalActions,
|
|
769
|
+
width,
|
|
770
|
+
height,
|
|
771
|
+
maximizable: true,
|
|
772
|
+
beforeClose: mayDiscard,
|
|
773
|
+
dismissOnBackdrop,
|
|
774
|
+
onMount: (_body, modalControls) => {
|
|
775
|
+
controls = modalControls;
|
|
776
|
+
refresh();
|
|
777
|
+
// Editing a name usually means replacing it, and " (copy)" always does.
|
|
778
|
+
nameInput?.focus();
|
|
779
|
+
nameInput?.select();
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
drawPreview();
|
|
783
|
+
|
|
784
|
+
try {
|
|
785
|
+
return await closed;
|
|
786
|
+
} finally {
|
|
787
|
+
clearTimeout(previewTimer);
|
|
788
|
+
previewRun?.abort();
|
|
789
|
+
builder.destroy();
|
|
790
|
+
}
|
|
791
|
+
}
|