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

atinc / ngx-tethys / 233ed64a-b8f5-4cf5-ac5f-a2f2763ca062

22 Nov 2024 07:31AM UTC coverage: 90.355% (+0.004%) from 90.351%
233ed64a-b8f5-4cf5-ac5f-a2f2763ca062

Pull #3272

circleci

minlovehua
feat: empty icon use 'preset-light' in dark theme #TINFR-975
Pull Request #3272: feat: panel empty icon use 'preset-light' in dark theme #TINFR-975

5548 of 6791 branches covered (81.7%)

Branch coverage included in aggregate %.

5 of 5 new or added lines in 3 files covered. (100.0%)

35 existing lines in 4 files now uncovered.

13263 of 14028 relevant lines covered (94.55%)

992.53 hits per line

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

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

45
import { scaleYMotion } from 'ngx-tethys/core';
4✔
46
import { ThyDivider } from 'ngx-tethys/divider';
47
import { ThyCascaderOptionComponent } from './cascader-li.component';
48
import { ThyCascaderSearchOptionComponent } from './cascader-search-option.component';
40✔
49
import { ThyCascaderService } from './cascader.service';
50
import { ThyCascaderExpandTrigger, ThyCascaderOption, ThyCascaderSearchOption, ThyCascaderTriggerType } from './types';
51
import { injectLocale, ThyCascaderLocale } from 'ngx-tethys/i18n';
63✔
52

53
/**
54
 * 级联选择菜单
37✔
55
 * @name thy-cascader
37✔
56
 */
57
@Component({
58
    selector: 'thy-cascader,[thy-cascader]',
404✔
59
    templateUrl: './cascader.component.html',
60
    providers: [
61
        {
37✔
62
            provide: NG_VALUE_ACCESSOR,
37✔
63
            useExisting: forwardRef(() => ThyCascader),
64
            multi: true
65
        },
126✔
66
        ThyCascaderService
67
    ],
68
    host: {
58✔
69
        '[attr.tabindex]': `tabIndex`,
70
        '(focus)': 'onFocus($event)',
71
        '(blur)': 'onBlur($event)'
655✔
72
    },
73
    standalone: true,
74
    imports: [
38✔
75
        CdkOverlayOrigin,
76
        ThySelectControl,
77
        NgClass,
59✔
78
        NgTemplateOutlet,
59✔
79
        CdkConnectedOverlay,
80
        NgStyle,
81
        ThyCascaderOptionComponent,
4,477✔
82
        ThyCascaderSearchOptionComponent,
83
        ThyEmpty,
84
        ThyIcon,
814✔
85
        ThyDivider
86
    ],
87
    animations: [scaleYMotion]
32✔
88
})
89
export class ThyCascader
UNCOV
90
    extends TabIndexDisabledControlValueAccessorMixin
×
91
    implements ControlValueAccessor, OnInit, OnChanges, OnDestroy, AfterContentInit
