flexdesk 0.18.0 → 0.18.2
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 +58 -0
- package/css/flexdesk.css +83 -5
- package/css/tokens.css +7 -0
- package/dist/charts.js +78 -12
- package/dist/charts.js.map +2 -2
- package/dist/{chunk-5W4EEKSK.js → chunk-3BLIVOFD.js} +3 -3
- package/dist/{chunk-P74F6I6P.js → chunk-4X4SY3CP.js} +4 -4
- package/dist/{chunk-P74F6I6P.js.map → chunk-4X4SY3CP.js.map} +2 -2
- package/dist/{chunk-ZL3VKINP.js → chunk-HL3IIHU3.js} +494 -3
- package/dist/{chunk-ZL3VKINP.js.map → chunk-HL3IIHU3.js.map} +4 -4
- package/dist/{chunk-RKVYIXDC.js → chunk-NFZJ6P42.js} +19 -500
- package/dist/chunk-NFZJ6P42.js.map +7 -0
- package/dist/{chunk-U5GKJUOC.js → chunk-QRBOIUU3.js} +5 -6
- package/dist/chunk-QRBOIUU3.js.map +7 -0
- package/dist/flexdesk.css +83 -5
- package/dist/flow.js +22 -10
- package/dist/flow.js.map +2 -2
- package/dist/git.js +3 -3
- package/dist/tiles.js +4 -4
- package/dist/tokens.css +7 -0
- package/dist/widgets.js +226 -58
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +2 -2
- package/docs/components/charts.md +40 -0
- package/docs/components/chat-shell.md +8 -2
- package/docs/components/filter-builder.md +52 -6
- package/docs/components/flow-editors.md +20 -0
- package/docs/components/forms-and-dialogs.md +8 -0
- package/docs/components/input-fields.md +24 -0
- package/docs/components/menus.md +4 -0
- package/docs/components/shell.md +4 -0
- package/docs/components/workspace-picker.md +46 -2
- package/package.json +1 -1
- package/src/charts/plot_config_editor.js +92 -6
- package/src/charts/plot_grid.js +6 -1
- package/src/flow/kit/settings_panel.js +24 -9
- package/src/ui/components/chat_shell.js +7 -1
- package/src/ui/components/context_menu.js +8 -2
- package/src/ui/components/filter_builder.js +53 -28
- package/src/ui/components/filter_model.js +86 -18
- package/src/ui/components/inline_renamer.js +10 -5
- package/src/ui/components/modal.js +25 -8
- package/src/ui/components/workspace_picker.js +125 -27
- package/src/ui/utils/tooltip_service.js +81 -14
- package/dist/chunk-RKVYIXDC.js.map +0 -7
- package/dist/chunk-U5GKJUOC.js.map +0 -7
- /package/dist/{chunk-5W4EEKSK.js.map → chunk-3BLIVOFD.js.map} +0 -0
|
@@ -703,13 +703,18 @@ export function mountChatShell(host, {
|
|
|
703
703
|
changed();
|
|
704
704
|
}
|
|
705
705
|
|
|
706
|
-
/**
|
|
706
|
+
/**
|
|
707
|
+
* Stops the reply: the chat can send again at once. That send waits for
|
|
708
|
+
* `runEnding`, unless this turn was itself still waiting for it: the person
|
|
709
|
+
* has now said twice that they will not wait, so the next send goes out.
|
|
710
|
+
*/
|
|
707
711
|
function cancel() {
|
|
708
712
|
if (!turn) return false;
|
|
709
713
|
const { request, sent } = turn;
|
|
710
714
|
closeReply('stopped');
|
|
711
715
|
machine.cancel();
|
|
712
716
|
if (sent) callCancel(request);
|
|
717
|
+
else runEnding = null;
|
|
713
718
|
syncWelcome();
|
|
714
719
|
changed();
|
|
715
720
|
return true;
|
|
@@ -789,6 +794,7 @@ export function mountChatShell(host, {
|
|
|
789
794
|
const modelBtn = el('button', 'twm-chat-shell__model-btn');
|
|
790
795
|
modelBtn.type = 'button';
|
|
791
796
|
modelBtn.setAttribute('aria-haspopup', 'menu');
|
|
797
|
+
modelBtn.setAttribute('aria-expanded', 'false');
|
|
792
798
|
const modelDot = el('span', 'twm-chat-shell__status');
|
|
793
799
|
const modelLabel = el('span', 'twm-chat-shell__model-label');
|
|
794
800
|
modelBtn.append(modelDot, modelLabel, icon('expand_more', 'twm-chat-shell__model-chevron'));
|
|
@@ -37,6 +37,7 @@ import { placeMenuInViewport, moveMenuFocus, focusMenuItem } from '../utils/menu
|
|
|
37
37
|
|
|
38
38
|
let _activeMenu = null;
|
|
39
39
|
let _returnFocusTo = null;
|
|
40
|
+
let _armTimer = null;
|
|
40
41
|
|
|
41
42
|
export function showContextMenu(x, y, items, onAction) {
|
|
42
43
|
hideContextMenu();
|
|
@@ -104,8 +105,11 @@ export function showContextMenu(x, y, items, onAction) {
|
|
|
104
105
|
|
|
105
106
|
// Close on outside click / scroll / Escape. A scroll INSIDE the menu is
|
|
106
107
|
// the menu being read, not the page moving under it.
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
// Armed a tick later, so the press that opened it is not its first outside
|
|
109
|
+
// press, and held until the menu closes: a press inside (a separator, the
|
|
110
|
+
// padding, a disabled row) closes nothing and must leave the next one heard.
|
|
111
|
+
_armTimer = setTimeout(() => {
|
|
112
|
+
document.addEventListener('mousedown', _outsideHandler, { capture: true });
|
|
109
113
|
}, 0);
|
|
110
114
|
document.addEventListener('keydown', _keyHandler);
|
|
111
115
|
window.addEventListener('scroll', _scrollHandler, { capture: true });
|
|
@@ -122,6 +126,8 @@ export function hideContextMenu() {
|
|
|
122
126
|
_activeMenu.remove();
|
|
123
127
|
_activeMenu = null;
|
|
124
128
|
_returnFocusTo = null;
|
|
129
|
+
clearTimeout(_armTimer);
|
|
130
|
+
document.removeEventListener('mousedown', _outsideHandler, { capture: true });
|
|
125
131
|
document.removeEventListener('keydown', _keyHandler);
|
|
126
132
|
window.removeEventListener('scroll', _scrollHandler, { capture: true });
|
|
127
133
|
// Only take focus back if the menu still had it. A click elsewhere has
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
* the node and redraws nothing, so the caret survives.
|
|
13
13
|
*
|
|
14
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
|
|
16
|
-
* until someone changes or removes them: the selects show
|
|
17
|
-
* entry and the value is read-only.
|
|
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
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { cloneFilter, coerceFilterValue, emptyFilter, filterOptions } from './filter_model.js';
|
|
@@ -215,21 +215,25 @@ export function createFilterBuilder(host, { model, value = null, onChange = null
|
|
|
215
215
|
function operatorChoices(clause) {
|
|
216
216
|
const offered = model.operatorsFor(clause.field);
|
|
217
217
|
const held = model.operatorById(clause.op);
|
|
218
|
-
const list = held &&
|
|
219
|
-
? [...offered, held] : offered;
|
|
218
|
+
const list = held && !offered.includes(held) ? [...offered, held] : offered;
|
|
220
219
|
return list.map((op) => ({ value: op.id, label: op.label }));
|
|
221
220
|
}
|
|
222
221
|
|
|
223
222
|
function clauseEl(clause) {
|
|
224
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 ?? ''))}”`;
|
|
225
226
|
|
|
226
227
|
const fieldSelect = selectBox(fieldChoices(clause.field), clause.field, {
|
|
227
228
|
label: 'Field',
|
|
228
|
-
className: `${SELECT_CLASS} twm-filter-builder__select--field`,
|
|
229
|
-
notFound: () =>
|
|
229
|
+
className: `${SELECT_CLASS} twm-filter-builder__select--field${known ? '' : ' twm-filter-builder__select--unknown'}`,
|
|
230
|
+
notFound: () => unknownWords,
|
|
230
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;
|
|
231
235
|
fieldSelect.box.addEventListener('change', () => {
|
|
232
|
-
const next = model.
|
|
236
|
+
const next = model.moveClause(clause, fieldSelect.valueOf());
|
|
233
237
|
for (const key of ['op', 'value']) delete clause[key];
|
|
234
238
|
Object.assign(clause, next);
|
|
235
239
|
restructure(clause, 'field');
|
|
@@ -269,7 +273,9 @@ export function createFilterBuilder(host, { model, value = null, onChange = null
|
|
|
269
273
|
function drawValue(clause, cellEl, cell, drawCell) {
|
|
270
274
|
const field = model.fieldByKey(clause.field);
|
|
271
275
|
const op = model.operatorById(clause.op);
|
|
272
|
-
|
|
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))) {
|
|
273
279
|
if (clause.value !== undefined) {
|
|
274
280
|
cellEl.appendChild(el('span', 'twm-filter-builder__carried', JSON.stringify(clause.value)));
|
|
275
281
|
}
|
|
@@ -327,6 +333,7 @@ export function createFilterBuilder(host, { model, value = null, onChange = null
|
|
|
327
333
|
if (numeric) {
|
|
328
334
|
input.type = 'number';
|
|
329
335
|
input.step = 'any';
|
|
336
|
+
if (op.input === 'days') input.min = '0';
|
|
330
337
|
} else if (field.type === 'date') {
|
|
331
338
|
input.type = field.time ? 'datetime-local' : 'date';
|
|
332
339
|
} else {
|
|
@@ -508,12 +515,13 @@ function createIconPicker({ value, options, onChange }) {
|
|
|
508
515
|
* @param {object} options.model from `createFilterModel`
|
|
509
516
|
* @param {object} [options.value] the expression to edit (copied; Cancel is lossless)
|
|
510
517
|
* @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
|
|
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.
|
|
512
520
|
* @param {string} [options.title]
|
|
513
521
|
* @param {string} [options.icon] the window's icon
|
|
514
522
|
* @param {HTMLElement} [options.head] drawn above the conditions
|
|
515
523
|
* @param {{value?: string, label?: string, placeholder?: string, required?: boolean}} [options.name]
|
|
516
|
-
* adds a name box; the result carries `name`
|
|
524
|
+
* adds a name box, which opens with its text selected; the result carries `name`
|
|
517
525
|
* @param {{value?: string, options: string[]}} [options.iconChoice]
|
|
518
526
|
* adds an icon picker over those Material Symbols names; the result carries `icon`
|
|
519
527
|
* @param {(host: HTMLElement, state: {expr: object, sentence: string, signal: AbortSignal}) => void} [options.preview]
|
|
@@ -521,10 +529,13 @@ function createIconPicker({ value, options, onChange }) {
|
|
|
521
529
|
* while the expression can be submitted. `signal` aborts when this drawing is
|
|
522
530
|
* superseded or the dialog closes, for a preview that asks a server.
|
|
523
531
|
* @param {boolean} [options.requireValid] submit only a valid expression (default
|
|
524
|
-
* true); its errors are listed. False lets one with an unknown field through.
|
|
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`).
|
|
525
534
|
* @param {boolean} [options.prune] drop half-built rows from what is validated,
|
|
526
535
|
* previewed and submitted (`model.pruneFilter`); `expr` is null when nothing is left
|
|
527
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)
|
|
528
539
|
* @param {string} [options.submitLabel]
|
|
529
540
|
* @param {(result: object) => any} [options.onSubmit] may be async: until it settles
|
|
530
541
|
* every button is off and Esc and the X do nothing, so the answer is what happened.
|
|
@@ -532,8 +543,9 @@ function createIconPicker({ value, options, onChange }) {
|
|
|
532
543
|
* @param {string} [options.clearLabel] adds a button answering `{ action: 'clear', expr: null }`
|
|
533
544
|
* @param {Array<{label: string, value: string, danger?: boolean, icon?: string,
|
|
534
545
|
* confirm?: object, run?: (result: object) => any}>} [options.actions]
|
|
535
|
-
* more buttons, before Cancel. `confirm` is `openConfirm`'s options, asked first;
|
|
536
|
-
* `
|
|
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`.
|
|
537
549
|
* @param {number} [options.width]
|
|
538
550
|
* @param {number} [options.height]
|
|
539
551
|
* @returns {Promise<{action: string, expr: object|null, name?: string, icon?: string}|null>}
|
|
@@ -542,16 +554,17 @@ function createIconPicker({ value, options, onChange }) {
|
|
|
542
554
|
export async function openFilterDialog({
|
|
543
555
|
model, value = null, seedField = null, title = 'Filter', icon = 'filter_alt', head = null,
|
|
544
556
|
name = null, iconChoice = null, preview = null,
|
|
545
|
-
requireValid = true, prune = false, confirmDiscard = false,
|
|
557
|
+
requireValid = true, prune = false, confirmDiscard = false, dismissOnBackdrop = false,
|
|
546
558
|
submitLabel = 'Apply', onSubmit = null, clearLabel = null, actions = [],
|
|
547
559
|
width = preview ? 920 : 760, height = 560,
|
|
548
560
|
} = {}) {
|
|
549
561
|
if (!model) throw new Error('[FilterDialog] needs a filter model');
|
|
550
562
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
if (
|
|
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);
|
|
555
568
|
}
|
|
556
569
|
|
|
557
570
|
const content = el('div', 'twm-filter-dialog');
|
|
@@ -630,10 +643,14 @@ export async function openFilterDialog({
|
|
|
630
643
|
const expr = builder.getValue();
|
|
631
644
|
return prune ? model.pruneFilter(expr) : expr;
|
|
632
645
|
}
|
|
633
|
-
|
|
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
|
+
};
|
|
634
651
|
|
|
635
652
|
const nameMissing = () => !!nameInput && name.required !== false && !nameInput.value.trim();
|
|
636
|
-
const canSubmit = () => !busy && !nameMissing() &&
|
|
653
|
+
const canSubmit = () => !busy && !nameMissing() && !refusals(current()).length;
|
|
637
654
|
|
|
638
655
|
function refresh() {
|
|
639
656
|
if (!controls) return;
|
|
@@ -653,11 +670,11 @@ export async function openFilterDialog({
|
|
|
653
670
|
|
|
654
671
|
function drawPreview() {
|
|
655
672
|
const expr = current();
|
|
656
|
-
const
|
|
673
|
+
const errors = refusals(expr);
|
|
657
674
|
previewRun?.abort();
|
|
658
675
|
previewRun = null;
|
|
659
|
-
const blocked =
|
|
660
|
-
showErrors(
|
|
676
|
+
const blocked = errors.length > 0;
|
|
677
|
+
showErrors(errors);
|
|
661
678
|
if (!preview) return;
|
|
662
679
|
slot.hidden = blocked;
|
|
663
680
|
if (blocked) {
|
|
@@ -702,13 +719,17 @@ export async function openFilterDialog({
|
|
|
702
719
|
showFailure('');
|
|
703
720
|
busy = true;
|
|
704
721
|
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
722
|
const result = { action: action.value, expr: action.value === CLEAR ? null : current() };
|
|
710
723
|
if (nameInput) result.name = nameInput.value.trim();
|
|
711
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();
|
|
712
733
|
await action.run?.(result);
|
|
713
734
|
close(result);
|
|
714
735
|
} catch (err) {
|
|
@@ -749,9 +770,13 @@ export async function openFilterDialog({
|
|
|
749
770
|
height,
|
|
750
771
|
maximizable: true,
|
|
751
772
|
beforeClose: mayDiscard,
|
|
773
|
+
dismissOnBackdrop,
|
|
752
774
|
onMount: (_body, modalControls) => {
|
|
753
775
|
controls = modalControls;
|
|
754
776
|
refresh();
|
|
777
|
+
// Editing a name usually means replacing it, and " (copy)" always does.
|
|
778
|
+
nameInput?.focus();
|
|
779
|
+
nameInput?.select();
|
|
755
780
|
},
|
|
756
781
|
});
|
|
757
782
|
drawPreview();
|
|
@@ -319,6 +319,34 @@ export function createFilterModel({
|
|
|
319
319
|
return clause;
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
/** Does `clause`'s value still mean something on `field`, under `op`? */
|
|
323
|
+
function valueFits(field, op, value) {
|
|
324
|
+
const errors = [];
|
|
325
|
+
validateClause({ field: field.key, op: op.id, value }, errors, '');
|
|
326
|
+
if (errors.length) return false;
|
|
327
|
+
// A list the editor offers: a text field's single value is typed freely.
|
|
328
|
+
const options = op.multi || field.type === 'enum' ? filterOptions(field) : [];
|
|
329
|
+
return !options.length || arr(value).every((v) => options.some((o) => o.value === v));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* `clause` moved to the field `key`: its operator stays when that field offers
|
|
334
|
+
* it, and its value when the value still fits; otherwise they start as
|
|
335
|
+
* `newClause(key)`'s do. Null when the catalogue has no such field.
|
|
336
|
+
*/
|
|
337
|
+
function moveClause(clause, key) {
|
|
338
|
+
const next = newClause(key);
|
|
339
|
+
if (!next) return null;
|
|
340
|
+
const field = byKey.get(key);
|
|
341
|
+
const op = operatorsFor(key).find((o) => o.id === clause.op);
|
|
342
|
+
if (!op) return next;
|
|
343
|
+
next.op = op.id;
|
|
344
|
+
delete next.value;
|
|
345
|
+
const value = op.arity && valueFits(field, op, clause.value) ? clause.value : defaultValue(field, op);
|
|
346
|
+
if (value !== undefined) next.value = value;
|
|
347
|
+
return next;
|
|
348
|
+
}
|
|
349
|
+
|
|
322
350
|
/* Compiled per type so a clause does its type dispatch ONCE, not on every row. */
|
|
323
351
|
function compileClause(clause) {
|
|
324
352
|
const field = byKey.get(clause?.field);
|
|
@@ -475,35 +503,50 @@ export function createFilterModel({
|
|
|
475
503
|
const [a, b] = arr(value);
|
|
476
504
|
return `${shown(a)} and ${shown(b)}`;
|
|
477
505
|
}
|
|
478
|
-
|
|
506
|
+
// Every value of a list, even under an operator that takes one: a saved
|
|
507
|
+
// or foreign condition may hold several, and the sentence must not hide them.
|
|
508
|
+
return Array.isArray(value) ? value.map(shown).join(', ') : shown(value);
|
|
479
509
|
}
|
|
480
510
|
|
|
481
|
-
function describeNode(node, depth,
|
|
511
|
+
function describeNode(node, depth, opts) {
|
|
482
512
|
if (!node) return '';
|
|
483
513
|
if (node.kind === 'clause') {
|
|
484
514
|
const field = byKey.get(node.field);
|
|
515
|
+
const op = catalogue.get(node.op) ?? { label: node.op ?? '', arity: 1 };
|
|
485
516
|
// A field that has gone is SAID, so a filter matching nothing reads
|
|
486
|
-
// as broken, not as empty.
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
const val = describeValue(field, op, node.value, format);
|
|
491
|
-
return val ? `${field.label} ${op.label} ${val}` : `${field.label} ${op.label}`;
|
|
517
|
+
// as broken, not as empty, and what it asked stays readable.
|
|
518
|
+
const subject = field ? field.label : `⚠ unknown field “${unknownName(String(node.field ?? ''))}”`;
|
|
519
|
+
const value = describeValue(field, op, node.value, field ? opts.formatValue : null);
|
|
520
|
+
return [subject, op.label, value].filter(Boolean).join(' ');
|
|
492
521
|
}
|
|
493
522
|
if (node.kind !== 'group') return `⚠ ${JSON.stringify(node.node ?? node)}`;
|
|
494
523
|
const parts = (Array.isArray(node.children) ? node.children : [])
|
|
495
|
-
.map((c) => describeNode(c, depth + 1,
|
|
524
|
+
.map((c) => describeNode(c, depth + 1, opts)).filter(Boolean);
|
|
496
525
|
if (!parts.length) return emptyMatches(node) !== !!node.negate ? 'everything' : 'nothing';
|
|
497
526
|
const joined = parts.join(String(node.op || 'AND').toUpperCase() === 'OR' ? ' OR ' : ' AND ');
|
|
527
|
+
const many = parts.length > 1;
|
|
528
|
+
if (opts.groupBrackets) {
|
|
529
|
+
const group = many ? `(${joined})` : joined;
|
|
530
|
+
return node.negate ? `NOT ${group}` : group;
|
|
531
|
+
}
|
|
498
532
|
if (node.negate) return `NOT (${joined})`;
|
|
499
|
-
return depth > 0 &&
|
|
533
|
+
return depth > 0 && many ? `(${joined})` : joined;
|
|
500
534
|
}
|
|
501
535
|
|
|
502
536
|
/**
|
|
503
537
|
* One line of words. `formatValue(value, field)` names each value (a key shown
|
|
504
|
-
* as a person's name, say); without it a value is shown as itself.
|
|
538
|
+
* as a person's name, say); without it a value is shown as itself. A condition
|
|
539
|
+
* on a field the catalogue does not know says so, with its operator and value,
|
|
540
|
+
* and its value is shown as itself.
|
|
541
|
+
*
|
|
542
|
+
* Brackets go where the sentence would misread without them: a nested group of
|
|
543
|
+
* two or more, and whatever a NOT covers. `groupBrackets` brackets every group of
|
|
544
|
+
* two or more, the root too, and leaves a NOT of one thing unbracketed: `(A AND B)`,
|
|
545
|
+
* `NOT A`, for a surface that reads a group by its brackets.
|
|
505
546
|
*/
|
|
506
|
-
const describeFilter = (expr, { formatValue = null } = {}) =>
|
|
547
|
+
const describeFilter = (expr, { formatValue = null, groupBrackets = false } = {}) => (
|
|
548
|
+
describeNode(expr, 0, { formatValue, groupBrackets })
|
|
549
|
+
);
|
|
507
550
|
|
|
508
551
|
/** The field keys an expression names that the catalogue does not know. */
|
|
509
552
|
function unknownFields(expr) {
|
|
@@ -535,13 +578,13 @@ export function createFilterModel({
|
|
|
535
578
|
}
|
|
536
579
|
if (op.arity === 0) return;
|
|
537
580
|
const say = (what) => errors.push(`${field.label} ${op.label}: ${what}`);
|
|
538
|
-
if (op.multi) {
|
|
539
|
-
|
|
581
|
+
if (op.multi && (!Array.isArray(node.value) || node.value.length === 0)) {
|
|
582
|
+
say('pick at least one value');
|
|
540
583
|
return;
|
|
541
584
|
}
|
|
542
|
-
const values = op.arity === 2 ? arr(node.value).slice(0, 2) : [first(node.value)];
|
|
585
|
+
const values = op.multi ? node.value : op.arity === 2 ? arr(node.value).slice(0, 2) : [first(node.value)];
|
|
543
586
|
const needed = op.arity === 2 ? 2 : 1;
|
|
544
|
-
if (values.length < needed || values.some(isBlank)) {
|
|
587
|
+
if (!op.multi && (values.length < needed || values.some(isBlank))) {
|
|
545
588
|
say(needed === 2 ? 'needs two values' : 'needs a value');
|
|
546
589
|
return;
|
|
547
590
|
}
|
|
@@ -581,6 +624,29 @@ export function createFilterModel({
|
|
|
581
624
|
return { ok: errors.length === 0, errors };
|
|
582
625
|
}
|
|
583
626
|
|
|
627
|
+
/**
|
|
628
|
+
* What is said of each range in `expr` with one end filled: it is not blank, so
|
|
629
|
+
* `pruneFilter` keeps it, and what it asks (between 5 and nothing) matches nothing.
|
|
630
|
+
*/
|
|
631
|
+
function partialRanges(expr) {
|
|
632
|
+
const said = [];
|
|
633
|
+
const walk = (node) => {
|
|
634
|
+
if (node?.kind === 'group') {
|
|
635
|
+
(Array.isArray(node.children) ? node.children : []).forEach(walk);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (node?.kind !== 'clause') return;
|
|
639
|
+
const field = byKey.get(node.field);
|
|
640
|
+
const op = catalogue.get(node.op);
|
|
641
|
+
if (!field || op?.arity !== 2 || op.multi) return;
|
|
642
|
+
const ends = arr(node.value).slice(0, 2);
|
|
643
|
+
const filled = ends.filter((v) => !isBlank(v)).length;
|
|
644
|
+
if (filled === 1) said.push(`${field.label} ${op.label}: needs two values`);
|
|
645
|
+
};
|
|
646
|
+
walk(expr);
|
|
647
|
+
return said;
|
|
648
|
+
}
|
|
649
|
+
|
|
584
650
|
/* ── prune: what is left once half-built rows are dropped ───────────── */
|
|
585
651
|
|
|
586
652
|
function prune(node) {
|
|
@@ -603,12 +669,14 @@ export function createFilterModel({
|
|
|
603
669
|
/**
|
|
604
670
|
* A copy with empty groups and conditions with nothing to compare against
|
|
605
671
|
* dropped, or null when nothing is left. A condition this catalogue cannot
|
|
606
|
-
* read, and a carried node, are kept: they were not built here.
|
|
672
|
+
* read, and a carried node, are kept: they were not built here. So is a range
|
|
673
|
+
* with one end filled, which `partialRanges` names: someone meant that.
|
|
607
674
|
*/
|
|
608
675
|
const pruneFilter = (expr) => prune(cloneFilter(expr));
|
|
609
676
|
|
|
610
677
|
return Object.freeze({
|
|
611
|
-
fields, noun, fieldByKey, operatorsFor, operatorById, defaultValue, newClause, emptyMatches,
|
|
678
|
+
fields, noun, fieldByKey, operatorsFor, operatorById, defaultValue, newClause, moveClause, emptyMatches,
|
|
612
679
|
unknownName, matcherFor, evaluateFilter, describeFilter, validateFilter, unknownFields, pruneFilter,
|
|
680
|
+
partialRanges,
|
|
613
681
|
});
|
|
614
682
|
}
|
|
@@ -32,11 +32,12 @@ export function attachInlineRenamer(options = {}) {
|
|
|
32
32
|
let originalText = label.textContent || '';
|
|
33
33
|
|
|
34
34
|
const cleanup = () => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
// `input` goes first: taking a focused field off the page fires its blur,
|
|
36
|
+
// and a blur with the field still held would commit an edit that just ended.
|
|
37
|
+
const gone = input;
|
|
38
38
|
input = null;
|
|
39
39
|
editing = false;
|
|
40
|
+
if (gone && gone.parentNode === label) gone.remove();
|
|
40
41
|
};
|
|
41
42
|
|
|
42
43
|
const cancel = () => {
|
|
@@ -68,8 +69,8 @@ export function attachInlineRenamer(options = {}) {
|
|
|
68
69
|
return;
|
|
69
70
|
}
|
|
70
71
|
const finalValue = result.newValue ?? next ?? '';
|
|
71
|
-
label.textContent = finalValue;
|
|
72
72
|
cleanup();
|
|
73
|
+
label.textContent = finalValue;
|
|
73
74
|
options.onFinish?.({ value: finalValue });
|
|
74
75
|
} catch (err) {
|
|
75
76
|
window.logger?.warn('inline-renamer', 'onCommit failed', err);
|
|
@@ -89,6 +90,11 @@ export function attachInlineRenamer(options = {}) {
|
|
|
89
90
|
input.addEventListener('mousedown', stopEvents);
|
|
90
91
|
input.addEventListener('click', stopEvents);
|
|
91
92
|
|
|
93
|
+
// Before the renamer's own keys, so that what it attaches to the input
|
|
94
|
+
// hears them first: `attachInputOptions` takes Enter on a highlighted row
|
|
95
|
+
// and Esc with its list open, and the renamer never sees either.
|
|
96
|
+
options.onStart?.({ value: originalText, triggerEvent, input });
|
|
97
|
+
|
|
92
98
|
input.addEventListener('keydown', (ev) => {
|
|
93
99
|
if (ev.key === 'Enter') {
|
|
94
100
|
ev.preventDefault();
|
|
@@ -101,7 +107,6 @@ export function attachInlineRenamer(options = {}) {
|
|
|
101
107
|
|
|
102
108
|
input.addEventListener('blur', () => commit('blur'));
|
|
103
109
|
|
|
104
|
-
options.onStart?.({ value: originalText, triggerEvent });
|
|
105
110
|
setTimeout(() => {
|
|
106
111
|
input?.focus();
|
|
107
112
|
input?.select?.();
|
|
@@ -444,7 +444,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
444
444
|
|
|
445
445
|
/**
|
|
446
446
|
* `openConfirm` — a yes/no modal. Resolves `true` on confirm, `false` on
|
|
447
|
-
* cancel, Esc
|
|
447
|
+
* cancel, Esc or X.
|
|
448
448
|
*
|
|
449
449
|
* `message`, `confirmLabel` and `cancelLabel` are TEXT, never markup: callers
|
|
450
450
|
* put user-named things in them ("Delete flow "<name>"?"), so they are set
|
|
@@ -531,7 +531,8 @@ export function openConfirm({
|
|
|
531
531
|
* omitted, defaults to a single `[Close]` action that
|
|
532
532
|
* resolves with `null`. The first action with
|
|
533
533
|
* `primary: true` is the rightmost; otherwise the
|
|
534
|
-
* last action wins. Esc / X
|
|
534
|
+
* last action wins. Esc / X (and, with
|
|
535
|
+
* `dismissOnBackdrop`, a click on the backdrop) resolve with
|
|
535
536
|
* `null` regardless of what's in `actions`. `icon` is
|
|
536
537
|
* an optional material-symbols-outlined ligature drawn
|
|
537
538
|
* BEFORE the label; when it is given the label lives in
|
|
@@ -558,6 +559,9 @@ export function openConfirm({
|
|
|
558
559
|
* - `beforeClose` `() => boolean | Promise<boolean>`: asked when Esc or
|
|
559
560
|
* the X would dismiss; answering false keeps the modal open. An
|
|
560
561
|
* action button does not ask it.
|
|
562
|
+
* - `dismissOnBackdrop` a click on the dimmed page beside the modal dismisses
|
|
563
|
+
* it, as Esc does, so `beforeClose` is asked. Default `false`:
|
|
564
|
+
* the backdrop only blocks.
|
|
561
565
|
*
|
|
562
566
|
* ══ C29. WHY A DIALOG'S MAXIMISE IS THE GEOMETRIC ONE ═══════════════════
|
|
563
567
|
*
|
|
@@ -600,6 +604,7 @@ export function openModal({
|
|
|
600
604
|
maximizable = false,
|
|
601
605
|
onMaximizeChange = null,
|
|
602
606
|
beforeClose = null,
|
|
607
|
+
dismissOnBackdrop = false,
|
|
603
608
|
} = {}) {
|
|
604
609
|
return new Promise((resolve) => {
|
|
605
610
|
const body = document.createElement('div');
|
|
@@ -610,8 +615,8 @@ export function openModal({
|
|
|
610
615
|
if (content instanceof HTMLElement) bodyInner.appendChild(content);
|
|
611
616
|
body.appendChild(bodyInner);
|
|
612
617
|
|
|
613
|
-
// Actions row — default = single Close button. Esc
|
|
614
|
-
//
|
|
618
|
+
// Actions row — default = single Close button. Esc and X (and an
|
|
619
|
+
// opted-in backdrop) bypass this and resolve with null.
|
|
615
620
|
const acts = Array.isArray(actions) && actions.length > 0
|
|
616
621
|
? actions
|
|
617
622
|
: [{ label: 'Close', value: null }];
|
|
@@ -706,16 +711,24 @@ export function openModal({
|
|
|
706
711
|
defaultWidth: width,
|
|
707
712
|
defaultHeight: height,
|
|
708
713
|
beforeClose,
|
|
709
|
-
// Esc, the X and
|
|
710
|
-
// `close`, so the listener is dropped here as well as there —
|
|
711
|
-
//
|
|
712
|
-
//
|
|
714
|
+
// Esc, the X and an opted-in backdrop all land here without passing
|
|
715
|
+
// through `close`, so the listener is dropped here as well as there —
|
|
716
|
+
// the dismissals a user reaches for most are exactly the ones that
|
|
717
|
+
// would otherwise leak it.
|
|
713
718
|
onClose: () => {
|
|
714
719
|
_dropMaxListener();
|
|
715
720
|
if (!_resolved) { _resolved = true; resolve(null); }
|
|
716
721
|
},
|
|
717
722
|
});
|
|
718
723
|
win.show();
|
|
724
|
+
// A click, not a press: a drag that starts in the window and ends on the
|
|
725
|
+
// page (selecting text, resizing) has no click on the backdrop.
|
|
726
|
+
// The press takes no focus from the window: `beforeClose` may refuse, and
|
|
727
|
+
// the person goes on typing where they were.
|
|
728
|
+
if (dismissOnBackdrop) {
|
|
729
|
+
win.backdropElement.addEventListener('mousedown', (e) => e.preventDefault());
|
|
730
|
+
win.backdropElement.addEventListener('click', () => win.close());
|
|
731
|
+
}
|
|
719
732
|
|
|
720
733
|
// C29. THE STATE, FOR A CALLER WHOSE LAYOUT DEPENDS ON IT.
|
|
721
734
|
//
|
|
@@ -818,7 +831,11 @@ export function openModal({
|
|
|
818
831
|
// whose first control is a read-only statement panel (C27's disabled
|
|
819
832
|
// primary is the sibling case) would swallow the caret into a box that
|
|
820
833
|
// cannot take it.
|
|
834
|
+
//
|
|
835
|
+
// AN `onMount` THAT PLACED FOCUS KEEPS IT, with its selection: it ran in
|
|
836
|
+
// the frame before this one, and it knows the field better than "the first".
|
|
821
837
|
requestAnimationFrame(() => {
|
|
838
|
+
if (bodyInner.contains(document.activeElement)) return;
|
|
822
839
|
const field = bodyInner.querySelector(
|
|
823
840
|
'input:not([type="hidden"]):not([disabled]):not([readonly]),'
|
|
824
841
|
+ ' textarea:not([disabled]):not([readonly]),'
|