Compare commits

...

6 Commits

Author SHA1 Message Date
Nino
ba09cb2508 fix(isa-app-scroll-container): Fixed issue with reloading on several lists
Ref: #5237
2025-12-22 15:00:57 +01:00
Nino
d9b653073b fix(isa-app-customer): Fixed selection of other addresses in customer area
Ref: #5522
2025-12-18 17:51:44 +01:00
Nino Righi
de3edaa0f9 Merged PR 2077: fix(checkout-data-access, checkout-reward-shopping-cart, checkout-reward-sele...
fix(checkout-data-access, checkout-reward-shopping-cart, checkout-reward-selection-dialog): Show Low Stock message inside Dialog, Adjusted Item Identifyer so that mergedItems inside reward-selection-dialog service works properly, Adjusted Error Message Logic and Quantity Select Logic based on purchasing Options for Abholung

Ref: #5523
2025-12-10 17:12:47 +00:00
Nino Righi
964a6026a0 Merged PR 2076: fix(common-data-access, crm-data-access): Improved Error handling, handling i...
fix(common-data-access, crm-data-access): Improved Error handling, handling invalidProperties errors corretly inside crm customer card area

Refs: #5528, #5529
2025-12-10 17:11:22 +00:00
Nino Righi
83ad5f526e Merged PR 2075: fix(ui-layout, ui-input-controls, shared-filter): Set overlayPositions inside...
fix(ui-layout, ui-input-controls, shared-filter): Set overlayPositions inside filter-menu-button and outsourced the logic

Ref: #5526, #5477
2025-12-10 09:50:15 +00:00
Nino Righi
ccc5285602 Merged PR 2074: fix(remission): Implementation of Abort Remission Logic
fix(remission): Implementation of Abort Remission Logic

Ref: #5489
2025-12-10 09:48:49 +00:00
26 changed files with 405 additions and 157 deletions

View File

@@ -166,6 +166,11 @@ export class DetailsMainViewBillingAddressesComponent
customer as unknown as Customer,
),
);
// Clear the selected payer ID when using customer address
this.crmTabMetadataService.setSelectedPayerId(
this.tabId(),
undefined,
);
}
});
}

View File

@@ -191,6 +191,11 @@ export class DetailsMainViewDeliveryAddressesComponent
customer as unknown as Customer,
),
);
// Clear the selected shipping address ID when using customer address
this.crmTabMetadataService.setSelectedShippingAddressId(
this.tabId(),
undefined,
);
}
});
}

View File

