File

src/app/shared/components/drawer/drawer/drawer.component.ts

Description

Side drawer component. Contains all the logic for opening/closing/expanding.

Implements

AfterViewInit OnDestroy

Metadata

Index

Properties
Methods
Inputs
Outputs
HostBindings
HostListeners
Accessors

Constructor

constructor(messageService: MessageService, cdr: ChangeDetectorRef, element: ElementRef)

Creates an instance of drawer component.

Parameters :
Name Type Optional Description
messageService MessageService No

Service for sending/receiving event messages.

cdr ChangeDetectorRef No

The change detector reference.

element ElementRef<HTMLElement> No

Reference to components HTML element.

Inputs

expanded
Type : boolean

Whether the drawer is expanded.

opened
Type : boolean

Whether the drawer is opened.

position
Type : "start" | "end"

Position of the drawer - start (left) or end (right).

Outputs

expandedChange
Type : EventEmitter

Output emitting when the drawer has expanded.

openedChange
Type : EventEmitter

Output emitting when the drawer has opened.

stateChange
Type : EventEmitter

Output emitting whenever the drawer state changes.

HostBindings

@expandCollapse

Expanded/collapsed state parameters.

@openClose
Type : OpenedState
Default value : 'closed'

Current open/close animation state.

class
Type : "ccf-drawer"
Default value : 'ccf-drawer'

HTML class

class.ccf-drawer-end
Type : boolean

Whether this is located at the end position.

HostListeners

@expandCollapse.done
@expandCollapse.done()

Listener to expand/collapse animation completion.

@openClose.done
@openClose.done()

Listener to open/close animation completion.

Methods

close
close()

Closes the drawer.

Returns : void
closeExpanded
closeExpanded()

Collapses the drawer.

Returns : void
closeOpenDone
closeOpenDone()
Decorators :
@HostListener('@openClose.done')

Listener to open/close animation completion.

Returns : void
expandCollapseDone
expandCollapseDone()
Decorators :
@HostListener('@expandCollapse.done')

Listener to expand/collapse animation completion.

Returns : void
open
open()

Opens the drawer.

Returns : void
openExpanded
openExpanded()

Expands the drawer.

Returns : void
toggle
toggle(opened)

Toggles the drawer between opened and closed.

Parameters :
Name Optional Default value Description
opened No !this.opened

Whether to open or close the drawer.

Returns : void
toggleExpanded
toggleExpanded(expanded)

Toggles the drawer between expanded and collapsed.

Parameters :
Name Optional Default value Description
expanded No !this.expanded

Whether to expand or collapse the drawer.

Returns : void

Properties

Readonly className
Type : string
Default value : 'ccf-drawer'
Decorators :
@HostBinding('class')

HTML class

expandedState
Type : ExpandedState
Default value : 'closed'

Current expanded/collapsed animation state.

Static ngAcceptInputType_expanded
Type : BooleanInput
Static ngAcceptInputType_opened
Type : BooleanInput
Static ngAcceptInputType_position
Type : string | "start" | "end"

Workaround for getter/setter pair not accepting different types.

openedState
Type : OpenedState
Default value : 'closed'
Decorators :
@HostBinding('@openClose')

Current open/close animation state.

Accessors

classEnd
getclassEnd()

Whether this is located at the end position.

Returns : boolean
position
getposition()

Position of the drawer - start (left) or end (right).

Returns : "start" | "end"
setposition(value: "start" | "end")
Parameters :
Name Type Optional
value "start" | "end" No
Returns : void
opened
getopened()

Whether the drawer is opened.

Returns : boolean
setopened(value: boolean)
Parameters :
Name Type Optional
value boolean No
Returns : void
expanded
getexpanded()

Whether the drawer is expanded.

Returns : boolean
setexpanded(value: boolean)
Parameters :
Name Type Optional
value boolean No
Returns : void
expandedStateObj
getexpandedStateObj()

Expanded/collapsed state parameters.

