• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

atinc / ngx-tethys / bcb919fa-5eda-402a-973b-0eb61f10e54e

27 Dec 2023 10:05AM UTC coverage: 90.569% (+0.03%) from 90.538%
bcb919fa-5eda-402a-973b-0eb61f10e54e

Pull #3001

circleci

minlovehua
fix(cascader): update position when cascader resize #INFR-11114
Pull Request #3001: fix(cascader): update position when cascader resize #INFR-11114

5409 of 6630 branches covered (0.0%)

Branch coverage included in aggregate %.

13499 of 14247 relevant lines covered (94.75%)

978.06 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.5
/src/cascader/cascader.component.ts
1
import {
2
    EXPANDED_DROPDOWN_POSITIONS,
3
    InputBoolean,
4
    InputNumber,
5
    ScrollToService,
6
    TabIndexDisabledControlValueAccessorMixin,
7
    ThyClickDispatcher
8
} from 'ngx-tethys/core';
9
import { ThyEmptyComponent } from 'ngx-tethys/empty';
10
import { ThyIconComponent } from 'ngx-tethys/icon';
11
import { SelectControlSize, SelectOptionBase, ThySelectControlComponent } from 'ngx-tethys/shared';
12
import { coerceBooleanProperty, elementMatchClosest, isEmpty } from 'ngx-tethys/util';
13
import { BehaviorSubject, Observable, Subject, Subscription, timer } from 'rxjs';
14
import { debounceTime, distinctUntilChanged, filter, take, takeUntil } from 'rxjs/operators';
15
import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedOverlayPositionChange, ConnectionPositionPair } from '@angular/cdk/overlay';
16
import { NgClass, NgFor, NgIf, NgStyle, NgTemplateOutlet, isPlatformBrowser } from '@angular/common';
17
import {
18
    AfterContentInit,
19
    ChangeDetectorRef,
20
    Component,
21
    ElementRef,
22
    EventEmitter,
23
    forwardRef,
24
    HostListener,
1✔
25
    Inject,
26
    Input,
56✔
27
    NgZone,
56✔
28
    OnChanges,
56✔
29
    OnDestroy,
4✔
30
    OnInit,
31
    Output,
32
    PLATFORM_ID,
33
    QueryList,
3✔
34
    SimpleChanges,
3✔
35
    TemplateRef,
3✔
36
    ViewChild,
37
    ViewChildren
38
} from '@angular/core';
4✔
39
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
40
import { useHostRenderer } from '@tethys/cdk/dom';
41

34✔
42
import { ThyCascaderOptionComponent } from './cascader-li.component';
43
import { ThyCascaderSearchOptionComponent } from './cascader-search-option.component';
44
import { ThyCascaderExpandTrigger, ThyCascaderOption, ThyCascaderSearchOption, ThyCascaderTriggerType } from './types';
49✔
45
import { ThyCascaderService } from './cascader.service';
46
import { scaleYMotion } from 'ngx-tethys/core';
47

31✔
48
/**
31✔
49
 * 级联选择菜单
50
 * @name thy-cascader
51
 */
322✔
52
@Component({
53
    selector: 'thy-cascader,[thy-cascader]',
54
    templateUrl: './cascader.component.html',
31✔
55
    providers: [
31✔
56
        {
57
            provide: NG_VALUE_ACCESSOR,
58
            useExisting: forwardRef(() => ThyCascaderComponent),
98✔
59
            multi: true
60
        },
61
        ThyCascaderService
448✔
62
    ],
63
    host: {
64
        '[attr.tabindex]': `tabIndex`,
46✔
65
        '(focus)': 'onFocus($event)',
66
        '(blur)': 'onBlur($event)'
67
    },
32✔
68
    standalone: true,
69
    imports: [
70
        CdkOverlayOrigin,
45✔
71
        NgIf,
45✔
72
        ThySelectControlComponent,
73
        NgClass,
74
        NgTemplateOutlet,
2,676✔
75
        CdkConnectedOverlay,
76
        NgStyle,
77
        NgFor,
528✔
78
        ThyCascaderOptionComponent,
79
        ThyCascaderSearchOptionComponent,
80
        ThyEmptyComponent,
28✔
81
        ThyIconComponent
82
    ],
83
    animations: [scaleYMotion]
×
84
})
85
export class ThyCascaderComponent
86
    extends TabIndexDisabledControlValueAccessorMixin
