white-ui-kit
Version:
Модуль компонентов UI Имя модуля: `white-ui-kit`
365 lines (318 loc) • 10.5 kB
text/typescript
import {
Component,
forwardRef,
Injector,
EventEmitter,
HostBinding,
ViewChild,
Input,
OnInit,
AfterViewInit,
OnDestroy,
HostListener,
ElementRef,
} from '@angular/core';
import {
ControlValueAccessor,
NG_VALUE_ACCESSOR,
FormControl,
NgControl,
} from '@angular/forms';
import {
CdkOverlayOrigin,
OverlayRef,
ConnectedPosition,
} from '@angular/cdk/overlay';
import {
Subscription,
Observable,
of,
} from 'rxjs';
import {
debounceTime,
switchMap,
finalize,
tap,
filter,
} from 'rxjs/operators';
// Сенсор
import { ResizeSensor } from 'css-element-queries';
// Компоненты
import { HitSelectorPopupComponent } from '../hit-selector-popup/hit-selector-popup.component';
// Сервисы
import { HitOverlayService } from '../../services';
// Хелпер
import { MathHelper } from '../../helpers';
// Тип делегата
type InvokeListLoad = (search: string) => Observable<any[]>;
// Селектор
export class HitSelectorComponent implements
OnInit, AfterViewInit, OnDestroy, ControlValueAccessor {
constructor(
private readonly el: ElementRef,
private readonly injector: Injector,
private readonly overlayService: HitOverlayService,
) {
this.CONFIG.closing.subscribe(() => {
this.filterControl.patchValue(this.selected && this.selected.title);
});
this.DATA.select.subscribe((selected: any) => {
this.selected = selected;
this.focusInput();
this.close();
});
}
// Флаг наличия ошибки
public hasError: boolean;
public get attrHasError(): string {
return this.hasError ? 'has-error' : null;
}
// Ссылка на компонент
private readonly popupOrigin: CdkOverlayOrigin;
// Заглушка поля
public placeholder: string;
// Заглушка
private readonly EMPTY_INVOKE: InvokeListLoad = () => of([]);
// Делегат вызова загрузки списка
private _invokeListLoad: InvokeListLoad;
public get invokeListLoad(): InvokeListLoad {
return this._invokeListLoad || this.EMPTY_INVOKE;
}
public set invokeListLoad(invoke: InvokeListLoad) {
this._invokeListLoad = invoke;
}
// Вызовем когда значение изменится
private onChange: (value) => void;
// Вызовем при любом дествии пользователя с контроллом
private onTouched: () => void;
// Флаг, отключено
private _disabled: boolean = false;
public get disabled(): boolean {
return this._disabled;
}
public set disabled(value: boolean) {
this._disabled = value;
if (value) {
this.filterControl.disable();
} else {
this.filterControl.enable();
}
}
// Контрол
private control: FormControl;
// Подписка на контрол
private subscription: Subscription;
// Контрол фильтрации
public filterControl = new FormControl();
// Подписка на контрол фильтрации
private filterSubscription: Subscription;
// Выбранный элемент
public _selected: any;
public get selected(): any {
return this._selected;
}
public set selected(value: any) {
this._selected = value;
this.DATA.selected = value;
this.filterControl.patchValue(value && value.title);
if (this.onChange) {
this.onChange(value);
}
}
// Сенсор изменения размера
private resizeSensor: any;
// Конфигурация всплывающего окна
private readonly CONFIG = {
elementRef: null,
overlayComponent: HitSelectorPopupComponent,
positions: [
{
overlayX: 'start', overlayY: 'top',
originX: 'start', originY: 'bottom',
},
{
overlayX: 'start', overlayY: 'bottom',
originX: 'start', originY: 'top',
},
] as ConnectedPosition[],
canClose: false,
closing: new EventEmitter<void>(),
};
// Данные всплывающего окна
private readonly DATA = {
width: 0, // ширина окна
isLoaded: false, // флаг, список загружен
list: [], // список
selected: null, // выбранный элемент из списка
select: new EventEmitter<any>(), // событие выбора
};
// Ссылка на всплывающее окно
private overlayRef: OverlayRef;
// Флаг, окно открыто
private get isPopupOpened(): boolean {
return !!(this.overlayRef && this.overlayRef.hasAttached());
}
// Флаг, фокус в контроле
private get isFocusOrigin(): boolean {
return MathHelper.hasParent(this.el.nativeElement, document.activeElement);
}
// Флаг, фокус в окне
private get isFocusOverlay(): boolean {
return MathHelper.hasParent(
this.overlayRef && this.overlayRef.overlayElement,
document.activeElement,
);
}
// --------------------------------------------------------------------------
// Добавить сенсор
private addSensor(): void {
if (this.el && this.el.nativeElement) {
this.resizeSensor = new ResizeSensor(
this.el.nativeElement,
this.updateWidth.bind(this),
);
this.updateWidth();
}
}
// Удалить сенсор
private removeSensor(): void {
if (this.resizeSensor) { this.resizeSensor.detach(); }
}
// Обновить ширину
private updateWidth(): void {
this.DATA.width =
this.el &&
this.el.nativeElement &&
this.el.nativeElement.offsetWidth;
}
// --------------------------------------------------------------------------
// Выбрать первый в списке
public selectFirst(event: KeyboardEvent): void {
if (
!this.disabled &&
this.isPopupOpened &&
this.DATA.isLoaded &&
this.DATA.list[0]
) {
event.stopPropagation();
this.selected = this.DATA.list[0];
this.focusInput();
this.close();
}
}
// Очистить
public clear(): void {
if (!this.disabled) {
this.selected = null;
}
}
// Обработчик смены фокуса
private onFocus(): void {
if (this.isFocusOrigin) {
this.focusInput();
this.open();
}
}
// Обработчик потери фокуса
private onBlur(): void {
if (this.isPopupOpened) {
setTimeout(() => {
if (!this.isFocusOverlay && !this.isFocusOrigin) {
this.close();
}
});
}
}
// Установить фокус на поле фильтрации
private focusInput(): void {
this.el.nativeElement.getElementsByTagName('input')[0].focus();
}
// --------------------------------------------------------------------------
// Открыть окно
public open(): void {
if (!this.isPopupOpened && !this.disabled) {
this.overlayRef = this.overlayService.openContext(this.CONFIG, this.DATA);
this.filterControl.patchValue(this.filterControl.value);
}
}
// Закрыть окно
public close(): void {
this.CONFIG.closing.emit();
if (this.overlayRef) { this.overlayRef.dispose(); }
}
// --------------------------------------------------------------------------
// Реакция на клик хост-элемента
public onClick(): void {
if (this.onTouched) { this.onTouched(); }
}
// Вызовет форма, если значение изменилось извне
public writeValue(selected: any): void {
this.selected = selected;
}
// Сохраняем обратный вызов для изменений
public registerOnChange(fn: any): void {
this.onChange = fn;
}
// Сохраняем обратный вызов для "касаний"
public registerOnTouched(fn: any): void {
this.onTouched = fn;
}
// Установка состояния disabled
public setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
// --------------------------------------------------------------------------
// Инициализация
public ngOnInit(): void {
document.addEventListener('focus', this.onFocus.bind(this), true);
document.addEventListener('blur', this.onBlur.bind(this), true);
this.addSensor();
this.CONFIG.elementRef = this.popupOrigin.elementRef;
this.filterSubscription = this.filterControl
.valueChanges
.pipe(
tap(() => {
this.onFocus();
this.DATA.isLoaded = false;
}),
debounceTime(300),
filter(() => this.isPopupOpened),
switchMap(search => this.invokeListLoad(search)),
finalize(() => { this.DATA.isLoaded = true; }),
)
.subscribe((list: any[]) => {
this.DATA.list = list;
this.DATA.isLoaded = true;
});
}
// Инициализация завершена
public ngAfterViewInit(): void {
const ngControl: NgControl = this.injector.get(NgControl, null);
if (ngControl) {
this.control = ngControl.control as FormControl;
this.subscription = this.control.statusChanges
.subscribe(() => {
this.hasError = !this.control.pristine && !this.control.valid;
});
}
}
// Уничтожение
public ngOnDestroy(): void {
if (this.subscription) { this.subscription.unsubscribe(); }
if (this.filterSubscription) { this.filterSubscription.unsubscribe(); }
this.removeSensor();
this.close();
document.removeEventListener('focus', this.onFocus.bind(this), true);
document.removeEventListener('blur', this.onBlur.bind(this), true);
}
}