@@ -11,7 +11,7 @@ import {
SimpleChanges,
} from '@angular/core';
import { Subject } from 'rxjs';
import { filter } from 'rxjs/operators';
import { debounceTime, filter } from 'rxjs/operators';
@Directive({
selector: '[uiScrollContainer]',
@@ -27,30 +27,48 @@ export class UiScrollContainerDirective implements OnChanges, OnInit {
@Input()
deltaEnd = 0;
private scrollEvent$ = new Subject<Event>();
private scrollEvent$ = new Subject<void>();
/**
* Tracks the last scrollHeight when reachEnd was emitted.
* This prevents duplicate emissions at the same scroll position after content loads.
*/
private lastEmittedScrollHeight = 0;
@Output()
reachStart = this.scrollEvent$.pipe(
filter((event) => {
debounceTime(100),
filter(() => {
if (this.direction === 'vertical') {
const top = this.nativeElement.scrollTop;
return top <= this.deltaStart;
} else {
throw new Error('not implemented');
return this.nativeElement.scrollTop <= this.deltaStart;
}
throw new Error('Horizontal scroll not implemented');
}),
);
@Output()
reachEnd = this.scrollEvent$.pipe(
filter((event) => {
debounceTime(100),
filter(() => {
if (this.direction === 'vertical') {
const top = this.nativeElement.scrollTop;
const height = this.nativeElement.scrollHeight - this.nativeElement.clientHeight - this.deltaEnd;
return top >= height;
} else {
throw new Error('not implemented');
const { scrollTop, scrollHeight, clientHeight } = this.nativeElement;
const threshold = scrollHeight - clientHeight - this.deltaEnd;
const isAtEnd = scrollTop >= threshold;
if (!isAtEnd) {
return false;
}
// Only emit if scrollHeight changed (new content loaded)
// This prevents re-emitting when user is still at end after a load
if (scrollHeight !== this.lastEmittedScrollHeight) {
this.lastEmittedScrollHeight = scrollHeight;
return true;
}
return false;
}
throw new Error('Horizontal scroll not implemented');
}),
);
@@ -71,17 +89,33 @@ export class UiScrollContainerDirective implements OnChanges, OnInit {
ngOnChanges({ direction }: SimpleChanges): void {
if (direction) {
if (this.direction === 'horizontal') {
this.renderer.setStyle(this.elementRef.nativeElement, 'overflow-x', 'auto');
this.renderer.setStyle(this.elementRef.nativeElement, 'overflow-y', 'auto');
this.renderer.setStyle(
this.elementRef.nativeElement,
'overflow-x',
'auto',
);
this.renderer.setStyle(
this.elementRef.nativeElement,
'overflow-y',
'auto',
);
} else {
this.renderer.setStyle(this.elementRef.nativeElement, 'overflow-y', 'auto');
this.renderer.setStyle(this.elementRef.nativeElement, 'overflow-x', 'hidden');
this.renderer.setStyle(
this.elementRef.nativeElement,
'overflow-y',
'auto',
);
this.renderer.setStyle(
this.elementRef.nativeElement,
'overflow-x',
'hidden',
);
}
}
}
@HostListener('scroll', ['$event'])
onScroll(event: Event) {
this.scrollEvent$.next(event);
@HostListener('scroll')
onScroll() {
this.scrollEvent$.next();
}
}

View File

@@ -8,20 +8,27 @@
(reachEnd)="reachedEnd()"
(reachStart)="reachedStart()"
[deltaEnd]="deltaEnd"
>
@if (!loading) {
>
@if (!loading || itemLength > 0) {
<ng-content></ng-content>
} @else {
@if (useLoadAnimation) {
}
@if (loading && useLoadAnimation) {
@if (itemLength === 0 || itemLength === undefined) {
<!-- Initial load: show multiple skeletons -->
<ui-skeleton-loader [template]="skeletonTemplate"></ui-skeleton-loader>
@for (skeletons of createSkeletons(); track skeletons) {
@for (skeleton of createSkeletons(); track skeleton) {
<ui-skeleton-loader [template]="skeletonTemplate"></ui-skeleton-loader>
}
} @else {
<ui-content-loader [loading]="loading"></ui-content-loader>
<!-- Load more: show single skeleton at the end -->
<ui-skeleton-loader [template]="skeletonTemplate"></ui-skeleton-loader>
}
}
@if (loading && !useLoadAnimation) {
<ui-content-loader [loading]="loading"></ui-content-loader>
}
@if (showSpacer && !loading) {
<div class="spacer"></div>

View File

@@ -4,8 +4,10 @@ import {
ElementRef,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
ViewChild,
} from '@angular/core';
@@ -16,7 +18,7 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class UiScrollContainerComponent implements OnInit {
export class UiScrollContainerComponent implements OnInit, OnChanges {
@ViewChild('scrollContainer', { read: ElementRef, static: true })
scrollContainer: ElementRef;
@@ -61,13 +63,48 @@ export class UiScrollContainerComponent implements OnInit {
}
}
createSkeletons() {
if (this.itemLength && this.itemLength !== 0) {
return Array.from(Array(this.itemLength - 1), (_, i) => i);
} else {
return [];
ngOnChanges(changes: SimpleChanges): void {
// When new items are loaded, adjust scroll position so user can scroll down again
if (changes['itemLength']) {
const prevLength = changes['itemLength'].previousValue ?? 0;
const newLength = changes['itemLength'].currentValue ?? 0;
// Only adjust if items were added (not on initial load or reset)
if (newLength > prevLength && prevLength > 0) {
this.adjustScrollPositionAfterLoad();
}
}
}
/**
* After new items are loaded, adjust scroll position so user is not at the very end.
* This allows them to scroll down again to trigger the next load.
*/
private adjustScrollPositionAfterLoad(): void {
const el = this.scrollContainer?.nativeElement;
if (!el) return;
// Wait for DOM to update with new items
setTimeout(() => {
const maxScroll = el.scrollHeight - el.clientHeight;
const currentScroll = el.scrollTop;
// Only adjust if we're at or very near the end
if (currentScroll >= maxScroll - this.deltaEnd - 20) {
// Move scroll position up by deltaEnd + buffer so user has room to scroll
const offset = this.deltaEnd + 100;
const targetScroll = Math.max(0, maxScroll - offset);
el.scrollTop = targetScroll;
}
}, 50);
}
createSkeletons(): number[] {
if (this.itemLength && this.itemLength !== 0) {
return Array.from({ length: this.itemLength - 1 }, (_, i) => i);
}
return [];
}
reachedEnd() {
this.reachEnd.emit();
@@ -79,7 +116,8 @@ export class UiScrollContainerComponent implements OnInit {
get scrollPersantage() {
const scrollHeight =
this.scrollContainer?.nativeElement?.scrollHeight - this.scrollContainer?.nativeElement?.clientHeight;
this.scrollContainer?.nativeElement?.scrollHeight -
this.scrollContainer?.nativeElement?.clientHeight;
if (scrollHeight === 0) {
return 0;
}
@@ -95,7 +133,10 @@ export class UiScrollContainerComponent implements OnInit {
scrollTo(top: number) {
setTimeout(() => {
this.scrollContainer?.nativeElement?.scrollTo({ top, behavior: 'smooth' });
this.scrollContainer?.nativeElement?.scrollTo({
top,
behavior: 'smooth',
});
}, 0);
}

View File

@@ -4,12 +4,13 @@ import {
} from '@isa/checkout/data-access';
/**
* Creates a unique key for an item based on EAN, destination, and orderItemType.
* Creates a unique key for an item based on EAN, targetBranchId, and orderItemType.
* Items are only considered identical if all three match.
*/
export const getItemKey = (item: ShoppingCartItem): string => {
const ean = item.product.ean ?? 'no-ean';
const destinationId = item.destination?.data?.id ?? 'no-destination';
const targetBranchId =
item.destination?.data?.targetBranch?.id ?? 'no-target-branch-id';
const orderType = getOrderTypeFeature(item.features) ?? 'no-orderType';
return `${ean}|${destinationId}|${orderType}`;
return `${ean}|${targetBranchId}|${orderType}`;
};

View File

@@ -66,7 +66,8 @@ export class RewardShoppingCartItemQuantityControlComponent {
if (
orderType === OrderTypeFeature.Delivery ||
orderType === OrderTypeFeature.DigitalShipping ||
orderType === OrderTypeFeature.B2BShipping
orderType === OrderTypeFeature.B2BShipping ||
orderType === OrderTypeFeature.Pickup
) {
return 999;
}

View File

@@ -37,11 +37,20 @@
</div>
</div>
@if (quantityControl.maxQuantity() < 2 && !isDownload()) {
@if (!isDownload()) {
@if (showLowStockMessage()) {
<div
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-2"
>
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
<div>{{ inStock() }} Exemplare sofort lieferbar</div>
</div>
} @else if (quantityControl.maxQuantity() < 2) {
<div
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-2"
>
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
<div>Geringer Bestand - Artikel holen vor Abschluss</div>
</div>
}
}

View File

@@ -72,6 +72,16 @@ export class RewardShoppingCartItemComponent {
hasOrderTypeFeature(this.item().features, ['Download']),
);
isAbholung = computed(() =>
hasOrderTypeFeature(this.item().features, ['Abholung']),
);
inStock = computed(() => this.item().availability?.inStock ?? 0);
showLowStockMessage = computed(() => {
return this.isAbholung() && this.inStock() < 2;
});
async updatePurchaseOption() {
const shoppingCartItemId = this.itemId();
const shoppingCartId = this.shoppingCartId();

View File

@@ -1,3 +1,3 @@
:host {
@apply text-isa-accent-red isa-text-body-2-bold flex flex-row gap-2 items-center;
@apply text-isa-accent-red isa-text-body-2-bold flex flex-col gap-2 items-start;
}

View File

@@ -1,8 +1,10 @@
@if (store.totalLoyaltyPointsNeeded() > store.customerRewardPoints()) {
<div class="flex flex-row gap-2 items-center">
<ng-icon
class="w-6 h-6 inline-flex items-center justify-center"
size="1.5rem"
name="isaOtherInfo"
></ng-icon>
<span>Lesepunkte reichen nicht für alle Artikel</span>
</div>
}

View File

@@ -41,10 +41,7 @@ export class RewardSelectionInputsComponent {
hasCorrectOrderType = computed(() => {
const item = this.rewardSelectionItem().item;
return hasOrderTypeFeature(item.features, [
OrderTypeFeature.InStore,
OrderTypeFeature.Pickup,
]);
return hasOrderTypeFeature(item.features, [OrderTypeFeature.InStore]);
});
hasStock = computed(() => {

View File

@@ -27,3 +27,16 @@
<lib-reward-selection-inputs></lib-reward-selection-inputs>
</div>
@if (showLowStockMessage()) {
<div
class="flex flex-row gap-2 items-center text-isa-accent-red isa-text-body-2-bold"
>
<ng-icon
class="w-6 h-6 inline-flex items-center justify-center"
size="1.5rem"
name="isaOtherInfo"
></ng-icon>
<span>{{ inStock() }} Exemplare sofort lieferbar</span>
</div>
}

View File

@@ -1,8 +1,18 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { RewardSelectionInputsComponent } from './reward-selection-inputs/reward-selection-inputs.component';
import { RewardSelectionItem } from '@isa/checkout/data-access';
import {
hasOrderTypeFeature,
RewardSelectionItem,
} from '@isa/checkout/data-access';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaOtherInfo } from '@isa/icons';
@Component({
selector: 'lib-reward-selection-item',
@@ -13,8 +23,24 @@ import { RewardSelectionItem } from '@isa/checkout/data-access';
ProductImageDirective,
ProductRouterLinkDirective,
RewardSelectionInputsComponent,
NgIcon,
],
providers: [provideIcons({ isaOtherInfo })],
})
export class RewardSelectionItemComponent {
rewardSelectionItem = input.required<RewardSelectionItem>();
inStock = computed(
() => this.rewardSelectionItem().item?.availability?.inStock ?? 0,
);
isAbholung = computed(() =>
hasOrderTypeFeature(this.rewardSelectionItem()?.item?.features, [
'Abholung',
]),
);
showLowStockMessage = computed(() => {
return this.isAbholung() && this.inStock() < 2;
});
}

View File

@@ -34,9 +34,16 @@ export const catchResponseArgsErrorPipe = <T>(): OperatorFunction<T, T> =>
return throwError(() => err);
}),
mergeMap((response) => {
if (isResponseArgs(response) && response.error === true) {
if (isResponseArgs(response)) {
// Treat as error if error flag is true OR if invalidProperties has entries
const hasInvalidProps =
response.invalidProperties &&
Object.keys(response.invalidProperties).length > 0;
if (response.error === true || hasInvalidProps) {
return throwError(() => new ResponseArgsError(response));
}
}
return [response];
}),

View File

@@ -2,6 +2,7 @@ import { Injectable, inject, resource, signal, computed } from '@angular/core';
import { logger } from '@isa/core/logging';
import { CustomerBonRedemptionFacade } from '../facades/customer-bon-redemption.facade';
import { LoyaltyBonResponse } from '@generated/swagger/crm-api';
import { ResponseArgsError } from '@isa/common/data-access';
/**
* Resource for checking/validating Bon numbers.
@@ -47,8 +48,24 @@ export class CustomerBonCheckResource {
this.#logger.debug('Bon checked', () => ({
bonNr,
found: !!response?.result,
hasInvalidProperties:
!!response?.invalidProperties &&
Object.keys(response.invalidProperties).length > 0,
}));
// Check for invalidProperties even when error is false
// Backend may return { error: false, invalidProperties: {...} } for validation issues
if (
response?.invalidProperties &&
Object.keys(response.invalidProperties).length > 0
) {
this.#logger.warn('Bon check has invalid properties', () => ({
bonNr,
invalidProperties: response.invalidProperties,
}));
throw new ResponseArgsError(response);
}
return response?.result;
},
defaultValue: undefined,

View File

@@ -224,15 +224,10 @@ export class CrmSearchService {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched current booking partner store');
return res?.result;
} catch (error) {
this.#logger.error('Error fetching current booking partner store', error);
return undefined;
}
}
async addBooking(

View File

@@ -114,12 +114,7 @@ export class CrmFeatureCustomerBonRedemptionComponent {
}
// Handle API errors
else if (error) {
let errorMsg = 'Bon-Validierung fehlgeschlagen';
if (error instanceof ResponseArgsError) {
errorMsg = error.message || errorMsg;
} else if (error instanceof Error) {
errorMsg = error.message;
}
const errorMsg = this.#extractErrorMessage(error);
this.store.setError(errorMsg);
}
});
@@ -224,4 +219,23 @@ export class CrmFeatureCustomerBonRedemptionComponent {
this.store.reset();
this.#bonCheckResource.reset();
}
/**
* Extract error message from various error types.
* ResponseArgsError already formats invalidProperties into a readable message.
*/
#extractErrorMessage(error: unknown): string {
const defaultMsg = 'Bon-Validierung fehlgeschlagen';
const actualError = (error as { cause?: unknown })?.cause ?? error;
if (actualError instanceof ResponseArgsError) {
return actualError.message || defaultMsg;
}
if (actualError instanceof Error) {
return actualError.message || defaultMsg;
}
return defaultMsg;
}
}

View File

@@ -63,6 +63,18 @@
}
</div>
}
<button
type="button"
uiButton
color="tertiary"
size="large"
(click)="abortRemission()"
class="fixed right-[15rem] bottom-6"
>
Warenbegleitschein abbrechen
</button>
@if (!returnLoading() && !returnData()?.completed) {
<lib-remission-return-receipt-complete
[returnId]="returnId()"

View File

@@ -17,6 +17,7 @@ import {
getPackageNumbersFromReturn,
getReceiptItemsFromReturn,
getReceiptNumberFromReturn,
RemissionStore,
} from '@isa/remission/data-access';
import { EmptyStateComponent } from '@isa/ui/empty-state';
import { EMPTY_WBS_DESCRIPTION, EMPTY_WBS_TITLE } from './constants';
@@ -24,6 +25,8 @@ import {
RemissionReturnReceiptActionsComponent,
RemissionReturnReceiptCompleteComponent,
} from '@isa/remission/shared/return-receipt-actions';
import { Router } from '@angular/router';
import { injectTabId } from '@isa/core/tabs';
@Component({
selector: 'remi-remission-return-receipt-details',
@@ -53,6 +56,15 @@ export class RemissionReturnReceiptDetailsComponent {
/** Angular Location service for navigation */
location = inject(Location);
/** Remission store for managing remission state */
#store = inject(RemissionStore);
/** Angular Router for navigation */
#router = inject(Router);
/** Injects the current activated tab ID as a signal. */
#tabId = injectTabId();
/**
* Required input for the return ID.
* Automatically coerced to a number from string input.
@@ -111,4 +123,9 @@ export class RemissionReturnReceiptDetailsComponent {
const returnData = this.returnData();
return getPackageNumbersFromReturn(returnData!) !== '';
});
async abortRemission() {
this.#store.clearState();
await this.#router.navigate(['/', this.#tabId(), 'remission']);
}
}

View File

@@ -20,11 +20,13 @@
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="open()"
[cdkConnectedOverlayPositions]="overlayPositions"
[cdkConnectedOverlayHasBackdrop]="true"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
[cdkConnectedOverlayScrollStrategy]="scrollStrategy"
[cdkConnectedOverlayOffsetX]="-10"
[cdkConnectedOverlayOffsetY]="18"
[cdkConnectedOverlayFlexibleDimensions]="true"
[cdkConnectedOverlayGrowAfterOpen]="true"
[cdkConnectedOverlayPush]="true"
cdkConnectedOverlayWidth="18.375rem"
(backdropClick)="toggle()"
>

View File

@@ -2,13 +2,13 @@ import { Overlay, OverlayModule } from '@angular/cdk/overlay';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
model,
output,
} from '@angular/core';
import { IconButtonComponent } from '@isa/ui/buttons';
import { DROPDOWN_OVERLAY_POSITIONS } from '@isa/ui/layout';
import { FilterMenuComponent } from './filter-menu.component';
import { FilterService } from '../../core';
@@ -30,6 +30,9 @@ export class FilterMenuButtonComponent {
selectedFilters = this.#filter.selectedFilterCount;
/** Standard overlay positions for the filter menu panel */
readonly overlayPositions = DROPDOWN_OVERLAY_POSITIONS;
/**
* Tracks the open state of the filter menu.
*/

View File

@@ -10,7 +10,9 @@
[cdkConnectedOverlayDisableClose]="false"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
[cdkConnectedOverlayMinWidth]="overlayMinWidth"
[cdkConnectedOverlayLockPosition]="true"
[cdkConnectedOverlayFlexibleDimensions]="true"
[cdkConnectedOverlayGrowAfterOpen]="true"
[cdkConnectedOverlayPush]="true"
[cdkConnectedOverlayScrollStrategy]="blockScrollStrategy"
(backdropClick)="close()"
(detach)="isOpen.set(false)"

View File

@@ -13,9 +13,7 @@ import {
signal,
viewChild,
} from '@angular/core';
import { isEqual } from 'lodash';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronUp, isaActionChevronDown } from '@isa/icons';
@@ -23,14 +21,15 @@ import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
import {
CdkConnectedOverlay,
CdkOverlayOrigin,
ConnectedPosition,
ScrollStrategyOptions,
} from '@angular/cdk/overlay';
import { DropdownAppearance } from './dropdown.types';
import { DropdownService } from './dropdown.service';
import { CloseOnScrollDirective } from '@isa/ui/layout';
import {
CloseOnScrollDirective,
DROPDOWN_OVERLAY_POSITIONS,
} from '@isa/ui/layout';
import { logger } from '@isa/core/logging';
import { DropdownOptionComponent } from './dropdown-option.component';
import { DropdownFilterComponent } from './dropdown-filter.component';
import { DROPDOWN_HOST, DropdownHost } from './dropdown-host';
@@ -99,80 +98,8 @@ export class DropdownButtonComponent<T>
return this.#scrollStrategy.block();
}
/** Offset in pixels between the trigger and the overlay panel */
readonly #overlayOffset = 12;
/**
* Position priority for the overlay panel.
* Order: bottom-left, bottom-right, top-left, top-right,
* right-top, right-bottom, left-top, left-bottom
*/
readonly overlayPositions: ConnectedPosition[] = [
// Bottom left
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: this.#overlayOffset,
},
// Bottom right
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
offsetY: this.#overlayOffset,
},
// Top left
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -this.#overlayOffset,
},
// Top right
{
originX: 'end',
originY: 'top',
overlayX: 'end',
overlayY: 'bottom',
offsetY: -this.#overlayOffset,
},
// Right top
{
originX: 'end',
originY: 'top',
overlayX: 'start',
overlayY: 'top',
offsetX: this.#overlayOffset,
},
// Right bottom
{
originX: 'end',
originY: 'bottom',
overlayX: 'start',
overlayY: 'bottom',
offsetX: this.#overlayOffset,
},
// Left top
{
originX: 'start',
originY: 'top',
overlayX: 'end',
overlayY: 'top',
offsetX: -this.#overlayOffset,
},
// Left bottom
{
originX: 'start',
originY: 'bottom',
overlayX: 'end',
overlayY: 'bottom',
offsetX: -this.#overlayOffset,
},
];
/** Standard overlay positions for the dropdown panel */
readonly overlayPositions = DROPDOWN_OVERLAY_POSITIONS;
appearance = input<DropdownAppearance>(DropdownAppearance.AccentOutline);

View File

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

View File

@@ -0,0 +1,100 @@
import { ConnectedPosition } from '@angular/cdk/overlay';
/** Default offset in pixels between the trigger and the overlay panel */
export const OVERLAY_OFFSET = 12;
/**
* Creates standard dropdown overlay positions with configurable offset.
*
* Position priority for overlay panels:
* CDK tries positions in order and picks the first one that fits in the viewport.
*
* With `flexibleDimensions=true` and `push=true`, the overlay will:
* 1. Try positions in order
* 2. Constrain size to fit in viewport (flexibleDimensions)
* 3. Push into viewport if needed (push)
*
* Priority order (most to least preferred):
* 1. Below the trigger (default, most natural for dropdowns)
* 2. Above the trigger (when no space below)
* 3. To the right (when no vertical space)
* 4. To the left (last resort)
*
* @param offset - Offset in pixels between trigger and overlay (default: 12)
* @returns Array of ConnectedPosition configurations
*/
export const createOverlayPositions = (
offset: number = OVERLAY_OFFSET,
): ConnectedPosition[] => [
// Priority 1: Below trigger, left-aligned (default/preferred)
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: offset,
},
// Priority 2: Below trigger, right-aligned
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
offsetY: offset,
},
// Priority 3: Above trigger, left-aligned
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -offset,
},
// Priority 4: Above trigger, right-aligned
{
originX: 'end',
originY: 'top',
overlayX: 'end',
overlayY: 'bottom',
offsetY: -offset,
},
// Priority 5: Right of trigger, top-aligned
{
originX: 'end',
originY: 'top',
overlayX: 'start',
overlayY: 'top',
offsetX: offset,
},
// Priority 6: Right of trigger, bottom-aligned
{
originX: 'end',
originY: 'bottom',
overlayX: 'start',
overlayY: 'bottom',
offsetX: offset,
},
// Priority 7: Left of trigger, top-aligned
{
originX: 'start',
originY: 'top',
overlayX: 'end',
overlayY: 'top',
offsetX: -offset,
},
// Priority 8: Left of trigger, bottom-aligned
{
originX: 'start',
originY: 'bottom',
overlayX: 'end',
overlayY: 'bottom',
offsetX: -offset,
},
];
/**
* Standard dropdown overlay positions with default offset (12px).
* Use this for most dropdown/menu components.
*/
export const DROPDOWN_OVERLAY_POSITIONS: ConnectedPosition[] =
createOverlayPositions(OVERLAY_OFFSET);