Enhanced return details and search components with new features and improvements.

-  **Feature**: Added InViewport directive for element visibility detection
-  **Feature**: Introduced new button for navigation in return details
- 🛠️ **Refactor**: Improved scroll position restoration logic and removed deprecated files
- 📚 **Docs**: Updated README with usage instructions for new directives

Ref: #5034
This commit is contained in:
Lorenz Hilpert
2025-04-08 10:51:24 +02:00
parent 62d0783e88
commit 8ca7977f7c
17 changed files with 287 additions and 148 deletions

View File

@@ -37,6 +37,7 @@ When conducting a code review, follow these steps to ensure a thorough and const
## Additional Informations
- Treat missing tests and JSDocs as warnings
- Tread missing unit test as warnings
### Review Template

View File

@@ -0,0 +1,17 @@
# [Library Name]
## Overview
<!-- Brief description of the library and its purpose -->
## Features
<!-- Key features or modules -->
## API
<!-- List Functions, Components, Services, etc. with their api and their purpose -->
## Usage
<!-- Sample code snippet or usage instructions -->

View File

@@ -1,5 +1,4 @@
@let receipt = receiptResult().data;
<button
class="fixed bottom-6 right-6"
uiButton
@@ -11,49 +10,64 @@
Rückgabe starten
</button>
@if (receipt) {
<oms-feature-return-details-header [buyer]="receipt.buyer"></oms-feature-return-details-header>
<div>
<button
uiButton
color="tertiary"
size="small"
class="px-[0.875rem] py-1 min-w-0 bg-white gap-1"
(click)="location.back()"
>
<ng-icon name="isaActionChevronLeft" size="1.5rem" class="-ml-2"></ng-icon>
<span>zurück</span>
</button>
</div>
@if (showMore()) {
<oms-feature-return-details-order-group-data
[receipt]="receipt"
></oms-feature-return-details-order-group-data>
<button
class="-ml-3"
uiTextButton
type="button"
color="strong"
size="small"
(click)="showMore.set(false)"
>
<ng-icon name="isaActionMinus"></ng-icon>
Weniger anzeigen
</button>
} @else {
<oms-feature-return-details-data [receipt]="receipt"></oms-feature-return-details-data>
<button
class="-ml-3"
uiTextButton
type="button"
color="strong"
size="small"
(click)="showMore.set(true)"
>
<ng-icon name="isaActionPlus"></ng-icon>
Bestelldetails anzeigen
</button>
}
<div></div>
<oms-feature-return-details-order-group
[items]="receiptItems()"
[(selectedItems)]="selectedItems"
></oms-feature-return-details-order-group>
@for (item of receipt.items; track item.id; let last = $last) {
<oms-feature-return-details-order-group-item
class="border-b border-solid border-isa-neutral-300 last:border-none"
[item]="item.data"
(selectedChange)="selectItemById(item.id, $event)"
[selected]="selectedItemIds().includes(item.id)"
></oms-feature-return-details-order-group-item>
}
@if (receipt) {
<div class="flex flex-col items-start justify-stretch gap-6 rounded-2xl bg-isa-white px-4 py-6">
<oms-feature-return-details-header [buyer]="receipt.buyer"></oms-feature-return-details-header>
@if (showMore()) {
<oms-feature-return-details-order-group-data
[receipt]="receipt"
></oms-feature-return-details-order-group-data>
<button
class="-ml-3"
uiTextButton
type="button"
color="strong"
size="small"
(click)="showMore.set(false)"
>
<ng-icon name="isaActionMinus"></ng-icon>
Weniger anzeigen
</button>
} @else {
<oms-feature-return-details-data [receipt]="receipt"></oms-feature-return-details-data>
<button
class="-ml-3"
uiTextButton
type="button"
color="strong"
size="small"
(click)="showMore.set(true)"
>
<ng-icon name="isaActionPlus"></ng-icon>
Bestelldetails anzeigen
</button>
}
<div></div>
<oms-feature-return-details-order-group
[items]="receiptItems()"
[(selectedItems)]="selectedItems"
></oms-feature-return-details-order-group>
@for (item of receipt.items; track item.id; let last = $last) {
<oms-feature-return-details-order-group-item
class="border-b border-solid border-isa-neutral-300 last:border-none"
[item]="item.data"
(selectedChange)="selectItemById(item.id, $event)"
[selected]="selectedItemIds().includes(item.id)"
></oms-feature-return-details-order-group-item>
}
</div>
}

View File

@@ -1,12 +1,8 @@
:host {
margin-top: 1.5rem;
display: flex;
padding: 1rem 1.5rem;
flex-direction: column;
align-items: flex-start;
gap: 1.5rem;
align-self: stretch;
border-radius: 1rem;
background: #fff;
align-items: stretch;
justify-content: stretch;
gap: 1rem;
}

View File

@@ -12,7 +12,7 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { z } from 'zod';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionPlus, isaActionMinus } from '@isa/icons';
import { isaActionPlus, isaActionMinus, isaActionChevronLeft } from '@isa/icons';
import { ButtonComponent, TextButtonComponent } from '@isa/ui/buttons';
import { ReceiptItem, ReturnDetailsStore, ReturnProcessStore } from '@isa/oms/data-access';
import { injectActivatedProcessId } from '@isa/core/process';
@@ -21,6 +21,7 @@ import { ReturnDetailsHeaderComponent } from './return-details-header/return-det
import { ReturnDetailsOrderGroupComponent } from './return-details-order-group/return-details-order-group.component';
import { ReturnDetailsOrderGroupItemComponent } from './return-details-order-group-item/return-details-order-group-item.component';
import { ReturnDetailsOrderGroupDataComponent } from './return-details-order-group-data/return-details-order-group-data.component';
import { Location } from '@angular/common';
@Component({
selector: 'oms-feature-return-details',
@@ -37,7 +38,7 @@ import { ReturnDetailsOrderGroupDataComponent } from './return-details-order-gro
ReturnDetailsOrderGroupItemComponent,
ReturnDetailsOrderGroupDataComponent,
],
providers: [provideIcons({ isaActionPlus, isaActionMinus })],
providers: [provideIcons({ isaActionPlus, isaActionMinus, isaActionChevronLeft })],
})
export class ReturnDetailsComponent {
private processId = injectActivatedProcessId();
@@ -46,6 +47,8 @@ export class ReturnDetailsComponent {
private _activatedRoute = inject(ActivatedRoute);
location = inject(Location);
params = toSignal(this._activatedRoute.params);
selectedItems = signal<ReceiptItem[]>([]);

View File

@@ -57,7 +57,7 @@
<ui-icon-button [pending]="true" [color]="'tertiary'"></ui-icon-button>
</div>
} @else if (renderPageTrigger()) {
<div (utilScrolledIntoViewport)="paging($event)"></div>
<div (uiInViewport)="paging($event)"></div>
}
</div>
} @else if (renderSearchLoader()) {

View File

@@ -22,12 +22,9 @@ import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionSort } from '@isa/icons';
import { ReceiptListItem, ReturnSearchStatus, ReturnSearchStore } from '@isa/oms/data-access';
import { ReturnSearchResultItemComponent } from './return-search-result-item/return-search-result-item.component';
import { BreakpointDirective } from '@isa/ui/layout';
import { BreakpointDirective, InViewportDirective } from '@isa/ui/layout';
import { CallbackResult, ListResponseArgs } from '@isa/common/result';
import {
injectRestoreScrollPosition,
ScrolledIntoViewportDirective,
} from '@isa/utils/scroll-position';
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
@Component({
selector: 'oms-feature-return-search-result',
@@ -44,7 +41,7 @@ import {
NgIconComponent,
FilterMenuButtonComponent,
BreakpointDirective,
ScrolledIntoViewportDirective,
InViewportDirective,
],
providers: [provideIcons({ isaActionSort })],
})

