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

atinc / ngx-tethys / 3a5a9ef7-ad8c-494e-90ab-c18329961299

10 Feb 2025 09:09AM UTC coverage: 90.276% (-0.004%) from 90.28%
3a5a9ef7-ad8c-494e-90ab-c18329961299

push

circleci

web-flow
fix(nav): support thyPauseReCalculate and fix need to monitor the resize changes of the newly added nav item #TINFR-1497 (#3293)

5571 of 6827 branches covered (81.6%)

Branch coverage included in aggregate %.

19 of 20 new or added lines in 1 file covered. (95.0%)

8 existing lines in 1 file now uncovered.

13284 of 14059 relevant lines covered (94.49%)

991.4 hits per line

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

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

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

42
export type ThyNavType = 'pulled' | 'tabs' | 'pills' | 'lite' | 'card' | 'primary' | 'secondary' | 'thirdly' | 'secondary-divider';
1✔
43
export type ThyNavSize = 'lg' | 'md' | 'sm';
44
export type ThyNavHorizontal = '' | 'start' | 'center' | 'end';
49✔
45

49✔
46
const navTypeClassesMap = {
49✔
47
    pulled: ['thy-nav-pulled'],
49✔
48
    tabs: ['thy-nav-tabs'],
49✔
49
    pills: ['thy-nav-pills'],
49✔
50
    lite: ['thy-nav-lite'],
49✔
51
    card: ['thy-nav-card'],
49✔
52
    //如下类型已经废弃
49✔
53
    primary: ['thy-nav-primary'],
54
    secondary: ['thy-nav-secondary'],
55
    thirdly: ['thy-nav-thirdly'],
56
    'secondary-divider': ['thy-nav-secondary-divider']
57
};
58

49✔
59
const navSizeClassesMap = {
49✔
60
    lg: 'thy-nav-lg',
49✔
61
    md: 'thy-nav-md',
49✔
62
    sm: 'thy-nav-sm'
49✔
63
};
49✔
64

49✔
65
const tabItemRight = 20;
49✔
66

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

77✔
98
    private readonly destroyRef = inject(DestroyRef);
77!
99

77✔
100
    public type: ThyNavType = 'pulled';
101
    private size: ThyNavSize = 'md';
77✔
102
    public initialized = false;
48✔
103

104
    public horizontal: ThyNavHorizontal;
77✔
105
    public wrapperOffset: { height: number; width: number; left: number; top: number } = {
106
        height: 0,
107
        width: 0,
49✔
108
        left: 0,
41✔
109
        top: 0
110
    };
49✔
111

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

49✔
114
    public moreActive: boolean;
8✔
115

8✔
116
    public showMore = true;
21✔
117

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

120
    private hostRenderer = useHostRenderer();
49✔
121

49✔
122
    private innerLinks: QueryList<ThyNavItemDirective>;
55✔
123

6✔
124
    locale: Signal<ThyNavLocale> = injectLocale('nav');
125

159!
126
    /**
127
     * 导航类型
4!
UNCOV
128
     * @type pulled | tabs | pills | lite | primary | secondary | thirdly | secondary-divider
×
129
     * @default pulled
130
     */
4✔
131
    @Input()
1✔
132
    set thyType(type: ThyNavType) {
1✔
133
        this.type = type || 'pulled';
1✔
134
        if (this.initialized) {
135
            this.updateClasses();
4!
UNCOV
136
        }
×
137
    }
138

139
    /**
140
     * 导航大小
4✔
141
     * @type lg | md | sm
142
     * @default md
143
     */
144
    @Input()
145
    set thySize(size: ThyNavSize) {
146
        this.size = size;
49✔
147
        if (this.initialized) {
8✔
148
            this.updateClasses();
8✔
149
        }
150
    }
151

152
    /**
153
     * 水平排列
138✔
154
     * @type '' | 'start' | 'center' | 'end'
213✔
155
     * @default false
138✔
156
     */
25✔
157
    @Input()
158
    set thyHorizontal(horizontal: ThyNavHorizontal) {
113✔
159
        this.horizontal = (horizontal as string) === 'right' ? 'end' : horizontal;
53✔
160
    }
161

162
    /**
163
     * 是否垂直排列
8✔
164
     * @default false
165
     */
166
    @HostBinding('class.thy-nav--vertical')
167
    @Input({ transform: coerceBooleanProperty })
168
    thyVertical: boolean;
UNCOV
169

×
170
    /**
×
UNCOV
171
     * 是否是填充模式
×
UNCOV
172
     */
×
UNCOV
173
    @HostBinding('class.thy-nav--fill')
×
174
    @Input({ transform: coerceBooleanProperty })
175
    thyFill: boolean = false;
UNCOV
176

×
177
    /**
178
     * 是否响应式,自动计算宽度存放 thyNavItem,并添加更多弹框
179
     * @default false
180
     */
181
    @Input({ transform: coerceBooleanProperty })
214!
182
    thyResponsive: boolean;
183

184
    /**
214✔
NEW
185
     * 支持暂停自适应计算
×
186
     */