import { animate, state, style, transition, trigger } from '@angular/animations';
import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion';
import {
  AfterViewInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ElementRef,
  EventEmitter,
  HostBinding,
  HostListener,
  Input,
  OnDestroy,
  Output,
} from '@angular/core';
import { Subscription } from 'rxjs';

import { Message, MessageChannel, MessageService } from '../messages';

/** Opened/closed state. */
type OpenedState = 'open' | 'open-instant' | 'closed';
/** Expanded/collapsed state. */
type ExpandedState = 'open' | 'open-instant' | 'closed';
/** Expanded state relative to an opposite drawer. */
type ExpandedState2 = 'collapsed' | 'half' | 'extended' | 'full';

/** Default animation parameters. */
const EXPAND_COLLAPSE_PARAMS_DEFAULT = {
  params: {
    width: 0,
    margin: 0,
    margin2: 0,
  },
};

/**
 * Boolean state that can also be awaited.
 */
class InitializationState {
  /** Whether this state is true or false. */
  private initialized = false;
  /** Promise used to await on. */
  private readonly deferred = new Promise<void>((resolve) => {
    this.resolve = resolve;
  });
  /** Resolve function for the promise. */
  private resolve!: () => void;

  /**
   * Sets the state to true and
   */
  set(): void {
    this.initialized = true;
    this.resolve();
  }

  /**
   * Gets a promise that resolves when this state is set to true.
   *
   * @returns A promise.
   */
  async wait(): Promise<void> {
    return this.deferred;
  }

  /**
   * Gets the boolean state of this object.
   *
   * @returns true if set has been called.
   */
  valueOf(): boolean {
    return this.initialized;
  }
}

/**
 * Side drawer component.
 * Contains all the logic for opening/closing/expanding.
 */
