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

atinc / ngx-tethys / f16b0e2a-3809-4e30-b509-3a872838f4cd

19 Jul 2024 10:23AM UTC coverage: 90.464% (-0.001%) from 90.465%
f16b0e2a-3809-4e30-b509-3a872838f4cd

Pull #3126

circleci

minlovehua
fix(cascader): update position when sub menu appears off screen #INFR-12988
Pull Request #3126: fix(cascader): update position when sub menu appears off screen #INFR-12988

5494 of 6718 branches covered (81.78%)

Branch coverage included in aggregate %.

13242 of 13993 relevant lines covered (94.63%)

997.19 hits per line

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

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

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

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

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

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

106
    /**
107
     * 控制大小(4种)
108
     * @type 'sm' | 'md' | 'lg' | ''
109
     */
63✔
110
    @Input() thySize: SelectControlSize = '';
63✔
111

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

63✔
126
    /**
127
     * 自定义选项
128
     * @type ThyCascaderOption[]
129
     * @default []
55✔
130
     */
131
    @Input() set thyCustomOptions(options: ThyCascaderOption[] | null) {
132
        this.thyCascaderService.customOptions = (options || []).map(item => ({ ...item }));
1✔
133
    }
1✔
134

1✔
135
    get thyCustomOptions() {
136
        return this.thyCascaderService.customOptions;
137
    }
138

139
    /**
140
     * 点击父级菜单选项时,可通过该函数判断是否允许值的变化
141
     */
63✔
142
    @Input() thyChangeOn: (option: ThyCascaderOption, level: number) => boolean;
37✔
143

37✔
144
    /**
37✔
145
     * 点击项时,表单是否动态展示数据项
146
     * @type boolean
147
     */
148
    @Input({ transform: coerceBooleanProperty }) thyChangeOnSelect = false;
149

98✔
150
    /**
61✔
151
     * 显示输入框
152
     * @type boolean
153
     */
154
    @Input({ transform: coerceBooleanProperty }) thyShowInput = true;
63✔
155

252✔
156
    /**
157
     * 用户自定义选项模板
63✔
158
     * @type TemplateRef
159
     */
160
    @Input() thyOptionRender: TemplateRef<SafeAny>;
137✔
161

137✔
162
    /**
45✔
163
     * 用户自定义模板
164
     * @type TemplateRef
165
     */
166
    @Input()
63✔
167
    set thyLabelRender(value: TemplateRef<any>) {
168
        this.labelRenderTpl = value;
169
        this.isLabelRenderTemplate = value instanceof TemplateRef;
21!
170
        this.thyCascaderService.setCascaderOptions({ isLabelRenderTemplate: this.isLabelRenderTemplate });
21!
171
    }
×
172

×
173
    get thyLabelRender(): TemplateRef<any> {
174
        return this.labelRenderTpl;
175
    }
176

2,585✔
177
    /**
178
     * 用于动态加载选项
179
     */
2,585✔
180
    @Input() set thyLoadData(value: (node: ThyCascaderOption, index?: number) => PromiseLike<any>) {
181
        this.thyCascaderService.setCascaderOptions({ loadData: value });
182
    }
2,585✔
183

184
    get thyLoadData() {
185
        return this.thyCascaderService?.cascaderOptions?.loadData;
45✔
186
    }
45✔
187

21✔
188
    /**
189
     * 控制触发状态, 支持 `click` | `hover`
190
     * @type ThyCascaderTriggerType | ThyCascaderTriggerType[]
191
     */
30✔
192
    @Input() thyTriggerAction: ThyCascaderTriggerType | ThyCascaderTriggerType[] = ['click'];
19✔
193

105✔
194
    /**
195
     * 鼠标经过下方列表项时,是否自动展开列表,支持 `click` | `hover`
196
     * @type ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[]
19✔
197
     */
36✔
198
    @Input() thyExpandTriggerAction: ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[] = ['click'];
26✔
199

26✔
200
    /**
201
     * 自定义浮层样式
202
     */
203
    @Input() thyMenuStyle: { [key: string]: string };
