@cocoar/ui-overlay 0.1.0-beta.70
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/README.md +18 -0
- package/fesm2022/cocoar-ui-overlay.mjs +1326 -0
- package/fesm2022/cocoar-ui-overlay.mjs.map +1 -0
- package/package.json +46 -0
- package/types/cocoar-ui-overlay.d.ts +296 -0
|
@@ -0,0 +1,1326 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, inject, ApplicationRef, EnvironmentInjector, createComponent, Injectable, Injector, createEnvironmentInjector } from '@angular/core';
|
|
3
|
+
import { Subject } from 'rxjs';
|
|
4
|
+
|
|
5
|
+
class ContentBuilder {
|
|
6
|
+
fromComponent(component) {
|
|
7
|
+
return { kind: 'component', component };
|
|
8
|
+
}
|
|
9
|
+
fromTemplate(template) {
|
|
10
|
+
return { kind: 'template', template };
|
|
11
|
+
}
|
|
12
|
+
fromText() {
|
|
13
|
+
return { kind: 'text' };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function deepFreeze(value) {
|
|
18
|
+
if (value == null)
|
|
19
|
+
return value;
|
|
20
|
+
if (typeof value !== 'object')
|
|
21
|
+
return value;
|
|
22
|
+
// Only freeze plain JSON-like objects/arrays.
|
|
23
|
+
// Framework objects (e.g. Angular TemplateRef) can have internal mutable state.
|
|
24
|
+
const proto = Object.getPrototypeOf(value);
|
|
25
|
+
const isPlainObject = proto === Object.prototype || proto === null;
|
|
26
|
+
if (!isPlainObject && !Array.isArray(value)) {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
if (Object.isFrozen(value))
|
|
30
|
+
return value;
|
|
31
|
+
Object.freeze(value);
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
for (const entry of value) {
|
|
34
|
+
deepFreeze(entry);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
for (const key of Object.keys(value)) {
|
|
39
|
+
deepFreeze(value[key]);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class OverlayBuilder {
|
|
45
|
+
draft;
|
|
46
|
+
constructor(seed) {
|
|
47
|
+
this.draft = { ...(seed ?? {}) };
|
|
48
|
+
}
|
|
49
|
+
backdrop(cfg) {
|
|
50
|
+
if (cfg === 'none') {
|
|
51
|
+
this.draft.backdrop = { kind: 'none' };
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
if (cfg === 'modal') {
|
|
55
|
+
this.draft.backdrop = { kind: 'modal', closeOnBackdropClick: true };
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
this.draft.backdrop = cfg;
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
anchor(cfg) {
|
|
62
|
+
this.draft.anchor = cfg;
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
position(cfg) {
|
|
66
|
+
this.draft.position = cfg;
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
size(cfg) {
|
|
70
|
+
this.draft.size = cfg;
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
scroll(cfg) {
|
|
74
|
+
this.draft.scroll = cfg;
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
dismiss(cfg) {
|
|
78
|
+
this.draft.dismiss = cfg;
|
|
79
|
+
return this;
|
|
80
|
+
}
|
|
81
|
+
focus(cfg) {
|
|
82
|
+
this.draft.focus = cfg;
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
a11y(cfg) {
|
|
86
|
+
this.draft.a11y = cfg;
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
attachment(cfg) {
|
|
90
|
+
this.draft.attachment = cfg;
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
93
|
+
content(fn) {
|
|
94
|
+
const contentBuilder = new ContentBuilder();
|
|
95
|
+
this.draft.content = fn(contentBuilder);
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
freeze() {
|
|
99
|
+
return deepFreeze({ ...this.draft });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
class CoarOverlay {
|
|
104
|
+
static define(fn, ...presets) {
|
|
105
|
+
const builder = new OverlayBuilder();
|
|
106
|
+
for (const preset of presets) {
|
|
107
|
+
preset(builder);
|
|
108
|
+
}
|
|
109
|
+
fn(builder);
|
|
110
|
+
return builder.freeze();
|
|
111
|
+
}
|
|
112
|
+
static fork(base, fn) {
|
|
113
|
+
const builder = new OverlayBuilder(base);
|
|
114
|
+
fn(builder);
|
|
115
|
+
return builder.freeze();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Alias kept for spec parity (`Overlay.define(...)`). */
|
|
119
|
+
const Overlay = CoarOverlay;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Multi-provider token. Resolvers are applied in registration order.
|
|
123
|
+
*/
|
|
124
|
+
const COAR_OVERLAY_SPEC_RESOLVERS = new InjectionToken('COAR_OVERLAY_SPEC_RESOLVERS');
|
|
125
|
+
const COAR_OVERLAY_DEFAULTS = {
|
|
126
|
+
anchor: { kind: 'virtual', placement: 'center' },
|
|
127
|
+
position: {
|
|
128
|
+
placement: ['top', 'bottom', 'left', 'right'],
|
|
129
|
+
offset: 8,
|
|
130
|
+
flip: true,
|
|
131
|
+
shift: true,
|
|
132
|
+
},
|
|
133
|
+
backdrop: { kind: 'none' },
|
|
134
|
+
scroll: { strategy: 'reposition' },
|
|
135
|
+
dismiss: { outsideClick: true, escapeKey: true },
|
|
136
|
+
focus: { trap: false, restore: true },
|
|
137
|
+
a11y: {},
|
|
138
|
+
attachment: { strategy: 'body' },
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
function getViewportRect() {
|
|
142
|
+
const docEl = document.documentElement;
|
|
143
|
+
const width = docEl?.clientWidth || window.innerWidth;
|
|
144
|
+
const height = docEl?.clientHeight || window.innerHeight;
|
|
145
|
+
return { width, height };
|
|
146
|
+
}
|
|
147
|
+
function getContainerRect(container) {
|
|
148
|
+
return rectFromDom(container.getBoundingClientRect());
|
|
149
|
+
}
|
|
150
|
+
function rectFromDom(domRect) {
|
|
151
|
+
return {
|
|
152
|
+
left: domRect.left,
|
|
153
|
+
top: domRect.top,
|
|
154
|
+
right: domRect.right,
|
|
155
|
+
bottom: domRect.bottom,
|
|
156
|
+
width: domRect.width,
|
|
157
|
+
height: domRect.height,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function getAnchorRect(anchor, viewport) {
|
|
161
|
+
switch (anchor.kind) {
|
|
162
|
+
case 'element':
|
|
163
|
+
return rectFromDom(anchor.element.getBoundingClientRect());
|
|
164
|
+
case 'point':
|
|
165
|
+
return rectFromPoint(anchor, 0, 0);
|
|
166
|
+
case 'virtual':
|
|
167
|
+
return rectFromVirtual(anchor, viewport);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function rectFromPoint(point, width, height) {
|
|
171
|
+
const left = point.x;
|
|
172
|
+
const top = point.y;
|
|
173
|
+
return {
|
|
174
|
+
left,
|
|
175
|
+
top,
|
|
176
|
+
right: left + width,
|
|
177
|
+
bottom: top + height,
|
|
178
|
+
width,
|
|
179
|
+
height,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function rectFromVirtual(spec, viewport) {
|
|
183
|
+
if (spec.placement === 'center') {
|
|
184
|
+
const x = viewport.width / 2;
|
|
185
|
+
const y = viewport.height / 2;
|
|
186
|
+
return rectFromPoint({ x, y }, 0, 0);
|
|
187
|
+
}
|
|
188
|
+
if (spec.placement === 'top') {
|
|
189
|
+
const x = viewport.width / 2;
|
|
190
|
+
const y = 0;
|
|
191
|
+
return rectFromPoint({ x, y }, 0, 0);
|
|
192
|
+
}
|
|
193
|
+
const x = viewport.width / 2;
|
|
194
|
+
const y = viewport.height;
|
|
195
|
+
return rectFromPoint({ x, y }, 0, 0);
|
|
196
|
+
}
|
|
197
|
+
function getScrollParents(element) {
|
|
198
|
+
const result = [];
|
|
199
|
+
let current = element;
|
|
200
|
+
while (current && current.parentElement) {
|
|
201
|
+
current = current.parentElement;
|
|
202
|
+
const style = getComputedStyle(current);
|
|
203
|
+
const overflowY = style.overflowY;
|
|
204
|
+
const overflowX = style.overflowX;
|
|
205
|
+
const scrollable = overflowY === 'auto' ||
|
|
206
|
+
overflowY === 'scroll' ||
|
|
207
|
+
overflowX === 'auto' ||
|
|
208
|
+
overflowX === 'scroll';
|
|
209
|
+
if (scrollable) {
|
|
210
|
+
result.push(current);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
result.push(window);
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
function computeOverlayCoordinates(anchorRect, overlaySize, position, viewport, boundaryRect) {
|
|
217
|
+
const placements = Array.isArray(position.placement)
|
|
218
|
+
? position.placement
|
|
219
|
+
: [position.placement];
|
|
220
|
+
const offset = position.offset ?? 0;
|
|
221
|
+
const allowFlip = position.flip ?? false;
|
|
222
|
+
const allowShift = position.shift ?? false;
|
|
223
|
+
// Use container boundaries if provided, otherwise use viewport
|
|
224
|
+
const boundary = boundaryRect ?? { left: 0, top: 0, right: viewport.width, bottom: viewport.height, width: viewport.width, height: viewport.height };
|
|
225
|
+
const candidates = placements.map((placement) => ({
|
|
226
|
+
placement,
|
|
227
|
+
coords: coordsForPlacement(anchorRect, overlaySize, placement, offset),
|
|
228
|
+
}));
|
|
229
|
+
if (allowFlip) {
|
|
230
|
+
for (const candidate of candidates) {
|
|
231
|
+
const fits = fitsInBoundary(candidate.coords, overlaySize, boundary);
|
|
232
|
+
if (fits) {
|
|
233
|
+
return allowShift
|
|
234
|
+
? { ...shiftIntoBoundary(candidate.coords, overlaySize, boundary), placement: candidate.placement }
|
|
235
|
+
: { ...candidate.coords, placement: candidate.placement };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Choose the candidate with the smallest total overflow.
|
|
240
|
+
let best = candidates[0];
|
|
241
|
+
let bestOverflow = Number.POSITIVE_INFINITY;
|
|
242
|
+
for (const candidate of candidates) {
|
|
243
|
+
const overflow = totalOverflowFromBoundary(candidate.coords, overlaySize, boundary);
|
|
244
|
+
if (overflow < bestOverflow) {
|
|
245
|
+
best = candidate;
|
|
246
|
+
bestOverflow = overflow;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const chosen = best?.coords ??
|
|
250
|
+
coordsForPlacement(anchorRect, overlaySize, placements[0] ?? 'bottom', offset);
|
|
251
|
+
const shifted = allowShift ? shiftIntoBoundary(chosen, overlaySize, boundary) : chosen;
|
|
252
|
+
return { ...shifted, placement: best?.placement ?? (placements[0] ?? 'bottom') };
|
|
253
|
+
}
|
|
254
|
+
function coordsForPlacement(anchorRect, overlaySize, placement, offset) {
|
|
255
|
+
const anchorCenterX = anchorRect.left + anchorRect.width / 2;
|
|
256
|
+
const anchorCenterY = anchorRect.top + anchorRect.height / 2;
|
|
257
|
+
switch (placement) {
|
|
258
|
+
case 'center':
|
|
259
|
+
return {
|
|
260
|
+
left: anchorCenterX - overlaySize.width / 2,
|
|
261
|
+
top: anchorCenterY - overlaySize.height / 2,
|
|
262
|
+
};
|
|
263
|
+
case 'top':
|
|
264
|
+
return {
|
|
265
|
+
left: anchorCenterX - overlaySize.width / 2,
|
|
266
|
+
top: anchorRect.top - overlaySize.height - offset,
|
|
267
|
+
};
|
|
268
|
+
case 'top-start':
|
|
269
|
+
return {
|
|
270
|
+
left: anchorRect.left,
|
|
271
|
+
top: anchorRect.top - overlaySize.height - offset,
|
|
272
|
+
};
|
|
273
|
+
case 'top-end':
|
|
274
|
+
return {
|
|
275
|
+
left: anchorRect.right - overlaySize.width,
|
|
276
|
+
top: anchorRect.top - overlaySize.height - offset,
|
|
277
|
+
};
|
|
278
|
+
case 'bottom':
|
|
279
|
+
return {
|
|
280
|
+
left: anchorCenterX - overlaySize.width / 2,
|
|
281
|
+
top: anchorRect.bottom + offset,
|
|
282
|
+
};
|
|
283
|
+
case 'bottom-start':
|
|
284
|
+
return {
|
|
285
|
+
left: anchorRect.left,
|
|
286
|
+
top: anchorRect.bottom + offset,
|
|
287
|
+
};
|
|
288
|
+
case 'bottom-end':
|
|
289
|
+
return {
|
|
290
|
+
left: anchorRect.right - overlaySize.width,
|
|
291
|
+
top: anchorRect.bottom + offset,
|
|
292
|
+
};
|
|
293
|
+
case 'left':
|
|
294
|
+
return {
|
|
295
|
+
left: anchorRect.left - overlaySize.width - offset,
|
|
296
|
+
top: anchorCenterY - overlaySize.height / 2,
|
|
297
|
+
};
|
|
298
|
+
case 'left-start':
|
|
299
|
+
return {
|
|
300
|
+
left: anchorRect.left - overlaySize.width - offset,
|
|
301
|
+
top: anchorRect.top,
|
|
302
|
+
};
|
|
303
|
+
case 'left-end':
|
|
304
|
+
return {
|
|
305
|
+
left: anchorRect.left - overlaySize.width - offset,
|
|
306
|
+
top: anchorRect.bottom - overlaySize.height,
|
|
307
|
+
};
|
|
308
|
+
case 'right':
|
|
309
|
+
return {
|
|
310
|
+
left: anchorRect.right + offset,
|
|
311
|
+
top: anchorCenterY - overlaySize.height / 2,
|
|
312
|
+
};
|
|
313
|
+
case 'right-start':
|
|
314
|
+
return {
|
|
315
|
+
left: anchorRect.right + offset,
|
|
316
|
+
top: anchorRect.top,
|
|
317
|
+
};
|
|
318
|
+
case 'right-end':
|
|
319
|
+
return {
|
|
320
|
+
left: anchorRect.right + offset,
|
|
321
|
+
top: anchorRect.bottom - overlaySize.height,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function fitsInBoundary(coords, overlaySize, boundary) {
|
|
326
|
+
return (coords.left >= boundary.left &&
|
|
327
|
+
coords.top >= boundary.top &&
|
|
328
|
+
coords.left + overlaySize.width <= boundary.right &&
|
|
329
|
+
coords.top + overlaySize.height <= boundary.bottom);
|
|
330
|
+
}
|
|
331
|
+
function shiftIntoBoundary(coords, overlaySize, boundary) {
|
|
332
|
+
const maxLeft = Math.max(boundary.left, boundary.right - overlaySize.width);
|
|
333
|
+
const maxTop = Math.max(boundary.top, boundary.bottom - overlaySize.height);
|
|
334
|
+
return {
|
|
335
|
+
left: clamp(coords.left, boundary.left, maxLeft),
|
|
336
|
+
top: clamp(coords.top, boundary.top, maxTop),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function totalOverflowFromBoundary(coords, overlaySize, boundary) {
|
|
340
|
+
const leftOverflow = Math.max(0, boundary.left - coords.left);
|
|
341
|
+
const topOverflow = Math.max(0, boundary.top - coords.top);
|
|
342
|
+
const rightOverflow = Math.max(0, coords.left + overlaySize.width - boundary.right);
|
|
343
|
+
const bottomOverflow = Math.max(0, coords.top + overlaySize.height - boundary.bottom);
|
|
344
|
+
return leftOverflow + topOverflow + rightOverflow + bottomOverflow;
|
|
345
|
+
}
|
|
346
|
+
function clamp(value, min, max) {
|
|
347
|
+
if (value < min)
|
|
348
|
+
return min;
|
|
349
|
+
if (value > max)
|
|
350
|
+
return max;
|
|
351
|
+
return value;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The OverlayRef for the overlay currently rendering this content.
|
|
356
|
+
*
|
|
357
|
+
* Provided automatically by CoarOverlayService for overlay content so components
|
|
358
|
+
* can open true child overlays without DOM lookups.
|
|
359
|
+
*/
|
|
360
|
+
const COAR_OVERLAY_REF = new InjectionToken('COAR_OVERLAY_REF');
|
|
361
|
+
/**
|
|
362
|
+
* Parent overlay reference for menu hierarchies.
|
|
363
|
+
* Each overlay provides this token pointing to itself, enabling child menu items
|
|
364
|
+
* to close siblings by calling parent.closeChildren().
|
|
365
|
+
*/
|
|
366
|
+
const COAR_MENU_PARENT = new InjectionToken('COAR_MENU_PARENT');
|
|
367
|
+
|
|
368
|
+
class CoarOverlayService {
|
|
369
|
+
appRef = inject(ApplicationRef);
|
|
370
|
+
environmentInjector = inject(EnvironmentInjector);
|
|
371
|
+
specResolvers = inject(COAR_OVERLAY_SPEC_RESOLVERS, { optional: true }) ??
|
|
372
|
+
[];
|
|
373
|
+
openOverlays = new Set();
|
|
374
|
+
globalListenersInstalled = false;
|
|
375
|
+
onDocumentPointerDown = (event) => {
|
|
376
|
+
const overlays = this.getOpenOverlaysInOrder();
|
|
377
|
+
if (overlays.length === 0)
|
|
378
|
+
return;
|
|
379
|
+
const target = event.target;
|
|
380
|
+
const topmostContaining = this.getTopmostOverlayContainingTarget(target);
|
|
381
|
+
if (topmostContaining) {
|
|
382
|
+
// When interacting with an overlay, close any child overlays (submenus) that may be open.
|
|
383
|
+
topmostContaining.closeChildren();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const topmost = this.getTopmostDismissableOverlay('outsideClick');
|
|
387
|
+
if (!topmost)
|
|
388
|
+
return;
|
|
389
|
+
// For overlay trees (e.g. menus + submenus), outside-click should close the entire tree.
|
|
390
|
+
const root = topmost.getRoot();
|
|
391
|
+
if (root !== topmost && root.isDismissable('outsideClick')) {
|
|
392
|
+
root.close();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
topmost.close();
|
|
396
|
+
};
|
|
397
|
+
onDocumentKeyDown = (event) => {
|
|
398
|
+
if (event.key === 'Escape') {
|
|
399
|
+
const topmost = this.getTopmostDismissableOverlay('escapeKey');
|
|
400
|
+
if (!topmost)
|
|
401
|
+
return;
|
|
402
|
+
event.preventDefault();
|
|
403
|
+
event.stopPropagation();
|
|
404
|
+
topmost.close();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (event.key === 'Tab') {
|
|
408
|
+
const topmost = this.getTopmostFocusTrappingOverlay();
|
|
409
|
+
if (!topmost)
|
|
410
|
+
return;
|
|
411
|
+
if (topmost.handleTabKey(event)) {
|
|
412
|
+
event.preventDefault();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
open(spec, inputs, options) {
|
|
417
|
+
const resolved = this.resolveSpec(spec);
|
|
418
|
+
return this.attach(resolved, inputs, options);
|
|
419
|
+
}
|
|
420
|
+
openChild(parent, spec, inputs, options) {
|
|
421
|
+
return this.open(spec, inputs, { parent, closeSiblings: options?.closeSiblings });
|
|
422
|
+
}
|
|
423
|
+
closeAll() {
|
|
424
|
+
for (const ref of Array.from(this.openOverlays)) {
|
|
425
|
+
ref.close();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
resolveSpec(spec) {
|
|
429
|
+
const resolvedSpec = this.applySpecResolvers(spec);
|
|
430
|
+
const content = resolvedSpec.content;
|
|
431
|
+
if (!content) {
|
|
432
|
+
throw new Error('OverlaySpec missing content');
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
content,
|
|
436
|
+
anchor: resolvedSpec.anchor ?? COAR_OVERLAY_DEFAULTS.anchor,
|
|
437
|
+
position: resolvedSpec.position ?? COAR_OVERLAY_DEFAULTS.position,
|
|
438
|
+
size: resolvedSpec.size ?? { mode: 'content' },
|
|
439
|
+
backdrop: resolvedSpec.backdrop ?? COAR_OVERLAY_DEFAULTS.backdrop,
|
|
440
|
+
scroll: resolvedSpec.scroll ?? COAR_OVERLAY_DEFAULTS.scroll,
|
|
441
|
+
dismiss: resolvedSpec.dismiss ?? COAR_OVERLAY_DEFAULTS.dismiss,
|
|
442
|
+
focus: resolvedSpec.focus ?? COAR_OVERLAY_DEFAULTS.focus,
|
|
443
|
+
a11y: resolvedSpec.a11y ?? COAR_OVERLAY_DEFAULTS.a11y,
|
|
444
|
+
attachment: resolvedSpec.attachment ?? COAR_OVERLAY_DEFAULTS.attachment,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
applySpecResolvers(spec) {
|
|
448
|
+
if (this.specResolvers.length === 0)
|
|
449
|
+
return spec;
|
|
450
|
+
let current = spec;
|
|
451
|
+
for (const resolver of this.specResolvers) {
|
|
452
|
+
const next = resolver(current);
|
|
453
|
+
current = (next ?? current);
|
|
454
|
+
}
|
|
455
|
+
return current;
|
|
456
|
+
}
|
|
457
|
+
attach(spec, inputs, options) {
|
|
458
|
+
const parent = this.getInternalRefOrNull(options?.parent);
|
|
459
|
+
// Close sibling overlays if requested (close all existing children of parent)
|
|
460
|
+
if (options?.closeSiblings && parent) {
|
|
461
|
+
parent.closeChildren();
|
|
462
|
+
}
|
|
463
|
+
const stackIndex = this.openOverlays.size;
|
|
464
|
+
const effectiveSpec = this.inheritDismissFromParent(spec, parent);
|
|
465
|
+
const ref = new CoarOverlayRef(this.appRef, this.environmentInjector, effectiveSpec, this.mergeInputs(spec.content, inputs), stackIndex, parent, () => {
|
|
466
|
+
this.openOverlays.delete(ref);
|
|
467
|
+
this.uninstallGlobalListenersIfIdle();
|
|
468
|
+
});
|
|
469
|
+
this.openOverlays.add(ref);
|
|
470
|
+
this.installGlobalListenersIfNeeded();
|
|
471
|
+
ref.open();
|
|
472
|
+
return ref;
|
|
473
|
+
}
|
|
474
|
+
inheritDismissFromParent(spec, parent) {
|
|
475
|
+
if (!parent)
|
|
476
|
+
return spec;
|
|
477
|
+
const parentHoverTree = parent.getHoverTreeDismissConfig();
|
|
478
|
+
if (!parentHoverTree?.enabled)
|
|
479
|
+
return spec;
|
|
480
|
+
const childHoverTree = spec.dismiss.hoverTree;
|
|
481
|
+
// Explicit disable in child wins.
|
|
482
|
+
if (childHoverTree?.enabled === false)
|
|
483
|
+
return spec;
|
|
484
|
+
let mergedHoverTree;
|
|
485
|
+
if (!childHoverTree) {
|
|
486
|
+
mergedHoverTree = { ...parentHoverTree };
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
mergedHoverTree = {
|
|
490
|
+
enabled: true,
|
|
491
|
+
delayMs: childHoverTree.delayMs ?? parentHoverTree.delayMs,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
// No change.
|
|
495
|
+
if (mergedHoverTree === childHoverTree)
|
|
496
|
+
return spec;
|
|
497
|
+
return {
|
|
498
|
+
...spec,
|
|
499
|
+
dismiss: {
|
|
500
|
+
...spec.dismiss,
|
|
501
|
+
hoverTree: mergedHoverTree,
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
getInternalRefOrNull(ref) {
|
|
506
|
+
if (!ref)
|
|
507
|
+
return null;
|
|
508
|
+
if (ref instanceof CoarOverlayRef)
|
|
509
|
+
return ref;
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
installGlobalListenersIfNeeded() {
|
|
513
|
+
if (this.globalListenersInstalled)
|
|
514
|
+
return;
|
|
515
|
+
if (typeof document === 'undefined')
|
|
516
|
+
return;
|
|
517
|
+
document.addEventListener('pointerdown', this.onDocumentPointerDown, { capture: true });
|
|
518
|
+
document.addEventListener('keydown', this.onDocumentKeyDown, { capture: true });
|
|
519
|
+
this.globalListenersInstalled = true;
|
|
520
|
+
}
|
|
521
|
+
uninstallGlobalListenersIfIdle() {
|
|
522
|
+
if (!this.globalListenersInstalled)
|
|
523
|
+
return;
|
|
524
|
+
if (this.openOverlays.size > 0)
|
|
525
|
+
return;
|
|
526
|
+
if (typeof document === 'undefined')
|
|
527
|
+
return;
|
|
528
|
+
document.removeEventListener('pointerdown', this.onDocumentPointerDown, { capture: true });
|
|
529
|
+
document.removeEventListener('keydown', this.onDocumentKeyDown, { capture: true });
|
|
530
|
+
this.globalListenersInstalled = false;
|
|
531
|
+
}
|
|
532
|
+
getOpenOverlaysInOrder() {
|
|
533
|
+
return Array.from(this.openOverlays);
|
|
534
|
+
}
|
|
535
|
+
getTopmostOverlayContainingTarget(target) {
|
|
536
|
+
const overlays = this.getOpenOverlaysInOrder();
|
|
537
|
+
for (let i = overlays.length - 1; i >= 0; i -= 1) {
|
|
538
|
+
const overlay = overlays[i];
|
|
539
|
+
if (overlay.containsEventTarget(target))
|
|
540
|
+
return overlay;
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
getTopmostDismissableOverlay(kind) {
|
|
545
|
+
const overlays = this.getOpenOverlaysInOrder();
|
|
546
|
+
for (let i = overlays.length - 1; i >= 0; i -= 1) {
|
|
547
|
+
const overlay = overlays[i];
|
|
548
|
+
if (overlay.isDismissable(kind)) {
|
|
549
|
+
return overlay;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
getTopmostFocusTrappingOverlay() {
|
|
555
|
+
const overlays = this.getOpenOverlaysInOrder();
|
|
556
|
+
for (let i = overlays.length - 1; i >= 0; i -= 1) {
|
|
557
|
+
const overlay = overlays[i];
|
|
558
|
+
if (overlay.hasFocusTrap()) {
|
|
559
|
+
return overlay;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
mergeInputs(content, inputs) {
|
|
565
|
+
if (!content.defaults)
|
|
566
|
+
return inputs;
|
|
567
|
+
if (inputs == null || typeof inputs !== 'object') {
|
|
568
|
+
return { ...content.defaults };
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
...content.defaults,
|
|
572
|
+
...inputs,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
renderContent(content, host, inputs) {
|
|
576
|
+
switch (content.kind) {
|
|
577
|
+
case 'text': {
|
|
578
|
+
const text = inputs?.text;
|
|
579
|
+
if (typeof text !== 'string') {
|
|
580
|
+
throw new Error('Text overlay requires inputs: { text: string }');
|
|
581
|
+
}
|
|
582
|
+
host.textContent = text;
|
|
583
|
+
return () => {
|
|
584
|
+
host.textContent = '';
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
case 'template': {
|
|
588
|
+
const template = content.template;
|
|
589
|
+
if (!template)
|
|
590
|
+
throw new Error('Template overlay requires a template');
|
|
591
|
+
const viewRef = template.createEmbeddedView(inputs);
|
|
592
|
+
this.appRef.attachView(viewRef);
|
|
593
|
+
viewRef.detectChanges();
|
|
594
|
+
for (const node of viewRef.rootNodes) {
|
|
595
|
+
host.appendChild(node);
|
|
596
|
+
}
|
|
597
|
+
return () => {
|
|
598
|
+
this.appRef.detachView(viewRef);
|
|
599
|
+
viewRef.destroy();
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
case 'component': {
|
|
603
|
+
const component = content.component;
|
|
604
|
+
if (!component)
|
|
605
|
+
throw new Error('Component overlay requires a component');
|
|
606
|
+
const componentRef = createComponent(component, {
|
|
607
|
+
environmentInjector: this.environmentInjector,
|
|
608
|
+
hostElement: host,
|
|
609
|
+
});
|
|
610
|
+
if (inputs && typeof inputs === 'object') {
|
|
611
|
+
for (const [key, value] of Object.entries(inputs)) {
|
|
612
|
+
componentRef.setInput(key, value);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
this.appRef.attachView(componentRef.hostView);
|
|
616
|
+
componentRef.changeDetectorRef.detectChanges();
|
|
617
|
+
return () => {
|
|
618
|
+
this.appRef.detachView(componentRef.hostView);
|
|
619
|
+
componentRef.destroy();
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: CoarOverlayService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
625
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: CoarOverlayService, providedIn: 'root' });
|
|
626
|
+
}
|
|
627
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: CoarOverlayService, decorators: [{
|
|
628
|
+
type: Injectable,
|
|
629
|
+
args: [{ providedIn: 'root' }]
|
|
630
|
+
}] });
|
|
631
|
+
class CoarOverlayRef {
|
|
632
|
+
appRef;
|
|
633
|
+
environmentInjector;
|
|
634
|
+
spec;
|
|
635
|
+
inputs;
|
|
636
|
+
stackIndex;
|
|
637
|
+
parent;
|
|
638
|
+
onClosed;
|
|
639
|
+
afterClosedSubject = new Subject();
|
|
640
|
+
afterClosed$ = this.afterClosedSubject.asObservable();
|
|
641
|
+
get isClosed() {
|
|
642
|
+
return this.closed;
|
|
643
|
+
}
|
|
644
|
+
host;
|
|
645
|
+
panel;
|
|
646
|
+
backdropElement = null;
|
|
647
|
+
destroyContent = null;
|
|
648
|
+
resizeObserver = null;
|
|
649
|
+
cleanupFns = [];
|
|
650
|
+
closed = false;
|
|
651
|
+
lastResult;
|
|
652
|
+
rafPending = false;
|
|
653
|
+
presented = false;
|
|
654
|
+
closeFinalized = false;
|
|
655
|
+
restoreFocusTarget;
|
|
656
|
+
children = new Set();
|
|
657
|
+
hoverCloseTimer = null;
|
|
658
|
+
contentInjector;
|
|
659
|
+
contentEnvironmentInjector;
|
|
660
|
+
shouldAnimateMenu;
|
|
661
|
+
constructor(appRef, environmentInjector, spec, inputs, stackIndex, parent, onClosed) {
|
|
662
|
+
this.appRef = appRef;
|
|
663
|
+
this.environmentInjector = environmentInjector;
|
|
664
|
+
this.spec = spec;
|
|
665
|
+
this.inputs = inputs;
|
|
666
|
+
this.stackIndex = stackIndex;
|
|
667
|
+
this.parent = parent;
|
|
668
|
+
this.onClosed = onClosed;
|
|
669
|
+
this.host = document.createElement('div');
|
|
670
|
+
this.host.className = 'coar-overlay-host';
|
|
671
|
+
this.panel = document.createElement('div');
|
|
672
|
+
this.panel.className = 'coar-overlay-panel';
|
|
673
|
+
this.host.appendChild(this.panel);
|
|
674
|
+
this.shouldAnimateMenu = this.spec.a11y.role === 'menu';
|
|
675
|
+
this.contentInjector = Injector.create({
|
|
676
|
+
providers: [
|
|
677
|
+
{ provide: COAR_OVERLAY_REF, useValue: this },
|
|
678
|
+
{ provide: COAR_MENU_PARENT, useValue: this },
|
|
679
|
+
],
|
|
680
|
+
parent: this.environmentInjector,
|
|
681
|
+
});
|
|
682
|
+
this.contentEnvironmentInjector = createEnvironmentInjector([
|
|
683
|
+
{ provide: COAR_OVERLAY_REF, useValue: this },
|
|
684
|
+
{ provide: COAR_MENU_PARENT, useValue: this },
|
|
685
|
+
], this.environmentInjector);
|
|
686
|
+
Object.assign(this.host.style, {
|
|
687
|
+
position: 'fixed',
|
|
688
|
+
top: '0px',
|
|
689
|
+
left: '0px',
|
|
690
|
+
transform: 'translate3d(0px, 0px, 0px)',
|
|
691
|
+
zIndex: `calc(var(--coar-z-overlay, 1000) + ${this.stackIndex * 2})`,
|
|
692
|
+
opacity: '1',
|
|
693
|
+
pointerEvents: 'none',
|
|
694
|
+
});
|
|
695
|
+
if (this.shouldAnimateMenu) {
|
|
696
|
+
Object.assign(this.panel.style, {
|
|
697
|
+
opacity: '0',
|
|
698
|
+
transition: 'opacity var(--coar-duration-slower) var(--coar-ease-out)',
|
|
699
|
+
willChange: 'opacity',
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
this.restoreFocusTarget =
|
|
703
|
+
(typeof document !== 'undefined' ? document.activeElement : null) ?? null;
|
|
704
|
+
if (this.parent) {
|
|
705
|
+
this.parent.children.add(this);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
getHoverTreeDismissConfig() {
|
|
709
|
+
return this.spec.dismiss.hoverTree;
|
|
710
|
+
}
|
|
711
|
+
getPanelElement() {
|
|
712
|
+
return this.panel;
|
|
713
|
+
}
|
|
714
|
+
getRoot() {
|
|
715
|
+
return this.parent?.getRoot() ?? this;
|
|
716
|
+
}
|
|
717
|
+
closeChildren(exclude) {
|
|
718
|
+
for (const child of Array.from(this.children)) {
|
|
719
|
+
if (child !== exclude) {
|
|
720
|
+
child.close();
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Close all sibling overlays (other children of the same parent).
|
|
726
|
+
* Used when opening a new child overlay with closeSiblings option.
|
|
727
|
+
*/
|
|
728
|
+
closeSiblings() {
|
|
729
|
+
if (this.parent) {
|
|
730
|
+
for (const sibling of Array.from(this.parent.children)) {
|
|
731
|
+
if (sibling !== this) {
|
|
732
|
+
sibling.close();
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
open() {
|
|
738
|
+
const backdrop = this.spec.backdrop;
|
|
739
|
+
if (backdrop.kind === 'modal') {
|
|
740
|
+
const backdropEl = document.createElement('div');
|
|
741
|
+
backdropEl.className = 'coar-overlay-backdrop';
|
|
742
|
+
Object.assign(backdropEl.style, {
|
|
743
|
+
position: 'fixed',
|
|
744
|
+
top: '0px',
|
|
745
|
+
left: '0px',
|
|
746
|
+
right: '0px',
|
|
747
|
+
bottom: '0px',
|
|
748
|
+
background: 'color-mix(in srgb, var(--coar-color-black) 40%, transparent)',
|
|
749
|
+
zIndex: `calc(var(--coar-z-overlay-backdrop, 999) + ${this.stackIndex * 2})`,
|
|
750
|
+
});
|
|
751
|
+
document.body.appendChild(backdropEl);
|
|
752
|
+
this.backdropElement = backdropEl;
|
|
753
|
+
if (backdrop.closeOnBackdropClick !== false) {
|
|
754
|
+
const onClick = (e) => {
|
|
755
|
+
if (e.target === backdropEl) {
|
|
756
|
+
this.close();
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
backdropEl.addEventListener('click', onClick);
|
|
760
|
+
this.cleanupFns.push(() => backdropEl.removeEventListener('click', onClick));
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// Attach overlay to the appropriate parent based on attachment strategy
|
|
764
|
+
const attachment = this.spec.attachment;
|
|
765
|
+
const attachmentParent = attachment.strategy === 'parent' ? attachment.container : document.body;
|
|
766
|
+
attachmentParent.appendChild(this.host);
|
|
767
|
+
this.installHoverTreeDismissIfEnabled();
|
|
768
|
+
this.applyA11y();
|
|
769
|
+
this.destroyContent = this.renderContent(this.spec.content, this.panel, this.inputs);
|
|
770
|
+
this.applySize();
|
|
771
|
+
if (this.spec.focus.trap) {
|
|
772
|
+
this.installFocusTrap();
|
|
773
|
+
}
|
|
774
|
+
this.installRepositionTriggers();
|
|
775
|
+
this.updatePosition();
|
|
776
|
+
}
|
|
777
|
+
installHoverTreeDismissIfEnabled() {
|
|
778
|
+
const hoverTree = this.spec.dismiss.hoverTree;
|
|
779
|
+
if (!hoverTree?.enabled)
|
|
780
|
+
return;
|
|
781
|
+
const delayMs = typeof hoverTree.delayMs === 'number' ? hoverTree.delayMs : 300;
|
|
782
|
+
const onEnter = () => {
|
|
783
|
+
this.cancelHoverCloseUpTree();
|
|
784
|
+
};
|
|
785
|
+
const onHostLeave = () => {
|
|
786
|
+
this.scheduleHoverCloseUpTree(delayMs);
|
|
787
|
+
};
|
|
788
|
+
// Important: an overlay's anchor element can be *inside* its parent overlay (submenu items).
|
|
789
|
+
// Leaving that anchor while still hovering the parent overlay should not schedule closing
|
|
790
|
+
// the parent/grandparent overlays; only this (leaf) overlay should be eligible to close.
|
|
791
|
+
const onAnchorLeave = () => {
|
|
792
|
+
this.scheduleHoverClose(delayMs);
|
|
793
|
+
};
|
|
794
|
+
this.host.addEventListener('pointerenter', onEnter);
|
|
795
|
+
this.host.addEventListener('pointerleave', onHostLeave);
|
|
796
|
+
this.cleanupFns.push(() => {
|
|
797
|
+
this.host.removeEventListener('pointerenter', onEnter);
|
|
798
|
+
this.host.removeEventListener('pointerleave', onHostLeave);
|
|
799
|
+
this.cancelHoverClose();
|
|
800
|
+
});
|
|
801
|
+
if (this.spec.anchor.kind === 'element') {
|
|
802
|
+
const el = this.spec.anchor.element;
|
|
803
|
+
el.addEventListener('pointerenter', onEnter);
|
|
804
|
+
el.addEventListener('pointerleave', onAnchorLeave);
|
|
805
|
+
this.cleanupFns.push(() => {
|
|
806
|
+
el.removeEventListener('pointerenter', onEnter);
|
|
807
|
+
el.removeEventListener('pointerleave', onAnchorLeave);
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
cancelHoverClose() {
|
|
812
|
+
if (!this.hoverCloseTimer)
|
|
813
|
+
return;
|
|
814
|
+
clearTimeout(this.hoverCloseTimer);
|
|
815
|
+
this.hoverCloseTimer = null;
|
|
816
|
+
}
|
|
817
|
+
cancelHoverCloseUpTree() {
|
|
818
|
+
this.cancelHoverClose();
|
|
819
|
+
this.parent?.cancelHoverCloseUpTree();
|
|
820
|
+
}
|
|
821
|
+
scheduleHoverClose(delayMs) {
|
|
822
|
+
const hoverTree = this.spec.dismiss.hoverTree;
|
|
823
|
+
if (!hoverTree?.enabled)
|
|
824
|
+
return;
|
|
825
|
+
this.cancelHoverClose();
|
|
826
|
+
this.hoverCloseTimer = setTimeout(() => {
|
|
827
|
+
this.hoverCloseTimer = null;
|
|
828
|
+
this.close();
|
|
829
|
+
}, delayMs);
|
|
830
|
+
}
|
|
831
|
+
scheduleHoverCloseUpTree(delayMs) {
|
|
832
|
+
this.scheduleHoverClose(delayMs);
|
|
833
|
+
this.parent?.scheduleHoverCloseUpTree(delayMs);
|
|
834
|
+
}
|
|
835
|
+
hasFocusTrap() {
|
|
836
|
+
return this.spec.focus.trap === true;
|
|
837
|
+
}
|
|
838
|
+
isDismissable(kind) {
|
|
839
|
+
if (kind === 'outsideClick') {
|
|
840
|
+
return this.spec.dismiss.outsideClick !== false;
|
|
841
|
+
}
|
|
842
|
+
return this.spec.dismiss.escapeKey !== false;
|
|
843
|
+
}
|
|
844
|
+
containsEventTarget(target) {
|
|
845
|
+
if (!(target instanceof Node))
|
|
846
|
+
return false;
|
|
847
|
+
if (this.host.contains(target))
|
|
848
|
+
return true;
|
|
849
|
+
if (this.backdropElement?.contains(target))
|
|
850
|
+
return true;
|
|
851
|
+
// Also check if click is on the anchor element (e.g., select trigger)
|
|
852
|
+
// to prevent closing when clicking the trigger that opened the overlay
|
|
853
|
+
if (this.spec.anchor.kind === 'element') {
|
|
854
|
+
if (this.spec.anchor.element.contains(target))
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
handleTabKey(event) {
|
|
860
|
+
if (!this.spec.focus.trap)
|
|
861
|
+
return false;
|
|
862
|
+
const focusables = this.getFocusableElements();
|
|
863
|
+
if (focusables.length === 0) {
|
|
864
|
+
this.focusElement(this.host);
|
|
865
|
+
return true;
|
|
866
|
+
}
|
|
867
|
+
const first = focusables[0];
|
|
868
|
+
const last = focusables[focusables.length - 1];
|
|
869
|
+
const active = document.activeElement;
|
|
870
|
+
const isActiveInside = active instanceof Node && (this.host.contains(active) || active === this.host);
|
|
871
|
+
// If focus is outside, bring it inside.
|
|
872
|
+
if (!isActiveInside) {
|
|
873
|
+
this.focusElement(event.shiftKey ? last : first);
|
|
874
|
+
return true;
|
|
875
|
+
}
|
|
876
|
+
if (event.shiftKey) {
|
|
877
|
+
if (active === first || active === this.host) {
|
|
878
|
+
this.focusElement(last);
|
|
879
|
+
return true;
|
|
880
|
+
}
|
|
881
|
+
return false;
|
|
882
|
+
}
|
|
883
|
+
if (active === last) {
|
|
884
|
+
this.focusElement(first);
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
889
|
+
updatePosition() {
|
|
890
|
+
if (this.closed)
|
|
891
|
+
return;
|
|
892
|
+
if (this.rafPending)
|
|
893
|
+
return;
|
|
894
|
+
this.rafPending = true;
|
|
895
|
+
this.schedule(() => {
|
|
896
|
+
this.rafPending = false;
|
|
897
|
+
if (this.closed)
|
|
898
|
+
return;
|
|
899
|
+
const viewport = getViewportRect();
|
|
900
|
+
const anchorRect = getAnchorRect(this.spec.anchor, viewport);
|
|
901
|
+
const rect = this.host.getBoundingClientRect();
|
|
902
|
+
const overlaySize = {
|
|
903
|
+
width: rect.width,
|
|
904
|
+
height: rect.height,
|
|
905
|
+
};
|
|
906
|
+
// For parent-attached overlays, use container boundaries instead of viewport
|
|
907
|
+
const attachment = this.spec.attachment;
|
|
908
|
+
const boundaryRect = attachment.strategy === 'parent' ? getContainerRect(attachment.container) : undefined;
|
|
909
|
+
const coords = computeOverlayCoordinates(anchorRect, overlaySize, this.spec.position, viewport, boundaryRect);
|
|
910
|
+
this.host.style.transform = `translate3d(${Math.round(coords.left)}px, ${Math.round(coords.top)}px, 0px)`;
|
|
911
|
+
this.present();
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
present() {
|
|
915
|
+
if (this.presented)
|
|
916
|
+
return;
|
|
917
|
+
this.presented = true;
|
|
918
|
+
if (this.shouldAnimateMenu) {
|
|
919
|
+
// Ensure the initial opacity=0 is committed before we flip to 1,
|
|
920
|
+
// otherwise some browsers skip the transition on first paint.
|
|
921
|
+
void this.panel.getBoundingClientRect();
|
|
922
|
+
}
|
|
923
|
+
this.host.style.pointerEvents = 'auto';
|
|
924
|
+
if (this.shouldAnimateMenu) {
|
|
925
|
+
this.panel.style.opacity = '1';
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
close(result) {
|
|
929
|
+
console.log('[OverlayRef] close() called', {
|
|
930
|
+
closed: this.closed,
|
|
931
|
+
result,
|
|
932
|
+
});
|
|
933
|
+
if (this.closed)
|
|
934
|
+
return;
|
|
935
|
+
this.closed = true;
|
|
936
|
+
this.lastResult = result;
|
|
937
|
+
this.cancelHoverClose();
|
|
938
|
+
// Ensure overlay trees (menus/submenus) close consistently.
|
|
939
|
+
this.closeChildren();
|
|
940
|
+
for (const cleanup of this.cleanupFns) {
|
|
941
|
+
cleanup();
|
|
942
|
+
}
|
|
943
|
+
this.cleanupFns = [];
|
|
944
|
+
this.resizeObserver?.disconnect();
|
|
945
|
+
this.resizeObserver = null;
|
|
946
|
+
// Begin visual close (menu overlays only). We keep the host in the DOM
|
|
947
|
+
// briefly so opacity can transition to 0, then tear down.
|
|
948
|
+
if (this.shouldAnimateMenu && this.presented) {
|
|
949
|
+
this.host.style.pointerEvents = 'none';
|
|
950
|
+
this.panel.style.opacity = '0';
|
|
951
|
+
const finalizeOnce = () => {
|
|
952
|
+
this.finalizeClose();
|
|
953
|
+
};
|
|
954
|
+
let fallbackTimer = null;
|
|
955
|
+
const onEnd = (e) => {
|
|
956
|
+
if (e.target === this.panel && e.propertyName === 'opacity') {
|
|
957
|
+
this.host.removeEventListener('transitionend', onEnd);
|
|
958
|
+
if (fallbackTimer) {
|
|
959
|
+
clearTimeout(fallbackTimer);
|
|
960
|
+
fallbackTimer = null;
|
|
961
|
+
}
|
|
962
|
+
finalizeOnce();
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
this.host.addEventListener('transitionend', onEnd);
|
|
966
|
+
const fallbackMs = this.getMaxTransitionTimeMs();
|
|
967
|
+
if (fallbackMs === 0) {
|
|
968
|
+
this.host.removeEventListener('transitionend', onEnd);
|
|
969
|
+
finalizeOnce();
|
|
970
|
+
}
|
|
971
|
+
else {
|
|
972
|
+
fallbackTimer = setTimeout(() => {
|
|
973
|
+
this.host.removeEventListener('transitionend', onEnd);
|
|
974
|
+
finalizeOnce();
|
|
975
|
+
}, fallbackMs);
|
|
976
|
+
}
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
this.finalizeClose();
|
|
980
|
+
}
|
|
981
|
+
finalizeClose() {
|
|
982
|
+
if (this.closeFinalized)
|
|
983
|
+
return;
|
|
984
|
+
this.closeFinalized = true;
|
|
985
|
+
this.destroyContent?.();
|
|
986
|
+
this.destroyContent = null;
|
|
987
|
+
this.host.remove();
|
|
988
|
+
this.backdropElement?.remove();
|
|
989
|
+
this.backdropElement = null;
|
|
990
|
+
if (this.spec.focus.restore !== false) {
|
|
991
|
+
const el = this.restoreFocusTarget;
|
|
992
|
+
if (el && typeof el.focus === 'function') {
|
|
993
|
+
try {
|
|
994
|
+
el.focus({ preventScroll: true });
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
try {
|
|
998
|
+
el.focus();
|
|
999
|
+
}
|
|
1000
|
+
catch {
|
|
1001
|
+
// noop
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
this.afterClosedSubject.next(this.lastResult);
|
|
1007
|
+
this.afterClosedSubject.complete();
|
|
1008
|
+
this.parent?.children.delete(this);
|
|
1009
|
+
this.onClosed();
|
|
1010
|
+
}
|
|
1011
|
+
getMaxTransitionTimeMs() {
|
|
1012
|
+
if (typeof getComputedStyle === 'undefined')
|
|
1013
|
+
return 0;
|
|
1014
|
+
const style = getComputedStyle(this.panel);
|
|
1015
|
+
const durations = style.transitionDuration.split(',').map((v) => v.trim());
|
|
1016
|
+
const delays = style.transitionDelay.split(',').map((v) => v.trim());
|
|
1017
|
+
const entries = Math.max(durations.length, delays.length);
|
|
1018
|
+
let maxMs = 0;
|
|
1019
|
+
for (let i = 0; i < entries; i++) {
|
|
1020
|
+
const duration = durations[i] ?? durations[durations.length - 1] ?? '0ms';
|
|
1021
|
+
const delay = delays[i] ?? delays[delays.length - 1] ?? '0ms';
|
|
1022
|
+
const ms = this.parseCssTimeToMs(duration) + this.parseCssTimeToMs(delay);
|
|
1023
|
+
maxMs = Math.max(maxMs, ms);
|
|
1024
|
+
}
|
|
1025
|
+
return maxMs;
|
|
1026
|
+
}
|
|
1027
|
+
parseCssTimeToMs(value) {
|
|
1028
|
+
const v = value.trim();
|
|
1029
|
+
if (!v)
|
|
1030
|
+
return 0;
|
|
1031
|
+
if (v.endsWith('ms')) {
|
|
1032
|
+
const n = Number(v.slice(0, -2));
|
|
1033
|
+
return Number.isFinite(n) ? n : 0;
|
|
1034
|
+
}
|
|
1035
|
+
if (v.endsWith('s')) {
|
|
1036
|
+
const n = Number(v.slice(0, -1));
|
|
1037
|
+
return Number.isFinite(n) ? n * 1000 : 0;
|
|
1038
|
+
}
|
|
1039
|
+
const n = Number(v);
|
|
1040
|
+
return Number.isFinite(n) ? n : 0;
|
|
1041
|
+
}
|
|
1042
|
+
installFocusTrap() {
|
|
1043
|
+
// Ensure the host can receive programmatic focus as a fallback.
|
|
1044
|
+
this.host.tabIndex = -1;
|
|
1045
|
+
// Move focus into the overlay on open (best-effort).
|
|
1046
|
+
this.schedule(() => {
|
|
1047
|
+
if (this.closed)
|
|
1048
|
+
return;
|
|
1049
|
+
const focusables = this.getFocusableElements();
|
|
1050
|
+
this.focusElement(focusables[0] ?? this.host);
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
applySize() {
|
|
1054
|
+
const size = this.spec.size;
|
|
1055
|
+
if (!size)
|
|
1056
|
+
return;
|
|
1057
|
+
const viewport = getViewportRect();
|
|
1058
|
+
const resolveMin = (value, anchorSize) => {
|
|
1059
|
+
if (value === 'anchor')
|
|
1060
|
+
return anchorSize;
|
|
1061
|
+
if (typeof value === 'number')
|
|
1062
|
+
return value;
|
|
1063
|
+
return null;
|
|
1064
|
+
};
|
|
1065
|
+
const resolveMax = (value, viewportSize) => {
|
|
1066
|
+
if (value === 'viewport')
|
|
1067
|
+
return viewportSize;
|
|
1068
|
+
if (typeof value === 'number')
|
|
1069
|
+
return value;
|
|
1070
|
+
return null;
|
|
1071
|
+
};
|
|
1072
|
+
const anchorRect = getAnchorRect(this.spec.anchor, viewport);
|
|
1073
|
+
const minWidthPx = resolveMin(size.minWidth, anchorRect.width);
|
|
1074
|
+
const minHeightPx = resolveMin(size.minHeight, anchorRect.height);
|
|
1075
|
+
const maxWidthPx = resolveMax(size.maxWidth, viewport.width);
|
|
1076
|
+
const maxHeightPx = resolveMax(size.maxHeight, viewport.height);
|
|
1077
|
+
// Reset in case an overlay host is re-used in the future.
|
|
1078
|
+
this.host.style.width = '';
|
|
1079
|
+
this.host.style.height = '';
|
|
1080
|
+
this.host.style.minWidth = '';
|
|
1081
|
+
this.host.style.minHeight = '';
|
|
1082
|
+
this.host.style.maxWidth = '';
|
|
1083
|
+
this.host.style.maxHeight = '';
|
|
1084
|
+
this.host.style.overflow = '';
|
|
1085
|
+
if (minWidthPx != null && minWidthPx > 0)
|
|
1086
|
+
this.host.style.minWidth = `${minWidthPx}px`;
|
|
1087
|
+
if (minHeightPx != null && minHeightPx > 0)
|
|
1088
|
+
this.host.style.minHeight = `${minHeightPx}px`;
|
|
1089
|
+
if (size.mode === 'content') {
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
if (size.mode === 'content-clamped') {
|
|
1093
|
+
if (maxWidthPx != null)
|
|
1094
|
+
this.host.style.maxWidth = `${maxWidthPx}px`;
|
|
1095
|
+
if (maxHeightPx != null)
|
|
1096
|
+
this.host.style.maxHeight = `${maxHeightPx}px`;
|
|
1097
|
+
this.host.style.overflow = 'auto';
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
// mode: 'fixed'
|
|
1101
|
+
if (maxWidthPx != null)
|
|
1102
|
+
this.host.style.width = `${maxWidthPx}px`;
|
|
1103
|
+
if (maxHeightPx != null)
|
|
1104
|
+
this.host.style.height = `${maxHeightPx}px`;
|
|
1105
|
+
this.host.style.overflow = 'auto';
|
|
1106
|
+
}
|
|
1107
|
+
applyA11y() {
|
|
1108
|
+
const a11y = this.spec.a11y;
|
|
1109
|
+
if (!a11y)
|
|
1110
|
+
return;
|
|
1111
|
+
if (a11y.role) {
|
|
1112
|
+
this.host.setAttribute('role', a11y.role);
|
|
1113
|
+
}
|
|
1114
|
+
this.setAttrIfDefined('aria-label', a11y.label);
|
|
1115
|
+
this.setAttrIfDefined('aria-labelledby', a11y.labelledBy);
|
|
1116
|
+
this.setAttrIfDefined('aria-describedby', a11y.describedBy);
|
|
1117
|
+
if (a11y.role === 'dialog' && this.spec.backdrop.kind === 'modal') {
|
|
1118
|
+
this.host.setAttribute('aria-modal', 'true');
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
setAttrIfDefined(name, value) {
|
|
1122
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
1123
|
+
this.host.setAttribute(name, value);
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
this.host.removeAttribute(name);
|
|
1127
|
+
}
|
|
1128
|
+
focusElement(el) {
|
|
1129
|
+
try {
|
|
1130
|
+
el.focus({ preventScroll: true });
|
|
1131
|
+
}
|
|
1132
|
+
catch {
|
|
1133
|
+
try {
|
|
1134
|
+
el.focus();
|
|
1135
|
+
}
|
|
1136
|
+
catch {
|
|
1137
|
+
// noop
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
getFocusableElements() {
|
|
1142
|
+
const candidates = Array.from(this.host.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"]),[contenteditable="true"]'));
|
|
1143
|
+
return candidates.filter((el) => !this.isHidden(el));
|
|
1144
|
+
}
|
|
1145
|
+
isHidden(el) {
|
|
1146
|
+
// Avoid using offsetParent as it behaves poorly in some environments (e.g. JSDOM).
|
|
1147
|
+
if (el.hasAttribute('hidden'))
|
|
1148
|
+
return true;
|
|
1149
|
+
if (el.getAttribute('aria-hidden') === 'true')
|
|
1150
|
+
return true;
|
|
1151
|
+
const style = el.style;
|
|
1152
|
+
if (style.display === 'none' || style.visibility === 'hidden')
|
|
1153
|
+
return true;
|
|
1154
|
+
return false;
|
|
1155
|
+
}
|
|
1156
|
+
installRepositionTriggers() {
|
|
1157
|
+
const strategy = this.spec.scroll.strategy;
|
|
1158
|
+
if (strategy === 'noop')
|
|
1159
|
+
return;
|
|
1160
|
+
const scrollParents = this.spec.anchor.kind === 'element' ? getScrollParents(this.spec.anchor.element) : [window];
|
|
1161
|
+
if (strategy === 'reposition') {
|
|
1162
|
+
const onScroll = () => this.updatePosition();
|
|
1163
|
+
const onResize = () => this.updatePosition();
|
|
1164
|
+
for (const parent of scrollParents) {
|
|
1165
|
+
if (parent === window) {
|
|
1166
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
1167
|
+
this.cleanupFns.push(() => window.removeEventListener('scroll', onScroll));
|
|
1168
|
+
continue;
|
|
1169
|
+
}
|
|
1170
|
+
parent.addEventListener('scroll', onScroll, { passive: true });
|
|
1171
|
+
this.cleanupFns.push(() => parent.removeEventListener('scroll', onScroll));
|
|
1172
|
+
}
|
|
1173
|
+
window.addEventListener('resize', onResize, { passive: true });
|
|
1174
|
+
this.cleanupFns.push(() => window.removeEventListener('resize', onResize));
|
|
1175
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
1176
|
+
const ro = new ResizeObserver(() => this.updatePosition());
|
|
1177
|
+
ro.observe(this.host);
|
|
1178
|
+
this.resizeObserver = ro;
|
|
1179
|
+
}
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
if (strategy === 'close') {
|
|
1183
|
+
const onScroll = (e) => {
|
|
1184
|
+
const target = e.target;
|
|
1185
|
+
if (target instanceof Node && this.host.contains(target))
|
|
1186
|
+
return;
|
|
1187
|
+
this.close();
|
|
1188
|
+
};
|
|
1189
|
+
if (typeof document !== 'undefined') {
|
|
1190
|
+
document.addEventListener('scroll', onScroll, { passive: true, capture: true });
|
|
1191
|
+
this.cleanupFns.push(() => document.removeEventListener('scroll', onScroll, { capture: true }));
|
|
1192
|
+
}
|
|
1193
|
+
// Fallback for environments where scroll is only observed on window.
|
|
1194
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
1195
|
+
this.cleanupFns.push(() => window.removeEventListener('scroll', onScroll));
|
|
1196
|
+
// Also attach to explicit scroll parents for element anchors (helps non-standard event targets).
|
|
1197
|
+
for (const parent of scrollParents) {
|
|
1198
|
+
if (parent === window)
|
|
1199
|
+
continue;
|
|
1200
|
+
parent.addEventListener('scroll', onScroll, { passive: true });
|
|
1201
|
+
this.cleanupFns.push(() => parent.removeEventListener('scroll', onScroll));
|
|
1202
|
+
}
|
|
1203
|
+
// Still reposition on size changes while open.
|
|
1204
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
1205
|
+
const ro = new ResizeObserver(() => this.updatePosition());
|
|
1206
|
+
ro.observe(this.host);
|
|
1207
|
+
this.resizeObserver = ro;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
schedule(fn) {
|
|
1212
|
+
if (typeof requestAnimationFrame !== 'undefined') {
|
|
1213
|
+
requestAnimationFrame(() => fn());
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
setTimeout(() => fn(), 0);
|
|
1217
|
+
}
|
|
1218
|
+
renderContent(content, host, inputs) {
|
|
1219
|
+
switch (content.kind) {
|
|
1220
|
+
case 'text': {
|
|
1221
|
+
const text = inputs?.text;
|
|
1222
|
+
if (typeof text !== 'string') {
|
|
1223
|
+
throw new Error('Text overlay requires inputs: { text: string }');
|
|
1224
|
+
}
|
|
1225
|
+
host.textContent = text;
|
|
1226
|
+
return () => {
|
|
1227
|
+
host.textContent = '';
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
case 'template': {
|
|
1231
|
+
const template = content.template;
|
|
1232
|
+
if (!template)
|
|
1233
|
+
throw new Error('Template overlay requires a template');
|
|
1234
|
+
const viewRef = template.createEmbeddedView(inputs, this.contentInjector);
|
|
1235
|
+
this.appRef.attachView(viewRef);
|
|
1236
|
+
viewRef.detectChanges();
|
|
1237
|
+
for (const node of viewRef.rootNodes) {
|
|
1238
|
+
host.appendChild(node);
|
|
1239
|
+
}
|
|
1240
|
+
return () => {
|
|
1241
|
+
this.appRef.detachView(viewRef);
|
|
1242
|
+
viewRef.destroy();
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
case 'component': {
|
|
1246
|
+
const component = content.component;
|
|
1247
|
+
if (!component)
|
|
1248
|
+
throw new Error('Component overlay requires a component');
|
|
1249
|
+
const componentRef = createComponent(component, {
|
|
1250
|
+
environmentInjector: this.contentEnvironmentInjector,
|
|
1251
|
+
hostElement: host,
|
|
1252
|
+
});
|
|
1253
|
+
if (inputs && typeof inputs === 'object') {
|
|
1254
|
+
for (const [key, value] of Object.entries(inputs)) {
|
|
1255
|
+
componentRef.setInput(key, value);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
this.appRef.attachView(componentRef.hostView);
|
|
1259
|
+
componentRef.changeDetectorRef.detectChanges();
|
|
1260
|
+
return () => {
|
|
1261
|
+
this.appRef.detachView(componentRef.hostView);
|
|
1262
|
+
componentRef.destroy();
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const coarTooltipPreset = (b) => {
|
|
1270
|
+
b.backdrop('none');
|
|
1271
|
+
b.scroll({ strategy: 'noop' });
|
|
1272
|
+
b.dismiss({ outsideClick: false, escapeKey: false });
|
|
1273
|
+
b.a11y({ role: 'tooltip' });
|
|
1274
|
+
b.position({
|
|
1275
|
+
placement: ['top', 'bottom', 'left', 'right'],
|
|
1276
|
+
offset: 8,
|
|
1277
|
+
flip: true,
|
|
1278
|
+
shift: true,
|
|
1279
|
+
});
|
|
1280
|
+
b.attachment({ strategy: 'body' });
|
|
1281
|
+
};
|
|
1282
|
+
const coarModalPreset = (b) => {
|
|
1283
|
+
b.backdrop('modal');
|
|
1284
|
+
b.anchor({ kind: 'virtual', placement: 'center' });
|
|
1285
|
+
b.focus({ trap: true, restore: true });
|
|
1286
|
+
b.size({ mode: 'content-clamped', maxWidth: 'viewport', maxHeight: 'viewport' });
|
|
1287
|
+
b.position({ placement: 'center', offset: 0, flip: false, shift: true });
|
|
1288
|
+
b.a11y({ role: 'dialog' });
|
|
1289
|
+
b.attachment({ strategy: 'body' });
|
|
1290
|
+
};
|
|
1291
|
+
const coarMenuPreset = (b) => {
|
|
1292
|
+
b.backdrop('none');
|
|
1293
|
+
b.scroll({ strategy: 'close' });
|
|
1294
|
+
b.dismiss({ outsideClick: true, escapeKey: true });
|
|
1295
|
+
b.focus({ trap: false, restore: true });
|
|
1296
|
+
b.a11y({ role: 'menu' });
|
|
1297
|
+
// Provide fallback placements so the overlay can choose a side that fits.
|
|
1298
|
+
// Start/end variants help when the anchor is near the viewport edges.
|
|
1299
|
+
b.position({
|
|
1300
|
+
placement: ['bottom-start', 'bottom-end', 'top-start', 'top-end'],
|
|
1301
|
+
offset: 4,
|
|
1302
|
+
flip: true,
|
|
1303
|
+
shift: true,
|
|
1304
|
+
});
|
|
1305
|
+
};
|
|
1306
|
+
/**
|
|
1307
|
+
* Menu preset for hover-driven menus (context menus, cascading flyouts).
|
|
1308
|
+
*
|
|
1309
|
+
* Enables hoverTree dismissal so a parent overlay stays open while the pointer
|
|
1310
|
+
* is inside any child overlay opened via openChild().
|
|
1311
|
+
*/
|
|
1312
|
+
const coarHoverMenuPreset = (b) => {
|
|
1313
|
+
coarMenuPreset(b);
|
|
1314
|
+
b.dismiss({
|
|
1315
|
+
outsideClick: true,
|
|
1316
|
+
escapeKey: true,
|
|
1317
|
+
hoverTree: { enabled: true, delayMs: 300 },
|
|
1318
|
+
});
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Generated bundle index. Do not edit.
|
|
1323
|
+
*/
|
|
1324
|
+
|
|
1325
|
+
export { COAR_MENU_PARENT, COAR_OVERLAY_DEFAULTS, COAR_OVERLAY_REF, COAR_OVERLAY_SPEC_RESOLVERS, CoarOverlay, CoarOverlayService, ContentBuilder, Overlay, OverlayBuilder, CoarOverlayService as OverlayService, coarHoverMenuPreset, coarMenuPreset, coarModalPreset, coarTooltipPreset, computeOverlayCoordinates, getAnchorRect, getContainerRect, getScrollParents, getViewportRect, coarHoverMenuPreset as hoverMenuPreset, coarMenuPreset as menuPreset, coarModalPreset as modalPreset, rectFromDom, rectFromPoint, rectFromVirtual, coarTooltipPreset as tooltipPreset };
|
|
1326
|
+
//# sourceMappingURL=cocoar-ui-overlay.mjs.map
|