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

atinc / ngx-tethys / 68ef226c-f83e-44c1-b8ed-e420a83c5d84

28 May 2025 10:31AM UTC coverage: 10.352% (-80.0%) from 90.316%
68ef226c-f83e-44c1-b8ed-e420a83c5d84

Pull #3460

circleci

pubuzhixing8
chore: xxx
Pull Request #3460: refactor(icon): migrate signal input #TINFR-1476

132 of 6823 branches covered (1.93%)

Branch coverage included in aggregate %.

10 of 14 new or added lines in 1 file covered. (71.43%)

11648 existing lines in 344 files now uncovered.

2078 of 14525 relevant lines covered (14.31%)

6.69 hits per line

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

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

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

×
UNCOV
52
    protected overlayRef: OverlayRef;
×
53

54
    private autocompleteRef: ThyAutocompleteRef<ThyAutocomplete>;
55

UNCOV
56
    private readonly closeKeyEventStream = new Subject<void>();
×
57

58
    private closingActionsSubscription: Subscription;
59

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

×
UNCOV
62
    /**
×
63
     * 下拉菜单组件实例。已废弃,请使用 thyAutocomplete
UNCOV
64
     * @type thyAutocompleteComponent
×
UNCOV
65
     * @deprecated
×
UNCOV
66
     */
×
UNCOV
67
    readonly thyAutocompleteComponent = input<ThyAutocomplete>();
×
UNCOV
68

×
69
    /**
UNCOV
70
     * 下拉菜单组件实例
×
UNCOV
71
     * @type thyAutocompleteComponent
×
UNCOV
72
     */
×
UNCOV
73
    readonly thyAutocomplete = input<ThyAutocomplete>();
×
UNCOV
74

×
75
    readonly autocompleteComponent: Signal<ThyAutocomplete> = computed(() => {
UNCOV
76
        return this.thyAutocomplete() || this.thyAutocompleteComponent();
×
UNCOV
77
    });
×
78

UNCOV
79
    /**
×
80
     * 弹出框默认 offset
UNCOV
81
     */
×
82
    readonly thyOffset = input<number, unknown>(4, { transform: numberAttribute });
83

84
    /**
85
     * 下拉菜单的宽度,不设置默认与输入框同宽
UNCOV
86
     */
×
UNCOV
87
    readonly thyAutocompleteWidth = input<number, unknown>(undefined, { transform: numberAttribute });
×
88

89
    /**
90
     * 下拉菜单的显示位置,'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight' | 'left' | 'leftTop' | 'leftBottom' | 'right' | 'rightTop' | 'rightBottom'
UNCOV
91
     */
×
UNCOV
92
    readonly thyPlacement = input<ThyPlacement>('bottomLeft');
×
93

UNCOV
94
    /**
×
UNCOV
95
     * 是否允许聚焦时打开下拉菜单
×
UNCOV
96
     */
×
97
    readonly thyIsFocusOpen = input(true, { transform: coerceBooleanProperty });
98

UNCOV
99
    readonly activeOption: Signal<ThyOption | null> = computed(() => {
×
UNCOV
100
        if (this.autocompleteComponent() && this.autocompleteComponent().keyManager) {
×
UNCOV
101
            return this.autocompleteComponent().keyManager.activeItem;
×
102
        }
103
        return null;
UNCOV
104
    });
×
UNCOV
105

×
106
    get panelClosingActions(): Observable<ThyOptionSelectionChangeEvent | null> {
107
        return merge(
UNCOV
108
            outputToObservable(this.autocompleteComponent().thyOptionSelected),
×
UNCOV
109
            this.autocompleteComponent().keyManager.tabOut.pipe(filter(() => this.panelOpened)),
×
110
            this.closeKeyEventStream,
111
            this.getOutsideClickStream(),
UNCOV
112
            this.overlayRef ? this.overlayRef.detachments().pipe(filter(() => this.panelOpened)) : of()
×
UNCOV
113
        ).pipe(
×
UNCOV
114
            // Normalize the output so we return a consistent type.
×
UNCOV
115
            map(event => (event instanceof ThyOptionSelectionChangeEvent ? event : null))
×
116
        );
117
    }
118

UNCOV
119
    ngOnInit(): void {}
×
120

