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

atinc / ngx-tethys / 3b40a702-4b4d-4ddb-81a7-a96baae6d682

08 Nov 2024 05:40AM UTC coverage: 90.395% (-0.04%) from 90.431%
3b40a702-4b4d-4ddb-81a7-a96baae6d682

push

circleci

why520crazy
Merge branch 'master' into feat-theme

5503 of 6730 branches covered (81.77%)

Branch coverage included in aggregate %.

424 of 431 new or added lines in 171 files covered. (98.38%)

344 existing lines in 81 files now uncovered.

13150 of 13905 relevant lines covered (94.57%)

999.86 hits per line

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

73.62
/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,
1✔
11
    Component,
1✔
12
    ElementRef,
13
    EventEmitter,
14
    Input,
15
    NgZone,
16
    numberAttribute,
17
    OnChanges,
1✔
18
    OnDestroy,
19
    Output,
164✔
20
    Renderer2,
164!
21
    SimpleChanges,
22
    ViewChild,
23
    ViewEncapsulation,
11✔
24
    inject
11✔
25
} from '@angular/core';
11✔
26

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

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

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

22✔
55
    @ViewChild('fixedElement', { static: true }) private fixedElement!: ElementRef<HTMLDivElement>;
154✔
56

57
    /**
1✔
58
     * 设置 thy-affix 需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数
59
     * @default window
22✔
60
     * @type string | Element | Window
61
     */
62
    @Input() thyContainer?: string | Element | Window;
33✔
63

33✔
64
    /**
33✔
65
     * 距离窗口顶部缓冲的偏移量阈值
33✔
66
     * @default 0
67
     */
68
    @Input({ transform: numberAttribute })
5✔
69
    thyOffsetTop?: null | number;
5✔
70

5✔
71
    /**
5✔
72
     * 距离窗口底部缓冲的偏移量阈值
5✔
73
     */
5✔
74
    @Input({ transform: numberAttribute })
5✔
75
    thyOffsetBottom?: null | number;
5✔
76

77
    /**
78
     * 固定状态改变时触发的回调函数
79
     */
80
    @Output() readonly thyChange = new EventEmitter<boolean>();
81

82
    private readonly placeholderNode: HTMLElement;
83

5✔
84
    private affixStyle?: any;
5✔
85
    private placeholderStyle?: any;
5!
UNCOV
86
    private positionChangeSubscription: Subscription = Subscription.EMPTY;
×
87
    private offsetChanged$ = new ReplaySubject(1);
88
    private destroy$ = new Subject<void>();
5✔
89
    private timeout?: any;
2✔
90
    private document: any;
91

3✔
92
    private get container(): Element | Window {
3✔
93
        const el = this.thyContainer;
3✔
94
        return (typeof el === 'string' ? this.document.querySelector(el) : el) || window;
3✔
95
    }
3!
96

3✔
97
    constructor() {
98
        const el = inject(ElementRef);
NEW
99
        const document = inject(DOCUMENT);
×
100

101
        // The wrapper would stay at the original position as a placeholder.
3!
102
        this.placeholderNode = el.nativeElement;
3✔
103
        this.document = document;
104
    }
105

106
    ngOnChanges(changes: SimpleChanges): void {
5✔
107
        const { thyOffsetBottom, thyOffsetTop, thyContainer } = changes;
5✔
108

2✔
109
        if (thyOffsetBottom || thyOffsetTop) {
110
            this.offsetChanged$.next(undefined);
3✔
111
        }
3✔
112
        if (thyContainer) {
113
            this.registerListeners();
114
        }
×
UNCOV
115
    }
×
116

117
    ngAfterViewInit(): void {
×
118
        this.registerListeners();
×
UNCOV
119
    }
×
120

121
    ngOnDestroy(): void {
122
        this.removeListeners();
UNCOV
123
    }
×
124

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

141
    private removeListeners(): void {
142
        clearTimeout(this.timeout);
5✔
143
        this.positionChangeSubscription.unsubscribe();
144
        this.destroy$.next();
145
        this.destroy$.complete();
146
    }
147

5!
148
    getOffset(element: Element, target: Element | Window | undefined): SimpleRect {
×
UNCOV
149
        const elemRect = element.getBoundingClientRect();
×
150
        const containerRect = dom.getContainerRect(target);
151

152
        const scrollTop = this.scrollService.getScroll(target, true);
5✔
153
        const scrollLeft = this.scrollService.getScroll(target, false);
5✔
154

155
        const docElem = this.document.body;
5✔
156
        const clientTop = docElem.clientTop || 0;
5✔
157
        const clientLeft = docElem.clientLeft || 0;
5✔
158

3✔
159
        return {
3✔
160
            top: elemRect.top - containerRect.top + scrollTop - clientTop,
3✔
161
            left: elemRect.left - containerRect.left + scrollLeft - clientLeft,
162
            width: elemRect.width,
163
            height: elemRect.height
164
        };
165
    }
166

3✔
167
    private setAffixStyle(e: Event, affixStyle?: any): void {
168
        const originalAffixStyle = this.affixStyle;
169
        const isWindow = this.container === window;
170
        if (e.type === 'scroll' && originalAffixStyle && affixStyle && isWindow) {
171
            return;
2!
172
        }
173
        if (shallowEqual(originalAffixStyle, affixStyle)) {
×
174
            return;
×
UNCOV
175
        }
×
176

177
        const fixed = !!affixStyle;
178
        const wrapElement = this.fixedElement.nativeElement;
179
        this.renderer.setStyle(wrapElement, 'cssText', dom.getStyleAsText(affixStyle));
180
        this.affixStyle = affixStyle;
UNCOV
181
        if (fixed) {
×
182
            wrapElement.classList.add(THY_AFFIX_CLS_PREFIX);
183
        } else {
184
            wrapElement.classList.remove(THY_AFFIX_CLS_PREFIX);
185
        }
186

187
        if ((affixStyle && !originalAffixStyle) || (!affixStyle && originalAffixStyle)) {
2!
188
            this.thyChange.emit(fixed);
189
        }
190
    }
UNCOV
191

×
192
    private setPlaceholderStyle(placeholderStyle?: any): void {
193
        const originalPlaceholderStyle = this.placeholderStyle;
194
        if (shallowEqual(placeholderStyle, originalPlaceholderStyle)) {
195
            return;
196
        }
197
        this.renderer.setStyle(this.placeholderNode, 'cssText', dom.getStyleAsText(placeholderStyle));
2✔
198
        this.placeholderStyle = placeholderStyle;
199
    }
2✔
200

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

218
    updatePosition(e: Event): void {
219
        if (!this.platform.isBrowser) {
220
            return;
221
        }
222

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

292
        if (e.type === 'resize') {
293
            this.syncPlaceholderStyle(e);
294
        }
295
    }
296
}
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

© 2026 Coveralls, Inc