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

atinc / ngx-tethys / 8852256f-44ca-4146-9e87-ba8c9defd157

09 Jan 2025 03:51AM UTC coverage: 90.234% (-0.1%) from 90.355%
8852256f-44ca-4146-9e87-ba8c9defd157

push

circleci

minlovehua
feat(nav): support card type and support thyIsExtraAppend

5559 of 6819 branches covered (81.52%)

Branch coverage included in aggregate %.

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

17 existing lines in 2 files now uncovered.

13280 of 14059 relevant lines covered (94.46%)

991.43 hits per line

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

83.74
/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
    ]
643✔
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

75✔
97
    private readonly destroyRef = inject(DestroyRef);
75!
98

75✔
99
    public type: ThyNavType = 'pulled';
100
    public isOpenMore = false;
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()
49!
UNCOV
132
    set thyType(type: ThyNavType) {
×
133
        this.type = type || 'pulled';
134
        if (this.initialized) {
UNCOV
135
            this.updateClasses();
×
136
        }
137
    }
138

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1✔
254
    private curActiveIndex: number;
255

1✔
256
    private prevActiveIndex: number = NaN;
257

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

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

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

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

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

1✔
313
    ngAfterContentChecked() {
314
        this.calculateMoreIsActive();
315

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

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

331
    private setNavItemDivider() {
1✔
332
        const tabs = this.links.toArray();
333
        const activeIndex = tabs.findIndex(item => item.linkIsActive());
334
        const rightDividerClass = 'has-right-divider';
335

336
        for (let i = 0; i < tabs.length; i++) {
337
            const isOrdinaryItem = i !== activeIndex && i !== activeIndex - 1 && i !== tabs.length - 1;
338
            const isMorePreviewItem = this.moreActive && i === activeIndex - 1;
339
            const isExtraPreviewItem =
340
                i === tabs.length - 1 && i !== activeIndex && (this.thyExtra || this.extra) && this.thyIsExtraAppend();
341

342
            if (isOrdinaryItem || isMorePreviewItem || isExtraPreviewItem) {
343
                tabs[i].addClass(rightDividerClass);
344
            } else {
345
                tabs[i].removeClass(rightDividerClass);
346
            }
347
        }
348
    }
349

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

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

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

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

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

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

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

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

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

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

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

450
    openMore(event: Event, template: TemplateRef<any>) {
451
        this.isOpenMore = true;
452
        const popoverRef = this.popover.open(template, {
453
            origin: event.currentTarget as HTMLElement,
454
            hasBackdrop: true,
455
            backdropClosable: true,
456
            insideClosable: this.thyInsideClosable,
457
            placement: 'bottom',
458
            panelClass: 'thy-nav-list-popover',
459
            originActiveClass: 'thy-nav-origin-active'
460
        });
461
        popoverRef.afterClosed().subscribe(() => {
462
            this.isOpenMore = false;
463
        });
464
    }
465

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

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

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

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

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