Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
@twnd/ux / toggle / toggle.ts
Size: Mime:
/**
 * @license
 * FOURBURNER CONFIDENTIAL
 * Unpublished Copyright (C) 2021 FourBurner Technologies, Inc. All Rights Reserved.
 *
 * NOTICE: All information contained herein is, and remains the property of FOURBURNER TECHNOLOGIES,
 * INC. The intellectual and technical concepts contained herein are proprietary to FOURBURNER
 * TECHNOLOGIES, INC. and may be covered by U.S. and Foreign Patents, patents in process, and are
 * protected by trade secret or copyright law. Dissemination of this information or reproduction of
 * this material is strictly forbidden unless prior written permission is obtained from FOURBURNER
 * TECHNOLOGIES, INC. Access to the source code contained herein is hereby forbidden to anyone
 * except current FOURBURNER TECHNOLOGIES, INC. employees, managers or contractors who have executed
 * Confidentiality and Non-disclosure agreements explicitly covering such access.
 *
 * The copyright notice above does not evidence any actual or intended publication or disclosure of
 * this source code, which includes information that is confidential and/or proprietary, and is a
 * trade secret, of FOURBURNER TECHNOLOGIES, INC. ANY REPRODUCTION, MODIFICATION, DISTRIBUTION,
 * PUBLIC PERFORMANCE, OR PUBLIC DISPLAY OF OR THROUGH USE OF THIS SOURCE CODE WITHOUT THE EXPRESS
 * WRITTEN CONSENT OF FOURBURNER TECHNOLOGIES, INC. IS STRICTLY PROHIBITED, AND IN VIOLATION OF
 * APPLICABLE LAWS AND INTERNATIONAL TREATIES. THE RECEIPT OR POSSESSION OF THIS SOURCE CODE AND/OR
 * RELATED INFORMATION DOES NOT CONVEY OR IMPLY ANY RIGHTS TO REPRODUCE, DISCLOSE OR DISTRIBUTE ITS
 * CONTENTS, OR TO MANUFACTURE, USE, OR SELL ANYTHING THAT IT MAY DESCRIBE, IN WHOLE OR IN PART.
 */

import {FocusMonitor} from '@angular/cdk/a11y';
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
import {SelectionModel} from '@angular/cdk/collections';
import {
  AfterContentInit,
  Attribute,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ContentChildren,
  Directive,
  ElementRef,
  EventEmitter,
  forwardRef,
  Input,
  OnDestroy,
  OnInit,
  Optional,
  Output,
  QueryList,
  ViewChild,
  ViewEncapsulation,
  InjectionToken,
  Inject,
  AfterViewInit,
} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
import {CanDisableRipple, mixinDisableRipple, ngDevMode} from '@twnd/ux/core';

/**
 * @deprecated No longer used.
 * @breaking-change 11.0.0
 */
export type ToggleType = 'checkbox'|'radio';

/** Possible appearance styles for the button toggle. */
export type TWNDButtonToggleAppearance = 'legacy'|'standard';

/**
 * Represents the default options for the button toggle that can be configured
 * using the `TWND_BUTTON_TOGGLE_DEFAULT_OPTIONS` injection token.
 */
export interface TWNDButtonToggleDefaultOptions {
  /**
   * Default appearance to be used by button toggles. Can be overridden by explicitly
   * setting an appearance on a button toggle or group.
   */
  appearance?: TWNDButtonToggleAppearance;
}

/**
 * Injection token that can be used to configure the
 * default options for all button toggles within an app.
 */
export const TWND_BUTTON_TOGGLE_DEFAULT_OPTIONS =
  new InjectionToken<TWNDButtonToggleDefaultOptions>(
    'TWND_BUTTON_TOGGLE_DEFAULT_OPTIONS',
  );

/**
 * Injection token that can be used to reference instances of `TWNDButtonToggleGroup`.
 * It serves as alternative token to the actual `TWNDButtonToggleGroup` class which
 * could cause unnecessary retention of the class and its component metadata.
 */
export const TWND_BUTTON_TOGGLE_GROUP = new InjectionToken<TWNDButtonToggleGroup>(
  'TWNDButtonToggleGroup',
);

/**
 * Provider Expression that allows twnd-button-toggle-group to register as a ControlValueAccessor.
 * This allows it to support [(ngModel)].
 * @docs-private
 */
