Files
ISA-Frontend/libs/ui/layout/src/lib/close-on-scroll.directive.ts
Nino Righi aee63711e4 Merged PR 2066: fix(ui-layout, input-controls-dropdown, oms-return-details): prevent stale sc...
fix(ui-layout, input-controls-dropdown, oms-return-details): prevent stale scroll events from closing dropdown on open

Delay scroll listener registration using requestAnimationFrame when
activating CloseOnScrollDirective. This prevents stale scroll events
still in the event queue from immediately triggering closeOnScroll
when opening the dropdown after scrolling.

Also adds conditional rendering for product format and publication date
in return-details-order-group-item component.

Refs: #5513
2025-12-02 14:02:32 +00:00

129 lines
3.9 KiB
TypeScript

// TODO: Consider moving to ui/common (don't exist) or ui/overlay (don't exist) - this directive handles overlay scroll behavior, not layout
import {
Directive,
effect,
inject,
model,
OnDestroy,
output,
} from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { logger } from '@isa/core/logging';
/**
* Directive that emits an event when scrolling occurs outside a specified element.
*
* This directive listens to all scroll events in capture phase and emits `closeOnScroll`
* when scrolling happens anywhere except within the excluded element (e.g., dropdown overlay panel).
*
* Use case: Close dropdown when user scrolls the page, but keep it open when scrolling within dropdown options.
*
* @example
* ```typescript
* // As hostDirective with programmatic configuration:
* hostDirectives: [{ directive: CloseOnScrollDirective, outputs: ['closeOnScroll'] }]
*
* // In component:
* readonly #closeOnScroll = inject(CloseOnScrollDirective, { self: true });
* readonly panel = viewChild<ElementRef<HTMLElement>>('panel');
*
* constructor() {
* effect(() => {
* this.#closeOnScroll.closeOnScrollWhen.set(this.isOpen());
* this.#closeOnScroll.closeOnScrollExclude.set(this.panel()?.nativeElement);
* });
* }
*
* // In host:
* host: { '(closeOnScroll)': 'close()' }
* ```
*/
@Directive({
selector: '[uiCloseOnScroll]',
standalone: true,
})
export class CloseOnScrollDirective implements OnDestroy {
readonly #logger = logger(() => ({ directive: 'CloseOnScrollDirective' }));
#document = inject(DOCUMENT);
#scrollListener?: (event: Event) => void;
#isActive = false;
#pendingActivation?: number;
/**
* When true, the directive listens for scroll events.
* Bind this to your open state (e.g., `closeOnScrollWhen.set(isOpen())`).
*/
closeOnScrollWhen = model<boolean>(false);
/**
* Element to exclude from scroll detection.
* If scroll occurs within this element or its children, the closeOnScroll event will NOT be emitted.
*/
closeOnScrollExclude = model<HTMLElement | undefined>(undefined);
/**
* Emitted when scrolling occurs outside the excluded element.
*/
closeOnScroll = output<void>();
constructor() {
// Auto-activate/deactivate based on closeOnScrollWhen input
effect(() => {
const shouldBeActive = this.closeOnScrollWhen();
if (shouldBeActive && !this.#isActive) {
this.#activate();
} else if (!shouldBeActive && this.#isActive) {
this.#deactivate();
}
});
}
#activate(): void {
if (this.#isActive) {
return;
}
this.#isActive = true;
// Delay listener registration to next frame to skip any stale scroll events
this.#pendingActivation = requestAnimationFrame(() => {
this.#scrollListener = (event: Event) => {
const excludeElement = this.closeOnScrollExclude();
if (excludeElement?.contains(event.target as HTMLElement)) {
return;
}
this.#logger.debug('Scroll detected outside panel, emitting close');
this.closeOnScroll.emit();
};
this.#document.defaultView?.addEventListener(
'scroll',
this.#scrollListener,
{ capture: true, passive: true },
);
this.#logger.debug('Activated scroll listener');
});
}
#deactivate(): void {
if (this.#pendingActivation) {
cancelAnimationFrame(this.#pendingActivation);
this.#pendingActivation = undefined;
}
if (this.#scrollListener) {
this.#document.defaultView?.removeEventListener(
'scroll',
this.#scrollListener,
{ capture: true },
);
this.#scrollListener = undefined;
}
this.#logger.debug('Deactivated scroll listener');
this.#isActive = false;
}
ngOnDestroy(): void {
this.#deactivate();
}
}