@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
@@ -0,0 +1,435 @@
1
+ /*
2
+ * Feedback of @miragon/modeler-shell (owned by src/dom/feedback): the toast stack and the problems
3
+ * popover (guide §5.10, §8, §9). Colours only from var(--cd-ui-*), sizes from var(--cd-shell-*).
4
+ * Toasts render in the web and embedded hosts, so their rules also reset what a foreign page's
5
+ * element rules would set (font, colour, letter spacing, text transform), like shell-dom.css.
6
+ * The toast action is a .cd-shell-button (buttons.css), which resets itself and is left out here.
7
+ * Private helper properties are named --modeler-shell-*, never --cd-*.
8
+ */
9
+
10
+ /* ---- Foreign-page reset --------------------------------------------------------------------- */
11
+
12
+ :where(.cd-shell)
13
+ :is(
14
+ .cd-shell-toasts,
15
+ .cd-shell-toast,
16
+ .cd-shell-toast-text,
17
+ .cd-shell-toast-close,
18
+ .cd-shell-problems-popover,
19
+ .cd-shell-problems-popover-title,
20
+ .cd-shell-problems-list,
21
+ .cd-shell-problem,
22
+ .cd-shell-problem-message,
23
+ .cd-shell-problem-location
24
+ ) {
25
+ color: inherit;
26
+ font: inherit;
27
+ letter-spacing: normal;
28
+ word-spacing: normal;
29
+ text-transform: none;
30
+ text-indent: 0;
31
+ text-shadow: none;
32
+ }
33
+
34
+ /* ---- Toast stack (guide §8, §9) ------------------------------------------------------------- */
35
+
36
+ /*
37
+ * Centred column at the bottom; the script sets `bottom` (toastStackBottom). In the web host it is
38
+ * a manual popover in the top layer, so the UA's popover box (fixed, centred, bordered, padded,
39
+ * Canvas colours) is reset; in the embedded host it is absolute in the root at layer 60.
40
+ */
41
+ .cd-shell-toasts {
42
+ position: absolute;
43
+ top: auto;
44
+ right: 0;
45
+ bottom: var(--cd-shell-rand);
46
+ left: 0;
47
+ z-index: var(--cd-shell-ebene-toast);
48
+ box-sizing: border-box;
49
+ display: flex;
50
+ flex-direction: column;
51
+ align-items: center;
52
+ gap: var(--cd-shell-luecke);
53
+ width: min(480px, 100cqw - 2 * var(--cd-shell-rand));
54
+ height: auto;
55
+ min-height: 0;
56
+ max-height: none;
57
+ margin: 0 auto;
58
+ padding: 0;
59
+ overflow: visible;
60
+ color: var(--cd-ui-text);
61
+ background: transparent;
62
+ border: 0;
63
+ pointer-events: none;
64
+ }
65
+
66
+ /* Always in the DOM, no box while empty (data-chrome="toasts" measures nothing then). */
67
+ .cd-shell-toasts:empty {
68
+ display: none;
69
+ }
70
+
71
+ /* The author display above would otherwise show a closed popover; engines without it drop the rule. */
72
+ .cd-shell-toasts[popover]:not(:popover-open) {
73
+ display: none;
74
+ }
75
+
76
+ .cd-shell-toast {
77
+ --modeler-shell-toast-status: var(--cd-ui-info);
78
+ position: relative;
79
+ box-sizing: border-box;
80
+ display: flex;
81
+ flex: none;
82
+ align-items: center;
83
+ gap: var(--cd-shell-luecke);
84
+ max-width: 100%;
85
+ min-height: var(--cd-shell-zeile);
86
+ margin: 0;
87
+ padding: 4px 4px 4px 16px;
88
+ overflow: hidden;
89
+ color: var(--cd-ui-text);
90
+ font-size: 13px;
91
+ font-weight: 400;
92
+ line-height: 1.5;
93
+ text-align: left;
94
+ background: var(--cd-ui-flaeche-hoch);
95
+ border: 1px solid var(--cd-ui-linie);
96
+ border-radius: var(--cd-radius-md);
97
+ box-shadow: var(--cd-shadow-3);
98
+ pointer-events: auto;
99
+ user-select: none;
100
+ -webkit-user-select: none;
101
+ animation: cd-shell-toast-in var(--cd-motion-base) var(--cd-ease-out);
102
+ }
103
+
104
+ .cd-shell-toast[data-status="success"] {
105
+ --modeler-shell-toast-status: var(--cd-ui-success);
106
+ }
107
+
108
+ .cd-shell-toast[data-status="warning"] {
109
+ --modeler-shell-toast-status: var(--cd-ui-warning);
110
+ }
111
+
112
+ .cd-shell-toast[data-status="danger"] {
113
+ --modeler-shell-toast-status: var(--cd-ui-danger);
114
+ }
115
+
116
+ /* Its own element, clipped by the card: a border-left would follow the radius into a crescent. */
117
+ .cd-shell-toast::before {
118
+ content: "";
119
+ position: absolute;
120
+ top: 0;
121
+ bottom: 0;
122
+ left: 0;
123
+ width: 4px;
124
+ background: var(--modeler-shell-toast-status);
125
+ }
126
+
127
+ .cd-shell-toast-dot {
128
+ flex: none;
129
+ width: 8px;
130
+ height: 8px;
131
+ background: var(--modeler-shell-toast-status);
132
+ border-radius: var(--cd-radius-pill);
133
+ }
134
+
135
+ .cd-shell-toast-text {
136
+ flex: 1 1 auto;
137
+ min-width: 0;
138
+ margin: 0;
139
+ padding: 6px 0;
140
+ color: var(--cd-ui-text);
141
+ font-size: 13px;
142
+ line-height: 1.5;
143
+ overflow-wrap: anywhere;
144
+ }
145
+
146
+ /* A 32px ghost button (buttons.css), before Close. */
147
+ .cd-shell-toast .cd-shell-toast-action {
148
+ height: var(--cd-shell-ziel);
149
+ }
150
+
151
+ .cd-shell-toast-close {
152
+ box-sizing: border-box;
153
+ display: inline-grid;
154
+ flex: none;
155
+ place-items: center;
156
+ width: var(--cd-shell-ziel);
157
+ height: var(--cd-shell-ziel);
158
+ margin: 0;
159
+ padding: 0;
160
+ color: var(--cd-ui-text);
161
+ background: transparent;
162
+ border: 0;
163
+ border-radius: var(--cd-radius-sm);
164
+ cursor: pointer;
165
+ transition:
166
+ background-color var(--cd-motion-fast) var(--cd-ease),
167
+ color var(--cd-motion-fast) var(--cd-ease);
168
+ }
169
+
170
+ .cd-shell-toast-close:hover {
171
+ color: var(--cd-ui-interaktion);
172
+ background: var(--cd-ui-hover);
173
+ }
174
+
175
+ .cd-shell-toast-close svg {
176
+ display: block;
177
+ flex: none;
178
+ }
179
+
180
+ .cd-shell-toast[data-leaving] {
181
+ pointer-events: none;
182
+ animation: cd-shell-toast-out var(--cd-motion-base) var(--cd-ease) forwards;
183
+ }
184
+
185
+ @keyframes cd-shell-toast-in {
186
+ from {
187
+ opacity: 0;
188
+ transform: translateY(8px);
189
+ }
190
+ }
191
+
192
+ @keyframes cd-shell-toast-out {
193
+ to {
194
+ opacity: 0;
195
+ transform: translateY(8px);
196
+ }
197
+ }
198
+
199
+ /* The toasts' live regions: in the accessibility tree at all times, never on screen. */
200
+ .cd-shell-live {
201
+ position: absolute;
202
+ width: 1px;
203
+ height: 1px;
204
+ margin: -1px;
205
+ padding: 0;
206
+ overflow: hidden;
207
+ clip-path: inset(50%);
208
+ white-space: nowrap;
209
+ border: 0;
210
+ pointer-events: none;
211
+ }
212
+
213
+ /* ---- Problems popover (guide §5.10) --------------------------------------------------------- */
214
+
215
+ /* Left-aligned with the pill, its bottom 8px above the pill's top: 12 + 40 + 8, web 12 + 20 + 8 + 40 + 8. */
216
+ .cd-shell-problems-popover {
217
+ --modeler-shell-problems-bottom: calc(
218
+ var(--cd-shell-rand) + var(--cd-shell-zeile) + var(--cd-shell-luecke)
219
+ );
220
+ position: absolute;
221
+ left: var(--cd-shell-rand);
222
+ bottom: var(--modeler-shell-problems-bottom);
223
+ box-sizing: border-box;
224
+ display: flex;
225
+ flex-direction: column;
226
+ width: min(320px, 100cqw - 2 * var(--cd-shell-rand));
227
+ max-height: min(320px, 100cqh - var(--modeler-shell-problems-bottom) - var(--cd-shell-rand));
228
+ margin: 0;
229
+ padding: 6px;
230
+ color: var(--cd-ui-text);
231
+ font-size: 13px;
232
+ font-weight: 400;
233
+ line-height: 1.5;
234
+ background: var(--cd-ui-flaeche-hoch);
235
+ border: 1px solid var(--cd-ui-linie);
236
+ border-radius: var(--cd-radius-md);
237
+ box-shadow: var(--cd-shadow-3);
238
+ pointer-events: auto;
239
+ user-select: none;
240
+ -webkit-user-select: none;
241
+ animation: cd-shell-problems-in var(--cd-motion-fast) var(--cd-ease-out);
242
+ }
243
+
244
+ .cd-shell[data-cd-shell-host="web"] .cd-shell-problems-popover {
245
+ --modeler-shell-problems-bottom: calc(
246
+ var(--cd-shell-rand) + var(--modeler-shell-legal-height, 20px) + var(--cd-shell-luecke) +
247
+ var(--cd-shell-zeile) + var(--cd-shell-luecke)
248
+ );
249
+ }
250
+
251
+ .cd-shell-problems-popover[hidden] {
252
+ display: none;
253
+ }
254
+
255
+ /* Focus lands on the dialog itself only when it has no row to take it; inside, as in a scroller. */
256
+ .cd-shell-problems-popover:focus {
257
+ outline: none;
258
+ }
259
+
260
+ .cd-shell-problems-popover:focus-visible {
261
+ outline: 2px solid var(--cd-ui-fokus);
262
+ outline-offset: -2px;
263
+ }
264
+
265
+ .cd-shell-problems-popover-title {
266
+ flex: none;
267
+ margin: 0;
268
+ padding: 8px 12px 4px;
269
+ color: var(--cd-ui-text);
270
+ font-size: 13px;
271
+ font-weight: 600;
272
+ line-height: 1.5;
273
+ }
274
+
275
+ .cd-shell-problems-list {
276
+ flex: 1 1 auto;
277
+ min-height: 0;
278
+ margin: 0;
279
+ padding: 0;
280
+ overflow-y: auto;
281
+ overscroll-behavior: contain;
282
+ list-style: none;
283
+ }
284
+
285
+ .cd-shell-problems-list > li {
286
+ display: block;
287
+ margin: 0;
288
+ padding: 0;
289
+ list-style: none;
290
+ }
291
+
292
+ .cd-shell-problem {
293
+ box-sizing: border-box;
294
+ display: grid;
295
+ grid-template-columns: 16px minmax(0, 1fr);
296
+ column-gap: 12px;
297
+ align-items: start;
298
+ width: 100%;
299
+ min-height: var(--cd-shell-eintrag);
300
+ margin: 0;
301
+ padding: 8px 12px;
302
+ color: var(--cd-ui-text);
303
+ font-size: 13px;
304
+ font-weight: 400;
305
+ line-height: 1.5;
306
+ text-align: left;
307
+ background: transparent;
308
+ border: 0;
309
+ border-radius: var(--cd-radius-sm);
310
+ }
311
+
312
+ button.cd-shell-problem {
313
+ cursor: pointer;
314
+ transition: background-color var(--cd-motion-fast) var(--cd-ease);
315
+ }
316
+
317
+ button.cd-shell-problem:hover,
318
+ button.cd-shell-problem:focus-visible {
319
+ background: var(--cd-ui-hover);
320
+ }
321
+
322
+ /* The pill's icon, centred on the message's first line (19.5px). */
323
+ .cd-shell-problem-icon {
324
+ display: grid;
325
+ grid-row: 1 / span 2;
326
+ place-items: center;
327
+ width: 16px;
328
+ height: 16px;
329
+ margin-top: 2px;
330
+ }
331
+
332
+ .cd-shell-problem-icon svg {
333
+ display: block;
334
+ flex: none;
335
+ }
336
+
337
+ .cd-shell-problem[data-severity="error"] .cd-shell-problem-icon {
338
+ color: var(--cd-ui-danger);
339
+ }
340
+
341
+ .cd-shell-problem[data-severity="warning"] .cd-shell-problem-icon {
342
+ color: var(--cd-ui-warning);
343
+ }
344
+
345
+ .cd-shell-problem-message {
346
+ grid-column: 2;
347
+ color: var(--cd-ui-text);
348
+ overflow-wrap: anywhere;
349
+ }
350
+
351
+ .cd-shell-problem-location {
352
+ grid-column: 2;
353
+ color: var(--cd-ui-text-leise);
354
+ overflow-wrap: anywhere;
355
+ }
356
+
357
+ @keyframes cd-shell-problems-in {
358
+ from {
359
+ opacity: 0;
360
+ transform: translateY(4px);
361
+ }
362
+ }
363
+
364
+ /* ---- Focus (guide §8): inside the dense toast and the scrolling list ------------------------ */
365
+
366
+ .cd-shell :is(.cd-shell-toast-close, .cd-shell-problem):focus {
367
+ outline: none;
368
+ }
369
+
370
+ .cd-shell :is(.cd-shell-toast-close, .cd-shell-toast-action, .cd-shell-problem):focus-visible {
371
+ outline: 2px solid var(--cd-ui-fokus);
372
+ outline-offset: -2px;
373
+ }
374
+
375
+ /* ---- High contrast (guide §5.3): no shadows, 3px focus -------------------------------------- */
376
+
377
+ .cd-shell:is([data-cd-kontrast="hoch"], [data-cd-kontrast="hoch"] *)
378
+ :is(.cd-shell-toast, .cd-shell-problems-popover) {
379
+ box-shadow: none;
380
+ }
381
+
382
+ .cd-shell:is([data-cd-kontrast="hoch"], [data-cd-kontrast="hoch"] *)
383
+ :is(
384
+ .cd-shell-toast-close,
385
+ .cd-shell-toast-action,
386
+ .cd-shell-problem,
387
+ .cd-shell-problems-popover
388
+ ):focus-visible {
389
+ outline-width: 3px;
390
+ outline-offset: -3px;
391
+ }
392
+
393
+ /* ---- Forced colours (guide §5.14): nothing disappears --------------------------------------- */
394
+
395
+ @media (forced-colors: active) {
396
+ .cd-shell :is(.cd-shell-toast, .cd-shell-problems-popover) {
397
+ border-color: CanvasText;
398
+ }
399
+
400
+ /* Bar and dot keep their shape; the text carries the status. */
401
+ .cd-shell .cd-shell-toast::before,
402
+ .cd-shell .cd-shell-toast-dot {
403
+ forced-color-adjust: none;
404
+ background: CanvasText;
405
+ }
406
+
407
+ .cd-shell
408
+ :is(
409
+ .cd-shell-toast-close,
410
+ .cd-shell-toast-action,
411
+ .cd-shell-problem,
412
+ .cd-shell-problems-popover
413
+ ):focus-visible {
414
+ outline-color: Highlight;
415
+ }
416
+
417
+ /* The hover fill is forced away; a thin ring shows which row the pointer is on. */
418
+ .cd-shell button.cd-shell-problem:hover:not(:focus-visible) {
419
+ outline: 1px solid Highlight;
420
+ outline-offset: -1px;
421
+ }
422
+ }
423
+
424
+ /* ---- Reduced motion (guide §7): no slide, no pop-in ----------------------------------------- */
425
+
426
+ @media (prefers-reduced-motion: reduce) {
427
+ .cd-shell :is(.cd-shell-toast, .cd-shell-problems-popover) {
428
+ animation: none;
429
+ }
430
+ }
431
+
432
+ /* The host asks for it although the OS does not: VS Code's workbench.reduceMotion. */
433
+ html.cd-shell-reduce-motion .cd-shell :is(.cd-shell-toast, .cd-shell-problems-popover) {
434
+ animation: none;
435
+ }
@@ -0,0 +1,287 @@
1
+ import { matchChromeKey, type ProblemItem, type ShellMessages } from "../../core/index.js";
2
+ import { createElement } from "../element.js";
3
+ import { iconElement } from "../icons.js";
4
+
5
+ export interface ProblemsPopoverContext {
6
+ ownerDocument: Document;
7
+ /** The shell root: outside clicks and the window's blur close the popover, as the menu does. */
8
+ root: HTMLElement;
9
+ /** The shell's layer (40): the popover lives there, like the dropdown. */
10
+ layer: HTMLElement;
11
+ /** The pill, its trigger: gets `aria-haspopup="dialog"`, `aria-expanded` and `aria-controls`. */
12
+ pill: HTMLButtonElement;
13
+ /** `cd-shell-problems-N` on the popover. */
14
+ id: string;
15
+ /** `cd-shell-problems-title-N` on its title. */
16
+ titleId: string;
17
+ messages: ShellMessages;
18
+ onSelectItem?: ((item: ProblemItem) => void) | undefined;
19
+ }
20
+
21
+ export interface ProblemsPopover {
22
+ /** The `<div data-chrome="problems-popover" role="dialog">`. */
23
+ readonly element: HTMLElement;
24
+ isOpen(): boolean;
25
+ /** Open (focus on the first row, else the popover) or close (focus back to the pill). */
26
+ toggle(): void;
27
+ open(): void;
28
+ close(options?: { restoreFocus?: boolean | undefined }): void;
29
+ /** The rows; while open the list re-renders and keeps the focused row. */
30
+ setItems(items: readonly ProblemItem[]): void;
31
+ destroy(): void;
32
+ }
33
+
34
+ interface Row {
35
+ /** The same problem across `setItems`, so a re-render keeps the focus on it. */
36
+ key: string;
37
+ /** A `<button>` for a row with an element, else a `<div tabindex="-1">`. */
38
+ element: HTMLElement;
39
+ }
40
+
41
+ const PILL_ATTRIBUTES = ["aria-haspopup", "aria-expanded", "aria-controls"] as const;
42
+
43
+ function isPrintable(event: KeyboardEvent): boolean {
44
+ return event.key.length === 1 && /\S/.test(event.key) && !event.ctrlKey && !event.metaKey && !event.altKey;
45
+ }
46
+
47
+ /**
48
+ * The web app's problems list (guide §5.10): a popover above the pill, left-aligned with it
49
+ * (`left: 12px`, its bottom 8px above the pill's top), `role="dialog"` without `aria-modal`,
50
+ * `aria-labelledby` its title `messages.problemsTitle` (13px/600); opaque `--cd-ui-flaeche-hoch`,
51
+ * 1px `--cd-ui-linie`, radius `md`, `shadow-3`, `min(320px, 100cqw − 24px)` wide, at most 320px
52
+ * high with a scrolling list; pop-in 150ms from `translateY(4px)`. A row is at least 36px: the 16px
53
+ * icon of the pill (`circle-x` in `--cd-ui-danger`, `triangle-alert` in `--cd-ui-warning`), the
54
+ * message 13px `--cd-ui-text`, below it the location 13px `--cd-ui-text-leise`. Rows with an
55
+ * `elementId` are buttons: a click closes the popover, returns the focus to the canvas and then
56
+ * calls `onSelectItem(item)`; rows without one are text that still takes the focus (`tabindex="-1"`),
57
+ * so the keyboard reads every row. ↑/↓/Home/End move between all rows and scroll the focused one
58
+ * into the list; Enter and Space act only on a row button. Escape (the popover's own handler) and
59
+ * Tab out close it with the focus back on the pill (Tab then moves on from the pill, Shift+Tab stays
60
+ * on it); an outside `pointerdown` (capture) and the window's blur close it without moving the
61
+ * focus. The zoom keys keep working (not modal).
62
+ */
63
+ export function createProblemsPopover(context: ProblemsPopoverContext): ProblemsPopover {
64
+ const { ownerDocument, root, layer, pill, id, titleId, messages } = context;
65
+ const view = ownerDocument.defaultView;
66
+
67
+ const list = createElement(ownerDocument, "ul", { class: "cd-shell-problems-list", role: "list" });
68
+ const element = createElement(
69
+ ownerDocument,
70
+ "div",
71
+ {
72
+ class: "cd-shell-problems-popover",
73
+ "data-chrome": "problems-popover",
74
+ role: "dialog",
75
+ id,
76
+ "aria-labelledby": titleId,
77
+ tabindex: "-1",
78
+ },
79
+ createElement(
80
+ ownerDocument,
81
+ "h2",
82
+ { class: "cd-shell-problems-popover-title", id: titleId },
83
+ messages.problemsTitle,
84
+ ),
85
+ list,
86
+ );
87
+ element.hidden = true;
88
+ layer.append(element);
89
+
90
+ pill.setAttribute("aria-haspopup", "dialog");
91
+ pill.setAttribute("aria-expanded", "false");
92
+ pill.setAttribute("aria-controls", id);
93
+
94
+ let items: readonly ProblemItem[] = [];
95
+ let rows: Row[] = [];
96
+ let destroyed = false;
97
+
98
+ const isOpen = (): boolean => !element.hidden;
99
+
100
+ function keyOf(item: ProblemItem): string {
101
+ return `${item.elementId ?? ""}\u0000${item.severity}\u0000${item.message}`;
102
+ }
103
+
104
+ function rowElement(item: ProblemItem): HTMLElement {
105
+ const severity = item.severity === "error" ? "error" : "warning";
106
+ const parts = [
107
+ // The shape and colour of the icon carry the severity on screen, its name for screen readers.
108
+ createElement(
109
+ ownerDocument,
110
+ "span",
111
+ { class: "cd-shell-problem-icon", role: "img", "aria-label": messages.problemSeverity(severity) },
112
+ iconElement(severity === "error" ? "circle-x" : "triangle-alert"),
113
+ ),
114
+ createElement(ownerDocument, "span", { class: "cd-shell-problem-message" }, item.message),
115
+ item.location
116
+ ? createElement(ownerDocument, "span", { class: "cd-shell-problem-location" }, item.location)
117
+ : undefined,
118
+ ];
119
+ const attributes = { class: "cd-shell-problem", "data-severity": severity };
120
+ if (!item.elementId) {
121
+ const text = createElement(ownerDocument, "div", { ...attributes, tabindex: "-1" }, ...parts);
122
+ rows.push({ key: keyOf(item), element: text });
123
+ return text;
124
+ }
125
+ const button = createElement(ownerDocument, "button", { type: "button", ...attributes }, ...parts);
126
+ button.addEventListener("click", () => select(item));
127
+ rows.push({ key: keyOf(item), element: button });
128
+ return button;
129
+ }
130
+
131
+ function focusedRowIndex(): number {
132
+ return rows.findIndex((row) => row.element === ownerDocument.activeElement);
133
+ }
134
+
135
+ function render(): void {
136
+ const focusedIndex = focusedRowIndex();
137
+ const focusedKey = rows[focusedIndex]?.key;
138
+ rows = [];
139
+ list.replaceChildren(...items.map((item) => createElement(ownerDocument, "li", {}, rowElement(item))));
140
+ if (focusedKey === undefined || !isOpen()) return;
141
+ const kept = rows.find((row) => row.key === focusedKey) ?? rows[Math.min(focusedIndex, rows.length - 1)];
142
+ if (kept) focusRowElement(kept.element);
143
+ else element.focus({ preventScroll: true });
144
+ }
145
+
146
+ // Scrolls only the list: `focus()` and `scrollIntoView()` would also scroll the shell root.
147
+ function revealInList(row: HTMLElement): void {
148
+ const rowBox = row.getBoundingClientRect();
149
+ const listBox = list.getBoundingClientRect();
150
+ if (rowBox.top < listBox.top) list.scrollTop -= listBox.top - rowBox.top;
151
+ else if (rowBox.bottom > listBox.bottom) list.scrollTop += rowBox.bottom - listBox.bottom;
152
+ }
153
+
154
+ function focusRowElement(row: HTMLElement): void {
155
+ row.focus({ preventScroll: true });
156
+ revealInList(row);
157
+ }
158
+
159
+ function focusCanvas(): void {
160
+ const canvas = root.querySelector<HTMLElement | SVGElement>(".cd-shell-canvas svg[tabindex]");
161
+ canvas?.focus({ preventScroll: true });
162
+ if ((!canvas || ownerDocument.activeElement !== canvas) && !pill.hidden)
163
+ pill.focus({ preventScroll: true });
164
+ }
165
+
166
+ function select(item: ProblemItem): void {
167
+ close();
168
+ focusCanvas();
169
+ context.onSelectItem?.(item);
170
+ }
171
+
172
+ function focusRow(index: number): void {
173
+ if (rows.length === 0) return;
174
+ const row = rows[(index + rows.length) % rows.length];
175
+ if (row) focusRowElement(row.element);
176
+ }
177
+
178
+ const onOutsidePointerDown = (event: PointerEvent): void => {
179
+ const target = event.target as Node | null;
180
+ if (target && (element.contains(target) || pill.contains(target))) return;
181
+ close();
182
+ };
183
+
184
+ // The focus left the document (another window, an iframe of the host page): from the user's
185
+ // view a click outside, so the popover closes without moving the focus.
186
+ const onWindowBlur = (): void => close();
187
+
188
+ function open(): void {
189
+ if (destroyed || isOpen()) return;
190
+ element.hidden = false;
191
+ pill.setAttribute("aria-expanded", "true");
192
+ // Capture phase: diagram-js stops pointer events on palette, context pad and canvas.
193
+ ownerDocument.addEventListener("pointerdown", onOutsidePointerDown, true);
194
+ view?.addEventListener("blur", onWindowBlur);
195
+ list.scrollTop = 0;
196
+ const first = rows[0];
197
+ if (first) focusRowElement(first.element);
198
+ else element.focus({ preventScroll: true });
199
+ }
200
+
201
+ function close(options: { restoreFocus?: boolean | undefined } = {}): void {
202
+ if (!isOpen()) return;
203
+ element.hidden = true;
204
+ pill.setAttribute("aria-expanded", "false");
205
+ ownerDocument.removeEventListener("pointerdown", onOutsidePointerDown, true);
206
+ view?.removeEventListener("blur", onWindowBlur);
207
+ if (options.restoreFocus && !pill.hidden) pill.focus({ preventScroll: true });
208
+ }
209
+
210
+ // Keys typed in the list stop here, like the menu's: a modeler's keyboard on document (Event
211
+ // Storming deletes the selection on Delete) must never act on them.
212
+ function consume(event: KeyboardEvent): void {
213
+ event.preventDefault();
214
+ event.stopPropagation();
215
+ }
216
+
217
+ element.addEventListener("keydown", (event) => {
218
+ // Zoom and fit keys still act with the popover open; the shell's key listener runs them.
219
+ if (matchChromeKey(event)) return;
220
+ const index = focusedRowIndex();
221
+ switch (event.key) {
222
+ case "Escape":
223
+ consume(event);
224
+ close({ restoreFocus: true });
225
+ return;
226
+ case "Tab": {
227
+ // Without a row the popover itself holds the focus and is first and last at once.
228
+ const first = index <= 0;
229
+ const last = rows.length === 0 || index === rows.length - 1;
230
+ if (event.shiftKey && first) {
231
+ consume(event);
232
+ close({ restoreFocus: true });
233
+ } else if (!event.shiftKey && last) {
234
+ // The browser's Tab runs after this handler from the pill, so it moves on to what follows it.
235
+ event.stopPropagation();
236
+ close({ restoreFocus: true });
237
+ }
238
+ return;
239
+ }
240
+ case "ArrowDown":
241
+ focusRow(index + 1);
242
+ break;
243
+ case "ArrowUp":
244
+ focusRow(index < 0 ? rows.length - 1 : index - 1);
245
+ break;
246
+ case "Home":
247
+ focusRow(0);
248
+ break;
249
+ case "End":
250
+ focusRow(rows.length - 1);
251
+ break;
252
+ case "Enter":
253
+ case " ":
254
+ case "Delete":
255
+ case "Backspace":
256
+ // A row button's native click handles Enter and Space; nothing else may act on these keys.
257
+ event.stopPropagation();
258
+ return;
259
+ default:
260
+ if (isPrintable(event)) event.stopPropagation();
261
+ return;
262
+ }
263
+ consume(event);
264
+ });
265
+
266
+ return {
267
+ element,
268
+ isOpen,
269
+ toggle() {
270
+ if (isOpen()) close({ restoreFocus: true });
271
+ else open();
272
+ },
273
+ open,
274
+ close,
275
+ setItems(next) {
276
+ items = next;
277
+ if (!destroyed) render();
278
+ },
279
+ destroy() {
280
+ if (destroyed) return;
281
+ close();
282
+ destroyed = true;
283
+ for (const name of PILL_ATTRIBUTES) pill.removeAttribute(name);
284
+ element.remove();
285
+ },
286
+ };
287
+ }