204

205
    /**
91✔
206
     * 自定义搜索样式
65✔
207
     */
65✔
208
    @Input() thySearchListStyle: { [key: string]: string };
65✔
209

65✔
210
    /**
65✔
211
     * 自定义浮层类名
56✔
212
     * @type string
56✔
213
     */
214
    @Input()
215
    set thyMenuClassName(value: string) {
9✔
216
        this.menuClassName = value;
217
        this.setMenuClass();
65✔
218
    }
219

220
    get thyMenuClassName(): string {
221
        return this.menuClassName;
495✔
222
    }
223

224
    /**
202✔
225
     * 自定义浮层列类名
226
     * @type string
227
     */
228
    @Input()
229
    set thyColumnClassName(value: string) {
230
        this.columnClassName = value;
231
        this.setMenuClass();
232
    }
873✔
233

234
    get thyColumnClassName(): string {
235
        return this.columnClassName;
63✔
236
    }
237

238
    /**
239
     * 是否只读
240
     * @default false
241
     */
603✔
242
    @Input({ transform: coerceBooleanProperty })
243
    override set thyDisabled(value: boolean) {
244
        this.disabled = value;
63✔
245
    }
246
    override get thyDisabled(): boolean {
247
        return this.disabled;
248
    }
249

250
    disabled = false;
251

252
    /**
128✔
253
     * 空状态下的展示文字
254
     * @default 暂无可选项
255
     */
256
    @Input()
257
    set thyEmptyStateText(value: string) {
258
        this.emptyStateText = value;
259
    }
128✔
260

261
    /**
262
     * 是否多选
43✔
263
     * @type boolean
27✔
264
     * @default false
265
     */
16✔
266
    @Input({ transform: coerceBooleanProperty })
267
    set thyMultiple(value: boolean) {
268
        this.isMultiple = value;
4!
269
        this.thyCascaderService.setCascaderOptions({ isMultiple: value });
4✔
270
    }
271

×
272
    get thyMultiple(): boolean {
273
        return this.isMultiple;
274
    }
2!
275

2✔
276
    /**
277
     * 设置多选时最大显示的标签数量,0 表示不限制
×
278
     * @type number
279
     */
280
    @Input({ transform: numberAttribute }) thyMaxTagCount = 0;
45✔
281

2✔
282
    /**
283
     * 是否仅允许选择叶子项
43!
284
     * @default true
43✔
285
     */
286
    @Input({ transform: coerceBooleanProperty })
287
    thyIsOnlySelectLeaf = true;
288

2!
289
    /**
×
290
     * 初始化时,是否展开面板
291
     * @default false
2✔
292
     */
293
    @Input({ transform: coerceBooleanProperty }) thyAutoExpand: boolean;
294

2✔
295
    /**
1✔
296
     * 是否支持搜索
1✔
297
     * @default false
298
     */
1✔
299
    @Input({ transform: coerceBooleanProperty }) thyShowSearch: boolean = false;
1✔
300

1!
301
    /**
302
     * 多选选中项的展示方式,默认为空,渲染文字模板,传入tag,渲染展示模板,
303
     * @default ''|tag
×
304
     */
305
    @Input() thyPreset: string = '';
1✔
306

307
    /**
308
     * 是否有幕布
5✔
309
     */
4✔
310
    @Input({ transform: coerceBooleanProperty }) thyHasBackdrop = true;
311

5✔
312
    /**
313
     * 值发生变化时触发,返回选择项的值
314
     * @type EventEmitter<any[]>
24✔
315
     */
24✔
316
    @Output() thyChange = new EventEmitter<any[]>();
24!
317

24✔
318
    /**
24✔
319
     * 值发生变化时触发,返回选择项列表
24✔
320
     * @type EventEmitter<ThyCascaderOption[]>
321
     */
322
    @Output() thySelectionChange = new EventEmitter<ThyCascaderOption[]>();
323

2!
324
    /**
2✔
325
     * 选择选项时触发
326
     */
