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

atinc / ngx-tethys / 2bb461f1-51aa-4bcb-8006-30243e37cb19

16 May 2025 03:32AM UTC coverage: 90.253% (-0.02%) from 90.272%
2bb461f1-51aa-4bcb-8006-30243e37cb19

Pull #3360

circleci

invalid-email-address
fix: fix type
Pull Request #3360: refactor(grid): migration signal #TINFR-1474

5609 of 6876 branches covered (81.57%)

Branch coverage included in aggregate %.

30 of 31 new or added lines in 5 files covered. (96.77%)

11 existing lines in 5 files now uncovered.

13400 of 14186 relevant lines covered (94.46%)

919.97 hits per line

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

88.16
/src/anchor/anchor.component.ts
1
import { Platform } from '@angular/cdk/platform';
2
import {
3
    AfterViewInit,
4
    ChangeDetectionStrategy,
5
    ChangeDetectorRef,
6
    Component,
7
    ElementRef,
8
    EventEmitter,
9
    Input,
10
    NgZone,
11
    OnChanges,
1✔
12
    OnDestroy,
13
    Output,
14
    Renderer2,
15
    SimpleChanges,
16
    ViewChild,
1✔
17
    ViewEncapsulation,
18
    numberAttribute,
11✔
19
    inject
11✔
20
} from '@angular/core';
11✔
21
import { Subject, fromEvent } from 'rxjs';
11✔
22
import { takeUntil, throttleTime } from 'rxjs/operators';
11✔
23

11✔
24
import { DOCUMENT, NgClass, NgStyle, NgTemplateOutlet } from '@angular/common';
11✔
25
import { ThyAffix } from 'ngx-tethys/affix';
11✔
26
import { ThyScrollService } from 'ngx-tethys/core';
11✔
27
import { coerceBooleanProperty, getOffset } from 'ngx-tethys/util';
11✔
28
import { ThyAnchorLink } from './anchor-link.component';
11✔
29
import { IThyAnchorComponent, THY_ANCHOR_COMPONENT } from './anchor.token';
11✔
30

11✔
31
interface Section {
11✔
32
    linkComponent: ThyAnchorLink;
11✔
33
    top: number;
11✔
34
}
11✔
35

11✔
36
const sharpMatcherRegx = /#([^#]+)$/;
37

38
/**
48✔
39
 * 锚点组件
40
 * @name thy-anchor
41
 */
