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

atinc / ngx-tethys / 791cc51e-21e8-4397-b2d3-74fb952fefc7

13 Jan 2025 03:03AM UTC coverage: 90.365% (+0.01%) from 90.355%
791cc51e-21e8-4397-b2d3-74fb952fefc7

Pull #3288

circleci

minlovehua
refactor(nav): migrate @Input to signal input
Pull Request #3288: refactor(nav): migrate @Input to signal input

5558 of 6802 branches covered (81.71%)

Branch coverage included in aggregate %.

15 of 16 new or added lines in 3 files covered. (93.75%)

4 existing lines in 2 files now uncovered.

13283 of 14048 relevant lines covered (94.55%)

992.2 hits per line

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

94.44
/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
    inject,
18
    Input,
19
    NgZone,
1✔
20
    OnChanges,
21
    OnInit,
22
    QueryList,
23
    Signal,
24
    SimpleChanges,
25
    TemplateRef,
26
    ViewChild,
27
    input,
28
    computed
29
} from '@angular/core';
30

1✔
31
import { RouterLinkActive } from '@angular/router';
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';
1✔
36
import { ThyIcon } from 'ngx-tethys/icon';
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' | 'primary' | 'secondary' | 'thirdly' | 'secondary-divider';
1✔
42
export type ThyNavSize = 'lg' | 'md' | 'sm';
43
export type ThyNavHorizontal = '' | 'start' | 'center' | 'end';
49✔
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
    //如下类型已经废弃
49✔
51
    primary: ['thy-nav-primary'],
49✔
52
    secondary: ['thy-nav-secondary'],
53
    thirdly: ['thy-nav-thirdly'],
54
    'secondary-divider': ['thy-nav-secondary-divider']
55
};
56

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

49✔
63
const tabItemRight = 20;
49✔
64

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

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

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

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

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

113
    public moreActive: boolean;
49✔
114

8✔
115
    public showMore = true;
8✔
116

21✔
117
    private moreBtnOffset: { height: number; width: number } = { height: 0, width: 0 };
8✔
118

119
    private hostRenderer = useHostRenderer();
120

49✔
121
    private innerLinks: QueryList<ThyNavItemDirective>;
137!
122

123
    locale: Signal<ThyNavLocale> = injectLocale('nav');
10✔
124

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

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

151
    /**
152
     * 水平排列
8✔
153
     * @type '' | 'start' | 'center' | 'end'
154
     */
155
    readonly thyHorizontal = input<ThyNavHorizontal>('');
156

157
    horizontal = computed(() => {
158
        return String(this.thyHorizontal()) === 'right' ? 'end' : this.thyHorizontal();
186!
159
    });
160

161
    /**
186✔
UNCOV
162
     * 是否垂直排列
×
163
     */
164
    readonly thyVertical = input(false, { transform: coerceBooleanProperty });
186✔
165

186✔
166
    /**
186✔
167
     * 是否是填充模式
168
     */
169
    readonly thyFill = input(false, { transform: coerceBooleanProperty });
170

171
    /**
138✔
172
     * 是否响应式,自动计算宽度存放 thyNavItem,并添加更多弹框
42✔
173
     */
174
    readonly thyResponsive = input(false, { transform: coerceBooleanProperty });
138✔
175

176
    /**
177
     * 更多操作的菜单点击内部是否可关闭
10✔
178
     */
10✔
179
    readonly thyInsideClosable = input(true, { transform: coerceBooleanProperty });
10✔
180

1✔
181
    /**
1✔
182
     * 右侧额外区域模板
1✔
183
     * @type TemplateRef
184
     */
9✔
185
    readonly thyExtra = input<TemplateRef<unknown>>(undefined);
9✔
186

9!
187
    /**
7✔
188
     * @private
189
     */
9!
190
    @ContentChildren(ThyNavItemDirective, { descendants: true })
9!
191
    set links(value) {
21✔
192
        this.innerLinks = value;
193
        this.prevActiveIndex = NaN;
9✔
194
    }
