@threadlabs/looma 0.26.25 → 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 (80) 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-icon-button/ui-icon-button.html +12 -2
  17. package/components/ui-listbox/ui-listbox.html +22 -17
  18. package/components/ui-menu/ui-menu.js +5 -3
  19. package/components/ui-popover/ui-popover.js +3 -2
  20. package/components/ui-scroll-area/ui-scroll-area.html +16 -0
  21. package/components/ui-search-shell/ui-search-shell.html +1 -1
  22. package/components/ui-search-shell/ui-search-shell.js +3 -5
  23. package/components/ui-section/ui-section.html +3 -0
  24. package/components/ui-toast/ui-toast.html +2 -1
  25. package/components/ui-toast/ui-toast.js +18 -2
  26. package/components/ui-toast-region/ui-toast-region.js +11 -3
  27. package/components/ui-tooltip/ui-tooltip.html +2 -1
  28. package/components/ui-tooltip/ui-tooltip.js +20 -0
  29. package/dist/index.js +1 -1
  30. package/editor/chunks/extensions.js +2 -0
  31. package/editor/extensions/slash-command.d.ts +2 -0
  32. package/editor/mention-contract.d.ts +2 -0
  33. package/package.json +1 -1
  34. package/styles/ui-card.css +3 -0
  35. package/styles/ui-dialog.css +4 -2
  36. package/styles/ui-editor-mention-menu.css +7 -1
  37. package/styles/ui-editor-slash-menu.css +7 -1
  38. package/styles/ui-editor-table-context-menu.css +29 -5
  39. package/styles/ui-editor-table-toolbar.css +3 -11
  40. package/styles/ui-icon-button.css +9 -2
  41. package/styles/ui-listbox.css +6 -0
  42. package/styles/ui-scroll-area.css +13 -0
  43. package/styles/ui-search-shell.css +1 -1
  44. package/styles/ui-section.css +3 -0
  45. package/vanilla/UiDialog.js +10 -6
  46. package/vanilla/UiIconButton.d.ts +1 -0
  47. package/vanilla/UiIconButton.js +14 -14
  48. package/vanilla/UiListbox.js +10 -9
  49. package/vanilla/UiScrollArea.d.ts +1 -0
  50. package/vanilla/UiScrollArea.js +4 -4
  51. package/vue/UiCard.vue +3 -0
  52. package/vue/UiDialog.vue +12 -6
  53. package/vue/UiEditorMentionMenu.vue +6 -0
  54. package/vue/UiEditorSlashMenu.vue +6 -0
  55. package/vue/UiEditorTableContextMenu.vue +21 -4
  56. package/vue/UiEditorTableToolbar.vue +3 -11
  57. package/vue/UiIconButton.d.ts +9 -0
  58. package/vue/UiIconButton.vue +31 -2
  59. package/vue/UiListbox.vue +20 -15
  60. package/vue/UiScrollArea.d.ts +9 -0
  61. package/vue/UiScrollArea.vue +39 -1
  62. package/vue/UiSearchShell.vue +1 -1
  63. package/vue/UiSection.vue +3 -0
  64. package/vue/UiToast.vue +1 -1
  65. package/vue/UiTooltip.vue +1 -1
  66. package/vue/chunks/UiCard.js +1 -1
  67. package/vue/chunks/UiDialog.js +25 -8
  68. package/vue/chunks/UiEditorMentionMenu.js +1 -1
  69. package/vue/chunks/UiEditorSlashMenu.js +1 -1
  70. package/vue/chunks/UiEditorTableContextMenu.js +1 -1
  71. package/vue/chunks/UiEditorTableToolbar.js +1 -1
  72. package/vue/chunks/UiIconButton.js +26 -1
  73. package/vue/chunks/UiListbox.js +12 -12
  74. package/vue/chunks/UiScrollArea.js +33 -2
  75. package/vue/chunks/UiSearchShell.js +1 -1
  76. package/vue/chunks/UiSection.js +1 -1
  77. package/vue/chunks/UiToast.js +2 -2
  78. package/vue/chunks/UiTooltip.js +2 -2
  79. package/vue/components.css +340 -289
  80. package/vue/editor/index.js +225 -40
