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

atinc / ngx-tethys / e81fa91b-a6bc-42f6-8418-153d67e93cd4

20 May 2025 10:32AM UTC coverage: 90.221% (+0.006%) from 90.215%
e81fa91b-a6bc-42f6-8418-153d67e93cd4

push

circleci

web-flow
refactor(nav): migrate to signal for nav #TINFR-1487 (#3443)

5555 of 6818 branches covered (81.48%)

Branch coverage included in aggregate %.

39 of 42 new or added lines in 5 files covered. (92.86%)

12 existing lines in 2 files now uncovered.

13608 of 14422 relevant lines covered (94.36%)

906.14 hits per line

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

87.26
/src/nav/nav.component.ts
1
import {
2
    AfterContentChecked,
3
    AfterContentInit,
4
    AfterViewInit,
5
    ChangeDetectionStrategy,
6
    ChangeDetectorRef,
7
    Component,
8
    computed,
9
    contentChild,
10
    ContentChildren,
11
    contentChildren,
12
    DestroyRef,
13
    effect,
14
    ElementRef,
15
    inject,
16
    input,
17
    NgZone,
18
    OnChanges,
19
    OnDestroy,
1✔
20
    OnInit,
21
    QueryList,
22
    signal,
23
    Signal,
24
    SimpleChanges,
25
    TemplateRef,
26
    viewChild,
27
    WritableSignal
28
} from '@angular/core';
29
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
30
import { useHostRenderer } from '@tethys/cdk/dom';
31
import { ThyPopover, ThyPopoverConfig } from 'ngx-tethys/popover';
1✔
32
import { merge, Observable, of } from 'rxjs';
33
import { startWith, take, tap } from 'rxjs/operators';
34

35
import { NgClass, NgTemplateOutlet } from '@angular/common';
36
import { RouterLinkActive } from '@angular/router';
1✔
37
import { ThyPlacement } from 'ngx-tethys/core';
38
import { ThyDropdownMenuComponent, ThyDropdownMenuItemActiveDirective, ThyDropdownMenuItemDirective } from 'ngx-tethys/dropdown';
39
import { injectLocale, ThyNavLocale } from 'ngx-tethys/i18n';
40
import { ThyIcon } from 'ngx-tethys/icon';
41
import { coerceBooleanProperty } from 'ngx-tethys/util';
42
import { ThyNavInkBarDirective } from './nav-ink-bar.directive';
1✔
43
import { ThyNavItemDirective } from './nav-item.directive';
44
import { BypassSecurityTrustHtmlPipe } from './nav.pipe';
55✔
45

55✔
46
export type ThyNavType = 'pulled' | 'tabs' | 'pills' | 'lite' | 'card' | 'primary' | 'secondary' | 'thirdly' | 'secondary-divider';
47
export type ThyNavSize = 'lg' | 'md' | 'sm';
48
export type ThyNavHorizontal = '' | 'start' | 'center' | 'end';
697✔
49

50
const navTypeClassesMap = {
51
    pulled: ['thy-nav-pulled'],
270✔
52
    tabs: ['thy-nav-tabs'],
270✔
53
    pills: ['thy-nav-pills'],
54
    lite: ['thy-nav-lite'],
55
    card: ['thy-nav-card'],
73✔
56
    //如下类型已经废弃
73!
57
    primary: ['thy-nav-primary'],
73✔
58
    secondary: ['thy-nav-secondary'],
59
    thirdly: ['thy-nav-thirdly'],
73✔
60
    'secondary-divider': ['thy-nav-secondary-divider']
45✔
61
};
62

73✔
63
const navSizeClassesMap = {
64
    lg: 'thy-nav-lg',
65
    md: 'thy-nav-md',
49✔
66
    sm: 'thy-nav-sm'
49✔
67
};
49✔
68

49✔
69
const tabItemRight = 20;
49✔
70

49✔
71
/**
49✔
72
 * 导航组件
73
 * @name thy-nav
74
 * @order 10
75
 */
