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

atinc / ngx-tethys / 90e84fad-ae56-4546-8492-68bddb8737b8

08 Jan 2025 08:06AM UTC coverage: 90.298% (-0.006%) from 90.304%
90e84fad-ae56-4546-8492-68bddb8737b8

push

circleci

minlovehua
feat: card nav

5558 of 6808 branches covered (81.64%)

Branch coverage included in aggregate %.

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

10 existing lines in 2 files now uncovered.

13280 of 14054 relevant lines covered (94.49%)

991.85 hits per line

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

89.13
/src/nav/nav.component.ts
1
import { ThyPopover } 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

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

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

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

49✔
64
const tabItemRight = 20;
49✔
65

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

75✔
99
    private readonly destroyRef = inject(DestroyRef);
100

75✔
101
    private size: ThyNavSize = 'md';
47✔
102
    public initialized = false;
103

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

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

8✔
114
    public moreActive: boolean;
8✔
115

21✔
116
    public showMore = true;
8✔
117

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

49✔
120
    private hostRenderer = useHostRenderer();
137!
121

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

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

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

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

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

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

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

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

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

190
    /**
138✔
191
     * 右侧额外区域模板
192
     * @type TemplateRef
193
     */
10✔
194
    @Input() thyExtra: TemplateRef<unknown>;
10✔
195

10✔
196
    thyIsExtraAppend = input<boolean>(false);
1✔
197

1✔
198
    /**
1✔
199
     * @private
200
     */
9✔
201
    @ContentChildren(ThyNavItemDirective, { descendants: true })
9✔
202
    set links(value) {
9!
203
        this.innerLinks = value;
7✔
204
        this.prevActiveIndex = NaN;
205
    }
9!
206
    get links(): QueryList<ThyNavItemDirective> {
9!
207
        return this.innerLinks;
21✔
208
    }
209

9✔
210
    /**
9✔
211
     * @private
212
     */
213
    @ContentChildren(RouterLinkActive, { descendants: true }) routers: QueryList<RouterLinkActive>;
8✔
214

8✔
215
    /**
8✔
216
     * 响应式模式下更多操作模板
8✔
217
     * @type TemplateRef
21✔
218
     */
21✔
219
    @ContentChild('more') moreOperation: TemplateRef<unknown>;
8✔
220

8✔
221
    /**
1✔
222
     * 响应式模式下更多弹框模板
223
     * @type TemplateRef
224
     */
7✔
225
    @ContentChild('morePopover') morePopover: TemplateRef<unknown>;
226

8✔
227
    /**
228
     * 右侧额外区域模板,支持 thyExtra 传参和 <ng-template #extra></ng-template> 模板
229
     * @name extra
13✔
230
     * @type TemplateRef
13✔
231
     */
232
    @ContentChild('extra') extra: TemplateRef<unknown>;
233

8✔
234
    @ViewChild('moreOperationContainer') defaultMoreOperation: ElementRef<HTMLAnchorElement>;
235

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

1✔
238
    get showInkBar(): boolean {
1✔
239
        const showTypes: ThyNavType[] = ['pulled', 'tabs'];
1✔
240
        return showTypes.includes(this.type);
3✔
241
    }
3✔
242

1✔
243
    private updateClasses() {
1!
UNCOV
244
        let classNames: string[] = [];
×
245
        if (navTypeClassesMap[this.type]) {
246
            classNames = [...navTypeClassesMap[this.type]];
247
        }
1✔
248
        if (navSizeClassesMap[this.size]) {
249
            classNames.push(navSizeClassesMap[this.size]);
1✔
250
        }
251
        this.hostRenderer.updateClass(classNames);
252
    }
2✔
253

2✔
254
    private curActiveIndex: number;
255

256
    private prevActiveIndex: number = NaN;
1✔
257

258
    ngOnInit() {
259
        if (!this.thyResponsive) {
10✔
260
            this.initialized = true;
10!
261
        }
10!
262

12✔
263
        this.updateClasses();
12✔
264
    }
265

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

295
            merge(...this.links.map(item => this.createResizeObserver(item.elementRef.nativeElement)))
121✔
296
                .pipe(takeUntilDestroyed(this.destroyRef))
71✔
297
                .subscribe(() => {
71✔
298
                    this.setNavItemDivider();
299
                });
300
        });
301
    }
80✔
302

80✔
303
    ngAfterContentInit(): void {
72✔
304
        if (this.thyResponsive) {
305
            this.ngZone.onStable.pipe(take(1)).subscribe(() => {
306
                this.resetSizes();
1✔
307
            });
308
        }
309
    }
310

311
    ngAfterContentChecked() {
312
        this.calculateMoreIsActive();
313

314
        this.curActiveIndex = this.links && this.links.length ? this.links.toArray().findIndex(item => item.linkIsActive()) : -1;
315
        if (this.curActiveIndex < 0) {
316
            this.inkBar.hide();
317
        } else if (this.curActiveIndex !== this.prevActiveIndex) {
318
            this.alignInkBarToSelectedTab();
319
        }
320
    }
321

322
    private setMoreBtnOffset() {
323
        this.moreBtnOffset = {
324
            height: this.defaultMoreOperation?.nativeElement?.offsetHeight,
325
            width: this.defaultMoreOperation?.nativeElement?.offsetWidth
1✔
326
        };
327
    }
328

329
    private setNavItemDivider() {
330
        const tabs = this.links.toArray();
331
        const activeIndex = tabs.findIndex(item => item.linkIsActive());
332
        for (let i = 0; i < tabs.length; i++) {
333
            if ((i !== activeIndex && i !== activeIndex - 1) || (this.moreActive && i === activeIndex - 1)) {
334
                tabs[i].addClass('has-right-divider');
335
            } else {
336
                tabs[i].removeClass('has-right-divider');
337
            }
338
        }
339
    }