754✔
87
    implements ControlValueAccessor, OnInit, OnChanges, OnDestroy, AfterContentInit
88
{
89
    /**
49✔
90
     * 选项的实际值的属性名
49✔
91
     */
49✔
92
    @Input() thyValueProperty = 'value';
49✔
93

49✔
94
    /**
49✔
95
     * 选项的显示值的属性名
49✔
96
     */
97
    @Input() thyLabelProperty = 'label';
98

99
    /**
100
     * 描述输入字段预期值的简短的提示信息
101
     */
102
    @Input() thyPlaceholder = '请选择';
103

49✔
104
    /**
49✔
105
     * 控制大小(4种)
9!
106
     * @type 'sm' | 'md' | 'lg' | ''
9✔
107
     */
9✔
108
    @Input() thySize: SelectControlSize = '';
1✔
109

110
    /**
9✔
111
     * 数据项
9✔
112
     * @type ThyCascaderOption[]
9✔
113
     * @default []
5✔
114
     */
5✔
115
    @Input()
116
    set thyOptions(options: ThyCascaderOption[] | null) {
117
        const columns = options && options.length ? [options] : [];
118
        this.thyCascaderService.initColumns(columns);
49!
119
        if (this.thyCascaderService.defaultValue && columns.length) {
49✔
120
            this.thyCascaderService.initOptions(0);
121
        }
122
    }
123

42✔
124
    /**
125
     * 点击父级菜单选项时,可通过该函数判断是否允许值的变化
126
     */
1✔
127
    @Input() thyChangeOn: (option: ThyCascaderOption, level: number) => boolean;
1✔
128

1✔
129
    /**
130
     * 点击项时,表单是否动态展示数据项
131
     * @type boolean
132
     */
133
    @Input() @InputBoolean() thyChangeOnSelect = false;
134

135
    /**
49✔
136
     * 显示输入框
31✔
137
     * @type boolean
31✔
138
     */
31✔
139
    @Input() @InputBoolean() thyShowInput = true;
140

141
    /**
142
     * 用户自定义模板
143
     * @type TemplateRef
74✔
144
     */
34✔
145
    @Input()
146
    set thyLabelRender(value: TemplateRef<any>) {
147
        this.labelRenderTpl = value;
148
        this.isLabelRenderTemplate = value instanceof TemplateRef;
49✔
149
        this.thyCascaderService.setCascaderOptions({ isLabelRenderTemplate: this.isLabelRenderTemplate });
196✔
150
    }
151

49✔
152
    get thyLabelRender(): TemplateRef<any> {
49✔
153
        return this.labelRenderTpl;
49✔
154
    }
49✔
155

49✔
156
    /**
157
     * 用于动态加载选项
158
     */
108✔
159
    @Input() set thyLoadData(value: (node: ThyCascaderOption, index?: number) => PromiseLike<any>) {
108✔
160
        this.thyCascaderService.setCascaderOptions({ loadData: value });
30✔
161
    }
162

163
    get thyLoadData() {
164
        return this.thyCascaderService?.cascaderOptions?.loadData;
49✔
165
    }
166

167
    /**
25✔
168
     * 控制触发状态, 支持 `click` | `hover`
25✔
169
     * @type ThyCascaderTriggerType | ThyCascaderTriggerType[]
1✔
170
     */
1✔
171
    @Input() thyTriggerAction: ThyCascaderTriggerType | ThyCascaderTriggerType[] = ['click'];
172

173
    /**
174
     * 鼠标经过下方列表项时,是否自动展开列表,支持 `click` | `hover`
1,599✔
175
     * @type ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[]
176
     */
177
    @Input() thyExpandTriggerAction: ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[] = ['click'];
1,599✔
178

179
    /**
180
     * 自定义浮层样式
1,599✔
181
     */
182
    @Input() thyMenuStyle: { [key: string]: string };
183

33✔
184
    /**
33✔
185
     * 自定义浮层类名
13✔
186
     * @type string
187
     */
188
    @Input()
189
    set thyMenuClassName(value: string) {
21✔
190
        this.menuClassName = value;
10✔
191
        this.setMenuClass();
67✔
192
    }
193

194
    get thyMenuClassName(): string {
10✔
195
        return this.menuClassName;
25✔
196
    }
22✔
197

22✔
198
    /**
199
     * 自定义浮层列类名
200
     * @type string
201
     */
202
    @Input()
203
    set thyColumnClassName(value: string) {
71✔
204
        this.columnClassName = value;
50✔
205
        this.setMenuClass();
50✔
206
    }
50✔
207

50✔
208
    get thyColumnClassName(): string {
50✔
209
        return this.columnClassName;
43✔
210
    }
43✔
211

212
    /**
213
     * 是否只读
7✔
214
     * @default false
215
     */
50✔
216
    @Input()
217
    // eslint-disable-next-line prettier/prettier
218
    override get thyDisabled(): boolean {
219
        return this.disabled;
318✔
220
    }
221

222
    override set thyDisabled(value: boolean) {
161✔
223
        this.disabled = coerceBooleanProperty(value);
224
    }
225

226
    disabled = false;
227

228
    /**
229
     * 空状态下的展示文字
230
     * @default 暂无可选项
596✔
231
     */
232
    @Input()
233
    set thyEmptyStateText(value: string) {
49✔
234
        this.emptyStateText = value;
235
    }
236

237
    /**
238
     * 是否多选
239
     * @type boolean
401✔
240
     * @default false
241
     */
242
    @Input()
49✔
243
    @InputBoolean()
244
    set thyMultiple(value: boolean) {
245
        this.isMultiple = value;
246
        this.thyCascaderService.setCascaderOptions({ isMultiple: value });
247
    }
248

249
    get thyMultiple(): boolean {
250
        return this.isMultiple;
99✔
251
    }
252

253
    /**
254
     * 设置多选时最大显示的标签数量,0 表示不限制
255
     * @type number
256
     */
257
    @Input() @InputNumber() thyMaxTagCount = 0;
99✔
258

259
    /**
260
     * 是否仅允许选择叶子项
31✔
261
     * @default true
21✔
262
     */
263
    @Input()
10✔
264
    @InputBoolean()
265
    thyIsOnlySelectLeaf = true;
266

4!
267
    /**
4✔
268
     * 初始化时,是否展开面板
269
     * @default false
×
270
     */
271
    @Input() @InputBoolean() thyAutoExpand: boolean;
272

2!
273
    /**
2✔
274
     * 是否支持搜索
275
     * @default false
×
276
     */
277
    @Input() @InputBoolean() thyShowSearch: boolean = false;
278

33✔
279
    /**
2✔
280
     * 多选选中项的展示方式,默认为空,渲染文字模板,传入tag,渲染展示模板,
281
     * @default ''|tag
31!
282
     */
31✔
283
    @Input() thyPreset: string = '';
284

285
    /**
286
     * 值发生变化时触发,返回选择项的值
2!
287
     * @type EventEmitter<any[]>
×
288
     */
289
    @Output() thyChange = new EventEmitter<any[]>();
2!
290

2✔
291
    /**
292
     * 值发生变化时触发,返回选择项列表
293
     * @type EventEmitter<ThyCascaderOption[]>
294
     */
20✔
295
    @Output() thySelectionChange = new EventEmitter<ThyCascaderOption[]>();
296

297
    /**
2!
298
     * 选择选项时触发
2✔
299
     */
300
    @Output() thySelect = new EventEmitter<{
2!
301
        option: ThyCascaderOption;
×
302
        index: number;
303
    }>();
2!
304

1✔
305
    /**
306
     * @private 暂无实现
1✔
307
     */
308
    @Output() thyDeselect = new EventEmitter<{
309
        option: ThyCascaderOption;
2!
310
        index: number;
2✔
311
    }>();
312

2✔
313
    /**
1✔
314
     * 清空选项时触发
315
     */
1✔
316
    @Output() thyClear = new EventEmitter<void>();
317

318
    /**
319
     * 下拉选项展开和折叠状态事件
1!
320
     */
×
321
    @Output() thyExpandStatusChange: EventEmitter<boolean> = new EventEmitter<boolean>();
322

1✔
323
    @ViewChildren('cascaderOptions', { read: ElementRef }) cascaderOptions: QueryList<ElementRef>;
324

325
    @ViewChildren('cascaderOptionContainers', { read: ElementRef }) cascaderOptionContainers: QueryList<ElementRef>;
1!
326

1✔
327
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
1✔
328

329
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<any>;
330

331
    @ViewChild('input') input: ElementRef;
4✔
332

1✔
333
    @ViewChild('menu') menu: ElementRef;
1✔
334

1✔
335
    public dropDownPosition = 'bottom';
1✔
336

337
    public menuVisible = false;
338

4✔
339
    public isLabelRenderTemplate = false;
4✔
340

341
    public triggerRect: DOMRect;
342

1✔
343
    public emptyStateText = '暂无可选项';
1✔
344

345
    private prefixCls = 'thy-cascader';
346

×
347
    private menuClassName: string;
348

349
    private columnClassName: string;
1!
350

1✔
351
    private _menuColumnCls: any;
1✔
352

353
    private readonly destroy$ = new Subject<void>();
1✔
354

1✔
355
    private _menuCls: { [name: string]: any };
356

1✔
357
    private _labelCls: { [name: string]: any };
358

359
    private labelRenderTpl: TemplateRef<any>;
49✔
360

49✔
361
    private hostRenderer = useHostRenderer();
49✔
362

49✔
363
    public positions: ConnectionPositionPair[];
49✔
364

49✔
365
    get selected(): SelectOptionBase | SelectOptionBase[] {
49✔
366
        return this.thyMultiple ? this.thyCascaderService.selectionModel.selected : this.thyCascaderService.selectionModel.selected[0];
49✔
367
    }
49✔
368
    private isMultiple = false;
49✔
369

49✔
370
    public menuMinWidth = 122;
49✔
371

49✔
372
    private searchText$ = new BehaviorSubject('');
49✔
373

49✔
374
    public get searchResultList(): ThyCascaderSearchOption[] {
49✔
375
        return this.thyCascaderService.searchResultList;
49✔
376
    }
49✔
377

49✔
378
    public isShowSearchPanel: boolean = false;
49✔
379

49✔
380
    /**
49✔
381
     * 解决搜索&多选的情况下,点击搜索项会导致 panel 闪烁
49✔
382
     * 由于点击后,thySelectedOptions变化,导致 thySelectControl
49✔
383
     * 又会触发 searchFilter 函数,即 resetSearch 会执行
49✔
384
     * 会导致恢复级联状态再变为搜索状态
49✔
385
     */
49✔
386
    private isSelectingSearchState: boolean = false;
49✔
387

49✔
388
    public get isLoading() {
49✔
389
        return this.thyCascaderService?.isLoading;
49✔
390
    }
49✔
391

49✔
392
    public get columns() {
49✔
393
        return this.thyCascaderService.columns;
49✔
394
    }
49✔
395

49✔
396
    private afterChangeFn: () => void;
397

398
    private resizeSubscription: Subscription;
399

400
    ngOnInit(): void {
401
        this.setClassMap();
402
        this.setMenuClass();
49✔
403
        this.setMenuColumnClass();
49✔
404
        this.setLabelClass();
9✔
405
        this.initPosition();
4✔
406
        this.initSearch();
4✔
407
        const options = {
4✔
408
            labelProperty: this.thyLabelProperty,
409
            valueProperty: this.thyValueProperty,
410
            isMultiple: this.isMultiple,
9✔
411
            isOnlySelectLeaf: this.thyIsOnlySelectLeaf,
9✔
412
            isLabelRenderTemplate: this.isLabelRenderTemplate,
9!
413
            loadData: this.thyLoadData
9✔
414
        };
415
        this.thyCascaderService.setCascaderOptions(options);
416

417
        this.thyCascaderService.cascaderValueChange().subscribe(options => {
418
            if (!options.isValueEqual) {
1,037!
419
                this.onChangeFn(options.value);
420
                if (options.isSelectionModelEmpty) {
421
                    this.thyClear.emit();
7✔
422
                }
1✔
423
                this.thySelectionChange.emit(this.thyCascaderService.selectedOptions);
424
                this.thyChange.emit(options.value);
7✔
425
                if (this.afterChangeFn) {
426
                    this.afterChangeFn();
427
                    this.afterChangeFn = null;
49✔
428
                }
50✔
429
            }
430
        });
6✔
431
        if (isPlatformBrowser(this.platformId)) {
432
            this.thyClickDispatcher
6✔
433
                .clicked(0)
6✔
434
                .pipe(takeUntil(this.destroy$))
435
                .subscribe(event => {
436
                    if (
437
                        !this.elementRef.nativeElement.contains(event.target) &&
6✔
438
                        !this.menu?.nativeElement.contains(event.target as Node) &&
439
                        this.menuVisible
440
                    ) {
8✔
441
                        this.ngZone.run(() => {
8✔
442
                            this.closeMenu();
8✔
443
                            this.cdr.markForCheck();
444
                        });
445
                    }
1✔
446
                });
1!
447
        }
×
448
    }
×
449

450
    ngAfterContentInit() {
×
451
        if (this.thyAutoExpand) {
452
            timer(0).subscribe(() => {
1✔
453
                this.cdr.markForCheck();
3✔
454
                this.setMenuVisible(true);
455
            });
1!
456
        }
×
457
    }
×
458

×
459
    ngOnChanges(changes: SimpleChanges): void {
460
        if (changes['thyIsOnlySelectLeaf']) {
×
461
            this.thyCascaderService.setCascaderOptions({ isOnlySelectLeaf: changes['thyIsOnlySelectLeaf'].currentValue });
×
462
        }
×
463
    }
×
464

465
    private initPosition() {
466
        const cascaderPosition: ConnectionPositionPair[] = EXPANDED_DROPDOWN_POSITIONS.map(item => {
467
            return { ...item };
1✔
468
        });
469
        cascaderPosition[0].offsetY = 4; // 左下
470
        cascaderPosition[1].offsetY = 4; // 右下
471
        cascaderPosition[2].offsetY = -4; // 右下
43✔
472
        cascaderPosition[3].offsetY = -4; // 右下
43✔
473
        this.positions = cascaderPosition;
43✔
474
    }
43✔
475

52✔
476
    writeValue(value: any): void {
477
        this.thyCascaderService.writeCascaderValue(value);
43✔
478
        if (this.isMultiple) {
479
            this.cdr.detectChanges();
18✔
480
        }
18✔
481
    }
18✔
482

11✔
483
    setDisabledState(isDisabled: boolean): void {
484
        this.disabled = isDisabled;
18✔
485
    }
486

487
    public positionChange(position: ConnectedOverlayPositionChange): void {
488
        const newValue = position.connectionPair.originY === 'bottom' ? 'bottom' : 'top';
489
        if (this.dropDownPosition !== newValue) {
490
            this.dropDownPosition = newValue;
99✔
491
            this.cdr.detectChanges();
34✔
492
        }
34✔
493
    }
494

495
    public isActivatedOption(option: ThyCascaderOption, index: number): boolean {
496
        return this.thyCascaderService.isActivatedOption(option, index);
49✔
497
    }
49✔
498

49✔
499
    public isHalfSelectedOption(option: ThyCascaderOption, index: number): boolean {
500
        return this.thyCascaderService.isHalfSelectedOption(option, index);
1✔
501
    }
502

503
    public isSelectedOption(option: ThyCascaderOption, index: number): boolean {
504
        return this.thyCascaderService.isSelectedOption(option, index);
505
    }
506

507
    public attached(): void {
508
        this.cdr.detectChanges();
1✔
509
        this.cdkConnectedOverlay.positionChange.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
510
            this.scrollActiveElementIntoView();
511
        });
