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

atinc / ngx-tethys / 642aee87-0f93-46de-9188-a7dc9863bc23

19 Jul 2024 10:21AM UTC coverage: 90.46% (-0.005%) from 90.465%
642aee87-0f93-46de-9188-a7dc9863bc23

push

circleci

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

5494 of 6718 branches covered (81.78%)

Branch coverage included in aggregate %.

1 of 1 new or added line in 1 file covered. (100.0%)

16 existing lines in 2 files now uncovered.

13243 of 13995 relevant lines covered (94.63%)

997.06 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
    @ViewChildren(ThyCascaderOptionComponent) cascaderItems!: QueryList<ThyCascaderOptionComponent>;
92

1,143✔
93
    /**
94
     * 选项的实际值的属性名
95
     */
63✔
96
    @Input() thyValueProperty = 'value';
63✔
97

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

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

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

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

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

137
    get thyCustomOptions() {
138
        return this.thyCascaderService.customOptions;
139
    }
140

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

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

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

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

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

175
    get thyLabelRender(): TemplateRef<any> {
176
        return this.labelRenderTpl;
2,585✔
177
    }
178

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

45✔
186
    get thyLoadData() {
45✔
187
        return this.thyCascaderService?.cascaderOptions?.loadData;
21✔
188
    }
189

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

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

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

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

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

495✔
222
    get thyMenuClassName(): string {
223
        return this.menuClassName;
224
    }
202✔
225

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

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

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

252
    disabled = false;
128✔
253

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

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

274
    get thyMultiple(): boolean {
2!
275
        return this.isMultiple;
2✔
276
    }
UNCOV
277

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

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

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

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

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

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

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

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

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

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

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

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

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

354
    @ViewChildren('cascaderOptionContainers', { read: ElementRef }) cascaderOptionContainers: QueryList<ElementRef>;
355

4✔
356
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
4✔
357

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

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

362
    @ViewChild('menu') menu: ElementRef;
UNCOV
363

×
364
    public dropDownPosition = 'bottom';
365

366
    public menuVisible = false;
1!
367

1✔
368
    public isLabelRenderTemplate = false;
1✔
369

370
    public triggerRect: DOMRect;
1✔
371

1✔
372
    public emptyStateText = '暂无可选项';
373

1✔
374
    private prefixCls = 'thy-cascader';
375

376
    private menuClassName: string;
63✔
377

63✔
378
    private columnClassName: string;
63✔
379

63✔
380
    private _menuColumnCls: any;
63✔
381

63✔
382
    private readonly destroy$ = new Subject<void>();
63✔
383

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

63✔
386
    private _labelCls: { [name: string]: any };
63✔
387

63✔
388
    private labelRenderTpl: TemplateRef<any>;
63✔
389

63✔
390
    private hostRenderer = useHostRenderer();
63✔
391

63✔
392
    public positions: ConnectionPositionPair[];
63✔
393

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

63✔
399
    public menuMinWidth = 122;
63✔
400

63✔
401
    private searchText$ = new BehaviorSubject('');
63✔
402

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

63✔
407
    public isShowSearchPanel: boolean = false;
63✔
408

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

417
    public get isLoading() {
418
        return this.thyCascaderService?.isLoading;
419
    }
420

63✔
421
    public get columns() {
63✔
422
        return this.thyCascaderService.columns;
16✔
423
    }
6✔
424

6✔
425
    private afterChangeFn: () => void;
6✔
426

427
    private resizeSubscription: Subscription;
428

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

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

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

×
UNCOV
480
    ngAfterContentInit() {
×
UNCOV
481
        if (this.thyAutoExpand) {
×
UNCOV
482
            timer(0).subscribe(() => {
×
483
                this.cdr.markForCheck();
484
                this.setMenuVisible(true);
485
            });
486
        }
1✔
487
    }
488

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

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

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

509
    setDisabledState(isDisabled: boolean): void {
128✔
510
        this.disabled = isDisabled;
46✔
511
    }
46✔
512

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

521
    public isActivatedOption(option: ThyCascaderOption, index: number): boolean {
522
        return this.thyCascaderService.isActivatedOption(option, index);
523
    }
524

525
    public isHalfSelectedOption(option: ThyCascaderOption, index: number): boolean {
526
        return this.thyCascaderService.isHalfSelectedOption(option, index);
527
    }
1✔
528

529
    public isSelectedOption(option: ThyCascaderOption, index: number): boolean {
530
        return this.thyCascaderService.isSelectedOption(option, index);
531
    }
532

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

658
        this.setMenuVisible(true);
659
    }
660

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

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

678
        this.setMenuVisible(false);
679
    }
680

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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