flexdesk 0.5.0 → 0.6.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/README.md +654 -1
- package/css/base.css +2868 -0
- package/css/flexdesk.css +2868 -0
- package/css/tokens.css +72 -0
- package/dist/canvas.js +390 -0
- package/dist/canvas.js.map +7 -0
- package/dist/charts.js +2 -2
- package/dist/{chunk-24MS7W5Y.js → chunk-6NG7YVBG.js} +2 -470
- package/dist/chunk-6NG7YVBG.js.map +7 -0
- package/dist/chunk-H4LT43RS.js +475 -0
- package/dist/chunk-H4LT43RS.js.map +7 -0
- package/dist/{chunk-KH2GJTKR.js → chunk-IYV5JVYB.js} +29 -1
- package/dist/chunk-IYV5JVYB.js.map +7 -0
- package/dist/chunk-SN4LSOAR.js +532 -0
- package/dist/chunk-SN4LSOAR.js.map +7 -0
- package/dist/{chunk-CNXP3SIN.js → chunk-ZMCRDOCF.js} +2 -2
- package/dist/flexdesk.css +2868 -0
- package/dist/flow.js +10030 -0
- package/dist/flow.js.map +7 -0
- package/dist/tiles.js +9 -9
- package/dist/tokens.css +72 -0
- package/dist/widgets.js +87 -5
- package/dist/widgets.js.map +3 -3
- package/dist/wm.js +4 -2
- package/dist/wm.js.map +1 -1
- package/package.json +9 -1
- package/src/canvas/canvas_adapter.js +523 -0
- package/src/canvas/edge_router.js +392 -0
- package/src/canvas/graph/connector_router.js +189 -0
- package/src/canvas/graph/history_service.js +170 -0
- package/src/canvas/graph/node_platform.js +116 -0
- package/src/canvas/index.js +16 -0
- package/src/flow/canvas_editor/editor.js +1081 -0
- package/src/flow/canvas_editor/index.js +16 -0
- package/src/flow/canvas_editor/layout.js +118 -0
- package/src/flow/canvas_editor/strings.js +37 -0
- package/src/flow/kit/always_before.js +325 -0
- package/src/flow/kit/catalogue.js +170 -0
- package/src/flow/kit/chip_input.js +461 -0
- package/src/flow/kit/dom.js +205 -0
- package/src/flow/kit/findings.js +129 -0
- package/src/flow/kit/history.js +173 -0
- package/src/flow/kit/index.js +38 -0
- package/src/flow/kit/keys.js +127 -0
- package/src/flow/kit/logic_graph.js +210 -0
- package/src/flow/kit/references.js +199 -0
- package/src/flow/kit/settings_panel.js +357 -0
- package/src/flow/kit/settings_schema.js +175 -0
- package/src/flow/kit/step_picker.js +209 -0
- package/src/flow/kit/strings.js +102 -0
- package/src/flow/kit/value_picker.js +238 -0
- package/src/flow/kit/widgets.js +763 -0
- package/src/flow/lanes/editor.js +1469 -0
- package/src/flow/lanes/index.js +26 -0
- package/src/flow/lanes/layout.js +525 -0
- package/src/flow/lanes/pipeline.js +327 -0
- package/src/flow/lanes/preview.js +109 -0
- package/src/flow/lanes/strings.js +98 -0
- package/src/flow/outline/editor.js +1511 -0
- package/src/flow/outline/index.js +20 -0
- package/src/flow/outline/mapping.js +286 -0
- package/src/flow/outline/menu.js +154 -0
- package/src/flow/outline/operations.js +738 -0
- package/src/flow/outline/recognise.js +755 -0
- package/src/flow/outline/strings.js +135 -0
- package/src/ui/components/data_table.js +40 -0
- package/src/ui/utils/scroll_lock.js +111 -0
- package/dist/chunk-24MS7W5Y.js.map +0 -7
- package/dist/chunk-KH2GJTKR.js.map +0 -7
- /package/dist/{chunk-CNXP3SIN.js.map → chunk-ZMCRDOCF.js.map} +0 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `@flexdesk/flow` exports for the OUTLINE editor (36 §6): the editor,
|
|
3
|
+
* and the pure parts under it for a consumer that wants to read or check an
|
|
4
|
+
* outline without drawing one — the recogniser and its inverse, the block
|
|
5
|
+
* mapping, the operations.
|
|
6
|
+
*
|
|
7
|
+
* OWNED BY the outline agent (C, 36 §2). Every name says "outline", so the
|
|
8
|
+
* three editors' barrels never export one name twice through `flow.js`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { createOutlineEditor, OUTLINE_ACTIONS, OUTLINE_INDENT } from './editor.js';
|
|
12
|
+
export { OUTLINE_STRINGS } from './strings.js';
|
|
13
|
+
export { createBlockMapping as createOutlineBlocks, displayName as outlineStepName } from './mapping.js';
|
|
14
|
+
export {
|
|
15
|
+
outlineFromGraph, graphFromOutline, describeOutline,
|
|
16
|
+
sameGraphAsSets as outlineGraphsEqual, indexTree as indexOutline, runOrder as outlineRunOrder,
|
|
17
|
+
flatRunOrder as outlineFlatOrder,
|
|
18
|
+
} from './recognise.js';
|
|
19
|
+
export { createOutlineOperations } from './operations.js';
|
|
20
|
+
export { openStepMenu as openOutlineStepMenu } from './menu.js';
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE BLOCK MAPPING — which step roles the outline draws as blocks, and how
|
|
3
|
+
* their ports read (36 §6.2). PURE: no DOM.
|
|
4
|
+
*
|
|
5
|
+
* The consumer says it; the outline guesses nothing about a domain:
|
|
6
|
+
*
|
|
7
|
+
* blocks: {
|
|
8
|
+
* start: { role: 'start', label: 'When it runs' },
|
|
9
|
+
* end: { role: 'end', entry: { label: 'End the run', sub: 'End' } },
|
|
10
|
+
* step: { input: 'in', continue: 'out' },
|
|
11
|
+
* branch: { role: 'branch', arms: { true: 'Then', false: 'Otherwise' },
|
|
12
|
+
* entry: { label: 'If … otherwise', sub: 'Condition' } },
|
|
13
|
+
* fanout: { role: 'fanout', join: { role: 'join', type: 'merge', input: 'in', output: 'out',
|
|
14
|
+
* field: 'join', foot: { all: '…', any: '…' } },
|
|
15
|
+
* arm: 'Branch {n}', addArm: 'Add a branch', entry: { … } },
|
|
16
|
+
* loop: { role: 'loop', ports: { entry: 'in', next: 'next', body: 'body', done: 'done' },
|
|
17
|
+
* foot: 'Next row', skip: 'Go on with the next row', addInside: 'Add a step to the loop',
|
|
18
|
+
* entry: { … } },
|
|
19
|
+
* arms: { error: { label: (step) => `If ${step.label} fails`, unconnected: 'fail',
|
|
20
|
+
* setting: { label, unconnected, connected: (arm) => … } } },
|
|
21
|
+
* types: { 'http-request': { arms: { error: { label: 'If the request fails' } } } },
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* `role` on each block names the CATALOGUE role it draws, so a consumer whose
|
|
25
|
+
* types use other role words maps them here. A role nobody mapped is drawn as
|
|
26
|
+
* an ordinary step.
|
|
27
|
+
*
|
|
28
|
+
* ══ PORTS, BY NAME AND THEN BY ORDER ═══════════════════════════════════
|
|
29
|
+
*
|
|
30
|
+
* An ordinary step is entered by `step.input` and carries on by
|
|
31
|
+
* `step.continue`; a type without a port of that name uses its first FLOW
|
|
32
|
+
* input or output instead, in DECLARED order. Every other FLOW output of an
|
|
33
|
+
* ordinary step is an ARM, in declared order — its words from `types` (per
|
|
34
|
+
* type), then `arms` (per port name), then the port's own label.
|
|
35
|
+
*
|
|
36
|
+
* `unconnected` is what the consumer's runtime does with a port nothing is
|
|
37
|
+
* connected to, and only the consumer can say it: `'fail'` (an error port the
|
|
38
|
+
* run cannot go on from) or `'stop'` (the path simply ends). It decides the
|
|
39
|
+
* words of the port's setting; the outline never assumes one.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
43
|
+
|
|
44
|
+
const DEFAULTS = Object.freeze({
|
|
45
|
+
start: { role: 'start', label: null },
|
|
46
|
+
end: { role: 'end', entry: null },
|
|
47
|
+
step: { input: 'in', continue: 'out' },
|
|
48
|
+
branch: { role: 'branch', arms: {}, unconnected: 'stop', entry: null, note: null },
|
|
49
|
+
fanout: {
|
|
50
|
+
role: 'fanout',
|
|
51
|
+
join: { role: 'join', type: null, input: 'in', output: 'out', field: 'join', foot: {}, choices: {} },
|
|
52
|
+
arm: 'Branch {n}', addArm: 'Add a branch', entry: null, note: null,
|
|
53
|
+
},
|
|
54
|
+
loop: {
|
|
55
|
+
role: 'loop', ports: { entry: 'in', next: 'next', body: 'body', done: 'done' },
|
|
56
|
+
foot: 'Next item', skip: 'Go on with the next item', addInside: 'Add a step inside', entry: null, note: null,
|
|
57
|
+
},
|
|
58
|
+
arms: {},
|
|
59
|
+
types: {},
|
|
60
|
+
add: 'Add a step',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const DEFAULT_SETTING = Object.freeze({
|
|
64
|
+
stop: 'Stop here',
|
|
65
|
+
fail: 'Fail the run',
|
|
66
|
+
connected: (arm) => `Run the steps under “${arm}”`,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** `fn(arg)` when it is a function, else itself; `fallback` for nullish. */
|
|
70
|
+
function word(v, arg, fallback = '') {
|
|
71
|
+
const r = typeof v === 'function' ? v(arg) : v;
|
|
72
|
+
return r === undefined || r === null ? fallback : String(r);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function merged(base, over) {
|
|
76
|
+
const out = { ...base };
|
|
77
|
+
if (isObject(over)) for (const [k, v] of Object.entries(over)) out[k] = v;
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The consumer's mapping laid over the defaults, read against a catalogue.
|
|
83
|
+
* Accepts its own result again unchanged.
|
|
84
|
+
*/
|
|
85
|
+
export function createBlockMapping(blocks = {}, catalogue = null) {
|
|
86
|
+
if (blocks && blocks.__outlineMapping) return blocks;
|
|
87
|
+
const b = isObject(blocks) ? blocks : {};
|
|
88
|
+
const start = merged(DEFAULTS.start, b.start);
|
|
89
|
+
const end = merged(DEFAULTS.end, b.end);
|
|
90
|
+
const step = merged(DEFAULTS.step, b.step);
|
|
91
|
+
const branch = merged(DEFAULTS.branch, b.branch);
|
|
92
|
+
const fanout = merged(DEFAULTS.fanout, b.fanout);
|
|
93
|
+
fanout.join = merged(DEFAULTS.fanout.join, b.fanout?.join);
|
|
94
|
+
const loop = merged(DEFAULTS.loop, b.loop);
|
|
95
|
+
loop.ports = merged(DEFAULTS.loop.ports, b.loop?.ports);
|
|
96
|
+
const arms = isObject(b.arms) ? b.arms : {};
|
|
97
|
+
const types = isObject(b.types) ? b.types : {};
|
|
98
|
+
|
|
99
|
+
const roleKind = new Map([
|
|
100
|
+
[start.role, 'start'], [end.role, 'end'], [branch.role, 'branch'], [fanout.role, 'fanout'],
|
|
101
|
+
[fanout.join.role, 'join'], [loop.role, 'loop'],
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const typeOf = (typeId) => catalogue?.get?.(typeId) ?? null;
|
|
105
|
+
const flowPorts = (typeId, direction) => (catalogue
|
|
106
|
+
? (direction === 'input' ? catalogue.inputs(typeId, { flow: true }) : catalogue.outputs(typeId, { flow: true }))
|
|
107
|
+
: []).map((p) => p.name);
|
|
108
|
+
const named = (typeId, direction, want) => {
|
|
109
|
+
const ports = flowPorts(typeId, direction);
|
|
110
|
+
if (want && ports.includes(want)) return want;
|
|
111
|
+
return ports[0] ?? null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** What a type is drawn as: start, end, branch, fanout, join, loop or step. */
|
|
115
|
+
function kindOf(typeId) {
|
|
116
|
+
const role = catalogue?.role ? catalogue.role(typeId) : 'step';
|
|
117
|
+
return roleKind.get(role) ?? 'step';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function override(typeId) {
|
|
121
|
+
return isObject(types[typeId]) ? types[typeId] : {};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The FLOW input a step is entered by. */
|
|
125
|
+
function input(typeId) {
|
|
126
|
+
const k = kindOf(typeId);
|
|
127
|
+
if (k === 'loop') return named(typeId, 'input', loop.ports.entry);
|
|
128
|
+
if (k === 'join') return named(typeId, 'input', override(typeId).input ?? fanout.join.input);
|
|
129
|
+
return named(typeId, 'input', override(typeId).input ?? step.input);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The FLOW output a step carries on by, or null (a branch, an end). */
|
|
133
|
+
function cont(typeId) {
|
|
134
|
+
const k = kindOf(typeId);
|
|
135
|
+
if (k === 'branch' || k === 'end' || k === 'fanout') return null;
|
|
136
|
+
if (k === 'loop') return flowPorts(typeId, 'output').includes(loop.ports.done) ? loop.ports.done : null;
|
|
137
|
+
if (k === 'join') return named(typeId, 'output', override(typeId).continue ?? fanout.join.output);
|
|
138
|
+
return named(typeId, 'output', override(typeId).continue ?? step.continue);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A step's ARM ports: its other FLOW outputs, in declared order. */
|
|
142
|
+
function armPorts(typeId) {
|
|
143
|
+
const k = kindOf(typeId);
|
|
144
|
+
const outs = flowPorts(typeId, 'output');
|
|
145
|
+
if (k === 'branch') return outs;
|
|
146
|
+
if (k === 'fanout' || k === 'end') return [];
|
|
147
|
+
if (k === 'loop') return outs.filter((p) => p !== loop.ports.body && p !== loop.ports.done);
|
|
148
|
+
const c = cont(typeId);
|
|
149
|
+
return outs.filter((p) => p !== c);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function portLabel(typeId, port) {
|
|
153
|
+
return catalogue?.port?.(typeId, port, 'output')?.label ?? port;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The step as an arm's `label` function reads it. */
|
|
157
|
+
function stepView(node) {
|
|
158
|
+
const type = typeOf(node?.type);
|
|
159
|
+
return { id: node?.id, label: node?.label || type?.label || node?.id || '', node, type };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function armSpec(node, port) {
|
|
163
|
+
const o = override(node?.type).arms?.[port];
|
|
164
|
+
const g = arms[port];
|
|
165
|
+
return { ...(isObject(g) ? g : {}), ...(isObject(o) ? o : {}) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** An ordinary step's arm words: per type, then per port, then the port's label. */
|
|
169
|
+
function armLabel(node, port) {
|
|
170
|
+
const spec = armSpec(node, port);
|
|
171
|
+
return word(spec.label, stepView(node), portLabel(node?.type, port));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** What the consumer's runtime does with this arm's port left unconnected. */
|
|
175
|
+
function armUnconnected(node, port) {
|
|
176
|
+
const spec = armSpec(node, port);
|
|
177
|
+
return spec.unconnected === 'fail' ? 'fail' : 'stop';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The words of the arm's setting (36 §6.5): its label, the unconnected choice, the connected one. */
|
|
181
|
+
function armSetting(node, port) {
|
|
182
|
+
const spec = armSpec(node, port);
|
|
183
|
+
const g = isObject(arms[port]?.setting) ? arms[port].setting : {};
|
|
184
|
+
const o = isObject(override(node?.type).arms?.[port]?.setting) ? override(node?.type).arms[port].setting : {};
|
|
185
|
+
const setting = { ...g, ...o };
|
|
186
|
+
const label = armLabel(node, port);
|
|
187
|
+
const how = armUnconnected(node, port);
|
|
188
|
+
return {
|
|
189
|
+
label: word(setting.label, stepView(node), label),
|
|
190
|
+
unconnected: word(setting.unconnected, stepView(node), DEFAULT_SETTING[how]),
|
|
191
|
+
connected: word(setting.connected ?? DEFAULT_SETTING.connected, label, ''),
|
|
192
|
+
meaning: how,
|
|
193
|
+
tone: spec.tone ?? (how === 'fail' ? 'fail' : 'arm'),
|
|
194
|
+
// the schema field the setting is drawn after, or null (at the end)
|
|
195
|
+
after: setting.after ?? null,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A branch's arm words (Then, Otherwise; a Switch's port labels). */
|
|
200
|
+
function branchArmLabel(node, port) {
|
|
201
|
+
const o = override(node?.type).arms?.[port];
|
|
202
|
+
if (isObject(o) && o.label !== undefined) return word(o.label, stepView(node), port);
|
|
203
|
+
if (typeof o === 'string') return o;
|
|
204
|
+
const g = branch.arms?.[port];
|
|
205
|
+
if (g !== undefined) return word(isObject(g) ? g.label : g, stepView(node), portLabel(node?.type, port));
|
|
206
|
+
return portLabel(node?.type, port);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The tone a branch's arm is drawn in: 'then', 'else' or 'arm'. */
|
|
210
|
+
function branchArmTone(node, port, index) {
|
|
211
|
+
const g = branch.arms?.[port];
|
|
212
|
+
if (isObject(g) && g.tone) return g.tone;
|
|
213
|
+
return index === 0 ? 'then' : 'else';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function fanoutArmLabel(n) {
|
|
217
|
+
return word(fanout.arm, n, `Branch ${n}`).replace('{n}', String(n));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The type a new Parallel's closing step is made from. */
|
|
221
|
+
function joinType() {
|
|
222
|
+
if (fanout.join.type && catalogue?.has?.(fanout.join.type)) return fanout.join.type;
|
|
223
|
+
return catalogue?.list?.().find((t) => kindOf(t.type_id) === 'join')?.type_id ?? null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function joinValue(joinNode) {
|
|
227
|
+
const field = fanout.join.field;
|
|
228
|
+
const v = joinNode?.config?.[field];
|
|
229
|
+
if (v !== undefined) return v;
|
|
230
|
+
const def = typeOf(joinNode?.type)?.config_schema?.properties?.[field]?.default;
|
|
231
|
+
return def === undefined ? 'all' : def;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function joinFoot(joinNode, S) {
|
|
235
|
+
const v = joinValue(joinNode);
|
|
236
|
+
const foot = fanout.join.foot?.[v];
|
|
237
|
+
if (foot !== undefined) return word(foot, v, '');
|
|
238
|
+
return v === 'any' ? (S?.joinFootAny ?? 'Then go on when the first finishes')
|
|
239
|
+
: (S?.joinFootAll ?? 'Then wait for every branch');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A block entry's words in the step picker. */
|
|
243
|
+
function entryFor(typeId) {
|
|
244
|
+
const t = typeOf(typeId);
|
|
245
|
+
const k = kindOf(typeId);
|
|
246
|
+
const own = override(typeId).entry;
|
|
247
|
+
const blockEntry = k === 'branch' ? branch.entry : k === 'fanout' ? fanout.entry : k === 'loop' ? loop.entry
|
|
248
|
+
: k === 'end' ? end.entry : null;
|
|
249
|
+
// A block's entry words belong to the block's one type; with several
|
|
250
|
+
// types of one role, only a type that names its own entry gets them.
|
|
251
|
+
const shared = blockEntry && catalogue?.list?.().filter((x) => kindOf(x.type_id) === k).length === 1
|
|
252
|
+
? blockEntry : null;
|
|
253
|
+
const e = { ...(isObject(shared) ? shared : {}), ...(isObject(own) ? own : {}) };
|
|
254
|
+
return {
|
|
255
|
+
label: e.label ?? t?.label ?? typeId,
|
|
256
|
+
sub: e.sub ?? (e.label ? (t?.label ?? '') : ''),
|
|
257
|
+
description: e.description ?? t?.description ?? '',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return Object.freeze({
|
|
262
|
+
__outlineMapping: true,
|
|
263
|
+
start, end, step, branch, fanout, loop,
|
|
264
|
+
loopPorts: Object.freeze({ ...loop.ports }),
|
|
265
|
+
kindOf, input, cont, armPorts, armLabel, armUnconnected, armSetting,
|
|
266
|
+
branchArmLabel, branchArmTone, fanoutArmLabel, joinType, joinValue, joinFoot, entryFor, stepView,
|
|
267
|
+
startLabel: (S) => start.label ?? S?.flowStart ?? 'Start',
|
|
268
|
+
loopFoot: (node) => word(loop.foot, stepView(node), 'Next item'),
|
|
269
|
+
loopSkip: (node) => word(loop.skip, stepView(node), 'Go on with the next item'),
|
|
270
|
+
loopAddInside: (node) => word(loop.addInside, stepView(node), 'Add a step inside'),
|
|
271
|
+
fanoutAddArm: (node) => word(fanout.addArm, stepView(node), 'Add a branch'),
|
|
272
|
+
addLabel: () => word(b.add ?? DEFAULTS.add, null, 'Add a step'),
|
|
273
|
+
note: (kind, node) => {
|
|
274
|
+
const n = kind === 'branch' ? branch.note : kind === 'fanout' ? fanout.note : kind === 'loop' ? loop.note : null;
|
|
275
|
+
return n === null || n === undefined ? null : word(n, stepView(node), '');
|
|
276
|
+
},
|
|
277
|
+
joinField: fanout.join.field,
|
|
278
|
+
joinChoices: fanout.join.choices || {},
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** What a step is called on the screen: its label, else its type's, else its id. */
|
|
283
|
+
export function displayName(node, catalogue) {
|
|
284
|
+
if (!node) return '';
|
|
285
|
+
return node.label || catalogue?.get?.(node.type)?.label || node.id;
|
|
286
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A STEP'S MENU (36 §6.6, the StepMenu mock) — a `role="menu"` in the kit's
|
|
3
|
+
* popover: one shared shell, so it is dismissed by a press outside, closed
|
|
4
|
+
* by Escape, gives the focus back to the button that opened it and never
|
|
5
|
+
* outlives the editor (`closeFlowPopovers`).
|
|
6
|
+
*
|
|
7
|
+
* openStepMenu({ anchor, label, items, onClose })
|
|
8
|
+
* // item: { id, label, keys?, danger?, refusal?, run?, items? } items: a flyout (Wrap in ›)
|
|
9
|
+
* // { separator: true }
|
|
10
|
+
*
|
|
11
|
+
* A REFUSED ITEM SAYS WHY IN ITS OWN LINE — "Move down — already last in the
|
|
12
|
+
* loop" — disabled, skipped by the arrows, never only greyed: a greyed line
|
|
13
|
+
* nobody can read the reason of is a dead control. ↑ ↓ (and Home, End) move,
|
|
14
|
+
* → opens a flyout and ← closes it, Enter and Space choose, Escape closes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { el, icon, openPopover, toneClass, uid } from '../kit/dom.js';
|
|
18
|
+
import { createStrings, say } from '../kit/strings.js';
|
|
19
|
+
import { OUTLINE_STRINGS } from './strings.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} o
|
|
23
|
+
* @param {HTMLElement} o.anchor
|
|
24
|
+
* @param {string} o.label the menu's accessible name (the step's name)
|
|
25
|
+
* @param {object[]} o.items
|
|
26
|
+
* @param {object} [o.strings]
|
|
27
|
+
* @param {() => void} [o.onClose]
|
|
28
|
+
* @returns {{close: () => void, el: HTMLElement}}
|
|
29
|
+
*/
|
|
30
|
+
export function openStepMenu({ anchor, label, items, strings = null, onClose = null }) {
|
|
31
|
+
const S = createStrings(OUTLINE_STRINGS, strings);
|
|
32
|
+
const root = el('div', 'twm-flow-outline__menu-list');
|
|
33
|
+
root.setAttribute('role', 'menu');
|
|
34
|
+
root.setAttribute('aria-label', label || '');
|
|
35
|
+
root.id = uid('outline-menu');
|
|
36
|
+
let sub = null;
|
|
37
|
+
|
|
38
|
+
function build(list, host, depth) {
|
|
39
|
+
const rows = [];
|
|
40
|
+
for (const it of list) {
|
|
41
|
+
if (it.separator) {
|
|
42
|
+
const sep = el('div', 'twm-flow-outline__menu-sep');
|
|
43
|
+
sep.setAttribute('role', 'separator');
|
|
44
|
+
host.appendChild(sep);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const b = el('button', `twm-flow-outline__menu-item${it.danger ? ' twm-flow-outline__menu-item--danger' : ''}`);
|
|
48
|
+
b.type = 'button';
|
|
49
|
+
b.setAttribute('role', 'menuitem');
|
|
50
|
+
b.dataset.item = it.id ?? '';
|
|
51
|
+
b.tabIndex = -1;
|
|
52
|
+
const words = el('span', 'twm-flow-outline__menu-words');
|
|
53
|
+
if (it.icon) {
|
|
54
|
+
const chip = el('span', `twm-flow-outline__menu-chip ${toneClass('twm-flow-outline__menu-chip', it.tone)}`);
|
|
55
|
+
chip.appendChild(icon(it.icon));
|
|
56
|
+
words.appendChild(chip);
|
|
57
|
+
}
|
|
58
|
+
words.appendChild(el('span', 'twm-flow-outline__menu-label',
|
|
59
|
+
it.refusal ? say(S, 'refusedLine', it.label, it.refusal) : it.label));
|
|
60
|
+
b.appendChild(words);
|
|
61
|
+
if (it.items) {
|
|
62
|
+
b.setAttribute('aria-haspopup', 'menu');
|
|
63
|
+
b.setAttribute('aria-expanded', 'false');
|
|
64
|
+
b.appendChild(icon('chevron_right', 'twm-flow-outline__menu-key'));
|
|
65
|
+
} else if (it.keys) {
|
|
66
|
+
b.appendChild(el('span', 'twm-flow-outline__menu-key', it.keys));
|
|
67
|
+
}
|
|
68
|
+
if (it.refusal) {
|
|
69
|
+
b.disabled = true;
|
|
70
|
+
b.setAttribute('aria-disabled', 'true');
|
|
71
|
+
}
|
|
72
|
+
b.addEventListener('mousedown', (ev) => ev.preventDefault());
|
|
73
|
+
b.addEventListener('click', () => {
|
|
74
|
+
if (b.disabled) return;
|
|
75
|
+
if (it.items) { openSub(b, it); return; }
|
|
76
|
+
handle.close('chosen');
|
|
77
|
+
it.run?.();
|
|
78
|
+
});
|
|
79
|
+
b.addEventListener('mouseenter', () => {
|
|
80
|
+
if (b.disabled) return;
|
|
81
|
+
if (depth === 0 && sub && sub.owner !== b) closeSub();
|
|
82
|
+
b.focus({ preventScroll: true });
|
|
83
|
+
});
|
|
84
|
+
host.appendChild(b);
|
|
85
|
+
rows.push(b);
|
|
86
|
+
}
|
|
87
|
+
return rows;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function enabled(container) {
|
|
91
|
+
return [...container.querySelectorAll(':scope > [role="menuitem"]')].filter((b) => !b.disabled);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function openSub(owner, it) {
|
|
95
|
+
if (sub?.owner === owner) { enabled(sub.el)[0]?.focus({ preventScroll: true }); return; }
|
|
96
|
+
closeSub();
|
|
97
|
+
const box = el('div', 'twm-flow-outline__menu-list twm-flow-outline__menu-sub');
|
|
98
|
+
box.setAttribute('role', 'menu');
|
|
99
|
+
box.setAttribute('aria-label', it.label);
|
|
100
|
+
build(it.items, box, 1);
|
|
101
|
+
owner.setAttribute('aria-expanded', 'true');
|
|
102
|
+
owner.classList.add('twm-flow-outline__menu-item--open');
|
|
103
|
+
owner.after(box);
|
|
104
|
+
// Beside its owner, inside the menu's own box (the popover clips nothing).
|
|
105
|
+
box.style.top = `${owner.offsetTop}px`;
|
|
106
|
+
sub = { owner, el: box };
|
|
107
|
+
enabled(box)[0]?.focus({ preventScroll: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function closeSub() {
|
|
111
|
+
if (!sub) return;
|
|
112
|
+
sub.owner.setAttribute('aria-expanded', 'false');
|
|
113
|
+
sub.owner.classList.remove('twm-flow-outline__menu-item--open');
|
|
114
|
+
const owner = sub.owner;
|
|
115
|
+
sub.el.remove();
|
|
116
|
+
sub = null;
|
|
117
|
+
return owner;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
build(items, root, 0);
|
|
121
|
+
|
|
122
|
+
root.addEventListener('keydown', (ev) => {
|
|
123
|
+
const inSub = sub && sub.el.contains(document.activeElement);
|
|
124
|
+
const box = inSub ? sub.el : root;
|
|
125
|
+
const list = enabled(box);
|
|
126
|
+
const at = list.indexOf(document.activeElement);
|
|
127
|
+
const go = (i) => { ev.preventDefault(); list[Math.max(0, Math.min(list.length - 1, i))]?.focus({ preventScroll: true }); };
|
|
128
|
+
switch (ev.key) {
|
|
129
|
+
case 'ArrowDown': go(at < 0 ? 0 : at + 1); break;
|
|
130
|
+
case 'ArrowUp': go(at < 0 ? list.length - 1 : at - 1); break;
|
|
131
|
+
case 'Home': go(0); break;
|
|
132
|
+
case 'End': go(list.length - 1); break;
|
|
133
|
+
case 'ArrowRight': {
|
|
134
|
+
const b = document.activeElement;
|
|
135
|
+
if (!inSub && b?.getAttribute('aria-haspopup') === 'menu' && !b.disabled) { ev.preventDefault(); b.click(); }
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
case 'ArrowLeft':
|
|
139
|
+
if (inSub) { ev.preventDefault(); closeSub()?.focus({ preventScroll: true }); }
|
|
140
|
+
break;
|
|
141
|
+
case 'Escape':
|
|
142
|
+
if (inSub) { ev.preventDefault(); ev.stopPropagation(); closeSub()?.focus({ preventScroll: true }); }
|
|
143
|
+
break;
|
|
144
|
+
case 'Tab': ev.preventDefault(); break;
|
|
145
|
+
default: break;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const handle = openPopover({
|
|
150
|
+
anchor, content: root, label: label || '', className: 'twm-flow-popover--menu',
|
|
151
|
+
focus: enabled(root)[0] || root, onClose: () => onClose?.(),
|
|
152
|
+
});
|
|
153
|
+
return { el: root, close: () => handle.close('closed'), toggled: handle.toggled };
|
|
154
|
+
}
|