48✔
42
@Component({
43
    selector: 'thy-anchor',
44
    exportAs: 'thyAnchor',
28✔
45
    preserveWhitespaces: false,
46
    template: `
47
        @if (thyAffix) {
11✔
48
            <thy-affix [thyOffsetTop]="thyOffsetTop" [thyContainer]="container">
11✔
49
                <ng-template [ngTemplateOutlet]="content"></ng-template>
50
            </thy-affix>
51
        } @else {
11✔
52
            <ng-template [ngTemplateOutlet]="content"></ng-template>
11✔
53
        }
11✔
54
        <ng-template #content>
55
            <div
56
                class="thy-anchor-wrapper"
11✔
57
                [ngClass]="{ 'thy-anchor-wrapper-horizontal': thyDirection === 'horizontal' }"
25✔
58
                [ngStyle]="wrapperStyle">
5✔
59
                <div class="thy-anchor">
1✔
60
                    <div class="thy-anchor-ink">
61
                        <div class="thy-anchor-ink-full" #ink></div>
62
                    </div>
63
                    <ng-content></ng-content>
64
                </div>
12!
65
            </div>
×
66
        </ng-template>
67
    `,
12✔
68
    encapsulation: ViewEncapsulation.None,
12✔
69
    changeDetection: ChangeDetectionStrategy.OnPush,
12✔
70
    imports: [ThyAffix, NgTemplateOutlet, NgStyle, NgClass],
UNCOV
71
    providers: [
×
72
        {
73
            provide: THY_ANCHOR_COMPONENT,
74
            useExisting: ThyAnchor
75
        }
12✔
76
    ]
77
})
78
export class ThyAnchor implements IThyAnchorComponent, OnDestroy, AfterViewInit, OnChanges {
5✔
79
    private document = inject(DOCUMENT);
1✔
80
    private cdr = inject(ChangeDetectorRef);
81
    private platform = inject(Platform);
4✔
82
    private zone = inject(NgZone);
4✔
83
    private renderer = inject(Renderer2);
4!
84
    private scrollService = inject(ThyScrollService);
4✔
85

12✔
86
    @ViewChild('ink') private ink!: ElementRef;
12!
87

×
88
    /**
89
     * 固定模式
12✔
90
     */
12✔
91
    @Input({ transform: coerceBooleanProperty }) thyAffix = true;
10✔
92

10✔
93
    /**
4✔
94
     * 锚点区域边界,单位:px
95
     */
96
    @Input({ transform: numberAttribute })
97
    thyBounds = 5;
98

99
    /**
100
     * 缓冲的偏移量阈值
4✔
101
     */
4!
102
    @Input({ transform: numberAttribute })
×
103
    thyOffsetTop?: number = undefined;
×
104

105
    /**
106
     * 指定滚动的容器
4!
107
     * @type string | HTMLElement
4✔
108
     */
109
    @Input() thyContainer?: string | HTMLElement;
4✔
110

111
    /**
112
     * 设置导航方向
6✔
113
     * @type 'vertical' | 'horizontal'
22✔
114
     */
115
    @Input() thyDirection: 'vertical' | 'horizontal' = 'vertical';
116

117
    /**
6✔
118
     * 点击项触发
6✔
119
     */
6✔
120
    @Output() readonly thyClick = new EventEmitter<ThyAnchorLink>();
6✔
121

6✔
122
    /**
6✔
123
     * 滚动到某锚点时触发
6✔
124
     */
6✔
125
    @Output() readonly thyScroll = new EventEmitter<ThyAnchorLink>();
6✔
126

6✔
127
    visible = false;
6✔
128

129
    wrapperStyle = { 'max-height': '100vh' };
130

10✔
131
    container?: HTMLElement | Window;
10✔
132

10!
133
    private links: ThyAnchorLink[] = [];
10!
134

10✔
135
    private animating = false;
136

137
    private destroy$ = new Subject<void>();
×
138

139
    private handleScrollTimeoutID: any = -1;
140

141
    registerLink(link: ThyAnchorLink): void {
142
        this.links.push(link);
3!
143
    }
3✔
144

3✔
145
    unregisterLink(link: ThyAnchorLink): void {
1✔
146
        this.links.splice(this.links.indexOf(link), 1);
147
    }
2✔
148

2✔
149
    private getContainer(): HTMLElement | Window {
2✔
150
        return this.container || window;
2!
151
    }
2✔
152

2✔
153
    ngAfterViewInit(): void {
154
        this.warningPrompt();
2✔
155
        this.registerScrollEvent();
2✔
156
    }
157

158
    ngOnDestroy(): void {
11✔
159
        clearTimeout(this.handleScrollTimeoutID);
11!
160
        this.destroy$.next();
11✔
161
        this.destroy$.complete();
162
    }
163

164
    private warningPrompt() {
11✔
165
        if (this.thyDirection === 'horizontal') {
1✔
166
            const hasChildren = this.links.some(link =>
1!
167
                Array.from(link?.elementRef?.nativeElement?.childNodes)?.some((item: HTMLElement) => item?.nodeName === 'THY-ANCHOR-LINK')
1✔
168
            );
169
            if (hasChildren) {
170
                console.warn("Anchor link nesting is not supported when 'Anchor' direction is horizontal.");
1✔
171
            }
172
        }
173
    }
174

175
    private registerScrollEvent(): void {
176
        if (!this.platform.isBrowser) {
177
            return;
178
        }
179
        this.destroy$.next();
180
        this.zone.runOutsideAngular(() => {
181
            fromEvent(this.getContainer(), 'scroll', { passive: true })
1✔
182
                .pipe(throttleTime(50), takeUntil(this.destroy$))
183
                .subscribe(() => this.handleScroll());
184
        });
185
        // Browser would maintain the scrolling position when refreshing.
186
        // So we have to delay calculation in avoid of getting a incorrect result.
187
        this.handleScrollTimeoutID = setTimeout(() => this.handleScroll());
188
    }
189

190
    handleScroll(): void {
191
        if (typeof document === 'undefined' || this.animating) {
192
            return;
193
        }
194
        const container: HTMLElement = this.container instanceof HTMLElement ? this.container : (this.document as unknown as HTMLElement);
195

196
        const sections: Section[] = [];
197
        const scope = (this.thyOffsetTop || 0) + this.thyBounds;
198
        this.links.forEach(linkComponent => {
199
            const sharpLinkMatch = sharpMatcherRegx.exec(linkComponent.thyHref.toString());
200
            if (!sharpLinkMatch) {
201
                return;
202
            }
203
            const target = container.querySelector(`#${sharpLinkMatch[1]}`) as HTMLElement;
204
            if (target) {
205
                const top = getOffset(target, this.getContainer()).top;
206
                if (top < scope) {
207
                    sections.push({
208
                        top,
209
                        linkComponent
210
                    });
211
                }
212
            }
213
        });
214

215
        this.visible = !!sections.length;
216
        if (!this.visible) {
217
            this.clearActive();
218
            this.cdr.detectChanges();
219
        } else {
220
            const maxSection = sections.reduce((prev, curr) => (curr.top > prev.top ? curr : prev));
221
            this.handleActive(maxSection.linkComponent);
222
        }
223
        this.setVisible();
224
    }
225

226
    private clearActive(): void {
227
        this.links.forEach(i => {
228
            i.unsetActive();
229
        });
230
    }
231

232
    private handleActive(linkComponent: ThyAnchorLink): void {
233
        this.clearActive();
234
        linkComponent.setActive();
235
        const linkNode = linkComponent.getLinkTitleElement();
236
        const horizontalAnchor = this.thyDirection === 'horizontal';
237

238
        this.ink.nativeElement.style.top = horizontalAnchor ? '' : `${linkNode.offsetTop}px`;
239
        this.ink.nativeElement.style.height = horizontalAnchor ? '' : `${linkNode.clientHeight}px`;
240
        this.ink.nativeElement.style.left = horizontalAnchor ? `${linkNode.offsetLeft}px` : '';
241
        this.ink.nativeElement.style.width = horizontalAnchor ? `${linkNode.clientWidth}px` : '';
242
        this.visible = true;
243
        this.setVisible();
244
        this.thyScroll.emit(linkComponent);
245
    }
246

247
    private setVisible(): void {
248
        const visible = this.visible;
249
        const visibleClassname = 'visible';
250
        if (this.ink) {
251
            if (visible) {
252
                this.renderer.addClass(this.ink.nativeElement, visibleClassname);
253
            } else {
254
                this.renderer.removeClass(this.ink.nativeElement, visibleClassname);
255
            }
256
        }
257
    }
258

259
    handleScrollTo(linkComponent: ThyAnchorLink): void {
260
        const container: HTMLElement = this.container instanceof HTMLElement ? this.container : (this.document as unknown as HTMLElement);
261
        const linkElement: HTMLElement = container.querySelector(linkComponent.thyHref);
262
        if (!linkElement) {
263
            return;
264
        }
265

266
        this.animating = true;
267
        const containerScrollTop = this.scrollService.getScroll(this.getContainer());
268
        const elementOffsetTop = getOffset(linkElement, this.getContainer()).top;
269
        const targetScrollTop = containerScrollTop + elementOffsetTop - (this.thyOffsetTop || 0);
270
        this.scrollService.scrollTo(this.getContainer(), targetScrollTop, undefined, () => {
271
            this.animating = false;
272
        });
273
        this.handleActive(linkComponent);
274
        this.thyClick.emit(linkComponent);
275
    }
276

277
    ngOnChanges(changes: SimpleChanges): void {
278
        const { thyOffsetTop, thyContainer } = changes;
279
        if (thyOffsetTop) {
280
            this.wrapperStyle = {
281
                'max-height': `calc(100vh - ${this.thyOffsetTop}px)`
282
            };
283
        }
284
        if (thyContainer && this.thyContainer) {
285
            const container = this.thyContainer;
286
            this.container = typeof container === 'string' ? (this.document.querySelector(container) as HTMLElement) : container;
287
            this.registerScrollEvent();
288
        }
289
    }
290
}
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