@threadlabs/looma 0.26.26 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/components/shared/editor.js +4 -5
  2. package/components/shared/overlay.js +231 -55
  3. package/components/ui-affordance-scope/ui-affordance-scope.js +4 -8
  4. package/components/ui-card/ui-card.html +3 -0
  5. package/components/ui-dialog/ui-dialog.html +7 -5
  6. package/components/ui-dialog/ui-dialog.js +67 -7
  7. package/components/ui-editor-mention-menu/ui-editor-mention-menu.html +6 -0
  8. package/components/ui-editor-mention-menu/ui-editor-mention-menu.js +5 -9
  9. package/components/ui-editor-slash-menu/ui-editor-slash-menu.html +6 -0
  10. package/components/ui-editor-slash-menu/ui-editor-slash-menu.js +5 -9
  11. package/components/ui-editor-table-context-menu/ui-editor-table-context-menu.html +21 -4
  12. package/components/ui-editor-table-context-menu/ui-editor-table-context-menu.js +50 -22
  13. package/components/ui-editor-table-overlay/ui-editor-table-overlay.js +4 -6
  14. package/components/ui-editor-table-toolbar/ui-editor-table-toolbar.html +3 -11
  15. package/components/ui-editor-table-toolbar/ui-editor-table-toolbar.js +33 -44
  16. package/components/ui-listbox/ui-listbox.html +22 -17
  17. package/components/ui-menu/ui-menu.js +5 -3
  18. package/components/ui-popover/ui-popover.js +3 -2
  19. package/components/ui-scroll-area/ui-scroll-area.html +16 -0
  20. package/components/ui-search-shell/ui-search-shell.html +1 -1
  21. package/components/ui-search-shell/ui-search-shell.js +3 -5
  22. package/components/ui-section/ui-section.html +3 -0
  23. package/components/ui-toast/ui-toast.html +2 -1
  24. package/components/ui-toast/ui-toast.js +18 -2
  25. package/components/ui-toast-region/ui-toast-region.js +11 -3
  26. package/dist/index.js +1 -1
  27. package/editor/chunks/extensions.js +2 -0
  28. package/editor/extensions/slash-command.d.ts +2 -0
  29. package/editor/mention-contract.d.ts +2 -0
  30. package/package.json +1 -1
  31. package/styles/ui-card.css +3 -0
  32. package/styles/ui-dialog.css +4 -2
  33. package/styles/ui-editor-mention-menu.css +7 -1
  34. package/styles/ui-editor-slash-menu.css +7 -1
  35. package/styles/ui-editor-table-context-menu.css +29 -5
  36. package/styles/ui-editor-table-toolbar.css +3 -11
  37. package/styles/ui-listbox.css +6 -0
  38. package/styles/ui-scroll-area.css +13 -0
  39. package/styles/ui-search-shell.css +1 -1
  40. package/styles/ui-section.css +3 -0
  41. package/vanilla/UiDialog.js +10 -6
  42. package/vanilla/UiListbox.js +10 -9
  43. package/vanilla/UiScrollArea.d.ts +1 -0
  44. package/vanilla/UiScrollArea.js +4 -4
  45. package/vue/UiCard.vue +3 -0
  46. package/vue/UiDialog.vue +12 -6
  47. package/vue/UiEditorMentionMenu.vue +6 -0
  48. package/vue/UiEditorSlashMenu.vue +6 -0
  49. package/vue/UiEditorTableContextMenu.vue +21 -4
  50. package/vue/UiEditorTableToolbar.vue +3 -11
  51. package/vue/UiListbox.vue +20 -15
  52. package/vue/UiScrollArea.d.ts +9 -0
  53. package/vue/UiScrollArea.vue +39 -1
  54. package/vue/UiSearchShell.vue +1 -1
  55. package/vue/UiSection.vue +3 -0
  56. package/vue/UiToast.vue +1 -1
  57. package/vue/chunks/UiCard.js +1 -1
  58. package/vue/chunks/UiDialog.js +25 -8
  59. package/vue/chunks/UiEditorMentionMenu.js +1 -1
  60. package/vue/chunks/UiEditorSlashMenu.js +1 -1
  61. package/vue/chunks/UiEditorTableContextMenu.js +1 -1
  62. package/vue/chunks/UiEditorTableToolbar.js +1 -1
  63. package/vue/chunks/UiListbox.js +12 -12
  64. package/vue/chunks/UiScrollArea.js +33 -2
  65. package/vue/chunks/UiSearchShell.js +1 -1
  66. package/vue/chunks/UiSection.js +1 -1
  67. package/vue/chunks/UiToast.js +2 -2
  68. package/vue/components.css +258 -213
  69. package/vue/editor/index.js +225 -40
