@miragon/modeler-shell 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +2869 -0
- package/LICENSE +21 -0
- package/README.md +566 -2
- package/THIRD_PARTY_NOTICES.md +52 -0
- package/dist/appearance-BBaM_4bD.d.ts +323 -0
- package/dist/appearance-D3FUcbu6.d.cts +53 -0
- package/dist/appearance-D3FUcbu6.d.ts +53 -0
- package/dist/appearance-XX3JC3pI.d.cts +323 -0
- package/dist/chunk-66HCSJNY.js +2792 -0
- package/dist/chunk-66HCSJNY.js.map +1 -0
- package/dist/chunk-KYDG5DMX.js +136 -0
- package/dist/chunk-KYDG5DMX.js.map +1 -0
- package/dist/chunk-N4QXFF3A.js +23 -0
- package/dist/chunk-N4QXFF3A.js.map +1 -0
- package/dist/chunk-OADW3GPA.js +785 -0
- package/dist/chunk-OADW3GPA.js.map +1 -0
- package/dist/core.cjs +873 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.d.cts +246 -0
- package/dist/core.d.ts +246 -0
- package/dist/core.js +131 -0
- package/dist/core.js.map +1 -0
- package/dist/diagram-js.cjs +1147 -0
- package/dist/diagram-js.cjs.map +1 -0
- package/dist/diagram-js.d.cts +170 -0
- package/dist/diagram-js.d.ts +170 -0
- package/dist/diagram-js.js +879 -0
- package/dist/diagram-js.js.map +1 -0
- package/dist/index.cjs +3632 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +29 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +143 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol-DhYBakWL.d.cts +15 -0
- package/dist/protocol-IeW8bf4f.d.ts +15 -0
- package/dist/shell.css +1603 -0
- package/dist/testing-vscode-stub.cjs +332 -0
- package/dist/testing-vscode-stub.cjs.map +1 -0
- package/dist/testing-vscode-stub.d.cts +65 -0
- package/dist/testing-vscode-stub.d.ts +65 -0
- package/dist/testing-vscode-stub.js +154 -0
- package/dist/testing-vscode-stub.js.map +1 -0
- package/dist/testing.cjs +4663 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +231 -0
- package/dist/testing.d.ts +231 -0
- package/dist/testing.js +4483 -0
- package/dist/testing.js.map +1 -0
- package/dist/toasts-Bp0REqdq.d.cts +142 -0
- package/dist/toasts-Bp0REqdq.d.ts +142 -0
- package/dist/types-CnRrINRs.d.cts +445 -0
- package/dist/types-PuDZczzy.d.ts +445 -0
- package/dist/vscode-extension.cjs +215 -0
- package/dist/vscode-extension.cjs.map +1 -0
- package/dist/vscode-extension.d.cts +103 -0
- package/dist/vscode-extension.d.ts +103 -0
- package/dist/vscode-extension.js +24 -0
- package/dist/vscode-extension.js.map +1 -0
- package/dist/vscode-webview.cjs +3561 -0
- package/dist/vscode-webview.cjs.map +1 -0
- package/dist/vscode-webview.d.cts +84 -0
- package/dist/vscode-webview.d.ts +84 -0
- package/dist/vscode-webview.js +259 -0
- package/dist/vscode-webview.js.map +1 -0
- package/package.json +145 -4
- package/src/core/appearance.ts +104 -0
- package/src/core/feedback/appearance.ts +141 -0
- package/src/core/feedback/problems.ts +23 -0
- package/src/core/feedback/toasts.ts +118 -0
- package/src/core/fit.ts +220 -0
- package/src/core/index.ts +127 -0
- package/src/core/keymap.ts +295 -0
- package/src/core/layout.ts +69 -0
- package/src/core/messages.ts +159 -0
- package/src/core/sizes.ts +85 -0
- package/src/core/types.ts +106 -0
- package/src/core/web-chrome/bottom-row.ts +37 -0
- package/src/core/web-chrome/drop.ts +78 -0
- package/src/core/web-chrome/top-row.ts +66 -0
- package/src/core/zoom.ts +74 -0
- package/src/core.ts +2 -0
- package/src/diagram-js/connect.ts +75 -0
- package/src/diagram-js/index.ts +16 -0
- package/src/diagram-js/module.ts +20 -0
- package/src/diagram-js/palette-skin.ts +267 -0
- package/src/diagram-js/palette.css +269 -0
- package/src/diagram-js/service.ts +243 -0
- package/src/diagram-js/types.ts +67 -0
- package/src/diagram-js/viewport.ts +583 -0
- package/src/diagram-js.ts +2 -0
- package/src/dom/buttons.css +124 -0
- package/src/dom/element.ts +51 -0
- package/src/dom/feedback/appearance.ts +184 -0
- package/src/dom/feedback/feedback.css +435 -0
- package/src/dom/feedback/problems-popover.ts +287 -0
- package/src/dom/feedback/toasts.ts +431 -0
- package/src/dom/hint.ts +51 -0
- package/src/dom/icons.ts +309 -0
- package/src/dom/index.ts +42 -0
- package/src/dom/inspector.ts +35 -0
- package/src/dom/keys.ts +67 -0
- package/src/dom/ledger.ts +87 -0
- package/src/dom/legend.ts +85 -0
- package/src/dom/menu.ts +404 -0
- package/src/dom/mount.ts +731 -0
- package/src/dom/problems.ts +73 -0
- package/src/dom/shell-dom.css +969 -0
- package/src/dom/status.ts +72 -0
- package/src/dom/tooltip.ts +237 -0
- package/src/dom/types.ts +459 -0
- package/src/dom/web/drop-zone.ts +181 -0
- package/src/dom/web/empty-state.ts +144 -0
- package/src/dom/web/legal.ts +75 -0
- package/src/dom/web/menu-groups.ts +69 -0
- package/src/dom/web/primary.ts +124 -0
- package/src/dom/web/web.css +386 -0
- package/src/dom/zoom-panel.ts +154 -0
- package/src/index.ts +3 -0
- package/src/shell.css +9 -0
- package/src/testing/case-tools.ts +164 -0
- package/src/testing/cases.ts +1077 -0
- package/src/testing/checks.ts +868 -0
- package/src/testing/facts.ts +641 -0
- package/src/testing/geometry.ts +652 -0
- package/src/testing/harness.ts +187 -0
- package/src/testing/index.ts +19 -0
- package/src/testing/page-tools.ts +457 -0
- package/src/testing/page.ts +27 -0
- package/src/testing/vscode-stub.ts +237 -0
- package/src/testing/web-cases.ts +913 -0
- package/src/testing/web-checks.ts +1049 -0
- package/src/testing/web-facts.ts +713 -0
- package/src/testing-vscode-stub.ts +2 -0
- package/src/testing.ts +2 -0
- package/src/vscode/extension/html.ts +107 -0
- package/src/vscode/extension/index.ts +8 -0
- package/src/vscode/extension/reveal.ts +9 -0
- package/src/vscode/extension/theme-bridge.ts +85 -0
- package/src/vscode/extension/types.ts +36 -0
- package/src/vscode/protocol.ts +31 -0
- package/src/vscode/webview/appearance.ts +141 -0
- package/src/vscode/webview/index.ts +8 -0
- package/src/vscode/webview/mount.ts +88 -0
- package/src/vscode/webview/state.ts +116 -0
- package/src/vscode-extension.ts +2 -0
- package/src/vscode-webview.ts +2 -0
package/src/dom/menu.ts
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { ariaKeyShortcutsFromLabel, matchChromeKey, type ShellMessages } from "../core/index.js";
|
|
2
|
+
import { createElement, toggleAttribute } from "./element.js";
|
|
3
|
+
import { iconElement, type ShellIconName } from "./icons.js";
|
|
4
|
+
import type { TooltipController } from "./tooltip.js";
|
|
5
|
+
import type { MenuEntry, MenuGroups, MenuHandle, MenuRadioGroup } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The fixed groups of guide §5.4 in their order, written as `data-menu-group`. File and Appearance
|
|
9
|
+
* exist in the web host only and come from the shell (`ShellOptions.file`, the Appearance group of
|
|
10
|
+
* a dark-capable modeler); Model, Export and Help from `MenuGroups`.
|
|
11
|
+
*/
|
|
12
|
+
export type MenuGroupName = "file" | "model" | "export" | "appearance" | "help";
|
|
13
|
+
const GROUP_ORDER: readonly MenuGroupName[] = ["file", "model", "export", "appearance", "help"];
|
|
14
|
+
type GroupName = MenuGroupName;
|
|
15
|
+
|
|
16
|
+
function isModelerGroup(name: MenuGroupName): name is keyof MenuGroups {
|
|
17
|
+
return name === "model" || name === "export" || name === "help";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Item {
|
|
21
|
+
/** Stable across renders, so a re-render keeps the focus on the same entry. */
|
|
22
|
+
key: string;
|
|
23
|
+
element: HTMLButtonElement;
|
|
24
|
+
label: string;
|
|
25
|
+
disabled: boolean;
|
|
26
|
+
activate(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MenuContext {
|
|
30
|
+
root: HTMLElement;
|
|
31
|
+
layer: HTMLElement;
|
|
32
|
+
id: string;
|
|
33
|
+
messages: ShellMessages;
|
|
34
|
+
tooltip: TooltipController;
|
|
35
|
+
groups: MenuGroups;
|
|
36
|
+
/**
|
|
37
|
+
* Entries the shell adds to a group, after the modeler's: the File group and the Appearance group
|
|
38
|
+
* (web host), the Legend checkbox at the end of Model.
|
|
39
|
+
*/
|
|
40
|
+
shellEntries(group: MenuGroupName): readonly MenuEntry[];
|
|
41
|
+
isReadOnly(): boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MenuController extends MenuHandle {
|
|
45
|
+
destroy(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isPrintable(event: KeyboardEvent): boolean {
|
|
49
|
+
return event.key.length === 1 && /\S/.test(event.key) && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createMenu(context: MenuContext): MenuController {
|
|
53
|
+
const { root, layer, id, messages, tooltip } = context;
|
|
54
|
+
const ownerDocument = root.ownerDocument;
|
|
55
|
+
let groups = context.groups;
|
|
56
|
+
let items: Item[] = [];
|
|
57
|
+
let destroyed = false;
|
|
58
|
+
|
|
59
|
+
const trigger = createElement(ownerDocument, "button", {
|
|
60
|
+
type: "button",
|
|
61
|
+
class: "cd-shell-menu-trigger cd-shell-island",
|
|
62
|
+
"data-chrome": "menu",
|
|
63
|
+
"aria-label": messages.menu,
|
|
64
|
+
"aria-haspopup": "menu",
|
|
65
|
+
"aria-expanded": "false",
|
|
66
|
+
"aria-controls": id,
|
|
67
|
+
});
|
|
68
|
+
trigger.append(iconElement("menu"));
|
|
69
|
+
|
|
70
|
+
const dropdown = createElement(ownerDocument, "div", {
|
|
71
|
+
class: "cd-shell-menu",
|
|
72
|
+
id,
|
|
73
|
+
role: "menu",
|
|
74
|
+
"aria-label": messages.menu,
|
|
75
|
+
});
|
|
76
|
+
dropdown.hidden = true;
|
|
77
|
+
layer.prepend(dropdown);
|
|
78
|
+
|
|
79
|
+
const isOpen = (): boolean => !dropdown.hidden;
|
|
80
|
+
|
|
81
|
+
function entriesOf(name: GroupName): readonly MenuEntry[] {
|
|
82
|
+
const own = isModelerGroup(name) ? (groups[name] ?? []) : [];
|
|
83
|
+
const added = context.shellEntries(name);
|
|
84
|
+
return added.length > 0 ? [...own, ...added] : own;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hasEntries(): boolean {
|
|
88
|
+
return GROUP_ORDER.some((name) => entriesOf(name).length > 0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function updateTriggerVisibility(): void {
|
|
92
|
+
trigger.hidden = !hasEntries();
|
|
93
|
+
if (trigger.hidden) close();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Guide §13.5: on a read-only canvas the Model entries that change the document are aria-disabled.
|
|
97
|
+
// View-only toggles (the shell's Legend, a modeler's Type captions) opt out and stay usable.
|
|
98
|
+
function isDisabled(entry: MenuEntry, group: GroupName): boolean {
|
|
99
|
+
if (group === "model" && context.isReadOnly() && entry.keepEnabledWhenReadOnly !== true) return true;
|
|
100
|
+
return entry.disabled?.() ?? false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function itemElement(
|
|
104
|
+
role: "menuitem" | "menuitemcheckbox" | "menuitemradio",
|
|
105
|
+
label: string,
|
|
106
|
+
options: {
|
|
107
|
+
icon?: ShellIconName | undefined;
|
|
108
|
+
end?: Node | undefined;
|
|
109
|
+
disabled: boolean;
|
|
110
|
+
checked?: boolean;
|
|
111
|
+
},
|
|
112
|
+
): HTMLButtonElement {
|
|
113
|
+
const iconSlot = createElement(ownerDocument, "span", { class: "cd-shell-menu-item-icon" });
|
|
114
|
+
if (options.icon) iconSlot.append(iconElement(options.icon));
|
|
115
|
+
const element = createElement(
|
|
116
|
+
ownerDocument,
|
|
117
|
+
"button",
|
|
118
|
+
{ type: "button", class: "cd-shell-menu-item", role, tabindex: "-1" },
|
|
119
|
+
iconSlot,
|
|
120
|
+
createElement(ownerDocument, "span", { class: "cd-shell-menu-item-label" }, label),
|
|
121
|
+
options.end && createElement(ownerDocument, "span", { class: "cd-shell-menu-item-end" }, options.end),
|
|
122
|
+
);
|
|
123
|
+
toggleAttribute(element, "aria-disabled", options.disabled && "true");
|
|
124
|
+
if (options.checked !== undefined) element.setAttribute("aria-checked", String(options.checked));
|
|
125
|
+
return element;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function checkMark(): SVGSVGElement {
|
|
129
|
+
return iconElement("check", { className: "cd-shell-menu-check" });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderEntry(entry: Exclude<MenuEntry, MenuRadioGroup>, group: GroupName): Item {
|
|
133
|
+
const disabled = isDisabled(entry, group);
|
|
134
|
+
if (entry.kind === "checkbox") {
|
|
135
|
+
const checked = entry.checked();
|
|
136
|
+
const element = itemElement("menuitemcheckbox", entry.label, {
|
|
137
|
+
icon: entry.icon,
|
|
138
|
+
end: checkMark(),
|
|
139
|
+
disabled,
|
|
140
|
+
checked,
|
|
141
|
+
});
|
|
142
|
+
element.dataset.menuId = entry.id;
|
|
143
|
+
element.dataset.menuGroup = group;
|
|
144
|
+
return {
|
|
145
|
+
key: entry.id,
|
|
146
|
+
element,
|
|
147
|
+
label: entry.label,
|
|
148
|
+
disabled,
|
|
149
|
+
activate() {
|
|
150
|
+
entry.onToggle(!entry.checked());
|
|
151
|
+
refresh();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const shortcut = entry.shortcut
|
|
156
|
+
? createElement(ownerDocument, "span", { class: "cd-shell-menu-shortcut" }, entry.shortcut)
|
|
157
|
+
: undefined;
|
|
158
|
+
const element = itemElement("menuitem", entry.label, { icon: entry.icon, end: shortcut, disabled });
|
|
159
|
+
element.dataset.menuId = entry.id;
|
|
160
|
+
element.dataset.menuGroup = group;
|
|
161
|
+
const keyShortcuts =
|
|
162
|
+
entry.ariaKeyShortcuts ?? (entry.shortcut ? ariaKeyShortcutsFromLabel(entry.shortcut) : undefined);
|
|
163
|
+
toggleAttribute(element, "aria-keyshortcuts", keyShortcuts || null);
|
|
164
|
+
if (entry.kind === "dialog") element.setAttribute("aria-haspopup", "dialog");
|
|
165
|
+
if (entry.kind === "action" && entry.danger) element.dataset.danger = "";
|
|
166
|
+
return {
|
|
167
|
+
key: entry.id,
|
|
168
|
+
element,
|
|
169
|
+
label: entry.label,
|
|
170
|
+
disabled,
|
|
171
|
+
activate() {
|
|
172
|
+
close({ restoreFocus: true });
|
|
173
|
+
entry.onSelect();
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function renderRadioGroup(
|
|
179
|
+
entry: MenuRadioGroup,
|
|
180
|
+
group: GroupName,
|
|
181
|
+
): { element: HTMLElement; items: Item[] } {
|
|
182
|
+
const disabled = isDisabled(entry, group);
|
|
183
|
+
const value = entry.value();
|
|
184
|
+
const container = createElement(
|
|
185
|
+
ownerDocument,
|
|
186
|
+
"div",
|
|
187
|
+
{
|
|
188
|
+
class: "cd-shell-menu-group",
|
|
189
|
+
role: "group",
|
|
190
|
+
"aria-label": entry.label,
|
|
191
|
+
"data-menu-id": entry.id,
|
|
192
|
+
"data-menu-group": group,
|
|
193
|
+
},
|
|
194
|
+
// The group's aria-label carries the heading; a menu holds no loose text.
|
|
195
|
+
createElement(
|
|
196
|
+
ownerDocument,
|
|
197
|
+
"div",
|
|
198
|
+
{ class: "cd-shell-menu-heading", "aria-hidden": "true" },
|
|
199
|
+
entry.label,
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
const radioItems = entry.options.map((option): Item => {
|
|
203
|
+
const element = itemElement("menuitemradio", option.label, {
|
|
204
|
+
end: checkMark(),
|
|
205
|
+
disabled,
|
|
206
|
+
checked: option.value === value,
|
|
207
|
+
});
|
|
208
|
+
element.dataset.menuValue = option.value;
|
|
209
|
+
container.append(element);
|
|
210
|
+
return {
|
|
211
|
+
key: `${entry.id}\u0000${option.value}`,
|
|
212
|
+
element,
|
|
213
|
+
label: option.label,
|
|
214
|
+
disabled,
|
|
215
|
+
activate() {
|
|
216
|
+
if (entry.value() !== option.value) entry.onSelect(option.value);
|
|
217
|
+
refresh();
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
return { element: container, items: radioItems };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function render(): void {
|
|
225
|
+
const focusedKey = items.find((item) => item.element === ownerDocument.activeElement)?.key;
|
|
226
|
+
const nodes: HTMLElement[] = [];
|
|
227
|
+
const rendered: Item[] = [];
|
|
228
|
+
for (const name of GROUP_ORDER) {
|
|
229
|
+
const entries = entriesOf(name);
|
|
230
|
+
if (entries.length === 0) continue;
|
|
231
|
+
if (nodes.length > 0) {
|
|
232
|
+
nodes.push(
|
|
233
|
+
createElement(ownerDocument, "div", { class: "cd-shell-menu-separator", role: "separator" }),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
for (const entry of entries) {
|
|
237
|
+
if (entry.kind === "radio") {
|
|
238
|
+
const radio = renderRadioGroup(entry, name);
|
|
239
|
+
nodes.push(radio.element);
|
|
240
|
+
rendered.push(...radio.items);
|
|
241
|
+
} else {
|
|
242
|
+
const item = renderEntry(entry, name);
|
|
243
|
+
nodes.push(item.element);
|
|
244
|
+
rendered.push(item);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (const item of rendered) {
|
|
249
|
+
item.element.addEventListener("click", () => {
|
|
250
|
+
if (!item.disabled) item.activate();
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
dropdown.replaceChildren(...nodes);
|
|
254
|
+
items = rendered;
|
|
255
|
+
if (focusedKey !== undefined && isOpen()) {
|
|
256
|
+
(items.find((item) => item.key === focusedKey) ?? items[0])?.element.focus();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function focusAt(index: number): void {
|
|
261
|
+
if (items.length === 0) return;
|
|
262
|
+
items[(index + items.length) % items.length]?.element.focus();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function currentIndex(): number {
|
|
266
|
+
return items.findIndex((item) => item.element === ownerDocument.activeElement);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function typeAhead(character: string): void {
|
|
270
|
+
const start = currentIndex();
|
|
271
|
+
const lower = character.toLowerCase();
|
|
272
|
+
for (let step = 1; step <= items.length; step++) {
|
|
273
|
+
const index = (Math.max(start, -1) + step) % items.length;
|
|
274
|
+
if (items[index]?.label.toLowerCase().startsWith(lower)) {
|
|
275
|
+
focusAt(index);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const onOutsidePointerDown = (event: PointerEvent): void => {
|
|
282
|
+
const target = event.target as Node | null;
|
|
283
|
+
if (target && (dropdown.contains(target) || trigger.contains(target))) return;
|
|
284
|
+
close();
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// The focus left the document (a click into the VS Code workbench, another iframe): from the
|
|
288
|
+
// user's view a click outside, so the menu closes without moving the focus (guide §5.4).
|
|
289
|
+
const onWindowBlur = (): void => close();
|
|
290
|
+
|
|
291
|
+
function open(focus: "first" | "last" = "first"): void {
|
|
292
|
+
if (destroyed || !hasEntries()) return;
|
|
293
|
+
tooltip.hide();
|
|
294
|
+
const wasOpen = isOpen();
|
|
295
|
+
render();
|
|
296
|
+
if (!wasOpen) {
|
|
297
|
+
dropdown.hidden = false;
|
|
298
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
299
|
+
// Capture phase: diagram-js stops pointer events on palette, context pad and canvas.
|
|
300
|
+
ownerDocument.addEventListener("pointerdown", onOutsidePointerDown, true);
|
|
301
|
+
ownerDocument.defaultView?.addEventListener("blur", onWindowBlur);
|
|
302
|
+
}
|
|
303
|
+
focusAt(focus === "last" ? items.length - 1 : 0);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function close(options: { restoreFocus?: boolean } = {}): void {
|
|
307
|
+
if (!isOpen()) return;
|
|
308
|
+
dropdown.hidden = true;
|
|
309
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
310
|
+
ownerDocument.removeEventListener("pointerdown", onOutsidePointerDown, true);
|
|
311
|
+
ownerDocument.defaultView?.removeEventListener("blur", onWindowBlur);
|
|
312
|
+
if (options.restoreFocus && !trigger.hidden) trigger.focus();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function refresh(): void {
|
|
316
|
+
if (isOpen()) render();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
trigger.addEventListener("click", () => {
|
|
320
|
+
if (isOpen()) close();
|
|
321
|
+
else open("first");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Keys the menu consumes stop here, in every host like the palette's: a modeler's keyboard on
|
|
325
|
+
// document (Event Storming moves the selection on ↑/↓, deletes it on Delete, creates a sticky on
|
|
326
|
+
// E) must never act on them, and VS Code never sees them.
|
|
327
|
+
function consume(event: KeyboardEvent): void {
|
|
328
|
+
event.preventDefault();
|
|
329
|
+
event.stopPropagation();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
trigger.addEventListener("keydown", (event) => {
|
|
333
|
+
if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") {
|
|
334
|
+
consume(event);
|
|
335
|
+
open("first");
|
|
336
|
+
} else if (event.key === "ArrowUp") {
|
|
337
|
+
consume(event);
|
|
338
|
+
open("last");
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
dropdown.addEventListener("keydown", (event) => {
|
|
343
|
+
// Zoom and fit keys still act with the menu open; the shell's key listener runs them.
|
|
344
|
+
if (matchChromeKey(event)) return;
|
|
345
|
+
switch (event.key) {
|
|
346
|
+
case "ArrowDown":
|
|
347
|
+
focusAt(currentIndex() + 1);
|
|
348
|
+
break;
|
|
349
|
+
case "ArrowUp": {
|
|
350
|
+
const index = currentIndex();
|
|
351
|
+
focusAt(index < 0 ? items.length - 1 : index - 1);
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
case "Home":
|
|
355
|
+
focusAt(0);
|
|
356
|
+
break;
|
|
357
|
+
case "End":
|
|
358
|
+
focusAt(items.length - 1);
|
|
359
|
+
break;
|
|
360
|
+
case "Tab":
|
|
361
|
+
// The browser's Tab runs after this handler from wherever the focus is then: from the
|
|
362
|
+
// trigger it moves on to the palette instead of leaving the shell from the layer.
|
|
363
|
+
close({ restoreFocus: true });
|
|
364
|
+
return;
|
|
365
|
+
case "Escape":
|
|
366
|
+
// The shell's key listener closes the menu (and keeps Escape from VS Code in the IDE host).
|
|
367
|
+
return;
|
|
368
|
+
case "Enter":
|
|
369
|
+
case " ":
|
|
370
|
+
case "Delete":
|
|
371
|
+
case "Backspace":
|
|
372
|
+
// The entry's native click handles Enter and Space; nothing else may act on these keys.
|
|
373
|
+
event.stopPropagation();
|
|
374
|
+
return;
|
|
375
|
+
default:
|
|
376
|
+
if (!isPrintable(event)) return;
|
|
377
|
+
typeAhead(event.key);
|
|
378
|
+
}
|
|
379
|
+
consume(event);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
updateTriggerVisibility();
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
trigger,
|
|
386
|
+
dropdown,
|
|
387
|
+
isOpen,
|
|
388
|
+
open,
|
|
389
|
+
close,
|
|
390
|
+
refresh,
|
|
391
|
+
setGroups(next) {
|
|
392
|
+
groups = next;
|
|
393
|
+
updateTriggerVisibility();
|
|
394
|
+
refresh();
|
|
395
|
+
},
|
|
396
|
+
destroy() {
|
|
397
|
+
if (destroyed) return;
|
|
398
|
+
close();
|
|
399
|
+
destroyed = true;
|
|
400
|
+
trigger.remove();
|
|
401
|
+
dropdown.remove();
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|