2!
327
    @Output() thySelect = new EventEmitter<{
×
328
        option: ThyCascaderOption;
329
        index: number;
2!
330
    }>();
1✔
331

332
    /**
1✔
333
     * @private 暂无实现
334
     */
335
    @Output() thyDeselect = new EventEmitter<{
336
        option: ThyCascaderOption;
1!
337
        index: number;
×
338
    }>();
339

1✔
340
    /**
341
     * 清空选项时触发
342
     */
1!
343
    @Output() thyClear = new EventEmitter<void>();
1✔
344

1✔
345
    /**
346
     * 下拉选项展开和折叠状态事件
347
     */
348
    @Output() thyExpandStatusChange: EventEmitter<boolean> = new EventEmitter<boolean>();
4✔
349

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

1✔
352
    @ViewChildren('cascaderOptionContainers', { read: ElementRef }) cascaderOptionContainers: QueryList<ElementRef>;
1✔
353

354
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
355

4✔
356
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<any>;
4✔
357

358
    @ViewChild('input') input: ElementRef;
359

1✔
360
    @ViewChild('menu') menu: ElementRef;
1✔
361

362
    public dropDownPosition = 'bottom';
363

×
364
    public menuVisible = false;
365

366
    public isLabelRenderTemplate = false;
1!
367

1✔
368
    public triggerRect: DOMRect;
1✔
369

370
    public emptyStateText = '暂无可选项';
1✔
371

1✔
372
    private prefixCls = 'thy-cascader';
373

1✔
374
    private menuClassName: string;
375

376
    private columnClassName: string;
63✔
377

63✔
378
    private _menuColumnCls: any;
63✔
379

63✔
380
    private readonly destroy$ = new Subject<void>();
63✔
381

63✔
382
    private _menuCls: { [name: string]: any };
63✔
383

63✔
384
    private _labelCls: { [name: string]: any };
63✔
385

63✔
386
    private labelRenderTpl: TemplateRef<any>;
63✔
387

63✔
388
    private hostRenderer = useHostRenderer();
63✔
389

63✔
390
    public positions: ConnectionPositionPair[];
63✔
391

63✔
392
    get selected(): SelectOptionBase | SelectOptionBase[] {
63✔
393
        return this.thyMultiple ? this.thyCascaderService.selectionModel.selected : this.thyCascaderService.selectionModel.selected[0];
63✔
394
    }
63✔
395
    private isMultiple = false;
63✔
396

63✔
397
    public menuMinWidth = 122;
63✔
398

63✔
399
    private searchText$ = new BehaviorSubject('');
63✔
400

63✔
401
    public get searchResultList(): ThyCascaderSearchOption[] {
63✔
402
        return this.thyCascaderService.searchResultList;
63✔
403
    }
63✔
404

63✔
405
    public isShowSearchPanel: boolean = false;
63✔
406

63✔
407
    /**
63✔
408
     * 解决搜索&多选的情况下,点击搜索项会导致 panel 闪烁
63✔
409
     * 由于点击后,thySelectedOptions变化,导致 thySelectControl
63✔
410
     * 又会触发 searchFilter 函数,即 resetSearch 会执行
63✔
411
     * 会导致恢复级联状态再变为搜索状态
63✔
412
     */
63✔
413
    private isSelectingSearchState: boolean = false;
63✔
414

415
    public get isLoading() {
416
        return this.thyCascaderService?.isLoading;
417
    }
418

419
    public get columns() {
420
        return this.thyCascaderService.columns;
63✔
421
    }
63✔
422

16✔
423
    private afterChangeFn: () => void;
6✔
424

6✔
425
    private resizeSubscription: Subscription;
6✔
426