@@ -1,3 +1,4 @@
1
+ import { createViewportSurface } from "../shared/overlay.js";
1
2
  import { normalizeAnchor, positionMenu } from "../shared/editor.js";
2
3
 
3
4
  // Places the menu at the slash and tracks the highlighted item; the template renders the items.
@@ -7,6 +8,7 @@ function connect(host) {
7
8
  const place = () => {
8
9
  if (host.state.visible) positionMenu(element, normalizeAnchor(host.props.anchorRect.value), 280);
9
10
  };
11
+ const surface = createViewportSurface(element, { position: place });
10
12
  const options = () => Array.from(element.querySelectorAll('[role="option"]'));
11
13
  const syncOptions = () => {
12
14
  const query = String(host.props.query.value || "").trim().toLocaleLowerCase();
@@ -31,7 +33,8 @@ function connect(host) {
31
33
  const visible = Boolean(host.props.open.value && normalizeAnchor(host.props.anchorRect.value)
32
34
  && (rows.length > 0 || host.refs.empty.children.length));
33
35
  if (host.state.visible !== visible) host.state.visible = visible;
34
- place();
36
+ if (visible) surface.show();
37
+ else surface.hide();
35
38
  };
36
39
  const stop = host.effect(() => {
37
40
  const items = Array.isArray(host.props.items.value) ? host.props.items.value : [];
@@ -60,20 +63,13 @@ function connect(host) {
60
63
  element.addEventListener("mousedown", onMousedown);
61
64
  element.addEventListener("click", onClick);
62
65
  element.addEventListener("mouseover", onMouseover);
63
- window.addEventListener("resize", place);
64
- window.addEventListener("scroll", place, true);
65
- window.visualViewport?.addEventListener("resize", place);
66
- window.visualViewport?.addEventListener("scroll", place);
67
66
  return () => {
68
67
  stop();
68
+ surface.destroy();
69
69
  observer.disconnect();
70
70
  element.removeEventListener("mousedown", onMousedown);
71
71
  element.removeEventListener("click", onClick);
72
72
  element.removeEventListener("mouseover", onMouseover);
73
- window.removeEventListener("resize", place);
74
- window.removeEventListener("scroll", place, true);
75
- window.visualViewport?.removeEventListener("resize", place);
76
- window.visualViewport?.removeEventListener("scroll", place);
77
73
  };
78
74
  }
79
75
 
@@ -80,10 +80,16 @@
80
80
  </template>
81
81
  </div>
82
82
  <style>
83
+ :host[popover] {
84
+ position: fixed;
85
+ inset: auto;
86
+ margin: 0;
87
+ }
88
+
83
89
  :host {
84
90
  /* The max-width is a promise about the menu's footprint, so it counts padding and border. */
85
91
  box-sizing: border-box;
86
- min-width: 14rem;
92
+ min-width: min(14rem, calc(100vw - 24px));
87
93
  max-width: min(17rem, calc(100vw - 24px));
88
94
  max-height: min(28rem, calc(100vh - 24px));
89
95
  padding: var(--ui-space-2);
@@ -138,16 +144,22 @@
138
144
  color: var(--ui-danger);
139
145
  }
140
146
 
147
+ /* Swatches wrap like words: the row asks for the menu's full width, and a narrower menu fits fewer. */
141
148
  .swatches {
142
- display: grid;
143
- grid-template-columns: repeat(3, minmax(0, 1fr));
149
+ display: flex;
150
+ flex-wrap: wrap;
144
151
  gap: 3px;
145
152
  }
146
153
 
147
- .swatch-button {
154
+ /* Three to a row at the menu's full width; 5rem keeps the longest label, "Default", whole. */
155
+ :host .swatch-button {
148
156
  display: inline-flex;
149
157
  align-items: center;
150
158
  gap: var(--ui-space-2);
159
+ flex: 1 0 5rem;
160
+ min-width: 5rem;
161
+ max-width: calc((100% - 6px) / 3);
162
+ padding-inline-end: var(--ui-space-1);
151
163
  }
152
164
 
153
165
  .swatch {
@@ -194,6 +206,11 @@
194
206
  margin: var(--ui-space-1) 0;
195
207
  }
196
208
 
209
+ /* Separators divide sections, so none sits above the first. */
210
+ .sep:first-child {
211
+ display: none;
212
+ }
213
+
197
214
  :host[hidden] {
198
215
  display: none;
199
216
  }
@@ -1,3 +1,4 @@
1
+ import { createViewportSurface, writtenPlace } from "../shared/overlay.js";
1
2
  import { backgrounds, normalizeCellBackground, viewport } from "../shared/editor.js";
2
3
 
3
4
  const sections = [
@@ -25,27 +26,55 @@ const sections = [
25
26
  ]],
26
27
  ];
27
28
 
28
- // Lists the actions the selection permits, and nudges the open menu back inside the viewport.
29
+ // Shows the menu at the place it is written, keeping one in a floating box inside the viewport, and lists
30
+ // the actions the selection permits.
29
31
  function connect(host) {
30
32
  const element = host.element;
31
- let frame;
33
+ let place = null;
32
34
  const nudge = () => {
33
- frame = undefined;
34
35
  element.style.translate = "";
36
+ if (!place) return;
35
37
  const rect = element.getBoundingClientRect();
36
- const view = viewport();
37
- const inset = 12;
38
- let x = 0;
39
- let y = 0;
40
- if (rect.left < view.left + inset) x = view.left + inset - rect.left;
41
- else if (rect.right > view.right - inset) x = view.right - inset - rect.right;
42
- if (rect.top < view.top + inset) y = view.top + inset - rect.top;
43
- else if (rect.bottom > view.bottom - inset) y = view.bottom - inset - rect.bottom;
38
+ const at = place.rect();
39
+ let x = at.left - rect.left;
40
+ let y = at.top - rect.top;
41
+ // Only a menu in a fixed or absolutely positioned box, as LoomaEditor places it, answers to the
42
+ // viewport; one in page flow travels with its place.
43
+ if (place.floating) {
44
+ const view = viewport();
45
+ const inset = 12;
46
+ const left = rect.left + x;
47
+ const top = rect.top + y;
48
+ if (left < view.left + inset) x += view.left + inset - left;
49
+ else if (left + rect.width > view.right - inset) x += view.right - inset - left - rect.width;
50
+ if (top < view.top + inset) y += view.top + inset - top;
51
+ else if (top + rect.height > view.bottom - inset) y += view.bottom - inset - top - rect.height;
52
+ }
44
53
  if (x || y) element.style.translate = `${x}px ${y}px`;
45
54
  };
46
- const schedule = () => {
47
- if (frame) cancelAnimationFrame(frame);
48
- frame = requestAnimationFrame(nudge);
55
+ const surface = createViewportSurface(element, { position: nudge });
56
+ // Scroll and resize events do not report layout that moves the place; sample it while shown.
57
+ let frame = null;
58
+ let last = "";
59
+ const track = () => {
60
+ frame = null;
61
+ if (!place || !element.matches(":popover-open")) return;
62
+ const at = place.rect();
63
+ if (`${at.left},${at.top}` !== last) {
64
+ last = `${at.left},${at.top}`;
65
+ surface.refresh();
66
+ }
67
+ frame = requestAnimationFrame(track);
68
+ };
69
+ const untrack = () => {
70
+ if (frame !== null) cancelAnimationFrame(frame);
71
+ frame = null;
72
+ last = "";
73
+ };
74
+ const show = () => {
75
+ if (!element.matches(":popover-open")) place = writtenPlace(element, ["translate"]);
76
+ surface.show();
77
+ frame ??= requestAnimationFrame(track);
49
78
  };
50
79
  const stop = host.effect(() => {
51
80
  const scope = String(host.props.scope.value || "cell");
@@ -62,23 +91,22 @@ function connect(host) {
62
91
  .map(([action, label, icon, tone]) => ({ action, label, icon, danger: tone === "danger", checkable: action.startsWith("toggle-header-"), checked: action === "toggle-header-row" ? Boolean(host.props.headerRow.value) : action === "toggle-header-column" ? Boolean(host.props.headerColumn.value) : false })),
63
92
  }))
64
93
  .filter((section) => section.items.length);
65
- if (host.props.open.value) schedule();
94
+ if (host.props.open.value) queueMicrotask(() => { if (host.props.open.value) show(); });
95
+ else {
96
+ untrack();
97
+ surface.hide();
98
+ }
66
99
  });
67
100
  const onClick = (event) => {
68
101
  const action = event.target.closest?.("[data-action]")?.dataset.action;
69
102
  if (action) host.dispatch("action", { action });
70
103
  };
71
104
  element.addEventListener("click", onClick);
72
- window.addEventListener("resize", schedule);
73
- window.visualViewport?.addEventListener("resize", schedule);
74
- window.visualViewport?.addEventListener("scroll", schedule);
75
105
  return () => {
76
106
  stop();
107
+ untrack();
108
+ surface.destroy();
77
109
  element.removeEventListener("click", onClick);
78
- window.removeEventListener("resize", schedule);
79
- window.visualViewport?.removeEventListener("resize", schedule);
80
- window.visualViewport?.removeEventListener("scroll", schedule);
81
- if (frame) cancelAnimationFrame(frame);
82
110
  };
83
111
  }
84
112
 
@@ -1,3 +1,5 @@
1
+ import { observeOverlayViewport } from "../shared/overlay.js";
2
+
1
3
  function fallbackBoundaries(segments, length) {
2
4
  return Array.from({ length: segments + 1 }, (_, index) => (index * length) / segments);
3
5
  }
@@ -36,15 +38,11 @@ function createProximity(scope, selector = ".handle[data-ui-affordance]", radius
36
38
  point = event.pointerType === "touch" ? null : { x: event.clientX, y: event.clientY }; schedule();
37
39
  };
38
40
  pointerTarget.addEventListener("pointermove", onPointermove, { passive: true });
39
- owner.addEventListener("resize", refresh, { passive: true });
40
- owner.addEventListener("scroll", refresh, { passive: true, capture: true });
41
- owner.visualViewport?.addEventListener("resize", refresh, { passive: true });
42
- owner.visualViewport?.addEventListener("scroll", refresh, { passive: true });
41
+ const stopViewport = observeOverlayViewport(scope.ownerDocument, refresh);
43
42
  measure();
44
43
  return { refresh, destroy() {
45
44
  pointerTarget.removeEventListener("pointermove", onPointermove);
46
- owner.removeEventListener("resize", refresh); owner.removeEventListener("scroll", refresh, true);
47
- owner.visualViewport?.removeEventListener("resize", refresh); owner.visualViewport?.removeEventListener("scroll", refresh);
45
+ stopViewport();
48
46
  if (frame !== null) owner.cancelAnimationFrame(frame); clear();
49
47
  } };
50
48
  }
@@ -140,14 +140,6 @@
140
140
  background: var(--ui-accent-soft, var(--ui-accent-subtle));
141
141
  }
142
142
 
143
- .looma-editor__mobile-toolbar-shell :host .menu {
144
- top: auto;
145
- right: 0;
146
- bottom: calc(100% + var(--ui-space-2));
147
- max-height: min(50dvh, 22rem);
148
- overflow-y: auto;
149
- overscroll-behavior: contain;
150
- }
151
143
  :host {
152
144
  display: inline-flex;
153
145
  align-items: center;
@@ -238,9 +230,9 @@
238
230
 
239
231
  .menu {
240
232
  box-sizing: border-box;
241
- position: absolute;
242
- top: calc(100% + var(--ui-space-2));
243
- right: 0;
233
+ position: fixed;
234
+ inset: auto;
235
+ margin: 0;
244
236
  min-width: 12rem;
245
237
  max-width: min(17rem, calc(100vw - 24px));
246
238
  padding: var(--ui-space-1);
@@ -1,5 +1,5 @@
1
+ import { closeOverlay, createViewportSurface, openOverlay, positionAnchoredSurface } from "../shared/overlay.js";
1
2
  import { backgrounds, normalizeCellBackground } from "../shared/editor.js";
2
- import { createAnchoredSurface } from "../shared/overlay.js";
3
3
 
4
4
  const overflowSections = [
5
5
  ["Structure", [["add-row-before", "Add row above", "panel-top"], ["add-column-before", "Add column left", "panel-left"], ["toggle-header-row", "Header row", "rows"], ["toggle-header-column", "Header column", "columns"], ["move-row-up", "Move row up", "rows"], ["move-row-down", "Move row down", "rows"], ["move-column-left", "Move column left", "columns"], ["move-column-right", "Move column right", "columns"]]],
@@ -10,19 +10,13 @@ const overflowSections = [
10
10
  // Lists the actions the table selection permits; the overflow menu opens and closes here.
11
11
  function connect(host) {
12
12
  const element = host.element;
13
+ let alive = true;
14
+ let menu = null;
13
15
  let surface = null;
14
- let surfaceMenu = null;
15
- let positionFrame = null;
16
+ const document = element.ownerDocument;
16
17
  const positionMenu = () => {
17
- if (!host.state.overflowOpen) return;
18
18
  const button = element.querySelector(".more");
19
- const menu = element.querySelector(".menu");
20
19
  if (!button || !menu) return;
21
- if (surfaceMenu !== menu) {
22
- surface?.destroy();
23
- surfaceMenu = menu;
24
- surface = createAnchoredSurface(menu, { anchor: button, placement: "bottom-end", gap: 8, viewportGap: 12 });
25
- }
26
20
  const rect = button.getBoundingClientRect();
27
21
  const viewport = window.visualViewport;
28
22
  const top = viewport?.offsetTop ?? 0;
@@ -30,12 +24,34 @@ function connect(host) {
30
24
  const above = rect.top - top - 20;
31
25
  const below = bottom - rect.bottom - 20;
32
26
  menu.style.maxHeight = `${Math.max(80, Math.floor(Math.max(above, below)))}px`;
33
- surface.show();
27
+ positionAnchoredSurface(menu, rect, { placement: "bottom-end", gap: 8, viewportGap: 12 });
28
+ };
29
+ const close = (reason) => {
30
+ host.state.overflowOpen = false;
31
+ if (reason === "escape") element.querySelector(".more")?.focus();
34
32
  };
35
- const schedulePosition = () => {
36
- if (positionFrame !== null) cancelAnimationFrame(positionFrame);
37
- positionFrame = requestAnimationFrame(() => { positionFrame = null; positionMenu(); });
33
+ const releasePresentation = () => {
34
+ surface?.destroy();
35
+ surface = null;
36
+ menu = null;
37
+ closeOverlay(document, element);
38
+ };
39
+ const syncPresentation = () => {
40
+ if (!alive || !host.state.overflowOpen) return;
41
+ const next = element.querySelector(".menu");
42
+ if (!next) return;
43
+ if (next !== menu) {
44
+ releasePresentation();
45
+ menu = next;
46
+ surface = createViewportSurface(menu, { position: positionMenu });
47
+ }
48
+ surface.show();
49
+ openOverlay({ id: element, modal: false, element: menu, relatedElements: [element], dismissible: true, requestClose: close });
38
50
  };
51
+ const stopPresentation = host.effect(() => {
52
+ if (host.state.overflowOpen) queueMicrotask(syncPresentation);
53
+ else releasePresentation();
54
+ });
39
55
  const stop = host.effect(() => {
40
56
  const enabled = new Set(Array.isArray(host.props.actions.value) ? host.props.actions.value : []);
41
57
  const alignment = host.props.cellAlignment.value === "center" || host.props.cellAlignment.value === "right" ? host.props.cellAlignment.value : "left";
@@ -58,14 +74,6 @@ function connect(host) {
58
74
  host.state.hasOverflow = host.state.swatches.length > 0 || host.state.sections.length > 0;
59
75
  if (!host.props.open.value || !host.state.hasOverflow) host.state.overflowOpen = false;
60
76
  });
61
- const stopMenu = host.effect(() => {
62
- if (host.state.overflowOpen) schedulePosition();
63
- else {
64
- surface?.destroy();
65
- surface = null;
66
- surfaceMenu = null;
67
- }
68
- });
69
77
  const onClick = (event) => {
70
78
  const action = event.target.closest?.("[data-action]")?.dataset.action;
71
79
  if (!action) return;
@@ -76,32 +84,13 @@ function connect(host) {
76
84
  host.state.overflowOpen = false;
77
85
  host.dispatch("action", { action });
78
86
  };
79
- const onOutside = (event) => {
80
- if (host.state.overflowOpen && !event.composedPath().includes(element)) host.state.overflowOpen = false;
81
- };
82
- const onKeydown = (event) => {
83
- if (event.key === "Escape" && host.state.overflowOpen) {
84
- host.state.overflowOpen = false;
85
- event.preventDefault();
86
- event.stopPropagation();
87
- element.querySelector(".more")?.focus();
88
- }
89
- };
90
87
  element.addEventListener("click", onClick);
91
- document.addEventListener("pointerdown", onOutside, true);
92
- document.addEventListener("keydown", onKeydown, true);
93
- window.addEventListener("resize", positionMenu);
94
- window.visualViewport?.addEventListener("resize", positionMenu);
95
88
  return () => {
89
+ alive = false;
96
90
  stop();
97
- stopMenu();
98
- surface?.destroy();
99
- if (positionFrame !== null) cancelAnimationFrame(positionFrame);
91
+ stopPresentation();
92
+ releasePresentation();
100
93
  element.removeEventListener("click", onClick);
101
- document.removeEventListener("pointerdown", onOutside, true);
102
- document.removeEventListener("keydown", onKeydown, true);
103
- window.removeEventListener("resize", positionMenu);
104
- window.visualViewport?.removeEventListener("resize", positionMenu);
105
94
  };
106
95
  }
107
96
 
@@ -32,23 +32,25 @@
32
32
  from:aria-invalid="$invalid or $validationError"
33
33
  style:--_rows="$rows"
34
34
  >
35
- <ui-scroll-area class="choices" $ref="choices">
36
- <div
37
- $each="choice of $choices"
38
- $key="$choice.index"
39
- class="option"
40
- class:active="$choice.index = $active"
41
- role="option"
42
- data-ui-choice-option
43
- from:data-multiple="$multiple"
44
- from:id="concat($uid, '-option-', $choice.index)"
45
- from:data-index="$choice.index"
46
- from:aria-selected="$choice.selected"
47
- from:aria-disabled="$choice.disabled"
48
- >
49
- <span>{$choice.label}</span>
50
- </div>
51
- </ui-scroll-area>
35
+ <div class="choices">
36
+ <ui-scroll-area $ref="choices">
37
+ <div
38
+ $each="choice of $choices"
39
+ $key="$choice.index"
40
+ class="option"
41
+ class:active="$choice.index = $active"
42
+ role="option"
43
+ data-ui-choice-option
44
+ from:data-multiple="$multiple"
45
+ from:id="concat($uid, '-option-', $choice.index)"
46
+ from:data-index="$choice.index"
47
+ from:aria-selected="$choice.selected"
48
+ from:aria-disabled="$choice.disabled"
49
+ >
50
+ <span>{$choice.label}</span>
51
+ </div>
52
+ </ui-scroll-area>
53
+ </div>
52
54
  <select
53
55
  class="fallback"
54
56
  $ref="options"
@@ -115,7 +117,10 @@
115
117
  outline: none;
116
118
  }
117
119
 
120
+ /* Its own box around the nested Scroll Area, which it shrinks into so the choices scroll. */
118
121
  .choices {
122
+ display: flex;
123
+ flex-direction: column;
119
124
  max-block-size: calc(max(2, var(--_rows, 5)) * var(--ui-control-size-md));
120
125
  }
121
126
 
@@ -53,7 +53,8 @@ function connect(host) {
53
53
  const setup = () => {
54
54
  const nextFor = host.props.inline.value ? "" : String(host.props.for.value ?? "");
55
55
  const nextPlacement = String(host.props.placement.value ?? "bottom-start");
56
- if (nextFor === lastFor && nextPlacement === lastPlacement && surface) return;
56
+ if (nextFor === lastFor && nextPlacement === lastPlacement && surface
57
+ && (!nextFor || anchor === document.getElementById(nextFor))) return;
57
58
  lastFor = nextFor;
58
59
  lastPlacement = nextPlacement;
59
60
  surface?.destroy();
@@ -61,7 +62,7 @@ function connect(host) {
61
62
  anchor = ids.get(nextFor);
62
63
  if (anchor) anchorAria = { "aria-haspopup": anchor.getAttribute("aria-haspopup"), "aria-expanded": anchor.getAttribute("aria-expanded") };
63
64
  anchor?.addEventListener("click", onAnchorClick);
64
- surface = anchor ? createAnchoredSurface(element, { anchor, placement: nextPlacement }) : null;
65
+ surface = host.props.inline.value ? null : createAnchoredSurface(element, { anchor, placement: nextPlacement });
65
66
  };
66
67
  const apply = () => {
67
68
  const inline = Boolean(host.props.inline.value);
@@ -77,7 +78,8 @@ function connect(host) {
77
78
  anchor.setAttribute("aria-expanded", String(open));
78
79
  }
79
80
  if (inline) {
80
- surface?.hide();
81
+ element.removeAttribute("popover");
82
+ element.hidden = false;
81
83
  closeOverlay(document, overlayId);
82
84
  } else if (open) {
83
85
  surface?.show();
@@ -49,7 +49,8 @@ function connect(host) {
49
49
  const setup = () => {
50
50
  const nextFor = String(host.props.for.value ?? "");
51
51
  const nextPlacement = String(host.props.placement.value ?? "bottom-start");
52
- if (surface && nextFor === lastFor && nextPlacement === lastPlacement) return;
52
+ if (surface && nextFor === lastFor && nextPlacement === lastPlacement
53
+ && (!nextFor || anchor === document.getElementById(nextFor))) return;
53
54
  lastFor = nextFor;
54
55
  lastPlacement = nextPlacement;
55
56
  surface?.destroy();
@@ -57,7 +58,7 @@ function connect(host) {
57
58
  anchor = ids.get(nextFor);
58
59
  if (anchor) anchorExpanded = anchor.getAttribute("aria-expanded");
59
60
  anchor?.addEventListener("click", onAnchorClick);
60
- surface = anchor ? createAnchoredSurface(element, { anchor, placement: nextPlacement, gap: offset }) : null;
61
+ surface = createAnchoredSurface(element, { anchor, placement: nextPlacement, gap: offset });
61
62
  };
62
63
  const apply = () => {
63
64
  const externalOpen = Boolean(host.props.open.value);
@@ -7,6 +7,9 @@
7
7
  <prop name="orientation" type="keyword" values="vertical, horizontal" default="vertical"
8
8
  >The direction the content scrolls. Vertical scrolls up and down and clips sideways overflow; horizontal scrolls sideways and clips vertical
9
9
  overflow.</prop>
10
+ <prop name="padding" type="keyword" values="none, xs, sm, md, lg" default="none"
11
+ >The space inside the scrolling region, on Card's scale: xs is 0.5rem, sm 0.75rem, md 1.5rem, and lg 2rem. It scrolls with the content, so the scrollbar and
12
+ fades stay at the edges.</prop>
10
13
  <prop name="trim" type="boolean" default="false"
11
14
  >Removes the outer block margins of the first and last projected items when the surrounding panel owns its edge padding. Margins between items remain
12
15
  intact.</prop>
@@ -73,6 +76,19 @@
73
76
  max-block-size: 30rem;
74
77
  }
75
78
 
79
+ :host([padding="xs"]) {
80
+ padding: var(--ui-space-2);
81
+ }
82
+ :host([padding="sm"]) {
83
+ padding: var(--ui-space-3);
84
+ }
85
+ :host([padding="md"]) {
86
+ padding: var(--ui-space-5);
87
+ }
88
+ :host([padding="lg"]) {
89
+ padding: var(--ui-space-6);
90
+ }
91
+
76
92
  /* A component that sets its display still honours the hidden attribute on its root. */
77
93
  :host[hidden] {
78
94
  display: none;
@@ -101,7 +101,7 @@
101
101
  color: var(--ui-text-primary);
102
102
  }
103
103
 
104
- dialog::backdrop {
104
+ dialog:modal::backdrop {
105
105
  background: var(--ui-overlay-backdrop);
106
106
  }
107
107
 
@@ -1,4 +1,4 @@
1
- import { closeOverlay, openOverlay } from "../shared/overlay.js";
1
+ import { closeDialog, closeOverlay, openOverlay, showDialog } from "../shared/overlay.js";
2
2
  import { trackTrigger } from "../shared/trigger.js";
3
3
 
4
4
  /** Owns native dialog state while leaving query and result state to the application. */
@@ -18,9 +18,8 @@ function connect(host) {
18
18
  host.dispatch("close", { open: false, reason, trigger: how });
19
19
  };
20
20
  const closeNative = () => {
21
- if (!dialog.open) return;
22
21
  suppressNativeClose = true;
23
- dialog.close();
22
+ closeDialog(dialog);
24
23
  suppressNativeClose = false;
25
24
  };
26
25
  const stop = host.effect(() => {
@@ -32,8 +31,7 @@ function connect(host) {
32
31
  if (dialog.open && modal !== activeModal) closeNative();
33
32
  activeModal = modal;
34
33
  if (host.state.internalOpen && !dialog.open) {
35
- if (modal) dialog.showModal();
36
- else dialog.show();
34
+ showDialog(dialog, modal);
37
35
  }
38
36
  if (host.state.internalOpen) {
39
37
  openOverlay({ id: overlayId, modal, element: dialog, modalElement: dialog, dismissible: false, requestClose: () => {} });
@@ -55,6 +55,9 @@
55
55
  --_ui-selection-context-text: var(--ui-text-primary);
56
56
  border-color: color-mix(in srgb, var(--ui-danger) 45%, var(--ui-border-default));
57
57
  border-inline-start: var(--ui-accent-line-width) solid var(--ui-danger);
58
+ /* An accent edge keeps the corners on its side straight. */
59
+ border-start-start-radius: 0;
60
+ border-end-start-radius: 0;
58
61
  background: var(--ui-danger-soft);
59
62
  }
60
63
 
@@ -5,7 +5,8 @@
5
5
  <prop name="tone" type="keyword" values="neutral, info, success, warning, danger" default="neutral"
6
6
  >Semantic accent for this message. Danger uses an assertive alert role; the other tones use polite status semantics.</prop>
7
7
  <prop name="duration" type="number" default="0"
8
- >Milliseconds before requesting dismissal; 0 keeps the toast until an action dismisses it. The timer pauses on hover or focus.</prop>
8
+ >Milliseconds the toast is shown before it dismisses itself; 0 keeps it until an action dismisses it. The timer starts when the toast is shown, starts again
9
+ each time it is shown, and pauses on hover or focus.</prop>
9
10
  <event name="dismiss" type="object">
10
11
  <prop name="id" required type="string"></prop>
11
12
  <prop name="reason" required type="keyword" values="action, timeout"></prop>
@@ -4,7 +4,7 @@ function inputFor(event) {
4
4
  return event.detail === 0 ? "keyboard" : "pointer";
5
5
  }
6
6
 
7
- /** Authored toasts request dismissal; their consumer owns removal from the rendered list. */
7
+ /** An authored toast hides itself when dismissed, like a dialog closing; clearing hidden shows it again. */
8
8
  function connect(host) {
9
9
  const element = host.element;
10
10
  const { action } = host.refs;
@@ -19,11 +19,13 @@ function connect(host) {
19
19
  if (dismissed) return;
20
20
  dismissed = true;
21
21
  clearTimeout(timer);
22
+ element.hidden = true;
22
23
  host.dispatch("dismiss", { id: element.id, reason, trigger });
23
24
  };
24
25
  const start = () => {
25
26
  clearTimeout(timer);
26
- if (paused || dismissed || remaining <= 0) return;
27
+ // The duration counts only while the toast is shown; a hidden toast has nothing to time out.
28
+ if (paused || dismissed || element.hidden || remaining <= 0) return;
27
29
  started = Date.now();
28
30
  timer = setTimeout(() => dismiss("timeout", "programmatic"), remaining);
29
31
  };
@@ -50,6 +52,19 @@ function connect(host) {
50
52
  remaining = duration;
51
53
  start();
52
54
  });
55
+ // Hidden by the app, the timer stops; shown (again), it is a fresh request with the full duration.
56
+ const shown = new MutationObserver(() => {
57
+ if (element.hidden) {
58
+ clearTimeout(timer);
59
+ timer = null;
60
+ return;
61
+ }
62
+ dismissed = false;
63
+ paused = false;
64
+ remaining = Math.max(0, Number(host.props.duration.value) || 0);
65
+ start();
66
+ });
67
+ shown.observe(element, { attributes: true, attributeFilter: ["hidden"] });
53
68
  element.addEventListener("click", onClick);
54
69
  element.addEventListener("pointerenter", pause);
55
70
  element.addEventListener("pointerleave", resume);
@@ -58,6 +73,7 @@ function connect(host) {
58
73
  start();
59
74
  return () => {
60
75
  stop();
76
+ shown.disconnect();
61
77
  clearTimeout(timer);
62
78
  element.removeEventListener("click", onClick);
63
79
  element.removeEventListener("pointerenter", pause);
@@ -5,13 +5,13 @@ let toastIds = 0;
5
5
 
6
6
  /**
7
7
  * Shows the region while it has authored or generated messages. Generated messages dismiss by
8
- * action or timeout; authored ui-toast children own their removal through the dismiss event.
8
+ * action or timeout; authored ui-toast children hide themselves, and a hidden one does not count.
9
9
  */
10
10
  function connect(host) {
11
11
  const element = host.element;
12
12
  const [trigger, stopTracking] = trackTrigger(host);
13
13
  const toasts = () => host.state.toasts ?? [];
14
- const authored = () => Array.from(element.children).some((child) => !child.classList.contains("toast"));
14
+ const authored = () => Array.from(element.children).some((child) => !child.classList.contains("toast") && !child.hidden);
15
15
  let announcedVisible = false;
16
16
  const sync = () => {
17
17
  const visible = authored() || toasts().length > 0;
@@ -86,8 +86,16 @@ function connect(host) {
86
86
  if (event.target === element && typeof event.detail?.message === "string") add(event.detail.message, event.detail);
87
87
  };
88
88
  const stop = host.effect(sync);
89
- const observer = new MutationObserver(sync);
89
+ // Children come and go, and an authored toast hides itself: watch each child's hidden, not the whole subtree.
90
+ const watchChildren = () => {
91
+ for (const child of element.children) observer.observe(child, { attributeFilter: ["hidden"] });
92
+ };
93
+ const observer = new MutationObserver(() => {
94
+ watchChildren();
95
+ sync();
96
+ });
90
97
  observer.observe(element, { childList: true });
98
+ watchChildren();
91
99
  element.addEventListener("click", onClick);
92
100
  element.addEventListener("command", onCommand);
93
101
  element.addEventListener("show-toast", onShowToast);