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

atinc / ngx-tethys / #55

30 Jul 2025 07:08AM UTC coverage: 9.866% (-80.4%) from 90.297%
#55

push

why520crazy
feat(empty): add setMessage for update display text #TINFR-2616

92 of 6794 branches covered (1.35%)

Branch coverage included in aggregate %.

2014 of 14552 relevant lines covered (13.84%)

6.15 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';
19
import { ThyAutocompleteRef } from './overlay/autocomplete-ref';
×
20
import { ThyAutocomplete } from './autocomplete.component';
×
21
import { ThyOption, ThyOptionSelectionChangeEvent } from 'ngx-tethys/shared';
×
22
import { DOCUMENT } from '@angular/common';
×
23
import { Subject, Observable, merge, fromEvent, of, Subscription } from 'rxjs';
×
24
import { ESCAPE, UP_ARROW, ENTER, DOWN_ARROW, TAB, coerceBooleanProperty } from 'ngx-tethys/util';
×
25
import { filter, map, take, delay, switchMap } from 'rxjs/operators';
×
26
import { ScrollToService } from 'ngx-tethys/core';
×
27
import { outputToObservable } from '@angular/core/rxjs-interop';
×
28

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

×
52
    protected overlayRef: OverlayRef;
×
53

54
    private autocompleteRef: ThyAutocompleteRef<ThyAutocomplete>;
55

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

58
    private closingActionsSubscription: Subscription;
59

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

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

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

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

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

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

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

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

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

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

119
    ngOnInit(): void {}
×
120

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

127
    onKeydown(event: KeyboardEvent) {
×
128
        const keyCode = event.keyCode;
×
129
        // Prevent the default action on all escape key presses. This is here primarily to bring IE
×
130
        // in line with other browsers. By default, pressing escape on IE will cause it to revert
×
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) {
134
            event.preventDefault();
×
135
        }
136
        const autocompleteComponent = this.autocompleteComponent();
137
        if (this.activeOption() && keyCode === ENTER && this.panelOpened) {
138
            this.activeOption().selectViaInteraction();
×
139
            this.resetActiveItem();
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()) {
147
                this.openPanel();
×
148
            }
×
149
            if (
150
                (isArrowKey || autocompleteComponent.keyManager.activeItem !== prevActiveItem) &&
151
                autocompleteComponent.keyManager.activeItem
152
            ) {
153
                ScrollToService.scrollToElement(
×
154
                    autocompleteComponent.keyManager.activeItem.element.nativeElement,
155
                    autocompleteComponent.optionsContainer().nativeElement
156
                );
157
            }
158
        }
×
159
    }
×
160

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

167
    openPanel() {
×
168
        if (this.overlayRef && this.overlayRef.hasAttached()) {
169
            return;
170
        }
×
171
        const overlayRef = this.createOverlay();
×
172
        this.overlayRef = overlayRef;
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)) {
177
                this.resetActiveItem();
×
178
                this.closeKeyEventStream.next();
179
                // We need to stop propagation, otherwise the event will eventually
180
                // reach the input itself and cause the overlay to be reopened.
×
181
                event.stopPropagation();
×
182
                event.preventDefault();
×
183
            }
184
        });
185
        this.panelOpened = true;
186
        this.autocompleteComponent().open();
187
    }
188

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

197
    createOverlay(): OverlayRef {
×
198
        const config = Object.assign({
×
199
            origin: this.elementRef.nativeElement,
200
            viewContainerRef: this.viewContainerRef,
201
            placement: this.thyPlacement(),
×
202
            offset: this.thyOffset(),
×
203
            scrollStrategy: this.overlay.scrollStrategies.reposition(),
×
204
            width: this.thyAutocompleteWidth() || this.elementRef.nativeElement.clientWidth
205
        });
206
        const autocompleteComponent = this.autocompleteComponent();
×
207
        this.autocompleteRef = this.autocompleteService.open(autocompleteComponent.contentTemplateRef(), config);
×
208
        this.autocompleteRef.afterClosed().subscribe(() => {
×
209
            this.panelOpened = false;
×
210
            autocompleteComponent.close();
211
        });
212
        // delay 200ms to prevent emit document click rightnow
213
        this.autocompleteRef
×
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

© 2026 Coveralls, Inc