340

341
    createResizeObserver(element: HTMLElement) {
342
        return typeof ResizeObserver === 'undefined'
343
            ? of(null)
344
            : new Observable(observer => {
345
                  const resize = new ResizeObserver(entries => {
346
                      observer.next(entries);
347
                  });
348
                  resize.observe(element);
349
                  return () => {
350
                      resize.disconnect();
351
                  };
352
              });
353
    }
354

355
    private calculateMoreIsActive() {
356
        this.moreActive = this.hiddenItems.some(item => {
357
            return item.linkIsActive();
358
        });
359
        this.changeDetectorRef.detectChanges();
360
    }
361

362
    private setHiddenItems() {
363
        this.moreActive = false;
364
        const tabs = this.links.toArray();
365
        if (!tabs.length) {
366
            this.hiddenItems = [];
367
            this.showMore = this.hiddenItems.length > 0;
368
            return;
369
        }
370

371
        const endIndex = this.thyVertical ? this.getShowItemsEndIndexWhenVertical(tabs) : this.getShowItemsEndIndexWhenHorizontal(tabs);
372

373
        const showItems = tabs.slice(0, endIndex + 1);
374
        (showItems || []).forEach(item => {
375
            item.setNavLinkHidden(false);
376
        });
377

378
        this.hiddenItems = endIndex === tabs.length - 1 ? [] : tabs.slice(endIndex + 1);
379
        (this.hiddenItems || []).forEach(item => {
380
            item.setNavLinkHidden(true);
381
        });
382

383
        this.showMore = this.hiddenItems.length > 0;
384
        this.initialized = true;
385
    }
386

387
    private getShowItemsEndIndexWhenHorizontal(tabs: ThyNavItemDirective[]) {
388
        const tabsLength = tabs.length;
389
        let endIndex = tabsLength;
390
        let totalWidth = 0;
391

392
        for (let i = 0; i < tabsLength; i += 1) {
393
            const _totalWidth = i === tabsLength - 1 ? totalWidth + tabs[i].offset.width : totalWidth + tabs[i].offset.width + tabItemRight;
394
            if (_totalWidth > this.wrapperOffset.width) {
395
                let moreOperationWidth = this.moreBtnOffset.width;
396
                if (totalWidth + moreOperationWidth <= this.wrapperOffset.width) {
397
                    endIndex = i - 1;
398
                } else {
399
                    endIndex = i - 2;
400
                }
401
                break;
402
            } else {
403
                totalWidth = _totalWidth;
404
                endIndex = i;
405
            }
406
        }
407
        return endIndex;
408
    }
409

410
    private getShowItemsEndIndexWhenVertical(tabs: ThyNavItemDirective[]) {
411
        const tabsLength = tabs.length;
412
        let endIndex = tabsLength;
413
        let totalHeight = 0;
414
        for (let i = 0; i < tabsLength; i += 1) {
415
            const _totalHeight = totalHeight + tabs[i].offset.height;
416
            if (_totalHeight > this.wrapperOffset.height) {
417
                let moreOperationHeight = this.moreBtnOffset.height;
418
                if (totalHeight + moreOperationHeight <= this.wrapperOffset.height) {
419
                    endIndex = i - 1;
420
                } else {
421
                    endIndex = i - 2;
422
                }
423
                break;
424
            } else {
425
                totalHeight = _totalHeight;
426
                endIndex = i;
427
            }
428
        }
429
        return endIndex;
430
    }
431

432
    private resetSizes() {
433
        this.wrapperOffset = {
434
            height: this.elementRef.nativeElement.offsetHeight || 0,
435
            width: this.elementRef.nativeElement.offsetWidth || 0,
436
            left: this.elementRef.nativeElement.offsetLeft || 0,
437
            top: this.elementRef.nativeElement.offsetTop || 0
438
        };
439
    }
440

441
    openMore(event: Event, template: TemplateRef<any>) {
442
        this.isOpenMore = true;
443
        const popoverRef = this.popover.open(template, {
444
            origin: event.currentTarget as HTMLElement,
445
            hasBackdrop: true,
446
            backdropClosable: true,
447
            insideClosable: this.thyInsideClosable,
448
            placement: 'bottom',
449
            panelClass: 'thy-nav-list-popover',
450
            originActiveClass: 'thy-nav-origin-active'
451
        });
452
        popoverRef.afterClosed().subscribe(() => {
453
            this.isOpenMore = false;
454
        });
455
    }
456

457
    navItemClick(item: ThyNavItemDirective) {
458
        item.elementRef.nativeElement.click();
459
    }
460

461
    private alignInkBarToSelectedTab(): void {
462
        if (!this.showInkBar) {
463
            this.inkBar.hide();
464
            return;
465
        }
466
        const tabs = this.links?.toArray() ?? [];
467
        const selectedItem = tabs.find(item => item.linkIsActive());
468
        let selectedItemElement: HTMLElement = selectedItem && selectedItem.elementRef.nativeElement;
469

470
        if (selectedItem && this.moreActive) {
471
            selectedItemElement = this.defaultMoreOperation.nativeElement;
472
        }
473
        if (selectedItemElement) {
474
            this.prevActiveIndex = this.curActiveIndex;
475
            this.inkBar.alignToElement(selectedItemElement);
476
        }
477
    }
478

479
    ngOnChanges(changes: SimpleChanges): void {
480
        const { thyVertical, thyType } = changes;
481

482
        if (thyType?.currentValue !== thyType?.previousValue || thyVertical?.currentValue !== thyVertical?.previousValue) {
483
            this.alignInkBarToSelectedTab();
484
        }
485
    }
486
}
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