@@ -1,3 +1,5 @@
1
+ import { positionAnchoredSurface } from "./overlay.js";
2
+
1
3
  export const backgrounds = [
2
4
  ["background-none", "Default", ""], ["background-gray", "Gray", "#f3f4f6"],
3
5
  ["background-yellow", "Yellow", "#fef3c7"], ["background-blue", "Blue", "#dbeafe"],
@@ -47,9 +49,6 @@ export function positionMenu(element, rect, width) {
47
49
  Object.assign(element.style, { left: `${view.left}px`, right: "", bottom: "", top: `${view.top + view.height - height - 56}px`, width: `${view.width}px`, maxHeight: `${height}px` });
48
50
  return;
49
51
  }
50
- const below = view.bottom - rect.bottom - 8;
51
- const above = rect.top - view.top - 8;
52
- const top = below >= 320 || below >= above ? rect.bottom + 8 : rect.top - 328;
53
- const left = Math.max(view.left + 8, Math.min(rect.left, view.right - width - 8));
54
- Object.assign(element.style, { top: `${Math.max(view.top + 8, top)}px`, left: `${left}px`, right: "", bottom: "", width: `${width}px`, maxHeight: "" });
52
+ Object.assign(element.style, { width: `${Math.min(width, Math.max(0, view.width - 16))}px`, maxHeight: `${Math.max(0, view.height - 16)}px` });
53
+ positionAnchoredSurface(element, rect, { placement: "bottom-start", gap: 8, viewportGap: 8 });
55
54
  }
@@ -1,10 +1,11 @@
1
- const stacks = new WeakMap();
1
+ const dialogInvokers = new WeakMap();
2
2
 
3
3
  function stateFor(document) {
4
- let state = stacks.get(document);
4
+ const key = Symbol.for("@threadlabs/looma/overlay-stack/v1");
5
+ let state = document[key];
5
6
  if (state) return state;
6
7
  state = { records: [], modalCount: 0, listening: false };
7
- stacks.set(document, state);
8
+ Object.defineProperty(document, key, { value: state });
8
9
  return state;
9
10
  }
10
11
 
@@ -132,6 +133,29 @@ function hide(surface) {
132
133
  surface.hidden = true;
133
134
  }
134
135
 
