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

atinc / ngx-tethys / ea558a85-3cd6-4571-bcfc-1e0cc10c7d45

10 Apr 2024 04:59AM UTC coverage: 90.369% (-0.04%) from 90.404%
ea558a85-3cd6-4571-bcfc-1e0cc10c7d45

push

circleci

web-flow
refactor(all): use takeUntilDestroyed instead of mixinUnsubscribe INFR-9529 (#3061)

5411 of 6635 branches covered (81.55%)

Branch coverage included in aggregate %.

45 of 48 new or added lines in 16 files covered. (93.75%)

53 existing lines in 8 files now uncovered.

13149 of 13903 relevant lines covered (94.58%)

981.76 hits per line

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

94.23
/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
    booleanAttribute,
11
    ChangeDetectionStrategy,
12
    ChangeDetectorRef,
13
    Component,
14
    ContentChild,
15
    ContentChildren,
16
    DestroyRef,
1✔
17
    ElementRef,
18
    HostBinding,
19
    inject,
20
    Input,
21
    NgZone,
22
    OnChanges,
23
    OnInit,
24
    QueryList,
25
    SimpleChanges,
26
    TemplateRef,
27
    ViewChild
1✔
28
} from '@angular/core';
29

30
import { RouterLinkActive } from '@angular/router';
31
import { ThyNavInkBarDirective } from './nav-ink-bar.directive';
32
import { ThyNavItemDirective } from './nav-item.directive';
1✔
33
import { BypassSecurityTrustHtmlPipe } from './nav.pipe';
34
import { ThyDropdownMenuComponent, ThyDropdownMenuItemDirective, ThyDropdownMenuItemActiveDirective } from 'ngx-tethys/dropdown';
35
import { ThyIcon } from 'ngx-tethys/icon';
36
import { NgClass, NgTemplateOutlet, NgIf, NgFor } from '@angular/common';
37

38
export type ThyNavType = 'pulled' | 'tabs' | 'pills' | 'lite' | 'primary' | 'secondary' | 'thirdly' | 'secondary-divider';
1✔
39
export type ThyNavSize = 'lg' | 'md' | 'sm';
40
export type ThyNavHorizontal = '' | 'start' | 'center' | 'end';
72✔
41

72✔
42
const navTypeClassesMap = {
22✔
43
    pulled: ['thy-nav-pulled'],
44
    tabs: ['thy-nav-tabs'],
45
    pills: ['thy-nav-pills'],
46
    lite: ['thy-nav-lite'],
52✔
47
    //如下类型已经废弃
52✔
48
    primary: ['thy-nav-primary'],
5✔
49
    secondary: ['thy-nav-secondary'],
50
    thirdly: ['thy-nav-thirdly'],
51
    'secondary-divider': ['thy-nav-secondary-divider']
52
};
21!
53

54
const navSizeClassesMap = {
55
    lg: 'thy-nav-lg',
278✔
56
    md: 'thy-nav-md',
278✔
57
    sm: 'thy-nav-sm'
58
};
59

77✔
60
const tabItemRight = 20;
77!
61

77✔
62
/**
63
 * 导航组件
77✔
64
 * @name thy-nav
49✔
65
 * @order 10
66
 */