export const TWND_BUTTON_TOGGLE_GROUP_VALUE_ACCESSOR: any = {
  provide : NG_VALUE_ACCESSOR,
  useExisting : forwardRef(() => TWNDButtonToggleGroup),
  multi : true,
};

// Counter used to generate unique IDs.
let uniqueIdCounter = 0;

/** Change event object emitted by TWNDButtonToggle. */
export class TWNDButtonToggleChange
{
  constructor(
    /** The TWNDButtonToggle that emits the event. */
    public source: TWNDButtonToggle,

    /** The value assigned to the TWNDButtonToggle. */
    public value: any,
  )
  {}
}

/** Exclusive selection button toggle group that behaves like a radio-button group. */
@Directive({
  selector : 'twnd-button-toggle-group',
  providers : [
    TWND_BUTTON_TOGGLE_GROUP_VALUE_ACCESSOR,
    {provide : TWND_BUTTON_TOGGLE_GROUP,  useExisting : TWNDButtonToggleGroup},
  ],
  host : {
      'role' : 'group',
      'class' : 'twnd-button-toggle-group',
      '[attr.aria-disabled]' : 'disabled',
      '[class.twnd-button-toggle-vertical]' : 'vertical',
      '[class.twnd-button-toggle-group-appearance-standard]' : 'appearance === "standard"',
      },
  exportAs : 'twndButtonToggleGroup',
})
export class TWNDButtonToggleGroup implements ControlValueAccessor, OnInit, AfterContentInit
{
  private _vertical = false;
  private _multiple = false;
  private _disabled = false;
  private _selectionModel: SelectionModel<TWNDButtonToggle>;

  /**
   * Reference to the raw value that the consumer tried to assign. The real
   * value will exclude any values from this one that don't correspond to a
   * toggle. Useful for the cases where the value is assigned before the toggles
   * have been initialized or at the same that they're being swapped out.
   */
  private _rawValue: any;

  /**
   * The method to be called in order to update ngModel.
   * Now `ngModel` binding is not supported in multiple selection mode.
   */
  _controlValueAccessorChangeFn: (value: any) => void = () => {
  };

  /** onTouch function registered via registerOnTouch (ControlValueAccessor). */
  _onTouched: () => any = () => {
  };

  /** Child button toggle buttons. */
  @ContentChildren(forwardRef(() => TWNDButtonToggle), {
    // Note that this would technically pick up toggles
    // from nested groups, but that's not a case that we support.
    descendants : true,
  })
  _buttonToggles: QueryList<TWNDButtonToggle>;

  /** The appearance for all the buttons in the group. */
  @Input() appearance: TWNDButtonToggleAppearance;

  /** `name` attribute for the underlying `input` element. */
  @Input() get name(): string
  {
    return this._name;
  }
  set name(value: string)
  {
    this._name = value;

    if (this._buttonToggles) {
      this._buttonToggles.forEach(toggle => {
        toggle.name = this._name;
        toggle._markForCheck();
      });
    }
  }
  private _name = `twnd-button-toggle-group-${uniqueIdCounter++}`;

  /** Whether the toggle group is vertical. */
  @Input() get vertical(): boolean
  {
    return this._vertical;
  }
  set vertical(value: boolean)
  {
    this._vertical = coerceBooleanProperty(value);
  }

  /** Value of the toggle group. */
  @Input() get value(): any
  {
    const selected = this._selectionModel ? this._selectionModel.selected : [];

    if (this.multiple) {
      return selected.map(toggle => toggle.value);
    }

    return selected[0] ? selected[0].value : undefined;
  }
  set value(newValue: any)
  {
    this._setSelectionByValue(newValue);
    this.valueChange.emit(this.value);
  }

  /**
   * Event that emits whenever the value of the group changes.
   * Used to facilitate two-way data binding.
   * @docs-private
   */
  @Output() readonly valueChange = new EventEmitter<any>();

  /** Selected button toggles in the group. */
  get selected(): TWNDButtonToggle|TWNDButtonToggle[]
  {
    const selected = this._selectionModel ? this._selectionModel.selected : [];
    return this.multiple ? selected : selected[0] || null;
  }

  /** Whether multiple button toggles can be selected. */
  @Input() get multiple(): boolean
  {
    return this._multiple;
  }
  set multiple(value: boolean)
  {
    this._multiple = coerceBooleanProperty(value);
  }