76
@Component({
77
    selector: 'thy-nav',
49✔
78
    templateUrl: './nav.component.html',
49✔
79
    host: {
49✔
80
        '[class.thy-nav]': 'true',
49✔
81
        '[class.thy-nav--vertical]': 'thyVertical()',
49✔
82
        '[class.thy-nav--fill]': 'thyFill()'
49✔
83
    },
49✔
84
    changeDetection: ChangeDetectionStrategy.OnPush,
49✔
85
    imports: [
49✔
86
        NgClass,
49✔
87
        NgTemplateOutlet,
49✔
88
        ThyNavItemDirective,
49✔
89
        ThyIcon,
49✔
90
        ThyNavInkBarDirective,
49✔
91
        ThyDropdownMenuComponent,
49✔
92
        ThyDropdownMenuItemDirective,
49✔
93
        ThyDropdownMenuItemActiveDirective,
49✔
94
        BypassSecurityTrustHtmlPipe
49✔
95
    ]
49✔
96
})
49✔
97
export class ThyNav implements OnInit, AfterViewInit, AfterContentInit, AfterContentChecked, OnChanges, OnDestroy {
49✔
98
    private elementRef = inject(ElementRef);
49✔
99
    private ngZone = inject(NgZone);
51✔
100
    private changeDetectorRef = inject(ChangeDetectorRef);
51!
101
    private popover = inject(ThyPopover);
102

49✔
103
    private readonly destroyRef = inject(DestroyRef);
49✔
104

72✔
105
    public initialized = false;
49✔
106
    public wrapperOffset: { height: number; width: number; left: number; top: number } = {
73✔
107
        height: 0,
108
        width: 0,
109
        left: 0,
110
        top: 0
49✔
111
    };
41✔
112

113
    public hiddenItems: ThyNavItemDirective[] = [];
114

115
    public moreActive: boolean;
49✔
116

8✔
117
    readonly showMore: WritableSignal<boolean> = signal(false);
8✔
118

8✔
119
    private moreBtnOffset: { height: number; width: number } = { height: 0, width: 0 };
21✔
120

8✔
121
    private hostRenderer = useHostRenderer();
122

123
    private innerLinks: QueryList<ThyNavItemDirective>;
49✔
124

49✔
125
    locale: Signal<ThyNavLocale> = injectLocale('nav');
55✔
126

6✔
127
    /**
128
     * 导航类型
159!
129
     * @type pulled | tabs | pills | lite | primary | secondary | thirdly | secondary-divider
130
     * @default pulled
4!
UNCOV
131
     */
×
132
    readonly thyType = input<ThyNavType>();
133

4✔
134
    /**
1✔
135
     * 导航大小
1✔
136
     * @type lg | md | sm
1✔
137
     * @default md
1✔
138
     */
139
    readonly thySize = input<ThyNavSize>('md');
4!
UNCOV
140

×
141
    /**
142
     * 水平排列
143
     * @type '' | 'start' | 'center' | 'end'
144
     * @default false
4✔
145
     */
146
    readonly thyHorizontal = input<ThyNavHorizontal>('');
147

148
    /**
149
     * 是否垂直排列
150
     * @default false
49✔
151
     */
8✔
152
    readonly thyVertical = input(false, { transform: coerceBooleanProperty });
8✔
153

154
    /**
155
     * 是否是填充模式
156
     */
157
    readonly thyFill = input(false, { transform: coerceBooleanProperty });
138✔
158

213✔
159
    /**
138✔
160
     * 是否响应式,自动计算宽度存放 thyNavItem,并添加更多弹框
25✔
161
     * @default false
162
     */
113✔
163
    readonly thyResponsive = input(undefined, { transform: coerceBooleanProperty });
53✔
164

165
    /**
166
     * 支持暂停自适应计算
167
     */
17✔
168
    thyPauseReCalculate = input<boolean>(false);
17✔
169

17✔
170
    /**
17!
171
     * 更多操作的菜单点击内部是否可关闭
17!
172
     * @deprecated please use thyPopoverOptions
173
     */
174
    readonly thyInsideClosable = input(true, { transform: coerceBooleanProperty });
UNCOV
175

×
UNCOV
176
    /**
×
UNCOV
177
     * 更多菜单弹出框的参数,底层使用 Popover 组件
×
UNCOV
178
     * @type ThyPopoverConfig
×
UNCOV
179
     */
×
180
    thyPopoverOptions = input<ThyPopoverConfig<unknown>>(null);
181

UNCOV
182
    /**
×
183
     * 右侧额外区域模板
184
     * @type TemplateRef
185
     */
186
    readonly thyExtra = input<TemplateRef<unknown>>();
187

214!
188
    /**
189
     * @private
190
     */
214✔
UNCOV
191
    @ContentChildren(ThyNavItemDirective, { descendants: true })
×
192
    set links(value) {
193
        this.innerLinks = value;
214✔
194
        this.prevActiveIndex = NaN;
214✔
195
    }
214✔
196
    get links(): QueryList<ThyNavItemDirective> {
197
        return this.innerLinks;
198
    }
199

200
    /**
139✔
201
     * @private
34✔
202
     */
203
    readonly routers = contentChildren(RouterLinkActive, { descendants: true });
139✔
204

205
    /**
206
     * 响应式模式下更多操作模板
9✔
207
     * @type TemplateRef
9✔
208
     */
9✔
209
    readonly moreOperation = contentChild<TemplateRef<unknown>>('more');
1✔
210

1✔
211
    /**
1✔
212
     * 响应式模式下更多弹框模板
213
     * @type TemplateRef
8✔
214
     */
8✔
215
    readonly morePopover = contentChild<TemplateRef<unknown>>('morePopover');
8!
216

7✔
217
    /**
218
     * 右侧额外区域模板,支持 thyExtra 传参和 <ng-template #extra></ng-template> 模板
8!
219
     * @name extra
8!
220
     * @type TemplateRef
17✔
221
     */
222
    readonly extra = contentChild<TemplateRef<unknown>>('extra');
8✔
223

8✔
224
    readonly defaultMoreOperation = viewChild<ElementRef<HTMLAnchorElement>>('moreOperationContainer');
225

226
    readonly inkBar = viewChild.required(ThyNavInkBarDirective);
7✔
227

7✔
228
    readonly horizontal = computed(() => {
7✔
229
        const horizontalValue = this.thyHorizontal() as string;
7✔
230
        return horizontalValue === 'right' ? 'end' : horizontalValue;
19✔
231
    });
19✔
232

7✔
233
    get showInkBar(): boolean {
7✔
234
        const showTypes: ThyNavType[] = ['pulled', 'tabs'];
1✔
235
        return showTypes.includes(this.type());
236
    }
237

6✔
238
    private updateClasses() {
239
        let classNames: string[] = [];
7✔
240
        if (navTypeClassesMap[this.type()]) {
241
            classNames = [...navTypeClassesMap[this.type()]];
242
        }
12✔
243
        if (navSizeClassesMap[this.thySize()]) {
12✔
244
            classNames.push(navSizeClassesMap[this.thySize()]);
245
        }
246
        this.hostRenderer.updateClass(classNames);
7✔
247
    }
248

249
    private curActiveIndex: number;
1✔
250

1✔
251
    private prevActiveIndex: number = NaN;
1✔
252

1✔
253
    private navSubscription: { unsubscribe: () => void } | null = null;
3✔
254

3✔
255
    readonly type = computed(() => this.thyType() || 'pulled');
1✔
256

1!
NEW
257
    constructor() {
×
258
        effect(() => {
259
            this.updateClasses();
260
        });
1✔
261
    }
262

1✔
263
    ngOnInit() {
264
        if (!this.thyResponsive()) {
265
            this.initialized = true;
2✔
266
        }
2✔
267
    }
268

269
    ngAfterViewInit() {
1✔
270
        if (this.thyResponsive()) {
271
            this.setMoreBtnOffset();
272
            this.ngZone.onStable.pipe(take(1)).subscribe(() => {
9✔
273
                this.setMoreBtnOffset();
9!
274
                this.links.toArray().forEach(link => link.setOffset());
9!
275
                this.setHiddenItems();
10✔
276
            });
10✔
277
        }
278

279
        this.ngZone.runOutsideAngular(() => {
280
            this.links.changes.pipe(startWith(this.links), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
2✔
281
                if (this.navSubscription) {
282
                    this.navSubscription.unsubscribe();
283
                }
284

285
                this.navSubscription = merge(
286
                    this.createResizeObserver(this.elementRef.nativeElement),
287
                    ...this.links.map(item => this.createResizeObserver(item.elementRef.nativeElement).pipe(tap(() => item.setOffset()))),
288
                    ...(this.routers() || []).map(router => router?.isActiveChange)
2!
289
                )
290
                    .pipe(
291
                        takeUntilDestroyed(this.destroyRef),
1✔
292
                        tap(() => {
293
                            if (this.thyPauseReCalculate()) {
294
                                return;
131✔
295
                            }
16✔
296

16✔
297
                            if (this.thyResponsive()) {
298
                                this.setMoreBtnOffset();
115✔
299
                                this.resetSizes();
115✔
300
                                this.setHiddenItems();
115✔
301
                                this.calculateMoreIsActive();
115✔
302
                            }
2✔
303

304
                            if (this.type() === 'card') {
115✔
305
                                this.setNavItemDivider();
66✔
306
                            }
66✔
307
                        })
308
                    )
309
                    .subscribe(() => {
310
                        this.alignInkBarToSelectedTab();
82✔
311
                    });
82✔
312
            });
74✔
313
        });
314
    }
