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

atinc / ngx-tethys / 9a7aecde-647c-4d32-bb4c-f876e44b7d0e

13 Jan 2025 09:57AM UTC coverage: 90.28% (-0.08%) from 90.355%
9a7aecde-647c-4d32-bb4c-f876e44b7d0e

push

circleci

web-flow
feat(nav): support card type (#3289)

5560 of 6813 branches covered (81.61%)

Branch coverage included in aggregate %.

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

15 existing lines in 2 files now uncovered.

13276 of 14051 relevant lines covered (94.48%)

991.96 hits per line

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

87.5
/src/nav/nav.component.ts
1
import { ThyPopover, ThyPopoverConfig } from 'ngx-tethys/popover';
2
import { merge, Observable, of } from 'rxjs';
3
import { debounceTime, 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
/**
68
 * 导航组件
69
 * @name thy-nav
72✔
70
 * @order 10
72✔
71
 */
23✔
72
@Component({
73
    selector: 'thy-nav',
74
    templateUrl: './nav.component.html',
75
    host: {
52✔
76
        class: 'thy-nav'
52✔
77
    },
5✔
78
    changeDetection: ChangeDetectionStrategy.OnPush,
79
    standalone: true,
80
    imports: [
81
        NgClass,
21!
82
        NgTemplateOutlet,
83
        ThyNavItemDirective,
84
        ThyIcon,
55✔
85
        ThyNavInkBarDirective,
55✔
86
        ThyDropdownMenuComponent,
87
        ThyDropdownMenuItemDirective,
88
        ThyDropdownMenuItemActiveDirective,
649✔
89
        BypassSecurityTrustHtmlPipe
90
    ]
91
})
277✔
92
export class ThyNav implements OnInit, AfterViewInit, AfterContentInit, AfterContentChecked, OnChanges {
277✔
93
    private elementRef = inject(ElementRef);
94
    private ngZone = inject(NgZone);
95
    private changeDetectorRef = inject(ChangeDetectorRef);
77✔
96
    private popover = inject(ThyPopover);
77!
97

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

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

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

49✔
112
    public hiddenItems: ThyNavItemDirective[] = [];
8✔
113

8✔
114
    public moreActive: boolean;
21✔
115

8✔
116
    public showMore = true;
117

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

137!
120
    private hostRenderer = useHostRenderer();
121

10✔
122
    private innerLinks: QueryList<ThyNavItemDirective>;
2✔
123

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

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

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

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

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

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

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

186✔
184
    /**
185
     * 更多操作的菜单点击内部是否可关闭
186
     * @deprecated please use thyPopoverOptions
187
     */
188
    @Input({ transform: coerceBooleanProperty })
140✔
189
    thyInsideClosable = true;
42✔
190

191
    /**
140✔
192
     * 更多菜单弹出框的参数,底层使用 Popover 组件
193
     * @type ThyPopoverConfig
194
     */
10✔
195
    thyPopoverOptions = input<ThyPopoverConfig<unknown>>(null);
10✔
196

10✔
197
    /**
1✔
198
     * 右侧额外区域模板
1✔
199
     * @type TemplateRef
1✔
200
     */
201
    @Input() thyExtra: TemplateRef<unknown>;
9✔
202

9✔
203
    /**
9!
204
     * @private
7✔
205
     */
206
    @ContentChildren(ThyNavItemDirective, { descendants: true })
9!
207
    set links(value) {
9!
208
        this.innerLinks = value;
21✔
209
        this.prevActiveIndex = NaN;
210
    }
9✔
211
    get links(): QueryList<ThyNavItemDirective> {
9✔
212
        return this.innerLinks;
213
    }
214

8✔
215
    /**
8✔
216
     * @private
8✔
217
     */
8✔
218
    @ContentChildren(RouterLinkActive, { descendants: true }) routers: QueryList<RouterLinkActive>;
21✔
219

21✔
220
    /**
8✔
221
     * 响应式模式下更多操作模板
8✔
222
     * @type TemplateRef
1✔
223
     */
224
    @ContentChild('more') moreOperation: TemplateRef<unknown>;
225

7✔
226
    /**
227
     * 响应式模式下更多弹框模板
8✔
228
     * @type TemplateRef
229
     */
230
    @ContentChild('morePopover') morePopover: TemplateRef<unknown>;
13✔
231

13✔
232
    /**
233
     * 右侧额外区域模板,支持 thyExtra 传参和 <ng-template #extra></ng-template> 模板
234
     * @name extra
8✔
235
     * @type TemplateRef
236
     */
237
    @ContentChild('extra') extra: TemplateRef<unknown>;
1✔
238

1✔
239
    @ViewChild('moreOperationContainer') defaultMoreOperation: ElementRef<HTMLAnchorElement>;
1✔
240

1✔
241
    @ViewChild(ThyNavInkBarDirective, { static: true }) inkBar!: ThyNavInkBarDirective;
3✔
242

3✔
243
    get showInkBar(): boolean {
1✔
244
        const showTypes: ThyNavType[] = ['pulled', 'tabs'];
1!
UNCOV
245
        return showTypes.includes(this.type);
×
246
    }
247

248
    private updateClasses() {
1✔
249
        let classNames: string[] = [];
250
        if (navTypeClassesMap[this.type]) {
1✔
251
            classNames = [...navTypeClassesMap[this.type]];
252
        }
253
        if (navSizeClassesMap[this.size]) {
2✔
254
            classNames.push(navSizeClassesMap[this.size]);
2✔
255
        }
256
        this.hostRenderer.updateClass(classNames);
257
    }
1✔
258

259
    private curActiveIndex: number;
260

10✔
261
    private prevActiveIndex: number = NaN;
10!
262

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

268
        this.updateClasses();
2✔
269
    }
270

271
    ngAfterViewInit() {
272
        if (this.thyResponsive) {
273
            this.setMoreBtnOffset();
274
            this.ngZone.onStable.pipe(take(1)).subscribe(() => {
275
                this.links.toArray().forEach(link => link.setOffset());
276
                this.setHiddenItems();
2!
277
            });
278
        }
279
        this.ngZone.runOutsideAngular(() => {
1✔
280
            merge(
281
                this.links.changes,
282
                this.createResizeObserver(this.elementRef.nativeElement).pipe(debounceTime(100)),
137✔
283
                ...this.links.map(item => this.createResizeObserver(item.elementRef.nativeElement).pipe(debounceTime(100))),
16✔
284
                ...(this.routers || []).map(router => router?.isActiveChange)
16✔
285
            )
286
                .pipe(
121✔
287
                    takeUntilDestroyed(this.destroyRef),
121✔
288
                    tap(() => {
121✔
289
                        if (this.thyResponsive) {
121✔
290
                            this.resetSizes();
2✔
291
                            this.setHiddenItems();
292
                            this.calculateMoreIsActive();
121✔
293
                        }
71✔
294
                    })
71✔
295
                )
296
                .subscribe(() => {
297
                    this.alignInkBarToSelectedTab();
298
                });
82✔
299

82✔
300
            if (this.type === 'card') {
74✔
301
                merge(this.links.changes, ...this.links.map(item => this.createResizeObserver(item.elementRef.nativeElement)))
302
                    .pipe(takeUntilDestroyed(this.destroyRef))
303
                    .subscribe(() => {
1✔
304
                        this.setNavItemDivider();
305
                    });
306
            }
307
        });
308
    }
309

310
    ngAfterContentInit(): void {
311
        if (this.thyResponsive) {
312
            this.ngZone.onStable.pipe(take(1)).subscribe(() => {
313
                this.resetSizes();
314
            });
315
        }
316
    }
317

318
    ngAfterContentChecked() {
319
        this.calculateMoreIsActive();
320

321
        this.curActiveIndex = this.links && this.links.length ? this.links.toArray().findIndex(item => item.linkIsActive()) : -1;
322
        if (this.curActiveIndex < 0) {
1✔
323
            this.inkBar.hide();
324
        } else if (this.curActiveIndex !== this.prevActiveIndex) {
325
            this.alignInkBarToSelectedTab();
326
        }
327
    }
328

329
    private setMoreBtnOffset() {
330
        this.moreBtnOffset = {
331
            height: this.defaultMoreOperation?.nativeElement?.offsetHeight,
332
            width: this.defaultMoreOperation?.nativeElement?.offsetWidth
333
        };
334
    }
335

336
    private setNavItemDivider() {
337
        const tabs = this.links.toArray();
338
        const activeIndex = tabs.findIndex(item => item.linkIsActive());
339

340
        for (let i = 0; i < tabs.length; i++) {
341
            if ((i !== activeIndex && i !== activeIndex - 1 && i !== tabs.length - 1) || (i === activeIndex - 1 && this.moreActive)) {
342
                tabs[i].addClass('has-right-divider');
343
            } else {
344
                tabs[i].removeClass('has-right-divider');
345
            }
346
        }
347
    }
348

349
    createResizeObserver(element: HTMLElement) {
350
        return typeof ResizeObserver === 'undefined'
351
            ? of(null)
352
            : new Observable(observer => {
353
                  const resize = new ResizeObserver(entries => {
354
                      observer.next(entries);
355
                  });
356
                  resize.observe(element);
357
                  return () => {
358
                      resize.disconnect();
359
                  };
360
              });
361
    }
362

363
    private calculateMoreIsActive() {
364
        this.moreActive = this.hiddenItems.some(item => {
365
            return item.linkIsActive();
366
        });
367
        this.changeDetectorRef.detectChanges();
368
    }
369

370
    private setHiddenItems() {
371
        this.moreActive = false;
372
        const tabs = this.links.toArray();
373
        if (!tabs.length) {
374
            this.hiddenItems = [];
375
            this.showMore = this.hiddenItems.length > 0;
376
            return;
377
        }
378

379
        const endIndex = this.thyVertical ? this.getShowItemsEndIndexWhenVertical(tabs) : this.getShowItemsEndIndexWhenHorizontal(tabs);
380

381
        const showItems = tabs.slice(0, endIndex + 1);
382
        (showItems || []).forEach(item => {
383
            item.setNavLinkHidden(false);
384
        });
385

386
        this.hiddenItems = endIndex === tabs.length - 1 ? [] : tabs.slice(endIndex + 1);
387
        (this.hiddenItems || []).forEach(item => {
388
            item.setNavLinkHidden(true);
389
        });
390

391
        this.showMore = this.hiddenItems.length > 0;
392
        this.initialized = true;
393
    }
394

395
    private getShowItemsEndIndexWhenHorizontal(tabs: ThyNavItemDirective[]) {
396
        const tabsLength = tabs.length;
397
        let endIndex = tabsLength;
398
        let totalWidth = 0;
399

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

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

440
    private resetSizes() {
441
        this.wrapperOffset = {
442
            height: this.elementRef.nativeElement.offsetHeight || 0,
443
            width: this.elementRef.nativeElement.offsetWidth || 0,
444
            left: this.elementRef.nativeElement.offsetLeft || 0,
445
            top: this.elementRef.nativeElement.offsetTop || 0
446
        };
447
    }
448

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

467
    navItemClick(item: ThyNavItemDirective) {
468
        item.elementRef.nativeElement.click();
469
    }
470

471
    private alignInkBarToSelectedTab(): void {
472
        if (!this.showInkBar) {
473
            this.inkBar.hide();
474
            return;
475
        }
476
        const tabs = this.links?.toArray() ?? [];
477
        const selectedItem = tabs.find(item => item.linkIsActive());
478
        let selectedItemElement: HTMLElement = selectedItem && selectedItem.elementRef.nativeElement;
479

480
        if (selectedItem && this.moreActive) {
481
            selectedItemElement = this.defaultMoreOperation.nativeElement;
482
        }
483
        if (selectedItemElement) {
484
            this.prevActiveIndex = this.curActiveIndex;
485
            this.inkBar.alignToElement(selectedItemElement);
486
        }
487
    }
488

489
    ngOnChanges(changes: SimpleChanges): void {
490
        const { thyVertical, thyType } = changes;
491

492
        if (thyType?.currentValue !== thyType?.previousValue || thyVertical?.currentValue !== thyVertical?.previousValue) {
493
            this.alignInkBarToSelectedTab();
494
        }
495
    }
496
}
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