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

atinc / ngx-tethys / a7752ce8-4ecf-4531-b048-ad77ed97d668

16 May 2025 03:53AM UTC coverage: 90.286% (-0.01%) from 90.296%
a7752ce8-4ecf-4531-b048-ad77ed97d668

Pull #3433

circleci

walkerkay
refactor(pagination): update input number types
Pull Request #3433: refactor(pagination): migrate to signal for pagination #TINFR-1757

5627 of 6893 branches covered (81.63%)

Branch coverage included in aggregate %.

49 of 51 new or added lines in 1 file covered. (96.08%)

14 existing lines in 6 files now uncovered.

13409 of 14191 relevant lines covered (94.49%)

920.14 hits per line

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

94.29
/src/autocomplete/autocomplete.trigger.directive.ts
1
import {
2
    Directive,
3
    ElementRef,
4
    NgZone,
5
    OnDestroy,
6
    Input,
7
    OnInit,
8
    ViewContainerRef,
9
    HostBinding,
10
    ChangeDetectorRef,
11
    numberAttribute,
12
    inject
13
} from '@angular/core';
14
import { OverlayRef, Overlay } from '@angular/cdk/overlay';
15
import { ThyPlacement } from 'ngx-tethys/core';
16
import { ThyAutocompleteService } from './overlay/autocomplete.service';
17
import { ThyAutocompleteRef } from './overlay/autocomplete-ref';
1✔
18
import { ThyAutocomplete } from './autocomplete.component';
19
import { ThyOption, ThyOptionSelectionChangeEvent } from 'ngx-tethys/shared';
15✔
20
import { DOCUMENT } from '@angular/common';
15✔
21
import { Subject, Observable, merge, fromEvent, of, Subscription } from 'rxjs';
15✔
22
import { ESCAPE, UP_ARROW, ENTER, DOWN_ARROW, TAB, coerceBooleanProperty } from 'ngx-tethys/util';
15✔
23
import { filter, map, take, delay, switchMap } from 'rxjs/operators';
15✔
24
import { ScrollToService } from 'ngx-tethys/core';
15✔
25
import { warnDeprecation } from 'ngx-tethys/util';
15✔
26

15✔
27
/**
15✔
28
 * 自动完成触发指令
15✔
29
 * @name thyAutocomplete
15✔
30
 */