9✔
195
    get links(): QueryList<ThyNavItemDirective> {
196
        return this.innerLinks;
197
    }
8✔
198

8✔
199
    /**
8✔
200
     * @private
8✔
201
     */
21✔
202
    @ContentChildren(RouterLinkActive, { descendants: true }) routers: QueryList<RouterLinkActive>;
21✔
203

8✔
204
    /**
8✔
205
     * 响应式模式下更多操作模板
1✔
206
     * @type TemplateRef
207
     */
208
    @ContentChild('more') moreOperation: TemplateRef<unknown>;
7✔
209

210
    /**
8✔
211
     * 响应式模式下更多弹框模板
212
     * @type TemplateRef
213
     */
13✔
214
    @ContentChild('morePopover') morePopover: TemplateRef<unknown>;
13✔
215

216
    /**
217
     * 右侧额外区域模板,支持 thyExtra 传参和 <ng-template #extra></ng-template> 模板
8✔
218
     * @name extra
219
     * @type TemplateRef
220
     */
1✔
221
    @ContentChild('extra') extra: TemplateRef<unknown>;
1✔
222

1✔
223
    @ViewChild('moreOperationContainer') defaultMoreOperation: ElementRef<HTMLAnchorElement>;
1✔
224

3✔
225
    @ViewChild(ThyNavInkBarDirective, { static: true }) inkBar!: ThyNavInkBarDirective;
3✔
226

1✔
227
    get showInkBar(): boolean {
1!
UNCOV
228
        const showTypes: ThyNavType[] = ['pulled', 'tabs'];
×
229
        return showTypes.includes(this.type);
230
    }
231

1✔
232
    private updateClasses() {
233
        let classNames: string[] = [];
1✔
234
        if (navTypeClassesMap[this.type]) {
235
            classNames = [...navTypeClassesMap[this.type]];
236
        }
2✔
237
        if (navSizeClassesMap[this.size]) {
2✔
238
            classNames.push(navSizeClassesMap[this.size]);
239
        }
240
        this.hostRenderer.updateClass(classNames);
1✔
241
    }
242

243
    private curActiveIndex: number;
10✔
244

10!
245
    private prevActiveIndex: number = NaN;
10!
246

12✔
247
    ngOnInit() {
12✔
248
        if (!this.thyResponsive()) {
249
            this.initialized = true;
250
        }
251

2✔
252
        this.updateClasses();
253
    }
254

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

286
    ngAfterContentInit(): void {
1✔
287
        if (this.thyResponsive()) {
288
            this.ngZone.onStable.pipe(take(1)).subscribe(() => {
289
                this.resetSizes();
290
            });
291
        }
292
    }
293

294
    ngAfterContentChecked() {
295
        this.calculateMoreIsActive();
296

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

1✔
305
    private setMoreBtnOffset() {
306
        this.moreBtnOffset = {
307
            height: this.defaultMoreOperation?.nativeElement?.offsetHeight,
308
            width: this.defaultMoreOperation?.nativeElement?.offsetWidth
309
        };
310
    }
311

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

326
    private calculateMoreIsActive() {
327
        this.moreActive = this.hiddenItems.some(item => {
328
            return item.linkIsActive();
329
        });
330
        this.changeDetectorRef.detectChanges();
331
    }
332

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

342
        const endIndex = this.thyVertical() ? this.getShowItemsEndIndexWhenVertical(tabs) : this.getShowItemsEndIndexWhenHorizontal(tabs);
343

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

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

354
        this.showMore = this.hiddenItems.length > 0;
355
        this.initialized = true;
356
    }
357

358
    private getShowItemsEndIndexWhenHorizontal(tabs: ThyNavItemDirective[]) {
359
        const tabsLength = tabs.length;
360
        let endIndex = tabsLength;
361
        let totalWidth = 0;
362

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

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

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

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

424
    navItemClick(item: ThyNavItemDirective) {
425
        item.elementRef.nativeElement.click();
426
    }
427

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

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

446
    ngOnChanges(changes: SimpleChanges): void {
447
        const { thyVertical, thyType } = changes;
448

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