import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, TemplateRef, ViewChild, ViewEncapsulation, Output, EventEmitter, HostBinding } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { UpdateHostClassService as UpdateCls } from './services/update-host-class.service'; import { isNotNil } from './utils/check'; import { reqAnimFrame } from '@farris/ui-common'; import { TimeValueAccessorDirective } from './time-value-accessor.directive'; import { TimeHolder } from './time-holder'; import { convertToDate, convertToStr } from './utils/convert'; function makeRange( length: number, step: number = 1, start: number = 0 ): number[] { step = Math.ceil(step); return new Array(Math.ceil(length / step)) .fill(0) .map((_, i) => (i + start) * step); } export type TimePickerUnit = 'hour' | 'minute' | 'second' | '12-hour'; @Component({ encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, selector: 'time-picker-panel', exportAs: 'timePickerPanel', templateUrl: './time-picker-panel.component.html', // styleUrls: ['./style/index.scss'], providers: [ UpdateCls, { provide: NG_VALUE_ACCESSOR, useExisting: TimePickerPanelComponent, multi: true } ] }) export class TimePickerPanelComponent implements ControlValueAccessor, OnInit, OnDestroy, OnChanges { private _hourStep = 1; private _minuteStep = 1; private _secondStep = 1; private unsubscribe$ = new Subject(); private onChange: (value: string) => void; private onTouch: () => void; private _format = 'HH:mm:ss'; private _disabledHours: () => number[]; private _disabledMinutes: (hour: number) => number[]; private _disabledSeconds: (hour: number, minute: number) => number[]; private _defaultOpenValue = new Date(); private _opened = false; private _allowEmpty = true; private _value = ''; prefixCls = 'time-picker-panel'; time = new TimeHolder(); hourEnabled = true; minuteEnabled = true; secondEnabled = true; enabledColumns = 3; hourRange: ReadonlyArray<{ index: number; disabled: boolean }>; minuteRange: ReadonlyArray<{ index: number; disabled: boolean }>; secondRange: ReadonlyArray<{ index: number; disabled: boolean }>; use12HoursRange: ReadonlyArray<{ index: number; value: string }>; @ViewChild(TimeValueAccessorDirective) timeValueAccessorDirective: TimeValueAccessorDirective; @ViewChild('hourListElement') hourListElement: ElementRef; @ViewChild('minuteListElement') minuteListElement: ElementRef; @ViewChild('secondListElement') secondListElement: ElementRef; @ViewChild('use12HoursListElement') use12HoursListElement: ElementRef; @Input() inDatePicker = false; // If inside a date-picker, more diff works need to be done @Input() addOn: TemplateRef; @Input() hideDisabledOptions = false; @Input() clearText: string; @Input() placeholder: string; @Input() set use12Hours(value: boolean) { this._use12Hours = !!value; if (!this._use12Hours) { this.format = this.format ? (this.format + '').replace(/h/g, 'H') : ''; } else { this.format = this.format ? (this.format + '').replace(/H/g, 'h') : ''; } } get use12Hours(): boolean { return this._use12Hours; } @Input() set allowEmpty(value: boolean) { if (isNotNil(value)) { this._allowEmpty = value; } } get allowEmpty(): boolean { return this._allowEmpty; } @Input() set opened(value: boolean) { this._opened = value; if (this.opened) { this.initPosition(); this.selectInputRange(); } } get opened(): boolean { return this._opened; } @Input() set defaultOpenValue(value: Date) { if (isNotNil(value)) { this._defaultOpenValue = value; this.time.setDefaultOpenValue(this.defaultOpenValue); } } get defaultOpenValue(): Date { return this._defaultOpenValue; } @Input() set disabledHours(value: () => number[]) { this._disabledHours = value; if (this._disabledHours) { this.buildHours(); } } get disabledHours(): () => number[] { return this._disabledHours; } @Input() set disabledMinutes(value: (hour: number) => number[]) { if (isNotNil(value)) { this._disabledMinutes = value; this.buildMinutes(); } } get disabledMinutes(): (hour: number) => number[] { return this._disabledMinutes; } @Input() set disabledSeconds(value: (hour: number, minute: number) => number[]) { if (isNotNil(value)) { this._disabledSeconds = value; this.buildSeconds(); } } get disabledSeconds(): (hour: number, minute: number) => number[] { return this._disabledSeconds; } panelWidth = 0; @Input() set format(value: string) { if (isNotNil(value)) { this._format = value; this.enabledColumns = 0; const charSet = new Set(value); this.hourEnabled = charSet.has('H') || charSet.has('h'); this.minuteEnabled = charSet.has('m'); this.secondEnabled = charSet.has('s'); if (this.hourEnabled) { this.enabledColumns++; } if (this.minuteEnabled) { this.enabledColumns++; } if (this.secondEnabled) { this.enabledColumns++; } if (this.use12Hours) { this.enabledColumns++; this.build12Hours(); } this.panelWidth = this.enabledColumns * 66; this.cdr.markForCheck(); } } get format(): string { return this._format; } @Input() set hourStep(value: number) { if (isNotNil(value)) { this._hourStep = value; this.time.hourStep = value; this.buildHours(); } } get hourStep(): number { return this._hourStep; } @Input() set minuteStep(value: number) { if (isNotNil(value)) { this._minuteStep = value; this.time.minuteStep = value; this.buildMinutes(); } } get minuteStep(): number { return this._minuteStep; } @Input() set secondStep(value: number) { if (isNotNil(value)) { this._secondStep = value; this.time.secondStep = value; this.buildSeconds(); } } get secondStep(): number { return this._secondStep; } @Input() set value(value: string) { if (isNotNil(value)) { this.time.text = value; const _value = convertToDate(this.time.text, this.format); this.time.setValue(_value, this.use12Hours); this.buildTimes(); this.cdr.markForCheck(); } else { this.time.setUse12Hours(this.use12Hours); } } get value(): string { this._value = this.time.text; return this._value; } @Output() valueChange = new EventEmitter(); private _use12Hours = false; selectInputRange(): void { // setTimeout(() => { // if (this.timeValueAccessorDirective) { // this.timeValueAccessorDirective.setRange(); // } // }); if (this.timeValueAccessorDirective) { this.timeValueAccessorDirective.setRange(); } } buildHours(): void { let hourRanges = 24; let disabledHours = this.disabledHours && this.disabledHours(); let startIndex = 0; if (this.use12Hours) { hourRanges = 12; if (disabledHours) { if (this.time.selected12Hours === 'PM') { /** * Filter and transform hours which greater or equal to 12 * [0, 1, 2, ..., 12, 13, 14, 15, ..., 23] => [12, 1, 2, 3, ..., 11] */ disabledHours = disabledHours .filter(i => i >= 12) .map(i => (i > 12 ? i - 12 : i)); } else { /** * Filter and transform hours which less than 12 * [0, 1, 2,..., 12, 13, 14, 15, ...23] => [12, 1, 2, 3, ..., 11] */ disabledHours = disabledHours .filter(i => i < 12 || i === 24) .map(i => (i === 24 || i === 0 ? 12 : i)); } } startIndex = 1; } this.hourRange = makeRange(hourRanges, this.hourStep, startIndex).map( r => { return { index: r, disabled: this.disabledHours && disabledHours.indexOf(r) !== -1 }; } ); if ( this.use12Hours && this.hourRange[this.hourRange.length - 1].index === 12 ) { const temp = [...this.hourRange]; temp.unshift(temp[temp.length - 1]); temp.splice(temp.length - 1, 1); this.hourRange = temp; } // 移除禁用的值 this.hourRange = this.hourRange.filter( n => { return !(this.hideDisabledOptions && n.disabled); }); } buildMinutes(): void { this.minuteRange = makeRange(60, this.minuteStep).map(r => { return { index: r, disabled: this.disabledMinutes && this.disabledMinutes(this.time.hours!).indexOf(r) !== -1 }; }); // 移除禁用的值 this.minuteRange = this.minuteRange.filter( n => { return !(this.hideDisabledOptions && n.disabled); }); } buildSeconds(): void { this.secondRange = makeRange(60, this.secondStep).map(r => { return { index: r, disabled: this.disabledSeconds && this.disabledSeconds( this.time.hours!, this.time.minutes! ).indexOf(r) !== -1 }; }); // 移除禁用的值 this.secondRange = this.secondRange.filter( n => { return !(this.hideDisabledOptions && n.disabled); }); } build12Hours(): void { const isUpperForamt = this._format.includes('A'); this.use12HoursRange = [ { index: 0, value: isUpperForamt ? 'AM' : 'am' }, { index: 1, value: isUpperForamt ? 'PM' : 'pm' } ]; // 移除禁用的值 this.use12HoursRange = this.use12HoursRange.filter( n => { return !this.hideDisabledOptions; }); } buildTimes(): void { this.buildHours(); this.buildMinutes(); this.buildSeconds(); this.build12Hours(); } selectHour( event: MouseEvent, hour: { index: number; disabled: boolean } ): void { event.stopPropagation(); this.time.setHours(hour.index, hour.disabled); this.scrollToSelected( this.hourListElement.nativeElement, hour.index, 120, 'hour' ); if (this._disabledMinutes) { this.buildMinutes(); } if (this._disabledSeconds || this._disabledMinutes) { this.buildSeconds(); } } selectMinute( event: MouseEvent, minute: { index: number; disabled: boolean } ): void { event.stopPropagation(); this.time.setMinutes(minute.index, minute.disabled); this.scrollToSelected( this.minuteListElement.nativeElement, minute.index, 120, 'minute' ); if (this._disabledSeconds) { this.buildSeconds(); } } selectSecond( event: MouseEvent, second: { index: number; disabled: boolean } ): void { event.stopPropagation(); this.time.setSeconds(second.index, second.disabled); this.scrollToSelected( this.secondListElement.nativeElement, second.index, 120, 'second' ); } select12Hours( event: MouseEvent, value: { index: number; value: string } ): void { event.stopPropagation(); this.time.selected12Hours = value.value; if (this._disabledHours) { this.buildHours(); } if (this._disabledMinutes) { this.buildMinutes(); } if (this._disabledSeconds) { this.buildSeconds(); } this.scrollToSelected( this.use12HoursListElement.nativeElement, value.index, 120, '12-hour' ); } scrollToSelected( instance: HTMLElement, index: number, duration: number = 0, unit: TimePickerUnit ): void { const transIndex = this.translateIndex(index, unit); const currentOption = (instance.children[0].children[transIndex] || instance.children[0].children[0]) as HTMLElement; this.scrollTo(instance, currentOption.offsetTop, duration); } translateIndex(index: number, unit: TimePickerUnit): number { if (unit === 'hour') { const disabledHours = this.disabledHours && this.disabledHours(); return this.calcIndex( disabledHours, this.hourRange.map(item => item.index).indexOf(index) ); } else if (unit === 'minute') { const disabledMinutes = this.disabledMinutes && this.disabledMinutes(this.time.hours!); return this.calcIndex( disabledMinutes, this.minuteRange.map(item => item.index).indexOf(index) ); } else if (unit === 'second') { // second const disabledSeconds = this.disabledSeconds && this.disabledSeconds(this.time.hours!, this.time.minutes!); return this.calcIndex( disabledSeconds, this.secondRange.map(item => item.index).indexOf(index) ); } else { // 12-hour return this.calcIndex( [], this.use12HoursRange.map(item => item.index).indexOf(index) ); } } scrollTo(element: HTMLElement, to: number, duration: number): void { if (duration <= 0) { element.scrollTop = to; return; } const difference = to - element.scrollTop; const perTick = (difference / duration) * 10; reqAnimFrame(() => { element.scrollTop = element.scrollTop + perTick; if (element.scrollTop === to) { return; } this.scrollTo(element, to, duration - 10); }); } calcIndex(array: number[], index: number): number { if (array && array.length && this.hideDisabledOptions) { return ( index - array.reduce((pre, value) => { return pre + (value < index ? 1 : 0); }, 0) ); } else { return index; } } protected changed(): void { if (this.onChange) { this.onChange(this.time.text); } } protected touched(): void { if (this.onTouch) { this.onTouch(); } } private setClassMap(): void { this.updateCls.updateHostClass(this.element.nativeElement, { [`${this.prefixCls}`]: true, [`${this.prefixCls}-column-${this.enabledColumns}`]: this .inDatePicker ? false : true, [`${this.prefixCls}-narrow`]: this.enabledColumns < 3, [`${this.prefixCls}-placement-bottomLeft`]: this.inDatePicker ? false : true }); } isSelectedHour(hour: { index: number; disabled: boolean }): boolean { return ( hour.index === this.time.viewHours || (!isNotNil(this.time.viewHours) && hour.index === this.time.defaultViewHours) ); } isSelectedMinute(minute: { index: number; disabled: boolean }): boolean { return ( minute.index === this.time.minutes || (!isNotNil(this.time.minutes) && minute.index === this.time.defaultMinutes) ); } isSelectedSecond(second: { index: number; disabled: boolean }): boolean { return ( second.index === this.time.seconds || (!isNotNil(this.time.seconds) && second.index === this.time.defaultSeconds) ); } isSelected12Hours(value: { index: number; value: string }): boolean { return ( value.value.toUpperCase() === this.time.selected12Hours || (!isNotNil(this.time.selected12Hours) && value.value.toUpperCase() === this.time.default12Hours) ); } initPosition(): void { // setTimeout(() => { if (this.hourEnabled && this.hourListElement) { if (isNotNil(this.time.viewHours)) { this.scrollToSelected( this.hourListElement.nativeElement, this.time.viewHours!, 0, 'hour' ); } else { this.scrollToSelected( this.hourListElement.nativeElement, this.time.defaultViewHours, 0, 'hour' ); } } if (this.minuteEnabled && this.minuteListElement) { if (isNotNil(this.time.minutes)) { this.scrollToSelected( this.minuteListElement.nativeElement, this.time.minutes!, 0, 'minute' ); } else { this.scrollToSelected( this.minuteListElement.nativeElement, this.time.defaultMinutes, 0, 'minute' ); } } if (this.secondEnabled && this.secondListElement) { if (isNotNil(this.time.seconds)) { this.scrollToSelected( this.secondListElement.nativeElement, this.time.seconds!, 0, 'second' ); } else { this.scrollToSelected( this.secondListElement.nativeElement, this.time.defaultSeconds, 0, 'second' ); } } if (this.use12Hours && this.use12HoursListElement) { const selectedHours = isNotNil(this.time.selected12Hours) ? this.time.selected12Hours : this.time.default12Hours; const index = selectedHours === 'AM' ? 0 : 1; this.scrollToSelected( this.use12HoursListElement.nativeElement, index, 0, '12-hour' ); } // }); } constructor( public element: ElementRef, private updateCls: UpdateCls, public cdr: ChangeDetectorRef ) { } ngOnInit(): void { if (this.inDatePicker) { this.prefixCls = 'calendar-time-picker'; } this.time.changes.pipe(takeUntil(this.unsubscribe$)).subscribe(() => { const dateObj = { hours: this.time.hours, minutes: this.time.minutes, seconds: this.time.seconds }; this.time.text = convertToStr( this.time.value, this.format, this.time.selected12Hours ); this.changed(); this.touched(); this.valueChange.emit(this.time.text); }); this.buildTimes(); this.setClassMap(); } ngAfterViewInit(): void { if (this.opened) { this.initPosition(); } } ngOnDestroy(): void { this.unsubscribe$.next(); this.unsubscribe$.complete(); } ngOnChanges(changes: SimpleChanges): void { const { use12Hours } = changes; if ( use12Hours && !use12Hours.previousValue && use12Hours.currentValue ) { this.build12Hours(); this.enabledColumns++; } } writeValue(value: string): void { this.time.text = value; const _value = convertToDate(this.time.text, this.format); this.time.setValue(_value, this.use12Hours); if (value && this.opened) { this.initPosition(); } this.buildTimes(); this.cdr.markForCheck(); } registerOnChange(fn: (value: string) => void): void { this.onChange = fn; } registerOnTouched(fn: () => void): void { this.onTouch = fn; } hmsCls(v, type) { let cls = ''; const selected = ` ${this.prefixCls}-select-option-selected `; const disabledC = ` ${this.prefixCls}-select-option-disabled `; let f = false; if (type === 'h') { f = this.isSelectedHour(v); } else if (type === 'm') { f = this.isSelectedMinute(v); } else if (type === 'r') { f = this.isSelected12Hours(v); } else { f = this.isSelectedSecond(v); } if (f) { cls += selected; } if (type === 'r') { return cls; } if (v.disabled) { cls += disabledC; } return cls; } trackByItems = (index, item) => { return index; } }