315

316
    ngAfterContentInit(): void {
49!
317
        if (this.thyResponsive()) {
49✔
318
            this.ngZone.onStable.pipe(take(1)).subscribe(() => {
319
                this.resetSizes();
320
            });
1✔
321
        }
1✔
322
    }
323

324
    ngAfterContentChecked() {
325
        this.calculateMoreIsActive();
326

327
        this.curActiveIndex = this.links && this.links.length ? this.links.toArray().findIndex(item => item.linkIsActive()) : -1;
328
        if (this.curActiveIndex < 0) {
329
            this.inkBar().hide();
330
        } else if (this.curActiveIndex !== this.prevActiveIndex) {
331
            this.alignInkBarToSelectedTab();
332
        }
333
    }
334

335
    private setMoreBtnOffset() {
336
        const defaultMoreOperation = this.defaultMoreOperation();
337
        const computedStyle = window.getComputedStyle(defaultMoreOperation?.nativeElement);
338
        this.moreBtnOffset = {
339
            height: defaultMoreOperation?.nativeElement?.offsetHeight + parseFloat(computedStyle?.marginBottom) || 0,
340
            width: defaultMoreOperation?.nativeElement?.offsetWidth + parseFloat(computedStyle?.marginRight) || 0
341
        };
1✔
342
    }