512
    }
513

514
    private scrollActiveElementIntoView() {
515
        if (!isEmpty(this.thyCascaderService.selectedOptions)) {
516
            const activeOptions = this.cascaderOptions
517
                .filter(item => item.nativeElement.classList.contains('thy-cascader-menu-item-active'))
518
                // for multiple mode
519
                .slice(-this.cascaderOptionContainers.length);
520

521
            this.cascaderOptionContainers.forEach((item, index) => {
522
                if (index <= activeOptions.length - 1) {
523
                    ScrollToService.scrollToElement(activeOptions[index].nativeElement, item.nativeElement);
524
                    this.cdr.detectChanges();
525
                }
526
            });
527
        }
528
    }
529

530
    public setMenuVisible(menuVisible: boolean): void {
531
        if (this.menuVisible !== menuVisible) {
532
            this.menuVisible = menuVisible;
533

534
            this.thyCascaderService.initActivatedOptions(this.menuVisible);
535
            this.setClassMap();
536
            this.setMenuClass();
537
            if (this.menuVisible) {
538
                this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
539
                this.subscribeTriggerResize();
540
            } else {
541
                this.unsubscribeTriggerResize();
542
            }
543
            this.thyExpandStatusChange.emit(menuVisible);
544
        }
545
    }
546