92
{
93
    private platformId = inject(PLATFORM_ID);
1,182✔
94
    private cdr = inject(ChangeDetectorRef);
95
    elementRef = inject(ElementRef);
96
    private thyClickDispatcher = inject(ThyClickDispatcher);
63✔
97
    private ngZone = inject(NgZone);
63✔
98
    thyCascaderService = inject(ThyCascaderService);
63✔
99
    private locale: Signal<ThyCascaderLocale> = injectLocale('cascader');
63✔
100
    emptyIcon: Signal<string> = injectPanelEmptyIcon();
63✔
101

63✔
102
    /**
63✔
103
     * 选项的实际值的属性名
104
     */
105
    @Input() thyValueProperty = 'value';
106

107
    /**
108
     * 选项的显示值的属性名
109
     */
110
    @Input() thyLabelProperty = 'label';
63✔
111

63✔
112
    /**
16!
113
     * 描述输入字段预期值的简短的提示信息
16✔
114
     */
16✔
115
    @Input() thyPlaceholder = this.locale().placeholder;
1✔
116

117
    /**
16✔
118
     * 控制大小(5种)
16✔
119
     * @type 'xs' | 'sm' | 'md' | 'lg' | ''
16✔
120
     */
7✔
121
    @Input() thySize: SelectControlSize = '';
7✔
122

123
    /**
124
     * 数据项
125
     * @type ThyCascaderOption[]
63!
126
     * @default []
63✔
127
     */
128
    @Input()
129
    set thyOptions(options: ThyCascaderOption[] | null) {
130
        const columns = options && options.length ? [options] : [];
55✔
131
        this.thyCascaderService.initColumns(columns);
132
        if (this.thyCascaderService.defaultValue && columns.length) {
133
            this.thyCascaderService.initOptions(0);
1✔
134
        }
1✔
135
    }
1✔
136

137
    /**
138
     * 自定义选项
139
     * @type ThyCascaderOption[]
140
     * @default []
141
     */
142
    @Input() set thyCustomOptions(options: ThyCascaderOption[] | null) {
63✔
143
        this.thyCascaderService.customOptions = (options || []).map(item => ({ ...item }));
37✔
144
    }
37✔
145

37✔
146
    get thyCustomOptions() {
147
        return this.thyCascaderService.customOptions;
148
    }
149

150
    /**
98✔
151
     * 点击父级菜单选项时,可通过该函数判断是否允许值的变化
61✔
152
     */
153
    @Input() thyChangeOn: (option: ThyCascaderOption, level: number) => boolean;
154

155
    /**
63✔
156
     * 点击项时,表单是否动态展示数据项
252✔
157
     * @type boolean
158
     */
63✔
159
    @Input({ transform: coerceBooleanProperty }) thyChangeOnSelect = false;
160

161
    /**
137✔
162
     * 显示输入框
137✔
163
     * @type boolean
45✔
164
     */
165
    @Input({ transform: coerceBooleanProperty }) thyShowInput = true;
166

167
    /**
63✔
168
     * 用户自定义选项模板
169
     * @type TemplateRef
170
     */
25✔
171
    @Input() thyOptionRender: TemplateRef<SafeAny>;
25✔
172

14✔
173
    /**
14✔
174
     * 用户自定义模板
175
     * @type TemplateRef
176
     */
177
    @Input()
2,819✔
178
    set thyLabelRender(value: TemplateRef<any>) {
179
        this.labelRenderTpl = value;
180
        this.isLabelRenderTemplate = value instanceof TemplateRef;
2,819✔
181
        this.thyCascaderService.setCascaderOptions({ isLabelRenderTemplate: this.isLabelRenderTemplate });
182
    }
183

2,819✔
184
    get thyLabelRender(): TemplateRef<any> {
185
        return this.labelRenderTpl;
186
    }
45✔
187

45✔
188
    /**
21✔
189
     * 用于动态加载选项
190
     */
191
    @Input() set thyLoadData(value: (node: ThyCascaderOption, index?: number) => PromiseLike<any>) {
192
        this.thyCascaderService.setCascaderOptions({ loadData: value });
30✔
193
    }
19✔
194

112✔
195
    get thyLoadData() {
196
        return this.thyCascaderService?.cascaderOptions?.loadData;
197
    }
19✔
198

43✔
199
    /**
38✔
200
     * 控制触发状态, 支持 `click` | `hover`
38✔
201
     * @type ThyCascaderTriggerType | ThyCascaderTriggerType[]
202
     */
203
    @Input() thyTriggerAction: ThyCascaderTriggerType | ThyCascaderTriggerType[] = ['click'];
204

205
    /**
206
     * 鼠标经过下方列表项时,是否自动展开列表,支持 `click` | `hover`
91✔
207
     * @type ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[]
65✔
208
     */
65✔
209
    @Input() thyExpandTriggerAction: ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[] = ['click'];
65✔
210

65✔
211
    /**
65✔
212
     * 自定义浮层样式
56✔
213
     */
56✔
214
    @Input() thyMenuStyle: { [key: string]: string };
215

216
    /**
9✔
217
     * 自定义搜索样式
218
     */
65✔
219
    @Input() thySearchListStyle: { [key: string]: string };
220

221
    /**
222
     * 自定义浮层类名
514✔
223
     * @type string
224
     */
225
    @Input()
202✔
226
    set thyMenuClassName(value: string) {
227
        this.menuClassName = value;
228
        this.setMenuClass();
229
    }
230

231
    get thyMenuClassName(): string {
232
        return this.menuClassName;
233
    }
958✔
234

235
    /**
236
     * 自定义浮层列类名
63✔
237
     * @type string
238
     */
239
    @Input()
240
    set thyColumnClassName(value: string) {
241
        this.columnClassName = value;
242
        this.setMenuClass();
666✔
243
    }
244

245
    get thyColumnClassName(): string {
63✔
246
        return this.columnClassName;
247
    }
248

249
    /**
250
     * 是否只读
251
     * @default false
252
     */
253
    @Input({ transform: coerceBooleanProperty })
128✔
254
    override set thyDisabled(value: boolean) {
255
        this.disabled = value;
256
    }
257
    override get thyDisabled(): boolean {
258
        return this.disabled;
259
    }
260

128✔
261
    disabled = false;
262

263
    /**
43✔
264
     * 空状态下的展示文字
27✔
265
     * @default 暂无可选项
266
     */
16✔
267
    @Input()
268
    set thyEmptyStateText(value: string) {
269
        this.emptyStateText = value;
4!
270
    }
4✔
271

UNCOV
272
    /**
×
273
     * 是否多选
274
     * @type boolean
275
     * @default false
2!
276
     */
2✔
277
    @Input({ transform: coerceBooleanProperty })
UNCOV
278
    set thyMultiple(value: boolean) {
×
279
        this.isMultiple = value;
280
        this.thyCascaderService.setCascaderOptions({ isMultiple: value });
281
    }
45✔
282

2✔
283
    get thyMultiple(): boolean {
284
        return this.isMultiple;
43!
285
    }
43✔
286

287
    /**
288
     * 设置多选时最大显示的标签数量,0 表示不限制
289
     * @type number
2!
UNCOV
290
     */
×
291
    @Input({ transform: numberAttribute }) thyMaxTagCount = 0;
292

2✔
293
    /**
294
     * 是否仅允许选择叶子项
295
     * @default true
2✔
296
     */
1✔
297
    @Input({ transform: coerceBooleanProperty })
1✔
298
    thyIsOnlySelectLeaf = true;
299

1✔
300
    /**
1✔
301
     * 初始化时,是否展开面板
1!
302
     * @default false
303
     */
UNCOV
304
    @Input({ transform: coerceBooleanProperty }) thyAutoExpand: boolean;
×
305

306
    /**
1✔
307
     * 是否支持搜索
308
     * @default false
309
     */
5✔
310
    @Input({ transform: coerceBooleanProperty }) thyShowSearch: boolean = false;
4✔
311

312
    /**
5✔
313
     * 多选选中项的展示方式,默认为空,渲染文字模板,传入tag,渲染展示模板,
314
     * @default ''|tag
315
     */
24✔
316
    @Input() thyPreset: string = '';
24✔
317

24!
318
    /**
319
     * 是否有幕布
24✔
320
     */
321
    @Input({ transform: coerceBooleanProperty }) thyHasBackdrop = true;
24✔
322

24✔
323
    /**
324
     * 值发生变化时触发,返回选择项的值
325
     * @type EventEmitter<any[]>
326
     */
2!
327
    @Output() thyChange = new EventEmitter<any[]>();
2✔
328

329
    /**
2!
UNCOV
330
     * 值发生变化时触发,返回选择项列表
×
331
     * @type EventEmitter<ThyCascaderOption[]>
332
     */
2!
333
    @Output() thySelectionChange = new EventEmitter<ThyCascaderOption[]>();
1✔
334

335
    /**
1✔
336
     * 选择选项时触发
337
     */
338
    @Output() thySelect = new EventEmitter<{
339
        option: ThyCascaderOption;
1!
UNCOV
340
        index: number;
×
341
    }>();
342

1✔
343
    /**
344
     * @private 暂无实现
345
     */
1!
346
    @Output() thyDeselect = new EventEmitter<{
1✔
347
        option: ThyCascaderOption;
1✔
348
        index: number;
349
    }>();
350

351
    /**
4✔
352
     * 清空选项时触发
1✔
353
     */
1✔
354
    @Output() thyClear = new EventEmitter<void>();
1✔
355

1✔
356
    /**
357
     * 下拉选项展开和折叠状态事件
358
     */
4✔
359
    @Output() thyExpandStatusChange: EventEmitter<boolean> = new EventEmitter<boolean>();
4✔
360

361
    @ViewChildren('cascaderOptions', { read: ElementRef }) cascaderOptions: QueryList<ElementRef>;
362

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

365
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
UNCOV
366

×
367
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<any>;
368

369
    @ViewChild('input') input: ElementRef;
1!
370

1✔
371
    @ViewChild('menu') menu: ElementRef;
1✔
372

373
    public dropDownPosition = 'bottom';
1✔
374

1✔
375
    public menuVisible = false;
376

1✔
377
    public isLabelRenderTemplate = false;
378

379
    public triggerRect: DOMRect;
63✔
380

63✔
381
    public emptyStateText = this.locale().empty;
63✔
382

63✔
383
    private prefixCls = 'thy-cascader';
63✔
384

63✔
385
    private menuClassName: string;
63✔
386

63✔
387
    private columnClassName: string;
63✔
388

63✔
389
    private _menuColumnCls: any;
63✔
390

63✔
391
    private readonly destroy$ = new Subject<void>();
63✔
392

63✔
393
    private _menuCls: { [name: string]: any };
63✔
394

63✔
395
    private _labelCls: { [name: string]: any };
63✔
396

63✔
397
    private labelRenderTpl: TemplateRef<any>;
63✔
398

63✔
399
    private hostRenderer = useHostRenderer();
63✔
400

63✔
401
    public positions: ConnectionPositionPair[];
63✔
402

63✔
403
    get selected(): SelectOptionBase | SelectOptionBase[] {
63✔
404
        return this.thyMultiple ? this.thyCascaderService.selectionModel.selected : this.thyCascaderService.selectionModel.selected[0];
63✔
405
    }
63✔
406
    private isMultiple = false;
63✔
407

63✔
408
    public menuMinWidth = 122;
63✔
409

63✔
410
    private searchText$ = new BehaviorSubject('');
63✔
411

63✔
412
    public get searchResultList(): ThyCascaderSearchOption[] {
63✔
413
        return this.thyCascaderService.searchResultList;
63✔
414
    }
63✔
415

63✔
416
    public isShowSearchPanel: boolean = false;
63✔
417

63✔
418
    /**
63✔
419
     * 解决搜索&多选的情况下,点击搜索项会导致 panel 闪烁
420
     * 由于点击后,thySelectedOptions变化,导致 thySelectControl
421
     * 又会触发 searchFilter 函数,即 resetSearch 会执行
422
     * 会导致恢复级联状态再变为搜索状态
423
     */
424
    private isSelectingSearchState: boolean = false;
425

63✔
426
    public get isLoading() {
63✔
427
        return this.thyCascaderService?.isLoading;
16✔
428
    }
6✔
429

6✔
430
    public get columns() {
6✔
431
        return this.thyCascaderService.columns;
432
    }
433

16✔
434
    private afterChangeFn: () => void;
16✔
435

16!
436
    private resizeSubscription: Subscription;
16✔
437

438
    ngOnInit(): void {
439
        this.setClassMap();
440
        this.setMenuClass();
441
        this.setMenuColumnClass();
2,832!
442
        this.setLabelClass();
443
        this.initPosition();
444
        this.initSearch();
8✔
445
        const options = {
1✔
446
            labelProperty: this.thyLabelProperty,
447
            valueProperty: this.thyValueProperty,
8✔
448
            isMultiple: this.isMultiple,
449
            isOnlySelectLeaf: this.thyIsOnlySelectLeaf,
450
            isLabelRenderTemplate: this.isLabelRenderTemplate,
63✔
451
            loadData: this.thyLoadData
64✔
452
        };
453
        this.thyCascaderService.setCascaderOptions(options);
7✔
454

455
        this.thyCascaderService.cascaderValueChange().subscribe(options => {
7✔
456
            if (!options.isValueEqual) {
7✔
457
                this.onChangeFn(options.value);
7✔
458
                if (options.isSelectionModelEmpty) {
459
                    this.thyClear.emit();
460
                }
461
                this.thySelectionChange.emit(this.thyCascaderService.selectedOptions);
7✔
462
                this.thyChange.emit(options.value);
463
                if (this.afterChangeFn) {
464
                    this.afterChangeFn();
9✔
465
                    this.afterChangeFn = null;
9✔
466
                }
9✔
467
            }
468
        });
469

1✔
470
        if (isPlatformBrowser(this.platformId)) {
1!
UNCOV
471
            this.thyClickDispatcher
×
472
                .clicked(0)
×
473
                .pipe(takeUntil(this.destroy$))
UNCOV
474
                .subscribe(event => {
×
475
                    if (
476
                        !this.elementRef.nativeElement.contains(event.target) &&
1✔
477
                        !this.menu?.nativeElement.contains(event.target as Node) &&
3✔
478
                        this.menuVisible
479
                    ) {
1!
UNCOV
480
                        this.ngZone.run(() => {
×
481
                            this.closeMenu();
×
482
                            this.cdr.markForCheck();
×
483
                        });
UNCOV
484
                    }
×
485
                });
×
486
        }
×
487
    }
×
488

489
    ngAfterContentInit() {
490
        if (this.thyAutoExpand) {
491
            timer(0).subscribe(() => {
1✔
492
                this.cdr.markForCheck();
493
                this.setMenuVisible(true);
494
            });
495
        }
56✔
496
    }
56✔
497

56✔
498
    ngOnChanges(changes: SimpleChanges): void {
56✔
499
        if (changes['thyIsOnlySelectLeaf']) {
70✔
500
            this.thyCascaderService.setCascaderOptions({ isOnlySelectLeaf: changes['thyIsOnlySelectLeaf'].currentValue });
501
        }
56✔
502
    }
503

24✔
504
    private initPosition() {
24✔
505
        const cascaderPosition: ConnectionPositionPair[] = EXPANDED_DROPDOWN_POSITIONS.map(item => {
24✔
506
            return { ...item };
17✔
507
        });
508
        this.positions = cascaderPosition;
24✔
509
    }
510

511
    writeValue(value: any): void {
512
        this.thyCascaderService.writeCascaderValue(value);
513
        if (this.isMultiple) {
514
            this.cdr.detectChanges();
128✔
515
        }
46✔
516
    }
46✔
517

518
    setDisabledState(isDisabled: boolean): void {
519
        this.disabled = isDisabled;
520
    }
63✔
521

63✔
522
    public positionChange(position: ConnectedOverlayPositionChange): void {
63✔
523
        const newValue = position.connectionPair.originY === 'bottom' ? 'bottom' : 'top';
524
        if (this.dropDownPosition !== newValue) {
1✔
525
            this.dropDownPosition = newValue;
1✔
526
            this.cdr.detectChanges();
527
        }
528
    }
529

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

534
    public isHalfSelectedOption(option: ThyCascaderOption, index: number): boolean {
535
        return this.thyCascaderService.isHalfSelectedOption(option, index);
536
    }
537

538
    public isSelectedOption(option: ThyCascaderOption, index: number): boolean {
539
        return this.thyCascaderService.isSelectedOption(option, index);
540
    }
541

542
    public attached(): void {
543
        this.cdr.detectChanges();
544
        this.cdkConnectedOverlay.positionChange.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
545
            this.scrollActiveElementIntoView();
546
        });
