Skip to content

feat(FlexibleConnectedPositionStrategy): Handle custom containers for flexible connected position strategy #27644

Open
@ko1ebayev

Description

@ko1ebayev

Feature Description

I would like to be able to provide a custom viewport to calculate FlexibleConnectedTo positions. Now a custom overlay container can be provided by the user, but in tandem with the FlexibleConnectedTo it does not work correctly, because under the hood _getOriginRect works with real viewport and pushing element into the custom container frame is calculated relative to the real document.clientWidth and document.clientHeight

For example, lets imagine we have div.custom-overlay-container with 500x200px dimensions and tooltip directive built with CDK. How I solve this problem now to achieve desired behavior:

Tooltip.directive.ts

import { FlexibleConnectedPositionStrategy, OriginConnectionPosition, Overlay, OverlayConnectionPosition, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import {
  ComponentRef,
  Directive,
  ElementRef,
  HostBinding,
  HostListener,
  Input,
  OnInit,
  TemplateRef,
} from '@angular/core';

const FIXED_WIDTH = 200;
const FIXED_HEIGHT = 500;

@Directive({
  selector: '[cdkTooltip]',
  standalone: true,
})
export class TooltipDirective implements OnInit {
  @Input('cdkTooltip') content: string | TemplateRef<unknown> | null = '';

  @Input() position: 'top' | 'right' | 'bottom' | 'left' = 'bottom';

  private overlayRef: Maybe<OverlayRef>;

  private tooltipHovered = false;

  @HostListener('mouseenter')
  show(): void {
    if (!this.content) {
      return;
    }

    this.overlayRef?.detach();

    const tooltipPortal = new ComponentPortal(TooltipComponent);
    if (this.overlayRef) {
      const tooltipRef: ComponentRef<TooltipComponent> = this.overlayRef.attach(tooltipPortal);
      const tooltipHostElement = tooltipRef.location.nativeElement as HTMLElement;
      tooltipRef.setInput(typeof this.content === 'string' ? 'text' : 'template', this.content);
    }
  }

  @HostListener('mouseleave')
  hide(): void {
      this.overlayRef?.detach();
  }

  constructor(
    private readonly overlay: Overlay,
    private readonly elementRef: ElementRef<HTMLElement>,
  ) {}

  ngOnInit(): void {
    this.createOverlay();
  }

  private createOverlay(): void {
    const strategy = this.overlay.position().flexibleConnectedTo(this.elementRef).withPush(true);

    // Fix for correct tooltip position calculations:
    // the problem is in the `_getOriginRect` function, it calculates the `DomRect` object
    // for tooltip host relative to the real viewport, but the application content area is limited
    // by the size of the `div.custom-overlay-container`, so we need to override it for correct calculations
    // CDK source where problem method defined - 
    (strategy as any)._getOriginRect = () => {
      const customViewportContainer = document.querySelector<HTMLDivElement>('.custom-overlay-container')!;
      return this.getRelativeBoundingClientRect(
        this.elementRef.nativeElement,
        customViewportContainer,
      );
    };

    // Override position's `_document.documentElement` for correct position adjustment,
    // which is used for pushing element inside viewport (`div.custom-overlay-container` in our case)
    // https://github.com/angular/components/blob/b13c6aa194cf560a304213961ae28725f8d0a4e2/src/cdk/overlay/position/flexible-connected-position-strategy.ts#L1036
    Object.defineProperty(strategy, '_document', {
      writable: true,
      value: {
        documentElement: {
          clientWidth: FIXED_WIDTH,
          clientHeight: FIXED_HEIGHT,
        },
      },
    });

    this.overlayRef = this.overlay.create({
      positionStrategy: strategy,
    });

    this.setOverlayPosition(this.overlayRef);
  }

  private setOverlayPosition(overlayRef: OverlayRef): void {
    const pos = overlayRef.getConfig().positionStrategy as FlexibleConnectedPositionStrategy;
    pos.withPositions([
      {
        ...this.getOriginPosition(),
        ...this.getOverlayPosition(),
        panelClass: this.position,
      },
    ]);
  }

  private getOverlayPosition(): OverlayConnectionPosition {
    const position = this.position;

    if (position === 'top') {
      return { overlayX: 'center', overlayY: 'bottom' };
    }

    if (position === 'bottom') {
      return { overlayX: 'center', overlayY: 'top' };
    }

    if (position === 'right') {
      return { overlayX: 'start', overlayY: 'center' };
    }

    return { overlayX: 'end', overlayY: 'center' };
  }

  private getOriginPosition(): OriginConnectionPosition {
    const position = this.position;

    if (position === 'bottom' || position === 'top') {
      return {
        originX: 'center',
        originY: position === 'top' ? 'top' : 'bottom',
      };
    }

    if (position === 'right') {
      return { originX: 'start', originY: 'center' };
    }

    return { originX: 'end', originY: 'center' };
  }

  private getRelativeBoundingClientRect(element: HTMLElement, relativeTo: HTMLElement): DOMRect {
    const elemRect = element.getBoundingClientRect();
    const containerRect = relativeTo.getBoundingClientRect();

    return {
      top: elemRect.top - containerRect.top,
      bottom: elemRect.bottom - containerRect.top,
      left: elemRect.left - containerRect.left,
      right: elemRect.right - containerRect.left,
      width: elemRect.width,
      height: elemRect.height,
      x: elemRect.left - containerRect.left,
      y: elemRect.top - containerRect.top,
    } as DOMRect;
  }
}

Use Case

the problem that I propose to solve is to add the ability FlexibleConnectedTo position to be able to work strictly within the custom container, that is, when withPush is enabled - the elements should not go beyond the custom container

Metadata

Metadata

Assignees

No one assigned

    Labels

    P5The team acknowledges the request but does not plan to address it, it remains open for discussionarea: cdk/overlayfeatureThis issue represents a new feature or feature request rather than a bug or bug fix

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions