@devzwo/ngx-magic-scroll 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +139 -0
- package/README.md +5 -3
- package/ai/usage.md +97 -0
- package/docs/concepts/anchors.md +129 -0
- package/docs/concepts/data-readiness-and-restoration.md +102 -0
- package/docs/concepts/position-preservation.md +149 -0
- package/docs/concepts/url-state-and-navigation.md +152 -0
- package/docs/getting-started.md +144 -0
- package/docs/migrations/v0.0.1-rc.1-to-0.0.1-rc.2.md +177 -0
- package/docs/migrations/v0.0.1-rc.2-to-1.0.0.md +176 -0
- package/docs/migrations/v1.0-to-v1.1.md +145 -0
- package/docs/recipes/material-drawer.md +99 -0
- package/docs/recipes/ngrx-signal-store.md +132 -0
- package/docs/recipes/paginated-lists.md +206 -0
- package/docs/recipes/return-to-list.md +110 -0
- package/docs/recipes/rx-resource.md +87 -0
- package/docs/recipes/scroll-to-anchor.md +148 -0
- package/docs/recipes/sticky-master-detail.md +138 -0
- package/docs/recipes/tanstack-query.md +96 -0
- package/fesm2022/devzwo-ngx-magic-scroll.mjs +80 -25
- package/fesm2022/devzwo-ngx-magic-scroll.mjs.map +1 -1
- package/package.json +1 -1
- package/types/devzwo-ngx-magic-scroll.d.ts +3 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Migration Guide: v1.0 to v1.1'
|
|
3
|
+
description: 'Upgrade instructions and code transformations for updating @devzwo/ngx-magic-scroll from v1.0 to v1.1.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
This guide details the new features, behavioral improvements, and recommended configuration updates when upgrading `@devzwo/ngx-magic-scroll` from `1.0.x` to `1.1.0`.
|
|
7
|
+
|
|
8
|
+
## Overview of Changes
|
|
9
|
+
|
|
10
|
+
Release `v1.1.0` is backwards-compatible and introduces several opt-in enhancements:
|
|
11
|
+
|
|
12
|
+
- **Layout Reflow Preservation**: New opt-in `preserveOnLayoutChange` setting to preserve reading positions during asynchronous DOM expansions (such as accordions, dynamic cards, or lazy images).
|
|
13
|
+
- **Exact Displacement Tracking**: Section-relative scroll tracking now maintains intra-section pixel displacement (`displacement`) rather than resetting to the section top.
|
|
14
|
+
- **Accessible Focus Management**: The `scrollTo()` method supports `{ focus: true }` to transfer keyboard focus safely to the scrolled target.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1. Enabling Layout Reflow Preservation
|
|
19
|
+
|
|
20
|
+
### Previous Behavior (v1.0.0)
|
|
21
|
+
|
|
22
|
+
In `v1.0.0`, position preservation was triggered when `scrollSource` data changed or on container window resize events. Asynchronous DOM reflows that did not alter `scrollSource` (e.g. collapsing/expanding an accordion or loading un-dimensioned images) could shift content away from the reader's viewport.
|
|
23
|
+
|
|
24
|
+
### Updated Configuration (v1.1.0)
|
|
25
|
+
|
|
26
|
+
Enable `preserveOnLayoutChange: true` in your global provider or directly on the `[magicScroll]` directive to automatically observe DOM layout shifts and maintain the reader's view.
|
|
27
|
+
|
|
28
|
+
#### Before (v1.0.0)
|
|
29
|
+
|
|
30
|
+
<!-- recipe-check: migration-v1-0-before.ts -->
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { Component } from '@angular/core';
|
|
34
|
+
import {
|
|
35
|
+
MagicScrollDirective,
|
|
36
|
+
ScrollAnchorDirective,
|
|
37
|
+
provideMagicScroll,
|
|
38
|
+
} from '@devzwo/ngx-magic-scroll';
|
|
39
|
+
|
|
40
|
+
@Component({
|
|
41
|
+
selector: 'app-migration-sample-before',
|
|
42
|
+
imports: [MagicScrollDirective, ScrollAnchorDirective],
|
|
43
|
+
providers: [provideMagicScroll()],
|
|
44
|
+
template: `
|
|
45
|
+
<div magicScroll>
|
|
46
|
+
<section id="section-1" scrollAnchor>
|
|
47
|
+
<h2>Section 1</h2>
|
|
48
|
+
</section>
|
|
49
|
+
<section id="section-2" scrollAnchor>
|
|
50
|
+
<h2>Section 2</h2>
|
|
51
|
+
</section>
|
|
52
|
+
</div>
|
|
53
|
+
`,
|
|
54
|
+
})
|
|
55
|
+
export class MigrationSampleBeforeComponent {}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
#### After (v1.1.0)
|
|
59
|
+
|
|
60
|
+
<!-- recipe-check: migration-v1-1-after.ts -->
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { Component } from '@angular/core';
|
|
64
|
+
import {
|
|
65
|
+
MagicScrollDirective,
|
|
66
|
+
ScrollAnchorDirective,
|
|
67
|
+
provideMagicScroll,
|
|
68
|
+
} from '@devzwo/ngx-magic-scroll';
|
|
69
|
+
|
|
70
|
+
@Component({
|
|
71
|
+
selector: 'app-migration-sample-after',
|
|
72
|
+
imports: [MagicScrollDirective, ScrollAnchorDirective],
|
|
73
|
+
providers: [
|
|
74
|
+
provideMagicScroll({
|
|
75
|
+
preserveOnLayoutChange: true,
|
|
76
|
+
}),
|
|
77
|
+
],
|
|
78
|
+
template: `
|
|
79
|
+
<div magicScroll [scrollOptions]="{ preserveOnLayoutChange: true }">
|
|
80
|
+
<section id="section-1" scrollAnchor>
|
|
81
|
+
<h2>Section 1</h2>
|
|
82
|
+
</section>
|
|
83
|
+
<section id="section-2" scrollAnchor>
|
|
84
|
+
<h2>Section 2</h2>
|
|
85
|
+
</section>
|
|
86
|
+
</div>
|
|
87
|
+
`,
|
|
88
|
+
})
|
|
89
|
+
export class MigrationSampleAfterComponent {}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 2. Accessible Focus Navigation
|
|
95
|
+
|
|
96
|
+
### Previous Implementation (v1.0.0)
|
|
97
|
+
|
|
98
|
+
In `v1.0.0`, programmatic navigation via `scrollTo('section-id')` scrolled the viewport but did not alter keyboard DOM focus. Developers had to manually manage element focus and `tabindex` attributes.
|
|
99
|
+
|
|
100
|
+
### Updated Implementation (v1.1.0)
|
|
101
|
+
|
|
102
|
+
Pass `{ focus: true }` in `scrollTo()`. The directive automatically manages temporary `tabindex="-1"` attributes on non-focusable elements and waits for smooth scrolling to complete (`scrollend`) before transferring focus:
|
|
103
|
+
|
|
104
|
+
<!-- recipe-check: migration-v1-1-focus.ts -->
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { Component, viewChild } from '@angular/core';
|
|
108
|
+
import { MagicScrollDirective, provideMagicScroll } from '@devzwo/ngx-magic-scroll';
|
|
109
|
+
|
|
110
|
+
@Component({
|
|
111
|
+
selector: 'app-migration-focus-sample',
|
|
112
|
+
imports: [MagicScrollDirective],
|
|
113
|
+
providers: [provideMagicScroll()],
|
|
114
|
+
template: `
|
|
115
|
+
<nav>
|
|
116
|
+
<button (click)="navigateToAnchor('section-2')">Jump to Section 2</button>
|
|
117
|
+
</nav>
|
|
118
|
+
<div magicScroll #scroll="magicScroll">
|
|
119
|
+
<section id="section-1">
|
|
120
|
+
<h2>Section 1</h2>
|
|
121
|
+
</section>
|
|
122
|
+
<section id="section-2">
|
|
123
|
+
<h2>Section 2</h2>
|
|
124
|
+
</section>
|
|
125
|
+
</div>
|
|
126
|
+
`,
|
|
127
|
+
})
|
|
128
|
+
export class MigrationFocusSampleComponent {
|
|
129
|
+
readonly scroll = viewChild.required<MagicScrollDirective>('scroll');
|
|
130
|
+
|
|
131
|
+
navigateToAnchor(targetId: string): void {
|
|
132
|
+
this.scroll().scrollTo(targetId, {
|
|
133
|
+
behavior: 'smooth',
|
|
134
|
+
focus: true,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 3. Deprecations and Breaking Changes
|
|
143
|
+
|
|
144
|
+
- **Breaking Changes**: None. All existing v1.0.0 configurations and APIs remain functional without modification.
|
|
145
|
+
- **Deprecations**: None in this release.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Angular Material Drawer
|
|
3
|
+
description: Scroll within mat-drawer-content and preserve anchors across layout changes.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Goal
|
|
7
|
+
|
|
8
|
+
Place a section list inside Angular Material's drawer layout, with navigation in the drawer and scrolling confined to its content area.
|
|
9
|
+
|
|
10
|
+
## Solution
|
|
11
|
+
|
|
12
|
+
Use this in an application that already has Angular Material and its theme configured, and provides Angular Router. The library requires no Material-specific adapter.
|
|
13
|
+
|
|
14
|
+
<!-- recipe-check: material-drawer-0.ts -->
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { Component } from '@angular/core';
|
|
18
|
+
import { MatSidenavModule } from '@angular/material/sidenav';
|
|
19
|
+
import { MagicScrollDirective } from '@devzwo/ngx-magic-scroll';
|
|
20
|
+
|
|
21
|
+
@Component({
|
|
22
|
+
selector: 'app-drawer-projects',
|
|
23
|
+
imports: [MatSidenavModule, MagicScrollDirective],
|
|
24
|
+
template: `
|
|
25
|
+
<button (click)="drawer.toggle()">Toggle navigation</button>
|
|
26
|
+
<mat-drawer-container class="shell">
|
|
27
|
+
<mat-drawer #drawer mode="side" opened>
|
|
28
|
+
<nav aria-label="Projects">
|
|
29
|
+
@for (project of projects; track project.id) {
|
|
30
|
+
<button (click)="scroll.scrollTo('project-' + project.id)">
|
|
31
|
+
{{ project.name }}
|
|
32
|
+
</button>
|
|
33
|
+
}
|
|
34
|
+
</nav>
|
|
35
|
+
</mat-drawer>
|
|
36
|
+
|
|
37
|
+
<mat-drawer-content>
|
|
38
|
+
<div magicScroll #scroll="magicScroll">
|
|
39
|
+
@for (project of projects; track project.id) {
|
|
40
|
+
<section [id]="'project-' + project.id">
|
|
41
|
+
<h2>{{ project.name }}</h2>
|
|
42
|
+
<details>
|
|
43
|
+
<summary>Project description</summary>
|
|
44
|
+
<p>Additional content changes this section's height.</p>
|
|
45
|
+
</details>
|
|
46
|
+
</section>
|
|
47
|
+
}
|
|
48
|
+
</div>
|
|
49
|
+
</mat-drawer-content>
|
|
50
|
+
</mat-drawer-container>
|
|
51
|
+
`,
|
|
52
|
+
styles: `
|
|
53
|
+
.shell {
|
|
54
|
+
height: 70vh;
|
|
55
|
+
}
|
|
56
|
+
mat-drawer {
|
|
57
|
+
width: 180px;
|
|
58
|
+
}
|
|
59
|
+
mat-drawer-content {
|
|
60
|
+
overflow-anchor: none;
|
|
61
|
+
}
|
|
62
|
+
nav {
|
|
63
|
+
display: flex;
|
|
64
|
+
flex-direction: column;
|
|
65
|
+
}
|
|
66
|
+
section {
|
|
67
|
+
min-height: 50vh;
|
|
68
|
+
padding: 16px;
|
|
69
|
+
}
|
|
70
|
+
`,
|
|
71
|
+
})
|
|
72
|
+
export class DrawerProjectsComponent {
|
|
73
|
+
readonly projects = Array.from({ length: 8 }, (_, index) => ({
|
|
74
|
+
id: index + 1,
|
|
75
|
+
name: `Project ${index + 1}`,
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The constrained container height makes Material's `mat-drawer-content` scrollable. The directive detects that ancestor automatically. For a plain HTML layout, the same principle is a constrained ancestor with `overflow-y: auto`, or a scrollable `magicScroll` host itself.
|
|
81
|
+
|
|
82
|
+
The drawer's navigation is outside the anchor region. Nested `magicScroll` regions own their own descendant anchors; use unique IDs throughout the document. Regions share the page's single URL fragment, so independent containers do not have separate URL state.
|
|
83
|
+
|
|
84
|
+
`overflow-anchor: none` disables native browser scroll anchoring in this content area so it does not compete with the library's anchor corrections.
|
|
85
|
+
|
|
86
|
+
### Data and layout changes
|
|
87
|
+
|
|
88
|
+
For an asynchronous list, add `[scrollSource]="projects"` to the region and render the source's values in both lists. Later ready data changes can preserve the current anchor with `restoreOnDataChange`; window resizing and measured header changes use `preserveOnResize`.
|
|
89
|
+
|
|
90
|
+
The library does not observe every DOM size change. A drawer toggle or expanded section can change layout without a window resize or source update. If your application needs an explicit realignment after such an interaction, call `scroll.scrollTo(scroll.activeAnchor(), { behavior: 'instant' })` once the layout has settled, after checking that the anchor is non-null. Decide whether that movement fits the interaction before adding it.
|
|
91
|
+
|
|
92
|
+
## Scope and related recipes
|
|
93
|
+
|
|
94
|
+
This recipe isolates container layout. It does not implement filtering, pagination or data loading.
|
|
95
|
+
|
|
96
|
+
- [Angular rxResource](./rx-resource.md) or [TanStack Query](./tanstack-query.md) supplies asynchronous cards, as in the drawer demo.
|
|
97
|
+
- [Paginated lists](./paginated-lists.md) adds loading and restoration of additional pages.
|
|
98
|
+
- [Return to a list](./return-to-list.md) adds detail routes and return links.
|
|
99
|
+
- [Sticky master–detail](./sticky-master-detail.md) covers document scrolling and measured headers.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: NgRx Signal Store
|
|
3
|
+
description: Adapt NgRx store signals into a scroll data source.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Goal
|
|
7
|
+
|
|
8
|
+
Use the data and loading state of an existing `@ngrx/signals` store for anchor restoration, without moving data fetching into the component.
|
|
9
|
+
|
|
10
|
+
## Solution
|
|
11
|
+
|
|
12
|
+
This follows the portfolio-store pattern used in zwoPRO: the store owns projects, loading and errors. Install `@ngrx/signals` in your application if you do not already use it. The library itself has no NgRx dependency.
|
|
13
|
+
|
|
14
|
+
A minimal `projects.store.ts` distinguishes data that has not loaded yet from a successful empty result:
|
|
15
|
+
|
|
16
|
+
<!-- recipe-check: projects.store.ts -->
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
|
|
20
|
+
|
|
21
|
+
interface Project {
|
|
22
|
+
id: number;
|
|
23
|
+
name: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ProjectsState {
|
|
27
|
+
projects: Project[] | undefined;
|
|
28
|
+
loading: boolean;
|
|
29
|
+
error: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const initialState: ProjectsState = {
|
|
33
|
+
projects: undefined,
|
|
34
|
+
loading: false,
|
|
35
|
+
error: null,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const ProjectsStore = signalStore(
|
|
39
|
+
withState(initialState),
|
|
40
|
+
withMethods((store) => ({
|
|
41
|
+
async load() {
|
|
42
|
+
patchState(store, { loading: true, error: null });
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetch('/api/projects');
|
|
45
|
+
if (!response.ok) throw new Error('Could not load projects.');
|
|
46
|
+
const projects: Project[] = await response.json();
|
|
47
|
+
patchState(store, { projects, loading: false });
|
|
48
|
+
} catch {
|
|
49
|
+
patchState(store, { loading: false, error: 'Could not load projects.' });
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
})),
|
|
53
|
+
);
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Use your existing API service or `rxMethod` implementation instead of `fetch` if the store already handles requests. Here `/api/projects` is an application endpoint returning a project array.
|
|
57
|
+
|
|
58
|
+
In `store-projects.component.ts`, provide the store and map its signals to the neutral `ScrollDataSource` contract:
|
|
59
|
+
|
|
60
|
+
<!-- recipe-check: store-projects.component.ts -->
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { Component, inject } from '@angular/core';
|
|
64
|
+
import { MagicScrollDirective, ScrollDataSource } from '@devzwo/ngx-magic-scroll';
|
|
65
|
+
import { ProjectsStore } from './projects.store';
|
|
66
|
+
|
|
67
|
+
@Component({
|
|
68
|
+
selector: 'app-store-projects',
|
|
69
|
+
imports: [MagicScrollDirective],
|
|
70
|
+
providers: [ProjectsStore],
|
|
71
|
+
template: `
|
|
72
|
+
<button [disabled]="store.loading()" (click)="store.load()">Reload</button>
|
|
73
|
+
<div magicScroll [scrollSource]="scrollSource">
|
|
74
|
+
@if (store.loading()) {
|
|
75
|
+
<p role="status">Loading projects…</p>
|
|
76
|
+
}
|
|
77
|
+
@if (store.error()) {
|
|
78
|
+
<p role="alert">{{ store.error() }}</p>
|
|
79
|
+
}
|
|
80
|
+
@if (store.projects()?.length === 0) {
|
|
81
|
+
<p>No projects.</p>
|
|
82
|
+
}
|
|
83
|
+
@for (project of store.projects(); track project.id) {
|
|
84
|
+
<section [id]="'project-' + project.id">
|
|
85
|
+
<h2>{{ project.name }}</h2>
|
|
86
|
+
</section>
|
|
87
|
+
}
|
|
88
|
+
</div>
|
|
89
|
+
`,
|
|
90
|
+
styles: `
|
|
91
|
+
section {
|
|
92
|
+
min-height: 70vh;
|
|
93
|
+
}
|
|
94
|
+
`,
|
|
95
|
+
})
|
|
96
|
+
export class StoreProjectsComponent {
|
|
97
|
+
readonly store = inject(ProjectsStore);
|
|
98
|
+
readonly scrollSource: ScrollDataSource = {
|
|
99
|
+
data: () => this.store.projects(),
|
|
100
|
+
isLoading: () => this.store.loading(),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
constructor() {
|
|
104
|
+
void this.store.load();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The getters must read signals so changes are tracked. Adapt the names to your store; for a filtered view, `data()` should return the collection actually rendered by the template. An NgRx store with fields called `projects` and `loading` does not automatically satisfy the `data()`/`isLoading()` contract.
|
|
110
|
+
|
|
111
|
+
### If your store starts with an empty array
|
|
112
|
+
|
|
113
|
+
`[]` is a ready value. If the store initially contains `projects: []` and `loading: false`, add a `loaded` signal that becomes true only after a successful request. Gate the source's data:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
readonly scrollSource: ScrollDataSource = {
|
|
117
|
+
data: () => this.store.loaded() ? this.store.projects() : undefined,
|
|
118
|
+
isLoading: () => this.store.loading(),
|
|
119
|
+
};
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
This is an alternative for an existing store; it requires that store to expose `loaded`. It prevents an idle or failed first request from consuming initial restoration. Do not use array length to detect successful loading, because an empty response is valid.
|
|
123
|
+
|
|
124
|
+
## Scope and related recipes
|
|
125
|
+
|
|
126
|
+
This recipe demonstrates the adapter and initial readiness. Store lifetime, caching, concurrency and filtering remain application responsibilities; use an existing root-provided store without adding a second component provider if you want to retain its state between pages.
|
|
127
|
+
|
|
128
|
+
- [Return to a list](./return-to-list.md) covers route fragments and detail navigation.
|
|
129
|
+
- [Sticky master–detail](./sticky-master-detail.md) combines the source with a portfolio layout.
|
|
130
|
+
- [Paginated lists](./paginated-lists.md) explains saved load ranges and later data corrections.
|
|
131
|
+
|
|
132
|
+
See the [NgRx Signal Store guide](https://ngrx.io/guide/signals/signal-store) for store design and additional features.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Paginated lists
|
|
3
|
+
description: Restore the loaded range before restoring an anchor, with rxResource or TanStack Infinite Query.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Goal
|
|
7
|
+
|
|
8
|
+
Let users load more items, open a detail page and return to an item beyond the first page. Restore both the loaded range and the anchor after browser Back or reload.
|
|
9
|
+
|
|
10
|
+
## Solution: restore data before scrolling
|
|
11
|
+
|
|
12
|
+
The fragment identifies the item; a query parameter records how much data must be available. For example, `/projects?count=16#project-12` means that the application loads the first 16 items before the directive attempts initial restoration.
|
|
13
|
+
|
|
14
|
+
This compact version of the drawer demo simulates a server that returns the requested range:
|
|
15
|
+
|
|
16
|
+
<!-- recipe-check: paginated-lists-0.ts -->
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { Component, inject, signal } from '@angular/core';
|
|
20
|
+
import { rxResource } from '@angular/core/rxjs-interop';
|
|
21
|
+
import { ActivatedRoute, Router } from '@angular/router';
|
|
22
|
+
import { delay, of } from 'rxjs';
|
|
23
|
+
import { MagicScrollDirective } from '@devzwo/ngx-magic-scroll';
|
|
24
|
+
|
|
25
|
+
@Component({
|
|
26
|
+
selector: 'app-paginated-projects',
|
|
27
|
+
imports: [MagicScrollDirective],
|
|
28
|
+
template: `
|
|
29
|
+
<button [disabled]="projects.isLoading() || count() >= 100" (click)="loadMore()">
|
|
30
|
+
Load more
|
|
31
|
+
</button>
|
|
32
|
+
<div magicScroll [scrollSource]="projects">
|
|
33
|
+
@if (projects.isLoading()) {
|
|
34
|
+
<p role="status">Loading projects…</p>
|
|
35
|
+
}
|
|
36
|
+
@if (projects.error()) {
|
|
37
|
+
<p role="alert">Could not load projects.</p>
|
|
38
|
+
<button (click)="projects.reload()">Retry</button>
|
|
39
|
+
}
|
|
40
|
+
@if (projects.hasValue()) {
|
|
41
|
+
@for (project of projects.value(); track project.id) {
|
|
42
|
+
<section [id]="'project-' + project.id">
|
|
43
|
+
<h2>{{ project.name }}</h2>
|
|
44
|
+
</section>
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
</div>
|
|
48
|
+
`,
|
|
49
|
+
styles: `
|
|
50
|
+
section {
|
|
51
|
+
min-height: 50vh;
|
|
52
|
+
}
|
|
53
|
+
`,
|
|
54
|
+
})
|
|
55
|
+
export class PaginatedProjectsComponent {
|
|
56
|
+
private readonly route = inject(ActivatedRoute);
|
|
57
|
+
private readonly router = inject(Router);
|
|
58
|
+
readonly count = signal(
|
|
59
|
+
Math.max(8, Math.min(100, Number(this.route.snapshot.queryParamMap.get('count')) || 8)),
|
|
60
|
+
);
|
|
61
|
+
readonly projects = rxResource({
|
|
62
|
+
params: () => this.count(),
|
|
63
|
+
stream: ({ params: count }) =>
|
|
64
|
+
of(
|
|
65
|
+
Array.from({ length: count }, (_, index) => ({
|
|
66
|
+
id: index + 1,
|
|
67
|
+
name: `Project ${index + 1}`,
|
|
68
|
+
})),
|
|
69
|
+
).pipe(delay(300)),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
async loadMore() {
|
|
73
|
+
this.count.update((count) => Math.min(100, count + 8));
|
|
74
|
+
await this.router.navigate([], {
|
|
75
|
+
relativeTo: this.route,
|
|
76
|
+
queryParams: { count: this.count() },
|
|
77
|
+
queryParamsHandling: 'merge',
|
|
78
|
+
preserveFragment: true,
|
|
79
|
+
replaceUrl: true,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Replace the simulated request with an Observable API call that returns the first `count` items. If your backend only exposes individual pages, load the required initial pages in the application's data layer and expose the combined result as ready **after all those pages have loaded**.
|
|
86
|
+
|
|
87
|
+
The source is initially loading, so the initial fragment is not consumed by a partial first page. On subsequent updates, `restoreOnDataChange` keeps the current anchor's top edge visible when needed. Stable item IDs are essential: inserting items above an anchor should not change that anchor's ID.
|
|
88
|
+
|
|
89
|
+
Use `preserveFragment: true` when persisting the loading range. `replaceUrl: true` avoids adding a history entry for each load-more operation. Preserve the count parameter on detail links and return links, as described in [Return to a list](./return-to-list.md).
|
|
90
|
+
|
|
91
|
+
### Choose the correction policy
|
|
92
|
+
|
|
93
|
+
The default preserves the current anchor after ready data changes. To let the user's viewport move naturally instead, disable data corrections for the region:
|
|
94
|
+
|
|
95
|
+
```html
|
|
96
|
+
<div magicScroll [scrollSource]="projects" [scrollOptions]="{ restoreOnDataChange: false }">
|
|
97
|
+
<!-- Render the loaded collection here. -->
|
|
98
|
+
</div>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Appending items does not automatically scroll to the newly added item. If you want that interaction, wait for its DOM to render and then call `scrollTo()` deliberately. The source's readiness is used for automatic restoration; it does not queue explicit calls to missing targets.
|
|
102
|
+
|
|
103
|
+
## TanStack Infinite Query
|
|
104
|
+
|
|
105
|
+
For `@tanstack/angular-query-experimental`, keep the same URL contract and pass the Infinite Query directly. Configure the query client as shown in [TanStack Query](./tanstack-query.md).
|
|
106
|
+
|
|
107
|
+
This component expects an endpoint accepting `offset` and `limit` and returning a project array. It requests the saved range in the first fetch, then adds eight items per subsequent page:
|
|
108
|
+
|
|
109
|
+
<!-- recipe-check: paginated-lists-1.ts -->
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { Component, computed, inject } from '@angular/core';
|
|
113
|
+
import { ActivatedRoute, Router } from '@angular/router';
|
|
114
|
+
import { injectInfiniteQuery } from '@tanstack/angular-query-experimental';
|
|
115
|
+
import { MagicScrollDirective } from '@devzwo/ngx-magic-scroll';
|
|
116
|
+
|
|
117
|
+
interface Project {
|
|
118
|
+
id: number;
|
|
119
|
+
name: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@Component({
|
|
123
|
+
selector: 'app-infinite-projects',
|
|
124
|
+
imports: [MagicScrollDirective],
|
|
125
|
+
template: `
|
|
126
|
+
<div magicScroll [scrollSource]="projects">
|
|
127
|
+
@if (projects.isLoading()) {
|
|
128
|
+
<p role="status">Loading projects…</p>
|
|
129
|
+
}
|
|
130
|
+
@if (projects.isError()) {
|
|
131
|
+
<p role="alert">Could not load projects.</p>
|
|
132
|
+
<button (click)="projects.refetch()">Retry</button>
|
|
133
|
+
}
|
|
134
|
+
@for (project of items(); track project.id) {
|
|
135
|
+
<section [id]="'project-' + project.id">
|
|
136
|
+
<h2>{{ project.name }}</h2>
|
|
137
|
+
</section>
|
|
138
|
+
}
|
|
139
|
+
</div>
|
|
140
|
+
<button [disabled]="!projects.hasNextPage() || projects.isFetching()" (click)="loadMore()">
|
|
141
|
+
{{ projects.isFetchingNextPage() ? 'Loading…' : 'Load more' }}
|
|
142
|
+
</button>
|
|
143
|
+
`,
|
|
144
|
+
styles: `
|
|
145
|
+
section {
|
|
146
|
+
min-height: 50vh;
|
|
147
|
+
}
|
|
148
|
+
`,
|
|
149
|
+
})
|
|
150
|
+
export class InfiniteProjectsComponent {
|
|
151
|
+
private readonly route = inject(ActivatedRoute);
|
|
152
|
+
private readonly router = inject(Router);
|
|
153
|
+
private readonly pageSize = 8;
|
|
154
|
+
private readonly initialCount = Math.max(
|
|
155
|
+
this.pageSize,
|
|
156
|
+
Math.min(100, Number(this.route.snapshot.queryParamMap.get('count')) || this.pageSize),
|
|
157
|
+
);
|
|
158
|
+
readonly projects = injectInfiniteQuery(() => ({
|
|
159
|
+
queryKey: ['projects', 'infinite', this.initialCount],
|
|
160
|
+
initialPageParam: 0,
|
|
161
|
+
queryFn: async ({ pageParam }): Promise<Project[]> => {
|
|
162
|
+
const limit = pageParam === 0 ? this.initialCount : Math.min(this.pageSize, 100 - pageParam);
|
|
163
|
+
const response = await fetch(`/api/projects?offset=${pageParam}&limit=${limit}`);
|
|
164
|
+
if (!response.ok) throw new Error('Could not load projects.');
|
|
165
|
+
return response.json();
|
|
166
|
+
},
|
|
167
|
+
getNextPageParam: (lastPage, pages, lastOffset) => {
|
|
168
|
+
const requested =
|
|
169
|
+
lastOffset === 0 ? this.initialCount : Math.min(this.pageSize, 100 - lastOffset);
|
|
170
|
+
const total = pages.reduce((count, page) => count + page.length, 0);
|
|
171
|
+
return lastPage.length === requested && total < 100 ? total : undefined;
|
|
172
|
+
},
|
|
173
|
+
}));
|
|
174
|
+
readonly items = computed(() => this.projects.data()?.pages.flat() ?? []);
|
|
175
|
+
|
|
176
|
+
async loadMore() {
|
|
177
|
+
if (!this.projects.hasNextPage() || this.projects.isFetching()) return;
|
|
178
|
+
const result = await this.projects.fetchNextPage();
|
|
179
|
+
if (result.isError) return;
|
|
180
|
+
const count = result.data?.pages.reduce((total, page) => total + page.length, 0);
|
|
181
|
+
if (count === undefined) return;
|
|
182
|
+
await this.router.navigate([], {
|
|
183
|
+
relativeTo: this.route,
|
|
184
|
+
queryParams: { count },
|
|
185
|
+
queryParamsHandling: 'merge',
|
|
186
|
+
preserveFragment: true,
|
|
187
|
+
replaceUrl: true,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The source returns the query's page structure; the template renders the flattened collection. Both read the same reactive query data. `initialCount` is captured once so persisting a new count does not change the running query's cache key and restart it. It belongs in the cache key because it changes the initial request's size.
|
|
194
|
+
|
|
195
|
+
This mirrors the HNE frontend's strategy of requesting the previously loaded range first. The example assumes deterministic ordering, an offset-based API and a short response indicating the end of the list. This example caps the loaded range at 100 items; adapt that limit and the backend contract to your application. Cursor APIs need a different restoration strategy, and cached or removed pages must still contain the target before initial readiness.
|
|
196
|
+
|
|
197
|
+
See [TanStack's Infinite Queries guide](https://tanstack.com/query/latest/docs/framework/angular/guides/infinite-queries) for page state and fetching APIs.
|
|
198
|
+
|
|
199
|
+
## Scope and related recipes
|
|
200
|
+
|
|
201
|
+
The library does not fetch missing pages, persist filters or virtualize the list. A target missing from the first ready render is not automatically searched for in later pages. Restore filters and the necessary data range before declaring the source ready; choose an application fallback when an item has been deleted or filtered out.
|
|
202
|
+
|
|
203
|
+
- [Return to a list](./return-to-list.md) covers the detail-navigation round trip.
|
|
204
|
+
- [Angular rxResource](./rx-resource.md) and [TanStack Query](./tanstack-query.md) explain the source contracts and error presentation.
|
|
205
|
+
- [NgRx Signal Store](./ngrx-signal-store.md) adapts a store that already owns pagination.
|
|
206
|
+
- [Angular Material Drawer](./material-drawer.md) adds the container layout used by the demo.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Return to a list
|
|
3
|
+
description: Restore a list section after detail navigation or a reload.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Goal
|
|
7
|
+
|
|
8
|
+
Open an item's detail page and return to the same list section, using either browser Back or an explicit link.
|
|
9
|
+
|
|
10
|
+
## Solution
|
|
11
|
+
|
|
12
|
+
Provide these routes through your application's existing `provideRouter(routes)` configuration:
|
|
13
|
+
|
|
14
|
+
<!-- recipe-check: app.routes.ts -->
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { Routes } from '@angular/router';
|
|
18
|
+
import { ProjectsComponent } from './projects.component';
|
|
19
|
+
import { ProjectDetailsComponent } from './project-details.component';
|
|
20
|
+
|
|
21
|
+
export const routes: Routes = [
|
|
22
|
+
{ path: 'projects', component: ProjectsComponent },
|
|
23
|
+
{ path: 'projects/:id', component: ProjectDetailsComponent },
|
|
24
|
+
];
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
In `projects.component.ts`, use stable IDs and capture the selected item before leaving the list:
|
|
28
|
+
|
|
29
|
+
<!-- recipe-check: projects.component.ts -->
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { Component, inject } from '@angular/core';
|
|
33
|
+
import { ActivatedRoute, Router } from '@angular/router';
|
|
34
|
+
import { MagicScrollDirective } from '@devzwo/ngx-magic-scroll';
|
|
35
|
+
|
|
36
|
+
@Component({
|
|
37
|
+
selector: 'app-projects',
|
|
38
|
+
imports: [MagicScrollDirective],
|
|
39
|
+
template: `
|
|
40
|
+
<div magicScroll>
|
|
41
|
+
@for (project of projects; track project.id) {
|
|
42
|
+
<section [id]="'project-' + project.id">
|
|
43
|
+
<h2>{{ project.name }}</h2>
|
|
44
|
+
<button (click)="openDetails(project.id)">Open details</button>
|
|
45
|
+
</section>
|
|
46
|
+
}
|
|
47
|
+
</div>
|
|
48
|
+
`,
|
|
49
|
+
styles: `
|
|
50
|
+
section {
|
|
51
|
+
min-height: 70vh;
|
|
52
|
+
}
|
|
53
|
+
`,
|
|
54
|
+
})
|
|
55
|
+
export class ProjectsComponent {
|
|
56
|
+
private readonly router = inject(Router);
|
|
57
|
+
private readonly route = inject(ActivatedRoute);
|
|
58
|
+
readonly projects = [
|
|
59
|
+
{ id: 1, name: 'Planning' },
|
|
60
|
+
{ id: 2, name: 'Delivery' },
|
|
61
|
+
{ id: 3, name: 'Review' },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
async openDetails(id: number) {
|
|
65
|
+
const recorded = await this.router.navigate([], {
|
|
66
|
+
relativeTo: this.route,
|
|
67
|
+
fragment: 'project-' + id,
|
|
68
|
+
queryParamsHandling: 'preserve',
|
|
69
|
+
replaceUrl: true,
|
|
70
|
+
});
|
|
71
|
+
if (!recorded) return;
|
|
72
|
+
await this.router.navigate(['/projects', id], { queryParamsHandling: 'preserve' });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`openDetails()` first records the clicked item's fragment in the list's current history entry, then waits for that URL update before navigating to the detail page. This avoids relying on the scroll-spy debounce or racing two navigations. Both this update and scroll-spy updates replace the current entry; detail navigation creates the next entry.
|
|
78
|
+
|
|
79
|
+
In `project-details.component.ts`, use the route ID for an explicit return link:
|
|
80
|
+
|
|
81
|
+
<!-- recipe-check: project-details.component.ts -->
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { Component, inject } from '@angular/core';
|
|
85
|
+
import { ActivatedRoute, RouterLink } from '@angular/router';
|
|
86
|
+
|
|
87
|
+
@Component({
|
|
88
|
+
selector: 'app-project-details',
|
|
89
|
+
imports: [RouterLink],
|
|
90
|
+
template: `
|
|
91
|
+
<h1>Project {{ id }}</h1>
|
|
92
|
+
<a routerLink="/projects" [fragment]="'project-' + id" queryParamsHandling="preserve">
|
|
93
|
+
Back to this project
|
|
94
|
+
</a>
|
|
95
|
+
`,
|
|
96
|
+
})
|
|
97
|
+
export class ProjectDetailsComponent {
|
|
98
|
+
readonly id = inject(ActivatedRoute).snapshot.paramMap.get('id');
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Browser Back restores the fragment in the previous list entry. The explicit link also works when the detail page was opened directly. Query parameters are preserved so list settings can survive the round trip. Opening or reloading `/projects#project-2` restores the section after the list renders.
|
|
103
|
+
|
|
104
|
+
## Scope and related recipes
|
|
105
|
+
|
|
106
|
+
This example recreates a static list. Initial restoration runs once per `magicScroll` instance; a missing target at the first ready render is not retried automatically. It restores an anchor position rather than an exact pixel offset.
|
|
107
|
+
|
|
108
|
+
- [Angular rxResource](./rx-resource.md), [NgRx Signal Store](./ngrx-signal-store.md) and [TanStack Query](./tanstack-query.md) supply asynchronous readiness.
|
|
109
|
+
- [Paginated lists](./paginated-lists.md) restores the loaded data range before scrolling.
|
|
110
|
+
- [Anchor navigation](./scroll-to-anchor.md) covers interaction options and navigation highlighting.
|