547
    }
548

549
    private scrollActiveElementIntoView() {
550
        if (!isEmpty(this.thyCascaderService.selectedOptions)) {
551
            const activeOptions = this.cascaderOptions
552
                .filter(item => item.nativeElement.classList.contains('thy-cascader-menu-item-active'))
553
                // for multiple mode
554
                .slice(-this.cascaderOptionContainers.length);
555

556
            this.cascaderOptionContainers.forEach((item, index) => {
557
                if (index <= activeOptions.length - 1) {
558
                    ScrollToService.scrollToElement(activeOptions[index].nativeElement, item.nativeElement);
559
                    this.cdr.detectChanges();
560
                }
561
            });
562
        }
563
    }
564

565
    public setMenuVisible(menuVisible: boolean): void {
566
        if (this.menuVisible !== menuVisible) {
567
            this.menuVisible = menuVisible;
568

569
            this.thyCascaderService.initActivatedOptions(this.menuVisible);
570
            this.setClassMap();
1✔
571
            this.setMenuClass();
572
            if (this.menuVisible) {
573
                this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
574
                this.subscribeTriggerResize();
575
            } else {
576
                this.unsubscribeTriggerResize();
577
            }
63✔
578
            this.thyExpandStatusChange.emit(menuVisible);
579
        }
580
    }
581

582
    public get menuCls(): any {
583
        return this._menuCls;
584
    }
585

586
    private setMenuClass(): void {
587
        this._menuCls = {
588
            [`${this.prefixCls}-menus`]: true,
589
            [`${this.prefixCls}-menus-hidden`]: !this.menuVisible,
590
            [`${this.thyMenuClassName}`]: this.thyMenuClassName,
591
            [`w-100`]: this.columns.length === 0
592
        };
593
    }
594

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

599
    private setMenuColumnClass(): void {
600
        this._menuColumnCls = {
601
            [`${this.prefixCls}-menu`]: true,
602
            [`${this.thyColumnClassName}`]: this.thyColumnClassName
603
        };
604
    }
605

606
    public get labelCls(): any {
607
        return this._labelCls;
608
    }
609

610
    private setLabelClass(): void {
611
        this._labelCls = {
612
            [`${this.prefixCls}-picker-label`]: true,
613
            [`${this.prefixCls}-show-search`]: false,
614
            [`${this.prefixCls}-focused`]: false,
615
            'text-truncate': true
616
        };
617
    }
618

619
    private setClassMap(): void {
620
        const classMap = {
621
            [`${this.prefixCls}`]: true,
622
            [`${this.prefixCls}-picker`]: true,
623
            [`${this.prefixCls}-${this.thySize}`]: true,
624
            [`${this.prefixCls}-picker-disabled`]: this.disabled,
625
            [`${this.prefixCls}-picker-open`]: this.menuVisible
626
        };
627
        this.hostRenderer.updateClassByMap(classMap);
628
    }
629

630
    private isClickTriggerAction(): boolean {
631
        if (typeof this.thyTriggerAction === 'string') {
632
            return this.thyTriggerAction === 'click';
633
        }
634
        return this.thyTriggerAction.indexOf('click') !== -1;
635
    }
636

637
    private isHoverTriggerAction(): boolean {
638
        if (typeof this.thyTriggerAction === 'string') {
639
            return this.thyTriggerAction === 'hover';
640
        }
641
        return this.thyTriggerAction.indexOf('hover') !== -1;
642
    }
643

644
    private isHoverExpandTriggerAction(): boolean {
645
        if (typeof this.thyExpandTriggerAction === 'string') {
646
            return this.thyExpandTriggerAction === 'hover';
647
        }
648
        return this.thyExpandTriggerAction.indexOf('hover') !== -1;
649
    }
650

651
    @HostListener('click', ['$event'])
652
    public toggleClick($event: Event) {
653
        if (this.disabled) {
654
            return;
655
        }
656
        if (this.isClickTriggerAction()) {
657
            this.setMenuVisible(!this.menuVisible);
658
        }
659
    }
660

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

667
        this.setMenuVisible(true);
668
    }
669

670
    @HostListener('mouseleave', ['$event'])
671
    public toggleMouseLeave(event: MouseEvent): void {
672
        if (this.disabled || !this.isHoverTriggerAction() || !this.menuVisible) {
673
            event.preventDefault();
674
            return;
675
        }
676

677
        const hostEl = this.elementRef.nativeElement;
678
        const mouseTarget = event.relatedTarget as HTMLElement;
679
        if (
680
            hostEl.contains(mouseTarget) ||
681
            mouseTarget?.classList.contains('cdk-overlay-pane') ||
682
            mouseTarget?.classList.contains('cdk-overlay-backdrop')
683
        ) {
684
            return;
685
        }
686

687
        this.setMenuVisible(false);
688
    }