121
    onFocus() {
122
        if (this.canOpen() && this.thyIsFocusOpen()) {
123
            this.openPanel();
124
        }
125
    }
×
126

UNCOV
127
    onKeydown(event: KeyboardEvent) {
×
UNCOV
128
        const keyCode = event.keyCode;
×
UNCOV
129
        // Prevent the default action on all escape key presses. This is here primarily to bring IE
×
UNCOV
130
        // in line with other browsers. By default, pressing escape on IE will cause it to revert
×
UNCOV
131
        // the input value to the one that it had on focus, however it won't dispatch any events
×
132
        // which means that the model value will be out of sync with the view.
133
        if (keyCode === ESCAPE) {
UNCOV
134
            event.preventDefault();
×
135
        }
136
        const autocompleteComponent = this.autocompleteComponent();
137
        if (this.activeOption() && keyCode === ENTER && this.panelOpened) {
UNCOV
138
            this.activeOption().selectViaInteraction();
×
139
            this.resetActiveItem();
UNCOV
140
            event.preventDefault();
×
141
        } else if (autocompleteComponent) {
142
            const prevActiveItem = autocompleteComponent.keyManager.activeItem;
143
            const isArrowKey = keyCode === UP_ARROW || keyCode === DOWN_ARROW;
144
            if (this.panelOpened || keyCode === TAB) {
145
                autocompleteComponent.keyManager.onKeydown(event);
146
            } else if (isArrowKey && this.canOpen()) {
UNCOV
147
                this.openPanel();
×
UNCOV
148
            }
×
149
            if (
150
                (isArrowKey || autocompleteComponent.keyManager.activeItem !== prevActiveItem) &&
151
                autocompleteComponent.keyManager.activeItem
152
            ) {
UNCOV
153
                ScrollToService.scrollToElement(
×
154
                    autocompleteComponent.keyManager.activeItem.element.nativeElement,
155
                    autocompleteComponent.optionsContainer().nativeElement
156
                );
157
            }
UNCOV
158
        }
×
UNCOV
159
    }
×
UNCOV
160

×
161
    handleInput(event: KeyboardEvent) {
UNCOV
162
        if (this.canOpen() && document.activeElement === event.target) {
×
163
            this.openPanel();
164
        }
165
    }
166

UNCOV
167
    openPanel() {
×
168
        if (this.overlayRef && this.overlayRef.hasAttached()) {
169
            return;
UNCOV
170
        }
×
UNCOV
171
        const overlayRef = this.createOverlay();
×
172
        this.overlayRef = overlayRef;
UNCOV
173
        overlayRef.keydownEvents().subscribe(event => {
×
174
            // Close when pressing ESCAPE or ALT + UP_ARROW, based on the a11y guidelines.
175
            // See: https://www.w3.org/TR/wai-aria-practices-1.1/#textbox-keyboard-interaction
176
            if (event.keyCode === ESCAPE || (event.keyCode === UP_ARROW && event.altKey)) {
UNCOV
177
                this.resetActiveItem();
×
178
                this.closeKeyEventStream.next();
179
                // We need to stop propagation, otherwise the event will eventually
UNCOV
180
                // reach the input itself and cause the overlay to be reopened.
×
UNCOV
181
                event.stopPropagation();
×
UNCOV
182
                event.preventDefault();
×
183
            }
184
        });
185
        this.panelOpened = true;
186
        this.autocompleteComponent().open();
187
    }
188

189
    closePanel() {
190
        if (this.autocompleteRef) {
UNCOV
191
            this.autocompleteRef.close();
×
UNCOV
192
            this.cdr.detectChanges();
×
UNCOV
193
            this.closingActionsSubscription.unsubscribe();
×
UNCOV
194
        }
×
195
    }
196

