@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.
- package/components/shared/editor.js +4 -5
- package/components/shared/overlay.js +231 -55
- package/components/ui-affordance-scope/ui-affordance-scope.js +4 -8
- package/components/ui-card/ui-card.html +3 -0
- package/components/ui-dialog/ui-dialog.html +7 -5
- package/components/ui-dialog/ui-dialog.js +67 -7
- package/components/ui-editor-mention-menu/ui-editor-mention-menu.html +6 -0
- package/components/ui-editor-mention-menu/ui-editor-mention-menu.js +5 -9
- package/components/ui-editor-slash-menu/ui-editor-slash-menu.html +6 -0
- package/components/ui-editor-slash-menu/ui-editor-slash-menu.js +5 -9
- package/components/ui-editor-table-context-menu/ui-editor-table-context-menu.html +21 -4
- package/components/ui-editor-table-context-menu/ui-editor-table-context-menu.js +50 -22
- package/components/ui-editor-table-overlay/ui-editor-table-overlay.js +4 -6
- package/components/ui-editor-table-toolbar/ui-editor-table-toolbar.html +3 -11
- package/components/ui-editor-table-toolbar/ui-editor-table-toolbar.js +33 -44
- package/components/ui-icon-button/ui-icon-button.html +12 -2
- package/components/ui-listbox/ui-listbox.html +22 -17
- package/components/ui-menu/ui-menu.js +5 -3
- package/components/ui-popover/ui-popover.js +3 -2
- package/components/ui-scroll-area/ui-scroll-area.html +16 -0
- package/components/ui-search-shell/ui-search-shell.html +1 -1
- package/components/ui-search-shell/ui-search-shell.js +3 -5
- package/components/ui-section/ui-section.html +3 -0
- package/components/ui-toast/ui-toast.html +2 -1
- package/components/ui-toast/ui-toast.js +18 -2
- package/components/ui-toast-region/ui-toast-region.js +11 -3
- package/components/ui-tooltip/ui-tooltip.html +2 -1
- package/components/ui-tooltip/ui-tooltip.js +20 -0
- package/dist/index.js +1 -1
- package/editor/chunks/extensions.js +2 -0
- package/editor/extensions/slash-command.d.ts +2 -0
- package/editor/mention-contract.d.ts +2 -0
- package/package.json +1 -1
- package/styles/ui-card.css +3 -0
- package/styles/ui-dialog.css +4 -2
- package/styles/ui-editor-mention-menu.css +7 -1
- package/styles/ui-editor-slash-menu.css +7 -1
- package/styles/ui-editor-table-context-menu.css +29 -5
- package/styles/ui-editor-table-toolbar.css +3 -11
- package/styles/ui-icon-button.css +9 -2
- package/styles/ui-listbox.css +6 -0
- package/styles/ui-scroll-area.css +13 -0
- package/styles/ui-search-shell.css +1 -1
- package/styles/ui-section.css +3 -0
- package/vanilla/UiDialog.js +10 -6
- package/vanilla/UiIconButton.d.ts +1 -0
- package/vanilla/UiIconButton.js +14 -14
- package/vanilla/UiListbox.js +10 -9
- package/vanilla/UiScrollArea.d.ts +1 -0
- package/vanilla/UiScrollArea.js +4 -4
- package/vue/UiCard.vue +3 -0
- package/vue/UiDialog.vue +12 -6
- package/vue/UiEditorMentionMenu.vue +6 -0
- package/vue/UiEditorSlashMenu.vue +6 -0
- package/vue/UiEditorTableContextMenu.vue +21 -4
- package/vue/UiEditorTableToolbar.vue +3 -11
- package/vue/UiIconButton.d.ts +9 -0
- package/vue/UiIconButton.vue +31 -2
- package/vue/UiListbox.vue +20 -15
- package/vue/UiScrollArea.d.ts +9 -0
- package/vue/UiScrollArea.vue +39 -1
- package/vue/UiSearchShell.vue +1 -1
- package/vue/UiSection.vue +3 -0
- package/vue/UiToast.vue +1 -1
- package/vue/UiTooltip.vue +1 -1
- package/vue/chunks/UiCard.js +1 -1
- package/vue/chunks/UiDialog.js +25 -8
- package/vue/chunks/UiEditorMentionMenu.js +1 -1
- package/vue/chunks/UiEditorSlashMenu.js +1 -1
- package/vue/chunks/UiEditorTableContextMenu.js +1 -1
- package/vue/chunks/UiEditorTableToolbar.js +1 -1
- package/vue/chunks/UiIconButton.js +26 -1
- package/vue/chunks/UiListbox.js +12 -12
- package/vue/chunks/UiScrollArea.js +33 -2
- package/vue/chunks/UiSearchShell.js +1 -1
- package/vue/chunks/UiSection.js +1 -1
- package/vue/chunks/UiToast.js +2 -2
- package/vue/chunks/UiTooltip.js +2 -2
- package/vue/components.css +340 -289
- 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
|
-
|
|
51
|
-
|
|
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
|
|
1
|
+
const dialogInvokers = new WeakMap();
|
|
2
2
|
|
|
3
3
|
function stateFor(document) {
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
385
|
+
let destroyed = false;
|
|
208
386
|
let anchorFrame = null;
|
|
209
387
|
let lastAnchorRect = null;
|
|
210
|
-
|
|
211
|
-
let
|
|
212
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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 =
|
|
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
|
|
225
|
-
|| anchor instanceof owner.Element && owner.getComputedStyle(anchor).visibility !== "visible") {
|
|
226
|
-
if (lastAnchorRect)
|
|
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
|
-
|
|
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 =
|
|
243
|
-
if (!lastAnchorRect || rect.left !== lastAnchorRect.left || rect.top !== lastAnchorRect.top
|
|
244
|
-
|| rect.width !== lastAnchorRect.width || rect.height !== lastAnchorRect.height)
|
|
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
|
|
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;
|
|
278
|
-
setPlacement(next) { placement = next || "bottom-start";
|
|
279
|
-
show() {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
|