@miragon/modeler-shell 0.1.0 → 0.2.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/API.md +1051 -84
- package/README.md +162 -31
- package/dist/appearance-BBaM_4bD.d.ts +323 -0
- package/dist/appearance-XX3JC3pI.d.cts +323 -0
- package/dist/{chunk-6LQOFACK.js → chunk-A4YGKH4Y.js} +1252 -31
- package/dist/chunk-A4YGKH4Y.js.map +1 -0
- package/dist/{chunk-RJRFR6N3.js → chunk-KYDG5DMX.js} +2 -2
- package/dist/{chunk-5JSZ4VGA.js → chunk-OADW3GPA.js} +240 -6
- package/dist/chunk-OADW3GPA.js.map +1 -0
- package/dist/core.cjs +260 -6
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +81 -6
- package/dist/core.d.ts +81 -6
- package/dist/core.js +43 -3
- package/dist/diagram-js.cjs +34 -2
- package/dist/diagram-js.cjs.map +1 -1
- package/dist/diagram-js.d.cts +10 -3
- package/dist/diagram-js.d.ts +10 -3
- package/dist/diagram-js.js +14 -2
- package/dist/diagram-js.js.map +1 -1
- package/dist/index.cjs +1658 -196
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -9
- package/dist/index.d.ts +18 -9
- package/dist/index.js +44 -4
- package/dist/shell.css +692 -3
- package/dist/testing-vscode-stub.js +2 -2
- package/dist/testing.cjs +2849 -550
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +75 -9
- package/dist/testing.d.ts +75 -9
- package/dist/testing.js +2744 -558
- package/dist/testing.js.map +1 -1
- package/dist/toasts-Bp0REqdq.d.cts +142 -0
- package/dist/toasts-Bp0REqdq.d.ts +142 -0
- package/dist/{types-OuFlqZp0.d.cts → types-CnRrINRs.d.cts} +170 -9
- package/dist/{types-DnZn9CPw.d.ts → types-PuDZczzy.d.ts} +170 -9
- package/dist/vscode-extension.js +2 -2
- package/dist/vscode-webview.cjs +1546 -178
- package/dist/vscode-webview.cjs.map +1 -1
- package/dist/vscode-webview.d.cts +6 -4
- package/dist/vscode-webview.d.ts +6 -4
- package/dist/vscode-webview.js +11 -2
- package/dist/vscode-webview.js.map +1 -1
- package/package.json +2 -2
- 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 +18 -3
- package/src/core/index.ts +43 -0
- package/src/core/keymap.ts +20 -0
- package/src/core/messages.ts +71 -0
- package/src/core/types.ts +24 -2
- 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/diagram-js/service.ts +4 -1
- package/src/diagram-js/viewport.ts +19 -0
- package/src/dom/buttons.css +124 -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/index.ts +13 -0
- package/src/dom/keys.ts +8 -0
- package/src/dom/menu.ts +28 -17
- package/src/dom/mount.ts +290 -20
- package/src/dom/problems.ts +5 -4
- package/src/dom/shell-dom.css +7 -4
- package/src/dom/tooltip.ts +10 -0
- package/src/dom/types.ts +188 -6
- 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/shell.css +3 -0
- package/src/testing/case-tools.ts +164 -0
- package/src/testing/cases.ts +209 -144
- package/src/testing/checks.ts +51 -15
- package/src/testing/facts.ts +56 -2
- package/src/testing/geometry.ts +206 -26
- package/src/testing/harness.ts +56 -7
- package/src/testing/index.ts +4 -0
- package/src/testing/page-tools.ts +24 -1
- package/src/testing/page.ts +10 -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/vscode/webview/mount.ts +13 -1
- package/dist/chunk-5JSZ4VGA.js.map +0 -1
- package/dist/chunk-6LQOFACK.js.map +0 -1
- package/dist/keymap-CObQE6q6.d.cts +0 -151
- package/dist/keymap-DORzHnK-.d.ts +0 -151
- package/dist/types-B6NfkiUG.d.cts +0 -66
- package/dist/types-B6NfkiUG.d.ts +0 -66
- /package/dist/{chunk-RJRFR6N3.js.map → chunk-KYDG5DMX.js.map} +0 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_TOASTS,
|
|
3
|
+
REDUCED_MOTION_CLASS,
|
|
4
|
+
toastLifetime,
|
|
5
|
+
toastToEvict,
|
|
6
|
+
type ShellHost,
|
|
7
|
+
type ShellMessages,
|
|
8
|
+
type ToastAction,
|
|
9
|
+
type ToastRequest,
|
|
10
|
+
type ToastSlot,
|
|
11
|
+
} from "../../core/index.js";
|
|
12
|
+
import { createElement } from "../element.js";
|
|
13
|
+
import { iconElement } from "../icons.js";
|
|
14
|
+
import type { ToastHandle } from "../types.js";
|
|
15
|
+
|
|
16
|
+
export interface ToasterContext {
|
|
17
|
+
ownerDocument: Document;
|
|
18
|
+
/** `web`: the stack is a `popover="manual"` raised on every toast; `embedded`: absolute in the root. */
|
|
19
|
+
host: Exclude<ShellHost, "ide">;
|
|
20
|
+
messages: ShellMessages;
|
|
21
|
+
/** `cd-shell-toast-N`: the prefix of the ids the Close buttons' `aria-describedby` names. */
|
|
22
|
+
idPrefix: string;
|
|
23
|
+
/** Where the focus goes when a closing toast held it and no other toast can take it. */
|
|
24
|
+
focusFallback(): void;
|
|
25
|
+
/** A toast came or went: the widest card, and with it the stack's bottom, may have changed. */
|
|
26
|
+
onChange?: (() => void) | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Toaster {
|
|
30
|
+
/** The stack `<div class="cd-shell-toasts" data-chrome="toasts">`, always in the DOM, empty = no box. */
|
|
31
|
+
readonly element: HTMLElement;
|
|
32
|
+
/**
|
|
33
|
+
* `<div class="cd-shell-live">` with the two live regions, always rendered and visually hidden,
|
|
34
|
+
* outside the stack: `role="status"` (polite) and `role="alert"` (danger). The shell puts it in the
|
|
35
|
+
* root, where it stays in the accessibility tree while the stack is empty or closed.
|
|
36
|
+
*/
|
|
37
|
+
readonly announcer: HTMLElement;
|
|
38
|
+
show(request: ToastRequest): ToastHandle;
|
|
39
|
+
/** The stack's `bottom` in px from `toastStackBottom`, set by the shell's layout pass. */
|
|
40
|
+
setBottom(bottom: number): void;
|
|
41
|
+
/** Rendered width of the widest card in the stack; `undefined` while it is empty. */
|
|
42
|
+
widestCard(): number | undefined;
|
|
43
|
+
destroy(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A paused toast resumes with at least this long, so it does not vanish the moment the pointer leaves. */
|
|
47
|
+
export const TOAST_RESUME_MIN_MS = 1500;
|
|
48
|
+
|
|
49
|
+
/** The leave animation runs `--cd-motion-base` (250ms); the toast goes after this even without `animationend`. */
|
|
50
|
+
export const TOAST_LEAVE_FALLBACK_MS = 400;
|
|
51
|
+
|
|
52
|
+
/** The keyframes of feedback.css the leaving toast runs. */
|
|
53
|
+
const LEAVE_ANIMATION = "cd-shell-toast-out";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A live region announces a change of its content, not content it was created with; the text
|
|
57
|
+
* arrives this long after the region was emptied, so a repeated toast is announced again too.
|
|
58
|
+
*/
|
|
59
|
+
export const TOAST_ANNOUNCE_DELAY_MS = 100;
|
|
60
|
+
|
|
61
|
+
const INERT_HANDLE: ToastHandle = Object.freeze({ dismiss() {} });
|
|
62
|
+
|
|
63
|
+
interface LiveToast {
|
|
64
|
+
readonly id: number;
|
|
65
|
+
readonly element: HTMLElement;
|
|
66
|
+
readonly closeButton: HTMLButtonElement;
|
|
67
|
+
readonly handle: ToastHandle;
|
|
68
|
+
request: ToastRequest;
|
|
69
|
+
actionButton: HTMLButtonElement | undefined;
|
|
70
|
+
hovered: boolean;
|
|
71
|
+
focused: boolean;
|
|
72
|
+
/** Milliseconds left while paused, the whole lifetime while running; `undefined` never closes. */
|
|
73
|
+
remaining: number | undefined;
|
|
74
|
+
startedAt: number;
|
|
75
|
+
timer: ReturnType<typeof setTimeout> | undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The toast stack (guide §8, §9): centred at `bottom` (`toastStackBottom`), `toastStackWidth`
|
|
80
|
+
* wide, column with 8px gap, newest at the bottom, layer 60 (`--cd-shell-ebene-toast`). In the
|
|
81
|
+
* web host the stack is a manual popover, re-shown on every toast, so it stays above a
|
|
82
|
+
* `showModal()` dialog in the top layer; in the embedded host it is absolute inside the root.
|
|
83
|
+
*
|
|
84
|
+
* A toast: `<div class="cd-shell-toast" data-status>`, opaque `--cd-ui-flaeche-hoch`, 1px
|
|
85
|
+
* `--cd-ui-linie`, a straight 4px bar and an 8px dot in `--cd-ui-<status>`, radius `md`,
|
|
86
|
+
* `shadow-3`, at least 40px high, padding `4px 4px 4px 16px`, gap 8px, text 13px/1.5
|
|
87
|
+
* `--cd-ui-text`; then the action as a 32px ghost button and Close as a 32 × 32 ghost icon button
|
|
88
|
+
* (Lucide `x`, `aria-label` `messages.close`, `aria-describedby` the toast's text). Slide and fade
|
|
89
|
+
* in from `translateY(8px)` (250ms), out the same way; none under reduced motion.
|
|
90
|
+
*
|
|
91
|
+
* Screen readers hear a toast through the two persistent live regions of `announcer` (guide §8,
|
|
92
|
+
* §12): its text goes to `role="alert"` for danger, else to `role="status"` (`aria-live="polite"`),
|
|
93
|
+
* `TOAST_ANNOUNCE_DELAY_MS` after the region was emptied; a repeat is announced again. The cards
|
|
94
|
+
* themselves carry no live role, so nothing is read twice and re-showing the stack re-announces
|
|
95
|
+
* nothing. The region empties again when its toast closes.
|
|
96
|
+
*
|
|
97
|
+
* Lifetime `toastLifetime(request)`, paused while hovered or focused (and resumed with at least
|
|
98
|
+
* 1.5 s left); a toast with an action never closes by itself. A fourth toast evicts per
|
|
99
|
+
* `toastToEvict`. A request equal to a shown toast (status and text) restarts that toast's timer
|
|
100
|
+
* instead of stacking a second; its action and lifetime become the new request's, so an "Undo"
|
|
101
|
+
* always undoes the latest change. An action click closes the toast, then runs `onSelect`. A
|
|
102
|
+
* closing toast that held the focus hands it to the next toast's Close button, else
|
|
103
|
+
* `focusFallback()`.
|
|
104
|
+
*/
|
|
105
|
+
export function createToaster(context: ToasterContext): Toaster {
|
|
106
|
+
const { ownerDocument, messages } = context;
|
|
107
|
+
const web = context.host === "web";
|
|
108
|
+
const element = createElement(ownerDocument, "div", {
|
|
109
|
+
class: "cd-shell-toasts",
|
|
110
|
+
"data-chrome": "toasts",
|
|
111
|
+
popover: web ? "manual" : undefined,
|
|
112
|
+
});
|
|
113
|
+
const liveRegion = (politeness: "polite" | "assertive"): HTMLElement =>
|
|
114
|
+
createElement(ownerDocument, "div", {
|
|
115
|
+
class: "cd-shell-live-region",
|
|
116
|
+
"data-live": politeness,
|
|
117
|
+
role: politeness === "polite" ? "status" : "alert",
|
|
118
|
+
"aria-live": politeness,
|
|
119
|
+
"aria-atomic": "true",
|
|
120
|
+
});
|
|
121
|
+
const politeRegion = liveRegion("polite");
|
|
122
|
+
const assertiveRegion = liveRegion("assertive");
|
|
123
|
+
const announcer = createElement(
|
|
124
|
+
ownerDocument,
|
|
125
|
+
"div",
|
|
126
|
+
{ class: "cd-shell-live" },
|
|
127
|
+
politeRegion,
|
|
128
|
+
assertiveRegion,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
/** Shown toasts, oldest first; a leaving toast is no longer one of them. */
|
|
132
|
+
const live: LiveToast[] = [];
|
|
133
|
+
const leaveTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
134
|
+
const announceTimers = new Map<HTMLElement, ReturnType<typeof setTimeout>>();
|
|
135
|
+
let lastId = 0;
|
|
136
|
+
let destroyed = false;
|
|
137
|
+
|
|
138
|
+
function regionFor(request: ToastRequest): HTMLElement {
|
|
139
|
+
return request.status === "danger" ? assertiveRegion : politeRegion;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function announce(request: ToastRequest): void {
|
|
143
|
+
const region = regionFor(request);
|
|
144
|
+
clearTimeout(announceTimers.get(region));
|
|
145
|
+
region.textContent = "";
|
|
146
|
+
announceTimers.set(
|
|
147
|
+
region,
|
|
148
|
+
setTimeout(() => {
|
|
149
|
+
announceTimers.delete(region);
|
|
150
|
+
if (!destroyed) region.textContent = request.text;
|
|
151
|
+
}, TOAST_ANNOUNCE_DELAY_MS),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A closed toast's text stays out of the regions, where a screen reader's reading cursor finds it.
|
|
156
|
+
function silence(request: ToastRequest): void {
|
|
157
|
+
const region = regionFor(request);
|
|
158
|
+
if (region.textContent === request.text) region.textContent = "";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function changed(): void {
|
|
162
|
+
if (!destroyed) context.onChange?.();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function supportsPopover(): boolean {
|
|
166
|
+
return web && typeof element.showPopover === "function" && typeof element.hidePopover === "function";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function popoverOpen(): boolean {
|
|
170
|
+
try {
|
|
171
|
+
return element.matches(":popover-open");
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function modalOpen(): boolean {
|
|
178
|
+
try {
|
|
179
|
+
return ownerDocument.querySelector(":modal") !== null;
|
|
180
|
+
} catch {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Showing a popover again puts it on top of the top layer, above a modal dialog opened since. An
|
|
186
|
+
// open stack without a modal dialog stays as it is: hiding it would rebuild it for screen readers.
|
|
187
|
+
function raise(): void {
|
|
188
|
+
if (!supportsPopover() || !element.isConnected) return;
|
|
189
|
+
if (popoverOpen() && !modalOpen()) return;
|
|
190
|
+
const focused = ownerDocument.activeElement as HTMLElement | null;
|
|
191
|
+
const keepFocus = focused && element.contains(focused) ? focused : undefined;
|
|
192
|
+
try {
|
|
193
|
+
element.hidePopover();
|
|
194
|
+
element.showPopover();
|
|
195
|
+
} catch {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (keepFocus && ownerDocument.activeElement !== keepFocus) keepFocus.focus({ preventScroll: true });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function lowerWhenEmpty(): void {
|
|
202
|
+
if (element.childElementCount > 0 || !supportsPopover()) return;
|
|
203
|
+
try {
|
|
204
|
+
element.hidePopover();
|
|
205
|
+
} catch {
|
|
206
|
+
// Not shown (or never connected): nothing to hide.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function engaged(toast: LiveToast): boolean {
|
|
211
|
+
return toast.hovered || toast.focused;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function stopTimer(toast: LiveToast): void {
|
|
215
|
+
clearTimeout(toast.timer);
|
|
216
|
+
toast.timer = undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function runTimer(toast: LiveToast): void {
|
|
220
|
+
stopTimer(toast);
|
|
221
|
+
if (toast.remaining === undefined || engaged(toast)) return;
|
|
222
|
+
toast.startedAt = Date.now();
|
|
223
|
+
toast.timer = setTimeout(() => close(toast), toast.remaining);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function pause(toast: LiveToast): void {
|
|
227
|
+
if (toast.timer === undefined) return;
|
|
228
|
+
stopTimer(toast);
|
|
229
|
+
if (toast.remaining !== undefined) {
|
|
230
|
+
toast.remaining = Math.max(0, toast.remaining - (Date.now() - toast.startedAt));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function setEngagement(toast: LiveToast, hovered: boolean, focused: boolean): void {
|
|
235
|
+
if (!live.includes(toast)) return;
|
|
236
|
+
const wasEngaged = engaged(toast);
|
|
237
|
+
toast.hovered = hovered;
|
|
238
|
+
toast.focused = focused;
|
|
239
|
+
if (engaged(toast) === wasEngaged) return;
|
|
240
|
+
if (wasEngaged) {
|
|
241
|
+
if (toast.remaining !== undefined) toast.remaining = Math.max(toast.remaining, TOAST_RESUME_MIN_MS);
|
|
242
|
+
runTimer(toast);
|
|
243
|
+
} else {
|
|
244
|
+
pause(toast);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function reducedMotion(): boolean {
|
|
249
|
+
if (ownerDocument.documentElement.classList.contains(REDUCED_MOTION_CLASS)) return true;
|
|
250
|
+
const view = ownerDocument.defaultView;
|
|
251
|
+
return (
|
|
252
|
+
typeof view?.matchMedia === "function" && view.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function removeElement(toastElement: HTMLElement): void {
|
|
257
|
+
toastElement.remove();
|
|
258
|
+
lowerWhenEmpty();
|
|
259
|
+
changed();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function leave(toastElement: HTMLElement): void {
|
|
263
|
+
toastElement.setAttribute("data-leaving", "");
|
|
264
|
+
// Nothing on its way out takes a click or the focus.
|
|
265
|
+
toastElement.inert = true;
|
|
266
|
+
if (reducedMotion()) {
|
|
267
|
+
removeElement(toastElement);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
let done = false;
|
|
271
|
+
const finish = (): void => {
|
|
272
|
+
if (done) return;
|
|
273
|
+
done = true;
|
|
274
|
+
clearTimeout(fallback);
|
|
275
|
+
leaveTimers.delete(fallback);
|
|
276
|
+
removeElement(toastElement);
|
|
277
|
+
};
|
|
278
|
+
toastElement.addEventListener("animationend", (event) => {
|
|
279
|
+
if (event.target === toastElement && event.animationName === LEAVE_ANIMATION) finish();
|
|
280
|
+
});
|
|
281
|
+
const fallback = setTimeout(finish, TOAST_LEAVE_FALLBACK_MS);
|
|
282
|
+
leaveTimers.add(fallback);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function close(toast: LiveToast): void {
|
|
286
|
+
const index = live.indexOf(toast);
|
|
287
|
+
if (index < 0) return;
|
|
288
|
+
live.splice(index, 1);
|
|
289
|
+
stopTimer(toast);
|
|
290
|
+
silence(toast.request);
|
|
291
|
+
const heldFocus = toast.element.contains(ownerDocument.activeElement);
|
|
292
|
+
if (heldFocus) {
|
|
293
|
+
const next = live[index] ?? live[index - 1];
|
|
294
|
+
if (next) next.closeButton.focus({ preventScroll: true });
|
|
295
|
+
else context.focusFallback();
|
|
296
|
+
}
|
|
297
|
+
leave(toast.element);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function setAction(toast: LiveToast, action: ToastAction | undefined): void {
|
|
301
|
+
const previous = toast.actionButton;
|
|
302
|
+
const hadFocus = previous !== undefined && ownerDocument.activeElement === previous;
|
|
303
|
+
previous?.remove();
|
|
304
|
+
toast.actionButton = undefined;
|
|
305
|
+
if (action) {
|
|
306
|
+
const button = createElement(
|
|
307
|
+
ownerDocument,
|
|
308
|
+
"button",
|
|
309
|
+
{ type: "button", class: "cd-shell-toast-action cd-shell-button", "data-variant": "ghost" },
|
|
310
|
+
action.label,
|
|
311
|
+
);
|
|
312
|
+
button.addEventListener("click", () => {
|
|
313
|
+
close(toast);
|
|
314
|
+
action.onSelect();
|
|
315
|
+
});
|
|
316
|
+
toast.closeButton.before(button);
|
|
317
|
+
toast.actionButton = button;
|
|
318
|
+
}
|
|
319
|
+
if (hadFocus) (toast.actionButton ?? toast.closeButton).focus({ preventScroll: true });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function slotOf(toast: LiveToast): ToastSlot {
|
|
323
|
+
return { id: toast.id, hasAction: toast.request.action !== undefined, engaged: engaged(toast) };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function render(request: ToastRequest): LiveToast {
|
|
327
|
+
const id = ++lastId;
|
|
328
|
+
const textId = `${context.idPrefix}-${id}`;
|
|
329
|
+
// Three Close buttons in a stack are told apart by the text of their toast.
|
|
330
|
+
const closeButton = createElement(
|
|
331
|
+
ownerDocument,
|
|
332
|
+
"button",
|
|
333
|
+
{
|
|
334
|
+
type: "button",
|
|
335
|
+
class: "cd-shell-toast-close",
|
|
336
|
+
"aria-label": messages.close,
|
|
337
|
+
"aria-describedby": textId,
|
|
338
|
+
},
|
|
339
|
+
iconElement("x"),
|
|
340
|
+
);
|
|
341
|
+
const toastElement = createElement(
|
|
342
|
+
ownerDocument,
|
|
343
|
+
"div",
|
|
344
|
+
{ class: "cd-shell-toast", "data-status": request.status },
|
|
345
|
+
createElement(ownerDocument, "span", { class: "cd-shell-toast-dot", "aria-hidden": "true" }),
|
|
346
|
+
createElement(ownerDocument, "span", { class: "cd-shell-toast-text", id: textId }, request.text),
|
|
347
|
+
closeButton,
|
|
348
|
+
);
|
|
349
|
+
const toast: LiveToast = {
|
|
350
|
+
id,
|
|
351
|
+
element: toastElement,
|
|
352
|
+
closeButton,
|
|
353
|
+
handle: Object.freeze({ dismiss: () => close(toast) }),
|
|
354
|
+
request,
|
|
355
|
+
actionButton: undefined,
|
|
356
|
+
hovered: false,
|
|
357
|
+
focused: false,
|
|
358
|
+
remaining: toastLifetime(request),
|
|
359
|
+
startedAt: Date.now(),
|
|
360
|
+
timer: undefined,
|
|
361
|
+
};
|
|
362
|
+
setAction(toast, request.action);
|
|
363
|
+
closeButton.addEventListener("click", () => close(toast));
|
|
364
|
+
toastElement.addEventListener("mouseenter", () => setEngagement(toast, true, toast.focused));
|
|
365
|
+
toastElement.addEventListener("mouseleave", () => setEngagement(toast, false, toast.focused));
|
|
366
|
+
toastElement.addEventListener("focusin", () => setEngagement(toast, toast.hovered, true));
|
|
367
|
+
toastElement.addEventListener("focusout", (event) =>
|
|
368
|
+
setEngagement(toast, toast.hovered, toastElement.contains(event.relatedTarget as Node | null)),
|
|
369
|
+
);
|
|
370
|
+
return toast;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function show(request: ToastRequest): ToastHandle {
|
|
374
|
+
if (destroyed) return INERT_HANDLE;
|
|
375
|
+
const same = live.find(
|
|
376
|
+
(toast) => toast.request.status === request.status && toast.request.text === request.text,
|
|
377
|
+
);
|
|
378
|
+
if (same) {
|
|
379
|
+
same.request = request;
|
|
380
|
+
setAction(same, request.action);
|
|
381
|
+
same.remaining = toastLifetime(request);
|
|
382
|
+
runTimer(same);
|
|
383
|
+
raise();
|
|
384
|
+
announce(request);
|
|
385
|
+
changed();
|
|
386
|
+
return same.handle;
|
|
387
|
+
}
|
|
388
|
+
while (live.length >= MAX_TOASTS) {
|
|
389
|
+
const evictedId = toastToEvict(live.map(slotOf));
|
|
390
|
+
const evicted = live.find((toast) => toast.id === evictedId) ?? live[0];
|
|
391
|
+
if (!evicted) break;
|
|
392
|
+
close(evicted);
|
|
393
|
+
}
|
|
394
|
+
const toast = render(request);
|
|
395
|
+
live.push(toast);
|
|
396
|
+
element.append(toast.element);
|
|
397
|
+
raise();
|
|
398
|
+
runTimer(toast);
|
|
399
|
+
announce(request);
|
|
400
|
+
changed();
|
|
401
|
+
return toast.handle;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
element,
|
|
406
|
+
announcer,
|
|
407
|
+
show,
|
|
408
|
+
setBottom(bottom) {
|
|
409
|
+
element.style.bottom = `${bottom}px`;
|
|
410
|
+
},
|
|
411
|
+
widestCard() {
|
|
412
|
+
let widest: number | undefined;
|
|
413
|
+
for (const card of element.children) {
|
|
414
|
+
const width = card.getBoundingClientRect().width;
|
|
415
|
+
if (width > 0) widest = Math.max(widest ?? 0, width);
|
|
416
|
+
}
|
|
417
|
+
return widest;
|
|
418
|
+
},
|
|
419
|
+
destroy() {
|
|
420
|
+
if (destroyed) return;
|
|
421
|
+
destroyed = true;
|
|
422
|
+
for (const toast of live.splice(0)) stopTimer(toast);
|
|
423
|
+
for (const timer of leaveTimers) clearTimeout(timer);
|
|
424
|
+
leaveTimers.clear();
|
|
425
|
+
for (const timer of announceTimers.values()) clearTimeout(timer);
|
|
426
|
+
announceTimers.clear();
|
|
427
|
+
element.remove();
|
|
428
|
+
announcer.remove();
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
package/src/dom/index.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
export type {
|
|
2
|
+
AppearanceHandle,
|
|
3
|
+
DropZoneOptions,
|
|
2
4
|
EmptyHintOptions,
|
|
5
|
+
EmptyStateOptions,
|
|
6
|
+
FileMenuAction,
|
|
7
|
+
FileMenuOptions,
|
|
8
|
+
LegalLineOptions,
|
|
9
|
+
LegalLink,
|
|
10
|
+
PrimaryActionHandle,
|
|
11
|
+
PrimaryActionOptions,
|
|
12
|
+
StorageLike,
|
|
13
|
+
ToastHandle,
|
|
14
|
+
WebAppearanceOptions,
|
|
3
15
|
InspectorHandle,
|
|
4
16
|
InspectorOptions,
|
|
5
17
|
LegendHandle,
|
|
@@ -23,6 +35,7 @@ export type {
|
|
|
23
35
|
TooltipContent,
|
|
24
36
|
TooltipHandle,
|
|
25
37
|
} from "./types.js";
|
|
38
|
+
export type { MenuGroupName } from "./menu.js";
|
|
26
39
|
export { mountShell } from "./mount.js";
|
|
27
40
|
|
|
28
41
|
export type { IconNode, IconOptions, ShellIconName } from "./icons.js";
|
package/src/dom/keys.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface KeyListenerContext {
|
|
|
5
5
|
/** Where dialogs and popovers count: the document in an own document, else the shell root. */
|
|
6
6
|
dialogScope: ParentNode;
|
|
7
7
|
menuOpen(): boolean;
|
|
8
|
+
/** The web app's start screen shows: the zoom keys belong to the browser (`KeyContext.startScreen`). */
|
|
9
|
+
startScreen?: (() => boolean) | undefined;
|
|
8
10
|
closeMenu(): void;
|
|
9
11
|
hideTooltip(): void;
|
|
10
12
|
run(key: ChromeKey): void;
|
|
@@ -38,6 +40,11 @@ function openDialogs(scope: ParentNode): Element[] {
|
|
|
38
40
|
return [...scope.querySelectorAll('[role="dialog"], [role="alertdialog"], dialog')].filter(isShown);
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/** A shown modal dialog in `scope`, by the keys' own test (the drop zone forbids drops meanwhile). */
|
|
44
|
+
export function isModalOpen(scope: ParentNode): boolean {
|
|
45
|
+
return openDialogs(scope).some(isModal);
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
/** The shell's one `keydown` listener (bubble phase): applies `decideKey` (guide §5.8, §13.5). */
|
|
42
49
|
export function createKeyListener(context: KeyListenerContext): (event: KeyboardEvent) => void {
|
|
43
50
|
return (event) => {
|
|
@@ -50,6 +57,7 @@ export function createKeyListener(context: KeyListenerContext): (event: Keyboard
|
|
|
50
57
|
dialogOpen: dialogs.length > 0,
|
|
51
58
|
modalOpen: dialogs.some(isModal),
|
|
52
59
|
defaultPrevented: event.defaultPrevented,
|
|
60
|
+
startScreen: context.startScreen?.() ?? false,
|
|
53
61
|
});
|
|
54
62
|
if (decision.preventDefault) event.preventDefault();
|
|
55
63
|
if (decision.stopPropagation) event.stopPropagation();
|
package/src/dom/menu.ts
CHANGED
|
@@ -2,10 +2,20 @@ import { ariaKeyShortcutsFromLabel, matchChromeKey, type ShellMessages } from ".
|
|
|
2
2
|
import { createElement, toggleAttribute } from "./element.js";
|
|
3
3
|
import { iconElement, type ShellIconName } from "./icons.js";
|
|
4
4
|
import type { TooltipController } from "./tooltip.js";
|
|
5
|
-
import type {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
}
|
|
9
19
|
|
|
10
20
|
interface Item {
|
|
11
21
|
/** Stable across renders, so a re-render keeps the focus on the same entry. */
|
|
@@ -23,8 +33,11 @@ export interface MenuContext {
|
|
|
23
33
|
messages: ShellMessages;
|
|
24
34
|
tooltip: TooltipController;
|
|
25
35
|
groups: MenuGroups;
|
|
26
|
-
/**
|
|
27
|
-
|
|
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[];
|
|
28
41
|
isReadOnly(): boolean;
|
|
29
42
|
}
|
|
30
43
|
|
|
@@ -66,8 +79,9 @@ export function createMenu(context: MenuContext): MenuController {
|
|
|
66
79
|
const isOpen = (): boolean => !dropdown.hidden;
|
|
67
80
|
|
|
68
81
|
function entriesOf(name: GroupName): readonly MenuEntry[] {
|
|
69
|
-
const own = groups[name] ?? [];
|
|
70
|
-
|
|
82
|
+
const own = isModelerGroup(name) ? (groups[name] ?? []) : [];
|
|
83
|
+
const added = context.shellEntries(name);
|
|
84
|
+
return added.length > 0 ? [...own, ...added] : own;
|
|
71
85
|
}
|
|
72
86
|
|
|
73
87
|
function hasEntries(): boolean {
|
|
@@ -138,18 +152,15 @@ export function createMenu(context: MenuContext): MenuController {
|
|
|
138
152
|
},
|
|
139
153
|
};
|
|
140
154
|
}
|
|
141
|
-
const shortcut =
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
: undefined;
|
|
155
|
+
const shortcut = entry.shortcut
|
|
156
|
+
? createElement(ownerDocument, "span", { class: "cd-shell-menu-shortcut" }, entry.shortcut)
|
|
157
|
+
: undefined;
|
|
145
158
|
const element = itemElement("menuitem", entry.label, { icon: entry.icon, end: shortcut, disabled });
|
|
146
159
|
element.dataset.menuId = entry.id;
|
|
147
160
|
element.dataset.menuGroup = group;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
toggleAttribute(element, "aria-keyshortcuts", keyShortcuts || null);
|
|
152
|
-
}
|
|
161
|
+
const keyShortcuts =
|
|
162
|
+
entry.ariaKeyShortcuts ?? (entry.shortcut ? ariaKeyShortcutsFromLabel(entry.shortcut) : undefined);
|
|
163
|
+
toggleAttribute(element, "aria-keyshortcuts", keyShortcuts || null);
|
|
153
164
|
if (entry.kind === "dialog") element.setAttribute("aria-haspopup", "dialog");
|
|
154
165
|
if (entry.kind === "action" && entry.danger) element.dataset.danger = "";
|
|
155
166
|
return {
|