547
    public get menuCls(): any {
548
        return this._menuCls;
1✔
549
    }
550

551
    private setMenuClass(): void {
552
        this._menuCls = {
1✔
553
            [`${this.prefixCls}-menus`]: true,
554
            [`${this.prefixCls}-menus-hidden`]: !this.menuVisible,
555
            [`${this.thyMenuClassName}`]: this.thyMenuClassName,
556
            [`w-100`]: this.columns.length === 0
1✔
557
        };
558
    }
559

560
    public get menuColumnCls(): any {
561
        return this._menuColumnCls;
1✔
562
    }
563

564
    private setMenuColumnClass(): void {
565
        this._menuColumnCls = {
1✔
566
            [`${this.prefixCls}-menu`]: true,
567
            [`${this.thyColumnClassName}`]: this.thyColumnClassName
568
        };
569
    }
1✔
570

571
    public get labelCls(): any {
572
        return this._labelCls;
573
    }
1✔
574

575
    private setLabelClass(): void {
576
        this._labelCls = {
577
            [`${this.prefixCls}-picker-label`]: true,
1✔
578
            [`${this.prefixCls}-show-search`]: false,
579
            [`${this.prefixCls}-focused`]: false,
580
            'text-truncate': true
581
        };
582
    }
583

584
    private setClassMap(): void {
49✔
585
        const classMap = {
586
            [`${this.prefixCls}`]: true,
587
            [`${this.prefixCls}-picker`]: true,
588
            [`${this.prefixCls}-${this.thySize}`]: true,
589
            [`${this.prefixCls}-picker-disabled`]: this.disabled,
590
            [`${this.prefixCls}-picker-open`]: this.menuVisible
591
        };
592
        this.hostRenderer.updateClassByMap(classMap);
593
    }