  /** Whether multiple button toggle group is disabled. */
  @Input() get disabled(): boolean
  {
    return this._disabled;
  }
  set disabled(value: boolean)
  {
    this._disabled = coerceBooleanProperty(value);

    if (this._buttonToggles) {
      this._buttonToggles.forEach(toggle => toggle._markForCheck());
    }
  }

  /** Event emitted when the group's value changes. */
  @Output()
  readonly change:
    EventEmitter<TWNDButtonToggleChange> = new EventEmitter<TWNDButtonToggleChange>();

  constructor(
    private _changeDetector: ChangeDetectorRef,
    @Optional() @Inject(TWND_BUTTON_TOGGLE_DEFAULT_OPTIONS) defaultOptions?:
      TWNDButtonToggleDefaultOptions,
  )
  {
    this.appearance = defaultOptions && defaultOptions.appearance ? defaultOptions.appearance :
                                                                    'standard';
  }

  ngOnInit()
  {
    this._selectionModel = new SelectionModel<TWNDButtonToggle>(this.multiple, undefined, false);
  }

  ngAfterContentInit()
  {
    this._selectionModel.select(...this._buttonToggles.filter(toggle => toggle.checked));
  }

  /**
   * Sets the model value. Implemented as part of ControlValueAccessor.
   * @param value Value to be set to the model.
   */
  writeValue(value: any)
  {
    this.value = value;
    this._changeDetector.markForCheck();
  }

  // Implemented as part of ControlValueAccessor.
  registerOnChange(fn: (value: any) => void)
  {
    this._controlValueAccessorChangeFn = fn;
  }

  // Implemented as part of ControlValueAccessor.
  registerOnTouched(fn: any)
  {
    this._onTouched = fn;
  }

  // Implemented as part of ControlValueAccessor.
  setDisabledState(isDisabled: boolean): void
  {
    this.disabled = isDisabled;
  }

  /** Dispatch change event with current selection and group value. */
  _emitChangeEvent(): void
  {
    const selected = this.selected;
    const source = Array.isArray(selected) ? selected[selected.length - 1] : selected;
    const event = new TWNDButtonToggleChange(source!, this.value);
    this._controlValueAccessorChangeFn(event.value);
    this.change.emit(event);
  }

  /**
   * Syncs a button toggle's selected state with the model value.
   * @param toggle Toggle to be synced.
   * @param select Whether the toggle should be selected.
   * @param isUserInput Whether the change was a result of a user interaction.
   * @param deferEvents Whether to defer emitting the change events.
   */
  _syncButtonToggle(
    toggle: TWNDButtonToggle,
    select: boolean,
    isUserInput = false,
    deferEvents = false,
  )
  {
    // Deselect the currently-selected toggle, if we're in single-selection
    // mode and the button being toggled isn't selected at the moment.
    if (!this.multiple && this.selected && !toggle.checked) {
      (this.selected as TWNDButtonToggle).checked = false;
    }

    if (this._selectionModel) {
      if (select) {
        this._selectionModel.select(toggle);
      } else {
        this._selectionModel.deselect(toggle);
      }
    } else {
      deferEvents = true;
    }

    // We need to defer in some cases in order to avoid "changed after checked errors", however
    // the side-effect is that we may end up updating the model value out of sequence in others
    // The `deferEvents` flag allows us to decide whether to do it on a case-by-case basis.
    if (deferEvents) {
      Promise.resolve().then(() => this._updateModelValue(isUserInput));
    } else {
      this._updateModelValue(isUserInput);
    }
  }

  /** Checks whether a button toggle is selected. */
  _isSelected(toggle: TWNDButtonToggle)
  {
    return this._selectionModel && this._selectionModel.isSelected(toggle);
  }

  /** Determines whether a button toggle should be checked on init. */
  _isPrechecked(toggle: TWNDButtonToggle)
  {
    if (typeof this._rawValue === 'undefined') {
      return false;
    }

    if (this.multiple && Array.isArray(this._rawValue)) {
      return this._rawValue.some(value => toggle.value != null && value === toggle.value);
    }

    return toggle.value === this._rawValue;
  }

  /** Updates the selection state of the toggles in the group based on a value. */
  private _setSelectionByValue(value: any|any[])
  {
    this._rawValue = value;

    if (!this._buttonToggles) {
      return;
    }

    if (this.multiple && value) {
      if (!Array.isArray(value) && (typeof ngDevMode === 'undefined' || ngDevMode)) {
        throw Error('Value must be an array in multiple-selection mode.');
      }

      this._clearSelection();
      value.forEach((currentValue: any) => this._selectValue(currentValue));
    } else {
      this._clearSelection();
      this._selectValue(value);
    }
  }

