@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.
Files changed (147) hide show
  1. package/API.md +2869 -0
  2. package/LICENSE +21 -0
  3. package/README.md +566 -2
  4. package/THIRD_PARTY_NOTICES.md +52 -0
  5. package/dist/appearance-BBaM_4bD.d.ts +323 -0
  6. package/dist/appearance-D3FUcbu6.d.cts +53 -0
  7. package/dist/appearance-D3FUcbu6.d.ts +53 -0
  8. package/dist/appearance-XX3JC3pI.d.cts +323 -0
  9. package/dist/chunk-66HCSJNY.js +2792 -0
  10. package/dist/chunk-66HCSJNY.js.map +1 -0
  11. package/dist/chunk-KYDG5DMX.js +136 -0
  12. package/dist/chunk-KYDG5DMX.js.map +1 -0
  13. package/dist/chunk-N4QXFF3A.js +23 -0
  14. package/dist/chunk-N4QXFF3A.js.map +1 -0
  15. package/dist/chunk-OADW3GPA.js +785 -0
  16. package/dist/chunk-OADW3GPA.js.map +1 -0
  17. package/dist/core.cjs +873 -0
  18. package/dist/core.cjs.map +1 -0
  19. package/dist/core.d.cts +246 -0
  20. package/dist/core.d.ts +246 -0
  21. package/dist/core.js +131 -0
  22. package/dist/core.js.map +1 -0
  23. package/dist/diagram-js.cjs +1147 -0
  24. package/dist/diagram-js.cjs.map +1 -0
  25. package/dist/diagram-js.d.cts +170 -0
  26. package/dist/diagram-js.d.ts +170 -0
  27. package/dist/diagram-js.js +879 -0
  28. package/dist/diagram-js.js.map +1 -0
  29. package/dist/index.cjs +3632 -0
  30. package/dist/index.cjs.map +1 -0
  31. package/dist/index.d.cts +29 -0
  32. package/dist/index.d.ts +29 -0
  33. package/dist/index.js +143 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/protocol-DhYBakWL.d.cts +15 -0
  36. package/dist/protocol-IeW8bf4f.d.ts +15 -0
  37. package/dist/shell.css +1603 -0
  38. package/dist/testing-vscode-stub.cjs +332 -0
  39. package/dist/testing-vscode-stub.cjs.map +1 -0
  40. package/dist/testing-vscode-stub.d.cts +65 -0
  41. package/dist/testing-vscode-stub.d.ts +65 -0
  42. package/dist/testing-vscode-stub.js +154 -0
  43. package/dist/testing-vscode-stub.js.map +1 -0
  44. package/dist/testing.cjs +4663 -0
  45. package/dist/testing.cjs.map +1 -0
  46. package/dist/testing.d.cts +231 -0
  47. package/dist/testing.d.ts +231 -0
  48. package/dist/testing.js +4483 -0
  49. package/dist/testing.js.map +1 -0
  50. package/dist/toasts-Bp0REqdq.d.cts +142 -0
  51. package/dist/toasts-Bp0REqdq.d.ts +142 -0
  52. package/dist/types-CnRrINRs.d.cts +445 -0
  53. package/dist/types-PuDZczzy.d.ts +445 -0
  54. package/dist/vscode-extension.cjs +215 -0
  55. package/dist/vscode-extension.cjs.map +1 -0
  56. package/dist/vscode-extension.d.cts +103 -0
  57. package/dist/vscode-extension.d.ts +103 -0
  58. package/dist/vscode-extension.js +24 -0
  59. package/dist/vscode-extension.js.map +1 -0
  60. package/dist/vscode-webview.cjs +3561 -0
  61. package/dist/vscode-webview.cjs.map +1 -0
  62. package/dist/vscode-webview.d.cts +84 -0
  63. package/dist/vscode-webview.d.ts +84 -0
  64. package/dist/vscode-webview.js +259 -0
  65. package/dist/vscode-webview.js.map +1 -0
  66. package/package.json +145 -4
  67. package/src/core/appearance.ts +104 -0
  68. package/src/core/feedback/appearance.ts +141 -0
  69. package/src/core/feedback/problems.ts +23 -0
  70. package/src/core/feedback/toasts.ts +118 -0
  71. package/src/core/fit.ts +220 -0
  72. package/src/core/index.ts +127 -0
  73. package/src/core/keymap.ts +295 -0
  74. package/src/core/layout.ts +69 -0
  75. package/src/core/messages.ts +159 -0
  76. package/src/core/sizes.ts +85 -0
  77. package/src/core/types.ts +106 -0
  78. package/src/core/web-chrome/bottom-row.ts +37 -0
  79. package/src/core/web-chrome/drop.ts +78 -0
  80. package/src/core/web-chrome/top-row.ts +66 -0
  81. package/src/core/zoom.ts +74 -0
  82. package/src/core.ts +2 -0
  83. package/src/diagram-js/connect.ts +75 -0
  84. package/src/diagram-js/index.ts +16 -0
  85. package/src/diagram-js/module.ts +20 -0
  86. package/src/diagram-js/palette-skin.ts +267 -0
  87. package/src/diagram-js/palette.css +269 -0
  88. package/src/diagram-js/service.ts +243 -0
  89. package/src/diagram-js/types.ts +67 -0
  90. package/src/diagram-js/viewport.ts +583 -0
  91. package/src/diagram-js.ts +2 -0
  92. package/src/dom/buttons.css +124 -0
  93. package/src/dom/element.ts +51 -0
  94. package/src/dom/feedback/appearance.ts +184 -0
  95. package/src/dom/feedback/feedback.css +435 -0
  96. package/src/dom/feedback/problems-popover.ts +287 -0
  97. package/src/dom/feedback/toasts.ts +431 -0
  98. package/src/dom/hint.ts +51 -0
  99. package/src/dom/icons.ts +309 -0
  100. package/src/dom/index.ts +42 -0
  101. package/src/dom/inspector.ts +35 -0
  102. package/src/dom/keys.ts +67 -0
  103. package/src/dom/ledger.ts +87 -0
  104. package/src/dom/legend.ts +85 -0
  105. package/src/dom/menu.ts +404 -0
  106. package/src/dom/mount.ts +731 -0
  107. package/src/dom/problems.ts +73 -0
  108. package/src/dom/shell-dom.css +969 -0
  109. package/src/dom/status.ts +72 -0
  110. package/src/dom/tooltip.ts +237 -0
  111. package/src/dom/types.ts +459 -0
  112. package/src/dom/web/drop-zone.ts +181 -0
  113. package/src/dom/web/empty-state.ts +144 -0
  114. package/src/dom/web/legal.ts +75 -0
  115. package/src/dom/web/menu-groups.ts +69 -0
  116. package/src/dom/web/primary.ts +124 -0
  117. package/src/dom/web/web.css +386 -0
  118. package/src/dom/zoom-panel.ts +154 -0
  119. package/src/index.ts +3 -0
  120. package/src/shell.css +9 -0
  121. package/src/testing/case-tools.ts +164 -0
  122. package/src/testing/cases.ts +1077 -0
  123. package/src/testing/checks.ts +868 -0
  124. package/src/testing/facts.ts +641 -0
  125. package/src/testing/geometry.ts +652 -0
  126. package/src/testing/harness.ts +187 -0
  127. package/src/testing/index.ts +19 -0
  128. package/src/testing/page-tools.ts +457 -0
  129. package/src/testing/page.ts +27 -0
  130. package/src/testing/vscode-stub.ts +237 -0
  131. package/src/testing/web-cases.ts +913 -0
  132. package/src/testing/web-checks.ts +1049 -0
  133. package/src/testing/web-facts.ts +713 -0
  134. package/src/testing-vscode-stub.ts +2 -0
  135. package/src/testing.ts +2 -0
  136. package/src/vscode/extension/html.ts +107 -0
  137. package/src/vscode/extension/index.ts +8 -0
  138. package/src/vscode/extension/reveal.ts +9 -0
  139. package/src/vscode/extension/theme-bridge.ts +85 -0
  140. package/src/vscode/extension/types.ts +36 -0
  141. package/src/vscode/protocol.ts +31 -0
  142. package/src/vscode/webview/appearance.ts +141 -0
  143. package/src/vscode/webview/index.ts +8 -0
  144. package/src/vscode/webview/mount.ts +88 -0
  145. package/src/vscode/webview/state.ts +116 -0
  146. package/src/vscode-extension.ts +2 -0
  147. package/src/vscode-webview.ts +2 -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