15✔
31
@Directive({
32
    selector:
33
        'input[thyAutocompleteTrigger], textarea[thyAutocompleteTrigger], thy-input[thyAutocompleteTrigger], thy-input-search[thyAutocompleteTrigger], input[thyAutocomplete], textarea[thyAutocomplete], thy-input[thyAutocomplete], thy-input-search[thyAutocomplete]',
12!
34
    exportAs: 'thyAutocompleteTrigger, thyAutocomplete',
12✔
35
    host: {
36
        '(input)': 'handleInput($event)',
12✔
37
        '(focusin)': 'onFocus()',
38
        '(keydown)': 'onKeydown($event)'
39
    }
3✔
40
})
41
export class ThyAutocompleteTriggerDirective implements OnInit, OnDestroy {
42
    private elementRef = inject(ElementRef);
155✔
43
    private ngZone = inject(NgZone);
44
    private overlay = inject(Overlay);
45
    private autocompleteService = inject(ThyAutocompleteService);
11!
46
    private viewContainerRef = inject(ViewContainerRef);
11✔
47
    private document = inject(DOCUMENT, { optional: true })!;
UNCOV
48
    private cdr = inject(ChangeDetectorRef);
×
49

50
    protected overlayRef: OverlayRef;
51

11!
52
    private autocompleteRef: ThyAutocompleteRef<ThyAutocomplete>;
53

5✔
54
    private readonly closeKeyEventStream = new Subject<void>();
55

56
    private closingActionsSubscription: Subscription;
57

14✔
58
    private _autocompleteComponent: ThyAutocomplete;
13✔
59

60
    @HostBinding(`class.thy-autocomplete-opened`) panelOpened = false;
61

62
    /**
8✔
63
     * 下拉菜单组件实例。已废弃,请使用 thyAutocomplete
64
     * @type thyAutocompleteComponent
65
     * @deprecated
66
     */
67
    @Input('thyAutocompleteComponent')
8✔
68
    set autocompleteComponent(data: ThyAutocomplete) {
1✔
69
        if (typeof ngDevMode === 'undefined' || ngDevMode) {
70
            warnDeprecation(`The property thyAutocompleteComponent will be deprecated, please use thyAutocomplete instead.`);
8✔
71
        }
2✔
72
        this._autocompleteComponent = data;
2✔
73
    }
2✔
74

75
    /**
6!
76
     * 下拉菜单组件实例
6✔
77
     * @type thyAutocompleteComponent
6✔
78
     */
6✔
79
    @Input('thyAutocomplete')
4✔
80
    set autocomplete(data: ThyAutocomplete) {
81
        this._autocompleteComponent = data;
2!
82
    }
2✔
83

84
    get autocompleteComponent() {
6✔
85
        return this._autocompleteComponent;
86
    }
3✔
87

88
    /**
89
     * 弹出框默认 offset
90
     * @type number
91
     */
1!
92
    @Input({ transform: numberAttribute }) thyOffset = 4;
1✔
93

94
    /**
95
     * 下拉菜单的宽度,不设置默认与输入框同宽
96
     * @type number
15✔
97
     */
4✔
98
    @Input({ transform: numberAttribute }) thyAutocompleteWidth: number;
99

11✔
100
    /**
11✔
101
     * 下拉菜单的显示位置,'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight' | 'left' | 'leftTop' | 'leftBottom' | 'right' | 'rightTop' | 'rightBottom'
11✔
102
     * @type string
103
     */
104
    @Input() thyPlacement: ThyPlacement = 'bottomLeft';
8✔
105

1✔
106
    /**
1✔
107
     * 是否允许聚焦时打开下拉菜单
108
     * @type boolean
109
     */
1✔
110
    @Input({ transform: coerceBooleanProperty }) thyIsFocusOpen = true;
1✔
111

112
    get activeOption(): ThyOption | null {
113
        if (this.autocompleteComponent && this.autocompleteComponent.keyManager) {
11✔
114
            return this.autocompleteComponent.keyManager.activeItem;
11✔
115
        }
116

117
        return null;
15!
118
    }
15✔
119

15✔
120
    get panelClosingActions(): Observable<ThyOptionSelectionChangeEvent | null> {
15✔
121
        return merge(
122
            this.autocompleteComponent.thyOptionSelected,
123
            this.autocompleteComponent.keyManager.tabOut.pipe(filter(() => this.panelOpened)),
124
            this.closeKeyEventStream,
11✔
125
            this.getOutsideClickStream(),
126
            this.overlayRef ? this.overlayRef.detachments().pipe(filter(() => this.panelOpened)) : of()
127
        ).pipe(
128
            // Normalize the output so we return a consistent type.
129
            map(event => (event instanceof ThyOptionSelectionChangeEvent ? event : null))
130
        );
12✔
131
    }
132

11✔
133
    ngOnInit(): void {}
11✔
134

11✔
135
    onFocus() {
11✔
136
        if (this.canOpen() && this.thyIsFocusOpen) {
137
            this.openPanel();
138
        }
11✔
139
    }
140

141
    onKeydown(event: KeyboardEvent) {
142
        const keyCode = event.keyCode;
11✔
143
        // Prevent the default action on all escape key presses. This is here primarily to bring IE
144
        // in line with other browsers. By default, pressing escape on IE will cause it to revert
11✔
145
        // the input value to the one that it had on focus, however it won't dispatch any events
146
        // which means that the model value will be out of sync with the view.
147
        if (keyCode === ESCAPE) {
148
            event.preventDefault();
149
        }
150
        if (this.activeOption && keyCode === ENTER && this.panelOpened) {
151
            this.activeOption.selectViaInteraction();
11✔
152
            this.resetActiveItem();
11✔
153
            event.preventDefault();
154
        } else if (this.autocompleteComponent) {
155
            const prevActiveItem = this.autocompleteComponent.keyManager.activeItem;
156
            const isArrowKey = keyCode === UP_ARROW || keyCode === DOWN_ARROW;
157
            if (this.panelOpened || keyCode === TAB) {
11✔
158
                this.autocompleteComponent.keyManager.onKeydown(event);
159
            } else if (isArrowKey && this.canOpen()) {
160
                this.openPanel();
161
            }
162
            if (
11✔
163
                (isArrowKey || this.autocompleteComponent.keyManager.activeItem !== prevActiveItem) &&
11!
164
                this.autocompleteComponent.keyManager.activeItem
11✔
165
            ) {
166
                ScrollToService.scrollToElement(
11✔
167
                    this.autocompleteComponent.keyManager.activeItem.element.nativeElement,
168
                    this.autocompleteComponent.optionsContainer.nativeElement
169
                );
170
            }
171
        }
5✔
172
    }
173

174
    handleInput(event: KeyboardEvent) {
4✔
175
        if (this.canOpen() && document.activeElement === event.target) {
3✔
176
            this.openPanel();
177
        }
4✔
178
    }
179

180
    openPanel() {
181
        if (this.overlayRef && this.overlayRef.hasAttached()) {
11✔
182
            return;
183
        }
184
        const overlayRef = this.createOverlay();
1✔
185
        this.overlayRef = overlayRef;
1✔
186
        overlayRef.keydownEvents().subscribe(event => {
1!
187
            // Close when pressing ESCAPE or ALT + UP_ARROW, based on the a11y guidelines.
188
            // See: https://www.w3.org/TR/wai-aria-practices-1.1/#textbox-keyboard-interaction
189
            if (event.keyCode === ESCAPE || (event.keyCode === UP_ARROW && event.altKey)) {
190
                this.resetActiveItem();
191
                this.closeKeyEventStream.next();
192
                // We need to stop propagation, otherwise the event will eventually
193
                // reach the input itself and cause the overlay to be reopened.
194
                event.stopPropagation();
195
                event.preventDefault();
3✔
196
            }
3✔
197
        });
3✔
198
        this.panelOpened = true;
3✔
199
        this.autocompleteComponent.open();
200
    }
201

17✔
202
    closePanel() {
17✔
203
        if (this.autocompleteRef) {
204
            this.autocompleteRef.close();
205
            this.cdr.detectChanges();
12✔
206
            this.closingActionsSubscription.unsubscribe();
207
        }
208
    }
15✔
209

11✔
210
    createOverlay(): OverlayRef {
11✔
211
        const config = Object.assign({
11✔
212
            origin: this.elementRef.nativeElement,
213
            viewContainerRef: this.viewContainerRef,
214
            placement: this.thyPlacement,
215
            offset: this.thyOffset,
15✔
216
            scrollStrategy: this.overlay.scrollStrategies.reposition(),
15✔
217
            width: this.thyAutocompleteWidth || this.elementRef.nativeElement.clientWidth
218
        });
1✔
219
        this.autocompleteRef = this.autocompleteService.open(this.autocompleteComponent.contentTemplateRef, config);
220
        this.autocompleteRef.afterClosed().subscribe(() => {
221
            this.panelOpened = false;
222
            this.autocompleteComponent.close();
223
        });
224
        // delay 200ms to prevent emit document click rightnow
225
        this.autocompleteRef
226
            .afterOpened()
227
            .pipe(delay(200))
228
            .subscribe(() => {
1✔
229
                this.closingActionsSubscription = this.subscribeToClosingActions();
230
            });
231
        return this.autocompleteRef.getOverlayRef();
232
    }
233

234
    /**
235
     * This method listens to a stream of panel closing actions and resets the
236
     * stream every time the option list changes.
237
     */
238
    private subscribeToClosingActions(): Subscription {
239
        const firstStable = this.ngZone.onStable.asObservable().pipe(take(1));
240
        const optionChanges = this.autocompleteComponent.options.changes.pipe(
241
            // Defer emitting to the stream until the next tick, because changing
242
            // bindings in here will cause "changed after checked" errors.
243
            delay(0)
244
        );
245
        // When the zone is stable initially, and when the option list changes...
246
        return (
247
            merge(firstStable, optionChanges)
248
                .pipe(
249
                    // create a new stream of panelClosingActions, replacing any previous streams
250
                    // that were created, and flatten it so our stream only emits closing events...
251
                    switchMap(() => {
252
                        this.resetActiveItem();
253

254
                        if (this.panelOpened) {
255
                            this.overlayRef.updatePosition();
256
                        }
257
                        return this.panelClosingActions;
258
                    }),
259
                    // when the first closing event occurs...
260
                    take(1)
261
                )
262
                // set the value, close the panel, and complete.
263
                .subscribe(event => this.setValueAndClose(event))
264
        );
265
    }
266

267
    private setValueAndClose(event: ThyOptionSelectionChangeEvent | null): void {
268
        if (event && event.option) {
269
            this.setValue(event.option.thyLabelText);
270
        }
271
        this.closePanel();
272
    }
273

274
    /** Stream of clicks outside of the autocomplete panel. */
275
    private getOutsideClickStream(): Observable<any> {
276
        return merge(
277
            fromEvent(this.document, 'click') as Observable<MouseEvent>,
278
            fromEvent(this.document, 'touchend') as Observable<TouchEvent>
279
        ).pipe(
280
            filter(event => {
281
                // If we're in the Shadow DOM, the event target will be the shadow root, so we have to
282
                // fall back to check the first element in the path of the click event.
283
                const clickTarget = event.target as HTMLElement;
284
                const formField: any = null;
285

286
                return (
287
                    this.panelOpened &&
288
                    clickTarget !== this.elementRef.nativeElement &&
289
                    !this.elementRef.nativeElement.contains(clickTarget) &&
290
                    (!formField || !formField.contains(clickTarget)) &&
291
                    !!this.overlayRef &&
292
                    !this.overlayRef.overlayElement.contains(clickTarget)
293
                );
294
            })
295
        );
296
    }
297

298
    private setValue(value: string) {
299
        const input = this.elementRef.nativeElement.querySelector('input');
300
        const element = input ? input : this.elementRef.nativeElement;
301
        element.value = value;
302
        element.focus();
303
    }
304

305
    private canOpen(): boolean {
306
        const element: HTMLInputElement = this.elementRef.nativeElement;
307
        return !element.readOnly && !element.disabled;
308
    }
309

310
    private resetActiveItem(): void {
311
        this.autocompleteComponent.keyManager.setActiveItem(this.autocompleteComponent.thyAutoActiveFirstOption ? 0 : -1);
312
    }
313

314
    private destroyPanel(): void {
315
        if (this.overlayRef) {
316
            this.closePanel();
317
            this.overlayRef.dispose();
318
            this.overlayRef = null;
319
        }
320
    }
321

322
    ngOnDestroy() {
323
        this.closeKeyEventStream.complete();
324
        this.destroyPanel();
325
    }
326
}
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