0
\$\begingroup\$

I would love some pointers in terms of clean code, performance and maybe edge cases that I missed.

Component Template

<div
  [formGroup]="parts"
  class="container"
  [attr.disabled]="isDisabled"
  [ngClass]="isDisabled ? 'disabled' : ''"
>
  <input
    #hours
    type="text"
    min="0"
    formControlName="hours"
    placeholder="0"
    (keypress)="numberOnly($event)"
    (input)="handleInput(parts.controls.hours, minutes)"
    (blur)="touched()"
    (keyup.enter)="autoFocusNext(parts.controls.hours, minutes)"
    (focus)="hours.select()"
    [size]="hoursLength"
    [hidden]="max < 60"
    [attr.disabled]="isDisabled"
    [ngStyle]="fadeOut(parts.value.hours)"
  />
  <span
    class="spacer"
    (click)="onSpacerClick()"
    [hidden]="max < 60"
    [ngStyle]="fadeOut(parts.value.hours)"
    [attr.disabled]="isDisabled"
    >h <b>-</b></span
  >
  <input
    #minutes
    class="minutes"
    type="text"
    formControlName="minutes"
    size="2"
    maxLength="2"
    placeholder="0"
    (keypress)="numberOnly($event)"
    (input)="handleInput(parts.controls.minutes)"
    (blur)="touched()"
    (keyup.backspace)="autoFocusPrev(parts.controls.minutes, hours)"
    (focus)="minutes.select()"
    [attr.disabled]="isDisabled"
    [ngStyle]="fadeOut(parts.value.minutes)"
  />
  <span [ngStyle]="fadeOut(parts.value.minutes)" (click)="onSpanClick()"
    >min</span
  >
</div>

Component Script

@Component({
  selector: 'duration-field',
  templateUrl: './duration-field.component.html',
  styleUrls: ['./duration-field.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => DurationFieldComponent),
      multi: true,
    },
  ],
})
export class DurationFieldComponent
  implements OnInit, AfterViewInit, ControlValueAccessor
{
  @Input()
  max: number;
  parts: FormGroup;
  changed = (value: any) => {};
  touched = () => {};
  isDisabled: boolean;

  @ViewChild('hours')
  private hoursInput: ElementRef;

  @ViewChild('minutes')
  private minutesInput: ElementRef;

  constructor(private fb: FormBuilder, private focusMonitor: FocusMonitor) {}

  ngOnInit() {
    this.parts = this.fb.group(
      {
        hours: [
          '',
          [
            Validators.min(0),
            Validators.max(this.max / 60),
            Validators.pattern(/^[0-9]\d*$/),
          ],
        ],
        minutes: [
          '',
          [
            Validators.min(0),
            Validators.max(59),
            Validators.pattern(/^[0-9]\d*$/),
          ],
        ],
      },
      {
        validators: this.max
          ? [CustomValidators.durationValidator(this.max)]
          : [],
      }
    );
  }

  ngAfterViewInit() {
    fromEvent(this.hoursInput.nativeElement, 'keydown').subscribe(
      (event: KeyboardEvent) => {
        if (event.key === 'ArrowUp') {
          this.increment(true);
        } else if (event.key === 'ArrowDown') {
          this.decrement(true);
        }
      }
    );
    fromEvent(this.minutesInput.nativeElement, 'keydown').subscribe(
      (event: KeyboardEvent) => {
        if (event.key === 'ArrowUp') {
          this.increment();
        } else if (event.key === 'ArrowDown') {
          this.decrement();
        }
      }
    );
  }

  @Input()
  get value(): number | null {
    if (this.parts.valid) {
      const {
        value: { hours, minutes },
      } = this.parts;
      return hours ? +hours * 60 + +minutes : +minutes;
    }
    return null;
  }
  set value(duration: number | null) {
    let hours = '';
    let minutes = '';
    if (duration) {
      hours += Math.floor(duration / 60);
      minutes += duration % 60;
    }
    if (
      (this.parts.get('hours').value !== '0' &&
        this.parts.get('hours').value !== undefined) ||
      duration === 60
    ) {
      this.parts.get('hours').setValue(hours);
    } else {
      this.parts.get('hours').setValue(undefined);
    }
    this.parts.get('minutes').setValue(minutes);
    this.changed(this.value);
  }

  get hoursLength() {
    return this.max ? (this.max / 60).toString().length : 6;
  }

  writeValue(duration: number | null): void {
    this.value = duration;
  }
  registerOnChange(fn: any): void {
    this.changed = fn;
  }
  registerOnTouched(fn: any): void {
    this.touched = fn;
  }
  setDisabledState?(isDisabled: boolean): void {
    this.isDisabled = isDisabled;
  }

  onSpacerClick() {
    this.focusMonitor.focusVia(this.hoursInput, 'program');
  }

  onSpanClick() {
    this.focusMonitor.focusVia(this.minutesInput, 'program');
  }

  autoFocusNext(
    control: AbstractControl,
    nextElement?: HTMLInputElement
  ): void {
    if (!control.errors && nextElement) {
      this.focusMonitor.focusVia(nextElement, 'program');
    }
  }

  autoFocusPrev(control: AbstractControl, prevElement: HTMLInputElement): void {
    if (control.value.length < 1) {
      this.focusMonitor.focusVia(prevElement, 'program');
    }
  }

  handleInput(control: AbstractControl, nextElement?: HTMLInputElement): void {
    if (control.value.length > this.hoursLength - 1) {
      this.autoFocusNext(control, nextElement);
    }
    this.changed(this.value);
  }

  fadeOut(value) {
    if (!value) return { opacity: '.3' };
  }

  numberOnly(event: KeyboardEvent): boolean {
    return /[0-9]/.test(event.key);
  }

  private increment(isHours?: boolean) {
    const current = this.value;
    const max = this.max || 60000000;
    this.value =
      isHours && +current + 60 <= max
        ? +current + 60
        : +current + 1 <= max
        ? +current + 1
        : current;
  }

  private decrement(isHours?: boolean) {
    const current = this.value;
    this.value =
      isHours && current >= 60
        ? +current - 60
        : current >= 1
        ? +current - 1
        : current;
  }
}

Custom Validator

export class CustomValidators {
  static durationValidator(max?: number): ValidatorFn {
    return (form: AbstractControl): ValidationErrors | null => {
      if (!form.value['hours']) return null;
      let hours = form.value['hours'];
      let minutes = form.value['minutes'] ? form.value['minutes'] : 0;
      return +minutes + +hours * 60 <= max ? null : { big: true };
    };
  }
}

You can try out a working example on Stackblitz.

\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.