From cab7c5102769854b388147d39fcc99a82d816844 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 11 Jun 2026 15:08:28 +0300 Subject: [PATCH] feat(shared): add AutocompleteAutoScrollRepositionDirective - New input[matAutocomplete]/textarea[matAutocomplete] directive that keeps an open MatAutocomplete panel attached to its anchor while the page scrolls or the window resizes: tracks the input's visibility via IntersectionObserver and re-requests panel position (or closes the panel when off-screen) when the nearest scroll container or window emits a resize/scroll event. - Declare and export the directive from SharedModule so it lights up across every screen that uses mat-autocomplete. - Drop the transparent backdrop from MAT_AUTOCOMPLETE_DEFAULT_OPTIONS (the directive now handles scroll-and-resize repositioning, so the backdrop-driven outside- click close is no longer needed) and keep the existing hideSingleSelectionIndicator default. --- ...mplete-auto-scroll-reposition.directive.ts | 80 +++++++++++++++++++ ui-ngx/src/app/shared/shared.module.ts | 7 +- 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 ui-ngx/src/app/shared/directives/autocomplete-auto-scroll-reposition.directive.ts diff --git a/ui-ngx/src/app/shared/directives/autocomplete-auto-scroll-reposition.directive.ts b/ui-ngx/src/app/shared/directives/autocomplete-auto-scroll-reposition.directive.ts new file mode 100644 index 0000000000..5f620789f1 --- /dev/null +++ b/ui-ngx/src/app/shared/directives/autocomplete-auto-scroll-reposition.directive.ts @@ -0,0 +1,80 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Directive, + ElementRef, + OnDestroy, + inject, AfterViewInit, Renderer2, +} from '@angular/core'; +import { MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { Subscription } from 'rxjs'; +import { onParentScrollOrWindowResize } from '@core/utils'; + +@Directive({ + selector: 'input[matAutocomplete], textarea[matAutocomplete]', + standalone: false +}) +export class AutocompleteAutoScrollRepositionDirective implements AfterViewInit, OnDestroy { + private readonly trigger = inject(MatAutocompleteTrigger, { host: true }); + private readonly elementRef = inject(ElementRef); + private readonly renderer = inject(Renderer2); + + private parentScrollSubscription: Subscription = null; + private isIntersecting: boolean = false; + + private intersectionObserver = new IntersectionObserver((entries) => { + if (this.isIntersecting !== entries[0].isIntersecting) { + this.isIntersecting = entries[0].isIntersecting; + this.updatePanelVisibility(); + } + }, {threshold: [0.5]}); + + constructor() { + } + + ngAfterViewInit(): void { + this.parentScrollSubscription = onParentScrollOrWindowResize(this.elementRef.nativeElement).subscribe(() => { + if (this.trigger.panelOpen) { + this.trigger.updatePosition(); + } + }); + this.intersectionObserver.observe(this.elementRef.nativeElement); + } + + ngOnDestroy(): void { + if (this.parentScrollSubscription) { + this.parentScrollSubscription.unsubscribe(); + this.parentScrollSubscription = null; + } + if (this.intersectionObserver) { + this.intersectionObserver.unobserve(this.elementRef.nativeElement); + this.intersectionObserver.disconnect(); + this.intersectionObserver = null; + } + } + + private updatePanelVisibility(): void { + if (this.trigger.panelOpen) { + if (this.isIntersecting) { + this.renderer.removeStyle(this.trigger.autocomplete.panel.nativeElement, 'display'); + } else { + this.renderer.setStyle(this.trigger.autocomplete.panel.nativeElement, 'display', 'none'); + } + } + } + +} diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 6fe78f7ee0..d5073332d2 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -230,6 +230,9 @@ import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; import { MqttVersionSelectComponent } from '@shared/components/mqtt-version-select.component'; import { MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS } from '@angular/material/button-toggle'; import { PhotoSwipeGalleryDirective } from '@shared/directives/photoswipe-gallery.directive'; +import { + AutocompleteAutoScrollRepositionDirective +} from '@shared/directives/autocomplete-auto-scroll-reposition.directive'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -278,8 +281,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, useValue: { - hasBackdrop: true, - backdropClass: 'cdk-overlay-transparent-backdrop', hideSingleSelectionIndicator: true } }, @@ -389,6 +390,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ContextMenuDirective, ChipOverflowDirective, PhotoSwipeGalleryDirective, + AutocompleteAutoScrollRepositionDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -655,6 +657,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ContextMenuDirective, ChipOverflowDirective, PhotoSwipeGalleryDirective, + AutocompleteAutoScrollRepositionDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe,