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

atinc / ngx-tethys / 1359fc79-d552-4f5c-9f38-7d3910ef37ee

14 May 2025 05:58AM UTC coverage: 90.274% (+0.003%) from 90.271%
1359fc79-d552-4f5c-9f38-7d3910ef37ee

push

circleci

web-flow
feat(affix): migrate to signal for affix  @wumeimin (#3413)

5611 of 6877 branches covered (81.59%)

Branch coverage included in aggregate %.

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

15 existing lines in 1 file now uncovered.

13397 of 14179 relevant lines covered (94.48%)

920.63 hits per line

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

74.4
/src/affix/affix.component.ts
1
import { ThyScrollService } from 'ngx-tethys/core';
2
import { dom, shallowEqual, SimpleRect } from 'ngx-tethys/util';
3
import { fromEvent, merge, ReplaySubject, Subject, Subscription } from 'rxjs';
4
import { auditTime, map, takeUntil } from 'rxjs/operators';
5

6
import { Platform } from '@angular/cdk/platform';
7
import { DOCUMENT } from '@angular/common';
8
import {
9
    AfterViewInit,
10
    ChangeDetectionStrategy,
11
    Component,
1✔
12
    ElementRef,
1✔
13
    inject,
14
    input,
15
    NgZone,
16
    numberAttribute,
17
    OnChanges,
18
    OnDestroy,
1✔
19
    output,
20
    Renderer2,
164✔
21
    SimpleChanges,
164!
22
    viewChild,
23
    ViewEncapsulation
24
} from '@angular/core';
11✔
25

11✔
26
import { AffixRespondEvents } from './respond-events';
11✔
27

11✔
28
const THY_AFFIX_CLS_PREFIX = 'thy-affix';
11✔
29
const THY_AFFIX_DEFAULT_SCROLL_TIME = 20;
11✔
30

11✔
31
/**
11✔
32
 * 固钉组件
11✔
33
 * @name thy-affix
11✔
34
 * @order 10
11✔
35
 */
11✔
36
@Component({
11✔
37
    selector: 'thy-affix',
11✔
38
    exportAs: 'thyAffix',
39
    template: `
11✔
40
        <div #fixedElement>
11✔
41
            <ng-content></ng-content>
42
        </div>
43
    `,
11✔
44
    changeDetection: ChangeDetectionStrategy.OnPush,
11!
45
    encapsulation: ViewEncapsulation.None
11✔
46
})
47
export class ThyAffix implements AfterViewInit, OnChanges, OnDestroy {
11!
48
    private scrollService = inject(ThyScrollService);
11✔
49
    private ngZone = inject(NgZone);
50
    private platform = inject(Platform);
51
    private renderer = inject(Renderer2);
52

11✔
53
    private readonly fixedElement = viewChild.required<ElementRef<HTMLDivElement>>('fixedElement');
54

55
    /**
11✔
56
     * 设置 thy-affix 需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数
57
     * @default window
58
     * @type string | Element | Window
22✔
59
     */
22✔
60
    readonly thyContainer = input<string | Element | Window>();
154✔
61

62
    /**
1✔
63
     * 距离窗口顶部缓冲的偏移量阈值
64
     */
22✔
65
    readonly thyOffsetTop = input<null | number, unknown>(0, { transform: numberAttribute });
66

67
    /**
33✔
68
     * 距离窗口底部缓冲的偏移量阈值
33✔
69
     */
33✔
70
    readonly thyOffsetBottom = input<null | number, unknown>(0, { transform: numberAttribute });
33✔
71

72
    /**
73
     * 固定状态改变时触发的回调函数
5✔
74
     */
5✔
75
    readonly thyChange = output<boolean>();
5✔
76

5✔
77
    private readonly placeholderNode: HTMLElement;
5✔
78

5✔
79
    private affixStyle?: any;
5✔
80
    private placeholderStyle?: any;
5✔
81
    private positionChangeSubscription: Subscription = Subscription.EMPTY;
82
    private offsetChanged$ = new ReplaySubject(1);
83
    private destroy$ = new Subject<void>();
84
    private timeout?: any;
85
    private document: any;
86

87
    private get container(): Element | Window {
88
        const el = this.thyContainer();
5✔
89
        return (typeof el === 'string' ? this.document.querySelector(el) : el) || window;
5✔
90
    }
5!
UNCOV
91

×
92
    constructor() {
93
        const el = inject(ElementRef);
5✔
94
        const document = inject(DOCUMENT);
2✔
95

96
        // The wrapper would stay at the original position as a placeholder.
3✔
97
        this.placeholderNode = el.nativeElement;
3✔
98
        this.document = document;
3✔
99
    }
3✔
100

3!
101
    ngOnChanges(changes: SimpleChanges): void {
3✔
102
        const { thyOffsetBottom, thyOffsetTop, thyContainer } = changes;
103

UNCOV
104
        if (thyOffsetBottom || thyOffsetTop) {
×
105
            this.offsetChanged$.next(undefined);
106
        }
3!
107
        if (thyContainer) {
3✔
108
            this.registerListeners();
109
        }
110
    }
111

5✔
112
    ngAfterViewInit(): void {
5✔
113
        this.registerListeners();
2✔
114
    }
115

3✔
116
    ngOnDestroy(): void {
3✔
117
        this.removeListeners();
118
    }
119

×
UNCOV
120
    private registerListeners(): void {
×
121
        this.removeListeners();
UNCOV
122
        this.positionChangeSubscription = this.ngZone.runOutsideAngular(() => {
×
123
            return merge(
×
UNCOV
124
                ...Object.keys(AffixRespondEvents).map(evName => fromEvent(this.container, evName)),
×
125
                this.offsetChanged$.pipe(
126
                    takeUntil(this.destroy$),
127
                    map(() => ({}))
UNCOV
128
                )
×
129
            )
130
                .pipe(auditTime(THY_AFFIX_DEFAULT_SCROLL_TIME))
131
                .subscribe(e => this.updatePosition(e as Event));
UNCOV
132
        });
×
133
        this.timeout = setTimeout(() => this.updatePosition({} as Event));
134
    }
135

5!
UNCOV
136
    private removeListeners(): void {
×
137
        clearTimeout(this.timeout);
138
        this.positionChangeSubscription.unsubscribe();
5✔
139
        this.destroy$.next();
5✔
140
        this.destroy$.complete();
5✔
141
    }
5✔
142

5✔
143
    getOffset(element: Element, target: Element | Window | undefined): SimpleRect {
5✔
144
        const elemRect = element.getBoundingClientRect();
145
        const containerRect = dom.getContainerRect(target);
146

147
        const scrollTop = this.scrollService.getScroll(target, true);
5✔
148
        const scrollLeft = this.scrollService.getScroll(target, false);
149

150
        const docElem = this.document.body;
151
        const clientTop = docElem.clientTop || 0;
152
        const clientLeft = docElem.clientLeft || 0;
5✔
153

5!
UNCOV
154
        return {
×
UNCOV
155
            top: elemRect.top - containerRect.top + scrollTop - clientTop,
×
156
            left: elemRect.left - containerRect.left + scrollLeft - clientLeft,
157
            width: elemRect.width,
158
            height: elemRect.height
5✔
159
        };
5✔
160
    }
161

5✔
162
    private setAffixStyle(e: Event, affixStyle?: any): void {
5✔
163
        const originalAffixStyle = this.affixStyle;
5✔
164
        const isWindow = this.container === window;
3✔
165
        if (e.type === 'scroll' && originalAffixStyle && affixStyle && isWindow) {
3✔
166
            return;
3✔
167
        }
168
        if (shallowEqual(originalAffixStyle, affixStyle)) {
169
            return;
170
        }
171

172
        const fixed = !!affixStyle;
3✔
173
        const wrapElement = this.fixedElement().nativeElement;
174
        this.renderer.setStyle(wrapElement, 'cssText', dom.getStyleAsText(affixStyle));
175
        this.affixStyle = affixStyle;
176
        if (fixed) {
177
            wrapElement.classList.add(THY_AFFIX_CLS_PREFIX);
2!
178
        } else {
UNCOV
179
            wrapElement.classList.remove(THY_AFFIX_CLS_PREFIX);
×
UNCOV
180
        }
×
UNCOV
181

×
182
        if ((affixStyle && !originalAffixStyle) || (!affixStyle && originalAffixStyle)) {
183
            this.thyChange.emit(fixed);
184
        }
185
    }
186

187
    private setPlaceholderStyle(placeholderStyle?: any): void {
×
188
        const originalPlaceholderStyle = this.placeholderStyle;
189
        if (shallowEqual(placeholderStyle, originalPlaceholderStyle)) {
190
            return;
191
        }
192
        this.renderer.setStyle(this.placeholderNode, 'cssText', dom.getStyleAsText(placeholderStyle));
193
        this.placeholderStyle = placeholderStyle;
2!
194
    }
195

196
    private syncPlaceholderStyle(e: Event): void {
UNCOV
197
        if (!this.affixStyle) {
×
198
            return;
199
        }
200
        this.renderer.setStyle(this.placeholderNode, 'cssText', '');
201
        this.placeholderStyle = undefined;
202
        const styleObj = {
203
            width: this.placeholderNode.offsetWidth,
2✔
204
            height: this.fixedElement().nativeElement.offsetHeight
205
        };
2✔
206
        this.setAffixStyle(e, {
207
            ...this.affixStyle,
5!
UNCOV
208
            ...styleObj
×
209
        });
210
        this.setPlaceholderStyle(styleObj);
211
    }
1✔
212

1✔
213
    updatePosition(e: Event): void {
214
        if (!this.platform.isBrowser) {
215
            return;
216
        }
217

218
        const containerNode = this.container;
219
        let offsetTop = this.thyOffsetTop();
220
        const scrollTop = this.scrollService.getScroll(containerNode, true);
1✔
221
        const elementOffset = this.getOffset(this.placeholderNode, containerNode);
222
        const fixedNode = this.fixedElement().nativeElement;
223
        const elemSize = {
224
            width: fixedNode.offsetWidth,
225
            height: fixedNode.offsetHeight
226
        };
227
        const offsetMode = {
228
            top: false,
229
            bottom: false
230
        };
231
        // Default to `offsetTop=0`.
232
        const thyOffsetBottom = this.thyOffsetBottom();
233
        if (typeof offsetTop !== 'number' && typeof thyOffsetBottom !== 'number') {
234
            offsetMode.top = true;
235
            offsetTop = 0;
236
        } else {
237
            offsetMode.top = typeof offsetTop === 'number';
238
            offsetMode.bottom = typeof thyOffsetBottom === 'number';
239
        }
240
        const containerRect = dom.getContainerRect(containerNode as Window);
241
        const targetInnerHeight = (containerNode as Window).innerHeight || (containerNode as HTMLElement).clientHeight;
242
        if (scrollTop >= elementOffset.top - (offsetTop as number) && offsetMode.top) {
243
            const width = elementOffset.width;
244
            const top = containerRect.top + (offsetTop as number);
245
            this.setAffixStyle(e, {
246
                position: 'fixed',
247
                top,
248
                left: containerRect.left + elementOffset.left,
249
                width
250
            });
251
            this.setPlaceholderStyle({
252
                width,
253
                height: elemSize.height
254
            });
255
        } else if (
256
            scrollTop <= elementOffset.top + elemSize.height + (thyOffsetBottom as number) - targetInnerHeight &&
257
            offsetMode.bottom
258
        ) {
259
            const targetBottomOffset = containerNode === window ? 0 : window.innerHeight - containerRect.bottom;
260
            const width = elementOffset.width;
261
            this.setAffixStyle(e, {
262
                position: 'fixed',
263
                bottom: targetBottomOffset + (thyOffsetBottom as number),
264
                left: containerRect.left + elementOffset.left,
265
                width
266
            });
267
            this.setPlaceholderStyle({
268
                width,
269
                height: elementOffset.height
270
            });
271
        } else {
272
            if (
273
                e.type === AffixRespondEvents.resize &&
274
                this.affixStyle &&
275
                this.affixStyle.position === 'fixed' &&
276
                this.placeholderNode.offsetWidth
277
            ) {
278
                this.setAffixStyle(e, {
279
                    ...this.affixStyle,
280
                    width: this.placeholderNode.offsetWidth
281
                });
282
            } else {
283
                this.setAffixStyle(e);
284
            }
285
            this.setPlaceholderStyle();
286
        }
287

288
        if (e.type === 'resize') {
289
            this.syncPlaceholderStyle(e);
290
        }
291
    }
292
}
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