427
    ngOnInit(): void {
428
        this.setClassMap();
16✔
429
        this.setMenuClass();
16✔
430
        this.setMenuColumnClass();
16!
431
        this.setLabelClass();
16✔
432
        this.initPosition();
433
        this.initSearch();
434
        const options = {
435
            labelProperty: this.thyLabelProperty,
436
            valueProperty: this.thyValueProperty,
1,657!
437
            isMultiple: this.isMultiple,
438
            isOnlySelectLeaf: this.thyIsOnlySelectLeaf,
439
            isLabelRenderTemplate: this.isLabelRenderTemplate,
8✔
440
            loadData: this.thyLoadData
1✔
441
        };
442
        this.thyCascaderService.setCascaderOptions(options);
8✔
443

444
        this.thyCascaderService.cascaderValueChange().subscribe(options => {
445
            if (!options.isValueEqual) {
63✔
446
                this.onChangeFn(options.value);
64✔
447
                if (options.isSelectionModelEmpty) {
448
                    this.thyClear.emit();
7✔
449
                }
450
                this.thySelectionChange.emit(this.thyCascaderService.selectedOptions);
7✔
451
                this.thyChange.emit(options.value);
7✔
452
                if (this.afterChangeFn) {
7✔
453
                    this.afterChangeFn();
454
                    this.afterChangeFn = null;
455
                }
456
            }
7✔
457
        });
458

459
        if (isPlatformBrowser(this.platformId)) {
9✔
460
            this.thyClickDispatcher
9✔
461
                .clicked(0)
9✔
462
                .pipe(takeUntil(this.destroy$))
463
                .subscribe(event => {
464
                    if (
1✔
465
                        !this.elementRef.nativeElement.contains(event.target) &&
1!
466
                        !this.menu?.nativeElement.contains(event.target as Node) &&
×
467
                        this.menuVisible
×
468
                    ) {
469
                        this.ngZone.run(() => {
×
470
                            this.closeMenu();
471
                            this.cdr.markForCheck();
1✔
472
                        });
3✔
473
                    }
474
                });
1!
475
        }
×
476
    }
×
477

×
478
    ngAfterContentInit() {
479
        if (this.thyAutoExpand) {
×
480
            timer(0).subscribe(() => {
×
481
                this.cdr.markForCheck();
×
482
                this.setMenuVisible(true);
×
483
            });
484
        }
485
    }
486

1✔
487
    ngOnChanges(changes: SimpleChanges): void {
488
        if (changes['thyIsOnlySelectLeaf']) {
489
            this.thyCascaderService.setCascaderOptions({ isOnlySelectLeaf: changes['thyIsOnlySelectLeaf'].currentValue });
490
        }
56✔
491
    }
56✔
492

56✔
493
    private initPosition() {
56✔
494
        const cascaderPosition: ConnectionPositionPair[] = EXPANDED_DROPDOWN_POSITIONS.map(item => {
70✔
495
            return { ...item };
496
        });
56✔
497
        this.positions = cascaderPosition;
498
    }
24✔
499

24✔
500
    writeValue(value: any): void {
24✔
501
        this.thyCascaderService.writeCascaderValue(value);
17✔
502
        if (this.isMultiple) {
503
            this.cdr.detectChanges();
24✔
504
        }
505
    }
506

507
    setDisabledState(isDisabled: boolean): void {
508
        this.disabled = isDisabled;
509
    }
128✔
510

46✔
511
    public positionChange(position: ConnectedOverlayPositionChange): void {
46✔
512
        const newValue = position.connectionPair.originY === 'bottom' ? 'bottom' : 'top';
513
        if (this.dropDownPosition !== newValue) {
514
            this.dropDownPosition = newValue;
515
            this.cdr.detectChanges();
63✔
516
        }
63✔
517
    }
63✔
518

519
    public isActivatedOption(option: ThyCascaderOption, index: number): boolean {
1✔
520
        return this.thyCascaderService.isActivatedOption(option, index);
521
    }
522

523
    public isHalfSelectedOption(option: ThyCascaderOption, index: number): boolean {
524
        return this.thyCascaderService.isHalfSelectedOption(option, index);
525
    }
526

527
    public isSelectedOption(option: ThyCascaderOption, index: number): boolean {
1✔
528
        return this.thyCascaderService.isSelectedOption(option, index);
529
    }
530