594

595
    private isClickTriggerAction(): boolean {
596
        if (typeof this.thyTriggerAction === 'string') {
597
            return this.thyTriggerAction === 'click';
598
        }
599
        return this.thyTriggerAction.indexOf('click') !== -1;
600
    }
601

602
    private isHoverTriggerAction(): boolean {
603
        if (typeof this.thyTriggerAction === 'string') {
604
            return this.thyTriggerAction === 'hover';
605
        }
606
        return this.thyTriggerAction.indexOf('hover') !== -1;
607
    }
608

609
    private isHoverExpandTriggerAction(): boolean {
610
        if (typeof this.thyExpandTriggerAction === 'string') {
611
            return this.thyExpandTriggerAction === 'hover';
612
        }
613
        return this.thyExpandTriggerAction.indexOf('hover') !== -1;
614
    }
615

616
    @HostListener('click', ['$event'])
617
    public toggleClick($event: Event) {
618
        if (this.disabled) {
619
            return;
620
        }
621
        if (this.isClickTriggerAction()) {
622
            this.setMenuVisible(!this.menuVisible);
623
        }
624
    }
625

626
    @HostListener('mouseover', ['$event'])
627
    public toggleHover($event: Event) {
628
        if (this.disabled) {
629
            return;
630
        }
631
        if (this.isHoverTriggerAction()) {
632
            this.setMenuVisible(!this.menuVisible);
633
        }
634
    }
