@threadlabs/looma 0.26.26 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-badge/ui-badge.html +17 -3
- 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-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/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-badge.css +16 -0
- 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-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/UiBadge.d.ts +1 -1
- package/vanilla/UiBadge.js +1 -1
- package/vanilla/UiDialog.js +10 -6
- package/vanilla/UiListbox.js +10 -9
- package/vanilla/UiScrollArea.d.ts +1 -0
- package/vanilla/UiScrollArea.js +4 -4
- package/vue/UiBadge.d.ts +3 -3
- package/vue/UiBadge.vue +24 -6
- 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/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/chunks/UiBadge.js +5 -4
- 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/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/components.css +332 -272
- 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
|
};
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
<template component="ui-badge">
|
|
2
2
|
<defs>
|
|
3
|
-
<prop name="variant" type="keyword" values="solid, subtle, outline, inverse" default="subtle"
|
|
3
|
+
<prop name="variant" type="keyword" values="solid, subtle, outline, inverse, overlay" default="subtle"
|
|
4
4
|
>Emphasis. subtle sets toned text over a soft wash; solid fills with the tone and uses contrasting text; outline keeps the surface with a toned edge.
|
|
5
5
|
inverse uses a translucent contrasting surface and toned ink for a count or label on a filled control of that tone. Neutral looks the same in solid and
|
|
6
|
-
subtle.
|
|
6
|
+
subtle. overlay inherits the containing filled control's text colour on a faint translucent fill, without a visible rim or shadow. Its small circle is
|
|
7
|
+
20px. When space is constrained, the label truncates with an ellipsis while its full text remains available.</prop>
|
|
7
8
|
<prop name="tone" type="keyword" values="neutral, accent, info, success, warning, danger" default="neutral"
|
|
8
9
|
>Which colour the badge speaks in. neutral is a quiet grey for plain labels; accent draws the eye in the accent colour; info, success, warning, and danger
|
|
9
10
|
mark a status, so match the tone to what the badge says. For a category rather than a status, set the --ui-badge-color hook on the badge instead: any CSS
|
|
@@ -16,7 +17,7 @@
|
|
|
16
17
|
<prop name="size" type="keyword" values="xs, sm, md, lg" default="md"
|
|
17
18
|
>Scale. xs is compact; sm keeps the usual badge geometry with compact text, fitting short counts such as 99+ inside a 24px circle. md is the usual text
|
|
18
19
|
badge; lg adds room to fixed marks while retaining the usual text size. A square is 24px at xs, 32px at sm and md, and 40px at lg. A circle is 16px at xs,
|
|
19
|
-
24px at sm and md, and 32px at lg.</prop>
|
|
20
|
+
24px at sm and md, and 32px at lg. A small overlay circle is 20px.</prop>
|
|
20
21
|
</defs>
|
|
21
22
|
<span>
|
|
22
23
|
<span class="icon" aria-hidden="true"><slot name="icon"></slot></span>
|
|
@@ -315,6 +316,15 @@
|
|
|
315
316
|
box-shadow: 0 1px 1px light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.12));
|
|
316
317
|
}
|
|
317
318
|
|
|
319
|
+
/* A count can keep its containing control's contrasting ink instead of reversing it.
|
|
320
|
+
The faint fill is painted over that control; no second rim or shadow competes with it. */
|
|
321
|
+
:host([variant="overlay"]) {
|
|
322
|
+
--_badge-outline-surface: var(--ui-badge-surface, color-mix(in srgb, currentColor 10%, transparent));
|
|
323
|
+
color: var(--ui-badge-text, inherit);
|
|
324
|
+
border-color: var(--ui-badge-border, transparent);
|
|
325
|
+
box-shadow: none;
|
|
326
|
+
}
|
|
327
|
+
|
|
318
328
|
/* Neutral reverses a subtle surface/ink pair rather than a strong tone. Keep its original
|
|
319
329
|
surface ink: moving it toward dark-theme primary text would reduce overlay contrast. */
|
|
320
330
|
:host([variant="inverse"][tone="neutral"]) {
|
|
@@ -406,6 +416,10 @@
|
|
|
406
416
|
text-box-trim: none;
|
|
407
417
|
}
|
|
408
418
|
|
|
419
|
+
:host([shape="circle"][size="sm"][variant="overlay"]) {
|
|
420
|
+
--_badge-circle-size: var(--ui-badge-circle-size, calc(var(--ui-space-4) + var(--ui-space-1)));
|
|
421
|
+
}
|
|
422
|
+
|
|
409
423
|
:host([shape="circle"][size="xs"]) {
|
|
410
424
|
--_badge-circle-size: var(--ui-badge-circle-size, var(--ui-space-4));
|
|
411
425
|
}
|
|
@@ -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
|
}
|