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

atinc / ngx-tethys / 072ac169-d8b0-42a5-9c3f-f3c92ccad6c5

28 Apr 2025 09:47AM UTC coverage: 90.189% (-0.006%) from 90.195%
072ac169-d8b0-42a5-9c3f-f3c92ccad6c5

push

circleci

web-flow
fix: fix RangeError: Maximum call stack size exceeded (#3374)

5613 of 6890 branches covered (81.47%)

Branch coverage included in aggregate %.

13378 of 14167 relevant lines covered (94.43%)

993.68 hits per line

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

86.96
/src/nav/nav.component.ts
1
import { ThyPopover, ThyPopoverConfig } from 'ngx-tethys/popover';
2
import { merge, Observable, of } from 'rxjs';
3
import { startWith, 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
    Signal,
27
    SimpleChanges,
28
    TemplateRef,
29
    ViewChild,
30
    WritableSignal
31
} from '@angular/core';
1✔
32

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

44
export type ThyNavType = 'pulled' | 'tabs' | 'pills' | 'lite' | 'card' | 'primary' | 'secondary' | 'thirdly' | 'secondary-divider';
49✔
45
export type ThyNavSize = 'lg' | 'md' | 'sm';
49✔
46
export type ThyNavHorizontal = '' | 'start' | 'center' | 'end';
49✔
47

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

49✔
61
const navSizeClassesMap = {
49✔
62
    lg: 'thy-nav-lg',
49✔
63
    md: 'thy-nav-md',
49✔
64
    sm: 'thy-nav-sm'
49✔
65
};
49✔
66

49✔
67
const tabItemRight = 20;
49✔
68

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

77✔
100
    private readonly destroyRef = inject(DestroyRef);
101

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

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

49✔
114
    public hiddenItems: ThyNavItemDirective[] = [];
8✔
115

8✔
116
    public moreActive: boolean;
8✔
117

21✔
118
    readonly showMore: WritableSignal<boolean> = signal(false);
8✔
119

120
    private moreBtnOffset: { height: number; width: number } = { height: 0, width: 0 };
121

49✔
122
    private hostRenderer = useHostRenderer();
49✔
123

55✔
124
    private innerLinks: QueryList<ThyNavItemDirective>;
6✔
125

126
    locale: Signal<ThyNavLocale> = injectLocale('nav');
159!
127

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

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

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

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

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

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

186
    /**
187
     * 支持暂停自适应计算
214✔
188
     */
×
189
    thyPauseReCalculate = input<boolean>(false);
190

214✔
191
    /**
214✔
192
     * 更多操作的菜单点击内部是否可关闭
214✔
193
     * @deprecated please use thyPopoverOptions
194
     */
195
    @Input({ transform: coerceBooleanProperty })
196
    thyInsideClosable = true;
197

139✔
198
    /**
34✔
199
     * 更多菜单弹出框的参数,底层使用 Popover 组件
200
     * @type ThyPopoverConfig
139✔
201
     */
202
    thyPopoverOptions = input<ThyPopoverConfig<unknown>>(null);
203

9✔
204
    /**
9✔
205
     * 右侧额外区域模板
9✔
206
     * @type TemplateRef
1✔
207
     */
1✔
208
    @Input() thyExtra: TemplateRef<unknown>;
1✔
209

210
    /**
8✔
211
     * @private
8✔
212
     */
8!
213
    @ContentChildren(ThyNavItemDirective, { descendants: true })
7✔
214
    set links(value) {
215
        this.innerLinks = value;
8!
216
        this.prevActiveIndex = NaN;
8!
217
    }
17✔
218
    get links(): QueryList<ThyNavItemDirective> {
219
        return this.innerLinks;
8✔
220
    }
8✔
221

222
    /**
223
     * @private
7✔
224
     */
7✔
225
    @ContentChildren(RouterLinkActive, { descendants: true }) routers: QueryList<RouterLinkActive>;
7✔
226

7✔
227
    /**
19✔
228
     * 响应式模式下更多操作模板
19✔
229
     * @type TemplateRef
7✔
230
     */
7✔
231
    @ContentChild('more') moreOperation: TemplateRef<unknown>;
1✔
232

233
    /**
234
     * 响应式模式下更多弹框模板
6✔
235
     * @type TemplateRef
236
     */
7✔
237
    @ContentChild('morePopover') morePopover: TemplateRef<unknown>;
238

239
    /**
12✔
240
     * 右侧额外区域模板,支持 thyExtra 传参和 <ng-template #extra></ng-template> 模板
12✔
241
     * @name extra
242
     * @type TemplateRef
243
     */
7✔
244
    @ContentChild('extra') extra: TemplateRef<unknown>;
245

246
    @ViewChild('moreOperationContainer') defaultMoreOperation: ElementRef<HTMLAnchorElement>;
1✔
247

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

1✔
250
    get showInkBar(): boolean {
3✔
251
        const showTypes: ThyNavType[] = ['pulled', 'tabs'];
3✔
252
        return showTypes.includes(this.type);
1✔
253
    }
1!
254

×
255
    private updateClasses() {
256
        let classNames: string[] = [];
257
        if (navTypeClassesMap[this.type]) {
1✔
258
            classNames = [...navTypeClassesMap[this.type]];
259
        }
1✔
260
        if (navSizeClassesMap[this.size]) {
261
            classNames.push(navSizeClassesMap[this.size]);
262
        }
2✔
263
        this.hostRenderer.updateClass(classNames);
2✔
264
    }
265

266
    private curActiveIndex: number;
1✔
267

268
    private prevActiveIndex: number = NaN;
269

9✔
270
    private navSubscription: { unsubscribe: () => void } | null = null;
9!
271

9!
272
    ngOnInit() {
10✔
273
        if (!this.thyResponsive) {
10✔
274
            this.initialized = true;
275
        }
276

277
        this.updateClasses();
2✔
278
    }
279

280
    ngAfterViewInit() {
281
        if (this.thyResponsive) {
282
            this.setMoreBtnOffset();
283
            this.ngZone.onStable.pipe(take(1)).subscribe(() => {
284
                this.setMoreBtnOffset();
285
                this.links.toArray().forEach(link => link.setOffset());
2!
286
                this.setHiddenItems();
287
            });
288
        }
1✔
289

290
        this.ngZone.runOutsideAngular(() => {
291
            this.links.changes.pipe(startWith(this.links), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
131✔
292
                if (this.navSubscription) {
16✔
293
                    this.navSubscription.unsubscribe();
16✔
294
                }
295

115✔
296
                this.navSubscription = merge(
115✔
297
                    this.createResizeObserver(this.elementRef.nativeElement),
115✔
298
                    ...this.links.map(item => this.createResizeObserver(item.elementRef.nativeElement).pipe(tap(() => item.setOffset()))),
115✔
299
                    ...(this.routers || []).map(router => router?.isActiveChange)
2✔
300
                )
301
                    .pipe(
115✔
302
                        takeUntilDestroyed(this.destroyRef),
66✔
303
                        tap(() => {
66✔
304
                            if (this.thyPauseReCalculate()) {
305
                                return;
306
                            }
307

82✔
308
                            if (this.thyResponsive) {
82✔
309
                                this.setMoreBtnOffset();
74✔
310
                                this.resetSizes();
311
                                this.setHiddenItems();
312
                                this.calculateMoreIsActive();
313
                            }
49!
314

49✔
315
                            if (this.type === 'card') {
316
                                this.setNavItemDivider();
317
                            }
1✔
318
                        })
319
                    )
320
                    .subscribe(() => {
321
                        this.alignInkBarToSelectedTab();
322
                    });
323
            });
324
        });
325
    }
