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

atinc / ngx-tethys / #92

12 Aug 2025 02:05AM UTC coverage: 90.324% (+0.004%) from 90.32%
#92

push

web-flow
fix(dropdown): handle placement default value (#3519)

* fix(dropdown): handle placement default value

5535 of 6813 branches covered (81.24%)

Branch coverage included in aggregate %.

1 of 1 new or added line in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

13862 of 14662 relevant lines covered (94.54%)

902.79 hits per line

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

88.89
/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
    Signal
48✔
20
} from '@angular/core';
21
import { Subject, fromEvent } from 'rxjs';
22
import { takeUntil, throttleTime } from 'rxjs/operators';
48✔
23

24
import { DOCUMENT, NgClass, NgStyle, NgTemplateOutlet } from '@angular/common';
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 = /#([^#]+)$/;
11✔
37

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

×
86
    readonly ink = viewChild.required<ElementRef>('ink');
87

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

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

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

103
    /**
12✔
104
     * 指定滚动的容器
12✔
105
     */
10✔
106
    readonly thyContainer = input<string | HTMLElement>(undefined);
10✔
107

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

113
    /**
114
     * 点击项触发
4✔
115
     */
4!
116
    readonly thyClick = output<ThyAnchorLink>();
×
117

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

123
    visible = false;
4✔
124

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

131
    readonly container: Signal<HTMLElement | Window> = computed(() => {
6✔
132
        return (
6✔
133
            (typeof this.thyContainer() === 'string'
6✔
134
                ? (this.document.querySelector(this.thyContainer() as string) as HTMLElement)
6✔
135
                : (this.thyContainer() as HTMLElement)) || window
6✔
136
        );
6✔
137
    });
6✔
138

6✔
139
    private links: ThyAnchorLink[] = [];
6✔
140

6✔
141
    private animating = false;
6✔
142

6✔
143
    private destroy$ = new Subject<void>();
144

145
    private handleScrollTimeoutID: any = -1;
10✔
146

10✔
147
    registerLink(link: ThyAnchorLink): void {
10✔
148
        this.links.push(link);
10!
149
    }
10!
150

10✔
151
    unregisterLink(link: ThyAnchorLink): void {
152
        this.links.splice(this.links.indexOf(link), 1);
153
    }
×
154

155
    constructor() {
156
        effect(() => {
157
            if (this.thyContainer()) {
158
                this.registerScrollEvent();
3!
159
            }
3✔
160
        });
3✔
161
    }
1✔
162

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

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

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

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

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

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

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

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

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

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

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

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

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