531
    public attached(): void {
532
        this.cdr.detectChanges();
533
        this.cdkConnectedOverlay.positionChange.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
534
            this.scrollActiveElementIntoView();
535
        });
536
    }
537

538
    private scrollActiveElementIntoView() {
539
        if (!isEmpty(this.thyCascaderService.selectedOptions)) {
540
            const activeOptions = this.cascaderOptions
541
                .filter(item => item.nativeElement.classList.contains('thy-cascader-menu-item-active'))
542
                // for multiple mode
543
                .slice(-this.cascaderOptionContainers.length);
544

545
            this.cascaderOptionContainers.forEach((item, index) => {
546
                if (index <= activeOptions.length - 1) {
547
                    ScrollToService.scrollToElement(activeOptions[index].nativeElement, item.nativeElement);
548
                    this.cdr.detectChanges();
549
                }
550
            });
551
        }
552
    }
553

554
    public setMenuVisible(menuVisible: boolean): void {
555
        if (this.menuVisible !== menuVisible) {
556
            this.menuVisible = menuVisible;
557

558
            this.thyCascaderService.initActivatedOptions(this.menuVisible);
559
            this.setClassMap();
560
            this.setMenuClass();
561
            if (this.menuVisible) {
562
                this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
563
                this.subscribeTriggerResize();
564
            } else {
565
                this.unsubscribeTriggerResize();
566
            }
567
            this.thyExpandStatusChange.emit(menuVisible);
568
        }
569
    }
570

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

575
    private setMenuClass(): void {
576
        this._menuCls = {
577
            [`${this.prefixCls}-menus`]: true,
578
            [`${this.prefixCls}-menus-hidden`]: !this.menuVisible,
579
            [`${this.thyMenuClassName}`]: this.thyMenuClassName,
63✔
580
            [`w-100`]: this.columns.length === 0
581
        };
582
    }
583

584
    public get menuColumnCls(): any {
585
        return this._menuColumnCls;
586
    }
587

588
    private setMenuColumnClass(): void {
589
        this._menuColumnCls = {
590
            [`${this.prefixCls}-menu`]: true,
591
            [`${this.thyColumnClassName}`]: this.thyColumnClassName
592
        };
593
    }
594

595
    public get labelCls(): any {
596
        return this._labelCls;
597
    }
598

599
    private setLabelClass(): void {
600
        this._labelCls = {
601
            [`${this.prefixCls}-picker-label`]: true,
602
            [`${this.prefixCls}-show-search`]: false,
603
            [`${this.prefixCls}-focused`]: false,
604
            'text-truncate': true
605
        };
606
    }
607

608
    private setClassMap(): void {
609
        const classMap = {
610
            [`${this.prefixCls}`]: true,
611
            [`${this.prefixCls}-picker`]: true,
612
            [`${this.prefixCls}-${this.thySize}`]: true,
613
            [`${this.prefixCls}-picker-disabled`]: this.disabled,
614
            [`${this.prefixCls}-picker-open`]: this.menuVisible
615
        };
616
        this.hostRenderer.updateClassByMap(classMap);
617
    }
618

619
    private isClickTriggerAction(): boolean {
620
        if (typeof this.thyTriggerAction === 'string') {
621
            return this.thyTriggerAction === 'click';
622
        }
623
        return this.thyTriggerAction.indexOf('click') !== -1;
624
    }
625

626
    private isHoverTriggerAction(): boolean {
627
        if (typeof this.thyTriggerAction === 'string') {
628
            return this.thyTriggerAction === 'hover';
629
        }
630
        return this.thyTriggerAction.indexOf('hover') !== -1;
631
    }
632

633
    private isHoverExpandTriggerAction(): boolean {
634
        if (typeof this.thyExpandTriggerAction === 'string') {
635
            return this.thyExpandTriggerAction === 'hover';
636
        }
637
        return this.thyExpandTriggerAction.indexOf('hover') !== -1;
638
    }
639

640
    @HostListener('click', ['$event'])
