Merged PR 2052: fix(ui-input-controls): Fix Dropdown Scrolling Issue on IPAD

fix(ui-input-controls): Fix Dropdown Scrolling Issue on IPAD

Ref: #5505
This commit is contained in:
Nino Righi
2025-11-25 13:00:58 +00:00
committed by Lorenz Hilpert
parent aee64d78e2
commit 0aeef0592b
7 changed files with 190 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
# Library Reference Guide
> **Last Updated:** 2025-11-20
> **Last Updated:** 2025-11-25
> **Angular Version:** 20.3.6
> **Nx Version:** 21.3.2
> **Total Libraries:** 72
@@ -345,7 +345,7 @@ A collection of reusable row components for displaying structured data with cons
**Location:** `libs/ui/item-rows/`
### `@isa/ui/layout`
This library provides utilities and directives for responsive design in Angular applications.
This library provides utilities and directives for responsive design and viewport behavior in Angular applications.
**Location:** `libs/ui/layout/`
@@ -399,7 +399,7 @@ This library was generated with [Nx](https://nx.dev).
**Location:** `libs/utils/format-name/`
### `@isa/utils/positive-integer-input`
This library was generated with [Nx](https://nx.dev).
An Angular directive that ensures only positive integers can be entered into number input fields.
**Location:** `libs/utils/positive-integer-input/`

View File

@@ -73,11 +73,15 @@
background: var(--Neutral-White, #fff);
box-shadow: 0px 0px 16px 0px rgba(0, 0, 0, 0.15);
width: 100%;
max-height: 20rem;
overflow: hidden;
overflow-y: auto;
.ui-dropdown-option {
display: flex;
width: 10rem;
height: 3rem;
min-height: 3rem;
padding: 0rem 1.5rem;
flex-direction: row;
justify-content: space-between;

View File

@@ -15,8 +15,7 @@
(backdropClick)="close()"
(detach)="isOpen.set(false)"
>
<ul [class]="['ui-dropdown__options']" role="listbox">
<!-- Fixed typo -->
<ul #optionsPanel [class]="['ui-dropdown__options']" role="listbox">
<ng-content></ng-content>
</ul>
</ng-template>

View File

@@ -11,6 +11,7 @@ import {
input,
model,
signal,
viewChild,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@@ -25,6 +26,7 @@ import {
import { isEqual } from 'lodash';
import { DropdownAppearance } from './dropdown.types';
import { DropdownService } from './dropdown.service';
import { CloseOnScrollDirective } from '@isa/ui/layout';
@Component({
selector: 'ui-dropdown-option',
@@ -94,7 +96,13 @@ export class DropdownOptionComponent<T> implements Highlightable {
selector: 'ui-dropdown',
templateUrl: './dropdown.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [CdkOverlayOrigin],
hostDirectives: [
CdkOverlayOrigin,
{
directive: CloseOnScrollDirective,
outputs: ['closeOnScroll'],
},
],
imports: [NgIconComponent, CdkConnectedOverlay],
providers: [
provideIcons({ isaActionChevronUp, isaActionChevronDown }),
@@ -117,6 +125,7 @@ export class DropdownOptionComponent<T> implements Highlightable {
'(keydown.escape)': 'close()',
'(click)':
'disabled() ? $event.stopImmediatePropagation() : (isOpen() ? close() : open())',
'(closeOnScroll)': 'close()',
},
})
export class DropdownButtonComponent<T>
@@ -124,10 +133,14 @@ export class DropdownButtonComponent<T>
{
#dropdownService = inject(DropdownService);
#scrollStrategy = inject(ScrollStrategyOptions);
#closeOnScroll = inject(CloseOnScrollDirective, { self: true });
readonly init = signal(false);
private elementRef = inject(ElementRef);
/** Reference to the options panel for scroll exclusion */
optionsPanel = viewChild<ElementRef<HTMLElement>>('optionsPanel');
get overlayMinWidth() {
return this.elementRef.nativeElement.offsetWidth;
}
@@ -209,6 +222,14 @@ export class DropdownButtonComponent<T>
.withWrap()
.skipPredicate((option) => option.disabled);
});
// Configure CloseOnScrollDirective: activate when open, exclude options panel
effect(() => {
this.#closeOnScroll.closeOnScrollWhen.set(this.isOpen());
this.#closeOnScroll.closeOnScrollExclude.set(
this.optionsPanel()?.nativeElement,
);
});
}
open() {

View File

@@ -1,11 +1,13 @@
# ui-layout
This library provides utilities and directives for responsive design in Angular applications.
This library provides utilities and directives for responsive design and viewport behavior in Angular applications.
## Features
- **Breakpoint Utility**: A function to detect viewport breakpoints using Angular's `BreakpointObserver`.
- **Breakpoint Directive**: A structural directive to conditionally render templates based on viewport breakpoints.
- **InViewport Directive**: Emits events when elements enter or leave the viewport.
- **CloseOnScroll Directive**: Emits events when scrolling occurs outside a specified element (useful for closing overlays).
## Installation
@@ -41,6 +43,29 @@ Use this directive to emit an event whenever the host element enters or leaves t
<some-element uiInViewport (uiInViewport)="onInViewportChange($event)"> ... </some-element>
```
### CloseOnScroll Directive
Use this directive to close overlays (dropdowns, popovers) when the user scrolls the page, while allowing scrolling within the overlay itself.
```typescript
// As hostDirective with programmatic configuration:
@Component({
hostDirectives: [{ directive: CloseOnScrollDirective, outputs: ['closeOnScroll'] }],
host: { '(closeOnScroll)': 'close()' },
})
export class MyOverlayComponent {
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);
});
}
}
```
## Breakpoints Table
| Breakpoint | CSS Media Query Selector |

View File

@@ -1,3 +1,4 @@
export * from './lib/breakpoint.directive';
export * from './lib/breakpoint';
export * from './lib/close-on-scroll.directive';
export * from './lib/in-viewport.directive';

View File

@@ -0,0 +1,133 @@
// 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;
/**
* 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.#scrollListener = (event: Event) => {
const target = event.target as HTMLElement;
const excludeElement = this.closeOnScrollExclude();
// Check if scroll happened within the excluded element
if (excludeElement?.contains(target)) {
return;
}
// Emit close event - scroll happened outside excluded element
this.#logger.debug('Scroll detected outside panel, emitting close');
this.closeOnScroll.emit();
};
// Use capture: true to catch scroll events from ALL elements (scroll events don't bubble)
// Use passive: true for better performance (we don't call preventDefault)
this.#document.defaultView?.addEventListener(
'scroll',
this.#scrollListener,
{
capture: true,
passive: true,
},
);
this.#isActive = true;
this.#logger.debug('Activated scroll listener');
}
#deactivate(): void {
if (!this.#isActive || !this.#scrollListener) {
return;
}
this.#document.defaultView?.removeEventListener(
'scroll',
this.#scrollListener,
{ capture: true },
);
this.#scrollListener = undefined;
this.#isActive = false;
this.#logger.debug('Deactivated scroll listener');
}
ngOnDestroy(): void {
this.#deactivate();
}
}