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

atinc / ngx-tethys / e55d72f0-a6c0-4d75-a11d-39d14a89c32c

30 Apr 2025 02:53AM UTC coverage: 90.27% (-0.002%) from 90.272%
e55d72f0-a6c0-4d75-a11d-39d14a89c32c

Pull #3377

circleci

minlovehua
refactor(anchor): migrate to signal for anchor #TINFR-1445
Pull Request #3377: refactor(anchor): migrate to signal for anchor #TINFR-1445

5616 of 6882 branches covered (81.6%)

Branch coverage included in aggregate %.

22 of 24 new or added lines in 2 files covered. (91.67%)

3 existing lines in 1 file now uncovered.

13384 of 14166 relevant lines covered (94.48%)

921.03 hits per line

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

88.96
/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
    NgZone,
9
    OnDestroy,
10
    Renderer2,
11
    ViewEncapsulation,
12
    numberAttribute,
1✔
13
    inject,
14
    input,
15
    viewChild,
16
    output,
17
    effect,
1✔
18
    computed
19
} from '@angular/core';
48✔
20
import { Subject, fromEvent } from 'rxjs';
21
import { takeUntil, throttleTime } from 'rxjs/operators';
22

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

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

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

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

85
    readonly ink = viewChild.required<ElementRef>('ink');
2✔
86

87
    /**
88
     * 固定模式
89
     */
12✔
90
    readonly thyAffix = input(true, { transform: coerceBooleanProperty });
91

92
    /**
7✔
93
     * 锚点区域边界,单位:px
1✔
94
     */
95
    readonly thyBounds = input(5, { transform: numberAttribute });
6✔
96

6✔
97
    /**
6!
98
     * 缓冲的偏移量阈值
6✔
99
     */
22✔
100
    readonly thyOffsetTop = input<number, unknown>(undefined, { transform: numberAttribute });
22!
101

×
102
    /**
103
     * 指定滚动的容器
22✔
104
     */
22✔
105
    readonly thyContainer = input<string | HTMLElement>(undefined);
18✔
106

18✔
107
    /**
6✔
108
     * 设置导航方向
109
     */
110
    readonly thyDirection = input<'vertical' | 'horizontal'>('vertical');
111

112
    /**
113
     * 点击项触发
114
     */
6✔
115
    readonly thyClick = output<ThyAnchorLink>();
6!
UNCOV
116

×
UNCOV
117
    /**
×
118
     * 滚动到某锚点时触发
119
     */
120
    readonly thyScroll = output<ThyAnchorLink>();
6!
121

6✔
122
    visible = false;
123

6✔
124
    wrapperStyle = computed(() => {
125
        return {
126
            'max-height': this.thyOffsetTop() ? `calc(100vh - ${this.thyOffsetTop()}px)` : '100vh'
8✔
127
        };
32✔
128
    });
129

130
    container?: HTMLElement | Window;
131

8✔
132
    private links: ThyAnchorLink[] = [];
8✔
133

8✔
134
    private animating = false;
8✔
135

8✔
136
    private destroy$ = new Subject<void>();
8✔
137

8✔
138
    private handleScrollTimeoutID: any = -1;
8✔
139

8✔
140
    registerLink(link: ThyAnchorLink): void {
8✔
141
        this.links.push(link);
8✔
142
    }
8✔
143

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

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

152
    constructor() {
NEW
153
        effect(() => {
×
154
            if (this.thyContainer()) {
155
                const container = this.thyContainer();
156
                this.container = typeof container === 'string' ? (this.document.querySelector(container) as HTMLElement) : container;
157
                this.registerScrollEvent();
158
            }
3!
159
        });
3✔
160
    }
3✔
161

1✔
162
    ngAfterViewInit(): void {
163
        this.warningPrompt();
2✔
164
        this.registerScrollEvent();
2✔
165
    }
2✔
166

2!
167
    ngOnDestroy(): void {
2✔
168
        clearTimeout(this.handleScrollTimeoutID);
2✔
169
        this.destroy$.next();
170
        this.destroy$.complete();
2✔
171
    }
2✔
172

173
    private warningPrompt() {
1✔
174
        if (this.thyDirection() === 'horizontal') {
1✔
175
            const hasChildren = this.links.some(link =>
176
                Array.from(link?.elementRef?.nativeElement?.childNodes)?.some((item: HTMLElement) => item?.nodeName === 'THY-ANCHOR-LINK')
177
            );
178
            if (hasChildren) {
179
                console.warn("Anchor link nesting is not supported when 'Anchor' direction is horizontal.");
180
            }
181
        }
182
    }