77✔
67
@Component({
68
    selector: 'thy-nav',
69
    templateUrl: './nav.component.html',
50✔
70
    host: {
50✔
71
        class: 'thy-nav'
50✔
72
    },
50✔
73
    changeDetection: ChangeDetectionStrategy.OnPush,
50✔
74
    standalone: true,
50✔
75
    imports: [
50✔
76
        NgClass,
50✔
77
        NgTemplateOutlet,
50✔
78
        NgIf,
79
        ThyNavItemDirective,
80
        ThyIcon,
81
        ThyNavInkBarDirective,
82
        ThyDropdownMenuComponent,
83
        NgFor,
50✔
84
        ThyDropdownMenuItemDirective,
50✔
85
        ThyDropdownMenuItemActiveDirective,
50✔
86
        BypassSecurityTrustHtmlPipe
50✔
87
    ]
50✔
88
})
50✔
89
export class ThyNav implements OnInit, AfterViewInit, AfterContentInit, AfterContentChecked, OnChanges {
50✔
90
    private readonly destroyRef = inject(DestroyRef);
91

92
    private type: ThyNavType = 'pulled';
50✔
93
    private size: ThyNavSize = 'md';
42✔
94
    public initialized = false;
95

50✔
96
    public horizontal: ThyNavHorizontal;
97
    public wrapperOffset: { height: number; width: number; left: number; top: number } = {
98
        height: 0,
50✔
99
        width: 0,
8✔
100
        left: 0,
8✔
101
        top: 0
21✔
102
    };
8✔
103

104
    public hiddenItems: ThyNavItemDirective[] = [];
105

50✔
106
    public moreActive: boolean;
140!
107

108
    public showMore = true;
10✔
109

2✔
110
    private moreBtnOffset: { height: number; width: number } = { height: 0, width: 0 };
2✔
111

2✔
112
    private hostRenderer = useHostRenderer();
113

114
    /**
115
     * 导航类型
10✔
116
     * @type pulled | tabs | pills | lite | primary | secondary | thirdly | secondary-divider
117
     * @default pulled
118
     */
119
    @Input()
120
    set thyType(type: ThyNavType) {
50✔
121
        this.type = type || 'pulled';
8✔
122
        if (this.initialized) {
8✔
123
            this.updateClasses();
124
        }
125
    }
126

127
    /**
139✔
128
     * 导航大小
215✔
129
     * @type lg | md | sm
139✔
130
     * @default md
25✔
131
     */
132
    @Input()
114✔
133
    set thySize(size: ThyNavSize) {
53✔
134
        this.size = size;
135
        if (this.initialized) {
136
            this.updateClasses();
137
        }
8✔
138
    }
139

140
    /**
141
     * 水平排列
142
     * @type '' | 'start' | 'center' | 'end'
143
     * @default false
190!
144
     */
145
    @Input()
146
    set thyHorizontal(horizontal: ThyNavHorizontal) {
190✔
UNCOV
147
        this.horizontal = (horizontal as string) === 'right' ? 'end' : horizontal;
×
148
    }
149

190✔
150
    /**
190✔
151
     * 是否垂直排列
190✔
152
     * @default false
153
     */
154
    @HostBinding('class.thy-nav--vertical')
155
    @Input({ transform: booleanAttribute })
156
    thyVertical: boolean;
141✔
157

42✔
158
    /**
159
     * 是否是填充模式
141✔
160
     */
161
    @HostBinding('class.thy-nav--fill')
162
    @Input({ transform: booleanAttribute })
10✔
163
    thyFill: boolean = false;
10✔
164

10✔
165
    /**
1✔
166
     * 是否响应式,自动计算宽度存放 thyNavItem,并添加更多弹框
1✔
167
     * @default false
1✔
168
     */
169
    @Input({ transform: booleanAttribute })
9✔
170
    thyResponsive: boolean;
9✔
171

9!
172
    /**
7✔
173
     * 更多操作的菜单点击内部是否可关闭
174
     */
9!
175
    @Input({ transform: booleanAttribute })
9!
176
    thyInsideClosable = true;
21✔
177

178
    /**
9✔
179
     * 右侧额外区域模板
9✔
180
     * @type TemplateRef
181
     */
182
    @Input() thyExtra: TemplateRef<unknown>;
8✔
183

8✔
184
    /**
8✔
185
     * @private
8✔
186
     */
21✔
187
    @ContentChildren(ThyNavItemDirective, { descendants: true }) links: QueryList<ThyNavItemDirective>;
21✔
188

8✔
189
    /**
8✔
190
     * @private
1✔
191
     */
192
    @ContentChildren(RouterLinkActive, { descendants: true }) routers: QueryList<RouterLinkActive>;
193

7✔
194
    /**
195
     * 响应式模式下更多操作模板
8✔
196
     * @type TemplateRef
197
     */
198
    @ContentChild('more') moreOperation: TemplateRef<unknown>;
13✔
199

13✔
200
    /**
201
     * 响应式模式下更多弹框模板
202
     * @type TemplateRef
8✔
203
     */
204
    @ContentChild('morePopover') morePopover: TemplateRef<unknown>;
205

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

1!
UNCOV
213
    @ViewChild('moreOperationContainer') defaultMoreOperation: ElementRef<HTMLAnchorElement>;
×
214

215
    @ViewChild(ThyNavInkBarDirective, { static: true }) inkBar!: ThyNavInkBarDirective;
216

1✔
217
    get showInkBar(): boolean {
218
        const showTypes: ThyNavType[] = ['pulled', 'tabs'];
1✔
219
        return showTypes.includes(this.type);
220
    }
221

2✔
222
    private updateClasses() {
2✔
223
        let classNames: string[] = [];
224
        if (navTypeClassesMap[this.type]) {
225
            classNames = [...navTypeClassesMap[this.type]];
1✔
226
        }
227
        if (navSizeClassesMap[this.size]) {
228
            classNames.push(navSizeClassesMap[this.size]);
10✔
229
        }
10!
230
        this.hostRenderer.updateClass(classNames);
10!
231
    }
12✔
232

12✔
233
    private curActiveIndex: number;
234

235
    private prevActiveIndex: number = NaN;
236

2✔
237
    constructor(
238
        private elementRef: ElementRef,
239
        private ngZone: NgZone,
240
        private changeDetectorRef: ChangeDetectorRef,
241
        private popover: ThyPopover
242
    ) {}
243

244
    ngOnInit() {
245
        if (!this.thyResponsive) {
246
            this.initialized = true;
247
        }
1✔
248

249
        this.updateClasses();
250
    }
137✔
251

14✔
252
    ngAfterViewInit() {
14✔
253
        if (this.thyResponsive) {
254
            this.setMoreBtnOffset();
123✔
255
            this.ngZone.onStable.pipe(take(1)).subscribe(() => {
123✔
256
                this.links.toArray().forEach(link => link.setOffset());
123✔
257
                this.setHiddenItems();
123✔
258
            });
2✔
259
        }
260
        this.ngZone.runOutsideAngular(() => {
123✔
261
            merge(
72✔
262
                this.links.changes,
72✔
263
                this.createResizeObserver(this.elementRef.nativeElement).pipe(debounceTime(100)),
264
                ...this.links.map(item => this.createResizeObserver(item.elementRef.nativeElement).pipe(debounceTime(100))),
265
                ...(this.routers || []).map(router => router?.isActiveChange)
266
            )
82✔
267
                .pipe(
82✔
268
                    takeUntilDestroyed(this.destroyRef),
74✔
269
                    tap(() => {
270
                        if (this.thyResponsive) {
271
                            this.resetSizes();
1✔
272
                            this.setHiddenItems();
273
                            this.calculateMoreIsActive();
274
                        }
275
                    })
276
                )
277
                .subscribe(() => {
1✔
278
                    this.alignInkBarToSelectedTab();
279
                });
280
        });
281
    }
282

283
    ngAfterContentInit(): void {
284
        if (this.thyResponsive) {
285
            this.ngZone.onStable.pipe(take(1)).subscribe(() => {
286
                this.resetSizes();
287
            });
288
        }
289
    }
290

291
    ngAfterContentChecked() {
292
        this.calculateMoreIsActive();
293

294
        this.curActiveIndex = this.links && this.links.length ? this.links.toArray().findIndex(item => item.linkIsActive()) : -1;
295
        if (this.curActiveIndex < 0) {
1✔
296
            this.inkBar.hide();
297
        } else if (this.curActiveIndex !== this.prevActiveIndex) {
298
            this.alignInkBarToSelectedTab();
299
        }
300
    }
301

302
    private setMoreBtnOffset() {
303
        this.moreBtnOffset = {
304
            height: this.defaultMoreOperation?.nativeElement?.offsetHeight,
305
            width: this.defaultMoreOperation?.nativeElement?.offsetWidth
306
        };
307
    }
308

309
    createResizeObserver(element: HTMLElement) {
310
        return typeof ResizeObserver === 'undefined'
311
            ? of(null)
312
            : new Observable(observer => {
313
                  const resize = new ResizeObserver(entries => {
314
                      observer.next(entries);
315
                  });
316
                  resize.observe(element);
317
                  return () => {
318
                      resize.disconnect();
319
                  };
320
              });
321
    }
322

323
    private calculateMoreIsActive() {
324
        this.moreActive = this.hiddenItems.some(item => {
325
            return item.linkIsActive();
326
        });
327
        this.changeDetectorRef.detectChanges();
328
    }
329

330
    private setHiddenItems() {
331
        this.moreActive = false;
332
        const tabs = this.links.toArray();
333
        if (!tabs.length) {
334
            this.hiddenItems = [];
335
            this.showMore = this.hiddenItems.length > 0;
336
            return;
337
        }
338

339
        const endIndex = this.thyVertical ? this.getShowItemsEndIndexWhenVertical(tabs) : this.getShowItemsEndIndexWhenHorizontal(tabs);
340

341
        const showItems = tabs.slice(0, endIndex + 1);
342
        (showItems || []).forEach(item => {
343
            item.setNavLinkHidden(false);
344
        });
345

346
        this.hiddenItems = endIndex === tabs.length - 1 ? [] : tabs.slice(endIndex + 1);
347
        (this.hiddenItems || []).forEach(item => {
348
            item.setNavLinkHidden(true);
349
        });
350

351
        this.showMore = this.hiddenItems.length > 0;
352
        this.initialized = true;
353
    }
354

355
    private getShowItemsEndIndexWhenHorizontal(tabs: ThyNavItemDirective[]) {
356
        const tabsLength = tabs.length;
357
        let endIndex = tabsLength;
358
        let totalWidth = 0;
359

360
        for (let i = 0; i < tabsLength; i += 1) {
361
            const _totalWidth = i === tabsLength - 1 ? totalWidth + tabs[i].offset.width : totalWidth + tabs[i].offset.width + tabItemRight;
362
            if (_totalWidth > this.wrapperOffset.width) {
363
                let moreOperationWidth = this.moreBtnOffset.width;
364
                if (totalWidth + moreOperationWidth <= this.wrapperOffset.width) {
365
                    endIndex = i - 1;
366
                } else {
367
                    endIndex = i - 2;
368
                }
369
                break;
370
            } else {
371
                totalWidth = _totalWidth;
372
                endIndex = i;
373
            }
374
        }
375
        return endIndex;
376
    }
377

378
    private getShowItemsEndIndexWhenVertical(tabs: ThyNavItemDirective[]) {
379
        const tabsLength = tabs.length;
380
        let endIndex = tabsLength;
381
        let totalHeight = 0;
382
        for (let i = 0; i < tabsLength; i += 1) {
383
            const _totalHeight = totalHeight + tabs[i].offset.height;
384
            if (_totalHeight > this.wrapperOffset.height) {
385
                let moreOperationHeight = this.moreBtnOffset.height;
386
                if (totalHeight + moreOperationHeight <= this.wrapperOffset.height) {
387
                    endIndex = i - 1;
388
                } else {
389
                    endIndex = i - 2;
390
                }
391
                break;
392
            } else {
393
                totalHeight = _totalHeight;
394
                endIndex = i;
395
            }
396
        }
397
        return endIndex;
398
    }
399

400
    private resetSizes() {
401
        this.wrapperOffset = {
402
            height: this.elementRef.nativeElement.offsetHeight || 0,
403
            width: this.elementRef.nativeElement.offsetWidth || 0,
404
            left: this.elementRef.nativeElement.offsetLeft || 0,
405
            top: this.elementRef.nativeElement.offsetTop || 0
406
        };
407
    }
408

409
    openMore(event: Event, template: TemplateRef<any>) {
410
        this.popover.open(template, {
411
            origin: event.currentTarget as HTMLElement,
412
            hasBackdrop: true,
413
            backdropClosable: true,
414
            insideClosable: this.thyInsideClosable,
415
            placement: 'bottom',
416
            panelClass: 'thy-nav-list-popover',
417
            originActiveClass: 'thy-nav-origin-active'
418
        });
419
    }
420

421
    navItemClick(item: ThyNavItemDirective) {
422
        item.elementRef.nativeElement.click();
423
    }
424

425
    private alignInkBarToSelectedTab(): void {
426
        if (!this.showInkBar) {
427
            this.inkBar.hide();
428
            return;
429
        }
430
        const tabs = this.links?.toArray() ?? [];
431
        const selectedItem = tabs.find(item => item.linkIsActive());
432
        let selectedItemElement: HTMLElement = selectedItem && selectedItem.elementRef.nativeElement;
433

434
        if (selectedItem && this.moreActive) {
435
            selectedItemElement = this.defaultMoreOperation.nativeElement;
436
        }
437
        if (selectedItemElement) {
438
            this.prevActiveIndex = this.curActiveIndex;
439
            this.inkBar.alignToElement(selectedItemElement);
440
        }
441
    }
442

443
    ngOnChanges(changes: SimpleChanges): void {
444
        const { thyVertical, thyType } = changes;
445

446
        if (thyType?.currentValue !== thyType?.previousValue || thyVertical?.currentValue !== thyVertical?.previousValue) {
447
            this.alignInkBarToSelectedTab();
448
        }
449
    }
450
}
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