@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.
Files changed (98) hide show
  1. package/API.md +1051 -84
  2. package/README.md +162 -31
  3. package/dist/appearance-BBaM_4bD.d.ts +323 -0
  4. package/dist/appearance-XX3JC3pI.d.cts +323 -0
  5. package/dist/{chunk-6LQOFACK.js → chunk-A4YGKH4Y.js} +1252 -31
  6. package/dist/chunk-A4YGKH4Y.js.map +1 -0
  7. package/dist/{chunk-RJRFR6N3.js → chunk-KYDG5DMX.js} +2 -2
  8. package/dist/{chunk-5JSZ4VGA.js → chunk-OADW3GPA.js} +240 -6
  9. package/dist/chunk-OADW3GPA.js.map +1 -0
  10. package/dist/core.cjs +260 -6
  11. package/dist/core.cjs.map +1 -1
  12. package/dist/core.d.cts +81 -6
  13. package/dist/core.d.ts +81 -6
  14. package/dist/core.js +43 -3
  15. package/dist/diagram-js.cjs +34 -2
  16. package/dist/diagram-js.cjs.map +1 -1
  17. package/dist/diagram-js.d.cts +10 -3
  18. package/dist/diagram-js.d.ts +10 -3
  19. package/dist/diagram-js.js +14 -2
  20. package/dist/diagram-js.js.map +1 -1
  21. package/dist/index.cjs +1658 -196
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +18 -9
  24. package/dist/index.d.ts +18 -9
  25. package/dist/index.js +44 -4
  26. package/dist/shell.css +692 -3
  27. package/dist/testing-vscode-stub.js +2 -2
  28. package/dist/testing.cjs +2849 -550
  29. package/dist/testing.cjs.map +1 -1
  30. package/dist/testing.d.cts +75 -9
  31. package/dist/testing.d.ts +75 -9
  32. package/dist/testing.js +2744 -558
  33. package/dist/testing.js.map +1 -1
  34. package/dist/toasts-Bp0REqdq.d.cts +142 -0
  35. package/dist/toasts-Bp0REqdq.d.ts +142 -0
  36. package/dist/{types-OuFlqZp0.d.cts → types-CnRrINRs.d.cts} +170 -9
  37. package/dist/{types-DnZn9CPw.d.ts → types-PuDZczzy.d.ts} +170 -9
  38. package/dist/vscode-extension.js +2 -2
  39. package/dist/vscode-webview.cjs +1546 -178
  40. package/dist/vscode-webview.cjs.map +1 -1
  41. package/dist/vscode-webview.d.cts +6 -4
  42. package/dist/vscode-webview.d.ts +6 -4
  43. package/dist/vscode-webview.js +11 -2
  44. package/dist/vscode-webview.js.map +1 -1
  45. package/package.json +2 -2
  46. package/src/core/feedback/appearance.ts +141 -0
  47. package/src/core/feedback/problems.ts +23 -0
  48. package/src/core/feedback/toasts.ts +118 -0
  49. package/src/core/fit.ts +18 -3
  50. package/src/core/index.ts +43 -0
  51. package/src/core/keymap.ts +20 -0
  52. package/src/core/messages.ts +71 -0
  53. package/src/core/types.ts +24 -2
  54. package/src/core/web-chrome/bottom-row.ts +37 -0
  55. package/src/core/web-chrome/drop.ts +78 -0
  56. package/src/core/web-chrome/top-row.ts +66 -0
  57. package/src/diagram-js/service.ts +4 -1
  58. package/src/diagram-js/viewport.ts +19 -0
  59. package/src/dom/buttons.css +124 -0
  60. package/src/dom/feedback/appearance.ts +184 -0
  61. package/src/dom/feedback/feedback.css +435 -0
  62. package/src/dom/feedback/problems-popover.ts +287 -0
  63. package/src/dom/feedback/toasts.ts +431 -0
  64. package/src/dom/index.ts +13 -0
  65. package/src/dom/keys.ts +8 -0
  66. package/src/dom/menu.ts +28 -17
  67. package/src/dom/mount.ts +290 -20
  68. package/src/dom/problems.ts +5 -4
  69. package/src/dom/shell-dom.css +7 -4
  70. package/src/dom/tooltip.ts +10 -0
  71. package/src/dom/types.ts +188 -6
  72. package/src/dom/web/drop-zone.ts +181 -0
  73. package/src/dom/web/empty-state.ts +144 -0
  74. package/src/dom/web/legal.ts +75 -0
  75. package/src/dom/web/menu-groups.ts +69 -0
  76. package/src/dom/web/primary.ts +124 -0
  77. package/src/dom/web/web.css +386 -0
  78. package/src/shell.css +3 -0
  79. package/src/testing/case-tools.ts +164 -0
  80. package/src/testing/cases.ts +209 -144
  81. package/src/testing/checks.ts +51 -15
  82. package/src/testing/facts.ts +56 -2
  83. package/src/testing/geometry.ts +206 -26
  84. package/src/testing/harness.ts +56 -7
  85. package/src/testing/index.ts +4 -0
  86. package/src/testing/page-tools.ts +24 -1
  87. package/src/testing/page.ts +10 -0
  88. package/src/testing/web-cases.ts +913 -0
  89. package/src/testing/web-checks.ts +1049 -0
  90. package/src/testing/web-facts.ts +713 -0
  91. package/src/vscode/webview/mount.ts +13 -1
  92. package/dist/chunk-5JSZ4VGA.js.map +0 -1
  93. package/dist/chunk-6LQOFACK.js.map +0 -1
  94. package/dist/keymap-CObQE6q6.d.cts +0 -151
  95. package/dist/keymap-DORzHnK-.d.ts +0 -151
  96. package/dist/types-B6NfkiUG.d.cts +0 -66
  97. package/dist/types-B6NfkiUG.d.ts +0 -66
  98. /package/dist/{chunk-RJRFR6N3.js.map → chunk-KYDG5DMX.js.map} +0 -0