641
    public toggleClick($event: Event) {
642
        if (this.disabled) {
643
            return;
644
        }
645
        if (this.isClickTriggerAction()) {
646
            this.setMenuVisible(!this.menuVisible);
647
        }
648
    }
649

650
    @HostListener('mouseenter', ['$event'])
651
    public toggleMouseEnter(event: MouseEvent): void {
652
        if (this.disabled || !this.isHoverTriggerAction() || this.menuVisible) {
653
            return;
654
        }
655

656
        this.setMenuVisible(true);
657
    }
658

659
    @HostListener('mouseleave', ['$event'])
660
    public toggleMouseLeave(event: MouseEvent): void {
661
        if (this.disabled || !this.isHoverTriggerAction() || !this.menuVisible) {
662
            event.preventDefault();
663
            return;
664
        }
665

666
        const hostEl = this.elementRef.nativeElement;
667
        const mouseTarget = event.relatedTarget as HTMLElement;
668
        if (
669
            hostEl.contains(mouseTarget) ||
670
            mouseTarget?.classList.contains('cdk-overlay-pane') ||
671
            mouseTarget?.classList.contains('cdk-overlay-backdrop')
672
        ) {
673
            return;
674
        }
675

676
        this.setMenuVisible(false);
677
    }
678

679
    public clickCustomOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
680
        if (event === true) {
681
            this.thyCascaderService.clearSelection();
682
        }
683
        this.thyCascaderService.clickOption(option, index, event, this.selectOption);
684
    }
685

686
    public clickOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
687
        this.thyCascaderService.removeCustomOption();
688
        this.thyCascaderService.clickOption(option, index, event, this.selectOption);
689

690
        if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
691
            this.cdr.detectChanges();
692
            this.cdkConnectedOverlay.overlayRef.updatePosition();
693
            this.cdr.markForCheck();
694
        }
695
    }
696

697
    public mouseoverOption(option: ThyCascaderOption, index: number, event: Event): void {
698
        if (event) {
699
            event.preventDefault();
700
        }
701

702
        if (option && option.disabled && !this.isMultiple) {
703
            return;
704
        }
705

706
        if (!this.isHoverExpandTriggerAction() && !(option && option.disabled && this.isMultiple)) {
707
            return;
708
        }
709
        this.setActiveOption(option, index, false);
710
    }
711

712
    onBlur(event?: FocusEvent) {
713
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
714
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
715
            return;
716
        }
717
        this.onTouchedFn();
718
    }
719

720
    onFocus(event?: FocusEvent) {
721
        if (!elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
722
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
723
            inputElement.focus();
724
        }
725
    }
726

727
    public closeMenu(): void {
728
        if (this.menuVisible) {
729
            this.setMenuVisible(false);
730
            this.onTouchedFn();
731
            this.isShowSearchPanel = false;
732
            this.thyCascaderService.searchResultList = [];
733
        }
734
    }
735

736
    public setActiveOption(option: ThyCascaderOption, index: number, select: boolean, loadChildren: boolean = true): void {
737
        this.thyCascaderService.setActiveOption(option, index, select, loadChildren, this.selectOption);
738
    }
739

740
    private selectOption = (option: ThyCascaderOption, index: number) => {
741
        if ((option.isLeaf || !this.thyIsOnlySelectLeaf) && !this.thyMultiple) {
742
            this.afterChangeFn = () => {
743
                this.setMenuVisible(false);
744
                this.onTouchedFn();
745
            };
746
        }
747
        this.thySelect.emit({ option, index });
748
        const isOptionCanSelect = this.thyChangeOnSelect && !this.isMultiple;
749
        if (option.isLeaf || !this.thyIsOnlySelectLeaf || isOptionCanSelect || this.shouldPerformSelection(option, index)) {
750
            this.thyCascaderService.selectOption(option, index);
751
        }
752
    };
753

754
    public removeSelectedItem(event: { item: SelectOptionBase; $eventOrigin: Event }) {
755
        event.$eventOrigin.stopPropagation();
756
        this.thyCascaderService.removeSelectedItem(event?.item);
757
    }
758