343

344
    private setNavItemDivider() {
345
        const tabs = this.links.toArray();
346
        const activeIndex = tabs.findIndex(item => item.linkIsActive());
347

348
        for (let i = 0; i < tabs.length; i++) {
349
            if ((i !== activeIndex && i !== activeIndex - 1 && i !== tabs.length - 1) || (i === activeIndex - 1 && this.moreActive)) {
350
                tabs[i].addClass('has-right-divider');
351
            } else {
352
                tabs[i].removeClass('has-right-divider');
353
            }
354
        }
355
    }
356

357
    createResizeObserver(element: HTMLElement) {
358
        return typeof ResizeObserver === 'undefined'
359
            ? of(null)
360
            : new Observable(observer => {
361
                  const resize = new ResizeObserver(entries => {
362
                      observer.next(entries);
363
                  });
364
                  resize.observe(element);
365
                  return () => {
366
                      resize.disconnect();
367
                  };
368
              });
369
    }
370

371
    private calculateMoreIsActive() {
372
        this.moreActive = this.hiddenItems.some(item => {
373
            return item.linkIsActive();
374
        });
375
        this.changeDetectorRef.detectChanges();
376
    }
377

378
    private setHiddenItems() {
379
        this.moreActive = false;
380
        const tabs = this.links.toArray();
381
        if (!tabs.length) {
382
            this.hiddenItems = [];
383
            this.showMore.set(false);
384
            return;
385
        }
386

387
        const endIndex = this.thyVertical() ? this.getShowItemsEndIndexWhenVertical(tabs) : this.getShowItemsEndIndexWhenHorizontal(tabs);
388

389
        const showItems = tabs.slice(0, endIndex + 1);
390
        (showItems || []).forEach(item => {
391
            item.setNavLinkHidden(false);
392
        });
393

394
        this.hiddenItems = endIndex === tabs.length - 1 ? [] : tabs.slice(endIndex + 1);
395
        (this.hiddenItems || []).forEach(item => {
396
            item.setNavLinkHidden(true);
397
        });
398

399
        this.showMore.set(this.hiddenItems.length > 0);
400
        this.initialized = true;
401
    }
