ng-bootstrap / ng-bootstrap

Angular powered Bootstrap

Home Page:https://ng-bootstrap.github.io

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Tooltip preventOverflow option

Ste35 opened this issue · comments

Hi,
I'd like to report a possible bug. When a tooltip is near the edge of the document (or near the edge of it's parent element with overflow:hidden style) and there isn't the auto value in placement option, the tooltip overflow the visible part of the viewport resulting in being partially "cropped". According to popper docs this can be avoided using the preventOverflow option that (if I'm not wrong) should be enabled by default, but it doesn't seem to work.

I also tried to manually add the modifiers via the popperOptions property without any success...

tp

It seems to work fine with only bootstrap:
tp1

<button
  type="button"
  class="btn btn-outline-secondary m-2"
  data-bs-toggle="tooltip"
  data-bs-placement="bottom"
  data-bs-title="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin porttitor semper sapien ac posuere. Ut mattis hendrerit massa in laoreet"
  data-bs-trigger="click"
>
  Tooltip on top
</button>
<script>const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl))</script>

Is there a way to keep the placement (e.g. bottom) and avoid the overflow?

Link to minimally-working StackBlitz that reproduces the issue:

https://stackblitz.com/edit/angular-jimum7?file=src%2Fapp%2Ftooltip-basic.html,src%2Fapp%2Ftooltip-basic.ts

Versions of Angular, ng-bootstrap and Bootstrap:

Angular: 17

ng-bootstrap: 16

The report issue is also happen to dropdown and popover. From the code I can see preventOverflow has been added as default.

ng-bootstrap utilize popper lite, which popper lite doesn't include preventOverflow, where you have to import in to make it work. Which ng-bootstrap did.

export function getPopperOptions({ placement, baseClass }: PositioningOptions, rtl: NgbRTL): Partial<Options> {
let placementVals: Array<Placement> = Array.isArray(placement)
? placement
: (placement.split(placementSeparator) as Array<Placement>);
// No need to consider left and right here, as start and end are enough, and it is used for 'auto' placement only
const allowedPlacements = [
'top',
'bottom',
'start',
'end',
'top-start',
'top-end',
'bottom-start',
'bottom-end',
'start-top',
'start-bottom',
'end-top',
'end-bottom',
];
// replace auto placement with other placements
let hasAuto = placementVals.findIndex((val) => val === 'auto');
if (hasAuto >= 0) {
allowedPlacements.forEach(function (obj) {
if (placementVals.find((val) => val.search('^' + obj) !== -1) == null) {
placementVals.splice(hasAuto++, 1, obj as Placement);
}
});
}
const popperPlacements = placementVals.map((_placement) => {
return getPopperClassPlacement(_placement, rtl.isRTL());
});
let mainPlacement = popperPlacements.shift();
const bsModifier: Partial<Modifier<any, any>> = {
name: 'bootstrapClasses',
enabled: !!baseClass,
phase: 'write',
fn({ state }) {
const bsClassRegExp = new RegExp(baseClass + '(-[a-z]+)*', 'gi');
const popperElement: HTMLElement = state.elements.popper as HTMLElement;
const popperPlacement = state.placement;
let className = popperElement.className;
// Remove old bootstrap classes
className = className.replace(bsClassRegExp, '');
// Add current placements
className += ` ${getBootstrapBaseClassPlacement(baseClass!, popperPlacement)}`;
// Remove multiple spaces
className = className.trim().replace(spacesRegExp, ' ');
// Reassign
popperElement.className = className;
},
};
return {
placement: mainPlacement,
modifiers: [
bsModifier,
flip,
preventOverflow,
arrow,
{
enabled: true,
name: 'flip',
options: {
fallbackPlacements: popperPlacements,
},
},
{
enabled: true,
name: 'preventOverflow',
phase: 'main',
fn: function () {},
},
],
};
}

bootstrap also has preventOverflow, but they use popper instead of popper lite.

https://github.com/twbs/bootstrap/blob/24cc552343c817b0d13201639c635a778d55da09/js/src/tooltip.js#L399-L437

Personally, this has been struggling me for a while as well.

I have found out the issue. The following code cause preventOverflow to not functional. The goal of the code was suppose to provide additional option and to be merged with popper's preventOverflow modifier. But it actually setup fn.

{
enabled: true,
name: 'preventOverflow',
phase: 'main',
fn: function () {},
},

If we compare the option bootstrap has added and ng-bootstrap's code.

// bootstrap
{
   name: 'preventOverflow',
   options: {
      boundary: this._config.boundary
   }
}

// ng-bootstrap
{
  enabled: true,
  name: 'preventOverflow',
  phase: 'main',
  fn: function () {},
}

If we either remove ng-bootstrap's modifier or swap with bootstrap's modifier. In both case works.

Following is popperOptions function I use to debug ng-bootstrap. The logic is to check how many preventOverflow has been added. If more than one, remove last one. @Ste35 you can give it a try.

  
  popperOptions(options: Partial<Options>): Partial<Options> {
    const target = options.modifiers?.filter(m => m.name === 'preventOverflow');
    if (!target || target.length <= 1) {
      return options;
    }

    const last = target.at(-1);
    if (options.modifiers && last) {
      options.modifiers = options.modifiers.filter(m => m !== last);
    }

    console.log('popper options', options);

    return options;
  }

@maxokorokov The second preventOverflow that ng-bootstrap added, I feels not necessary. Bootstrap use it to setup boundary, where ng-bootstrap setup nothing. We can potentially remove it.