326

327
    ngAfterContentInit(): void {
328
        if (this.thyResponsive) {
329
            this.ngZone.onStable.pipe(take(1)).subscribe(() => {
330
                this.resetSizes();
331
            });
332
        }
333
    }
334

335
    ngAfterContentChecked() {
336
        this.calculateMoreIsActive();
337

1✔
338
        this.curActiveIndex = this.links && this.links.length ? this.links.toArray().findIndex(item => item.linkIsActive()) : -1;
339
        if (this.curActiveIndex < 0) {
340
            this.inkBar.hide();
341
        } else if (this.curActiveIndex !== this.prevActiveIndex) {
342
            this.alignInkBarToSelectedTab();
343
        }
344
    }
345

346
    private setMoreBtnOffset() {
347
        const computedStyle = window.getComputedStyle(this.defaultMoreOperation?.nativeElement);
348
        this.moreBtnOffset = {
349
            height: this.defaultMoreOperation?.nativeElement?.offsetHeight + parseFloat(computedStyle?.marginBottom) || 0,
350
            width: this.defaultMoreOperation?.nativeElement?.offsetWidth + parseFloat(computedStyle?.marginRight) || 0
351
        };
352
    }
353

354
    private setNavItemDivider() {
355
        const tabs = this.links.toArray();
356
        const activeIndex = tabs.findIndex(item => item.linkIsActive());
357

358
        for (let i = 0; i < tabs.length; i++) {
359
            if ((i !== activeIndex && i !== activeIndex - 1 && i !== tabs.length - 1) || (i === activeIndex - 1 && this.moreActive)) {
360
                tabs[i].addClass('has-right-divider');
361
            } else {
362
                tabs[i].removeClass('has-right-divider');
363
            }
364
        }
365
    }
366

367
    createResizeObserver(element: HTMLElement) {
368
        return typeof ResizeObserver === 'undefined'
369
            ? of(null)
370
            : new Observable(observer => {
371
                  const resize = new ResizeObserver(entries => {
372
                      observer.next(entries);
373
                  });
374
                  resize.observe(element);
375
                  return () => {
376
                      resize.disconnect();
377
                  };
378
              });
379
    }
380

381
    private calculateMoreIsActive() {
382
        this.moreActive = this.hiddenItems.some(item => {
383
            return item.linkIsActive();
384
        });
385
        this.changeDetectorRef.detectChanges();
386
    }
387