635

636
    public clickOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
637
        this.thyCascaderService.clickOption(option, index, event, this.selectOption);
638
    }
639

640
    public mouseoverOption(option: ThyCascaderOption, index: number, event: Event): void {
641
        if (event) {
642
            event.preventDefault();
643
        }
644

645
        if (option && option.disabled && !this.isMultiple) {
646
            return;
647
        }
648

649
        if (!this.isHoverExpandTriggerAction() && !(option && option.disabled && this.isMultiple)) {
650
            return;
651
        }
652
        this.setActiveOption(option, index, false);
653
    }
654

655
    public mouseleaveMenu(event: Event) {
656
        if (event) {
657
            event.preventDefault();
658
        }
659
        if (!this.isHoverTriggerAction()) {
660
            return;
661
        }
662
        this.setMenuVisible(!this.menuVisible);
663
    }
664

665
    onBlur(event?: FocusEvent) {
666
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
667
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
668
            return;
669
        }
670
        this.onTouchedFn();
671
    }
672

673
    onFocus(event?: FocusEvent) {
674
        if (!elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
675
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
676
            inputElement.focus();
677
        }
678
    }
679

680
    public closeMenu(): void {
681
        if (this.menuVisible) {
682
            this.setMenuVisible(false);
683
            this.onTouchedFn();
684
            this.isShowSearchPanel = false;
685
            this.thyCascaderService.searchResultList = [];
686
        }
687
    }