183

184
    private registerScrollEvent(): void {
185
        if (!this.platform.isBrowser) {
1✔
186
            return;
187
        }
188
        this.destroy$.next();
189
        this.zone.runOutsideAngular(() => {
190
            fromEvent(this.getContainer(), 'scroll', { passive: true })
191
                .pipe(throttleTime(50), takeUntil(this.destroy$))
192
                .subscribe(() => this.handleScroll());
193
        });
194
        // Browser would maintain the scrolling position when refreshing.
195
        // So we have to delay calculation in avoid of getting a incorrect result.
196
        this.handleScrollTimeoutID = setTimeout(() => this.handleScroll());
197
    }
198

199
    handleScroll(): void {
200
        if (typeof document === 'undefined' || this.animating) {
201
            return;
202
        }
203
        const container: HTMLElement = this.container instanceof HTMLElement ? this.container : (this.document as unknown as HTMLElement);
204

205
        const sections: Section[] = [];
206
        const scope = (this.thyOffsetTop() || 0) + this.thyBounds();
207
        this.links.forEach(linkComponent => {
208
            const sharpLinkMatch = sharpMatcherRegx.exec(linkComponent.thyHref().toString());
209
            if (!sharpLinkMatch) {
210
                return;
211
            }
212
            const target = container.querySelector(`#${sharpLinkMatch[1]}`) as HTMLElement;
213
            if (target) {
214
                const top = getOffset(target, this.getContainer()).top;
215
                if (top < scope) {
216
                    sections.push({
217
                        top,
218
                        linkComponent
219
                    });
220
                }
221
            }
222
        });
223

224
        this.visible = !!sections.length;
225
        if (!this.visible) {
226
            this.clearActive();
227
            this.cdr.detectChanges();
228
        } else {
229
            const maxSection = sections.reduce((prev, curr) => (curr.top > prev.top ? curr : prev));
230
            this.handleActive(maxSection.linkComponent);
231
        }
232
        this.setVisible();
233
    }
234

235
    private clearActive(): void {
236
        this.links.forEach(i => {
237
            i.unsetActive();
238
        });
239
    }
240

241
    private handleActive(linkComponent: ThyAnchorLink): void {
242
        this.clearActive();
243
        linkComponent.setActive();
244
        const linkNode = linkComponent.getLinkTitleElement();
245
        const horizontalAnchor = this.thyDirection() === 'horizontal';
246

247
        const ink = this.ink();
248
        ink.nativeElement.style.top = horizontalAnchor ? '' : `${linkNode.offsetTop}px`;
249
        ink.nativeElement.style.height = horizontalAnchor ? '' : `${linkNode.clientHeight}px`;
250
        ink.nativeElement.style.left = horizontalAnchor ? `${linkNode.offsetLeft}px` : '';
251
        ink.nativeElement.style.width = horizontalAnchor ? `${linkNode.clientWidth}px` : '';
252
        this.visible = true;
253
        this.setVisible();
254
        this.thyScroll.emit(linkComponent);
255
    }
256

257
    private setVisible(): void {
258
        const visible = this.visible;
259
        const visibleClassname = 'visible';
260
        const ink = this.ink();
261
        if (ink) {
262
            if (visible) {
263
                this.renderer.addClass(ink.nativeElement, visibleClassname);
264
            } else {
265
                this.renderer.removeClass(ink.nativeElement, visibleClassname);
266
            }
267
        }
268
    }
269

270
    handleScrollTo(linkComponent: ThyAnchorLink): void {
271
        const container: HTMLElement = this.container instanceof HTMLElement ? this.container : (this.document as unknown as HTMLElement);
272
        const linkElement: HTMLElement = container.querySelector(linkComponent.thyHref());
273
        if (!linkElement) {
274
            return;
275
        }
276

277
        this.animating = true;
278
        const containerScrollTop = this.scrollService.getScroll(this.getContainer());
279
        const elementOffsetTop = getOffset(linkElement, this.getContainer()).top;
280
        const targetScrollTop = containerScrollTop + elementOffsetTop - (this.thyOffsetTop() || 0);
281
        this.scrollService.scrollTo(this.getContainer(), targetScrollTop, undefined, () => {
282
            this.animating = false;
283
        });
284
        this.handleActive(linkComponent);
285
        this.thyClick.emit(linkComponent);
286
    }
287
}
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