759
    private shouldPerformSelection(option: ThyCascaderOption, level: number): boolean {
760
        return typeof this.thyChangeOn === 'function' ? this.thyChangeOn(option, level) === true : false;
761
    }
762

763
    public clearSelection($event: Event): void {
764
        if ($event) {
765
            $event.stopPropagation();
766
            $event.preventDefault();
767
        }
768
        this.afterChangeFn = () => {
769
            this.setMenuVisible(false);
770
        };
771
        this.thyCascaderService.clearSelection();
772
    }
773

774
    constructor(
775
        @Inject(PLATFORM_ID) private platformId: string,
776
        private cdr: ChangeDetectorRef,
777
        public elementRef: ElementRef,
778
        private thyClickDispatcher: ThyClickDispatcher,
779
        private ngZone: NgZone,
780
        public thyCascaderService: ThyCascaderService
781
    ) {
782
        super();
783
    }
784

785
    public trackByFn(index: number, item: ThyCascaderOption) {
786
        return item?.value || item?._id || index;
787
    }
788

789
    public searchFilter(searchText: string) {
790
        if (!searchText && !this.isSelectingSearchState) {
791
            this.resetSearch();
792
        }
793
        this.searchText$.next(searchText);
794
    }
795

796
    private initSearch() {
797
        this.searchText$
798
            .pipe(
799
                takeUntil(this.destroy$),
800
                debounceTime(200),
801
                distinctUntilChanged(),
802
                filter(text => text !== '')
803
            )
804
            .subscribe(searchText => {
805
                this.resetSearch();
806

807
                // local search
808
                this.searchInLocal(searchText);
809
                this.isShowSearchPanel = true;
810
                this.cdr.markForCheck();
811
            });
812
    }
813

814
    private searchInLocal(searchText: string): void {
815
        this.thyCascaderService.searchInLocal(searchText);
816
    }
817

818
    private resetSearch() {
819
        this.isShowSearchPanel = false;
820
        this.thyCascaderService.resetSearch();
821
        this.scrollActiveElementIntoView();
822
    }
823

824
    public selectSearchResult(selectOptionData: ThyCascaderSearchOption): void {
825
        const { thyRowValue: selectedOptions } = selectOptionData;
826
        if (selectOptionData.selected) {
827
            if (!this.isMultiple) {
828
                this.closeMenu();
829
            }
830
            return;
831
        }
832
        selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
833
            this.setActiveOption(item, index, index === selectedOptions.length - 1);
834
        });
835
        if (this.isMultiple) {
836
            this.isSelectingSearchState = true;
837
            selectOptionData.selected = true;
838
            const originSearchResultList = this.searchResultList;
839
            // 保持搜索选项
840
            setTimeout(() => {
841
                this.isShowSearchPanel = true;
842
                this.thyCascaderService.searchResultList = originSearchResultList;
843
                this.isSelectingSearchState = false;
844
            });
845
        } else {
846
            this.resetSearch();
847
        }
848
    }
849

850
    private subscribeTriggerResize(): void {
851
        this.unsubscribeTriggerResize();
852
        this.ngZone.runOutsideAngular(() => {
853
            this.resizeSubscription = new Observable(observer => {
854
                const resize = new ResizeObserver((entries: ResizeObserverEntry[]) => {
855
                    observer.next();
856
                });
857
                resize.observe(this.trigger.nativeElement);
858
            }).subscribe(() => {
859
                this.ngZone.run(() => {
860
                    this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
861
                    if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
862
                        this.cdkConnectedOverlay.overlayRef.updatePosition();
863
                    }
864
                    this.cdr.markForCheck();
865
                });
866
            });
867
        });
868
    }
869

870
    private unsubscribeTriggerResize(): void {
871
        if (this.resizeSubscription) {
872
            this.resizeSubscription.unsubscribe();
873
            this.resizeSubscription = null;
874
        }
875
    }
876

877
    ngOnDestroy() {
878
        this.unsubscribeTriggerResize();
879
        this.destroy$.next();
880
        this.destroy$.complete();
881
    }
882
}
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