mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
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:
committed by
Lorenz Hilpert
parent
aee64d78e2
commit
0aeef0592b
@@ -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/`
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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';
|
||||
|
||||
133
libs/ui/layout/src/lib/close-on-scroll.directive.ts
Normal file
133
libs/ui/layout/src/lib/close-on-scroll.directive.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user