689

690
    public clickCustomOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
691
        if (event === true) {
692
            this.thyCascaderService.clearSelection();
693
        }
694
        this.thyCascaderService.clickOption(option, index, event, this.selectOption);
695
    }
696

697
    public clickOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
698
        this.thyCascaderService.removeCustomOption();
699
        this.thyCascaderService.clickOption(option, index, event, this.selectOption);
700

701
        if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
702
            // Make sure to calculate and update the position after the submenu is opened
703
            this.cdr.detectChanges();
704

705
            // Update the position to prevent the submenu from appearing off-screen
706
            this.cdkConnectedOverlay.overlayRef.updatePosition();
707
            this.cdr.markForCheck();
708
        }
709
    }
710

711
    public mouseoverOption(option: ThyCascaderOption, index: number, event: Event): void {
712
        if (event) {
713
            event.preventDefault();
714
        }
715

716
        if (option && option.disabled && !this.isMultiple) {
717
            return;
718
        }
719

720
        if (!this.isHoverExpandTriggerAction() && !(option && option.disabled && this.isMultiple)) {
721
            return;
722
        }
723
        this.setActiveOption(option, index, false);
724
    }
725

726
    onBlur(event?: FocusEvent) {
727
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
728
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
729
            return;
730
        }
731
        this.onTouchedFn();