388
    private setHiddenItems() {
389
        this.moreActive = false;
390
        const tabs = this.links.toArray();
391
        if (!tabs.length) {
392
            this.hiddenItems = [];
393
            this.showMore.set(false);
394
            return;
395
        }
396

397
        const endIndex = this.thyVertical ? this.getShowItemsEndIndexWhenVertical(tabs) : this.getShowItemsEndIndexWhenHorizontal(tabs);
398

399
        const showItems = tabs.slice(0, endIndex + 1);
400
        (showItems || []).forEach(item => {
401
            item.setNavLinkHidden(false);
402
        });
403

404
        this.hiddenItems = endIndex === tabs.length - 1 ? [] : tabs.slice(endIndex + 1);
405
        (this.hiddenItems || []).forEach(item => {
406
            item.setNavLinkHidden(true);
407
        });
408

409
        this.showMore.set(this.hiddenItems.length > 0);
410
        this.initialized = true;
411
    }
412

413
    private getShowItemsEndIndexWhenHorizontal(tabs: ThyNavItemDirective[]) {
414
        const tabsLength = tabs.length;
415
        let endIndex = tabsLength;
416
        let totalWidth = 0;
417

418
        for (let i = 0; i < tabsLength; i += 1) {
419
            const _totalWidth = i === tabsLength - 1 ? totalWidth + tabs[i].offset.width : totalWidth + tabs[i].offset.width + tabItemRight;
420
            if (_totalWidth > this.wrapperOffset.width) {
421
                let moreOperationWidth = this.moreBtnOffset.width;
422
                if (totalWidth + moreOperationWidth <= this.wrapperOffset.width) {
423
                    endIndex = i - 1;
424
                } else {
425
                    endIndex = i - 2;
426
                }
427
                break;
428
            } else {
429
                totalWidth = _totalWidth;
430
                endIndex = i;
431
            }
432
        }
433
        return endIndex;
434
    }
435

436
    private getShowItemsEndIndexWhenVertical(tabs: ThyNavItemDirective[]) {
437
        const tabsLength = tabs.length;
438
        let endIndex = tabsLength;
439
        let totalHeight = 0;
440
        for (let i = 0; i < tabsLength; i += 1) {
441
            const _totalHeight = totalHeight + tabs[i].offset.height;
442
            if (_totalHeight > this.wrapperOffset.height) {
443
                let moreOperationHeight = this.moreBtnOffset.height;
444
                if (totalHeight + moreOperationHeight <= this.wrapperOffset.height) {
445
                    endIndex = i - 1;
446
                } else {
447
                    endIndex = i - 2;
448
                }
449
                break;
450
            } else {
451
                totalHeight = _totalHeight;
452
                endIndex = i;
453
            }
454
        }
455
        return endIndex;
456
    }
457

458
    private resetSizes() {
459
        this.wrapperOffset = {
460
            height: this.elementRef.nativeElement.offsetHeight || 0,
461
            width: this.elementRef.nativeElement.offsetWidth || 0,
462
            left: this.elementRef.nativeElement.offsetLeft || 0,
463
            top: this.elementRef.nativeElement.offsetTop || 0
464
        };
465
    }
466

467
    openMoreMenu(event: Event, template: TemplateRef<any>) {
468
        this.popover.open(
469
            template,
470
            Object.assign(
471
                {
472
                    origin: event.currentTarget as HTMLElement,
473
                    hasBackdrop: true,
474
                    backdropClosable: true,
475
                    insideClosable: true,
476
                    placement: 'bottom' as ThyPlacement,
477
                    panelClass: 'thy-nav-list-popover',
478
                    originActiveClass: 'thy-nav-origin-active'
479
                },
480
                this.thyPopoverOptions() ? this.thyPopoverOptions() : {}
481
            )
482
        );
483
    }
484

485
    navItemClick(item: ThyNavItemDirective) {
486
        item.elementRef.nativeElement.click();
487
    }
488

489
    private alignInkBarToSelectedTab(): void {
490
        if (!this.showInkBar) {
491
            this.inkBar.hide();
492
            return;
493
        }
494
        const tabs = this.links?.toArray() ?? [];
495
        const selectedItem = tabs.find(item => item.linkIsActive());
496
        let selectedItemElement: HTMLElement = selectedItem && selectedItem.elementRef.nativeElement;
497

498
        if (selectedItem && this.moreActive) {
499
            selectedItemElement = this.defaultMoreOperation.nativeElement;
500
        }
501
        if (selectedItemElement) {
502
            this.prevActiveIndex = this.curActiveIndex;
503
            this.inkBar.alignToElement(selectedItemElement);
504
        }
505
    }
506

507
    ngOnChanges(changes: SimpleChanges): void {
508
        const { thyVertical, thyType } = changes;
509

510
        if (thyType?.currentValue !== thyType?.previousValue || thyVertical?.currentValue !== thyVertical?.previousValue) {
511
            this.alignInkBarToSelectedTab();
512
        }
513
    }
514

515
    ngOnDestroy() {
516
        if (this.navSubscription) {
517
            this.navSubscription.unsubscribe();
518
        }
519
    }
520
}
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