187
    thyPauseReCalculate = input<boolean>(false);
214✔
188

214✔
189
    /**
214✔
190
     * 更多操作的菜单点击内部是否可关闭
191
     * @deprecated please use thyPopoverOptions
192
     */
193
    @Input({ transform: coerceBooleanProperty })
194
    thyInsideClosable = true;
139✔
195

34✔
196
    /**
197
     * 更多菜单弹出框的参数,底层使用 Popover 组件
139✔
198
     * @type ThyPopoverConfig
199
     */
200
    thyPopoverOptions = input<ThyPopoverConfig<unknown>>(null);
9✔
201

9✔
202
    /**
9✔
203
     * 右侧额外区域模板
1✔
204
     * @type TemplateRef
1✔
205
     */
1✔
206
    @Input() thyExtra: TemplateRef<unknown>;
207

8✔
208
    /**
8✔
209
     * @private
8!
210
     */
7✔
211
    @ContentChildren(ThyNavItemDirective, { descendants: true })
212
    set links(value) {
8!
213
        this.innerLinks = value;
8!
214
        this.prevActiveIndex = NaN;
17✔
215
    }
216
    get links(): QueryList<ThyNavItemDirective> {
8✔
217
        return this.innerLinks;
8✔
218
    }
219

220
    /**
7✔
221
     * @private
7✔
222
     */
7✔
223
    @ContentChildren(RouterLinkActive, { descendants: true }) routers: QueryList<RouterLinkActive>;
7✔
224

19✔
225
    /**
19✔
226
     * 响应式模式下更多操作模板
7✔
227
     * @type TemplateRef
7✔
228
     */
1✔
229
    @ContentChild('more') moreOperation: TemplateRef<unknown>;
230

231
    /**
6✔
232
     * 响应式模式下更多弹框模板
233
     * @type TemplateRef
7✔
234
     */
235
    @ContentChild('morePopover') morePopover: TemplateRef<unknown>;
236

12✔
237
    /**
12✔
238
     * 右侧额外区域模板,支持 thyExtra 传参和 <ng-template #extra></ng-template> 模板
239
     * @name extra
240
     * @type TemplateRef
7✔
241
     */
242
    @ContentChild('extra') extra: TemplateRef<unknown>;
243

1✔
244
    @ViewChild('moreOperationContainer') defaultMoreOperation: ElementRef<HTMLAnchorElement>;
1✔
245

1✔
246
    @ViewChild(ThyNavInkBarDirective, { static: true }) inkBar!: ThyNavInkBarDirective;
1✔
247

3✔
248
    get showInkBar(): boolean {
3✔
249
        const showTypes: ThyNavType[] = ['pulled', 'tabs'];
1✔
250
        return showTypes.includes(this.type);
1!
UNCOV
251
    }
×
252

253
    private updateClasses() {
254
        let classNames: string[] = [];
1✔
255
        if (navTypeClassesMap[this.type]) {
256
            classNames = [...navTypeClassesMap[this.type]];
1✔
257
        }
258
        if (navSizeClassesMap[this.size]) {
259
            classNames.push(navSizeClassesMap[this.size]);
2✔
260
        }
2✔
261
        this.hostRenderer.updateClass(classNames);
262
    }
263

1✔
264
    private curActiveIndex: number;
265

266
    private prevActiveIndex: number = NaN;
9✔
267

9!
268
    private navSubscription: { unsubscribe: () => void } | null = null;
9!
269

10✔
270
    ngOnInit() {
10✔
271
        if (!this.thyResponsive) {
272
            this.initialized = true;
273
        }
274

2✔
275
        this.updateClasses();
276
    }
277

278
    ngAfterViewInit() {
279
        if (this.thyResponsive) {
280
            this.setMoreBtnOffset();
281
            this.ngZone.onStable.pipe(take(1)).subscribe(() => {
282
                this.links.toArray().forEach(link => link.setOffset());
2!
283
                this.setHiddenItems();
284
            });
285
        }
1✔
286

287
        this.ngZone.runOutsideAngular(() => {
288
            this.links.changes.pipe(startWith(this.links), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
131✔
289
                if (this.navSubscription) {
16✔
290
                    this.navSubscription.unsubscribe();
16✔
291
                }
292

115✔
293
                this.navSubscription = merge(
115✔
294
                    this.createResizeObserver(this.elementRef.nativeElement),
115✔
295
                    ...this.links.map(item => this.createResizeObserver(item.elementRef.nativeElement).pipe(tap(() => item.setOffset()))),
115✔
296
                    ...(this.routers || []).map(router => router?.isActiveChange)
2✔
297
                )
298
                    .pipe(
115✔
299
                        takeUntilDestroyed(this.destroyRef),
66✔
300
                        tap(() => {
66✔
301
                            if (this.thyPauseReCalculate()) {
302
                                return;
303
                            }
304

82✔
305
                            if (this.thyResponsive) {
82✔
306
                                this.resetSizes();
74✔
307
                                this.setHiddenItems();
308
                                this.calculateMoreIsActive();
309
                            }
310

49!
311
                            if (this.type === 'card') {
49✔
312
                                this.setNavItemDivider();
313
                            }
314
                        })
1✔
315
                    )
316
                    .subscribe(() => {
317
                        this.alignInkBarToSelectedTab();
318
                    });
319
            });
320
        });