136
+ /** Keeps both dialog modes in the native top layer; modality only controls inertness and backdrop. */
137
+ export function showDialog(dialog, modal) {
138
+ if (dialog.open && dialog.matches(":modal") !== modal) closeDialog(dialog);
139
+ if (!dialog.open) dialogInvokers.set(dialog, dialog.ownerDocument.activeElement);
140
+ if (modal) {
141
+ dialog.removeAttribute("popover");
142
+ if (!dialog.open) dialog.showModal();
143
+ } else {
144
+ dialog.setAttribute("popover", "manual");
145
+ if (!dialog.open) dialog.show();
146
+ if (!topLayerOpen(dialog)) dialog.showPopover();
147
+ }
148
+ }
149
+
150
+ /** Releases popover presentation before native close restores focus and reports the close event. */
151
+ export function closeDialog(dialog) {
152
+ if (topLayerOpen(dialog)) dialog.hidePopover();
153
+ if (dialog.open) dialog.close();
154
+ const invoker = dialogInvokers.get(dialog);
155
+ dialogInvokers.delete(dialog);
156
+ if (invoker?.isConnected) invoker.focus({ preventScroll: true });
157
+ }
158
+
135
159
  function viewport(owner) {
136
160
  const visual = owner.visualViewport;
137
161
  const left = visual?.offsetLeft ?? 0;
@@ -160,6 +184,57 @@ function layoutRect(surface) {
160
184
  return { left, top, right: left + width, bottom: top + height, width, height };
161
185
  }
162
186
 
187
+ /**
188
+ * Where an unanchored surface is written, for presenting it there in the top layer. The top layer
189
+ * takes a surface out of page flow, and engines disagree on its static position there (Chromium
190
+ * uses the viewport origin), so measure the surface in flow before it is shown, with the inline
191
+ * styles its caller owns cleared. `rect()` then carries that place with the box that holds it, so
192
+ * the surface travels with scrolling and with layout around it. A fixed surface keeps its measured
193
+ * place; one an integration places with inline insets the caller does not own keeps its live box.
194
+ * `floating` reports a surface placed that way or written in a fixed or absolute box, which answers to
195
+ * the viewport. Call it only while the surface is outside the top layer.
196
+ */
197
+ export function writtenPlace(surface, ownedStyles = []) {
198
+ const owner = surface.ownerDocument.defaultView;
199
+ const up = (node) => node.assignedSlot ?? node.parentElement ?? node.getRootNode().host ?? null;
200
+ let parent = up(surface);
201
+ while (parent && owner.getComputedStyle(parent).display === "contents") parent = up(parent);
202
+ const popover = surface.getAttribute("popover");
203
+ const hidden = surface.hidden;
204
+ const saved = ownedStyles.map((name) => [name, surface.style.getPropertyValue(name)]);
205
+ if (popover !== null) surface.removeAttribute("popover");
206
+ if (hidden) surface.hidden = false;
207
+ for (const name of ownedStyles) surface.style.removeProperty(name);
208
+ const spot = layoutRect(surface);
209
+ const floats = (node) => node instanceof owner.Element && /^(fixed|absolute)$/.test(owner.getComputedStyle(node).position);
210
+ const placed = ["left", "top"].some((name) => !ownedStyles.includes(name) && surface.style.getPropertyValue(name));
211
+ const fixed = owner.getComputedStyle(surface).position === "fixed";
212
+ const floating = placed || floats(surface) || floats(surface.offsetParent);
213
+ for (const [name, value] of saved) if (value) surface.style.setProperty(name, value);
214
+ if (hidden) surface.hidden = true;
215
+ if (popover !== null) surface.setAttribute("popover", popover);
216
+ // Settle the restored style, so showing it next still starts its entry transition from hidden.
217
+ void surface.offsetWidth;
218
+ // The document scroller's own box moves when it scrolls; any other parent's content moves inside it.
219
+ const scroll = () => !parent || parent === surface.ownerDocument.documentElement || parent === surface.ownerDocument.scrollingElement
220
+ ? [0, 0] : [parent.scrollLeft, parent.scrollTop];
221
+ let offset = null;
222
+ const rect = () => {
223
+ let { left, top } = placed ? layoutRect(surface) : spot;
224
+ if (!placed && !fixed && parent) {
225
+ const base = parent.getBoundingClientRect();
226
+ const [x, y] = scroll();
227
+ // Taken on first use, once the surface has left the flow: a parent that shrinks or re-centres
228
+ // without it must not move the place.
229
+ offset ??= { left: spot.left - base.left + x, top: spot.top - base.top + y };
230
+ left = base.left + offset.left - x;
231
+ top = base.top + offset.top - y;
232
+ }
233
+ return { left, top, right: left, bottom: top, width: 0, height: 0 };
234
+ };
235
+ return { rect, floating };
236
+ }
237
+
163
238
  function fallbackPosition(surface, anchor, placement, gap, viewportGap, surfaceRect = layoutRect(surface)) {
164
239
  const bounds = viewport(surface.ownerDocument.defaultView);
165
240
  const [preferred, align = "center"] = placement.split("-");
@@ -196,6 +271,109 @@ function fallbackPosition(surface, anchor, placement, gap, viewportGap, surfaceR
196
271
  surface.style.bottom = "auto";
197
272
  }
198
273
 
274
+ /** Applies the shared flip/shift policy to a live element or consumer-supplied virtual rectangle. */
275
+ export function positionAnchoredSurface(surface, rect, options = {}) {
276
+ fallbackPosition(surface, rect, options.placement ?? "bottom-start", options.gap ?? 4, options.viewportGap ?? 8);
277
+ }
278
+
279
+ /**
280
+ * One document-scoped scroll/resize listener and one frame for all active floating surfaces.
281
+ * The registry lives on the document so HTML and Vue bundles share the same coordinator.
282
+ * Each subscriber rereads its own live anchor and applies its viewport containment policy.
283
+ */
284
+ export function observeOverlayViewport(document, update) {
285
+ const key = Symbol.for("@threadlabs/looma/overlay-viewport/v1");
286
+ const owner = document.defaultView;
287
+ let state = document[key];
288
+ if (!state) {
289
+ state = { clients: new Set(), pending: new Map(), frame: null, abort: null };
290
+ Object.defineProperty(document, key, { value: state });
291
+ }
292
+ const schedule = (event) => {
293
+ for (const client of state.clients) state.pending.set(client, event);
294
+ if (state.frame !== null) return;
295
+ state.frame = owner.requestAnimationFrame(() => {
296
+ state.frame = null;
297
+ const pending = Array.from(state.pending);
298
+ state.pending.clear();
299
+ for (const [client, event] of pending) if (state.clients.has(client)) client(event);
300
+ });
301
+ };
302
+ state.clients.add(update);
303
+ if (!state.abort) {
304
+ state.abort = new owner.AbortController();
305
+ const signal = state.abort.signal;
306
+ owner.addEventListener("resize", schedule, { passive: true, signal });
307
+ owner.addEventListener("scroll", schedule, { passive: true, capture: true, signal });
308
+ owner.visualViewport?.addEventListener("resize", schedule, { passive: true, signal });
309
+ owner.visualViewport?.addEventListener("scroll", schedule, { passive: true, signal });
310
+ }
311
+ return () => {
312
+ state.clients.delete(update);
313
+ state.pending.delete(update);
314
+ if (state.clients.size) return;
315
+ state.abort?.abort();
316
+ state.abort = null;
317
+ if (state.frame !== null) owner.cancelAnimationFrame(state.frame);
318
+ state.frame = null;
319
+ };
320
+ }
321
+
322
+ /** Presents an unanchored or virtual-anchor surface in the top layer, using the shared viewport pass. */
323
+ export function createViewportSurface(surface, options = {}) {
324
+ const owner = surface.ownerDocument.defaultView;
325
+ let destroyed = false;
326
+ let stop = null;
327
+ let sizeObserver = null;
328
+ let frame = null;
329
+ const position = () => {
330
+ frame = null;
331
+ if (stop) options.position?.();
332
+ };
333
+ const refresh = () => {
334
+ if (stop && frame === null) frame = owner.requestAnimationFrame(position);
335
+ };
336
+ const chrome = options.preserveChrome ? owner.getComputedStyle(surface) : null;
337
+ const absentBorders = chrome ? ["Top", "Right", "Bottom", "Left"].filter(side => chrome[`border${side}Width`] === "0px") : [];
338
+ surface.setAttribute("popover", "manual");
339
+ if (options.bare) {
340
+ // Foreign shells need the UA positioning reset. Some own chrome; others delegate it to a child.
341
+ Object.assign(surface.style, {
342
+ position: "fixed", margin: "0",
343
+ top: surface.style.top || "auto", right: surface.style.right || "auto",
344
+ bottom: surface.style.bottom || "auto", left: surface.style.left || "auto",
345
+ });
346
+ if (chrome) {
347
+ // Suppress newly introduced UA borders without overriding authored theme-dependent chrome.
348
+ for (const side of absentBorders) surface.style[`border${side}Width`] = "0";
349
+ } else {
350
+ Object.assign(surface.style, { padding: "0", border: "0", background: "transparent" });
351
+ }
352
+ }
353
+ const hideSurface = () => {
354
+ stop?.(); stop = null;
355
+ sizeObserver?.disconnect(); sizeObserver = null;
356
+ if (frame !== null) owner.cancelAnimationFrame(frame);
357
+ frame = null;
358
+ hide(surface);
359
+ };
360
+ return {
361
+ show() {
362
+ if (destroyed) return;
363
+ show(surface);
364
+ stop ??= observeOverlayViewport(surface.ownerDocument, position);
365
+ options.position?.();
366
+ if (!sizeObserver && owner.ResizeObserver) {
367
+ sizeObserver = new owner.ResizeObserver(refresh);
368
+ sizeObserver.observe(surface);
369
+ }
370
+ },
371
+ hide: hideSurface,
372
+ refresh,
373
+ destroy() { destroyed = true; hideSurface(); },
374
+ };
375
+ }
376
+
199
377
  export function createAnchoredSurface(surface, options = {}) {
200
378
  const owner = surface.ownerDocument.defaultView;
201
379
  let placement = options.placement ?? "bottom-start";
@@ -204,44 +382,45 @@ export function createAnchoredSurface(surface, options = {}) {
204
382
  let anchor = options.anchor ?? null;
205
383
  let point = null;
206
384
  let open = false;
207
- let frame = null;
385
+ let destroyed = false;
208
386
  let anchorFrame = null;
209
387
  let lastAnchorRect = null;
210
- let abort = null;
211
- let sizeObserver = null;
212
- surface.setAttribute("popover", "manual");
213
- surface.style.position = "fixed";
214
- surface.style.margin = "0";
388
+ // Without an anchor, the surface stays at the place it is written (measured on each show).
389
+ let written = null;
390
+ const anchorRect = () => typeof anchor === "function" ? anchor() : anchor ? anchor.getBoundingClientRect() : written?.rect();
215
391
  const position = () => {
216
- frame = null;
217
- if (!open) return;
218
- if (point) {
219
- fallbackPosition(surface, { left: point.x, right: point.x, top: point.y, bottom: point.y, width: 0, height: 0 }, "bottom-start", gap(), viewportGap);
392
+ if (!anchor && !point) {
393
+ if (!written) return;
394
+ show(surface);
395
+ // No flip or shift: the surface is at its place, which scrolling may carry out of view.
396
+ const rect = lastAnchorRect = written.rect();
397
+ Object.assign(surface.style, { left: `${Math.round(rect.left)}px`, top: `${Math.round(rect.top)}px`, right: "auto", bottom: "auto" });
220
398
  return;
221
399
  }
222
- const rect = anchor?.getBoundingClientRect();
400
+ const rect = point ? { left: point.x, right: point.x, top: point.y, bottom: point.y, width: 0, height: 0 } : anchorRect();
401
+ if (!rect) return;
223
402
  // A disappearing trigger has no usable position. Keep the last valid position, or wait before first opening.
224
- if (!anchor || anchor.isConnected === false || !rect || rect.width === 0 && rect.height === 0
225
- || anchor instanceof owner.Element && owner.getComputedStyle(anchor).visibility !== "visible") {
226
- if (lastAnchorRect) fallbackPosition(surface, lastAnchorRect, placement, gap(), viewportGap);
403
+ if (!point && typeof anchor !== "function" && (anchor.isConnected === false || rect.width === 0 && rect.height === 0
404
+ || anchor instanceof owner.Element && owner.getComputedStyle(anchor).visibility !== "visible")) {
405
+ if (lastAnchorRect) positionAnchoredSurface(surface, lastAnchorRect, { placement, gap: gap(), viewportGap });
227
406
  else hide(surface);
228
407
  return;
229
408
  }
230
- lastAnchorRect = rect;
409
+ lastAnchorRect = { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height };
231
410
  show(surface);
232
- fallbackPosition(surface, lastAnchorRect, placement, gap(), viewportGap);
233
- };
234
- const schedule = () => {
235
- if (open && frame === null) frame = owner.requestAnimationFrame(position);
411
+ positionAnchoredSurface(surface, rect, { placement: point ? "bottom-start" : placement, gap: gap(), viewportGap });
236
412
  };
413
+ surface.style.position = "fixed";
414
+ surface.style.margin = "0";
415
+ const presentation = createViewportSurface(surface, { position });
237
416
  // Resize and scroll events do not report an ancestor's transition or layout shift.
238
- // While visible, sample the anchor and only re-position when its bounds change.
417
+ // While visible, sample the live anchor and only re-position when its bounds change.
239
418
  const trackAnchor = () => {
240
419
  anchorFrame = null;
241
- if (!open || !anchor || point) return;
242
- const rect = anchor.getBoundingClientRect();
243
- if (!lastAnchorRect || rect.left !== lastAnchorRect.left || rect.top !== lastAnchorRect.top
244
- || rect.width !== lastAnchorRect.width || rect.height !== lastAnchorRect.height) schedule();
420
+ if (!open || !(anchor || written) || point) return;
421
+ const rect = anchorRect();
422
+ if (rect && (!lastAnchorRect || rect.left !== lastAnchorRect.left || rect.top !== lastAnchorRect.top
423
+ || rect.width !== lastAnchorRect.width || rect.height !== lastAnchorRect.height)) presentation.refresh();
245
424
  anchorFrame = owner.requestAnimationFrame(trackAnchor);
246
425
  };
247
426
  const stopTracking = () => {
@@ -249,38 +428,35 @@ export function createAnchoredSurface(surface, options = {}) {
249
428
  anchorFrame = null;
250
429
  lastAnchorRect = null;
251
430
  };
252
- const syncListeners = () => {
253
- if (open && anchor && !point) {
431
+ const syncTracking = () => {
432
+ if (open && (anchor || written) && !point) {
254
433
  if (anchorFrame === null) anchorFrame = owner.requestAnimationFrame(trackAnchor);
255
434
  } else stopTracking();
256
- const needed = open && (point || anchor);
257
- if (!needed) {
258
- abort?.abort();
259
- abort = null;
260
- return;
261
- }
262
- if (abort) return;
263
- abort = new AbortController();
264
- const signal = abort.signal;
265
- owner.addEventListener("resize", schedule, { passive: true, signal });
266
- owner.addEventListener("scroll", schedule, { passive: true, capture: true, signal });
267
- owner.visualViewport?.addEventListener("resize", schedule, { passive: true, signal });
268
- owner.visualViewport?.addEventListener("scroll", schedule, { passive: true, signal });
269
- };
270
- const observeSize = () => {
271
- if (sizeObserver || !owner.ResizeObserver) return;
272
- sizeObserver = new owner.ResizeObserver(schedule);
273
- sizeObserver.observe(surface);
274
435
  };
275
- const stopSize = () => { sizeObserver?.disconnect(); sizeObserver = null; };
276
436
  return {
277
- setAnchor(next) { point = null; anchor = next; syncListeners(); schedule(); },
278
- setPlacement(next) { placement = next || "bottom-start"; schedule(); },
279
- show() { point = null; open = true; syncListeners(); show(surface); position(); schedule(); observeSize(); },
280
- showAtPoint(next) { point = next; open = true; syncListeners(); show(surface); position(); schedule(); observeSize(); },
281
- hide() { open = false; point = null; syncListeners(); stopSize(); if (frame !== null) owner.cancelAnimationFrame(frame); frame = null; hide(surface); },
282
- refresh: schedule,
283
- destroy() { open = false; stopTracking(); abort?.abort(); abort = null; stopSize(); if (frame !== null) owner.cancelAnimationFrame(frame); frame = null; hide(surface); anchor = null; },
437
+ setAnchor(next) { point = null; anchor = next; syncTracking(); presentation.refresh(); },
438
+ setPlacement(next) { placement = next || "bottom-start"; presentation.refresh(); },
439
+ show() {
440
+ if (destroyed) return;
441
+ point = null;
442
+ open = true;
443
+ if (anchor || topLayerOpen(surface)) {
444
+ presentation.show();
445
+ syncTracking();
446
+ return;
447
+ }
448
+ // Measure the written place a microtask later, once the host's open state styles it.
449
+ queueMicrotask(() => {
450
+ if (destroyed || !open || point || anchor || topLayerOpen(surface)) return;
451
+ written = writtenPlace(surface, ["position", "margin", "left", "top", "right", "bottom"]);
452
+ presentation.show();
453
+ syncTracking();
454
+ });
455
+ },
456
+ showAtPoint(next) { if (destroyed) return; point = next; open = true; syncTracking(); presentation.show(); },
457
+ hide() { open = false; point = null; syncTracking(); presentation.hide(); },
458
+ refresh: presentation.refresh,
459
+ destroy() { destroyed = true; open = false; stopTracking(); presentation.destroy(); anchor = null; },
284
460
  };
285
461
  }
286
462
 
@@ -1,3 +1,5 @@
1
+ import { observeOverlayViewport } from "../shared/overlay.js";
2
+
1
3
  function distance(point, rect) {
2
4
  const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
3
5
  const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
@@ -52,10 +54,7 @@ function connect(host) {
52
54
  const observer = new MutationObserver(invalidate);
53
55
  observer.observe(element, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-ui-affordance", "disabled", "aria-disabled"] });
54
56
  element.ownerDocument.addEventListener("pointermove", onPointermove, { passive: true });
55
- owner.addEventListener("resize", invalidate, { passive: true });
56
- owner.addEventListener("scroll", invalidate, { passive: true, capture: true });
57
- owner.visualViewport?.addEventListener("resize", invalidate, { passive: true });
58
- owner.visualViewport?.addEventListener("scroll", invalidate, { passive: true });
57
+ const stopViewport = observeOverlayViewport(element.ownerDocument, invalidate);
59
58
  const stop = host.effect(() => {
60
59
  const configured = Number(host.props.nearRadius.value ?? 16);
61
60
  nearRadius = Number.isFinite(configured) ? Math.max(0, configured) : 16;
@@ -64,12 +63,9 @@ function connect(host) {
64
63
  measure();
65
64
  return () => {
66
65
  stop();
66
+ stopViewport();
67
67
  observer.disconnect();
68
68
  element.ownerDocument.removeEventListener("pointermove", onPointermove);
69
- owner.removeEventListener("resize", invalidate);
70
- owner.removeEventListener("scroll", invalidate, true);
71
- owner.visualViewport?.removeEventListener("resize", invalidate);
72
- owner.visualViewport?.removeEventListener("scroll", invalidate);
73
69
  if (frame !== null) owner.cancelAnimationFrame(frame);
74
70
  clear();
75
71
  };
@@ -89,6 +89,9 @@
89
89
  :host([tone="danger"]) {
90
90
  border-color: color-mix(in srgb, var(--ui-danger) 45%, var(--ui-border-default));
91
91
  border-inline-start: var(--ui-accent-line-width) solid var(--ui-danger);
92
+ /* An accent edge keeps the corners on its side straight. */
93
+ border-start-start-radius: 0;
94
+ border-end-start-radius: 0;
92
95
  --_ui-card-surface: var(--ui-danger-soft);
93
96
  }
94
97
 
@@ -36,12 +36,12 @@
36
36
  </event>
37
37
  </defs>
38
38
  <dialog from:aria-label="$accessibleLabel">
39
- <header>
39
+ <header $ref="header">
40
40
  <h2 class="title" $if="$label">{$label}</h2>
41
41
  <button class="close" $ref="close" type="button" aria-label="Close"></button>
42
42
  </header>
43
- <div class="body"><ui-scroll-area from:trim="true"><slot></slot></ui-scroll-area></div>
44
- <footer><slot name="actions"></slot></footer>
43
+ <div class="body" $ref="body"><ui-scroll-area from:trim="true"><slot></slot></ui-scroll-area></div>
44
+ <footer $ref="footer"><slot name="actions"></slot></footer>
45
45
  </dialog>
46
46
  <style>
47
47
  /* This component's hooks style the element they are set on, and never a nested one. */
@@ -143,7 +143,9 @@
143
143
  align-items: center;
144
144
  gap: var(--ui-space-3);
145
145
  flex: none;
146
- padding: var(--ui-space-3) var(--ui-space-3) var(--ui-space-3) var(--ui-space-5);
146
+ /* The title's ink sits inside its line box and the taller close target. Balance that
147
+ visible top inset against the action row's bottom inset, not just the boxes. */
148
+ padding: calc(var(--ui-space-3) + var(--ui-space-1) / 2) var(--ui-space-3) var(--ui-space-3) var(--ui-space-5);
147
149
  }
148
150
 
149
151
  /* :host-qualified so page heading rules that reach in cannot outrank the dialog chrome. */
@@ -251,7 +253,7 @@
251
253
  overlay var(--_ui-dialog-duration, var(--ui-motion-base)) var(--ui-motion-ease) allow-discrete;
252
254
  }
253
255
 
254
- :host[open]::backdrop {
256
+ :host[open]:modal::backdrop {
255
257
  background-color: var(--ui-overlay-backdrop);
256
258
  animation: ui-dialog-backdrop-enter var(--_ui-dialog-duration, var(--ui-motion-base)) var(--ui-motion-ease);
257
259
  }
@@ -1,10 +1,59 @@
1
- import { closeOverlay, createIdResolver, openOverlay, requestTopOverlayClose } from "../shared/overlay.js";
1
+ import { closeDialog, closeOverlay, createIdResolver, openOverlay, requestTopOverlayClose, showDialog } from "../shared/overlay.js";
2
2
 
3
3
  function inferredLabel(element, explicit) {
4
4
  if (String(explicit ?? "").trim()) return String(explicit).trim();
5
5
  return element.querySelector("h1, h2, h3, h4, h5, h6")?.textContent?.trim() || "Dialog";
6
6
  }
7
7
 
8
+ /** Animates intrinsic content changes while leaving CSS in charge of the final viewport cap. */
9
+ function observeContentHeight(dialog, refs) {
10
+ const owner = dialog.ownerDocument.defaultView;
11
+ const motion = owner.matchMedia("(prefers-reduced-motion: reduce)");
12
+ let lastHeight = null;
13
+ let animation = null;
14
+ const reset = () => {
15
+ animation?.cancel();
16
+ animation = null;
17
+ lastHeight = null;
18
+ };
19
+ const observer = new owner.ResizeObserver(() => {
20
+ if (!dialog.open) { reset(); return; }
21
+ const previous = animation ? parseFloat(owner.getComputedStyle(dialog).height) : lastHeight;
22
+ animation?.cancel();
23
+ animation = null;
24
+ // Cancel before measuring: auto sizing now includes the latest content and native CSS constraints.
25
+ const style = owner.getComputedStyle(dialog);
26
+ const next = parseFloat(style.height);
27
+ lastHeight = next;
28
+ if (previous === null || !Number.isFinite(previous) || !Number.isFinite(next) || Math.abs(next - previous) < 1 || motion.matches) return;
29
+ const time = style.transitionDuration.split(",")[0].trim();
30
+ const duration = parseFloat(time) * (time.endsWith("ms") ? 1 : 1000);
31
+ if (!(duration > 0)) return;
32
+ const current = dialog.animate([{ height: `${previous}px` }, { height: `${next}px` }], {
33
+ duration, easing: style.getPropertyValue("--ui-motion-ease").trim() || "ease",
34
+ });
35
+ animation = current;
36
+ current.onfinish = () => {
37
+ if (animation !== current) return;
38
+ animation = null;
39
+ current.cancel();
40
+ lastHeight = dialog.open ? parseFloat(owner.getComputedStyle(dialog).height) : null;
41
+ };
42
+ });
43
+ // Observe Scroll Area's intrinsic flow wrapper, not its constrained scrolling viewport.
44
+ // Watching the dialog or body would feed animation frames back into the resize observer.
45
+ // Scroll Area offers no public hook for that wrapper, so this reads its rendered structure.
46
+ for (const region of [refs.header, refs.body?.firstElementChild?.firstElementChild, refs.footer]) {
47
+ if (region) observer.observe(region);
48
+ }
49
+ const onMotionChange = () => {
50
+ reset();
51
+ if (dialog.open) lastHeight = parseFloat(owner.getComputedStyle(dialog).height);
52
+ };
53
+ motion.addEventListener("change", onMotionChange);
54
+ return { reset, destroy() { reset(); observer.disconnect(); motion.removeEventListener("change", onMotionChange); } };
55
+ }
56
+
8
57
  function connect(host) {
9
58
  const element = host.element;
10
59
  const document = element.ownerDocument;
@@ -23,6 +72,15 @@ function connect(host) {
23
72
  };
24
73
  const closeButton = host.refs.close;
25
74
  const onCloseClick = (event) => requestClose("action", event.detail === 0 ? "keyboard" : "pointer");
75
+ // A popover dialog does not receive native dialog command defaults in every browser.
76
+ // Keep authored close actions equivalent in both presentation modes.
77
+ const onActionClick = (event) => {
78
+ if (event.defaultPrevented) return;
79
+ const action = event.target.closest?.('button[command="close"]');
80
+ if (!action || !dialog?.id || action.getAttribute("commandfor") !== dialog.id) return;
81
+ event.preventDefault();
82
+ onCloseClick(event);
83
+ };
26
84
  const onTriggerClick = (event) => {
27
85
  if (host.state.internalOpen) return;
28
86
  host.state.internalOpen = true;
@@ -67,14 +125,11 @@ function connect(host) {
67
125
  if (!dialog) return;
68
126
  dialog.setAttribute("closedby", closedBy);
69
127
  if (open) {
70
- if (dialog.open && dialog.matches(":modal") !== modal) dialog.close();
71
- if (!dialog.open) {
72
- if (modal) dialog.showModal();
73
- else dialog.show();
74
- }
128
+ showDialog(dialog, modal);
75
129
  openOverlay({ id: overlayId, modal, element, modalElement: dialog, dismissible: true, canClose: (reason) => reason === "light-dismiss" ? closedBy === "any" : reason !== "escape" || closedBy !== "none", requestClose });
76
130
  } else {
77
- if (dialog.open) dialog.close();
131
+ resize.reset();
132
+ closeDialog(dialog);
78
133
  closeOverlay(document, overlayId);
79
134
  }
80
135
  if (lastOpen !== undefined && lastOpen !== open) {
@@ -112,17 +167,22 @@ function connect(host) {
112
167
  dialog?.addEventListener("cancel", onCancel);
113
168
  closeButton?.addEventListener("click", onCloseClick);
114
169
  element.addEventListener("keydown", onKeydown);
170
+ document.addEventListener("click", onActionClick);
171
+ const resize = observeContentHeight(dialog, host.refs);
115
172
  const stop = host.effect(apply);
116
173
  apply();
117
174
  return () => {
118
175
  stop();
119
176
  ids.stop();
120
177
  observer.disconnect();
178
+ resize.destroy();
121
179
  dialog?.removeEventListener("close", onClose, true);
122
180
  dialog?.removeEventListener("cancel", onCancel);
123
181
  closeButton?.removeEventListener("click", onCloseClick);
124
182
  trigger?.removeEventListener("click", onTriggerClick);
125
183
  element.removeEventListener("keydown", onKeydown);
184
+ document.removeEventListener("click", onActionClick);
185
+ closeDialog(dialog);
126
186
  closeOverlay(document, overlayId);
127
187
  };
128
188
  }
@@ -44,6 +44,12 @@
44
44
  <div class="empty" $ref="empty"><slot name="empty"></slot></div>
45
45
  </div>
46
46
  <style>
47
+ :host[popover] {
48
+ position: fixed;
49
+ inset: auto;
50
+ margin: 0;
51
+ }
52
+
47
53
  :host {
48
54
  user-select: none;
49
55
  }
@@ -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 @ and derives its rows; the template renders them.
@@ -7,6 +8,7 @@ function connect(host) {
7
8
  const place = () => {
8
9
  if (host.state.visible) positionMenu(element, normalizeAnchor(host.props.anchorRect.value), 320);
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 rows = options();
@@ -23,7 +25,8 @@ function connect(host) {
23
25
  const visible = Boolean(host.props.open.value && normalizeAnchor(host.props.anchorRect.value)
24
26
  && (host.props.loading.value || rows.length > 0 || empty));
25
27
  if (host.state.visible !== visible) host.state.visible = visible;
26
- place();
28
+ if (visible) surface.show();
29
+ else surface.hide();
27
30
  };
28
31
  const stop = host.effect(() => {
29
32
  if (!authoredLabel) element.setAttribute("aria-label", String(host.props.label.value || "Mentions"));
@@ -54,20 +57,13 @@ function connect(host) {
54
57
  element.addEventListener("mousedown", onMousedown);
55
58
  element.addEventListener("click", onClick);
56
59
  element.addEventListener("mouseover", onMouseover);
57
- window.addEventListener("resize", place);
58
- window.addEventListener("scroll", place, true);
59
- window.visualViewport?.addEventListener("resize", place);
60
- window.visualViewport?.addEventListener("scroll", place);
61
60
  return () => {
62
61
  stop();
62
+ surface.destroy();
63
63
  observer.disconnect();
64
64
  element.removeEventListener("mousedown", onMousedown);
65
65
  element.removeEventListener("click", onClick);
66
66
  element.removeEventListener("mouseover", onMouseover);
67
- window.removeEventListener("resize", place);
68
- window.removeEventListener("scroll", place, true);
69
- window.visualViewport?.removeEventListener("resize", place);
70
- window.visualViewport?.removeEventListener("scroll", place);
71
67
  };
72
68
  }
73
69
 
@@ -43,6 +43,12 @@
43
43
  <div class="footer"><slot name="footer"></slot></div>
44
44
  </div>
45
45
  <style>
46
+ :host[popover] {
47
+ position: fixed;
48
+ inset: auto;
49
+ margin: 0;
50
+ }
51
+
46
52
  :host {
47
53
  user-select: none;
48
54
  }