View File

@@ -33,6 +33,14 @@ The `uiBreakpoint` directive conditionally includes a template based on the view
</ng-container>
```
### InViewport Directive
Use this directive to emit an event whenever the host element enters or leaves the viewport.
```html
<some-element uiInViewport (uiInViewport)="onInViewportChange($event)"> ... </some-element>
```
## Breakpoints Table
| Breakpoint | CSS Media Query Selector |

View File

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

View File

@@ -1,13 +1,27 @@
import { Directive, ElementRef, AfterViewInit, OnDestroy, output } from '@angular/core';
/**
* Directive that emits an event when its host element enters or leaves the viewport.
*/
@Directive({
selector: '[utilScrolledIntoViewport]',
selector: '[uiInViewport]',
exportAs: 'uiInViewport',
})
export class ScrolledIntoViewportDirective implements AfterViewInit, OnDestroy {
export class InViewportDirective implements AfterViewInit, OnDestroy {
/**
* Emits true when the element enters the viewport and false when it leaves.
*/
scrolledIntoViewport = output<boolean>({ alias: 'utilScrolledIntoViewport' });
inViewport = output<boolean>({ alias: 'uiInViewport' });
/**
* Emits when the element enters the viewport.
*/
enteredViewport = output<void>();
/**
* Emits when the element leaves the viewport.
*/
leftViewport = output<void>();
private observer: IntersectionObserver | null = null;
@@ -18,9 +32,11 @@ export class ScrolledIntoViewportDirective implements AfterViewInit, OnDestroy {
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.scrolledIntoViewport.emit(true);
this.inViewport.emit(true);
this.enteredViewport.emit();
} else {
this.scrolledIntoViewport.emit(false);
this.inViewport.emit(false);
this.leftViewport.emit();
}
});
},

View File

@@ -1,7 +1,66 @@
# utils-scroll-position
# Scroll Position Library
This library was generated with [Nx](https://nx.dev).
## Overview
## Running unit tests
Provides utilities for storing, restoring, and observing scroll position across navigation or component view changes.
Run `nx test utils-scroll-position` to execute the unit tests.
## Features
- Store current scroll position in session storage.
- Restore saved scroll position with an optional delay.
- Observe when an element enters or leaves the viewport.
## Usage
### Storing Scroll Position
Call the function to save the current scroll position:
```typescript
import { storeScrollPosition } from '@isa/utils/scroll-position';
storeScrollPosition();
```
### Restoring Scroll Position
Inject the restore function and call it with an optional delay:
```typescript
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
const restorePosition = injectRestoreScrollPosition();
await restorePosition(200);
```
### Automatic Restoration
Provide environment initializer in your app module to auto-save scroll positions:
```typescript
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
provideScrollPositionRestoration();
```
### Marking a Route for Auto Restoration
To enable automatic scroll restoration on a specific route, add the “scrollPositionRestoration” property in its data:
```typescript
{
path: 'example',
component: ExampleComponent,
data: {
scrollPositionRestoration: true
}
},
```
### Detecting Element Visibility
Apply the directive to a component template to emit true or false when entering or exiting the viewport:
```html
<div utilScrolledIntoViewport (utilScrolledIntoViewport)="onVisibilityChange($event)">...</div>
```

View File

@@ -1,2 +1,3 @@
export * from './lib/scroll-position-restoration';
export * from './lib/scrolled-into-viewport.directive';
export * from './lib/inject-restore-scroll-position';
export * from './lib/provide-scroll-position-restoration';
export * from './lib/store-scroll-position';

View File

@@ -0,0 +1,29 @@
import { ViewportScroller } from '@angular/common';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { SessionStorageProvider } from '@isa/core/storage';
/**
* Returns a function that restores the scroll position from session storage.
*
* The returned function waits for an optional delay to ensure the DOM is ready before scrolling.
*
* @returns A function accepting an optional delay (in milliseconds) and restoring scroll position.
*/
export function injectRestoreScrollPosition(): () => Promise<void> {
const router = inject(Router);
const viewportScroller = inject(ViewportScroller);
const sessionStorage = inject(SessionStorageProvider);
return async (delay = 0) => {
const url = router.url;
const position = await sessionStorage.get(url);
if (position) {
// wait for the next tick to ensure the DOM is ready
await new Promise((r) => setTimeout(r, delay));
sessionStorage.clear(url);
viewportScroller.scrollToPosition(position as [number, number]);
}
};
}

View File

@@ -0,0 +1,42 @@
import { ViewportScroller } from '@angular/common';
import { EnvironmentProviders, inject, provideEnvironmentInitializer } from '@angular/core';
import { NavigationStart, Router } from '@angular/router';
import { SessionStorageProvider } from '@isa/core/storage';
import { getDeepestActivatedRoute } from './utils/get-deepest-activated-route';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
/**
* Provides an environment initializer that restores scroll position upon navigation and page unload.
*
* Listens to router navigation events and the window's beforeunload event to store the current scroll position.
*
* @returns EnvironmentProviders for scroll position restoration.
*/
export function provideScrollPositionRestoration(): EnvironmentProviders {
return provideEnvironmentInitializer(() => {
const router = inject(Router);
const viewportScroller = inject(ViewportScroller);
const sessionStorage = inject(SessionStorageProvider);
function storeScrollPosition() {
const url = router.url;
const route = getDeepestActivatedRoute(router.routerState.root);
if (route.snapshot.data?.['scrollPositionRestoration']) {
sessionStorage.set(url, viewportScroller.getScrollPosition());
}
}
if (window) {
window.addEventListener('beforeunload', () => {
storeScrollPosition();
});
}
router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
if (event instanceof NavigationStart) {
storeScrollPosition();
}
});
});
}

View File

@@ -1,75 +0,0 @@
import { ViewportScroller } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EnvironmentProviders, inject, provideEnvironmentInitializer } from '@angular/core';
import { ActivatedRoute, NavigationStart, Router } from '@angular/router';
import { SessionStorageProvider } from '@isa/core/storage';
// const route: Route = {
// component: AnyComponent,
// data: {
// scrollPositionRestoration: true
// }
// }
const getDeepestActivatedRoute = (route: ActivatedRoute): ActivatedRoute => {
while (route.firstChild) {
route = route.firstChild;
}
return route;
};
export function provideScrollPositionRestoration(): EnvironmentProviders {
return provideEnvironmentInitializer(() => {
const router = inject(Router);
const viewportScroller = inject(ViewportScroller);
const sessionStorage = inject(SessionStorageProvider);
function storeScrollPosition() {
const url = router.url;
const route = getDeepestActivatedRoute(router.routerState.root);
if (route.snapshot.data?.['scrollPositionRestoration']) {
sessionStorage.set(url, viewportScroller.getScrollPosition());
}
}
if (window) {
window.addEventListener('beforeunload', () => {
storeScrollPosition();
});
}
router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
if (event instanceof NavigationStart) {
storeScrollPosition();
}
});
});
}
export async function storeScrollPosition() {
const router = inject(Router);
const viewportScroller = inject(ViewportScroller);
const sessionStorage = inject(SessionStorageProvider);
const url = router.url;
sessionStorage.set(url, viewportScroller.getScrollPosition());
}
export function injectRestoreScrollPosition(): () => Promise<void> {
const router = inject(Router);
const viewportScroller = inject(ViewportScroller);
const sessionStorage = inject(SessionStorageProvider);
return async (delay = 0) => {
const url = router.url;
const position = await sessionStorage.get(url);
if (position) {
// wait for the next tick to ensure the DOM is ready
await new Promise((r) => setTimeout(r, delay));
sessionStorage.clear(url);
viewportScroller.scrollToPosition(position as [number, number]);
}
};
}

View File

@@ -0,0 +1,17 @@
import { ViewportScroller } from '@angular/common';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { SessionStorageProvider } from '@isa/core/storage';
/**
* Stores the current scroll position in session storage.
* Uses the current router URL as the key.
*/
export async function storeScrollPosition() {
const router = inject(Router);
const viewportScroller = inject(ViewportScroller);
const sessionStorage = inject(SessionStorageProvider);
const url = router.url;
sessionStorage.set(url, viewportScroller.getScrollPosition());
}

View File

@@ -0,0 +1,14 @@
import { ActivatedRoute } from '@angular/router';
/**
* Recursively retrieves the deepest activated route.
*
* @param route - The starting ActivatedRoute instance.
* @returns The deepest ActivatedRoute found.
*/
export const getDeepestActivatedRoute = (route: ActivatedRoute): ActivatedRoute => {
while (route.firstChild) {
route = route.firstChild;
}
return route;
};