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

atinc / ngx-tethys / #102

26 May 2026 08:11AM UTC coverage: 91.111% (+0.7%) from 90.407%
#102

push

web-flow
build: bump docgeni to 2.8.0-next.5 (#3809)

4571 of 5491 branches covered (83.25%)

Branch coverage included in aggregate %.

13141 of 13949 relevant lines covered (94.21%)

966.75 hits per line

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

86.78
/src/nav/nav.component.ts
1
import {
2
    ChangeDetectionStrategy,
3
    Component,
4
    computed,
5
    contentChild,
6
    contentChildren,
7
    DestroyRef,
8
    effect,
9
    ElementRef,
10
    inject,
11
    input,
12
    OnDestroy,
13
    OnInit,
14
    signal,
15
    Signal,
16
    TemplateRef,
17
    viewChild,
18
    WritableSignal,
19
    afterNextRender,
20
    afterEveryRender,
21
    untracked
22
} from '@angular/core';
23
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
24
import { useHostRenderer } from '@tethys/cdk/dom';
25
import { ThyPopover, ThyPopoverConfig } from 'ngx-tethys/popover';
26
import { from, merge, Observable, of } from 'rxjs';
27
import { tap } from 'rxjs/operators';
28

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

40
export type ThyNavType = 'pulled' | 'tabs' | 'pills' | 'lite' | 'card' | 'primary' | 'secondary' | 'thirdly' | 'secondary-divider';
41
export type ThyNavSize = 'lg' | 'md' | 'sm';
42
export type ThyNavHorizontal = '' | 'start' | 'center' | 'end';
43

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

57
const navSizeClassesMap = {
58
    lg: 'thy-nav-lg',
59
    md: 'thy-nav-md',
60
    sm: 'thy-nav-sm'
61
};
62

63
const tabItemRight = 20;
1✔
64

65
/**
66
 * 导航组件
67
 * @name thy-nav
68
 * @order 10
69
 */
1✔
70
@Component({
71
    selector: 'thy-nav',
72
    templateUrl: './nav.component.html',
73
    host: {
74
        '[class.thy-nav]': 'true',
75
        '[class.thy-nav--vertical]': 'thyVertical()',
76
        '[class.thy-nav--fill]': 'thyFill()'
77
    },
78
    changeDetection: ChangeDetectionStrategy.OnPush,
79
    imports: [
80
        NgClass,
81
        NgTemplateOutlet,
82
        ThyNavItemDirective,
83
        ThyIcon,
84
        ThyNavInkBarDirective,
85
        ThyDropdownMenuComponent,
86
        ThyDropdownMenuItemDirective,
87
        ThyDropdownMenuItemActiveDirective,
88
        BypassSecurityTrustHtmlPipe
89
    ]
90
})
91
export class ThyNav implements OnDestroy {
92
    public elementRef = inject(ElementRef);
93

94
    private popover = inject(ThyPopover);
95

96
    private readonly destroyRef = inject(DestroyRef);
97

1✔
98
    private hostRenderer = useHostRenderer();
49✔
99

49✔
100
    public readonly locale: Signal<ThyNavLocale> = injectLocale('nav');
49✔
101

49✔
102
    public readonly initialized: WritableSignal<boolean> = signal(false);
103

49✔
104
    private readonly wrapperOffset: WritableSignal<{ height: number; width: number; left: number; top: number }> = signal({
105
        height: 0,
49✔
106
        width: 0,
49✔
107
        left: 0,
108
        top: 0
109
    });
110

111
    public readonly hiddenItems: WritableSignal<ThyNavItemDirective[]> = signal([]);
112

113
    public readonly moreActive = computed(() => {
49✔
114
        return this.calculateMoreIsActive();
115
    });
116

117
    public readonly showMore = computed(() => {
49✔
118
        return this.hiddenItems().length > 0;
119
    });
49✔
120

121
    private readonly moreBtnOffset: WritableSignal<{ height: number; width: number }> = signal({ height: 0, width: 0 });
49✔
122

123
    /**
124
     * 导航类型
125
     * @type pulled | tabs | pills | lite | primary | secondary | thirdly | secondary-divider
49✔
126
     * @default pulled
127
     */
128
    readonly thyType = input<ThyNavType>();
129

130
    /**
131
     * 导航大小
132
     * @type lg | md | sm
49✔
133
     * @default md
134
     */
135
    readonly thySize = input<ThyNavSize>('md');
136

137
    /**
138
     * 水平排列
139
     * @type '' | 'start' | 'center' | 'end'
49✔
140
     * @default false
141
     */
142
    readonly thyHorizontal = input<ThyNavHorizontal>('');
143

144
    /**
145
     * 是否垂直排列
146
     * @default false
49✔
147
     */
148
    readonly thyVertical = input(false, { transform: coerceBooleanProperty });
149

150
    /**
151
     * 是否是填充模式
152
     */
49✔
153
    readonly thyFill = input(false, { transform: coerceBooleanProperty });
154

155
    /**
156
     * 是否响应式,自动计算宽度存放 thyNavItem,并添加更多弹框
157
     * @default false
49✔
158
     */
159
    readonly thyResponsive = input(undefined, { transform: coerceBooleanProperty });
160

161
    /**
162
     * 支持暂停自适应计算
163
     */
49✔
164
    readonly thyPauseReCalculate = input<boolean>(false);
165

166
    /**
167
     * 更多操作的菜单点击内部是否可关闭
168
     * @deprecated please use thyPopoverOptions
49✔
169
     */
170
    readonly thyInsideClosable = input(true, { transform: coerceBooleanProperty });
171

172
    /**
173
     * 更多菜单弹出框的参数,底层使用 Popover 组件
174
     * @type ThyPopoverConfig
49✔
175
     */
176
    readonly thyPopoverOptions = input<ThyPopoverConfig<unknown> | null>(null);
177

178
    /**
179
     * 右侧额外区域模板
180
     * @type TemplateRef
49✔
181
     */
182
    readonly thyExtra = input<TemplateRef<unknown>>();
183

184
    /**
185
     * @private
186
     */
49✔
187
    public readonly links = contentChildren(ThyNavItemDirective, { descendants: true });
188

189
    /**
190
     * @private
191
     */
192
    readonly routers = contentChildren(RouterLinkActive, { descendants: true });
193

55✔
194
    /**
55✔
195
     * 响应式模式下更多操作模板
196
     * @type TemplateRef
197
     */
694✔
198
    readonly moreOperation = contentChild<TemplateRef<unknown>>('more');
199

200
    /**
201
     * 响应式模式下更多弹框模板
202
     * @type TemplateRef
203
     */
49✔
204
    readonly morePopover = contentChild<TemplateRef<unknown>>('morePopover');
205

206
    /**
207
     * 右侧额外区域模板,支持 thyExtra 传参和 <ng-template #extra></ng-template> 模板
208
     * @name extra
209
     * @type TemplateRef
49✔
210
     */
211
    readonly extra = contentChild<TemplateRef<unknown>>('extra');
212

213
    readonly defaultMoreOperation = viewChild<ElementRef<HTMLAnchorElement>>('moreOperationContainer');
214

215
    readonly inkBar = viewChild.required(ThyNavInkBarDirective);
49✔
216

217
    readonly horizontal = computed(() => {
218
        const horizontalValue = this.thyHorizontal() as string;
219
        return horizontalValue === 'right' ? 'end' : horizontalValue;
220
    });
221

222
    readonly type = computed(() => this.thyType() || 'pulled');
49✔
223

224
    readonly showInkBar = computed(() => {
49✔
225
        const showTypes: ThyNavType[] = ['pulled', 'tabs'];
226
        return showTypes.includes(this.type());
49✔
227
    });
228

49✔
229
    private updateClasses() {
51✔
230
        let classNames: string[] = [];
51!
231
        if (navTypeClassesMap[this.type()]) {
232
            classNames = [...navTypeClassesMap[this.type()]];
233
        }
234
        if (navSizeClassesMap[this.thySize()]) {
267✔
235
            classNames.push(navSizeClassesMap[this.thySize()]);
267✔
236
        }
237
        this.hostRenderer.updateClass(classNames);
238
    }
239

73✔
240
    private curActiveIndex?: number;
73✔
241

73✔
242
    private prevActiveIndex: WritableSignal<number> = signal(NaN);
243

73✔
244
    private navSubscription: { unsubscribe: () => void } | null = null;
45✔
245

246
    constructor() {
73✔
247
        effect(() => {
248
            this.updateClasses();
249
        });
250

251
        effect(() => {
49✔
252
            (this.hiddenItems() || []).forEach(item => {
253
                item.setNavLinkHidden(true);
49✔
254
            });
255
        });
72✔
256

257
        effect(() => {
258
            const thyVertical = this.thyVertical();
49✔
259
            const thyType = this.thyType();
73✔
260

261
            untracked(() => {
262
                this.alignInkBarToSelectedTab();
263
            });
264
        });
49✔
265

41✔
266
        effect(() => {
267
            const links = this.links();
268
            this.prevActiveIndex.set(NaN);
269
            const responsive = this.thyResponsive();
270

49✔
271
            untracked(() => {
8✔
272
                if (this.navSubscription) {
8✔
273
                    this.navSubscription.unsubscribe();
8✔
274
                }
21✔
275

8✔
276
                this.navSubscription = merge(
277
                    this.createResizeObserver(this.elementRef.nativeElement),
278
                    ...links.map(item =>
279
                        this.createResizeObserver(item.elementRef.nativeElement).pipe(
49✔
280
                            tap(() => {
49✔
281
                                item.setOffset();
55✔
282
                            })
6✔
283
                        )
284
                    ),
285
                    ...(this.routers() || []).map(router => router?.isActiveChange)
55✔
286
                )
287
                    .pipe(
159✔
288
                        takeUntilDestroyed(this.destroyRef),
34!
289
                        tap(() => {
290
                            if (this.thyPauseReCalculate()) {
291
                                return;
292
                            }
293

4!
294
                            if (responsive) {
×
295
                                this.setMoreBtnOffset();
296
                                this.resetSizes();
297
                                this.setHiddenItems();
4✔
298
                            }
1✔
299

1✔
300
                            if (this.type() === 'card') {
1✔
301
                                this.setNavItemDivider();
1✔
302
                            }
303
                        })
304
                    )
4!
305
                    .subscribe(() => {
×
306
                        this.alignInkBarToSelectedTab();
307
                    });
308
            });
309
        });
310

4✔
311
        afterNextRender(() => {
312
            if (this.thyResponsive()) {
313
                from(Promise.resolve()).subscribe(() => {
314
                    this.setMoreBtnOffset();
315
                    this.links().forEach(link => link.setOffset());
316
                    this.setHiddenItems();
317
                    this.resetSizes();
49✔
318
                    this.initialized.set(true);
8✔
319
                });
8✔
320
            } else {
321
                this.initialized.set(true);
322
            }
323
        });
324

325
        afterEveryRender(() => {
138✔
326
            this.curActiveIndex = this.links() && this.links().length ? this.links().findIndex(item => item.linkIsActive()) : -1;
327
            if (this.curActiveIndex < 0) {
220✔
328
                this.inkBar().hide();
138✔
329
            } else if (this.curActiveIndex !== this.prevActiveIndex()) {
29✔
330
                this.alignInkBarToSelectedTab();
109✔
331
            }
50✔
332
        });
333
    }
334

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

344
    private setNavItemDivider() {
345
        const tabs = this.links();
×
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'
214!
359
            ? of(null)
360
            : new Observable(observer => {
361
                  const resize = new ResizeObserver(entries => {
214✔
362
                      observer.next(entries);
×
363
                  });
364
                  resize.observe(element);
214✔
365
                  return () => {
214✔
366
                      resize.disconnect();
214✔
367
                  };
368
              });
369
    }
370

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

378
    private setHiddenItems() {
379
        const tabs = this.links();
9✔
380
        if (!tabs.length) {
9✔
381
            this.hiddenItems.set([]);
9✔
382
            return;
1✔
383
        }
1✔
384

1✔
385
        const endIndex = this.thyVertical()
386
            ? this.getShowItemsEndIndexWhenVertical(tabs as ThyNavItemDirective[])
387
            : this.getShowItemsEndIndexWhenHorizontal(tabs as ThyNavItemDirective[]);
8✔
388

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

394
        this.hiddenItems.set(endIndex === tabs.length - 1 ? [] : tabs.slice(endIndex + 1));
8!
395
    }
8!
396

18✔
397
    private getShowItemsEndIndexWhenHorizontal(tabs: ThyNavItemDirective[]) {
398
        const tabsLength = tabs.length;
399
        let endIndex = tabsLength;
8✔
400
        let totalWidth = 0;
8✔
401

402
        for (let i = 0; i < tabsLength; i += 1) {
403
            const _totalWidth =
404
                i === tabsLength - 1 ? totalWidth + tabs[i].offset().width : totalWidth + tabs[i].offset().width + tabItemRight;
7✔
405
            if (_totalWidth > this.wrapperOffset().width) {
7✔
406
                const moreOperationWidth = this.moreBtnOffset().width;
7✔
407
                if (totalWidth + moreOperationWidth <= this.wrapperOffset().width) {
408
                    endIndex = i - 1;
7✔
409
                } else {
14!
410
                    endIndex = i - 2;
14✔
411
                }
7✔
412
                break;
7✔
413
            } else {
5✔
414
                totalWidth = _totalWidth;
415
                endIndex = i;
2✔
416
            }
417
        }
7✔
418
        return endIndex;
419
    }
7✔
420

7✔
421
    private getShowItemsEndIndexWhenVertical(tabs: ThyNavItemDirective[]) {
422
        const tabsLength = tabs.length;
423
        let endIndex = tabsLength;
7✔
424
        let totalHeight = 0;
425
        for (let i = 0; i < tabsLength; i += 1) {
426
            const _totalHeight = totalHeight + tabs[i].offset().height;
427
            if (_totalHeight > this.wrapperOffset().height) {
1✔
428
                const moreOperationHeight = this.moreBtnOffset().height;
1✔
429
                if (totalHeight + moreOperationHeight <= this.wrapperOffset().height) {
1✔
430
                    endIndex = i - 1;
1✔
431
                } else {
3✔
432
                    endIndex = i - 2;
3✔
433
                }
1✔
434
                break;
1!
435
            } else {
×
436
                totalHeight = _totalHeight;
437
                endIndex = i;
1✔
438
            }
439
        }
1✔
440
        return endIndex;
441
    }
2✔
442

2✔
443
    private resetSizes() {
444
        this.wrapperOffset.set({
445
            height: this.elementRef.nativeElement.clientHeight || 0,
1✔
446
            width: this.elementRef.nativeElement.clientWidth || 0,
447
            left: this.elementRef.nativeElement.offsetLeft || 0,
448
            top: this.elementRef.nativeElement.offsetTop || 0
449
        });
9✔
450
    }
9!
451

9!
452
    openMoreMenu(event: Event, template: TemplateRef<any>) {
10✔
453
        this.popover.open(
10✔
454
            template,
455
            Object.assign(
456
                {
457
                    origin: event.currentTarget as HTMLElement,
458
                    hasBackdrop: true,
2✔
459
                    backdropClosable: true,
460
                    insideClosable: true,
461
                    placement: 'bottom' as ThyPlacement,
462
                    panelClass: 'thy-nav-list-popover',
463
                    originActiveClass: 'thy-nav-origin-active'
464
                },
465
                this.thyPopoverOptions() ? this.thyPopoverOptions() : {}
466
            )
467
        );
468
    }
469

470
    navItemClick(item: ThyNavItemDirective) {
2!
471
        item.elementRef.nativeElement.click();
472
    }
473

474
    private alignInkBarToSelectedTab(): void {
475
        if (!this.showInkBar()) {
476
            this.inkBar().hide();
1✔
477
            return;
478
        }
479
        const tabs = this.links() ?? [];
480
        const selectedItem = tabs.find(item => item.linkIsActive());
128✔
481
        let selectedItemElement: HTMLElement = selectedItem && selectedItem.elementRef.nativeElement;
16✔
482

16✔
483
        if (selectedItem && this.moreActive()) {
484
            selectedItemElement = this.defaultMoreOperation()!.nativeElement;
112✔
485
        }
112✔
486
        if (selectedItemElement) {
112✔
487
            this.prevActiveIndex.set(this.curActiveIndex!);
488
            this.inkBar().alignToElement(selectedItemElement);
112✔
489
        }
2✔
490
    }
491

112✔
492
    ngOnDestroy() {
63✔
493
        if (this.navSubscription) {
63✔
494
            this.navSubscription.unsubscribe();
495
        }
496
    }
497
}
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