688

689
    public setActiveOption(option: ThyCascaderOption, index: number, select: boolean, loadChildren: boolean = true): void {
690
        this.thyCascaderService.setActiveOption(option, index, select, loadChildren, this.selectOption);
691
    }
692

693
    private selectOption = (option: ThyCascaderOption, index: number) => {
694
        if ((option.isLeaf || !this.thyIsOnlySelectLeaf) && !this.thyMultiple) {
695
            this.afterChangeFn = () => {
696
                this.setMenuVisible(false);
697
                this.onTouchedFn();
698
            };
699
        }
700
        this.thySelect.emit({ option, index });
701
        const isOptionCanSelect = this.thyChangeOnSelect && !this.isMultiple;
702
        if (option.isLeaf || !this.thyIsOnlySelectLeaf || isOptionCanSelect || this.shouldPerformSelection(option, index)) {
703
            this.thyCascaderService.selectOption(option, index);
704
        }
705
    };
706

707
    public removeSelectedItem(event: { item: SelectOptionBase; $eventOrigin: Event }) {
708
        event.$eventOrigin.stopPropagation();
709
        this.thyCascaderService.removeSelectedItem(event?.item);
710
    }
711

712
    private shouldPerformSelection(option: ThyCascaderOption, level: number): boolean {
713
        return typeof this.thyChangeOn === 'function' ? this.thyChangeOn(option, level) === true : false;
714
    }