  /** Clears the selected toggles. */
  private _clearSelection()
  {
    this._selectionModel.clear();
    this._buttonToggles.forEach(toggle => (toggle.checked = false));
  }

  /** Selects a value if there's a toggle that corresponds to it. */
  private _selectValue(value: any)
  {
    const correspondingOption = this._buttonToggles.find(toggle => {
      return toggle.value != null && toggle.value === value;
    });

    if (correspondingOption) {
      correspondingOption.checked = true;
      this._selectionModel.select(correspondingOption);
    }
  }

  /** Syncs up the group's value with the model and emits the change event. */
  private _updateModelValue(isUserInput: boolean)
  {
    // Only emit the change event for user input.
    if (isUserInput) {
      this._emitChangeEvent();
    }

    // Note: we emit this one no twndter whether it was a user interaction, because
    // it is used by Angular to sync up the two-way data binding.
    this.valueChange.emit(this.value);
  }

  static ngAcceptInputType_disabled: BooleanInput;
  static ngAcceptInputType_multiple: BooleanInput;
  static ngAcceptInputType_vertical: BooleanInput;
}

// Boilerplate for applying mixins to the TWNDButtonToggle class.
/** @docs-private */
const _TWNDButtonToggleBase = mixinDisableRipple(class {});

/** Single button inside of a toggle group. */
@Component({
  selector : 'twnd-button-toggle',
  templateUrl : 'toggle.html',
  encapsulation : ViewEncapsulation.None,
  exportAs : 'twndButtonToggle',
  changeDetection : ChangeDetectionStrategy.OnPush,
  inputs : [ 'disableRipple' ],
  host : {
          '[class.twnd-button-toggle-standalone]' : '!buttonToggleGroup',
          '[class.twnd-button-toggle-checked]' : 'checked',
          '[class.twnd-button-toggle-disabled]' : 'disabled',
          '[class.twnd-button-toggle-appearance-standard]' : 'appearance === "standard"',
          'class' : 'twnd-button-toggle',
          '[attr.aria-label]' : 'null',
          '[attr.aria-labelledby]' : 'null',
          '[attr.id]' : 'id',
          '[attr.name]' : 'null',
          '(focus)' : 'focus()',
          'role' : 'presentation',
          },
})
export class TWNDButtonToggle extends _TWNDButtonToggleBase implements OnInit, AfterViewInit,
                                                                       CanDisableRipple, OnDestroy
{
  private _isSingleSelector = false;
  private _checked = false;

  /**
   * Attached to the aria-label attribute of the host element. In most cases, aria-labelledby will
   * take precedence so this may be omitted.
   */
  @Input('aria-label') ariaLabel: string;

  /**
   * Users can specify the `aria-labelledby` attribute which will be forwarded to the input element
   */
  @Input('aria-labelledby') ariaLabelledby: string|null = null;

  /** Underlying native `button` element. */
  @ViewChild('button') _buttonElement: ElementRef<HTMLButtonElement>;

  /** The parent button toggle group (exclusive selection). Optional. */
  buttonToggleGroup: TWNDButtonToggleGroup;

  /** Unique ID for the underlying `button` element. */
  get buttonId(): string
  {
    return `${this.id}-button`;
  }

  /** The unique ID for this button toggle. */
  @Input() id: string;

  /** HTML's 'name' attribute used to group radios for unique selection. */
  @Input() name: string;

  /** TWNDButtonToggleGroup reads this to assign its own value. */
  @Input() value: any;

  /** Tabindex for the toggle. */
  @Input() tabIndex: number|null;

  /** The appearance style of the button. */
  @Input() get appearance(): TWNDButtonToggleAppearance
  {
    return this.buttonToggleGroup ? this.buttonToggleGroup.appearance : this._appearance;
  }
  set appearance(value: TWNDButtonToggleAppearance)
  {
    this._appearance = value;
  }
  private _appearance: TWNDButtonToggleAppearance;

  /** Whether the button is checked. */
  @Input() get checked(): boolean
  {
    return this.buttonToggleGroup ? this.buttonToggleGroup._isSelected(this) : this._checked;
  }
  set checked(value: boolean)
  {
    const newValue = coerceBooleanProperty(value);

    if (newValue !== this._checked) {
      this._checked = newValue;

      if (this.buttonToggleGroup) {
        this.buttonToggleGroup._syncButtonToggle(this, this._checked);
      }

      this._changeDetectorRef.markForCheck();
    }
  }

  /** Whether the button is disabled. */
  @Input() get disabled(): boolean
  {
    return this._disabled || (this.buttonToggleGroup && this.buttonToggleGroup.disabled);
  }
  set disabled(value: boolean)
  {
    this._disabled = coerceBooleanProperty(value);
  }
  private _disabled: boolean = false;

  /** Event emitted when the group value changes. */
  @Output()
  readonly change:
    EventEmitter<TWNDButtonToggleChange> = new EventEmitter<TWNDButtonToggleChange>();

  constructor(
    @Optional() @Inject(TWND_BUTTON_TOGGLE_GROUP) toggleGroup: TWNDButtonToggleGroup,
    private _changeDetectorRef: ChangeDetectorRef,
    private _elementRef: ElementRef<HTMLElement>,
    private _focusMonitor: FocusMonitor,
    @Attribute('tabindex') defaultTabIndex: string,
    @Optional() @Inject(TWND_BUTTON_TOGGLE_DEFAULT_OPTIONS) defaultOptions?:
      TWNDButtonToggleDefaultOptions,
  )
  {
    super();

    const parsedTabIndex = Number(defaultTabIndex);
    this.tabIndex = parsedTabIndex || parsedTabIndex === 0 ? parsedTabIndex : null;
    this.buttonToggleGroup = toggleGroup;
    this.appearance = defaultOptions && defaultOptions.appearance ? defaultOptions.appearance :
                                                                    'standard';
  }

  ngOnInit()
  {
    const group = this.buttonToggleGroup;
    this._isSingleSelector = group && !group.multiple;
    this.id = this.id || `twnd-button-toggle-${uniqueIdCounter++}`;

    if (this._isSingleSelector) {
      this.name = group.name;
    }

    if (group) {
      if (group._isPrechecked(this)) {
        this.checked = true;
      } else if (group._isSelected(this) !== this._checked) {
        // As as side effect of the circular dependency between the toggle group and the button,
        // we may end up in a state where the button is supposed to be checked on init, but it
        // isn't, because the checked value was assigned too early. This can happen when Ivy
        // assigns the static input value before the `ngOnInit` has run.
        group._syncButtonToggle(this, this._checked);
      }
    }
  }

  ngAfterViewInit()
  {
    this._focusMonitor.monitor(this._elementRef, true);
  }

  ngOnDestroy()
  {
    const group = this.buttonToggleGroup;

    this._focusMonitor.stopMonitoring(this._elementRef);

    // Remove the toggle from the selection once it's destroyed. Needs to happen
    // on the next tick in order to avoid "changed after checked" errors.
    if (group && group._isSelected(this)) {
      group._syncButtonToggle(this, false, false, true);
    }
  }

  /** Focuses the button. */
  focus(options?: FocusOptions): void
  {
    this._buttonElement.nativeElement.focus(options);
  }

  /** Checks the button toggle due to an interaction with the underlying native button. */
  _onButtonClick()
  {
    const newChecked = this._isSingleSelector ? true : !this._checked;

    if (newChecked !== this._checked) {
      this._checked = newChecked;
      if (this.buttonToggleGroup) {
        this.buttonToggleGroup._syncButtonToggle(this, this._checked, true);
        this.buttonToggleGroup._onTouched();
      }
    }
    // Emit a change event when it's the single selector
    this.change.emit(new TWNDButtonToggleChange(this, this.value));
  }

  /**
   * Marks the button toggle as needing checking for change detection.
   * This method is exposed because the parent button toggle group will directly
   * update bound properties of the radio button.
   */
  _markForCheck()
  {
    // When the group value changes, the button will not be notified.
    // Use `markForCheck` to explicit update button toggle's status.
    this._changeDetectorRef.markForCheck();
  }

  static ngAcceptInputType_checked: BooleanInput;
  static ngAcceptInputType_disabled: BooleanInput;
  static ngAcceptInputType_vertical: BooleanInput;
  static ngAcceptInputType_multiple: BooleanInput;
  static ngAcceptInputType_disableRipple: BooleanInput;
}