@Component({
  selector: 'ccf-drawer',
  exportAs: 'ccfDrawer',
  templateUrl: './drawer.component.html',
  styleUrls: ['./drawer.component.scss'],
  animations: [
    trigger('openClose', [
      state(
        'open, open-instant',
        style({
          transform: 'none',
        }),
      ),
      state('closed', style({})),

      transition('closed => open-instant', animate(0)),
      transition('closed <=> open, open-instant => closed', animate('.5s ease-in-out')),
    ]),
    trigger('expandCollapse', [
      state('collapsed', style({})),
      state(
        'half',
        style({
          width: 'calc(50% - {{ margin }}px)',
        }),
        EXPAND_COLLAPSE_PARAMS_DEFAULT,
      ),
      state(
        'extended',
        style({
          width: 'calc(100% - {{ width }}px - {{ margin }}px)',
        }),
        EXPAND_COLLAPSE_PARAMS_DEFAULT,
      ),
      state(
        'full',
        style({
          width: 'calc(100% - {{ margin }}px - {{ margin2 }}px)',
        }),
        EXPAND_COLLAPSE_PARAMS_DEFAULT,
      ),

      transition('* <=> *', animate('.5s ease-in-out')),
    ]),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DrawerComponent implements AfterViewInit, OnDestroy {
  /** HTML class */
  @HostBinding('class') readonly className = 'ccf-drawer';
  /** Whether this is located at the end position. */
  @HostBinding('class.ccf-drawer-end') // eslint-disable-line
  get classEnd(): boolean {
    return this.position === 'end';
  }

  /** Position of the drawer - start (left) or end (right). */
  @Input() // eslint-disable-line
  get position(): 'start' | 'end' {
    return this._position;
  }
  set position(value: 'start' | 'end') {
    this._position = value || 'start';
  }
  /** Property for position getter/setter. */
  private _position: 'start' | 'end' = 'start';

  /** Whether the drawer is opened. */
  @Input() // eslint-disable-line
  @HostBinding('class.ccf-drawer-opened') // eslint-disable-line
  get opened(): boolean {
    return this._opened;
  }
  set opened(value: boolean) {
    this.toggle(coerceBooleanProperty(value));
  }
  /** Property for opened getter/setter. */
  private _opened = false;

  /** Whether the drawer is expanded. */
  @Input() // eslint-disable-line
  @HostBinding('class.ccf-drawer-expanded') // eslint-disable-line
  get expanded(): boolean {
    return this._expanded;
  }
  set expanded(value: boolean) {
    this.toggleExpanded(coerceBooleanProperty(value));
  }
  /** Property for expanded getter/setter */
  private _expanded = false;

  /** Output emitting when the drawer has opened. */
  @Output() readonly openedChange = new EventEmitter<boolean>(true);
  /** Output emitting when the drawer has expanded. */
  @Output() readonly expandedChange = new EventEmitter<boolean>(true);
  /** Output emitting whenever the drawer state changes. */
  @Output() readonly stateChange = new EventEmitter<void>(true);

  /** Current open/close animation state. */
  @HostBinding('@openClose') openedState: OpenedState = 'closed';

  /** Expanded/collapsed state parameters. */
  @HostBinding('@expandCollapse') // eslint-disable-line
  get expandedStateObj(): unknown {
    return {
      value: this.expandedState2,
      params: {
        width: this.width,
        margin: this.measuredMargin,
        margin2: this.margin2,
      },
    };
  }
  /** Current expanded/collapsed animation state. */
  expandedState: ExpandedState = 'closed';
  /** Current expanded state relative to the opposite drawer. */
  private expandedState2: ExpandedState2 = 'collapsed';

  /** Gets the calculated width of the drawer. */
  private get measuredWidth(): number {
    if (this._measuredWidth > 0) {
      return this._measuredWidth;
    }

    const element = this.element.nativeElement;
    if (!element) {
      return 0;
    }

    const bbox = element.getBoundingClientRect();
    const width = bbox.right - bbox.left;
    if (width === 0) {
      return 0;
    }

    this._measuredWidth = width;
    return width;
  }
  /** Cached measured width. */
  private _measuredWidth = 0;
  /** Width of opposite drawer. */
  private width = 0;

  /** Gets the calculated margin of the drawer. */
  private get measuredMargin(): number {
    if (this._measuredMargin > 0) {
      return this._measuredMargin;
    }

    const element = this.element.nativeElement;
    if (!element) {
      return 0;
    }

    const styles = globalThis.getComputedStyle(element);
    const property = this.position === 'start' ? 'margin-right' : 'margin-left';
    const value = styles.getPropertyValue(property);
    const margin = Number(value.slice(0, -2));

    this._measuredMargin = margin;
    return margin;
  }
  /** Cached measured margin. */
  private _measuredMargin = 0;
  /** Margin of the opposite drawer. */
  private margin2 = 0;

  /** Initialization state. */
  private readonly initialized = new InitializationState();
  /** Connected message channel. */
  private readonly channel: MessageChannel;
  /** Subscriptions managed by this component. */
  private readonly subscriptions = new Subscription();

  /**
   * Creates an instance of drawer component.
   *
   * @param messageService Service for sending/receiving event messages.
   * @param cdr The change detector reference.
   * @param element Reference to components HTML element.
   */
  constructor(
    messageService: MessageService,
    cdr: ChangeDetectorRef,
    private readonly element: ElementRef<HTMLElement>,
  ) {
    this.channel = messageService.connect(this);
    this.subscriptions.add(
      this.channel.getMessages().subscribe((msg) => {
        if (this.handleMessage(msg)) {
          cdr.markForCheck();
        }
      }),
    );
  }

  /**
   * Initializes this component.
   */
  ngAfterViewInit(): void {
    this.initialized.set();
    setTimeout(() => this.channel.sendMessage({ type: 'drawer-initialized' }));
  }

  /**
   * Cleans up all subscriptions.
   */
  ngOnDestroy(): void {
    this.subscriptions.unsubscribe();
  }

  /**
   * Opens the drawer.
   */
  open(): void {
    this.toggle(true);
  }

  /**
   * Closes the drawer.
   */
  close(): void {
    this.toggle(false);
  }

  /**
   * Toggles the drawer between opened and closed.
   *
   * @param [opened] Whether to open or close the drawer.
   */
  toggle(opened = !this.opened): void {
    if (this.opened === opened) {
      return;
    }

    if (!opened) {
      this.openedState = 'closed';
      this.expandedState = 'closed';
      this.expandedState2 = 'collapsed';
      this._expanded = false;
    } else if (this.initialized.valueOf()) {
      this.openedState = 'open';
    } else {
      this.openedState = 'open-instant';
    }

    this._opened = opened;
    this.sendToggle();
  }

  /**
   * Expands the drawer.
   */
  openExpanded(): void {
    this.toggleExpanded(true);
  }

  /**
   * Collapses the drawer.
   */
  closeExpanded(): void {
    this.toggleExpanded(false);
  }

  /**
   * Toggles the drawer between expanded and collapsed.
   *
   * @param [expanded] Whether to expand or collapse the drawer.
   */
  toggleExpanded(expanded = !this.expanded): void {
    if (this.expanded === expanded) {
      return;
    }

    if (!expanded) {
      this.expandedState = 'closed';
      this.expandedState2 = 'collapsed';
    } else if (this.initialized.valueOf()) {
      this.expandedState = 'open';
    } else {
      this.expandedState = 'open-instant';
    }

    this._expanded = expanded;
    this.sendToggle();
  }

  /**
   * Listener to open/close animation completion.
   */
  @HostListener('@openClose.done') // eslint-disable-line
  closeOpenDone(): void {
    this.openedChange.emit(this.opened);
  }

  /**
   * Listener to expand/collapse animation completion.
   */
  @HostListener('@expandCollapse.done') // eslint-disable-line
  expandCollapseDone(): void {
    this.expandedChange.emit(this.expanded);
  }

  /**
   * Sends a toggle event to the channel.
   * Waits until initialization is completed before sending.
   */
  private async sendToggle(): Promise<void> {
    await this.initialized.wait();
    this.channel.sendMessage({
      type: 'drawer-toggled',
      opened: this.opened,
      expanded: this.expanded,
      width: this.measuredWidth,
      margin: this.measuredMargin,
    });
    this.stateChange.emit();
  }

  /**
   * Processes an event.
   *
   * @param msg The event.
   * @returns true if change detection should run.
   */
  private handleMessage(msg: Message): boolean {
    if (msg.payload.type === 'drawer-toggled') {
      const other = msg.source as DrawerComponent;
      this.syncExpanded(other);
      return true;
    }

    return false;
  }

  /**
   * Syncs the drawer widths and margin against the opposite drawer.
   *
   * @param other The opposite drawer.
   */
  private syncExpanded(other: DrawerComponent): void {
    if (this.expanded || other.expanded) {
      if (this.expanded && other.expanded) {
        this.expandedState2 = other.expandedState2 = 'half';
      } else if (this.expanded) {
        if (other.opened) {
          this.expandedState2 = 'extended';
          this.width = other.measuredWidth + other.measuredMargin;
        } else {
          this.expandedState2 = 'full';
          this.margin2 = other.measuredMargin;
        }
      } else {
        if (this.opened) {
          other.expandedState2 = 'extended';
          other.width = this.measuredWidth + this.measuredMargin;
        } else {
          other.expandedState2 = 'full';
          other.margin2 = this.measuredMargin;
        }
      }
    }
  }

  /** Workaround for getter/setter pair not accepting different types. */
  // eslint-disable-next-line @typescript-eslint/naming-convention
  static ngAcceptInputType_position: '' | 'start' | 'end';
  // eslint-disable-next-line @typescript-eslint/naming-convention
  static ngAcceptInputType_opened: BooleanInput;
  // eslint-disable-next-line @typescript-eslint/naming-convention
  static ngAcceptInputType_expanded: BooleanInput;
}
<div class="cff-drawer-inner-container">
  <ng-content></ng-content>
</div>

./drawer.component.scss

:host {
  display: block;
  position: absolute;
  top: 0;
  bottom: 0;
  z-index: 3;
  outline: 0;
  transform: translate3d(-100%, 0, 0);
  background-color: white;

  &.ccf-drawer-end {
    right: 0;
    transform: translate3d(100%, 0, 0);
  }

  .cff-drawer-inner-container {
    width: 100%;
    height: 100%;
    overflow: auto;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: thin;
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""