UNCOV
197
    createOverlay(): OverlayRef {
×
UNCOV
198
        const config = Object.assign({
×
199
            origin: this.elementRef.nativeElement,
200
            viewContainerRef: this.viewContainerRef,
UNCOV
201
            placement: this.thyPlacement(),
×
UNCOV
202
            offset: this.thyOffset(),
×
UNCOV
203
            scrollStrategy: this.overlay.scrollStrategies.reposition(),
×
204
            width: this.thyAutocompleteWidth() || this.elementRef.nativeElement.clientWidth
205
        });
UNCOV
206
        const autocompleteComponent = this.autocompleteComponent();
×
UNCOV
207
        this.autocompleteRef = this.autocompleteService.open(autocompleteComponent.contentTemplateRef(), config);
×
UNCOV
208
        this.autocompleteRef.afterClosed().subscribe(() => {
×
UNCOV
209
            this.panelOpened = false;
×
210
            autocompleteComponent.close();
211
        });
212
        // delay 200ms to prevent emit document click rightnow
UNCOV
213
        this.autocompleteRef
×
UNCOV
214
            .afterOpened()
×
215
            .pipe(delay(200))
216
            .subscribe(() => {
1✔
217
                this.closingActionsSubscription = this.subscribeToClosingActions();
218
            });
219
        return this.autocompleteRef.getOverlayRef();
220
    }
221

222
    /**
223
     * This method listens to a stream of panel closing actions and resets the
224
     * stream every time the option list changes.
225
     */
226
    private subscribeToClosingActions(): Subscription {
1✔
227
        const firstStable = this.ngZone.onStable.asObservable().pipe(take(1));
228
        const optionChanges = this.autocompleteComponent().options.changes.pipe(
229
            // Defer emitting to the stream until the next tick, because changing
230
            // bindings in here will cause "changed after checked" errors.
231
            delay(0)
232
        );
233
        // When the zone is stable initially, and when the option list changes...
234
        return (
235
            merge(firstStable, optionChanges)
236
                .pipe(
237
                    // create a new stream of panelClosingActions, replacing any previous streams
238
                    // that were created, and flatten it so our stream only emits closing events...
239
                    switchMap(() => {
240
                        this.resetActiveItem();
241

242
                        if (this.panelOpened) {
243
                            this.overlayRef.updatePosition();
244
                        }
245
                        return this.panelClosingActions;
246
                    }),
247
                    // when the first closing event occurs...
248
                    take(1)
249
                )
250
                // set the value, close the panel, and complete.
251
                .subscribe(event => this.setValueAndClose(event))
252
        );
253
    }
254

255
    private setValueAndClose(event: ThyOptionSelectionChangeEvent | null): void {
256
        if (event && event.option) {
257
            this.setValue(event.option.thyLabelText);
258
        }
259
        this.closePanel();
260
    }
261

262
    /** Stream of clicks outside of the autocomplete panel. */
263
    private getOutsideClickStream(): Observable<any> {
264
        return merge(
265
            fromEvent(this.document, 'click') as Observable<MouseEvent>,
266
            fromEvent(this.document, 'touchend') as Observable<TouchEvent>
267
        ).pipe(
268
            filter(event => {
269
                // If we're in the Shadow DOM, the event target will be the shadow root, so we have to
270
                // fall back to check the first element in the path of the click event.
271
                const clickTarget = event.target as HTMLElement;
272
                const formField: any = null;
273

274
                return (
275
                    this.panelOpened &&
276
                    clickTarget !== this.elementRef.nativeElement &&
277
                    !this.elementRef.nativeElement.contains(clickTarget) &&
278
                    (!formField || !formField.contains(clickTarget)) &&
279
                    !!this.overlayRef &&
280
                    !this.overlayRef.overlayElement.contains(clickTarget)
281
                );
282
            })
283
        );
284
    }
285

286
    private setValue(value: string) {
287
        const input = this.elementRef.nativeElement.querySelector('input');
288
        const element = input ? input : this.elementRef.nativeElement;
289
        element.value = value;
290
        element.focus();
291
    }
292

293
    private canOpen(): boolean {
294
        const element: HTMLInputElement = this.elementRef.nativeElement;
295
        return !element.readOnly && !element.disabled;
296
    }
297

298
    private resetActiveItem(): void {
299
        const autocompleteComponent = this.autocompleteComponent();
300
        const index = autocompleteComponent.thyAutoActiveFirstOption() ? 0 : -1;
301
        autocompleteComponent.keyManager.setActiveItem(index);
302
    }
303

304
    private destroyPanel(): void {
305
        if (this.overlayRef) {
306
            this.closePanel();
307
            this.overlayRef.dispose();
308
            this.overlayRef = null;
309
        }
310
    }
311

312
    ngOnDestroy() {
313
        this.closeKeyEventStream.complete();
314
        this.destroyPanel();
315
    }
316
}
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