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

atinc / ngx-tethys / c0ef8457-a839-451f-8b72-80fd73106231

02 Apr 2024 02:27PM UTC coverage: 90.524% (-0.06%) from 90.585%
c0ef8457-a839-451f-8b72-80fd73106231

Pull #3062

circleci

minlovehua
refactor(all): use the transform attribute of @Input() instead of @InputBoolean() and @InputNumber()
Pull Request #3062: refactor(all): use the transform attribute of @input() instead of @InputBoolean() and @InputNumber()

4987 of 6108 branches covered (81.65%)

Branch coverage included in aggregate %.

217 of 223 new or added lines in 82 files covered. (97.31%)

202 existing lines in 53 files now uncovered.

12246 of 12929 relevant lines covered (94.72%)

1055.59 hits per line

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

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

30
/**
31
 * 自动完成触发指令
11!
32
 * @name thyAutocomplete
11✔
33
 */
UNCOV
34
@Directive({
×
35
    selector:
36
        'input[thyAutocompleteTrigger], textarea[thyAutocompleteTrigger], thy-input[thyAutocompleteTrigger], thy-input-search[thyAutocompleteTrigger], input[thyAutocomplete], textarea[thyAutocomplete], thy-input[thyAutocomplete], thy-input-search[thyAutocomplete]',
37
    exportAs: 'thyAutocompleteTrigger, thyAutocomplete',
11!
38
    host: {
39
        '(input)': 'handleInput($event)',
5✔
40
        '(focusin)': 'onFocus()',
41
        '(keydown)': 'onKeydown($event)'
42
    },
15✔
43
    standalone: true
15✔
44
})
15✔
45
export class ThyAutocompleteTriggerDirective implements OnInit, OnDestroy {
15✔
46
    protected overlayRef: OverlayRef;
15✔
47

15✔
48
    private autocompleteRef: ThyAutocompleteRef<ThyAutocomplete>;
15✔
49

15✔
50
    private readonly closeKeyEventStream = new Subject<void>();
15✔
51

15✔
52
    private closingActionsSubscription: Subscription;
15✔
53

15✔
54
    private _autocompleteComponent: ThyAutocomplete;
55

56
    @HostBinding(`class.thy-autocomplete-opened`) panelOpened = false;
57

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

8✔
71
    /**
2✔
72
     * 下拉菜单组件实例
2✔
73
     * @type thyAutocompleteComponent
2✔
74
     */
75
    @Input('thyAutocomplete')
6!
76
    set autocomplete(data: ThyAutocomplete) {
6✔
77
        this._autocompleteComponent = data;
6✔
78
    }
6✔
79

4✔
80
    get autocompleteComponent() {
81
        return this._autocompleteComponent;
2!
82
    }
2✔
83

84
    /**
6✔
85
     * 弹出框默认 offset
86
     * @type number
3✔
87
     */
88
    @Input({ transform: numberAttribute }) thyOffset = 4;
89

90
    /**
91
     * 下拉菜单的宽度,不设置默认与输入框同宽
1!
92
     * @type number
1✔
93
     */
94
    @Input({ transform: numberAttribute }) thyAutocompleteWidth: number;
95

96
    /**
15✔
97
     * 下拉菜单的显示位置,'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight' | 'left' | 'leftTop' | 'leftBottom' | 'right' | 'rightTop' | 'rightBottom'
4✔
98
     * @type string
99
     */
11✔
100
    @Input() thyPlacement: ThyPlacement = 'bottomLeft';
11✔
101

11✔
102
    /**
103
     * 是否允许聚焦时打开下拉菜单
104
     * @type boolean
8✔
105
     */
1✔
106
    @Input({ transform: booleanAttribute }) thyIsFocusOpen = true;
1✔
107

108
    get activeOption(): ThyOption | null {
109
        if (this.autocompleteComponent && this.autocompleteComponent.keyManager) {
1✔
110
            return this.autocompleteComponent.keyManager.activeItem;
1✔
111
        }
112

113
        return null;
11✔
114
    }
11✔
115

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

129
    constructor(
130
        private elementRef: ElementRef,
12✔
131
        private ngZone: NgZone,
132
        private overlay: Overlay,
11✔
133
        private autocompleteService: ThyAutocompleteService,
11✔
134
        private viewContainerRef: ViewContainerRef,
11✔
135
        @Optional() @Inject(DOCUMENT) private document: any,
11✔
136
        private cdr: ChangeDetectorRef
137
    ) {}
138

11✔
139
    ngOnInit(): void {}
140

141
    onFocus() {
142
        if (this.canOpen() && this.thyIsFocusOpen) {
11✔
143
            this.openPanel();
144
        }
11✔
145
    }
146

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

180
    handleInput(event: KeyboardEvent) {
181
        if (this.canOpen() && document.activeElement === event.target) {
11✔
182
            this.openPanel();
183
        }
184
    }
1✔
185

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

208
    closePanel() {
15✔
209
        if (this.autocompleteRef) {
11✔
210
            this.autocompleteRef.close();
11✔
211
            this.cdr.detectChanges();
11✔
212
            this.closingActionsSubscription.unsubscribe();
213
        }
214
    }
215

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

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

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

273
    private setValueAndClose(event: ThyOptionSelectionChangeEvent | null): void {
274
        if (event && event.option) {
275
            this.setValue(event.option.thyLabelText);
276
        }
277
        this.closePanel();
278
    }
279

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

292
                return (
293
                    this.panelOpened &&
294
                    clickTarget !== this.elementRef.nativeElement &&
295
                    !this.elementRef.nativeElement.contains(clickTarget) &&
296
                    (!formField || !formField.contains(clickTarget)) &&
297
                    !!this.overlayRef &&
298
                    !this.overlayRef.overlayElement.contains(clickTarget)
299
                );
300
            })
301
        );
302
    }
303

304
    private setValue(value: string) {
305
        const input = this.elementRef.nativeElement.querySelector('input');
306
        const element = input ? input : this.elementRef.nativeElement;
307
        element.value = value;
308
        element.focus();
309
    }
310

311
    private canOpen(): boolean {
312
        const element: HTMLInputElement = this.elementRef.nativeElement;
313
        return !element.readOnly && !element.disabled;
314
    }
315

316
    private resetActiveItem(): void {
317
        this.autocompleteComponent.keyManager.setActiveItem(this.autocompleteComponent.thyAutoActiveFirstOption ? 0 : -1);
318
    }
319

320
    private destroyPanel(): void {
321
        if (this.overlayRef) {
322
            this.closePanel();
323
            this.overlayRef.dispose();
324
            this.overlayRef = null;
325
        }
326
    }
327

328
    ngOnDestroy() {
329
        this.closeKeyEventStream.complete();
330
        this.destroyPanel();
331
    }
332
}
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