@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
@@ -60,13 +60,28 @@ var SHELL = {
60
60
  layerToast: 60
61
61
  };
62
62
 
63
+ // src/core/web-chrome/bottom-row.ts
64
+ var LEGAL_LINE_HEIGHT = 20;
65
+ function legalLineHeight(measured) {
66
+ if (measured === void 0 || !Number.isFinite(measured) || measured <= 0) return LEGAL_LINE_HEIGHT;
67
+ return Math.max(1, Math.round(measured / LEGAL_LINE_HEIGHT)) * LEGAL_LINE_HEIGHT;
68
+ }
69
+ function zoomRowRaised(input) {
70
+ const { containerWidth, legalRight, zoomWidth } = input;
71
+ if (legalRight === void 0 || zoomWidth === void 0) return false;
72
+ const zoomLeft = containerWidth - SHELL.edge - zoomWidth;
73
+ return legalRight + SHELL.gap > zoomLeft;
74
+ }
75
+
63
76
  // src/core/fit.ts
64
77
  function fitInsets(layout) {
65
- const { container, host, paletteRow, problemsVisible, legendWidth, inspector } = layout;
78
+ const { container, host, paletteRow, problemsVisible, zoomRaised, legendWidth, inspector } = layout;
79
+ const raisedBottomRow = host === "web" && (problemsVisible || zoomRaised === true);
80
+ const wrappedLegalLines = legalLineHeight(layout.legalHeight) - LEGAL_LINE_HEIGHT;
66
81
  const insets = {
67
82
  top: paletteRow === "second-row" ? SHELL.fitInsetTopSecondRow : SHELL.fitInsetTop,
68
83
  right: SHELL.fitInsetSide,
69
- bottom: host === "web" && problemsVisible ? SHELL.fitInsetBottomWeb : SHELL.fitInsetBottom,
84
+ bottom: raisedBottomRow ? SHELL.fitInsetBottomWeb + wrappedLegalLines : SHELL.fitInsetBottom,
70
85
  left: SHELL.fitInsetSide
71
86
  };
72
87
  const bottomSheetOpen = inspector !== void 0 && container.width <= SHELL.narrow;
@@ -201,6 +216,7 @@ function decideKey(event, context) {
201
216
  }
202
217
  const chromeKey = matchChromeKey(event);
203
218
  if (chromeKey !== void 0) {
219
+ if (context.startScreen === true && !stop) return { preventDefault: false, stopPropagation: false };
204
220
  const blocked = composing || context.inTextField || context.modalOpen;
205
221
  if (!blocked) return { action: chromeKey, preventDefault: true, stopPropagation: stop };
206
222
  return { preventDefault: false, stopPropagation: chromeKey === "fit" ? false : stop };
@@ -371,7 +387,27 @@ var defaultMessages = {
371
387
  emptyHint: "Pick a tool from the palette to start.",
372
388
  insertExample: "Insert example",
373
389
  invalidFile: (diagram) => `This file isn't a valid ${diagram} right now. The diagram shows the last valid version and is read-only until you fix the text.`,
374
- keyNames: { ctrl: "Ctrl", shift: "Shift", plus: "Plus", minus: "Minus" }
390
+ keyNames: { ctrl: "Ctrl", shift: "Shift", plus: "Plus", minus: "Minus" },
391
+ share: "Share",
392
+ legal: "Legal",
393
+ copyright: (year) => `\xA9 ${year} Miragon GmbH`,
394
+ opensInNewTab: "Opens in a new tab",
395
+ newDiagram: "New diagram",
396
+ openFile: "Open\u2026",
397
+ showExample: "Show example",
398
+ importFile: "Import\u2026",
399
+ appearance: "Appearance",
400
+ appearanceSystem: "System",
401
+ appearanceLight: "Light",
402
+ appearanceDark: "Dark",
403
+ problemsTitle: "Problems",
404
+ problemSeverity: (severity) => severity === "error" ? "Error" : "Warning",
405
+ close: "Close",
406
+ dropFile: "Drop file here",
407
+ dropForbidden: (reason) => reason === "multiple" ? "Drop one file at a time" : reason === "dialog" ? "Close the dialog to drop a file" : "This modeler can't open this file type",
408
+ dropHint: "Or drop a file here",
409
+ openFileLink: "Open a file\u2026",
410
+ cannotOpenFile: (name, accepted) => `Couldn't open \u201C${name}\u201D. Drop a ${accepted} file.`
375
411
  };
376
412
  function resolveMessages(overrides) {
377
413
  const { keyNames, ...texts } = overrides ?? {};
@@ -410,6 +446,121 @@ function placeTooltip(input) {
410
446
  return { x, y };
411
447
  }
412
448
 
449
+ // src/core/web-chrome/top-row.ts
450
+ function decideTopRow(input) {
451
+ const { containerWidth, paletteWidth, menuRight, primary } = input;
452
+ if (!primary) {
453
+ return {
454
+ paletteRow: decidePaletteRow({ containerWidth, paletteWidth, menuRight }),
455
+ primaryLabel: "shown"
456
+ };
457
+ }
458
+ const paletteRowBeside = (primaryWidth) => decidePaletteRow({
459
+ containerWidth,
460
+ paletteWidth,
461
+ menuRight,
462
+ primaryLeft: containerWidth - SHELL.edge - primaryWidth
463
+ });
464
+ if (paletteRowBeside(primary.withLabel) === "top") return { paletteRow: "top", primaryLabel: "shown" };
465
+ if (paletteRowBeside(primary.iconOnly) === "top") return { paletteRow: "top", primaryLabel: "collapsed" };
466
+ const labelledLeft = containerWidth - SHELL.edge - primary.withLabel;
467
+ return {
468
+ paletteRow: "second-row",
469
+ primaryLabel: menuRight + PALETTE_CLEARANCE <= labelledLeft ? "shown" : "collapsed"
470
+ };
471
+ }
472
+
473
+ // src/core/web-chrome/drop.ts
474
+ function acceptEntries(accept) {
475
+ const entries = accept.map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== "");
476
+ return {
477
+ extensions: entries.filter((entry) => entry.startsWith(".")),
478
+ mimeTypes: entries.filter((entry) => !entry.startsWith(".") && entry.includes("/"))
479
+ };
480
+ }
481
+ function matchesMimeType(type, mimeTypes) {
482
+ const normalized = type.trim().toLowerCase();
483
+ if (normalized === "") return false;
484
+ return mimeTypes.some(
485
+ (entry) => entry.endsWith("/*") ? normalized.startsWith(entry.slice(0, -1)) : normalized === entry
486
+ );
487
+ }
488
+ function dragVerdict(items, context) {
489
+ const files = items.filter((item) => item.kind === "file");
490
+ if (files.length === 0) return { state: "none" };
491
+ if (context.dialogOpen) return { state: "forbidden", reason: "dialog" };
492
+ if (files.length > 1 && !context.multiple) return { state: "forbidden", reason: "multiple" };
493
+ const { mimeTypes } = acceptEntries(context.accept);
494
+ const foreignType = (item) => item.type.trim() !== "" && !matchesMimeType(item.type, mimeTypes);
495
+ if (mimeTypes.length > 0 && files.every(foreignType)) return { state: "forbidden", reason: "type" };
496
+ return { state: "over" };
497
+ }
498
+ function acceptsFile(file, accept) {
499
+ const { extensions, mimeTypes } = acceptEntries(accept);
500
+ if (extensions.length === 0 && mimeTypes.length === 0) return true;
501
+ const name = file.name.toLowerCase();
502
+ return extensions.some((extension) => name.endsWith(extension)) || matchesMimeType(file.type, mimeTypes);
503
+ }
504
+
505
+ // src/core/feedback/toasts.ts
506
+ var MAX_TOASTS = 3;
507
+ var TOAST_DURATION_MS = {
508
+ success: 2800,
509
+ info: 2800,
510
+ warning: 5e3,
511
+ danger: 5e3
512
+ };
513
+ var TOAST_MAX_WIDTH = 480;
514
+ function toastLifetime(request) {
515
+ if (request.action) return void 0;
516
+ const requested = request.durationMs;
517
+ const longer = requested !== void 0 && Number.isFinite(requested) ? requested : 0;
518
+ return Math.max(TOAST_DURATION_MS[request.status], longer);
519
+ }
520
+ function toastToEvict(stack) {
521
+ if (stack.length < MAX_TOASTS) return void 0;
522
+ const evicted = stack.find((slot) => !slot.engaged && !slot.hasAction) ?? stack.find((slot) => !slot.engaged) ?? stack[0];
523
+ return evicted?.id;
524
+ }
525
+ function toastStackWidth(containerWidth) {
526
+ return Math.max(0, Math.min(TOAST_MAX_WIDTH, containerWidth - 2 * SHELL.edge));
527
+ }
528
+ function toastStackBottom(input) {
529
+ const stackWidth = toastStackWidth(input.containerWidth);
530
+ const { cardWidth } = input;
531
+ const width = cardWidth !== void 0 && Number.isFinite(cardWidth) ? Math.min(stackWidth, Math.max(0, cardWidth)) : stackWidth;
532
+ const left = (input.containerWidth - width) / 2;
533
+ const right = left + width;
534
+ let highest;
535
+ for (const obstacle of input.obstacles) {
536
+ const distance = Math.max(obstacle.left - right, left - obstacle.right);
537
+ if (distance >= SHELL.gap) continue;
538
+ highest = highest === void 0 ? obstacle.top : Math.max(highest, obstacle.top);
539
+ }
540
+ return highest === void 0 ? SHELL.edge : Math.max(SHELL.edge, highest + SHELL.gap);
541
+ }
542
+
543
+ // src/core/feedback/appearance.ts
544
+ var APPEARANCE_STORAGE_KEY = "miragon.darstellung";
545
+ function parseAppearanceChoice(stored) {
546
+ return stored === "light" || stored === "dark" ? stored : "system";
547
+ }
548
+ function documentMode(choice, themes, colors) {
549
+ if (!themes.includes("dark")) return fixedMode("light", colors.light);
550
+ const parsed = parseAppearanceChoice(choice);
551
+ if (parsed === "system") {
552
+ return {
553
+ theme: "system",
554
+ colorScheme: "light dark",
555
+ themeColor: { light: colors.light, dark: colors.dark }
556
+ };
557
+ }
558
+ return fixedMode(parsed, colors[parsed]);
559
+ }
560
+ function fixedMode(theme, color) {
561
+ return { theme, colorScheme: theme, themeColor: { light: color, dark: color } };
562
+ }
563
+
413
564
  // src/vscode/protocol.ts
414
565
  var APPEARANCE_MESSAGE_TYPE = "modelerShell.appearance";
415
566
  function appearanceMessage(appearance) {
@@ -554,163 +705,134 @@ function hasVisibleFocus(element) {
554
705
  }
555
706
  }
556
707
 
557
- // src/dom/hint.ts
558
- function createEmptyHint(ownerDocument, messages, options) {
559
- const { onInsertExample } = options;
560
- const action = onInsertExample ? createElement(
561
- ownerDocument,
562
- "button",
563
- { type: "button", class: "cd-shell-hint-action" },
564
- messages.insertExample
565
- ) : void 0;
566
- action?.addEventListener("click", () => onInsertExample?.());
567
- const element = createElement(
568
- ownerDocument,
569
- "div",
570
- { class: "cd-shell-hint" },
571
- createElement(ownerDocument, "p", { class: "cd-shell-hint-text" }, messages.emptyHint),
572
- action
573
- );
574
- element.hidden = true;
575
- return {
576
- element,
577
- isShown: () => !element.hidden,
578
- setShown(shown) {
579
- element.hidden = !shown;
580
- },
581
- place({ top, right, bottom, left }) {
582
- element.style.inset = `${top}px ${right}px ${bottom}px ${left}px`;
583
- },
584
- destroy() {
585
- element.remove();
586
- }
587
- };
588
- }
589
-
590
- // src/dom/inspector.ts
591
- function createInspector(ownerDocument, options, onChange) {
592
- const element = createElement(ownerDocument, "aside", {
593
- class: "cd-shell-inspector",
594
- "data-chrome": "inspector",
595
- "aria-label": options.label
596
- });
597
- element.hidden = true;
598
- function setOpen(open) {
599
- if (open === !element.hidden) return;
600
- element.hidden = !open;
601
- onChange(open);
708
+ // src/dom/feedback/appearance.ts
709
+ var NO_TRANSITION_CLASS2 = "cd-shell-no-transition";
710
+ var CONTRAST_QUERY = "(prefers-contrast: more)";
711
+ var SCHEME_MEDIA = {
712
+ light: "(prefers-color-scheme: light)",
713
+ dark: "(prefers-color-scheme: dark)"
714
+ };
715
+ function onMediaChange(query, listener) {
716
+ if (typeof query.addEventListener === "function") {
717
+ query.addEventListener("change", listener);
718
+ return () => query.removeEventListener("change", listener);
719
+ }
720
+ query.addListener(listener);
721
+ return () => query.removeListener(listener);
722
+ }
723
+ function connectWebAppearance(context) {
724
+ const { ownerDocument, themes, options } = context;
725
+ const darkCapable = themes.includes("dark");
726
+ if (darkCapable && !options) {
727
+ throw new Error(
728
+ '@miragon/modeler-shell: a web modeler whose themes include "dark" needs the appearance option ({ themeColors } from its vendored cd-tokens.generated.json)'
729
+ );
602
730
  }
603
- return {
604
- element,
605
- isOpen: () => !element.hidden,
606
- open: () => setOpen(true),
607
- close: () => setOpen(false),
608
- destroy() {
609
- element.remove();
731
+ const html = ownerDocument.documentElement;
732
+ const view = ownerDocument.defaultView;
733
+ const cleanups = [];
734
+ const contrast = typeof view?.matchMedia === "function" ? view.matchMedia(CONTRAST_QUERY) : void 0;
735
+ if (contrast) {
736
+ const mirrorContrast = () => toggleAttribute(html, "data-cd-kontrast", contrast.matches && "hoch");
737
+ mirrorContrast();
738
+ cleanups.push(onMediaChange(contrast, mirrorContrast));
739
+ }
740
+ function destroy() {
741
+ for (const cleanup of cleanups.splice(0)) cleanup();
742
+ }
743
+ if (!darkCapable || !options) {
744
+ return { choice: () => "system", setChoice() {
745
+ }, destroy };
746
+ }
747
+ const { themeColors } = options;
748
+ function storage() {
749
+ if (options?.storage) return options.storage;
750
+ try {
751
+ return globalThis.localStorage;
752
+ } catch {
753
+ return void 0;
610
754
  }
611
- };
612
- }
613
-
614
- // src/dom/keys.ts
615
- function isTextField(target) {
616
- if (!target || typeof target.closest !== "function") return false;
617
- const element = target;
618
- return element.isContentEditable === true || element.closest(TEXT_FIELD_SELECTOR) !== null;
619
- }
620
- function isShown(element) {
621
- if (element.localName === "dialog" && !element.hasAttribute("open")) return false;
622
- if (element.closest("[hidden]")) return false;
623
- return typeof element.checkVisibility !== "function" || element.checkVisibility({ visibilityProperty: true });
624
- }
625
- function isModal(element) {
626
- if (element.getAttribute("aria-modal") === "true") return true;
627
- try {
628
- return element.matches(":modal");
629
- } catch {
630
- return false;
631
- }
632
- }
633
- function openDialogs(scope) {
634
- return [...scope.querySelectorAll('[role="dialog"], [role="alertdialog"], dialog')].filter(isShown);
635
- }
636
- function createKeyListener(context) {
637
- return (event) => {
638
- if (event.key === "Escape") context.hideTooltip();
639
- const dialogs = openDialogs(context.dialogScope);
640
- const decision = decideKey(event, {
641
- host: context.host,
642
- inTextField: isTextField(event.target),
643
- menuOpen: context.menuOpen(),
644
- dialogOpen: dialogs.length > 0,
645
- modalOpen: dialogs.some(isModal),
646
- defaultPrevented: event.defaultPrevented
647
- });
648
- if (decision.preventDefault) event.preventDefault();
649
- if (decision.stopPropagation) event.stopPropagation();
650
- if (decision.action === "closeMenu") context.closeMenu();
651
- else if (decision.action) context.run(decision.action);
652
- };
653
- }
654
-
655
- // src/dom/ledger.ts
656
- var Ledger = class {
657
- undo = [];
658
- originalAttributes = /* @__PURE__ */ new Map();
659
- closed = false;
660
- get isClosed() {
661
- return this.closed;
662
755
  }
663
- /** Runs `task` on `close()`, in reverse order of registration. */
664
- defer(task) {
665
- this.undo.push(task);
666
- }
667
- /** Adds `className` unless present; `close()` removes it only if this ledger added it. */
668
- addClass(element, className) {
669
- if (element.classList.contains(className)) return;
670
- const hadClassAttribute = element.hasAttribute("class");
671
- element.classList.add(className);
672
- this.defer(() => {
673
- element.classList.remove(className);
674
- if (!hadClassAttribute && element.classList.length === 0) element.removeAttribute("class");
675
- });
756
+ function readStored() {
757
+ try {
758
+ return parseAppearanceChoice(storage()?.getItem(APPEARANCE_STORAGE_KEY));
759
+ } catch {
760
+ return "system";
761
+ }
676
762
  }
677
- /** Sets (string) or removes (`null`) an attribute; `close()` restores the value before the first change. */
678
- attribute(element, name, value) {
679
- let originals = this.originalAttributes.get(element);
680
- if (!originals) {
681
- originals = /* @__PURE__ */ new Map();
682
- this.originalAttributes.set(element, originals);
763
+ function store(choice) {
764
+ try {
765
+ const target = storage();
766
+ if (choice === "system") target?.removeItem(APPEARANCE_STORAGE_KEY);
767
+ else target?.setItem(APPEARANCE_STORAGE_KEY, choice);
768
+ } catch {
683
769
  }
684
- if (!originals.has(name)) {
685
- originals.set(name, element.getAttribute(name));
686
- this.defer(() => {
687
- const original = originals.get(name) ?? null;
688
- if (original === null) element.removeAttribute(name);
689
- else element.setAttribute(name, original);
690
- });
770
+ }
771
+ function nextFrame(callback) {
772
+ if (view && typeof view.requestAnimationFrame === "function") {
773
+ const frame = view.requestAnimationFrame(() => callback());
774
+ return () => view.cancelAnimationFrame(frame);
691
775
  }
692
- if (value === null) element.removeAttribute(name);
693
- else element.setAttribute(name, value);
776
+ const timer = setTimeout(callback, 16);
777
+ return () => clearTimeout(timer);
694
778
  }
695
- listen(target, type, listener, options) {
696
- const handler = listener;
697
- target.addEventListener(type, handler, options);
698
- this.defer(() => target.removeEventListener(type, handler, options));
779
+ let cancelTransitionFrame;
780
+ function allowTransitions() {
781
+ cancelTransitionFrame?.();
782
+ cancelTransitionFrame = void 0;
783
+ html.classList.remove(NO_TRANSITION_CLASS2);
699
784
  }
700
- /** Runs every undo task once; later calls are no-ops. */
701
- close() {
702
- if (this.closed) return;
703
- this.closed = true;
704
- for (const task of this.undo.reverse()) {
705
- try {
706
- task();
707
- } catch {
708
- }
709
- }
710
- this.undo.length = 0;
711
- this.originalAttributes.clear();
785
+ cleanups.push(() => {
786
+ if (cancelTransitionFrame) allowTransitions();
787
+ });
788
+ function suspendTransitions() {
789
+ cancelTransitionFrame?.();
790
+ html.classList.add(NO_TRANSITION_CLASS2);
791
+ cancelTransitionFrame = nextFrame(() => {
792
+ cancelTransitionFrame = nextFrame(allowTransitions);
793
+ });
712
794
  }
713
- };
795
+ function metaFor(name, media) {
796
+ const normalize = (value) => (value ?? "").replace(/\s+/g, "").toLowerCase();
797
+ const existing = [...ownerDocument.querySelectorAll(`meta[name="${name}"]`)].find(
798
+ (meta) => media === void 0 ? true : normalize(meta.getAttribute("media")) === normalize(media)
799
+ );
800
+ if (existing) return existing;
801
+ const created = createElement(ownerDocument, "meta", { name, media });
802
+ (ownerDocument.head ?? html).append(created);
803
+ return created;
804
+ }
805
+ function apply(choice) {
806
+ const mode = documentMode(choice, themes, themeColors);
807
+ suspendTransitions();
808
+ html.setAttribute("data-cd-theme", mode.theme);
809
+ metaFor("color-scheme").setAttribute("content", mode.colorScheme);
810
+ metaFor("theme-color", SCHEME_MEDIA.light).setAttribute("content", mode.themeColor.light);
811
+ metaFor("theme-color", SCHEME_MEDIA.dark).setAttribute("content", mode.themeColor.dark);
812
+ }
813
+ let current = readStored();
814
+ const onStorage = (event) => {
815
+ if (event.key !== null && event.key !== APPEARANCE_STORAGE_KEY) return;
816
+ const next = event.key === null ? "system" : parseAppearanceChoice(event.newValue);
817
+ if (next === current) return;
818
+ current = next;
819
+ apply(next);
820
+ };
821
+ if (view) {
822
+ view.addEventListener("storage", onStorage);
823
+ cleanups.push(() => view.removeEventListener("storage", onStorage));
824
+ }
825
+ return {
826
+ choice: () => current,
827
+ setChoice(choice) {
828
+ const next = parseAppearanceChoice(choice);
829
+ current = next;
830
+ store(next);
831
+ apply(next);
832
+ },
833
+ destroy
834
+ };
835
+ }
714
836
 
715
837
  // src/dom/icons.ts
716
838
  var ICONS = {
@@ -928,9 +1050,672 @@ function iconElement(icon, options) {
928
1050
  for (const [name, value] of attributes) child.setAttribute(name, value);
929
1051
  svg.append(child);
930
1052
  }
931
- return svg;
1053
+ return svg;
1054
+ }
1055
+
1056
+ // src/dom/feedback/problems-popover.ts
1057
+ var PILL_ATTRIBUTES = ["aria-haspopup", "aria-expanded", "aria-controls"];
1058
+ function isPrintable(event) {
1059
+ return event.key.length === 1 && /\S/.test(event.key) && !event.ctrlKey && !event.metaKey && !event.altKey;
1060
+ }
1061
+ function createProblemsPopover(context) {
1062
+ const { ownerDocument, root, layer, pill, id, titleId, messages } = context;
1063
+ const view = ownerDocument.defaultView;
1064
+ const list = createElement(ownerDocument, "ul", { class: "cd-shell-problems-list", role: "list" });
1065
+ const element = createElement(
1066
+ ownerDocument,
1067
+ "div",
1068
+ {
1069
+ class: "cd-shell-problems-popover",
1070
+ "data-chrome": "problems-popover",
1071
+ role: "dialog",
1072
+ id,
1073
+ "aria-labelledby": titleId,
1074
+ tabindex: "-1"
1075
+ },
1076
+ createElement(
1077
+ ownerDocument,
1078
+ "h2",
1079
+ { class: "cd-shell-problems-popover-title", id: titleId },
1080
+ messages.problemsTitle
1081
+ ),
1082
+ list
1083
+ );
1084
+ element.hidden = true;
1085
+ layer.append(element);
1086
+ pill.setAttribute("aria-haspopup", "dialog");
1087
+ pill.setAttribute("aria-expanded", "false");
1088
+ pill.setAttribute("aria-controls", id);
1089
+ let items = [];
1090
+ let rows = [];
1091
+ let destroyed = false;
1092
+ const isOpen = () => !element.hidden;
1093
+ function keyOf(item) {
1094
+ return `${item.elementId ?? ""}\0${item.severity}\0${item.message}`;
1095
+ }
1096
+ function rowElement(item) {
1097
+ const severity = item.severity === "error" ? "error" : "warning";
1098
+ const parts = [
1099
+ // The shape and colour of the icon carry the severity on screen, its name for screen readers.
1100
+ createElement(
1101
+ ownerDocument,
1102
+ "span",
1103
+ { class: "cd-shell-problem-icon", role: "img", "aria-label": messages.problemSeverity(severity) },
1104
+ iconElement(severity === "error" ? "circle-x" : "triangle-alert")
1105
+ ),
1106
+ createElement(ownerDocument, "span", { class: "cd-shell-problem-message" }, item.message),
1107
+ item.location ? createElement(ownerDocument, "span", { class: "cd-shell-problem-location" }, item.location) : void 0
1108
+ ];
1109
+ const attributes = { class: "cd-shell-problem", "data-severity": severity };
1110
+ if (!item.elementId) {
1111
+ const text = createElement(ownerDocument, "div", { ...attributes, tabindex: "-1" }, ...parts);
1112
+ rows.push({ key: keyOf(item), element: text });
1113
+ return text;
1114
+ }
1115
+ const button = createElement(ownerDocument, "button", { type: "button", ...attributes }, ...parts);
1116
+ button.addEventListener("click", () => select(item));
1117
+ rows.push({ key: keyOf(item), element: button });
1118
+ return button;
1119
+ }
1120
+ function focusedRowIndex() {
1121
+ return rows.findIndex((row) => row.element === ownerDocument.activeElement);
1122
+ }
1123
+ function render() {
1124
+ const focusedIndex = focusedRowIndex();
1125
+ const focusedKey = rows[focusedIndex]?.key;
1126
+ rows = [];
1127
+ list.replaceChildren(...items.map((item) => createElement(ownerDocument, "li", {}, rowElement(item))));
1128
+ if (focusedKey === void 0 || !isOpen()) return;
1129
+ const kept = rows.find((row) => row.key === focusedKey) ?? rows[Math.min(focusedIndex, rows.length - 1)];
1130
+ if (kept) focusRowElement(kept.element);
1131
+ else element.focus({ preventScroll: true });
1132
+ }
1133
+ function revealInList(row) {
1134
+ const rowBox = row.getBoundingClientRect();
1135
+ const listBox = list.getBoundingClientRect();
1136
+ if (rowBox.top < listBox.top) list.scrollTop -= listBox.top - rowBox.top;
1137
+ else if (rowBox.bottom > listBox.bottom) list.scrollTop += rowBox.bottom - listBox.bottom;
1138
+ }
1139
+ function focusRowElement(row) {
1140
+ row.focus({ preventScroll: true });
1141
+ revealInList(row);
1142
+ }
1143
+ function focusCanvas() {
1144
+ const canvas = root.querySelector(".cd-shell-canvas svg[tabindex]");
1145
+ canvas?.focus({ preventScroll: true });
1146
+ if ((!canvas || ownerDocument.activeElement !== canvas) && !pill.hidden)
1147
+ pill.focus({ preventScroll: true });
1148
+ }
1149
+ function select(item) {
1150
+ close();
1151
+ focusCanvas();
1152
+ context.onSelectItem?.(item);
1153
+ }
1154
+ function focusRow(index) {
1155
+ if (rows.length === 0) return;
1156
+ const row = rows[(index + rows.length) % rows.length];
1157
+ if (row) focusRowElement(row.element);
1158
+ }
1159
+ const onOutsidePointerDown = (event) => {
1160
+ const target = event.target;
1161
+ if (target && (element.contains(target) || pill.contains(target))) return;
1162
+ close();
1163
+ };
1164
+ const onWindowBlur = () => close();
1165
+ function open() {
1166
+ if (destroyed || isOpen()) return;
1167
+ element.hidden = false;
1168
+ pill.setAttribute("aria-expanded", "true");
1169
+ ownerDocument.addEventListener("pointerdown", onOutsidePointerDown, true);
1170
+ view?.addEventListener("blur", onWindowBlur);
1171
+ list.scrollTop = 0;
1172
+ const first = rows[0];
1173
+ if (first) focusRowElement(first.element);
1174
+ else element.focus({ preventScroll: true });
1175
+ }
1176
+ function close(options = {}) {
1177
+ if (!isOpen()) return;
1178
+ element.hidden = true;
1179
+ pill.setAttribute("aria-expanded", "false");
1180
+ ownerDocument.removeEventListener("pointerdown", onOutsidePointerDown, true);
1181
+ view?.removeEventListener("blur", onWindowBlur);
1182
+ if (options.restoreFocus && !pill.hidden) pill.focus({ preventScroll: true });
1183
+ }
1184
+ function consume(event) {
1185
+ event.preventDefault();
1186
+ event.stopPropagation();
1187
+ }
1188
+ element.addEventListener("keydown", (event) => {
1189
+ if (matchChromeKey(event)) return;
1190
+ const index = focusedRowIndex();
1191
+ switch (event.key) {
1192
+ case "Escape":
1193
+ consume(event);
1194
+ close({ restoreFocus: true });
1195
+ return;
1196
+ case "Tab": {
1197
+ const first = index <= 0;
1198
+ const last = rows.length === 0 || index === rows.length - 1;
1199
+ if (event.shiftKey && first) {
1200
+ consume(event);
1201
+ close({ restoreFocus: true });
1202
+ } else if (!event.shiftKey && last) {
1203
+ event.stopPropagation();
1204
+ close({ restoreFocus: true });
1205
+ }
1206
+ return;
1207
+ }
1208
+ case "ArrowDown":
1209
+ focusRow(index + 1);
1210
+ break;
1211
+ case "ArrowUp":
1212
+ focusRow(index < 0 ? rows.length - 1 : index - 1);
1213
+ break;
1214
+ case "Home":
1215
+ focusRow(0);
1216
+ break;
1217
+ case "End":
1218
+ focusRow(rows.length - 1);
1219
+ break;
1220
+ case "Enter":
1221
+ case " ":
1222
+ case "Delete":
1223
+ case "Backspace":
1224
+ event.stopPropagation();
1225
+ return;
1226
+ default:
1227
+ if (isPrintable(event)) event.stopPropagation();
1228
+ return;
1229
+ }
1230
+ consume(event);
1231
+ });
1232
+ return {
1233
+ element,
1234
+ isOpen,
1235
+ toggle() {
1236
+ if (isOpen()) close({ restoreFocus: true });
1237
+ else open();
1238
+ },
1239
+ open,
1240
+ close,
1241
+ setItems(next) {
1242
+ items = next;
1243
+ if (!destroyed) render();
1244
+ },
1245
+ destroy() {
1246
+ if (destroyed) return;
1247
+ close();
1248
+ destroyed = true;
1249
+ for (const name of PILL_ATTRIBUTES) pill.removeAttribute(name);
1250
+ element.remove();
1251
+ }
1252
+ };
1253
+ }
1254
+
1255
+ // src/dom/feedback/toasts.ts
1256
+ var TOAST_RESUME_MIN_MS = 1500;
1257
+ var TOAST_LEAVE_FALLBACK_MS = 400;
1258
+ var LEAVE_ANIMATION = "cd-shell-toast-out";
1259
+ var TOAST_ANNOUNCE_DELAY_MS = 100;
1260
+ var INERT_HANDLE = Object.freeze({ dismiss() {
1261
+ } });
1262
+ function createToaster(context) {
1263
+ const { ownerDocument, messages } = context;
1264
+ const web = context.host === "web";
1265
+ const element = createElement(ownerDocument, "div", {
1266
+ class: "cd-shell-toasts",
1267
+ "data-chrome": "toasts",
1268
+ popover: web ? "manual" : void 0
1269
+ });
1270
+ const liveRegion = (politeness) => createElement(ownerDocument, "div", {
1271
+ class: "cd-shell-live-region",
1272
+ "data-live": politeness,
1273
+ role: politeness === "polite" ? "status" : "alert",
1274
+ "aria-live": politeness,
1275
+ "aria-atomic": "true"
1276
+ });
1277
+ const politeRegion = liveRegion("polite");
1278
+ const assertiveRegion = liveRegion("assertive");
1279
+ const announcer = createElement(
1280
+ ownerDocument,
1281
+ "div",
1282
+ { class: "cd-shell-live" },
1283
+ politeRegion,
1284
+ assertiveRegion
1285
+ );
1286
+ const live = [];
1287
+ const leaveTimers = /* @__PURE__ */ new Set();
1288
+ const announceTimers = /* @__PURE__ */ new Map();
1289
+ let lastId = 0;
1290
+ let destroyed = false;
1291
+ function regionFor(request) {
1292
+ return request.status === "danger" ? assertiveRegion : politeRegion;
1293
+ }
1294
+ function announce(request) {
1295
+ const region = regionFor(request);
1296
+ clearTimeout(announceTimers.get(region));
1297
+ region.textContent = "";
1298
+ announceTimers.set(
1299
+ region,
1300
+ setTimeout(() => {
1301
+ announceTimers.delete(region);
1302
+ if (!destroyed) region.textContent = request.text;
1303
+ }, TOAST_ANNOUNCE_DELAY_MS)
1304
+ );
1305
+ }
1306
+ function silence(request) {
1307
+ const region = regionFor(request);
1308
+ if (region.textContent === request.text) region.textContent = "";
1309
+ }
1310
+ function changed() {
1311
+ if (!destroyed) context.onChange?.();
1312
+ }
1313
+ function supportsPopover() {
1314
+ return web && typeof element.showPopover === "function" && typeof element.hidePopover === "function";
1315
+ }
1316
+ function popoverOpen() {
1317
+ try {
1318
+ return element.matches(":popover-open");
1319
+ } catch {
1320
+ return false;
1321
+ }
1322
+ }
1323
+ function modalOpen() {
1324
+ try {
1325
+ return ownerDocument.querySelector(":modal") !== null;
1326
+ } catch {
1327
+ return true;
1328
+ }
1329
+ }
1330
+ function raise() {
1331
+ if (!supportsPopover() || !element.isConnected) return;
1332
+ if (popoverOpen() && !modalOpen()) return;
1333
+ const focused = ownerDocument.activeElement;
1334
+ const keepFocus = focused && element.contains(focused) ? focused : void 0;
1335
+ try {
1336
+ element.hidePopover();
1337
+ element.showPopover();
1338
+ } catch {
1339
+ return;
1340
+ }
1341
+ if (keepFocus && ownerDocument.activeElement !== keepFocus) keepFocus.focus({ preventScroll: true });
1342
+ }
1343
+ function lowerWhenEmpty() {
1344
+ if (element.childElementCount > 0 || !supportsPopover()) return;
1345
+ try {
1346
+ element.hidePopover();
1347
+ } catch {
1348
+ }
1349
+ }
1350
+ function engaged(toast) {
1351
+ return toast.hovered || toast.focused;
1352
+ }
1353
+ function stopTimer(toast) {
1354
+ clearTimeout(toast.timer);
1355
+ toast.timer = void 0;
1356
+ }
1357
+ function runTimer(toast) {
1358
+ stopTimer(toast);
1359
+ if (toast.remaining === void 0 || engaged(toast)) return;
1360
+ toast.startedAt = Date.now();
1361
+ toast.timer = setTimeout(() => close(toast), toast.remaining);
1362
+ }
1363
+ function pause(toast) {
1364
+ if (toast.timer === void 0) return;
1365
+ stopTimer(toast);
1366
+ if (toast.remaining !== void 0) {
1367
+ toast.remaining = Math.max(0, toast.remaining - (Date.now() - toast.startedAt));
1368
+ }
1369
+ }
1370
+ function setEngagement(toast, hovered, focused) {
1371
+ if (!live.includes(toast)) return;
1372
+ const wasEngaged = engaged(toast);
1373
+ toast.hovered = hovered;
1374
+ toast.focused = focused;
1375
+ if (engaged(toast) === wasEngaged) return;
1376
+ if (wasEngaged) {
1377
+ if (toast.remaining !== void 0) toast.remaining = Math.max(toast.remaining, TOAST_RESUME_MIN_MS);
1378
+ runTimer(toast);
1379
+ } else {
1380
+ pause(toast);
1381
+ }
1382
+ }
1383
+ function reducedMotion() {
1384
+ if (ownerDocument.documentElement.classList.contains(REDUCED_MOTION_CLASS)) return true;
1385
+ const view = ownerDocument.defaultView;
1386
+ return typeof view?.matchMedia === "function" && view.matchMedia("(prefers-reduced-motion: reduce)").matches;
1387
+ }
1388
+ function removeElement(toastElement) {
1389
+ toastElement.remove();
1390
+ lowerWhenEmpty();
1391
+ changed();
1392
+ }
1393
+ function leave(toastElement) {
1394
+ toastElement.setAttribute("data-leaving", "");
1395
+ toastElement.inert = true;
1396
+ if (reducedMotion()) {
1397
+ removeElement(toastElement);
1398
+ return;
1399
+ }
1400
+ let done = false;
1401
+ const finish = () => {
1402
+ if (done) return;
1403
+ done = true;
1404
+ clearTimeout(fallback);
1405
+ leaveTimers.delete(fallback);
1406
+ removeElement(toastElement);
1407
+ };
1408
+ toastElement.addEventListener("animationend", (event) => {
1409
+ if (event.target === toastElement && event.animationName === LEAVE_ANIMATION) finish();
1410
+ });
1411
+ const fallback = setTimeout(finish, TOAST_LEAVE_FALLBACK_MS);
1412
+ leaveTimers.add(fallback);
1413
+ }
1414
+ function close(toast) {
1415
+ const index = live.indexOf(toast);
1416
+ if (index < 0) return;
1417
+ live.splice(index, 1);
1418
+ stopTimer(toast);
1419
+ silence(toast.request);
1420
+ const heldFocus = toast.element.contains(ownerDocument.activeElement);
1421
+ if (heldFocus) {
1422
+ const next = live[index] ?? live[index - 1];
1423
+ if (next) next.closeButton.focus({ preventScroll: true });
1424
+ else context.focusFallback();
1425
+ }
1426
+ leave(toast.element);
1427
+ }
1428
+ function setAction(toast, action) {
1429
+ const previous = toast.actionButton;
1430
+ const hadFocus = previous !== void 0 && ownerDocument.activeElement === previous;
1431
+ previous?.remove();
1432
+ toast.actionButton = void 0;
1433
+ if (action) {
1434
+ const button = createElement(
1435
+ ownerDocument,
1436
+ "button",
1437
+ { type: "button", class: "cd-shell-toast-action cd-shell-button", "data-variant": "ghost" },
1438
+ action.label
1439
+ );
1440
+ button.addEventListener("click", () => {
1441
+ close(toast);
1442
+ action.onSelect();
1443
+ });
1444
+ toast.closeButton.before(button);
1445
+ toast.actionButton = button;
1446
+ }
1447
+ if (hadFocus) (toast.actionButton ?? toast.closeButton).focus({ preventScroll: true });
1448
+ }
1449
+ function slotOf(toast) {
1450
+ return { id: toast.id, hasAction: toast.request.action !== void 0, engaged: engaged(toast) };
1451
+ }
1452
+ function render(request) {
1453
+ const id = ++lastId;
1454
+ const textId = `${context.idPrefix}-${id}`;
1455
+ const closeButton = createElement(
1456
+ ownerDocument,
1457
+ "button",
1458
+ {
1459
+ type: "button",
1460
+ class: "cd-shell-toast-close",
1461
+ "aria-label": messages.close,
1462
+ "aria-describedby": textId
1463
+ },
1464
+ iconElement("x")
1465
+ );
1466
+ const toastElement = createElement(
1467
+ ownerDocument,
1468
+ "div",
1469
+ { class: "cd-shell-toast", "data-status": request.status },
1470
+ createElement(ownerDocument, "span", { class: "cd-shell-toast-dot", "aria-hidden": "true" }),
1471
+ createElement(ownerDocument, "span", { class: "cd-shell-toast-text", id: textId }, request.text),
1472
+ closeButton
1473
+ );
1474
+ const toast = {
1475
+ id,
1476
+ element: toastElement,
1477
+ closeButton,
1478
+ handle: Object.freeze({ dismiss: () => close(toast) }),
1479
+ request,
1480
+ actionButton: void 0,
1481
+ hovered: false,
1482
+ focused: false,
1483
+ remaining: toastLifetime(request),
1484
+ startedAt: Date.now(),
1485
+ timer: void 0
1486
+ };
1487
+ setAction(toast, request.action);
1488
+ closeButton.addEventListener("click", () => close(toast));
1489
+ toastElement.addEventListener("mouseenter", () => setEngagement(toast, true, toast.focused));
1490
+ toastElement.addEventListener("mouseleave", () => setEngagement(toast, false, toast.focused));
1491
+ toastElement.addEventListener("focusin", () => setEngagement(toast, toast.hovered, true));
1492
+ toastElement.addEventListener(
1493
+ "focusout",
1494
+ (event) => setEngagement(toast, toast.hovered, toastElement.contains(event.relatedTarget))
1495
+ );
1496
+ return toast;
1497
+ }
1498
+ function show(request) {
1499
+ if (destroyed) return INERT_HANDLE;
1500
+ const same = live.find(
1501
+ (toast2) => toast2.request.status === request.status && toast2.request.text === request.text
1502
+ );
1503
+ if (same) {
1504
+ same.request = request;
1505
+ setAction(same, request.action);
1506
+ same.remaining = toastLifetime(request);
1507
+ runTimer(same);
1508
+ raise();
1509
+ announce(request);
1510
+ changed();
1511
+ return same.handle;
1512
+ }
1513
+ while (live.length >= MAX_TOASTS) {
1514
+ const evictedId = toastToEvict(live.map(slotOf));
1515
+ const evicted = live.find((toast2) => toast2.id === evictedId) ?? live[0];
1516
+ if (!evicted) break;
1517
+ close(evicted);
1518
+ }
1519
+ const toast = render(request);
1520
+ live.push(toast);
1521
+ element.append(toast.element);
1522
+ raise();
1523
+ runTimer(toast);
1524
+ announce(request);
1525
+ changed();
1526
+ return toast.handle;
1527
+ }
1528
+ return {
1529
+ element,
1530
+ announcer,
1531
+ show,
1532
+ setBottom(bottom) {
1533
+ element.style.bottom = `${bottom}px`;
1534
+ },
1535
+ widestCard() {
1536
+ let widest;
1537
+ for (const card of element.children) {
1538
+ const width = card.getBoundingClientRect().width;
1539
+ if (width > 0) widest = Math.max(widest ?? 0, width);
1540
+ }
1541
+ return widest;
1542
+ },
1543
+ destroy() {
1544
+ if (destroyed) return;
1545
+ destroyed = true;
1546
+ for (const toast of live.splice(0)) stopTimer(toast);
1547
+ for (const timer of leaveTimers) clearTimeout(timer);
1548
+ leaveTimers.clear();
1549
+ for (const timer of announceTimers.values()) clearTimeout(timer);
1550
+ announceTimers.clear();
1551
+ element.remove();
1552
+ announcer.remove();
1553
+ }
1554
+ };
1555
+ }
1556
+
1557
+ // src/dom/hint.ts
1558
+ function createEmptyHint(ownerDocument, messages, options) {
1559
+ const { onInsertExample } = options;
1560
+ const action = onInsertExample ? createElement(
1561
+ ownerDocument,
1562
+ "button",
1563
+ { type: "button", class: "cd-shell-hint-action" },
1564
+ messages.insertExample
1565
+ ) : void 0;
1566
+ action?.addEventListener("click", () => onInsertExample?.());
1567
+ const element = createElement(
1568
+ ownerDocument,
1569
+ "div",
1570
+ { class: "cd-shell-hint" },
1571
+ createElement(ownerDocument, "p", { class: "cd-shell-hint-text" }, messages.emptyHint),
1572
+ action
1573
+ );
1574
+ element.hidden = true;
1575
+ return {
1576
+ element,
1577
+ isShown: () => !element.hidden,
1578
+ setShown(shown) {
1579
+ element.hidden = !shown;
1580
+ },
1581
+ place({ top, right, bottom, left }) {
1582
+ element.style.inset = `${top}px ${right}px ${bottom}px ${left}px`;
1583
+ },
1584
+ destroy() {
1585
+ element.remove();
1586
+ }
1587
+ };
1588
+ }
1589
+
1590
+ // src/dom/inspector.ts
1591
+ function createInspector(ownerDocument, options, onChange) {
1592
+ const element = createElement(ownerDocument, "aside", {
1593
+ class: "cd-shell-inspector",
1594
+ "data-chrome": "inspector",
1595
+ "aria-label": options.label
1596
+ });
1597
+ element.hidden = true;
1598
+ function setOpen(open) {
1599
+ if (open === !element.hidden) return;
1600
+ element.hidden = !open;
1601
+ onChange(open);
1602
+ }
1603
+ return {
1604
+ element,
1605
+ isOpen: () => !element.hidden,
1606
+ open: () => setOpen(true),
1607
+ close: () => setOpen(false),
1608
+ destroy() {
1609
+ element.remove();
1610
+ }
1611
+ };
1612
+ }
1613
+
1614
+ // src/dom/keys.ts
1615
+ function isTextField(target) {
1616
+ if (!target || typeof target.closest !== "function") return false;
1617
+ const element = target;
1618
+ return element.isContentEditable === true || element.closest(TEXT_FIELD_SELECTOR) !== null;
1619
+ }
1620
+ function isShown(element) {
1621
+ if (element.localName === "dialog" && !element.hasAttribute("open")) return false;
1622
+ if (element.closest("[hidden]")) return false;
1623
+ return typeof element.checkVisibility !== "function" || element.checkVisibility({ visibilityProperty: true });
1624
+ }
1625
+ function isModal(element) {
1626
+ if (element.getAttribute("aria-modal") === "true") return true;
1627
+ try {
1628
+ return element.matches(":modal");
1629
+ } catch {
1630
+ return false;
1631
+ }
1632
+ }
1633
+ function openDialogs(scope) {
1634
+ return [...scope.querySelectorAll('[role="dialog"], [role="alertdialog"], dialog')].filter(isShown);
1635
+ }
1636
+ function isModalOpen(scope) {
1637
+ return openDialogs(scope).some(isModal);
1638
+ }
1639
+ function createKeyListener(context) {
1640
+ return (event) => {
1641
+ if (event.key === "Escape") context.hideTooltip();
1642
+ const dialogs = openDialogs(context.dialogScope);
1643
+ const decision = decideKey(event, {
1644
+ host: context.host,
1645
+ inTextField: isTextField(event.target),
1646
+ menuOpen: context.menuOpen(),
1647
+ dialogOpen: dialogs.length > 0,
1648
+ modalOpen: dialogs.some(isModal),
1649
+ defaultPrevented: event.defaultPrevented,
1650
+ startScreen: context.startScreen?.() ?? false
1651
+ });
1652
+ if (decision.preventDefault) event.preventDefault();
1653
+ if (decision.stopPropagation) event.stopPropagation();
1654
+ if (decision.action === "closeMenu") context.closeMenu();
1655
+ else if (decision.action) context.run(decision.action);
1656
+ };
932
1657
  }
933
1658
 
1659
+ // src/dom/ledger.ts
1660
+ var Ledger = class {
1661
+ undo = [];
1662
+ originalAttributes = /* @__PURE__ */ new Map();
1663
+ closed = false;
1664
+ get isClosed() {
1665
+ return this.closed;
1666
+ }
1667
+ /** Runs `task` on `close()`, in reverse order of registration. */
1668
+ defer(task) {
1669
+ this.undo.push(task);
1670
+ }
1671
+ /** Adds `className` unless present; `close()` removes it only if this ledger added it. */
1672
+ addClass(element, className) {
1673
+ if (element.classList.contains(className)) return;
1674
+ const hadClassAttribute = element.hasAttribute("class");
1675
+ element.classList.add(className);
1676
+ this.defer(() => {
1677
+ element.classList.remove(className);
1678
+ if (!hadClassAttribute && element.classList.length === 0) element.removeAttribute("class");
1679
+ });
1680
+ }
1681
+ /** Sets (string) or removes (`null`) an attribute; `close()` restores the value before the first change. */
1682
+ attribute(element, name, value) {
1683
+ let originals = this.originalAttributes.get(element);
1684
+ if (!originals) {
1685
+ originals = /* @__PURE__ */ new Map();
1686
+ this.originalAttributes.set(element, originals);
1687
+ }
1688
+ if (!originals.has(name)) {
1689
+ originals.set(name, element.getAttribute(name));
1690
+ this.defer(() => {
1691
+ const original = originals.get(name) ?? null;
1692
+ if (original === null) element.removeAttribute(name);
1693
+ else element.setAttribute(name, original);
1694
+ });
1695
+ }
1696
+ if (value === null) element.removeAttribute(name);
1697
+ else element.setAttribute(name, value);
1698
+ }
1699
+ listen(target, type, listener, options) {
1700
+ const handler = listener;
1701
+ target.addEventListener(type, handler, options);
1702
+ this.defer(() => target.removeEventListener(type, handler, options));
1703
+ }
1704
+ /** Runs every undo task once; later calls are no-ops. */
1705
+ close() {
1706
+ if (this.closed) return;
1707
+ this.closed = true;
1708
+ for (const task of this.undo.reverse()) {
1709
+ try {
1710
+ task();
1711
+ } catch {
1712
+ }
1713
+ }
1714
+ this.undo.length = 0;
1715
+ this.originalAttributes.clear();
1716
+ }
1717
+ };
1718
+
934
1719
  // src/dom/legend.ts
935
1720
  function createLegend(context) {
936
1721
  const { ownerDocument, messages, tooltip, titleId, options } = context;
@@ -990,8 +1775,11 @@ function createLegend(context) {
990
1775
  }
991
1776
 
992
1777
  // src/dom/menu.ts
993
- var GROUP_ORDER = ["model", "export", "help"];
994
- function isPrintable(event) {
1778
+ var GROUP_ORDER = ["file", "model", "export", "appearance", "help"];
1779
+ function isModelerGroup(name) {
1780
+ return name === "model" || name === "export" || name === "help";
1781
+ }
1782
+ function isPrintable2(event) {
995
1783
  return event.key.length === 1 && /\S/.test(event.key) && !event.ctrlKey && !event.metaKey && !event.altKey;
996
1784
  }
997
1785
  function createMenu(context) {
@@ -1020,8 +1808,9 @@ function createMenu(context) {
1020
1808
  layer.prepend(dropdown);
1021
1809
  const isOpen = () => !dropdown.hidden;
1022
1810
  function entriesOf(name) {
1023
- const own = groups[name] ?? [];
1024
- return name === "model" ? [...own, ...context.shellModelEntries()] : own;
1811
+ const own = isModelerGroup(name) ? groups[name] ?? [] : [];
1812
+ const added = context.shellEntries(name);
1813
+ return added.length > 0 ? [...own, ...added] : own;
1025
1814
  }
1026
1815
  function hasEntries() {
1027
1816
  return GROUP_ORDER.some((name) => entriesOf(name).length > 0);
@@ -1075,14 +1864,12 @@ function createMenu(context) {
1075
1864
  }
1076
1865
  };
1077
1866
  }
1078
- const shortcut = entry.kind === "action" && entry.shortcut ? createElement(ownerDocument, "span", { class: "cd-shell-menu-shortcut" }, entry.shortcut) : void 0;
1867
+ const shortcut = entry.shortcut ? createElement(ownerDocument, "span", { class: "cd-shell-menu-shortcut" }, entry.shortcut) : void 0;
1079
1868
  const element = itemElement("menuitem", entry.label, { icon: entry.icon, end: shortcut, disabled });
1080
1869
  element.dataset.menuId = entry.id;
1081
1870
  element.dataset.menuGroup = group;
1082
- if (entry.kind === "action") {
1083
- const keyShortcuts = entry.ariaKeyShortcuts ?? (entry.shortcut ? ariaKeyShortcutsFromLabel(entry.shortcut) : void 0);
1084
- toggleAttribute(element, "aria-keyshortcuts", keyShortcuts || null);
1085
- }
1871
+ const keyShortcuts = entry.ariaKeyShortcuts ?? (entry.shortcut ? ariaKeyShortcutsFromLabel(entry.shortcut) : void 0);
1872
+ toggleAttribute(element, "aria-keyshortcuts", keyShortcuts || null);
1086
1873
  if (entry.kind === "dialog") element.setAttribute("aria-haspopup", "dialog");
1087
1874
  if (entry.kind === "action" && entry.danger) element.dataset.danger = "";
1088
1875
  return {
@@ -1267,7 +2054,7 @@ function createMenu(context) {
1267
2054
  event.stopPropagation();
1268
2055
  return;
1269
2056
  default:
1270
- if (!isPrintable(event)) return;
2057
+ if (!isPrintable2(event)) return;
1271
2058
  typeAhead(event.key);
1272
2059
  }
1273
2060
  consume(event);
@@ -1297,7 +2084,7 @@ function createMenu(context) {
1297
2084
 
1298
2085
  // src/dom/problems.ts
1299
2086
  function createProblemsPill(context) {
1300
- const { ownerDocument, messages, lang, tooltip, options } = context;
2087
+ const { ownerDocument, messages, lang, tooltip } = context;
1301
2088
  const icon = createElement(ownerDocument, "span", { class: "cd-shell-problems-icon" });
1302
2089
  const text = createElement(ownerDocument, "span", { class: "cd-shell-problems-text" });
1303
2090
  const element = createElement(
@@ -1308,7 +2095,7 @@ function createProblemsPill(context) {
1308
2095
  text
1309
2096
  );
1310
2097
  element.hidden = true;
1311
- element.addEventListener("click", () => options.onActivate());
2098
+ element.addEventListener("click", () => context.onActivate());
1312
2099
  const detachTooltip = context.showTooltip ? tooltip.attach(element, { label: messages.showProblems }) : void 0;
1313
2100
  let severity;
1314
2101
  return {
@@ -1412,6 +2199,10 @@ function createTooltip({ root, layer, id }) {
1412
2199
  let warmUntil = 0;
1413
2200
  let pressed;
1414
2201
  const detachers = /* @__PURE__ */ new Set();
2202
+ const resizeObserver = typeof ResizeObserver === "function" ? new ResizeObserver(() => {
2203
+ if (shown) place(shown.control);
2204
+ }) : void 0;
2205
+ resizeObserver?.observe(element);
1415
2206
  function cancelPending() {
1416
2207
  if (pending) clearTimeout(pending.timer);
1417
2208
  pending = void 0;
@@ -1579,6 +2370,381 @@ function createTooltip({ root, layer, id }) {
1579
2370
  destroy() {
1580
2371
  hide();
1581
2372
  for (const detach of [...detachers]) detach();
2373
+ resizeObserver?.disconnect();
2374
+ element.remove();
2375
+ }
2376
+ };
2377
+ }
2378
+
2379
+ // src/dom/web/drop-zone.ts
2380
+ var DRAG_EVENTS = ["dragenter", "dragover", "dragleave", "drop"];
2381
+ function carriesFiles(transfer) {
2382
+ if (!transfer) return false;
2383
+ if (Array.from(transfer.types ?? []).includes("Files")) return true;
2384
+ return Array.from(transfer.items ?? []).some((item) => item.kind === "file");
2385
+ }
2386
+ function dragItems(transfer) {
2387
+ const items = Array.from(transfer.items ?? [], (item) => ({ kind: item.kind, type: item.type }));
2388
+ return items.length > 0 ? items : [{ kind: "file", type: "" }];
2389
+ }
2390
+ function acceptList(accept, lang) {
2391
+ const entries = accept.map((entry) => entry.trim()).filter((entry) => entry !== "");
2392
+ const extensions = entries.filter((entry) => entry.startsWith("."));
2393
+ const listed = extensions.length > 0 ? extensions : entries;
2394
+ let format;
2395
+ try {
2396
+ format = new Intl.ListFormat(lang, { type: "disjunction" });
2397
+ } catch {
2398
+ format = new Intl.ListFormat("en", { type: "disjunction" });
2399
+ }
2400
+ return format.format(listed);
2401
+ }
2402
+ function createDropZone(context) {
2403
+ const { ownerDocument, layer, messages, lang, options, modalOpen, toast, setState } = context;
2404
+ const chip = createElement(ownerDocument, "span", { class: "cd-shell-drop-zone-chip" });
2405
+ const element = createElement(
2406
+ ownerDocument,
2407
+ "div",
2408
+ { class: "cd-shell-drop-zone", "data-chrome": "drop-zone", "aria-hidden": "true" },
2409
+ chip
2410
+ );
2411
+ element.hidden = true;
2412
+ layer.append(element);
2413
+ const view = ownerDocument.defaultView;
2414
+ let depth = 0;
2415
+ let current = null;
2416
+ function verdictFor(items) {
2417
+ return dragVerdict(items, {
2418
+ accept: options.accept,
2419
+ multiple: options.multiple ?? false,
2420
+ dialogOpen: modalOpen()
2421
+ });
2422
+ }
2423
+ function show(verdict) {
2424
+ const next = verdict.state === "none" ? null : verdict.state;
2425
+ if (verdict.state === "over") chip.textContent = messages.dropFile;
2426
+ if (verdict.state === "forbidden") chip.textContent = messages.dropForbidden(verdict.reason);
2427
+ if (next === current) return;
2428
+ current = next;
2429
+ element.hidden = next === null;
2430
+ if (next === null) element.removeAttribute("data-state");
2431
+ else element.setAttribute("data-state", next);
2432
+ setState(next);
2433
+ }
2434
+ function track(transfer) {
2435
+ const verdict = verdictFor(dragItems(transfer));
2436
+ show(verdict);
2437
+ transfer.dropEffect = verdict.state === "forbidden" ? "none" : "copy";
2438
+ }
2439
+ function rejectByDefault(files) {
2440
+ const accepted = acceptList(options.accept, lang);
2441
+ for (const file of files) toast({ status: "danger", text: messages.cannotOpenFile(file.name, accepted) });
2442
+ }
2443
+ function receive(files, verdict) {
2444
+ if (verdict.state === "forbidden" && verdict.reason !== "type") return;
2445
+ const accepted = files.filter((file) => acceptsFile(file, options.accept));
2446
+ const rejected = files.filter((file) => !accepted.includes(file));
2447
+ if (accepted.length > 0) options.onDrop(accepted);
2448
+ if (rejected.length > 0) (options.onReject ?? rejectByDefault)(rejected);
2449
+ }
2450
+ const onDragEvent = (event) => {
2451
+ const transfer = event.dataTransfer;
2452
+ if (!carriesFiles(transfer)) return;
2453
+ event.preventDefault();
2454
+ switch (event.type) {
2455
+ case "dragenter":
2456
+ depth += 1;
2457
+ track(transfer);
2458
+ break;
2459
+ case "dragover":
2460
+ depth = Math.max(depth, 1);
2461
+ track(transfer);
2462
+ break;
2463
+ case "dragleave":
2464
+ depth = Math.max(0, depth - 1);
2465
+ if (depth === 0) show({ state: "none" });
2466
+ break;
2467
+ case "drop": {
2468
+ depth = 0;
2469
+ show({ state: "none" });
2470
+ const files = Array.from(transfer.files ?? []);
2471
+ const items = files.length > 0 ? files.map((file) => ({ kind: "file", type: file.type })) : dragItems(transfer);
2472
+ receive(files, verdictFor(items));
2473
+ break;
2474
+ }
2475
+ }
2476
+ };
2477
+ for (const type of DRAG_EVENTS) view?.addEventListener(type, onDragEvent);
2478
+ return {
2479
+ element,
2480
+ state: () => current,
2481
+ destroy() {
2482
+ for (const type of DRAG_EVENTS) view?.removeEventListener(type, onDragEvent);
2483
+ depth = 0;
2484
+ if (current !== null) {
2485
+ current = null;
2486
+ setState(null);
2487
+ }
2488
+ element.remove();
2489
+ }
2490
+ };
2491
+ }
2492
+
2493
+ // src/dom/web/empty-state.ts
2494
+ var KOMET_SIZE = "64";
2495
+ function createEmptyState(context) {
2496
+ const { ownerDocument, messages, options, dropHint, open, titleId, onLeave } = context;
2497
+ const button = (variant, label, run) => {
2498
+ const element2 = createElement(
2499
+ ownerDocument,
2500
+ "button",
2501
+ { type: "button", class: "cd-shell-button", "data-variant": variant },
2502
+ label
2503
+ );
2504
+ element2.addEventListener("click", () => {
2505
+ onLeave();
2506
+ run();
2507
+ });
2508
+ return element2;
2509
+ };
2510
+ const onShowExample = options.onShowExample;
2511
+ const newDiagram = button("primary", messages.newDiagram, () => options.onNewDiagram());
2512
+ const element = createElement(
2513
+ ownerDocument,
2514
+ "section",
2515
+ { class: "cd-shell-empty-state", "data-chrome": "empty-state", "aria-labelledby": titleId },
2516
+ // Dragging the mark would start an image drag that some browsers carry as a file.
2517
+ createElement(ownerDocument, "img", {
2518
+ class: "cd-shell-empty-state-mark",
2519
+ src: options.komet,
2520
+ width: KOMET_SIZE,
2521
+ height: KOMET_SIZE,
2522
+ alt: "",
2523
+ draggable: "false"
2524
+ }),
2525
+ createElement(ownerDocument, "h1", { class: "cd-shell-empty-state-title", id: titleId }, options.title),
2526
+ createElement(ownerDocument, "p", { class: "cd-shell-empty-state-text" }, options.text),
2527
+ createElement(
2528
+ ownerDocument,
2529
+ "div",
2530
+ { class: "cd-shell-empty-state-actions" },
2531
+ newDiagram,
2532
+ onShowExample && button("ghost", messages.showExample, () => onShowExample())
2533
+ ),
2534
+ hintParagraph()
2535
+ );
2536
+ function hintParagraph() {
2537
+ const hasDropHint = dropHint !== void 0 && dropHint !== "";
2538
+ if (!hasDropHint && !open) return void 0;
2539
+ const paragraph = createElement(ownerDocument, "p", { class: "cd-shell-empty-state-hint" });
2540
+ if (hasDropHint) paragraph.append(dropHint);
2541
+ if (!open) return paragraph;
2542
+ const shortcut = open.shortcut?.trim();
2543
+ const link = createElement(
2544
+ ownerDocument,
2545
+ "button",
2546
+ {
2547
+ type: "button",
2548
+ class: "cd-shell-empty-state-open",
2549
+ "aria-keyshortcuts": shortcut ? ariaKeyShortcutsFromLabel(shortcut) : void 0,
2550
+ "aria-haspopup": open.dialog ? "dialog" : void 0
2551
+ },
2552
+ messages.openFileLink
2553
+ );
2554
+ link.addEventListener("click", () => open.onSelect());
2555
+ if (hasDropHint) {
2556
+ paragraph.append(" ", createElement(ownerDocument, "span", { "aria-hidden": "true" }, "\xB7"), " ");
2557
+ }
2558
+ const openItem = createElement(ownerDocument, "span", { class: "cd-shell-empty-state-open-item" }, link);
2559
+ if (shortcut) {
2560
+ openItem.append(
2561
+ " ",
2562
+ createElement(
2563
+ ownerDocument,
2564
+ "kbd",
2565
+ { class: "cd-shell-empty-state-key", "aria-hidden": "true" },
2566
+ shortcut
2567
+ )
2568
+ );
2569
+ }
2570
+ paragraph.append(openItem);
2571
+ return paragraph;
2572
+ }
2573
+ element.hidden = true;
2574
+ return {
2575
+ element,
2576
+ isShown: () => !element.hidden,
2577
+ setShown(shown) {
2578
+ element.hidden = !shown;
2579
+ },
2580
+ focus() {
2581
+ newDiagram.focus({ preventScroll: true });
2582
+ },
2583
+ destroy() {
2584
+ element.remove();
2585
+ }
2586
+ };
2587
+ }
2588
+
2589
+ // src/dom/web/legal.ts
2590
+ var MIRAGON_HOME = "https://www.miragon.io/";
2591
+ var MIRAGON_IMPRESSUM = "https://www.miragon.io/impressum/";
2592
+ var MIRAGON_DATENSCHUTZ = "https://www.miragon.io/datenschutz/";
2593
+ function createLegalLine(context) {
2594
+ const { ownerDocument, messages, options } = context;
2595
+ const year = options.year ?? (/* @__PURE__ */ new Date()).getFullYear();
2596
+ const newTabId = `${context.idPrefix}-new-tab`;
2597
+ const link = (label, href, lang) => createElement(
2598
+ ownerDocument,
2599
+ "a",
2600
+ { href, lang, target: "_blank", rel: "noopener noreferrer", "aria-describedby": newTabId },
2601
+ label
2602
+ );
2603
+ const separator = () => createElement(ownerDocument, "span", { "aria-hidden": "true" }, "\xB7");
2604
+ const item = (anchor) => createElement(ownerDocument, "span", { class: "cd-shell-legal-item" }, separator(), anchor);
2605
+ const links = [
2606
+ link(messages.copyright(year), MIRAGON_HOME),
2607
+ link("Impressum", MIRAGON_IMPRESSUM, "de"),
2608
+ link("Datenschutz", MIRAGON_DATENSCHUTZ, "de"),
2609
+ ...(options.links ?? []).map((extra) => link(extra.label, extra.href, extra.lang))
2610
+ ];
2611
+ const element = createElement(ownerDocument, "nav", {
2612
+ class: "cd-shell-legal",
2613
+ "data-chrome": "legal",
2614
+ "aria-label": messages.legal
2615
+ });
2616
+ element.append(
2617
+ ...links.map((anchor, index) => index === 0 ? anchor : item(anchor)),
2618
+ createElement(ownerDocument, "span", { id: newTabId, hidden: "" }, messages.opensInNewTab)
2619
+ );
2620
+ return {
2621
+ element,
2622
+ destroy() {
2623
+ element.remove();
2624
+ }
2625
+ };
2626
+ }
2627
+
2628
+ // src/dom/web/menu-groups.ts
2629
+ function fileEntry(spec, action) {
2630
+ const common = {
2631
+ id: spec.id,
2632
+ label: spec.label,
2633
+ ...spec.icon ? { icon: spec.icon } : {},
2634
+ ...action.disabled ? { disabled: action.disabled } : {},
2635
+ ...action.shortcut ? { shortcut: action.shortcut } : {},
2636
+ onSelect: () => action.onSelect()
2637
+ };
2638
+ return action.dialog ? { kind: "dialog", ...common } : { kind: "action", ...common };
2639
+ }
2640
+ function fileMenuEntries(options, messages) {
2641
+ const specs = [
2642
+ { option: "newDiagram", id: "new-diagram", label: messages.newDiagram, icon: "file-plus" },
2643
+ { option: "open", id: "open", label: messages.openFile, icon: "folder-open" },
2644
+ // §5.14 gives "Show example" no icon.
2645
+ { option: "showExample", id: "show-example", label: messages.showExample, icon: void 0 },
2646
+ { option: "import", id: "import", label: messages.importFile, icon: "import" }
2647
+ ];
2648
+ return specs.flatMap((spec) => {
2649
+ const action = options[spec.option];
2650
+ return action ? [fileEntry(spec, action)] : [];
2651
+ });
2652
+ }
2653
+ function appearanceMenuEntries(appearance, messages) {
2654
+ return [
2655
+ {
2656
+ kind: "radio",
2657
+ id: "appearance",
2658
+ label: messages.appearance,
2659
+ options: [
2660
+ { value: "system", label: messages.appearanceSystem },
2661
+ { value: "light", label: messages.appearanceLight },
2662
+ { value: "dark", label: messages.appearanceDark }
2663
+ ],
2664
+ value: () => appearance.choice(),
2665
+ onSelect: (value) => appearance.setChoice(parseAppearanceChoice(value))
2666
+ }
2667
+ ];
2668
+ }
2669
+
2670
+ // src/dom/web/primary.ts
2671
+ var COLLAPSED_ATTRIBUTE = "data-collapsed";
2672
+ function createPrimaryAction(context) {
2673
+ const { ownerDocument, messages, tooltip, options } = context;
2674
+ const label = options.label ?? messages.share;
2675
+ const labelElement = createElement(ownerDocument, "span", { class: "cd-shell-primary-label" }, label);
2676
+ const element = createElement(
2677
+ ownerDocument,
2678
+ "button",
2679
+ {
2680
+ type: "button",
2681
+ class: "cd-shell-primary cd-shell-button",
2682
+ "data-variant": "primary",
2683
+ "data-chrome": "primary",
2684
+ "aria-label": label
2685
+ },
2686
+ iconElement(options.icon ?? "share-2"),
2687
+ labelElement
2688
+ );
2689
+ let collapsed = false;
2690
+ let disabled = false;
2691
+ let labelledWidth;
2692
+ function readDisabled() {
2693
+ disabled = options.disabled?.() ?? false;
2694
+ toggleAttribute(element, "aria-disabled", disabled ? "true" : null);
2695
+ }
2696
+ function showCollapsed(state) {
2697
+ labelElement.hidden = state;
2698
+ toggleAttribute(element, COLLAPSED_ATTRIBUTE, state ? "" : null);
2699
+ }
2700
+ function measureLabelledWidth() {
2701
+ if (!collapsed) return element.offsetWidth;
2702
+ showCollapsed(false);
2703
+ const width = element.offsetWidth;
2704
+ showCollapsed(true);
2705
+ return width;
2706
+ }
2707
+ function isEngaged() {
2708
+ if (ownerDocument.activeElement === element) return true;
2709
+ try {
2710
+ return element.matches(":hover");
2711
+ } catch {
2712
+ return false;
2713
+ }
2714
+ }
2715
+ const onClick = (event) => {
2716
+ if (disabled) {
2717
+ event.preventDefault();
2718
+ return;
2719
+ }
2720
+ options.onSelect();
2721
+ };
2722
+ element.addEventListener("click", onClick);
2723
+ const detachTooltip = tooltip.attach(element, () => collapsed ? { label } : null);
2724
+ readDisabled();
2725
+ return {
2726
+ element,
2727
+ isCollapsed: () => collapsed,
2728
+ refresh() {
2729
+ readDisabled();
2730
+ labelledWidth = void 0;
2731
+ },
2732
+ widths() {
2733
+ if (!collapsed || labelledWidth === void 0) {
2734
+ const measured = measureLabelledWidth();
2735
+ if (measured > 0) labelledWidth = measured;
2736
+ }
2737
+ return { withLabel: labelledWidth ?? 0, iconOnly: SHELL.row };
2738
+ },
2739
+ setCollapsed(next) {
2740
+ if (next === collapsed) return;
2741
+ collapsed = next;
2742
+ showCollapsed(next);
2743
+ if (!next && isEngaged()) tooltip.hide();
2744
+ },
2745
+ destroy() {
2746
+ detachTooltip();
2747
+ element.removeEventListener("click", onClick);
1582
2748
  element.remove();
1583
2749
  }
1584
2750
  };
@@ -1701,6 +2867,16 @@ function nextMountIndex() {
1701
2867
  return index;
1702
2868
  }
1703
2869
  var DEFAULT_THEMES = ["light"];
2870
+ var LEGAL_HEIGHT_PROPERTY = "--modeler-shell-legal-height";
2871
+ var INERT_TOAST = Object.freeze({ dismiss() {
2872
+ } });
2873
+ function visibleBoxIn(element, root) {
2874
+ if (!element || element.hidden || !element.isConnected) return void 0;
2875
+ if (typeof element.checkVisibility === "function" && !element.checkVisibility({ visibilityProperty: true }))
2876
+ return void 0;
2877
+ const box = boxRelativeTo(element, root);
2878
+ return box.width > 0 && box.height > 0 ? box : void 0;
2879
+ }
1704
2880
  function languageOf(root, host, requested) {
1705
2881
  if (requested) return requested;
1706
2882
  const declared = host === "embedded" ? root.getAttribute("lang") : root.closest("[lang]")?.getAttribute("lang");
@@ -1729,6 +2905,7 @@ function renderShell(root, options, themes, ledger) {
1729
2905
  const platform = options.platform ?? detectPlatform(globalThis.navigator);
1730
2906
  const lang = languageOf(root, host, options.lang);
1731
2907
  const keyScope = options.keyScope ?? (host === "embedded" ? "root" : "document");
2908
+ const web = host === "web";
1732
2909
  ledger.addClass(root, "cd-shell");
1733
2910
  ledger.attribute(root, "data-cd-shell-host", host);
1734
2911
  ledger.attribute(root, "data-palette", "top");
@@ -1741,6 +2918,9 @@ function renderShell(root, options, themes, ledger) {
1741
2918
  const setState = (name, value) => {
1742
2919
  if (!ledger.isClosed) ledger.attribute(root, name, value);
1743
2920
  };
2921
+ const appearance = web ? connectWebAppearance({ ownerDocument, themes, options: options.appearance }) : void 0;
2922
+ if (appearance) ledger.defer(() => appearance.destroy());
2923
+ const darkCapable = themes.includes("dark");
1744
2924
  const layer = createElement(ownerDocument, "div", { class: "cd-shell-layer" });
1745
2925
  ledger.defer(() => layer.remove());
1746
2926
  const tooltip = createTooltip({ root, layer, id: `cd-shell-tooltip-${index}` });
@@ -1772,6 +2952,14 @@ function renderShell(root, options, themes, ledger) {
1772
2952
  keepEnabledWhenReadOnly: true
1773
2953
  } : void 0;
1774
2954
  const shellModelEntries = legendEntry ? [legendEntry] : [];
2955
+ const fileEntries = web && options.file ? fileMenuEntries(options.file, messages) : [];
2956
+ const appearanceEntries = appearance && darkCapable ? appearanceMenuEntries(appearance, messages) : [];
2957
+ const shellEntries = (group) => {
2958
+ if (group === "file") return fileEntries;
2959
+ if (group === "model") return shellModelEntries;
2960
+ if (group === "appearance") return appearanceEntries;
2961
+ return [];
2962
+ };
1775
2963
  const menu = createMenu({
1776
2964
  root,
1777
2965
  layer,
@@ -1779,55 +2967,133 @@ function renderShell(root, options, themes, ledger) {
1779
2967
  messages,
1780
2968
  tooltip,
1781
2969
  groups: options.menu ?? {},
1782
- shellModelEntries: () => shellModelEntries,
2970
+ shellEntries,
1783
2971
  isReadOnly: () => readOnly
1784
2972
  });
1785
2973
  ledger.defer(() => menu.destroy());
1786
2974
  ledger.defer(tooltip.attach(menu.trigger, () => menu.isOpen() ? null : { label: messages.menu }));
2975
+ const primary = web && options.primary ? createPrimaryAction({ ownerDocument, messages, tooltip, options: options.primary }) : void 0;
2976
+ if (primary) ledger.defer(() => primary.destroy());
1787
2977
  const canvas = options.canvas ?? createElement(ownerDocument, "div");
1788
2978
  ledger.addClass(canvas, "cd-shell-canvas");
2979
+ if (web && !canvas.hasAttribute("role")) ledger.attribute(canvas, "role", "main");
1789
2980
  if (!options.canvas) ledger.defer(() => canvas.remove());
2981
+ function focusCanvasOrTrigger() {
2982
+ const took = (target) => {
2983
+ target?.focus({ preventScroll: true });
2984
+ return target !== null && target !== void 0 && ownerDocument.activeElement === target;
2985
+ };
2986
+ if (emptyState?.isShown()) {
2987
+ emptyState.focus();
2988
+ if (emptyState.element.contains(ownerDocument.activeElement)) return;
2989
+ }
2990
+ if (took(canvas.querySelector("svg[tabindex]"))) return;
2991
+ if (!menu.trigger.hidden) took(menu.trigger);
2992
+ }
1790
2993
  function moveFocusOutOf(island) {
1791
- if (!island.contains(ownerDocument.activeElement)) return;
1792
- const canvasTarget = canvas.querySelector("svg[tabindex]");
1793
- canvasTarget?.focus({ preventScroll: true });
1794
- if (canvasTarget && ownerDocument.activeElement === canvasTarget) return;
1795
- if (!menu.trigger.hidden) menu.trigger.focus({ preventScroll: true });
2994
+ if (island.contains(ownerDocument.activeElement)) focusCanvasOrTrigger();
1796
2995
  }
1797
2996
  const status = createStatusBox(ownerDocument);
1798
2997
  ledger.defer(() => status.destroy());
1799
2998
  const hint = options.emptyHint ? createEmptyHint(ownerDocument, messages, options.emptyHint) : void 0;
1800
2999
  if (hint) ledger.defer(() => hint.destroy());
3000
+ const emptyState = web && options.emptyState ? createEmptyState({
3001
+ ownerDocument,
3002
+ messages,
3003
+ options: options.emptyState,
3004
+ dropHint: options.dropZone ? options.emptyState.dropHint ?? messages.dropHint : void 0,
3005
+ open: options.file?.open,
3006
+ titleId: `cd-shell-empty-state-title-${index}`,
3007
+ onLeave: () => setEmptyStateShown(false)
3008
+ }) : void 0;
3009
+ if (emptyState) ledger.defer(() => emptyState.destroy());
1801
3010
  const inspector = options.inspector ? createInspector(ownerDocument, options.inspector, (open) => {
1802
3011
  setState("data-inspector", open ? "open" : null);
3012
+ updateBottomRow();
1803
3013
  placeHint();
1804
3014
  }) : void 0;
1805
3015
  if (inspector) ledger.defer(() => inspector.destroy());
1806
- const pill = options.problems ? createProblemsPill({
3016
+ const problemsOptions = options.problems;
3017
+ let popover;
3018
+ const pill = problemsOptions ? createProblemsPill({
1807
3019
  ownerDocument,
1808
3020
  messages,
1809
3021
  lang,
1810
3022
  tooltip,
1811
3023
  showTooltip: host === "ide",
1812
- options: options.problems
3024
+ onActivate() {
3025
+ if (popover) popover.toggle();
3026
+ else problemsOptions.onActivate?.();
3027
+ }
1813
3028
  }) : void 0;
1814
3029
  if (pill) ledger.defer(() => pill.destroy());
3030
+ if (pill && (web || host === "embedded" && !problemsOptions?.onActivate)) {
3031
+ const created = createProblemsPopover({
3032
+ ownerDocument,
3033
+ root,
3034
+ layer,
3035
+ pill: pill.element,
3036
+ id: `cd-shell-problems-${index}`,
3037
+ titleId: `cd-shell-problems-title-${index}`,
3038
+ messages,
3039
+ onSelectItem: problemsOptions?.onSelectItem
3040
+ });
3041
+ popover = created;
3042
+ ledger.defer(() => created.destroy());
3043
+ }
3044
+ const legal = web ? createLegalLine({
3045
+ ownerDocument,
3046
+ messages,
3047
+ options: options.legal ?? {},
3048
+ idPrefix: `cd-shell-legal-${index}`
3049
+ }) : void 0;
3050
+ if (legal) ledger.defer(() => legal.destroy());
1815
3051
  const zoomPanel = createZoomPanel({ ownerDocument, messages, lang, platform, tooltip });
1816
3052
  ledger.defer(() => zoomPanel.destroy());
1817
- root.prepend(menu.trigger, canvas);
3053
+ const toaster = host === "ide" ? void 0 : createToaster({
3054
+ ownerDocument,
3055
+ host,
3056
+ messages,
3057
+ idPrefix: `cd-shell-toast-${index}`,
3058
+ focusFallback: focusCanvasOrTrigger,
3059
+ onChange: () => updateBottomRow()
3060
+ });
3061
+ if (toaster) ledger.defer(() => toaster.destroy());
3062
+ const defined = (element) => element !== void 0;
3063
+ root.prepend(...[menu.trigger, primary?.element, canvas].filter(defined));
1818
3064
  root.append(
1819
3065
  ...[
1820
3066
  status.element,
1821
3067
  hint?.element,
3068
+ emptyState?.element,
1822
3069
  inspector?.element,
1823
3070
  pill?.element,
1824
3071
  legend?.element,
3072
+ legal?.element,
1825
3073
  zoomPanel.element
1826
- ].filter((element) => element !== void 0),
1827
- layer
3074
+ ].filter(defined),
3075
+ layer,
3076
+ ...[toaster?.announcer, toaster?.element].filter(defined)
1828
3077
  );
3078
+ function showToast(request) {
3079
+ return ledger.isClosed || !toaster ? INERT_TOAST : toaster.show(request);
3080
+ }
3081
+ const dropZone = web && options.dropZone ? createDropZone({
3082
+ ownerDocument,
3083
+ layer,
3084
+ messages,
3085
+ lang,
3086
+ options: options.dropZone,
3087
+ modalOpen: () => isModalOpen(ownerDocument),
3088
+ toast: showToast,
3089
+ setState: (state) => setState("data-drop", state)
3090
+ }) : void 0;
3091
+ if (dropZone) ledger.defer(() => dropZone.destroy());
1829
3092
  let palette;
1830
3093
  let paletteRow = "top";
3094
+ let zoomRaised = false;
3095
+ let legalHeight = LEGAL_LINE_HEIGHT;
3096
+ if (legal) ledger.defer(() => root.style.removeProperty(LEGAL_HEIGHT_PROPERTY));
1831
3097
  function containerSize() {
1832
3098
  return { width: root.clientWidth, height: root.clientHeight };
1833
3099
  }
@@ -1847,6 +3113,10 @@ function renderShell(root, options, themes, ledger) {
1847
3113
  paletteRow,
1848
3114
  problemsVisible: pill?.isShown() ?? false
1849
3115
  };
3116
+ if (web) {
3117
+ layout.zoomRaised = zoomRaised;
3118
+ layout.legalHeight = legalHeight;
3119
+ }
1850
3120
  if (legend?.isOpen()) layout.legendWidth = legend.element.getBoundingClientRect().width;
1851
3121
  if (inspector?.isOpen()) {
1852
3122
  layout.inspector = container.width > SHELL.narrow ? {} : { sheetTop: boxRelativeTo(inspector.element, root).y };
@@ -1860,18 +3130,76 @@ function renderShell(root, options, themes, ledger) {
1860
3130
  if (ledger.isClosed) return;
1861
3131
  const size = containerSize();
1862
3132
  if (size.width === 0 || size.height === 0) return;
1863
- paletteRow = decidePaletteRow({
3133
+ const topRow = decideTopRow({
1864
3134
  containerWidth: size.width,
1865
3135
  paletteWidth: paletteWidth(),
1866
- menuRight: menuRight()
3136
+ menuRight: menuRight(),
3137
+ primary: primary?.widths()
1867
3138
  });
3139
+ paletteRow = topRow.paletteRow;
1868
3140
  setState("data-palette", paletteRow);
3141
+ if (primary) {
3142
+ const collapsed = topRow.primaryLabel === "collapsed";
3143
+ primary.setCollapsed(collapsed);
3144
+ setState("data-primary", collapsed ? "collapsed" : null);
3145
+ }
1869
3146
  if (legend && !legend.isStarted()) legend.start(options.legend?.open ?? legendStartsOpen(size.height));
3147
+ updateBottomRow();
1870
3148
  placeHint();
1871
3149
  }
3150
+ function updateBottomRow() {
3151
+ if (ledger.isClosed) return;
3152
+ const size = containerSize();
3153
+ if (size.width === 0 || size.height === 0) return;
3154
+ if (legal) {
3155
+ const legalBox = visibleBoxIn(legal.element, root);
3156
+ const height = legalLineHeight(legalBox?.height);
3157
+ if (height !== legalHeight || !root.style.getPropertyValue(LEGAL_HEIGHT_PROPERTY)) {
3158
+ legalHeight = height;
3159
+ root.style.setProperty(LEGAL_HEIGHT_PROPERTY, `${height}px`);
3160
+ }
3161
+ zoomRaised = zoomRowRaised({
3162
+ containerWidth: size.width,
3163
+ legalRight: legalBox && legalBox.x + legalBox.width,
3164
+ zoomWidth: visibleBoxIn(zoomPanel.element, root)?.width
3165
+ });
3166
+ setState("data-zoom-row", zoomRaised ? "raised" : null);
3167
+ }
3168
+ if (toaster) {
3169
+ const obstacles = [zoomPanel.element, legal?.element, pill?.element].flatMap(
3170
+ (element) => {
3171
+ const box = visibleBoxIn(element, root);
3172
+ return box ? [{ left: box.x, right: box.x + box.width, top: size.height - box.y }] : [];
3173
+ }
3174
+ );
3175
+ toaster.setBottom(
3176
+ toastStackBottom({ containerWidth: size.width, obstacles, cardWidth: toaster.widestCard() })
3177
+ );
3178
+ }
3179
+ }
3180
+ function setEmptyStateShown(shown) {
3181
+ if (ledger.isClosed || !emptyState || emptyState.isShown() === shown) return;
3182
+ const active = ownerDocument.activeElement;
3183
+ const hadFocus = emptyState.element.contains(active);
3184
+ const workHadFocus = shown && active !== null && root.contains(active) && ![emptyState.element, legal?.element, toaster?.element].some((kept) => kept?.contains(active));
3185
+ emptyState.setShown(shown);
3186
+ setState("data-empty-state", shown ? "shown" : null);
3187
+ ledger.attribute(canvas, "inert", shown ? "" : null);
3188
+ if (shown) {
3189
+ menu.close();
3190
+ popover?.close();
3191
+ tooltip.hide();
3192
+ if (workHadFocus) emptyState.focus();
3193
+ } else if (hadFocus) {
3194
+ focusCanvasOrTrigger();
3195
+ }
3196
+ updateLayout();
3197
+ }
1872
3198
  const ResizeObserverClass = globalThis.ResizeObserver;
1873
3199
  const resizeObserver = ResizeObserverClass ? new ResizeObserverClass(() => updateLayout()) : void 0;
1874
3200
  resizeObserver?.observe(root);
3201
+ if (primary) resizeObserver?.observe(primary.element);
3202
+ if (legal) resizeObserver?.observe(legal.element);
1875
3203
  ledger.defer(() => resizeObserver?.disconnect());
1876
3204
  if (legend && options.legend?.open !== void 0) legend.start(options.legend.open);
1877
3205
  updateLayout();
@@ -1880,12 +3208,16 @@ function renderShell(root, options, themes, ledger) {
1880
3208
  host,
1881
3209
  dialogScope: keyTarget,
1882
3210
  menuOpen: () => menu.isOpen(),
3211
+ startScreen: () => emptyState?.isShown() ?? false,
1883
3212
  closeMenu() {
1884
3213
  const active = ownerDocument.activeElement;
1885
3214
  menu.close({ restoreFocus: active === menu.trigger || menu.dropdown.contains(active) });
1886
3215
  },
1887
3216
  hideTooltip: () => tooltip.hide(),
1888
- run: (key) => zoomPanel.run(key)
3217
+ // Behind the start screen the keys reach the browser instead: there is no diagram to zoom.
3218
+ run: (key) => {
3219
+ if (!emptyState?.isShown()) zoomPanel.run(key);
3220
+ }
1889
3221
  });
1890
3222
  keyTarget.addEventListener("keydown", onKeydown);
1891
3223
  ledger.defer(() => keyTarget.removeEventListener("keydown", onKeydown));
@@ -1937,6 +3269,21 @@ function renderShell(root, options, themes, ledger) {
1937
3269
  if (!ledger.isClosed) legend.toggle();
1938
3270
  }
1939
3271
  };
3272
+ const primaryHandle = primary && {
3273
+ element: primary.element,
3274
+ isCollapsed: () => primary.isCollapsed(),
3275
+ refresh: () => {
3276
+ if (!ledger.isClosed) primary.refresh();
3277
+ }
3278
+ };
3279
+ const appearanceHandle = appearance && darkCapable ? {
3280
+ choice: () => appearance.choice(),
3281
+ setChoice: (choice) => {
3282
+ if (ledger.isClosed) return;
3283
+ appearance.setChoice(choice);
3284
+ menu.refresh();
3285
+ }
3286
+ } : void 0;
1940
3287
  const inspectorHandle = inspector && {
1941
3288
  element: inspector.element,
1942
3289
  isOpen: () => inspector.isOpen(),
@@ -1960,6 +3307,8 @@ function renderShell(root, options, themes, ledger) {
1960
3307
  tooltip: tooltipHandle,
1961
3308
  legend: legendHandle,
1962
3309
  inspector: inspectorHandle,
3310
+ primary: primaryHandle,
3311
+ appearance: appearanceHandle,
1963
3312
  setZoomTarget(target) {
1964
3313
  if (!ledger.isClosed) zoomPanel.setTarget(target);
1965
3314
  },
@@ -1988,8 +3337,15 @@ function renderShell(root, options, themes, ledger) {
1988
3337
  },
1989
3338
  setProblems(summary) {
1990
3339
  if (ledger.isClosed || !pill) return;
3340
+ const listHadFocus = popover?.element.contains(ownerDocument.activeElement) ?? false;
1991
3341
  pill.set(summary);
1992
3342
  setState("data-problems", pill.isShown() ? "shown" : null);
3343
+ popover?.setItems(summary.items ?? []);
3344
+ if (!pill.isShown() && popover?.isOpen()) {
3345
+ popover.close();
3346
+ if (listHadFocus) focusCanvasOrTrigger();
3347
+ }
3348
+ updateBottomRow();
1993
3349
  placeHint();
1994
3350
  },
1995
3351
  setStatus(next, statusOptions) {
@@ -2019,6 +3375,9 @@ function renderShell(root, options, themes, ledger) {
2019
3375
  applyReadOnly();
2020
3376
  },
2021
3377
  isReadOnly: () => readOnly,
3378
+ setEmptyState: setEmptyStateShown,
3379
+ isEmptyStateShown: () => emptyState?.isShown() ?? false,
3380
+ toast: showToast,
2022
3381
  destroy() {
2023
3382
  ledger.close();
2024
3383
  }
@@ -2077,6 +3436,12 @@ function withDestroyHook(shell, beforeDestroy) {
2077
3436
  get inspector() {
2078
3437
  return shell.inspector;
2079
3438
  },
3439
+ get primary() {
3440
+ return shell.primary;
3441
+ },
3442
+ get appearance() {
3443
+ return shell.appearance;
3444
+ },
2080
3445
  setZoomTarget: (target) => shell.setZoomTarget(target),
2081
3446
  fitInsets: () => shell.fitInsets(),
2082
3447
  registerPalette: (palette) => shell.registerPalette(palette),
@@ -2085,6 +3450,9 @@ function withDestroyHook(shell, beforeDestroy) {
2085
3450
  setEmptyHint: (visible) => shell.setEmptyHint(visible),
2086
3451
  setReadOnly: (readOnly, options) => shell.setReadOnly(readOnly, options),
2087
3452
  isReadOnly: () => shell.isReadOnly(),
3453
+ setEmptyState: (shown) => shell.setEmptyState(shown),
3454
+ isEmptyStateShown: () => shell.isEmptyStateShown(),
3455
+ toast: (request) => shell.toast(request),
2088
3456
  destroy() {
2089
3457
  beforeDestroy();
2090
3458
  shell.destroy();