321
    }
322

323
    ngAfterContentInit(): void {
324
        if (this.thyResponsive) {
325
            this.ngZone.onStable.pipe(take(1)).subscribe(() => {
326
                this.resetSizes();
327
            });
328
        }
329
    }
330

331
    ngAfterContentChecked() {
332
        this.calculateMoreIsActive();
333

334
        this.curActiveIndex = this.links && this.links.length ? this.links.toArray().findIndex(item => item.linkIsActive()) : -1;
1✔
335
        if (this.curActiveIndex < 0) {
336
            this.inkBar.hide();
337
        } else if (this.curActiveIndex !== this.prevActiveIndex) {
338
            this.alignInkBarToSelectedTab();
339
        }
340
    }
341

342
    private setMoreBtnOffset() {
343
        this.moreBtnOffset = {
344
            height: this.defaultMoreOperation?.nativeElement?.offsetHeight,
345
            width: this.defaultMoreOperation?.nativeElement?.offsetWidth
346
        };
347
    }
348

349
    private setNavItemDivider() {
350
        const tabs = this.links.toArray();
351
        const activeIndex = tabs.findIndex(item => item.linkIsActive());
352

353
        for (let i = 0; i < tabs.length; i++) {
354
            if ((i !== activeIndex && i !== activeIndex - 1 && i !== tabs.length - 1) || (i === activeIndex - 1 && this.moreActive)) {
355
                tabs[i].addClass('has-right-divider');
356
            } else {
357
                tabs[i].removeClass('has-right-divider');
358
            }
359
        }
360
    }
361

362
    createResizeObserver(element: HTMLElement) {
363
        return typeof ResizeObserver === 'undefined'
364
            ? of(null)
365
            : new Observable(observer => {
366
                  const resize = new ResizeObserver(entries => {
367
                      observer.next(entries);
368
                  });
369
                  resize.observe(element);
370
                  return () => {
371
                      resize.disconnect();
372
                  };
373
              });
374
    }
375

376
    private calculateMoreIsActive() {
377
        this.moreActive = this.hiddenItems.some(item => {
378
            return item.linkIsActive();
379
        });
380
        this.changeDetectorRef.detectChanges();
381
    }
382

383
    private setHiddenItems() {
384
        this.moreActive = false;
385
        const tabs = this.links.toArray();
386
        if (!tabs.length) {
387
            this.hiddenItems = [];
388
            this.showMore = this.hiddenItems.length > 0;
389
            return;
390
        }
391

392
        const endIndex = this.thyVertical ? this.getShowItemsEndIndexWhenVertical(tabs) : this.getShowItemsEndIndexWhenHorizontal(tabs);
393

394
        const showItems = tabs.slice(0, endIndex + 1);
395
        (showItems || []).forEach(item => {
396
            item.setNavLinkHidden(false);
397
        });
398

399
        this.hiddenItems = endIndex === tabs.length - 1 ? [] : tabs.slice(endIndex + 1);
400
        (this.hiddenItems || []).forEach(item => {
401
            item.setNavLinkHidden(true);
402
        });
403

404
        this.showMore = this.hiddenItems.length > 0;
405
        this.initialized = true;
406
    }
407

408
    private getShowItemsEndIndexWhenHorizontal(tabs: ThyNavItemDirective[]) {
409
        const tabsLength = tabs.length;
410
        let endIndex = tabsLength;
411
        let totalWidth = 0;
412

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

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

453
    private resetSizes() {
454
        this.wrapperOffset = {
455
            height: this.elementRef.nativeElement.offsetHeight || 0,
456
            width: this.elementRef.nativeElement.offsetWidth || 0,
457
            left: this.elementRef.nativeElement.offsetLeft || 0,
458
            top: this.elementRef.nativeElement.offsetTop || 0
459
        };
460
    }
461

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

480
    navItemClick(item: ThyNavItemDirective) {
481
        item.elementRef.nativeElement.click();
482
    }
483

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

493
        if (selectedItem && this.moreActive) {
494
            selectedItemElement = this.defaultMoreOperation.nativeElement;
495
        }
496
        if (selectedItemElement) {
497
            this.prevActiveIndex = this.curActiveIndex;
498
            this.inkBar.alignToElement(selectedItemElement);
499
        }
500
    }
501

502
    ngOnChanges(changes: SimpleChanges): void {
503
        const { thyVertical, thyType } = changes;
504

505
        if (thyType?.currentValue !== thyType?.previousValue || thyVertical?.currentValue !== thyVertical?.previousValue) {
506
            this.alignInkBarToSelectedTab();
507
        }
508
    }
509

510
    ngOnDestroy() {
511
        if (this.navSubscription) {
512
            this.navSubscription.unsubscribe();
513
        }
514
    }
515
}
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