mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merge branch 'release/4.5' into develop
This commit is contained in:
@@ -12,7 +12,7 @@ variables:
|
|||||||
value: '4'
|
value: '4'
|
||||||
# Minor Version einstellen
|
# Minor Version einstellen
|
||||||
- name: 'Minor'
|
- name: 'Minor'
|
||||||
value: '4'
|
value: '5'
|
||||||
- name: 'Patch'
|
- name: 'Patch'
|
||||||
value: "$[counter(format('{0}.{1}', variables['Major'], variables['Minor']),0)]"
|
value: "$[counter(format('{0}.{1}', variables['Major'], variables['Minor']),0)]"
|
||||||
- name: 'BuildUniqueID'
|
- name: 'BuildUniqueID'
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ A collection of reusable row components for displaying structured data with cons
|
|||||||
**Location:** `libs/ui/item-rows/`
|
**Location:** `libs/ui/item-rows/`
|
||||||
|
|
||||||
### `@isa/ui/layout`
|
### `@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/`
|
**Location:** `libs/ui/layout/`
|
||||||
|
|
||||||
@@ -432,4 +432,4 @@ This file should be updated when:
|
|||||||
- Library purposes significantly change
|
- Library purposes significantly change
|
||||||
- Angular or Nx versions are upgraded
|
- Angular or Nx versions are upgraded
|
||||||
|
|
||||||
**Automation:** This file is auto-generated using `npm run docs:generate`. Run this command after adding or modifying libraries to keep the documentation up-to-date.
|
**Automation:** This file is auto-generated using `npm run docs:generate`. Run this command after adding or modifying libraries to keep the documentation up-to-date.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { BonusCardInfoDTO } from '@generated/swagger/crm-api';
|
import { BonusCardInfoDTO } from '@generated/swagger/crm-api';
|
||||||
|
|
||||||
export interface BonusCardInfo extends BonusCardInfoDTO {
|
export interface BonusCardInfo extends BonusCardInfoDTO {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
totalPoints: number;
|
totalPoints: number;
|
||||||
}
|
code: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,27 +4,39 @@ import { CrmSearchService } from '../services/crm-search.service';
|
|||||||
import { BonusCardInfo } from '../models';
|
import { BonusCardInfo } from '../models';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resource for loading customer bonus cards (Kundenkarten).
|
* Resource for loading customer bonus cards (Kundenkarten) using Angular's Resource API.
|
||||||
*
|
*
|
||||||
* Provides reactive loading of all bonus cards for a given customer ID.
|
* Provides reactive, automatic loading of all bonus cards for a given customer ID.
|
||||||
* Customer ID can be changed dynamically via `params()` method.
|
* Uses Angular's `resource()` function for declarative data fetching with built-in
|
||||||
|
* loading states, error handling, and automatic race condition prevention.
|
||||||
*
|
*
|
||||||
* **Note:** This resource should be provided at the component level,
|
* **Features:**
|
||||||
* not in root. Provide it in the `providers` array of the component
|
* - Reactive loading triggered by parameter changes
|
||||||
* that needs scoped access to customer bonus cards.
|
* - Automatic request cancellation on parameter updates (race condition prevention)
|
||||||
|
* - Built-in loading, error, and status states
|
||||||
|
* - Customer ID can be changed dynamically via `params()` method
|
||||||
|
* - Lazy loading: only fetches when customerId is provided
|
||||||
|
*
|
||||||
|
* **Lifecycle:**
|
||||||
|
* - **Injectable:** Component-level only (not providedIn: 'root')
|
||||||
|
* - **Scope:** Provide in component `providers` array for isolated state
|
||||||
|
* - **Data Flow:** params → loader → resource.value → UI
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* @Component({
|
* @Component({
|
||||||
* providers: [CustomerBonusCardsResource],
|
* providers: [CustomerBonusCardsResource],
|
||||||
* })
|
* })
|
||||||
* export class MyFeatureComponent {
|
* export class CustomerCardsComponent {
|
||||||
* #bonusCardsResource = inject(CustomerBonusCardsResource);
|
* readonly #bonusCardsResource = inject(CustomerBonusCardsResource);
|
||||||
*
|
*
|
||||||
* cards = this.#bonusCardsResource.resource.value;
|
* // Reactive signals exposed by resource
|
||||||
* isLoading = this.#bonusCardsResource.resource.isLoading;
|
* readonly cards = this.#bonusCardsResource.resource.value;
|
||||||
|
* readonly isLoading = this.#bonusCardsResource.resource.isLoading;
|
||||||
|
* readonly error = this.#bonusCardsResource.resource.error;
|
||||||
*
|
*
|
||||||
* loadCards(customerId: number) {
|
* // Trigger load by updating params
|
||||||
|
* loadCards(customerId: number): void {
|
||||||
* this.#bonusCardsResource.params({ customerId });
|
* this.#bonusCardsResource.params({ customerId });
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
@@ -39,17 +51,40 @@ export class CustomerBonusCardsResource {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Current customer ID being loaded.
|
* Current customer ID being loaded.
|
||||||
|
*
|
||||||
|
* Read-only computed signal that exposes the current customer ID parameter.
|
||||||
|
* Returns `undefined` if no customer ID has been set via `params()`.
|
||||||
|
*
|
||||||
|
* @returns Customer ID or undefined if not set
|
||||||
*/
|
*/
|
||||||
readonly customerId = computed(() => this.#customerId());
|
readonly customerId = computed(() => this.#customerId());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resource that loads bonus cards based on current parameters.
|
* Angular Resource API instance that manages bonus card loading.
|
||||||
*
|
*
|
||||||
* Exposes:
|
* Automatically loads bonus cards when `customerId` parameter changes.
|
||||||
* - `value()` - Array of bonus cards or undefined
|
* Provides reactive signals for data, loading state, errors, and status.
|
||||||
* - `isLoading()` - Loading state
|
*
|
||||||
* - `error()` - Error state
|
* **Exposed Signals:**
|
||||||
* - `status()` - Current status ('idle' | 'loading' | 'resolved' | 'error')
|
* - `value()` - Array of bonus cards or undefined (when no data loaded)
|
||||||
|
* - `isLoading()` - Boolean indicating if a request is in progress
|
||||||
|
* - `error()` - Error object if load failed, undefined otherwise
|
||||||
|
* - `status()` - Current lifecycle status: 'idle' | 'loading' | 'resolved' | 'error'
|
||||||
|
*
|
||||||
|
* **Behavior:**
|
||||||
|
* - Lazy: Only loads when customerId is provided (not undefined)
|
||||||
|
* - Reactive: Automatically reloads when params change
|
||||||
|
* - Safe: Cancels previous requests to prevent race conditions
|
||||||
|
* - Cacheable: Returns undefined as default value before first load
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Access reactive signals
|
||||||
|
* const cards = this.resource.value(); // BonusCardInfo[] | undefined
|
||||||
|
* const loading = this.resource.isLoading(); // boolean
|
||||||
|
* const error = this.resource.error(); // Error | undefined
|
||||||
|
* const status = this.resource.status(); // ResourceStatus
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
readonly resource = resource({
|
readonly resource = resource({
|
||||||
params: computed(() => ({ customerId: this.#customerId() })),
|
params: computed(() => ({ customerId: this.#customerId() })),
|
||||||
@@ -81,14 +116,85 @@ export class CustomerBonusCardsResource {
|
|||||||
defaultValue: undefined,
|
defaultValue: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
(window as any)['__customerBonusCardsResource'] = this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update resource parameters to trigger a reload.
|
* Updates resource parameters to trigger a reactive reload of bonus cards.
|
||||||
|
*
|
||||||
|
* Setting a new `customerId` will automatically cancel any in-flight request
|
||||||
|
* and start a new load with the updated parameter. The resource's loader
|
||||||
|
* function will be called with the new parameters.
|
||||||
|
*
|
||||||
|
* **Behavior:**
|
||||||
|
* - Passing `customerId` triggers a load for that customer
|
||||||
|
* - Passing `undefined` clears the data and returns resource to idle state
|
||||||
|
* - Automatically cancels previous requests (race condition prevention)
|
||||||
|
* - Updates are reactive: UI updates automatically via resource signals
|
||||||
*
|
*
|
||||||
* @param params - Parameters for loading bonus cards
|
* @param params - Parameters for loading bonus cards
|
||||||
* @param params.customerId - Customer ID to load cards for (undefined clears data)
|
* @param params.customerId - Customer ID to load cards for, or undefined to clear
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Load cards for customer 12345
|
||||||
|
* bonusCardsResource.params({ customerId: 12345 });
|
||||||
|
*
|
||||||
|
* // Clear data and reset to idle
|
||||||
|
* bonusCardsResource.params({ customerId: undefined });
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
params(params: { customerId?: number }): void {
|
params(params: { customerId?: number }): void {
|
||||||
this.#logger.debug('Updating params', () => params);
|
this.#logger.debug('Updating params', () => params);
|
||||||
this.#customerId.set(params.customerId);
|
this.#customerId.set(params.customerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a random test card to the current card list.
|
||||||
|
*
|
||||||
|
* **For testing purposes only.** Creates a new card with random code
|
||||||
|
* and copies customer details from the first existing card.
|
||||||
|
*
|
||||||
|
* **Testing the Card Stack Display:**
|
||||||
|
* This method is useful for testing the card carousel stack behavior,
|
||||||
|
* particularly verifying that newly added cards appear correctly in the stack
|
||||||
|
* rather than mispositioned on the right side.
|
||||||
|
*
|
||||||
|
* **How to Test:**
|
||||||
|
* 1. Open browser DevTools console (F12)
|
||||||
|
* 2. Navigate to a customer with bonus cards
|
||||||
|
* 3. Execute: `window.__customerBonusCardsResource.addRandomCardForTesting()`
|
||||||
|
* 4. Verify the new card appears properly in the stack (not on the right)
|
||||||
|
* 5. Repeat to test with multiple new cards
|
||||||
|
*
|
||||||
|
* **Expected Behavior:**
|
||||||
|
* - New card should appear in correct stack position based on isPrimary/isActive
|
||||||
|
* - Card should animate smoothly into position
|
||||||
|
* - Stack should recalculate positions using ResizeObserver
|
||||||
|
* - No visual glitches or misalignment
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // From browser console (development only):
|
||||||
|
* window.__customerBonusCardsResource.addRandomCardForTesting();
|
||||||
|
*
|
||||||
|
* // From component code:
|
||||||
|
* this.#bonusCardsResource.addRandomCardForTesting();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
addRandomCardForTesting(): void {
|
||||||
|
const currentCards = this.resource.value() ?? [];
|
||||||
|
const newCard: BonusCardInfo = {
|
||||||
|
code: `TEST-${Math.floor(Math.random() * 100000)}`,
|
||||||
|
format: 'CARDCODE',
|
||||||
|
isActive: true,
|
||||||
|
isPrimary: false,
|
||||||
|
totalPoints: currentCards[0].totalPoints,
|
||||||
|
firstName: currentCards[0].firstName,
|
||||||
|
lastName: currentCards[0].lastName,
|
||||||
|
};
|
||||||
|
this.#logger.debug('Adding random test card', () => ({ newCard }));
|
||||||
|
this.resource.set([...currentCards, newCard]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* Customer card styles - using Tailwind, no additional CSS needed */
|
/* Customer card styles - using Tailwind, no additional CSS needed */
|
||||||
:host {
|
:host {
|
||||||
@apply rounded-2xl overflow-hidden;
|
@apply rounded-2xl overflow-hidden bg-white;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
Directive,
|
Directive,
|
||||||
|
effect,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
@@ -11,6 +12,25 @@ export class CardStackContainerDirective implements AfterViewInit {
|
|||||||
readonly elementRef = inject(ElementRef<HTMLElement>);
|
readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||||
|
|
||||||
readonly centerX = signal(0);
|
readonly centerX = signal(0);
|
||||||
|
private resizeObserver?: ResizeObserver;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Use ResizeObserver to detect layout changes (when cards added/removed)
|
||||||
|
effect((onCleanup) => {
|
||||||
|
const el = this.elementRef.nativeElement;
|
||||||
|
|
||||||
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
|
// Recalculate center when container size changes
|
||||||
|
this.centerX.set(this.getHorizontalCenter());
|
||||||
|
});
|
||||||
|
|
||||||
|
this.resizeObserver.observe(el);
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
this.resizeObserver?.disconnect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
this.centerX.set(this.getHorizontalCenter());
|
this.centerX.set(this.getHorizontalCenter());
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
computed,
|
computed,
|
||||||
Directive,
|
Directive,
|
||||||
|
effect,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
@@ -26,7 +27,24 @@ export class CardStackDistanceDirective implements AfterViewInit {
|
|||||||
|
|
||||||
centerX = signal(0);
|
centerX = signal(0);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Recalculate position when container center changes
|
||||||
|
effect(() => {
|
||||||
|
// React to container center changes
|
||||||
|
const containerCenter = this.container.centerX();
|
||||||
|
|
||||||
|
// Recalculate this card's center position
|
||||||
|
if (containerCenter !== 0) {
|
||||||
|
// Use requestAnimationFrame to ensure DOM is stable
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.centerX.set(this.getHorizontalCenter());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
|
// Initial calculation
|
||||||
this.centerX.set(this.getHorizontalCenter());
|
this.centerX.set(this.getHorizontalCenter());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,24 +39,29 @@ crm-customer-card {
|
|||||||
transform: translateX(var(--distance-to-center));
|
transform: translateX(var(--distance-to-center));
|
||||||
}
|
}
|
||||||
|
|
||||||
.stacked crm-customer-card:nth-child(1) {
|
/* Use data attributes instead of nth-child for dynamic positioning */
|
||||||
|
.stacked crm-customer-card[data-stack-position='0'] {
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stacked crm-customer-card:nth-child(2) {
|
.stacked crm-customer-card[data-stack-position='1'] {
|
||||||
transform: translateX(calc(var(--distance-to-center) - 3.6rem))
|
transform: translateX(calc(var(--distance-to-center) - 3.6rem))
|
||||||
translateY(1rem) rotate(-2.538deg);
|
translateY(1rem) rotate(-2.538deg);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stacked crm-customer-card:nth-child(3) {
|
.stacked crm-customer-card[data-stack-position='2'] {
|
||||||
transform: translateX(calc(var(--distance-to-center) + 3.6rem))
|
transform: translateX(calc(var(--distance-to-center) + 3.6rem))
|
||||||
translateY(1rem) rotate(2.538deg);
|
translateY(1rem) rotate(2.538deg);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stacked crm-customer-card:nth-child(n + 4) {
|
/* Hide cards beyond the 3rd position */
|
||||||
|
.stacked
|
||||||
|
crm-customer-card:not([data-stack-position='0']):not([data-stack-position='1']):not([data-stack-position='2']) {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Respect user's motion preferences */
|
/* Respect user's motion preferences */
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
(cardLocked)="cardLocked.emit()"
|
(cardLocked)="cardLocked.emit()"
|
||||||
crmCardStackDistance
|
crmCardStackDistance
|
||||||
[style.--card-index]="idx"
|
[style.--card-index]="idx"
|
||||||
|
[style.--stack-position]="idx"
|
||||||
|
[attr.data-stack-position]="idx"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</ui-carousel>
|
</ui-carousel>
|
||||||
|
|||||||
@@ -52,15 +52,24 @@ export class CustomerCardsCarouselComponent {
|
|||||||
readonly cardLocked = output<void>();
|
readonly cardLocked = output<void>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cards sorted with blocked cards at the end.
|
* Cards sorted by priority: isPrimary > isActive > code
|
||||||
* Per Figma annotation: "gesperrte Karte immer nach hinten"
|
* Per Figma annotation: "gesperrte Karte immer nach hinten"
|
||||||
*/
|
*/
|
||||||
readonly sortedCards = computed(() => {
|
readonly sortedCards = computed(() => {
|
||||||
const cards = this.cards();
|
const cards = this.cards();
|
||||||
return [...cards].sort((a, b) => {
|
return [...cards].sort((a, b) => {
|
||||||
// Active cards first, blocked cards last
|
// 1. Primary cards first
|
||||||
if (a.isActive === b.isActive) return 0;
|
if (a.isPrimary !== b.isPrimary) {
|
||||||
return a.isActive ? -1 : 1;
|
return a.isPrimary ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Active cards before blocked cards
|
||||||
|
if (a.isActive !== b.isActive) {
|
||||||
|
return a.isActive ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Sort by code for stable ordering
|
||||||
|
return a.code.localeCompare(b.code);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,11 +73,15 @@
|
|||||||
background: var(--Neutral-White, #fff);
|
background: var(--Neutral-White, #fff);
|
||||||
box-shadow: 0px 0px 16px 0px rgba(0, 0, 0, 0.15);
|
box-shadow: 0px 0px 16px 0px rgba(0, 0, 0, 0.15);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-height: 20rem;
|
||||||
|
overflow: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
.ui-dropdown-option {
|
.ui-dropdown-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 10rem;
|
width: 10rem;
|
||||||
height: 3rem;
|
height: 3rem;
|
||||||
|
min-height: 3rem;
|
||||||
padding: 0rem 1.5rem;
|
padding: 0rem 1.5rem;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -11,11 +11,11 @@
|
|||||||
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
||||||
[cdkConnectedOverlayMinWidth]="overlayMinWidth"
|
[cdkConnectedOverlayMinWidth]="overlayMinWidth"
|
||||||
[cdkConnectedOverlayLockPosition]="true"
|
[cdkConnectedOverlayLockPosition]="true"
|
||||||
|
[cdkConnectedOverlayScrollStrategy]="blockScrollStrategy"
|
||||||
(backdropClick)="close()"
|
(backdropClick)="close()"
|
||||||
(detach)="isOpen.set(false)"
|
(detach)="isOpen.set(false)"
|
||||||
>
|
>
|
||||||
<ul [class]="['ui-dropdown__options']" role="listbox">
|
<ul #optionsPanel [class]="['ui-dropdown__options']" role="listbox">
|
||||||
<!-- Fixed typo -->
|
|
||||||
<ng-content></ng-content>
|
<ng-content></ng-content>
|
||||||
</ul>
|
</ul>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
@@ -11,16 +11,22 @@ import {
|
|||||||
input,
|
input,
|
||||||
model,
|
model,
|
||||||
signal,
|
signal,
|
||||||
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||||
import { isaActionChevronUp, isaActionChevronDown } from '@isa/icons';
|
import { isaActionChevronUp, isaActionChevronDown } from '@isa/icons';
|
||||||
import { ActiveDescendantKeyManager, Highlightable } from '@angular/cdk/a11y';
|
import { ActiveDescendantKeyManager, Highlightable } from '@angular/cdk/a11y';
|
||||||
import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay';
|
import {
|
||||||
|
CdkConnectedOverlay,
|
||||||
|
CdkOverlayOrigin,
|
||||||
|
ScrollStrategyOptions,
|
||||||
|
} from '@angular/cdk/overlay';
|
||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
import { DropdownAppearance } from './dropdown.types';
|
import { DropdownAppearance } from './dropdown.types';
|
||||||
import { DropdownService } from './dropdown.service';
|
import { DropdownService } from './dropdown.service';
|
||||||
|
import { CloseOnScrollDirective } from '@isa/ui/layout';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ui-dropdown-option',
|
selector: 'ui-dropdown-option',
|
||||||
@@ -90,7 +96,13 @@ export class DropdownOptionComponent<T> implements Highlightable {
|
|||||||
selector: 'ui-dropdown',
|
selector: 'ui-dropdown',
|
||||||
templateUrl: './dropdown.component.html',
|
templateUrl: './dropdown.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
hostDirectives: [CdkOverlayOrigin],
|
hostDirectives: [
|
||||||
|
CdkOverlayOrigin,
|
||||||
|
{
|
||||||
|
directive: CloseOnScrollDirective,
|
||||||
|
outputs: ['closeOnScroll'],
|
||||||
|
},
|
||||||
|
],
|
||||||
imports: [NgIconComponent, CdkConnectedOverlay],
|
imports: [NgIconComponent, CdkConnectedOverlay],
|
||||||
providers: [
|
providers: [
|
||||||
provideIcons({ isaActionChevronUp, isaActionChevronDown }),
|
provideIcons({ isaActionChevronUp, isaActionChevronDown }),
|
||||||
@@ -113,20 +125,30 @@ export class DropdownOptionComponent<T> implements Highlightable {
|
|||||||
'(keydown.escape)': 'close()',
|
'(keydown.escape)': 'close()',
|
||||||
'(click)':
|
'(click)':
|
||||||
'disabled() ? $event.stopImmediatePropagation() : (isOpen() ? close() : open())',
|
'disabled() ? $event.stopImmediatePropagation() : (isOpen() ? close() : open())',
|
||||||
|
'(closeOnScroll)': 'close()',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class DropdownButtonComponent<T>
|
export class DropdownButtonComponent<T>
|
||||||
implements ControlValueAccessor, AfterViewInit
|
implements ControlValueAccessor, AfterViewInit
|
||||||
{
|
{
|
||||||
#dropdownService = inject(DropdownService);
|
#dropdownService = inject(DropdownService);
|
||||||
|
#scrollStrategy = inject(ScrollStrategyOptions);
|
||||||
|
#closeOnScroll = inject(CloseOnScrollDirective, { self: true });
|
||||||
|
|
||||||
readonly init = signal(false);
|
readonly init = signal(false);
|
||||||
private elementRef = inject(ElementRef);
|
private elementRef = inject(ElementRef);
|
||||||
|
|
||||||
|
/** Reference to the options panel for scroll exclusion */
|
||||||
|
optionsPanel = viewChild<ElementRef<HTMLElement>>('optionsPanel');
|
||||||
|
|
||||||
get overlayMinWidth() {
|
get overlayMinWidth() {
|
||||||
return this.elementRef.nativeElement.offsetWidth;
|
return this.elementRef.nativeElement.offsetWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get blockScrollStrategy() {
|
||||||
|
return this.#scrollStrategy.block();
|
||||||
|
}
|
||||||
|
|
||||||
appearance = input<DropdownAppearance>(DropdownAppearance.AccentOutline);
|
appearance = input<DropdownAppearance>(DropdownAppearance.AccentOutline);
|
||||||
|
|
||||||
appearanceClass = computed(() => `ui-dropdown__${this.appearance()}`);
|
appearanceClass = computed(() => `ui-dropdown__${this.appearance()}`);
|
||||||
@@ -200,6 +222,14 @@ export class DropdownButtonComponent<T>
|
|||||||
.withWrap()
|
.withWrap()
|
||||||
.skipPredicate((option) => option.disabled);
|
.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() {
|
open() {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
# ui-layout
|
# 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
|
## Features
|
||||||
|
|
||||||
- **Breakpoint Utility**: A function to detect viewport breakpoints using Angular's `BreakpointObserver`.
|
- **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.
|
- **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
|
## 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>
|
<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
|
## Breakpoints Table
|
||||||
|
|
||||||
| Breakpoint | CSS Media Query Selector |
|
| Breakpoint | CSS Media Query Selector |
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './lib/breakpoint.directive';
|
export * from './lib/breakpoint.directive';
|
||||||
export * from './lib/breakpoint';
|
export * from './lib/breakpoint';
|
||||||
|
export * from './lib/close-on-scroll.directive';
|
||||||
export * from './lib/in-viewport.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