• 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

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
    standalone: true
41
})
42
export class ThyAutocompleteTriggerDirective implements OnInit, OnDestroy {
155✔
43
    private elementRef = inject(ElementRef);
44
    private ngZone = inject(NgZone);
45
    private overlay = inject(Overlay);
11!
46
    private autocompleteService = inject(ThyAutocompleteService);
11✔
47
    private viewContainerRef = inject(ViewContainerRef);
NEW
48
    private document = inject(DOCUMENT, { optional: true })!;
×
49
    private cdr = inject(ChangeDetectorRef);
50

51
    protected overlayRef: OverlayRef;
11!
52

53
    private autocompleteRef: ThyAutocompleteRef<ThyAutocomplete>;
5✔
54

55
    private readonly closeKeyEventStream = new Subject<void>();
56

57
    private closingActionsSubscription: Subscription;
14✔
58

13✔
59
    private _autocompleteComponent: ThyAutocomplete;
60

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

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

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

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

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

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

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

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

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

15!
118
        return null;
15✔
119
    }
15✔
120

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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