732
    }
733

734
    onFocus(event?: FocusEvent) {
735
        if (!elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
736
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
737
            inputElement.focus();
738
        }
739
    }
740

741
    public closeMenu(): void {
742
        if (this.menuVisible) {
743
            this.setMenuVisible(false);
744
            this.onTouchedFn();
745
            this.isShowSearchPanel = false;
746
            this.thyCascaderService.searchResultList = [];
747
        }
748
    }
749

750
    public setActiveOption(option: ThyCascaderOption, index: number, select: boolean, loadChildren: boolean = true): void {
751
        this.thyCascaderService.setActiveOption(option, index, select, loadChildren, this.selectOption);
752
    }
753

754
    private selectOption = (option: ThyCascaderOption, index: number) => {
755
        if ((option.isLeaf || !this.thyIsOnlySelectLeaf) && !this.thyMultiple) {
756
            this.afterChangeFn = () => {
757
                this.setMenuVisible(false);
758
                this.onTouchedFn();
759
            };
760
        }
761
        this.thySelect.emit({ option, index });
762
        const isOptionCanSelect = this.thyChangeOnSelect && !this.isMultiple;
763
        if (option.isLeaf || !this.thyIsOnlySelectLeaf || isOptionCanSelect || this.shouldPerformSelection(option, index)) {
764
            this.thyCascaderService.selectOption(option, index);
765
        }
766
    };
767

768
    public removeSelectedItem(event: { item: SelectOptionBase; $eventOrigin: Event }) {
769
        event.$eventOrigin.stopPropagation();
770
        this.thyCascaderService.removeSelectedItem(event?.item);
771
    }
772

773
    private shouldPerformSelection(option: ThyCascaderOption, level: number): boolean {
774
        return typeof this.thyChangeOn === 'function' ? this.thyChangeOn(option, level) === true : false;
775
    }
776

777
    public clearSelection($event: Event): void {
778
        if ($event) {
779
            $event.stopPropagation();
780
            $event.preventDefault();
781
        }
782
        this.afterChangeFn = () => {
783
            this.setMenuVisible(false);
784
        };
785
        this.thyCascaderService.clearSelection();
786
    }
787

788
    constructor() {
789
        super();
790
    }
791

792
    public trackByFn(index: number, item: ThyCascaderOption) {
793
        return item?.value || item?._id || index;
794
    }
795

796
    public searchFilter(searchText: string) {
797
        if (!searchText && !this.isSelectingSearchState) {
798
            this.resetSearch();
799
        }
800
        this.searchText$.next(searchText);
801
    }
802

803
    private initSearch() {
804
        this.searchText$
805
            .pipe(
806
                takeUntil(this.destroy$),
807
                DebounceTimeWrapper.debounceTime(200),
808
                distinctUntilChanged(),
809
                filter(text => text !== '')
810
            )
811
            .subscribe(searchText => {
812
                this.resetSearch();
813

814
                // local search
815
                this.searchInLocal(searchText);
816
                this.isShowSearchPanel = true;
817
                this.cdr.markForCheck();
818
            });
819
    }
820

821
    private searchInLocal(searchText: string): void {
822
        this.thyCascaderService.searchInLocal(searchText);
823
    }
824

825
    private resetSearch() {
826
        this.isShowSearchPanel = false;
827
        this.thyCascaderService.resetSearch();
828
        this.scrollActiveElementIntoView();
829
    }
830

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

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

877
    private unsubscribeTriggerResize(): void {
878
        if (this.resizeSubscription) {
879
            this.resizeSubscription.unsubscribe();
880
            this.resizeSubscription = null;
881
        }
882
    }
883

884
    ngOnDestroy() {
885
        this.unsubscribeTriggerResize();
886
        this.destroy$.next();
887
        this.destroy$.complete();
888
    }
889
}
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