+ }
@@ -0,0 +1,51 @@
1
+ import type { Insets, ShellMessages } from "../core/index.js";
2
+ import { createElement } from "./element.js";
3
+ import type { EmptyHintOptions } from "./types.js";
4
+
5
+ export interface EmptyHint {
6
+ readonly element: HTMLElement;
7
+ isShown(): boolean;
8
+ setShown(shown: boolean): void;
9
+ /** Centres the hint in the free rectangle of the fit (guide §13.5). */
10
+ place(insets: Insets): void;
11
+ destroy(): void;
12
+ }
13
+
14
+ export function createEmptyHint(
15
+ ownerDocument: Document,
16
+ messages: ShellMessages,
17
+ options: EmptyHintOptions,
18
+ ): EmptyHint {
19
+ const { onInsertExample } = options;
20
+ const action = onInsertExample
21
+ ? createElement(
22
+ ownerDocument,
23
+ "button",
24
+ { type: "button", class: "cd-shell-hint-action" },
25
+ messages.insertExample,
26
+ )
27
+ : undefined;
28
+ action?.addEventListener("click", () => onInsertExample?.());
29
+ const element = createElement(
30
+ ownerDocument,
31
+ "div",
32
+ { class: "cd-shell-hint" },
33
+ createElement(ownerDocument, "p", { class: "cd-shell-hint-text" }, messages.emptyHint),
34
+ action,
35
+ );
36
+ element.hidden = true;
37
+
38
+ return {
39
+ element,
40
+ isShown: () => !element.hidden,
41
+ setShown(shown) {
42
+ element.hidden = !shown;
43
+ },
44
+ place({ top, right, bottom, left }) {
45
+ element.style.inset = `${top}px ${right}px ${bottom}px ${left}px`;
46
+ },
47
+ destroy() {
48
+ element.remove();
49
+ },
50
+ };
51
+ }