715

716
    public clearSelection($event: Event): void {
717
        if ($event) {
718
            $event.stopPropagation();
719
            $event.preventDefault();
720
        }
721
        this.afterChangeFn = () => {
722
            this.setMenuVisible(false);
723
        };
724
        this.thyCascaderService.clearSelection();
725
    }
726

727
    constructor(
728
        @Inject(PLATFORM_ID) private platformId: string,
729
        private cdr: ChangeDetectorRef,
730
        public elementRef: ElementRef,
731
        private thyClickDispatcher: ThyClickDispatcher,
732
        private ngZone: NgZone,
733
        public thyCascaderService: ThyCascaderService
734
    ) {
735
        super();
736
    }
737

738
    public trackByFn(index: number, item: ThyCascaderOption) {
739
        return item?.value || item?._id || index;
740
    }
741

742
    public searchFilter(searchText: string) {
743
        if (!searchText && !this.isSelectingSearchState) {
744
            this.resetSearch();
745
        }
746
        this.searchText$.next(searchText);
747
    }
748

749
    private initSearch() {
750
        this.searchText$
751
            .pipe(
752
                takeUntil(this.destroy$),
753
                debounceTime(200),
754
                distinctUntilChanged(),
755
                filter(text => text !== '')
756
            )
757
            .subscribe(searchText => {
758
                this.resetSearch();
759

760
                // local search
761
                this.searchInLocal(searchText);
762
                this.isShowSearchPanel = true;
763
            });
764
    }
765

766
    private searchInLocal(searchText: string): void {
767
        this.thyCascaderService.searchInLocal(searchText);
768
    }
769

770
    private resetSearch() {
771
        this.isShowSearchPanel = false;
772
        this.thyCascaderService.resetSearch();
773
        this.scrollActiveElementIntoView();
774
    }
775

776
    public selectSearchResult(selectOptionData: ThyCascaderSearchOption): void {
777
        const { thyRowValue: selectedOptions } = selectOptionData;
778
        if (selectOptionData.selected) {
779
            if (!this.isMultiple) {
780
                this.closeMenu();
781
            }
782
            return;
783
        }
784
        selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
785
            this.setActiveOption(item, index, index === selectedOptions.length - 1);
786
        });
787
        if (this.isMultiple) {
788
            this.isSelectingSearchState = true;
789
            selectOptionData.selected = true;
790
            const originSearchResultList = this.searchResultList;
791
            // 保持搜索选项
792
            setTimeout(() => {
793
                this.isShowSearchPanel = true;
794
                this.thyCascaderService.searchResultList = originSearchResultList;
795
                this.isSelectingSearchState = false;
796
            });
797
        } else {
798
            this.resetSearch();
799
        }
800
    }
801

802
    private subscribeTriggerResize(): void {
803
        this.unsubscribeTriggerResize();
804
        this.ngZone.runOutsideAngular(() => {
805
            this.resizeSubscription = new Observable(observer => {
806
                const resize = new ResizeObserver((entries: ResizeObserverEntry[]) => {
807
                    observer.next();
808
                });
809
                resize.observe(this.trigger.nativeElement);
810
            }).subscribe(() => {
811
                this.ngZone.run(() => {
812
                    this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
813
                    if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
814
                        this.cdkConnectedOverlay.overlayRef.updatePosition();
815
                    }
816
                    this.cdr.markForCheck();
817
                });
818
            });
819
        });
820
    }
821

822
    private unsubscribeTriggerResize(): void {
823
        if (this.resizeSubscription) {
824
            this.resizeSubscription.unsubscribe();
825
            this.resizeSubscription = null;
826
        }
827
    }
828

829
    ngOnDestroy() {
830
        this.unsubscribeTriggerResize();
831
        this.destroy$.next();
832
        this.destroy$.complete();
833
    }
834
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc