Skip to content

Commit a5427e8

Browse files
authored
feat(popover, tooltip): DLT-3419 auto-append to nearest dialog for top-layer support (#1266)
1 parent e4c5b9e commit a5427e8

4 files changed

Lines changed: 145 additions & 4 deletions

File tree

packages/dialtone-vue/components/Popover/Popover.test.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,71 @@ describe('DtPopover Tests', () => {
388388
});
389389
});
390390

391+
describe('appendTo behavior', () => {
392+
describe('when anchor is inside a <dialog> element and appendTo is "body"', () => {
393+
it('should append the popover to the dialog element, not body', async () => {
394+
const dialogEl = document.createElement('dialog');
395+
document.body.appendChild(dialogEl);
396+
397+
const localWrapper = mount(DtPopover, {
398+
props: { ...baseProps, open: null },
399+
slots: { ...baseSlots },
400+
global: { stubs: { transition: false } },
401+
attachTo: dialogEl,
402+
});
403+
404+
const btn = localWrapper.find('[data-qa="dt-button"]');
405+
await btn.trigger('click');
406+
407+
expect(localWrapper.vm.tip.popper.parentElement).toBe(dialogEl);
408+
409+
localWrapper.unmount();
410+
document.body.removeChild(dialogEl);
411+
});
412+
});
413+
414+
describe('when anchor is NOT inside a <dialog> element and appendTo is "body"', () => {
415+
it('should append the popover to document.body', async () => {
416+
const localWrapper = mount(DtPopover, {
417+
props: { ...baseProps, open: null },
418+
slots: { ...baseSlots },
419+
global: { stubs: { transition: false } },
420+
attachTo: document.body,
421+
});
422+
423+
const btn = localWrapper.find('[data-qa="dt-button"]');
424+
await btn.trigger('click');
425+
426+
expect(localWrapper.vm.tip.popper.parentElement).toBe(document.body);
427+
428+
localWrapper.unmount();
429+
});
430+
});
431+
432+
describe('when anchor is inside a <dialog> but appendTo is explicitly set', () => {
433+
it('should use the explicit appendTo target, bypassing dialog detection', async () => {
434+
const dialogEl = document.createElement('dialog');
435+
document.body.appendChild(dialogEl);
436+
437+
const localWrapper = mount(DtPopover, {
438+
props: { ...baseProps, open: null, appendTo: 'parent' },
439+
slots: { ...baseSlots },
440+
global: { stubs: { transition: false } },
441+
attachTo: dialogEl,
442+
});
443+
444+
const btn = localWrapper.find('[data-qa="dt-button"]');
445+
await btn.trigger('click');
446+
447+
// 'parent' means the popover container element, not the dialog
448+
expect(localWrapper.vm.tip.popper.parentElement).not.toBe(dialogEl);
449+
450+
localWrapper.unmount();
451+
document.body.removeChild(dialogEl);
452+
});
453+
});
454+
});
455+
391456
describe('When anchor slot content changes', () => {
392457
it('should attach the tippy instance to the new DOM node', async () => {
393458
const component = {

packages/dialtone-vue/components/Popover/Popover.vue

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,9 @@ export default {
526526
527527
/**
528528
* Sets the element to which the popover is going to append to.
529-
* 'body' will append to the nearest body (supports shadow DOM).
529+
* 'body' will append to the nearest ancestor <dialog> element when inside one
530+
* (keeping the popover in the browser's top layer), or to the nearest body otherwise.
531+
* To always append to body regardless of dialog context, pass document.body as an HTMLElement.
530532
* 'root' will try append to the iFrame's parent body if it is contained in an iFrame
531533
* and has permissions to access it, else, it'd default to 'parent'.
532534
* @values 'body', 'parent', 'root', HTMLElement
@@ -1074,7 +1076,10 @@ export default {
10741076
10751077
switch (this.appendTo) {
10761078
case 'body':
1077-
internalAppendTo = this.anchorEl?.getRootNode()?.querySelector('body');
1079+
// When inside a native <dialog> (e.g. DtModal in Dialtone Next), append to the
1080+
// dialog so the popover stays in the browser's top layer. Otherwise fall back to body.
1081+
internalAppendTo = this.anchorEl?.closest('dialog') ??
1082+
this.anchorEl?.getRootNode()?.querySelector('body');
10781083
break;
10791084
10801085
case 'root':

packages/dialtone-vue/components/Tooltip/Tooltip.test.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,4 +307,64 @@ describe('DtTooltip tests', () => {
307307
});
308308
});
309309
});
310+
311+
describe('appendTo behavior', () => {
312+
describe('when anchor is inside a <dialog> element and appendTo is "body"', () => {
313+
it('should append the tooltip to the dialog element, not body', async () => {
314+
const dialogEl = document.createElement('dialog');
315+
document.body.appendChild(dialogEl);
316+
317+
const localWrapper = mount(DtTooltip, {
318+
props: { delay: false, appendTo: 'body', open: true },
319+
slots: { ...baseSlots },
320+
global: { stubs: { transition: false } },
321+
attachTo: dialogEl,
322+
});
323+
324+
await flushPromises();
325+
326+
const tippyBoxInDialog = dialogEl.querySelector('.tippy-box');
327+
expect(tippyBoxInDialog).not.toBeNull();
328+
329+
localWrapper.unmount();
330+
document.body.removeChild(dialogEl);
331+
});
332+
});
333+
334+
describe('when anchor is NOT inside a <dialog> element', () => {
335+
it('should append the tooltip to body', async () => {
336+
mockProps = { open: true };
337+
updateWrapper();
338+
await flushPromises();
339+
340+
const tippyBoxInBody = document.body.querySelector('.tippy-box');
341+
expect(tippyBoxInBody).not.toBeNull();
342+
});
343+
});
344+
345+
describe('when anchor is inside a <dialog> but appendTo is explicitly set to an HTMLElement', () => {
346+
it('should use the explicit appendTo target, bypassing dialog detection', async () => {
347+
const dialogEl = document.createElement('dialog');
348+
const explicitTarget = document.createElement('div');
349+
document.body.appendChild(dialogEl);
350+
document.body.appendChild(explicitTarget);
351+
352+
const localWrapper = mount(DtTooltip, {
353+
props: { delay: false, appendTo: explicitTarget, open: true },
354+
slots: { ...baseSlots },
355+
global: { stubs: { transition: false } },
356+
attachTo: dialogEl,
357+
});
358+
359+
await flushPromises();
360+
361+
expect(explicitTarget.querySelector('.tippy-box')).not.toBeNull();
362+
expect(dialogEl.querySelector('.tippy-box')).toBeNull();
363+
364+
localWrapper.unmount();
365+
document.body.removeChild(dialogEl);
366+
document.body.removeChild(explicitTarget);
367+
});
368+
});
369+
});
310370
});

packages/dialtone-vue/components/Tooltip/Tooltip.vue

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,9 @@ export default {
172172
173173
/**
174174
* Sets the element to which the tooltip is going to append to.
175-
* 'body' will append to the nearest body (supports shadow DOM).
175+
* 'body' appends to the nearest ancestor <dialog> element when inside one
176+
* (keeping the tooltip in the browser's top layer), or to the nearest body otherwise.
177+
* To always append to body regardless of dialog context, pass document.body as an HTMLElement.
176178
* This prop is not reactive, must be set on initial render.
177179
* @values 'body', 'parent', HTMLElement,
178180
*/
@@ -488,14 +490,22 @@ export default {
488490
}
489491
},
490492
493+
resolveAppendTo () {
494+
// When inside a native <dialog>, append there to stay in the browser's top layer.
495+
if (this.appendTo === 'body') {
496+
return this.anchor?.closest('dialog') ?? this.anchor?.getRootNode()?.querySelector('body');
497+
}
498+
return this.appendTo;
499+
},
500+
491501
setProps () {
492502
if (!this.tip || !this.tip.setProps || !this.anchor) return;
493503
494504
if (this.tip && this.tip.setProps) {
495505
this.tip.setProps({
496506
...this.tippyProps,
497507
// these need to be set here rather than in tippyProps because they are non-reactive
498-
appendTo: this.appendTo === 'body' ? this.anchor?.getRootNode()?.querySelector('body') : this.appendTo,
508+
appendTo: this.resolveAppendTo(),
499509
zIndex: this.calculateAnchorZindex(),
500510
});
501511
}
@@ -529,6 +539,7 @@ export default {
529539
touch: false,
530540
onMount: this.onMount,
531541
showOnCreate: this.internalShow,
542+
appendTo: this.resolveAppendTo(),
532543
popperOptions: getPopperOptions({
533544
hasHideModifierEnabled: true,
534545
}),

0 commit comments

Comments
 (0)