angulardatepickeroverlay

Angular Material - Overlay X Datepicker


I have a question regarding overlay and datepicker with Angular Material. I'm using the overlayOutsideClick event to close the overlay whenever I click outside the overlay.

It's working fine but if I have a datepicker and I choose a date it is considered as a click outside the overlay, how can I prevent this behavior ?

Thanks

Here is a stackblitz to reproduce the situation: https://stackblitz.com/edit/ghjvsh-zy2lf2?file=src%2Fexample%2Fcdk-overlay-basic-example.html


Solution

  • So I came up with a solution that may be a little more complex than it needs to be.

    To fix the overlay closing on click on the datepicker toggle button, I didn't make use of the overlayOutsideClick, I instead relied on the blur event to run logic when the date input is loosing focus.

    By encapsulating the MatFormField in a div, I will check if the new focused element is inside of that div before closing the overlay, leaving it open if we clicked the datepicker toggle button.

    Minimal code example :

    Typescript :
    export class CdkOverlayBasicExample {
      protected isOpen = signal<boolean>(false);
    
      @ViewChild('container', { static: false })
      datepickerContainer!: ElementRef<HTMLDivElement>;
    
      protected onBlur($event: FocusEvent) {
        const relatedTarget = $event.relatedTarget;
        if (
          !relatedTarget || // If the related target is null, we didn't click on any HTML element
          !(relatedTarget instanceof HTMLElement) ||
          !this.datepickerContainer.nativeElement.contains(relatedTarget)
        ) {
          // If the relatedTarget is not contained inside our container div, close the overlay
          this.isOpen.set(false);
        }
      }
    }
    
    Template :
    <ng-template
      cdkConnectedOverlay
      [cdkConnectedOverlayOrigin]="trigger"
      [cdkConnectedOverlayOpen]="isOpen()"
    >
      <div #container>
        <mat-form-field>
          <mat-label>Choose a date</mat-label>
          <input
            #input
            matInput
            [matDatepicker]="picker"
            (blur)="onBlur($event)"
            [cdkTrapFocusAutoCapture]="true"
            cdkTrapFocus
          />
          <mat-hint>MM/DD/YYYY</mat-hint>
          <mat-datepicker-toggle
            matIconSuffix
            [for]="picker"
          ></mat-datepicker-toggle>
          <mat-datepicker
            #picker
            [restoreFocus]="false"
            (closed)="input.focus()"
          ></mat-datepicker>
        </mat-form-field>
      </div>
    </ng-template>
    

    While working as intended this solution had a few caveats, notably the datepicker input is not focused by default and closing the datepicker would not set the focus on the input.

    To achieve a better result I've added the cdkFocusTrap from the A11yModule to the datepicker input, I've disabled the datepicker restore focus function and I manually set the focus on the datepicker input when the datepicker is closed.

    Full example:

    import {
      Component,
      ElementRef,
      TemplateRef,
      ViewChild,
      signal,
    } from '@angular/core';
    import { OverlayModule } from '@angular/cdk/overlay';
    import {
      MatDatepicker,
      MatDatepickerModule,
    } from '@angular/material/datepicker';
    import { MatFormField, MatInputModule } from '@angular/material/input';
    import { MatFormFieldModule } from '@angular/material/form-field';
    import { A11yModule } from '@angular/cdk/a11y';
    
    /**
     * @title Overlay basic example
     */
    @Component({
      selector: 'cdk-overlay-basic-example',
      template: `
        <!-- This button triggers the overlay and is it's origin -->
    <button
      (click)="isOpen.set(!isOpen());"
      type="button"
      cdkOverlayOrigin
      #trigger="cdkOverlayOrigin"
    >
      {{isOpen() ? "Close" : "Open"}}
    </button>
    
        <ng-template
          cdkConnectedOverlay
          [cdkConnectedOverlayOrigin]="trigger"
          [cdkConnectedOverlayOpen]="isOpen()"
        >
          <div #container>
            <mat-form-field>
              <mat-label>Choose a date</mat-label>
              <input
                #input
                matInput
                [matDatepicker]="picker"
                (blur)="onBlur($event)"
                [cdkTrapFocusAutoCapture]="true"
                cdkTrapFocus
              />
              <mat-hint>MM/DD/YYYY</mat-hint>
              <mat-datepicker-toggle
                matIconSuffix
                [for]="picker"
              ></mat-datepicker-toggle>
              <mat-datepicker
                #picker
                [restoreFocus]="false"
                (closed)="input.focus()"
              ></mat-datepicker>
            </mat-form-field>
          </div>
        </ng-template>
    
      `,
      standalone: true,
      imports: [
        OverlayModule,
        MatFormFieldModule,
        MatInputModule,
        MatDatepickerModule,
        A11yModule,
      ],
    })
    export class CdkOverlayBasicExample {
      protected isOpen = signal<boolean>(false);
    
      @ViewChild('container', { static: false })
      datepickerContainer!: ElementRef<HTMLDivElement>;
    
      protected onBlur($event: FocusEvent) {
        const relatedTarget = $event.relatedTarget;
        if (
          !relatedTarget ||
          !(relatedTarget instanceof HTMLElement) ||
          !this.datepickerContainer.nativeElement.contains(relatedTarget)
        ) {
          this.isOpen.set(false);
        }
      }
    }
    

    And here's a working stackblitz forked from yours :
    https://stackblitz.com/edit/ghjvsh-bmd6my?file=src%2Fexample%2Fcdk-overlay-basic-example.html

    I hope this helps !

    Update :

    I'd like to add that the encapsulation of the mat-form-field inside a div element is not mandatory, as one could also just use the HTMLElement of the mat-form-field instead :

    Template :

    <ng-template
      cdkConnectedOverlay
      [cdkConnectedOverlayOrigin]="trigger"
      [cdkConnectedOverlayOpen]="isOpen()"
    >
      <mat-form-field #container>
        ...
      </mat-form-field>
    </ng-template>
    

    Typescript :

    @ViewChild('container', { static: false })
    datepickerContainer!: ElementRef<HTMLElement>;