swimlane/ngx-ui

View on GitHub
projects/swimlane/ngx-ui/src/lib/components/radiobutton/radiobutton-group.component.ts

Summary

Maintainability
B
5 hrs
Test Coverage
import {
  Component,
  Input,
  EventEmitter,
  Output,
  forwardRef,
  ViewEncapsulation,
  ContentChildren,
  OnDestroy,
  AfterContentInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  OnChanges,
  HostListener,
  HostBinding
} from '@angular/core';
import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

import { RadioButtonComponent } from './radiobutton.component';
import { KeyboardKeys } from '../../enums/keyboard-keys.enum';
import { coerceBooleanProperty, coerceNumberProperty } from '@angular/cdk/coercion';

import type { QueryList } from '@angular/core';

const RADIOGROUP_VALUE_ACCESSOR = {
  provide: NG_VALUE_ACCESSOR,
  useExisting: forwardRef(() => RadioButtonGroupComponent),
  multi: true
};

let nextId = 0;

function mod(v: number, n: number): number {
  return ((v % n) + n) % n;
}

@Component({
  exportAs: 'ngxRadiobuttonGroup',
  selector: 'ngx-radiobutton-group',
  providers: [RADIOGROUP_VALUE_ACCESSOR],
  template: ' <ng-content></ng-content> ',
  styleUrls: ['./radiobutton.component.scss'],
  host: {
    class: 'ngx-radiobutton-group',
    '[class.disabled]': 'disabled'
  },
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class RadioButtonGroupComponent implements ControlValueAccessor, OnDestroy, OnChanges, AfterContentInit {
  readonly UNIQUE_ID = `ngx-radio-group-${++nextId}`;

  @Input() id: string = this.UNIQUE_ID;

  @Input()
  get disabled() {
    return this._disabled;
  }
  set disabled(val: boolean) {
    this._disabled = coerceBooleanProperty(val);
    this._updateRadioDisabledState();
  }

  @Input()
  get value(): any {
    return this._value;
  }
  set value(value) {
    if (this._value !== value) {
      this._value = value;
      this.update();
      this.onChangeCallback(this._value);
    }
  }

  @Input()
  get name() {
    return this._name;
  }
  set name(name: string) {
    if (this._name !== name) {
      this._name = name;
      this._updateRadioButtonNames();
    }
  }

  @Input()
  get focusIndex() {
    return this._focusIndex;
  }
  set focusIndex(val: number) {
    this._focusIndex = coerceNumberProperty(val);
    this.focusOn(this._focusIndex);
  }

  @HostBinding('attr.tabindex')
  @Input()
  get tabIndex() {
    return this.disabled ? -1 : this._tabIndex;
  }
  set tabIndex(val: number) {
    this._tabIndex = coerceNumberProperty(val);
  }

  @Output() change = new EventEmitter<boolean>();
  @Output() blur = new EventEmitter<Event>();
  @Output() focus = new EventEmitter<FocusEvent>();

  @ContentChildren(forwardRef(() => RadioButtonComponent), { descendants: true })
  readonly _radios: QueryList<RadioButtonComponent>;

  get selected(): RadioButtonComponent {
    return this._selected;
  }

  private _name: string = this.UNIQUE_ID;
  private _value = false;
  private _disabled = false;
  private _selected: RadioButtonComponent;
  private _focusIndex = -1;
  private _tabIndex = 0;
  private _destroy$ = new Subject<void>();

  constructor(private readonly _cdr: ChangeDetectorRef) {}

  ngAfterContentInit() {
    this.subscribeToRadios();

    /* istanbul ignore else */
    if (this._radios) {
      this._radios.changes.subscribe(this.subscribeToRadios.bind(this));
    }

    this.update();
  }

  ngOnDestroy() {
    this._destroy$.next();
    this._destroy$.complete();
  }

  ngOnChanges() {
    this.update();
  }

  @HostListener('focus')
  onFocus() {
    if (this.selected) {
      // Moves keyboard focus to the checked radio button in a radiogroup.
      this.focusIndex = this._radios.toArray().indexOf(this.selected);
    } else {
      // If no radio button is checked, focus moves to the first radio button in the group.
      this.focusFirst();
    }
  }

  @HostListener('keydown', ['$event'])
  onKeyUp(ev: KeyboardEvent) {
    switch (ev.code) {
      case KeyboardKeys.ARROW_LEFT:
      case KeyboardKeys.ARROW_UP:
        ev.stopPropagation();
        ev.preventDefault();
        this.focusIn(-1); // Moves focus to previous radio button in the group.
        this.selectIndex(this.focusIndex); // Selects the radio button in the group.
        break;
      case KeyboardKeys.ARROW_RIGHT:
      case KeyboardKeys.ARROW_DOWN:
        ev.stopPropagation();
        ev.preventDefault();
        this.focusIn(1); // Moves focus to next radio button in the group.
        this.selectIndex(this.focusIndex); // Selects the radio button in the group.
        break;
    }
  }

  subscribeToRadios(): void {
    this._destroy$.next();

    /* istanbul ignore else */
    if (this._radios) {
      this._radios.map(radio => {
        radio.change.pipe(takeUntil(this._destroy$)).subscribe(this.onRadioSelected.bind(this));
      });
    }

    this.update();
  }

  onRadioSelected(value: string) {
    if (this.value !== value) {
      setTimeout(() => {
        this.value = value;
      });
    }
  }

  writeValue(value: any): void {
    this.value = value;
    this.update();
  }

  registerOnChange(fn: any): void {
    this.onChangeCallback = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouchedCallback = fn;
  }

  onChangeCallback(_: any) {
    // placeholder
  }

  /* istanbul ignore next */
  onTouchedCallback() {
    // placeholder
  }

  focusFirst() {
    if (!this.disabled && this._radios) {
      const len = this._radios.length;
      for (let i = 0; i < len; i++) {
        if (!this._radios.get(i).disabled) {
          this.focusIndex = i;
          break;
        }
      }
    }
  }

  setDisabledState(isDisabled: boolean): void {
    this.disabled = coerceBooleanProperty(isDisabled);
  }

  private selectIndex(index: number) {
    if (!this.disabled && this.focusIndex > -1) {
      this.value = this._radios.get(index).value;
    }
  }

  private focusOn(index: number) {
    if (!this.disabled) {
      this._radios.get(index).focusElement();
    }
  }

  /**
   * Moves focus to next radio button in the group.
   * +1 is next radio button, -1 is previous radio button.
   */
  private focusIn(dir: 1 | -1) {
    if (!this.disabled && this._radios) {
      const len = this._radios.length;
      for (let i = 1; i < len; i++) {
        const ii = mod(this.focusIndex + dir * i, len);
        if (!this._radios.get(ii).disabled) {
          this.focusIndex = ii;
          return;
        }
      }
    }
  }

  private update() {
    this._updateSelectedRadioFromValue();
    this._updateRadioDisabledState();
    this._cdr.markForCheck();
  }

  private _updateRadioButtonNames(): void {
    if (this._radios) {
      this._radios.forEach(radio => {
        radio.name = this.name;
      });
    }
  }

  private _updateSelectedRadioFromValue(): void {
    /* istanbul ignore else */
    if (this._radios) {
      this._radios.forEach(radio => {
        radio.checked = this.value === radio.value;
        radio.isInGroup = true;

        if (radio.checked) {
          this._selected = radio;
        }
      });
    }
  }

  private _updateRadioDisabledState(): void {
    /* istanbul ignore else */
    if (this._radios) {
      this._radios.forEach(radio => {
        radio.groupDisabled = this.disabled;
      });
    }
  }
}