package/src/dom/types.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  import type {
2
+ AppearanceChoice,
2
3
  Insets,
3
4
  Platform,
5
+ ProblemItem,
4
6
  ShellHost,
5
7
  ShellMessageOverrides,
6
8
  ShellMessages,
7
9
  Theme,
10
+ ThemeColors,
11
+ ToastRequest,
8
12
  ZoomTarget,
9
13
  } from "../core/index.js";
10
14
  import type { ShellIconName } from "./icons.js";
@@ -32,10 +36,11 @@ export interface ShellOptions {
32
36
  keyScope?: "document" | "root" | undefined;
33
37
  /**
34
38
  * The modes the modeler can show (a static value of the modeler package), default `["light"]`.
35
- * Read in the `embedded` host only: there the root carries `data-cd-theme="<themes[0]>"` itself
36
- * (until the contract's `appearance` surface an embedded modeler stays in its first mode,
37
- * whatever the page shows) and mirrors the host's `data-cd-kontrast` (guide §13.3). In an own
38
- * document the host writes the mode on `<html>`.
39
+ * Read in the `embedded` and `web` hosts. Embedded: the root carries `data-cd-theme="<themes[0]>"`
40
+ * itself (until the contract's `appearance` surface an embedded modeler stays in its first mode,
41
+ * whatever the page shows) and mirrors the host's `data-cd-kontrast` (guide §13.3). Web: with
42
+ * `"dark"` the shell renders the Appearance group and applies the choice on `<html>` (needs
43
+ * `appearance`); light-only it renders no group. In the `ide` host the host writes the mode.
39
44
  */
40
45
  themes?: readonly Theme[] | undefined;
41
46
  /** The fixed menu groups this modeler fills (guide §5.4); omitted groups and empty ones collapse. */
@@ -48,6 +53,24 @@ export interface ShellOptions {
48
53
  inspector?: InspectorOptions | undefined;
49
54
  /** The empty-file hint of IDE and embedded hosts (guide §13.5); omitted = no hint. */
50
55
  emptyHint?: EmptyHintOptions | undefined;
56
+
57
+ // ---- Web host (0.2): read in `host: "web"` only, ignored elsewhere (guide §5.2) ----
58
+
59
+ /** The primary action at the top right (guide §5.7); omitted = the top right stays empty. */
60
+ primary?: PrimaryActionOptions | undefined;
61
+ /**
62
+ * The legal line at the bottom left (guide §5.10), always rendered in the web host: copyright,
63
+ * Impressum and Datenschutz (`lang="de"`), then `links`.
64
+ */
65
+ legal?: LegalLineOptions | undefined;
66
+ /** The File group, first in the menu (guide §5.4); omitted = no File group. */
67
+ file?: FileMenuOptions | undefined;
68
+ /** The mode choice of a dark-capable modeler (guide §13.3); required when `themes` includes `"dark"`. */
69
+ appearance?: WebAppearanceOptions | undefined;
70
+ /** The start screen (guide §8 Empty State); omitted = none. Shown by `setEmptyState(true)`. */
71
+ emptyState?: EmptyStateOptions | undefined;
72
+ /** Files dropped anywhere on the page (guide §9 Drag-over, Drop forbidden); omitted = none. */
73
+ dropZone?: DropZoneOptions | undefined;
51
74
  }
52
75
 
53
76
  /** Rendered in this order, separated by separators: Model, Export, Help (guide §5.4). */
@@ -96,6 +119,10 @@ export interface MenuActionEntry extends MenuItemBase {
96
119
  */
97
120
  export interface MenuDialogEntry extends MenuItemBase {
98
121
  kind: "dialog";
122
+ /** Shortcut chip text shown on the right, verbatim ("?" for "Keyboard shortcuts…"). */
123
+ shortcut?: string | undefined;
124
+ /** As for `MenuActionEntry`: default `ariaKeyShortcutsFromLabel(shortcut)`, `""` sets none. */
125
+ ariaKeyShortcuts?: string | undefined;
99
126
  onSelect(): void;
100
127
  }
101
128
 
@@ -126,8 +153,17 @@ export interface MenuRadioOption {
126
153
  }
127
154
 
128
155
  export interface ProblemsOptions {
129
- /** A click on the pill: the IDE host opens its Problems view. */
130
- onActivate(): void;
156
+ /**
157
+ * A click on the pill where the host has a problems view of its own: the IDE host opens its
158
+ * Problems view (give it there), an embedding host its own list. Ignored in the web host, where
159
+ * the pill opens the problems popover; an embedded shell without it opens the popover as well.
160
+ */
161
+ onActivate?: (() => void) | undefined;
162
+ /**
163
+ * A click on a row of the problems popover, after the popover closed (guide §5.10): select the
164
+ * element the item names, where there is one.
165
+ */
166
+ onSelectItem?: ((item: ProblemItem) => void) | undefined;
131
167
  }
132
168
 
133
169
  export interface LegendOptions {
@@ -168,6 +204,10 @@ export interface ShellHandle {
168
204
  readonly tooltip: TooltipHandle;
169
205
  readonly legend: LegendHandle | undefined;
170
206
  readonly inspector: InspectorHandle | undefined;
207
+ /** The web app's primary action; `undefined` without `primary` and outside the web host. */
208
+ readonly primary: PrimaryActionHandle | undefined;
209
+ /** The web app's mode choice; `undefined` outside the web host and for a light-only modeler. */
210
+ readonly appearance: AppearanceHandle | undefined;
171
211
  /** Binds zoom panel, readout and chrome keys; `null` unbinds (buttons disabled). */
172
212
  setZoomTarget(target: ZoomTarget | null): void;
173
213
  /** The fit insets for what is open right now (palette row, legend, inspector, pill). */
@@ -194,6 +234,21 @@ export interface ShellHandle {
194
234
  setReadOnly(readOnly: boolean, options?: { delayMs?: number | undefined }): void;
195
235
  /** The lock, true from the first `setReadOnly(true)` on, also while its marks are still delayed. */
196
236
  isReadOnly(): boolean;
237
+ /**
238
+ * Shows or hides the web app's start screen (guide §8): while it shows, menu trigger, primary
239
+ * action, palette, zoom panel, pill, legend, status box, hint and inspector are hidden, the canvas
240
+ * slot is `inert` and the chrome keys do nothing; the legal line, toasts and the drop zone stay.
241
+ * A no-op without `emptyState`. Hiding it while it holds the focus moves the focus to the canvas.
242
+ */
243
+ setEmptyState(shown: boolean): void;
244
+ isEmptyStateShown(): boolean;
245
+ /**
246
+ * Shows a toast (guide §8, §9) in the web and embedded hosts: bottom centre, at most three, the
247
+ * newest at the bottom, closing by itself after `toastLifetime` unless hovered or focused. In the
248
+ * `ide` host it shows nothing and returns an inert handle: there the extension shows the host's
249
+ * notification (§13.5).
250
+ */
251
+ toast(request: ToastRequest): ToastHandle;
197
252
  /** Removes every element, attribute, listener, observer and timer the shell added. Idempotent. */
198
253
  destroy(): void;
199
254
  }
@@ -267,6 +322,11 @@ export interface ProblemSummary {
267
322
  count: number;
268
323
  /** How many of them are errors; decides icon and wording. */
269
324
  errors: number;
325
+ /**
326
+ * The rows of the problems popover (web, embedded without `onActivate`), in this order; the IDE
327
+ * host ignores them. `problemSummary(items)` builds the whole summary from them.
328
+ */
329
+ items?: readonly ProblemItem[] | undefined;
270
330
  }
271
331
 
272
332
  export type StatusSeverity = "danger" | "warning";
@@ -275,3 +335,125 @@ export interface StatusMessage {
275
335
  severity: StatusSeverity;
276
336
  text: string;
277
337
  }
338
+
339
+ // ---- Web host (0.2) ----
340
+
341
+ /** The web app's primary action (guide §5.7): a blue primary button, icon and label, at the top right. */
342
+ export interface PrimaryActionOptions {
343
+ /** Label and `aria-label`; default `messages.share` ("Share"). */
344
+ label?: string | undefined;
345
+ /** Default `"share-2"`. */
346
+ icon?: ShellIconName | undefined;
347
+ onSelect(): void;
348
+ /** Read on mount and on `primary.refresh()`; disabled stays focusable (`aria-disabled`). */
349
+ disabled?: (() => boolean) | undefined;
350
+ }
351
+
352
+ export interface PrimaryActionHandle {
353
+ /** The `<button data-chrome="primary">`. */
354
+ readonly element: HTMLButtonElement;
355
+ /** True while the label is collapsed to the icon (guide §5.15); the root then carries `data-primary="collapsed"`. */
356
+ isCollapsed(): boolean;
357
+ /** Re-reads `disabled()`. */
358
+ refresh(): void;
359
+ }
360
+
361
+ /** A link of the legal line after Impressum and Datenschutz. */
362
+ export interface LegalLink {
363
+ label: string;
364
+ href: string;
365
+ /** For a label in another language than the tool's. */
366
+ lang?: string | undefined;
367
+ }
368
+
369
+ export interface LegalLineOptions {
370
+ /** After Impressum and Datenschutz: `{ label: "GitHub", href: "https://github.com/Miragon/…" }`. */
371
+ links?: readonly LegalLink[] | undefined;
372
+ /** The copyright year; default the current year at mount. */
373
+ year?: number | undefined;
374
+ }
375
+
376
+ /** One entry of the File group; label and icon come from the shell (guide §2, §5.4). */
377
+ export interface FileMenuAction {
378
+ onSelect(): void;
379
+ /** Shortcut chip, e.g. "⌘O"; the web app binds the key itself. */
380
+ shortcut?: string | undefined;
381
+ /** `kind: "dialog"` (`aria-haspopup="dialog"`) when it opens an in-page dialog, not the browser's file picker. */
382
+ dialog?: boolean | undefined;
383
+ disabled?: (() => boolean) | undefined;
384
+ }
385
+
386
+ /**
387
+ * The File group of the web app (guide §5.4), in this order, as far as the modeler has them:
388
+ * "New diagram" (`file-plus`), "Open…" (`folder-open`), "Show example", "Import…" (`import`).
389
+ * Menu ids `new-diagram`, `open`, `show-example`, `import`, `data-menu-group="file"`.
390
+ */
391
+ export interface FileMenuOptions {
392
+ newDiagram?: FileMenuAction | undefined;
393
+ open?: FileMenuAction | undefined;
394
+ showExample?: FileMenuAction | undefined;
395
+ import?: FileMenuAction | undefined;
396
+ }
397
+
398
+ /** What `localStorage` offers; a blocked storage throws, the shell catches it. */
399
+ export interface StorageLike {
400
+ getItem(key: string): string | null;
401
+ setItem(key: string, value: string): void;
402
+ removeItem(key: string): void;
403
+ }
404
+
405
+ export interface WebAppearanceOptions {
406
+ /** The two `theme-color` values from the vendored token JSON (see `ThemeColors`). */
407
+ themeColors: ThemeColors;
408
+ /** Default `globalThis.localStorage`. */
409
+ storage?: StorageLike | undefined;
410
+ }
411
+
412
+ /** The mode choice of a dark-capable web app (guide §13.3), as the Appearance group drives it. */
413
+ export interface AppearanceHandle {
414
+ /** The stored choice, `"system"` without one. */
415
+ choice(): AppearanceChoice;
416
+ /**
417
+ * Stores the choice (`"system"` removes `APPEARANCE_STORAGE_KEY`) and applies `documentMode` on
418
+ * `<html>` and the metas in one step, with `cd-shell-no-transition` for one frame (guide §7).
419
+ */
420
+ setChoice(choice: AppearanceChoice): void;
421
+ }
422
+
423
+ /** The web app's start screen (guide §8): a centred glass card with the Komet and two actions. */
424
+ export interface EmptyStateOptions {
425
+ /** The card's title (h1, 21px/600), the modeler's name: "Event Storming Modeler". */
426
+ title: string;
427
+ /** One line of help (16px/1.6, `--cd-ui-text-leise`): "Start a new diagram, or explore the example." */
428
+ text: string;
429
+ /**
430
+ * URL of the official Miragon icon `miragon-komet-blau.svg`, vendored unchanged from
431
+ * `Miragon/corporate-identity` (guide §2, §11), shown at 64px with `alt=""`.
432
+ */
433
+ komet: string;
434
+ /** "New diagram", the blue primary button. The shell hides the card first. */
435
+ onNewDiagram(): void;
436
+ /** "Show example", the ghost button; omitted = no example button. The shell hides the card first. */
437
+ onShowExample?: (() => void) | undefined;
438
+ /** The drag-and-drop hint (13px, `--cd-ui-text-leise`), shown with a `dropZone` only; default `messages.dropHint`. */
439
+ dropHint?: string | undefined;
440
+ }
441
+
442
+ export interface DropZoneOptions {
443
+ /**
444
+ * Like `<input accept>`: extensions (".storm") and MIME types ("application/json", "text/*").
445
+ * List the MIME types too, so a drag can already show a foreign type as forbidden. Empty = any file.
446
+ */
447
+ accept: readonly string[];
448
+ /** Default `false`: a drag with more than one file is forbidden. */
449
+ multiple?: boolean | undefined;
450
+ /** The dropped files that match `accept` (one unless `multiple`). */
451
+ onDrop(files: readonly File[]): void;
452
+ /** The dropped files outside `accept`; default a danger toast `messages.cannotOpenFile`. */
453
+ onReject?: ((files: readonly File[]) => void) | undefined;
454
+ }
455
+
456
+ export interface ToastHandle {
457
+ /** Closes the toast (slide and fade out); a second call does nothing. */
458
+ dismiss(): void;
459
+ }
@@ -0,0 +1,181 @@
1
+ import {
2
+ acceptsFile,
3
+ dragVerdict,
4
+ type DragItemLike,
5
+ type DragVerdict,
6
+ type ShellMessages,
7
+ type ToastRequest,
8
+ } from "../../core/index.js";
9
+ import { createElement } from "../element.js";
10
+ import type { DropZoneOptions, ToastHandle } from "../types.js";
11
+
12
+ /** The drop zone as the root shows it: `data-drop="over"` or `"forbidden"` while a file drag is on the page. */
13
+ export type DropZoneState = "over" | "forbidden" | null;
14
+
15
+ export interface DropZoneContext {
16
+ ownerDocument: Document;
17
+ /** Where the overlay goes: the shell's layer (40, guide §5.13). */
18
+ layer: HTMLElement;
19
+ messages: ShellMessages;
20
+ lang: string;
21
+ options: DropZoneOptions;
22
+ /** A modal dialog is open in the document (the shell's dialog test). */
23
+ modalOpen(): boolean;
24
+ /** For the default `onReject`. */
25
+ toast(request: ToastRequest): ToastHandle;
26
+ /** Writes the root's `data-drop`. */
27
+ setState(state: DropZoneState): void;
28
+ }
29
+
30
+ export interface DropZoneController {
31
+ /** The overlay `<div data-chrome="drop-zone" aria-hidden="true">`. */
32
+ readonly element: HTMLElement;
33
+ state(): DropZoneState;
34
+ destroy(): void;
35
+ }
36
+
37
+ const DRAG_EVENTS = ["dragenter", "dragover", "dragleave", "drop"] as const;
38
+
39
+ function carriesFiles(transfer: DataTransfer | null): transfer is DataTransfer {
40
+ if (!transfer) return false;
41
+ if (Array.from(transfer.types ?? []).includes("Files")) return true;
42
+ return Array.from(transfer.items ?? []).some((item) => item.kind === "file");
43
+ }
44
+
45
+ /** What the drag shows of its items; a browser without `items` still names "Files" in `types`. */
46
+ function dragItems(transfer: DataTransfer): DragItemLike[] {
47
+ const items = Array.from(transfer.items ?? [], (item) => ({ kind: item.kind, type: item.type }));
48
+ return items.length > 0 ? items : [{ kind: "file", type: "" }];
49
+ }
50
+
51
+ /** The extensions of `accept` for the reject toast ("a .storm, .json, or .txt file"), else every entry. */
52
+ function acceptList(accept: readonly string[], lang: string): string {
53
+ const entries = accept.map((entry) => entry.trim()).filter((entry) => entry !== "");
54
+ const extensions = entries.filter((entry) => entry.startsWith("."));
55
+ const listed = extensions.length > 0 ? extensions : entries;
56
+ let format: Intl.ListFormat;
57
+ try {
58
+ format = new Intl.ListFormat(lang, { type: "disjunction" });
59
+ } catch {
60
+ format = new Intl.ListFormat("en", { type: "disjunction" });
61
+ }
62
+ return format.format(listed);
63
+ }
64
+
65
+ /**
66
+ * The page-wide drop zone of the web app (guide §9). Listens on the window (`dragenter`,
67
+ * `dragover`, `dragleave`, `drop`; outermost pair counted) for drags carrying files only, so
68
+ * palette drags and text drags into the label editor stay alone; `dragVerdict` decides the state.
69
+ * Drag-over: overlay `inset: 12px`, fill `--cd-ui-hover`, 2px dashed `--cd-ui-interaktion`, radius
70
+ * `lg`, chip `messages.dropFile` centred on `--cd-ui-flaeche-hoch` (13px/600, radius `md`,
71
+ * `shadow-2`), `dropEffect = "copy"`. Forbidden: fill `--cd-ui-danger-flaeche`, dashed
72
+ * `--cd-ui-danger`, chip `messages.dropForbidden(reason)`, `dropEffect = "none"` (cursor
73
+ * `not-allowed`). Every file drag is `preventDefault`-ed, so the browser never navigates to the
74
+ * file. A drop: files through `acceptsFile` → `onDrop(accepted)`, the rest → `onReject` (default
75
+ * a danger toast `cannotOpenFile(name, accept joined by Intl.ListFormat disjunction)`); a forbidden
76
+ * drop opens nothing. Hidden while no file drag is on the page.
77
+ */
78
+ export function createDropZone(context: DropZoneContext): DropZoneController {
79
+ const { ownerDocument, layer, messages, lang, options, modalOpen, toast, setState } = context;
80
+ const chip = createElement(ownerDocument, "span", { class: "cd-shell-drop-zone-chip" });
81
+ const element = createElement(
82
+ ownerDocument,
83
+ "div",
84
+ { class: "cd-shell-drop-zone", "data-chrome": "drop-zone", "aria-hidden": "true" },
85
+ chip,
86
+ );
87
+ element.hidden = true;
88
+ layer.append(element);
89
+
90
+ const view = ownerDocument.defaultView;
91
+ // dragenter and dragleave fire for every element the pointer crosses: only the outermost pair counts.
92
+ let depth = 0;
93
+ let current: DropZoneState = null;
94
+
95
+ function verdictFor(items: readonly DragItemLike[]): DragVerdict {
96
+ return dragVerdict(items, {
97
+ accept: options.accept,
98
+ multiple: options.multiple ?? false,
99
+ dialogOpen: modalOpen(),
100
+ });
101
+ }
102
+
103
+ function show(verdict: DragVerdict): void {
104
+ const next: DropZoneState = verdict.state === "none" ? null : verdict.state;
105
+ if (verdict.state === "over") chip.textContent = messages.dropFile;
106
+ if (verdict.state === "forbidden") chip.textContent = messages.dropForbidden(verdict.reason);
107
+ if (next === current) return;
108
+ current = next;
109
+ element.hidden = next === null;
110
+ if (next === null) element.removeAttribute("data-state");
111
+ else element.setAttribute("data-state", next);
112
+ setState(next);
113
+ }
114
+
115
+ function track(transfer: DataTransfer): void {
116
+ const verdict = verdictFor(dragItems(transfer));
117
+ show(verdict);
118
+ transfer.dropEffect = verdict.state === "forbidden" ? "none" : "copy";
119
+ }
120
+
121
+ function rejectByDefault(files: readonly File[]): void {
122
+ const accepted = acceptList(options.accept, lang);
123
+ for (const file of files) toast({ status: "danger", text: messages.cannotOpenFile(file.name, accepted) });
124
+ }
125
+
126
+ function receive(files: readonly File[], verdict: DragVerdict): void {
127
+ // The type verdict only guesses while names are unreadable; at the drop acceptsFile knows.
128
+ if (verdict.state === "forbidden" && verdict.reason !== "type") return;
129
+ const accepted = files.filter((file) => acceptsFile(file, options.accept));
130
+ const rejected = files.filter((file) => !accepted.includes(file));
131
+ if (accepted.length > 0) options.onDrop(accepted);
132
+ if (rejected.length > 0) (options.onReject ?? rejectByDefault)(rejected);
133
+ }
134
+
135
+ const onDragEvent = (event: Event): void => {
136
+ const transfer = (event as DragEvent).dataTransfer;
137
+ if (!carriesFiles(transfer)) return;
138
+ // Unprevented, the browser opens the file in place of the app.
139
+ event.preventDefault();
140
+ switch (event.type) {
141
+ case "dragenter":
142
+ depth += 1;
143
+ track(transfer);
144
+ break;
145
+ case "dragover":
146
+ // A drag that entered before the listener existed is on the page all the same.
147
+ depth = Math.max(depth, 1);
148
+ track(transfer);
149
+ break;
150
+ case "dragleave":
151
+ depth = Math.max(0, depth - 1);
152
+ if (depth === 0) show({ state: "none" });
153
+ break;
154
+ case "drop": {
155
+ depth = 0;
156
+ show({ state: "none" });
157
+ const files = Array.from(transfer.files ?? []);
158
+ const items =
159
+ files.length > 0 ? files.map((file) => ({ kind: "file", type: file.type })) : dragItems(transfer);
160
+ receive(files, verdictFor(items));
161
+ break;
162
+ }
163
+ }
164
+ };
165
+
166
+ for (const type of DRAG_EVENTS) view?.addEventListener(type, onDragEvent);
167
+
168
+ return {
169
+ element,
170
+ state: () => current,
171
+ destroy() {
172
+ for (const type of DRAG_EVENTS) view?.removeEventListener(type, onDragEvent);
173
+ depth = 0;
174
+ if (current !== null) {
175
+ current = null;
176
+ setState(null);
177
+ }
178
+ element.remove();
179
+ },
180
+ };
181
+ }
@@ -0,0 +1,144 @@
1
+ import { ariaKeyShortcutsFromLabel, type ShellMessages } from "../../core/index.js";
2
+ import { createElement } from "../element.js";
3
+ import type { EmptyStateOptions, FileMenuAction } from "../types.js";
4
+
5
+ export interface EmptyStateContext {
6
+ ownerDocument: Document;
7
+ messages: ShellMessages;
8
+ options: EmptyStateOptions;
9
+ /** The drop hint under the buttons; `undefined` without a drop zone. */
10
+ dropHint: string | undefined;
11
+ /**
12
+ * The File group's "Open…": the hint offers it as a text link, the keyboard's way to a file
13
+ * while the start screen hides the menu and a drop needs a pointer. `undefined` without one.
14
+ */
15
+ open?: FileMenuAction | undefined;
16
+ /** `id` of the title, the section's `aria-labelledby`. */
17
+ titleId: string;
18
+ /** Runs before `onNewDiagram` / `onShowExample`: the shell hides the card and moves the focus. */
19
+ onLeave(): void;
20
+ }
21
+
22
+ export interface EmptyStateController {
23
+ /** The `<section data-chrome="empty-state">`. */
24
+ readonly element: HTMLElement;
25
+ isShown(): boolean;
26
+ setShown(shown: boolean): void;
27
+ /** Focuses "New diagram", the card's first button. */
28
+ focus(): void;
29
+ destroy(): void;
30
+ }
31
+
32
+ /** The Komet's size on the card (guide §8). */
33
+ const KOMET_SIZE = "64";
34
+
35
+ /**
36
+ * `<section class="cd-shell-empty-state" data-chrome="empty-state" aria-labelledby>` (guide §8):
37
+ * centred in the container, `min(400px, 100cqw − 24px)` wide, glass, `shadow-2`, radius `lg`,
38
+ * padding `32px 40px`, content centred with 16px gaps: `<img src=komet width=64 height=64
39
+ * alt="">`, `<h1>` title 21px/600, help 16px/1.6 `--cd-ui-text-leise`, then the buttons side by
40
+ * side with 8px gap, 36px high (`cd-shell-button`): primary "New diagram" first, ghost "Show
41
+ * example", then the drop hint 13px `--cd-ui-text-leise`. With the File group's "Open…" the hint
42
+ * ends in a text link `messages.openFileLink` in `--cd-ui-link` (and its shortcut chip in Geist
43
+ * Mono): not a third action, the keyboard's way to open a file. Opening keeps the card until the
44
+ * modeler leaves it (`setEmptyState(false)` after the file loaded). Layer 20, `hidden` until
45
+ * `setShown(true)`.
46
+ */
47
+ export function createEmptyState(context: EmptyStateContext): EmptyStateController {
48
+ const { ownerDocument, messages, options, dropHint, open, titleId, onLeave } = context;
49
+
50
+ const button = (variant: "primary" | "ghost", label: string, run: () => void): HTMLButtonElement => {
51
+ const element = createElement(
52
+ ownerDocument,
53
+ "button",
54
+ { type: "button", class: "cd-shell-button", "data-variant": variant },
55
+ label,
56
+ );
57
+ element.addEventListener("click", () => {
58
+ onLeave();
59
+ run();
60
+ });
61
+ return element;
62
+ };
63
+
64
+ const onShowExample = options.onShowExample;
65
+ const newDiagram = button("primary", messages.newDiagram, () => options.onNewDiagram());
66
+ const element = createElement(
67
+ ownerDocument,
68
+ "section",
69
+ { class: "cd-shell-empty-state", "data-chrome": "empty-state", "aria-labelledby": titleId },
70
+ // Dragging the mark would start an image drag that some browsers carry as a file.
71
+ createElement(ownerDocument, "img", {
72
+ class: "cd-shell-empty-state-mark",
73
+ src: options.komet,
74
+ width: KOMET_SIZE,
75
+ height: KOMET_SIZE,
76
+ alt: "",
77
+ draggable: "false",
78
+ }),
79
+ createElement(ownerDocument, "h1", { class: "cd-shell-empty-state-title", id: titleId }, options.title),
80
+ createElement(ownerDocument, "p", { class: "cd-shell-empty-state-text" }, options.text),
81
+ createElement(
82
+ ownerDocument,
83
+ "div",
84
+ { class: "cd-shell-empty-state-actions" },
85
+ newDiagram,
86
+ onShowExample && button("ghost", messages.showExample, () => onShowExample()),
87
+ ),
88
+ hintParagraph(),
89
+ );
90
+
91
+ function hintParagraph(): HTMLElement | undefined {
92
+ const hasDropHint = dropHint !== undefined && dropHint !== "";
93
+ if (!hasDropHint && !open) return undefined;
94
+ const paragraph = createElement(ownerDocument, "p", { class: "cd-shell-empty-state-hint" });
95
+ if (hasDropHint) paragraph.append(dropHint);
96
+ if (!open) return paragraph;
97
+ const shortcut = open.shortcut?.trim();
98
+ const link = createElement(
99
+ ownerDocument,
100
+ "button",
101
+ {
102
+ type: "button",
103
+ class: "cd-shell-empty-state-open",
104
+ "aria-keyshortcuts": shortcut ? ariaKeyShortcutsFromLabel(shortcut) : undefined,
105
+ "aria-haspopup": open.dialog ? "dialog" : undefined,
106
+ },
107
+ messages.openFileLink,
108
+ );
109
+ link.addEventListener("click", () => open.onSelect());
110
+ if (hasDropHint) {
111
+ paragraph.append(" ", createElement(ownerDocument, "span", { "aria-hidden": "true" }, "·"), " ");
112
+ }
113
+ // The link and its chip wrap as one: a chip alone on a line names no action.
114
+ const openItem = createElement(ownerDocument, "span", { class: "cd-shell-empty-state-open-item" }, link);
115
+ if (shortcut) {
116
+ openItem.append(
117
+ " ",
118
+ createElement(
119
+ ownerDocument,
120
+ "kbd",
121
+ { class: "cd-shell-empty-state-key", "aria-hidden": "true" },
122
+ shortcut,
123
+ ),
124
+ );
125
+ }
126
+ paragraph.append(openItem);
127
+ return paragraph;
128
+ }
129
+ element.hidden = true;
130
+
131
+ return {
132
+ element,
133
+ isShown: () => !element.hidden,
134
+ setShown(shown) {
135
+ element.hidden = !shown;
136
+ },
137
+ focus() {
138
+ newDiagram.focus({ preventScroll: true });
139
+ },
140
+ destroy() {
141
+ element.remove();
142
+ },
143
+ };
144
+ }
@@ -0,0 +1,75 @@
1
+ import type { ShellMessages } from "../../core/index.js";
2
+ import { createElement } from "../element.js";
3
+ import type { LegalLineOptions } from "../types.js";
4
+
5
+ /** The Miragon legal pages (corporate-identity, skill docs-site, `miragon/legal.ts`). */
6
+ export const MIRAGON_HOME = "https://www.miragon.io/";
7
+ export const MIRAGON_IMPRESSUM = "https://www.miragon.io/impressum/";
8
+ export const MIRAGON_DATENSCHUTZ = "https://www.miragon.io/datenschutz/";
9
+
10
+ export interface LegalLineContext {
11
+ ownerDocument: Document;
12
+ messages: ShellMessages;
13
+ options: LegalLineOptions;
14
+ /** `cd-shell-legal-N`: the prefix of the id the links' `aria-describedby` names. */
15
+ idPrefix: string;
16
+ }
17
+
18
+ export interface LegalLineController {
19
+ /** The `<nav data-chrome="legal">`. */
20
+ readonly element: HTMLElement;
21
+ destroy(): void;
22
+ }
23
+
24
+ /**
25
+ * `<nav class="cd-shell-legal" data-chrome="legal" aria-label="Legal">` (guide §5.10, §2): one
26
+ * line, 13px, line height 20px, at `left: 12px; bottom: 12px`, glass without border and shadow,
27
+ * padding `0 8px`, radius `sm`, layer 20; in a container too narrow for it, it wraps between its
28
+ * entries and grows upwards. Links in `--cd-ui-link`, `target="_blank" rel="noopener noreferrer"`
29
+ * and `aria-describedby` a hidden `messages.opensInNewTab`, each after the first in a
30
+ * `<span class="cd-shell-legal-item">` with its separator `<span aria-hidden="true">·</span>`:
31
+ * `messages.copyright(year)` → `MIRAGON_HOME`, "Impressum" → `MIRAGON_IMPRESSUM` and "Datenschutz"
32
+ * → `MIRAGON_DATENSCHUTZ` (both `lang="de"`, never translated), then `options.links`.
33
+ */
34
+ export function createLegalLine(context: LegalLineContext): LegalLineController {
35
+ const { ownerDocument, messages, options } = context;
36
+ const year = options.year ?? new Date().getFullYear();
37
+
38
+ // Every link opens a new tab; screen readers hear that from one shared description.
39
+ const newTabId = `${context.idPrefix}-new-tab`;
40
+ const link = (label: string, href: string, lang?: string): HTMLAnchorElement =>
41
+ createElement(
42
+ ownerDocument,
43
+ "a",
44
+ { href, lang, target: "_blank", rel: "noopener noreferrer", "aria-describedby": newTabId },
45
+ label,
46
+ );
47
+ const separator = (): HTMLElement => createElement(ownerDocument, "span", { "aria-hidden": "true" }, "·");
48
+ const item = (anchor: HTMLAnchorElement): HTMLElement =>
49
+ createElement(ownerDocument, "span", { class: "cd-shell-legal-item" }, separator(), anchor);
50
+
51
+ // The legal pages are German whatever the tool's language (guide §2), so their names are too.
52
+ const links = [
53
+ link(messages.copyright(year), MIRAGON_HOME),
54
+ link("Impressum", MIRAGON_IMPRESSUM, "de"),
55
+ link("Datenschutz", MIRAGON_DATENSCHUTZ, "de"),
56
+ ...(options.links ?? []).map((extra) => link(extra.label, extra.href, extra.lang)),
57
+ ];
58
+
59
+ const element = createElement(ownerDocument, "nav", {
60
+ class: "cd-shell-legal",
61
+ "data-chrome": "legal",
62
+ "aria-label": messages.legal,
63
+ });
64
+ element.append(
65
+ ...links.map((anchor, index) => (index === 0 ? anchor : item(anchor))),
66
+ createElement(ownerDocument, "span", { id: newTabId, hidden: "" }, messages.opensInNewTab),
67
+ );
68
+
69
+ return {
70
+ element,
71
+ destroy() {
72
+ element.remove();
73
+ },
74
+ };
75
+ }