402

403
    private getShowItemsEndIndexWhenHorizontal(tabs: ThyNavItemDirective[]) {
404
        const tabsLength = tabs.length;
405
        let endIndex = tabsLength;
406
        let totalWidth = 0;
407

408
        for (let i = 0; i < tabsLength; i += 1) {
409
            const _totalWidth = i === tabsLength - 1 ? totalWidth + tabs[i].offset.width : totalWidth + tabs[i].offset.width + tabItemRight;
410
            if (_totalWidth > this.wrapperOffset.width) {
411
                let moreOperationWidth = this.moreBtnOffset.width;
412
                if (totalWidth + moreOperationWidth <= this.wrapperOffset.width) {
413
                    endIndex = i - 1;
414
                } else {
415
                    endIndex = i - 2;
416
                }
417
                break;
418
            } else {
419
                totalWidth = _totalWidth;
420
                endIndex = i;
421
            }
422
        }
423
        return endIndex;
424
    }
425

426
    private getShowItemsEndIndexWhenVertical(tabs: ThyNavItemDirective[]) {
427
        const tabsLength = tabs.length;
428
        let endIndex = tabsLength;
429
        let totalHeight = 0;
430
        for (let i = 0; i < tabsLength; i += 1) {
431
            const _totalHeight = totalHeight + tabs[i].offset.height;
432
            if (_totalHeight > this.wrapperOffset.height) {
433
                let moreOperationHeight = this.moreBtnOffset.height;
434
                if (totalHeight + moreOperationHeight <= this.wrapperOffset.height) {
435
                    endIndex = i - 1;
436
                } else {
437
                    endIndex = i - 2;
438
                }
439
                break;
440
            } else {
441
                totalHeight = _totalHeight;
442
                endIndex = i;
443
            }
444
        }
445
        return endIndex;
446
    }
447

448
    private resetSizes() {
449
        this.wrapperOffset = {
450
            height: this.elementRef.nativeElement.offsetHeight || 0,
451
            width: this.elementRef.nativeElement.offsetWidth || 0,
452
            left: this.elementRef.nativeElement.offsetLeft || 0,
453
            top: this.elementRef.nativeElement.offsetTop || 0
454
        };
455
    }
456

457
    openMoreMenu(event: Event, template: TemplateRef<any>) {
458
        this.popover.open(
459
            template,
460
            Object.assign(
461
                {
462
                    origin: event.currentTarget as HTMLElement,
463
                    hasBackdrop: true,
464
                    backdropClosable: true,
465
                    insideClosable: true,
466
                    placement: 'bottom' as ThyPlacement,
467
                    panelClass: 'thy-nav-list-popover',
468
                    originActiveClass: 'thy-nav-origin-active'
469
                },
470
                this.thyPopoverOptions() ? this.thyPopoverOptions() : {}
471
            )
472
        );
473
    }
474

475
    navItemClick(item: ThyNavItemDirective) {
476
        item.elementRef.nativeElement.click();
477
    }
478

479
    private alignInkBarToSelectedTab(): void {
480
        if (!this.showInkBar) {
481
            this.inkBar().hide();
482
            return;
483
        }
484
        const tabs = this.links?.toArray() ?? [];
485
        const selectedItem = tabs.find(item => item.linkIsActive());
486
        let selectedItemElement: HTMLElement = selectedItem && selectedItem.elementRef.nativeElement;
487

488
        if (selectedItem && this.moreActive) {
489
            selectedItemElement = this.defaultMoreOperation().nativeElement;
490
        }
491
        if (selectedItemElement) {
492
            this.prevActiveIndex = this.curActiveIndex;
493
            this.inkBar().alignToElement(selectedItemElement);
494
        }
495
    }
496

497
    ngOnChanges(changes: SimpleChanges): void {
498
        const { thyVertical, thyType } = changes;
499

500
        if (thyType?.currentValue !== thyType?.previousValue || thyVertical?.currentValue !== thyVertical?.previousValue) {
501
            this.alignInkBarToSelectedTab();
502
        }
503
    }
504

505
    ngOnDestroy() {
506
        if (this.navSubscription) {
507
            this.navSubscription.unsubscribe();
508
        }
509
    }
510
}
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

© 2026 Coveralls, Inc