@devzwo/ngx-magic-scroll 1.0.0 → 1.1.1

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.
@@ -0,0 +1,87 @@
1
+ ---
2
+ title: Angular rxResource
3
+ description: Restore anchors after Observable data loads through rxResource.
4
+ ---
5
+
6
+ ## Goal
7
+
8
+ Open a URL such as `/projects#project-2` before the projects have loaded, then restore the section once its DOM exists.
9
+
10
+ ## Solution
11
+
12
+ Import `MagicScrollDirective` and pass the `rxResource` itself to `scrollSource`. This self-contained example simulates an Observable API request; replace `of(...).pipe(delay(...))` with your service's Observable.
13
+
14
+ <!-- recipe-check: rx-resource-0.ts -->
15
+
16
+ ```ts
17
+ import { Component } from '@angular/core';
18
+ import { rxResource } from '@angular/core/rxjs-interop';
19
+ import { delay, of } from 'rxjs';
20
+ import { MagicScrollDirective } from '@devzwo/ngx-magic-scroll';
21
+
22
+ @Component({
23
+ selector: 'app-resource-projects',
24
+ imports: [MagicScrollDirective],
25
+ template: `
26
+ <button (click)="projects.reload()">Reload</button>
27
+ <div magicScroll [scrollSource]="projects">
28
+ @if (projects.isLoading()) {
29
+ <p role="status">Loading projects…</p>
30
+ }
31
+ @if (projects.error()) {
32
+ <p role="alert">Could not load projects. Try reloading.</p>
33
+ }
34
+ @if (projects.hasValue()) {
35
+ @if (projects.value().length === 0) {
36
+ <p>No projects.</p>
37
+ }
38
+ @for (project of projects.value(); track project.id) {
39
+ <section [id]="'project-' + project.id">
40
+ <h2>{{ project.name }}</h2>
41
+ </section>
42
+ }
43
+ }
44
+ </div>
45
+ `,
46
+ styles: `
47
+ section {
48
+ min-height: 70vh;
49
+ }
50
+ `,
51
+ })
52
+ export class ResourceProjectsComponent {
53
+ readonly projects = rxResource({
54
+ stream: () =>
55
+ of([
56
+ { id: 1, name: 'Planning' },
57
+ { id: 2, name: 'Delivery' },
58
+ { id: 3, name: 'Review' },
59
+ ]).pipe(delay(300)),
60
+ });
61
+ }
62
+ ```
63
+
64
+ The library reads the resource's loading and value state and restores the initial fragment after the first ready render. Passing `projects.value()` instead would discard the resource's loading information. Guard template calls to `value()` with `hasValue()` because reading an errored resource without a value can throw.
65
+
66
+ An empty array is a successful, ready result; an error without a value is not. Your application owns requests, retries and error presentation. Angular `resource` can be passed directly in the same way.
67
+
68
+ ### Preserve position on later updates
69
+
70
+ Reloading or changing resource parameters can replace the content. After initialization, ready source changes preserve the **current** fragment with an instant correction when the anchor's top edge is no longer visible. They do not repeatedly restore the original fragment.
71
+
72
+ If that behavior does not fit your page, disable it for the region:
73
+
74
+ ```html
75
+ <div magicScroll [scrollSource]="projects" [scrollOptions]="{ restoreOnDataChange: false }">
76
+ <!-- Render projects here. -->
77
+ </div>
78
+ ```
79
+
80
+ ## Scope and related recipes
81
+
82
+ This recipe covers one resource and an unpaginated list. It does not retrieve missing pages or choose a new anchor when a filtered item disappears.
83
+
84
+ - [Return to a list](./return-to-list.md) adds detail navigation and return links.
85
+ - [Paginated lists](./paginated-lists.md) coordinates initial loading with a saved data range.
86
+ - [NgRx Signal Store](./ngrx-signal-store.md) adapts existing store state.
87
+ - [TanStack Query](./tanstack-query.md) uses a query result directly.
@@ -0,0 +1,148 @@
1
+ ---
2
+ title: Anchor navigation
3
+ description: Scroll to sections, choose anchors and highlight your navigation.
4
+ ---
5
+
6
+ ## Goal
7
+
8
+ Build a small section navigation that scrolls on click and highlights the current section as the user scrolls.
9
+
10
+ ## Solution
11
+
12
+ Start with an Angular application that provides Router, as described in [Getting started](../getting-started.md). This complete component uses static content:
13
+
14
+ <!-- recipe-check: scroll-to-anchor-0.ts -->
15
+
16
+ ```ts
17
+ import { Component } from '@angular/core';
18
+ import {
19
+ MagicScrollDirective,
20
+ ScrollAnchorDirective,
21
+ provideMagicScroll,
22
+ } from '@devzwo/ngx-magic-scroll';
23
+
24
+ @Component({
25
+ selector: 'app-project-sections',
26
+ imports: [MagicScrollDirective, ScrollAnchorDirective],
27
+ providers: [provideMagicScroll({ anchorPrefixes: ['project-'] })],
28
+ template: `
29
+ <nav aria-label="Project sections">
30
+ @for (id of sections; track id) {
31
+ <button
32
+ [attr.aria-current]="scroll.activeAnchor() === id ? 'location' : null"
33
+ (click)="scroll.scrollTo(id)"
34
+ >
35
+ {{ id }}
36
+ </button>
37
+ }
38
+ </nav>
39
+
40
+ <div magicScroll #scroll="magicScroll">
41
+ <section id="overview" scrollAnchor>
42
+ <h2>Overview</h2>
43
+ </section>
44
+ <section id="project-planning">
45
+ <h2>Planning</h2>
46
+ <h3 id="planning-notes">Notes</h3>
47
+ </section>
48
+ <section [scrollAnchor]="'project-delivery'">
49
+ <h2>Delivery</h2>
50
+ </section>
51
+ </div>
52
+ `,
53
+ styles: `
54
+ section {
55
+ min-height: 70vh;
56
+ }
57
+ button[aria-current='location'] {
58
+ font-weight: bold;
59
+ }
60
+ `,
61
+ })
62
+ export class ProjectSectionsComponent {
63
+ readonly sections = ['overview', 'project-planning', 'project-delivery'];
64
+ }
65
+ ```
66
+
67
+ `anchorPrefixes` includes the project sections and excludes unrelated IDs such as `planning-notes`. The explicit `scrollAnchor` includes `overview` despite its different prefix; the bound form also assigns the element's ID. Without a prefix, all descendant IDs participate and the markers are optional.
68
+
69
+ `scrollTo()` scrolls smoothly by default and synchronizes the fragment. `activeAnchor()` exposes that fragment; scroll-spy updates use a 500 ms debounce by default. IDs must be unique in the document and remain stable across renders.
70
+
71
+ ### Override one interaction
72
+
73
+ For a particular button, pass per-call options:
74
+
75
+ ```html
76
+ <button
77
+ (click)="scroll.scrollTo('project-delivery', {
78
+ behavior: 'instant',
79
+ ignoreWhenInView: 'full'
80
+ })"
81
+ >
82
+ Show delivery if it is not fully visible
83
+ </button>
84
+ ```
85
+
86
+ `scrollTo()` returns `false` when the target is unavailable, outside this region, excluded by anchor selection or skipped by the visibility rule. Each anchor belongs to its closest `magicScroll` region, so a parent cannot scroll to a nested region's anchors.
87
+
88
+ ### Accessible focus transfer
89
+
90
+ When navigating via keyboard shortcuts, skip links, or error notifications, transferring DOM focus to the target element is critical for accessibility. Pass `{ focus: true }` to `scrollTo()` to transfer keyboard focus without secondary layout shifts:
91
+
92
+ ```html
93
+ <button
94
+ (click)="scroll.scrollTo('project-delivery', {
95
+ behavior: 'smooth',
96
+ focus: true
97
+ })"
98
+ >
99
+ Jump to delivery and shift focus
100
+ </button>
101
+ ```
102
+
103
+ - **Safe Focus**: Invocations use `focus({ preventScroll: true })` so browser-native focus scrolling does not conflict with calculated header offsets.
104
+ - **Temporary Tabindex Management**: Naturally focusable elements (links, buttons, inputs) and elements with existing `tabindex` receive focus directly. Non-focusable container elements (e.g. `<section>`, `<div>`) receive a temporary `tabindex="-1"`, which is removed if focus fails, on `blur`, or when the directive is destroyed. Consumer changes to `tabindex` are preserved.
105
+ - **Smooth Scroll Coordination & Cancellation**: For effective smooth scrolling (including `behavior: 'auto'` when CSS `scroll-behavior` is smooth), focus is applied upon scroll completion (`scrollend`). No-movement requests focus immediately. A newer navigation, user scroll, Tab key, focus change, or pointer handoff cancels stale pending focus.
106
+ - **Target validation**: The target must still be connected and eligible in this region when focus is applied. Pending focus is canceled if scrolling does not complete within five seconds.
107
+
108
+ ```mermaid
109
+ sequenceDiagram
110
+ autonumber
111
+ actor User as User / Trigger
112
+ participant Directive as MagicScrollDirective
113
+ participant Service as ScrollService
114
+ participant Target as Anchor Element
115
+
116
+ User->>Directive: scrollTo('section-id', { focus: true, behavior: 'smooth' })
117
+ Directive->>Service: scroll('section-id', options)
118
+ alt Target resolved, but scroll is skipped or has no movement
119
+ Directive->>Directive: applyFocus(target)
120
+ else Target missing or excluded
121
+ Directive->>Directive: no focus transfer
122
+ else Scrolling Initiated
123
+ Service-->>Directive: returns true
124
+ alt Effective behavior is smooth
125
+ Directive->>Directive: scheduleFocus(target)
126
+ Note over Directive: Cancelled on newer navigation or user handoff
127
+ Directive->>Directive: on scrollend -> applyFocus(target)
128
+ else Instant behavior
129
+ Directive->>Directive: applyFocus(target)
130
+ end
131
+ end
132
+
133
+ opt Non-focusable Target (no tabindex)
134
+ Directive->>Target: setAttribute('tabindex', '-1')
135
+ Directive->>Target: addEventListener('blur', removeTabindex, { once: true })
136
+ end
137
+ Directive->>Target: target.focus({ preventScroll: true })
138
+ Directive-->>User: returns boolean
139
+ ```
140
+
141
+ ## Scope and related recipes
142
+
143
+ The target must already be rendered; this recipe does not load data or missing sections.
144
+
145
+ - [Return to a list](./return-to-list.md) adds navigation between routed pages.
146
+ - [Angular rxResource](./rx-resource.md) waits for asynchronously rendered anchors.
147
+ - [Sticky master–detail](./sticky-master-detail.md) adds header offsets and sticky navigation.
148
+ - [Angular Material Drawer](./material-drawer.md) moves scrolling into a nested container.
@@ -0,0 +1,138 @@
1
+ ---
2
+ title: Sticky master–detail
3
+ description: Combine document scrolling, a measured header and sticky section navigation.
4
+ ---
5
+
6
+ ## Goal
7
+
8
+ Build a portfolio page with a sticky header, an independently scrollable project index and details that scroll with the document. Keep navigation and offsets aligned when the header changes height.
9
+
10
+ ## Solution
11
+
12
+ This compact version of the master–detail demo uses static projects so the layout is easy to see. Angular Router must be provided by the application.
13
+
14
+ <!-- recipe-check: sticky-master-detail-0.ts -->
15
+
16
+ ```ts
17
+ import { Component, signal } from '@angular/core';
18
+ import { MagicScrollDirective, provideMagicScroll } from '@devzwo/ngx-magic-scroll';
19
+
20
+ @Component({
21
+ selector: 'app-portfolio',
22
+ imports: [MagicScrollDirective],
23
+ providers: [
24
+ provideMagicScroll({
25
+ anchorPrefixes: ['project-'],
26
+ headerSelector: '.portfolio-header',
27
+ headerOffset: 16,
28
+ }),
29
+ ],
30
+ template: `
31
+ <header class="portfolio-header">
32
+ <strong>Portfolio</strong>
33
+ <button (click)="expanded.set(!expanded())">Toggle toolbar</button>
34
+ @if (expanded()) {
35
+ <p>Additional project tools</p>
36
+ }
37
+ </header>
38
+
39
+ <div class="layout" [style.--scroll-offset]="scroll.offset() + 'px'">
40
+ <aside class="master">
41
+ <nav aria-label="Projects">
42
+ @for (project of projects; track project.id) {
43
+ <button
44
+ [attr.aria-current]="
45
+ scroll.activeAnchor() === 'project-' + project.id ? 'location' : null
46
+ "
47
+ (click)="scroll.scrollTo('project-' + project.id)"
48
+ >
49
+ {{ project.name }}
50
+ </button>
51
+ }
52
+ </nav>
53
+ </aside>
54
+
55
+ <div class="details" magicScroll #scroll="magicScroll">
56
+ @for (project of projects; track project.id) {
57
+ <section [id]="'project-' + project.id">
58
+ <h2>{{ project.name }}</h2>
59
+ <p>Planning, milestones and project details.</p>
60
+ </section>
61
+ }
62
+ </div>
63
+ </div>
64
+ `,
65
+ styles: `
66
+ :host {
67
+ display: block;
68
+ }
69
+ .portfolio-header {
70
+ position: sticky;
71
+ top: 0;
72
+ z-index: 10;
73
+ padding: 16px;
74
+ background: white;
75
+ }
76
+ .layout {
77
+ display: grid;
78
+ grid-template-columns: minmax(120px, 200px) minmax(0, 1fr);
79
+ align-items: start;
80
+ gap: 24px;
81
+ }
82
+ .master {
83
+ position: sticky;
84
+ top: var(--scroll-offset);
85
+ max-height: calc(100dvh - var(--scroll-offset) - 16px);
86
+ overflow-y: auto;
87
+ }
88
+ nav {
89
+ display: flex;
90
+ flex-direction: column;
91
+ }
92
+ button[aria-current='location'] {
93
+ font-weight: bold;
94
+ }
95
+ .details {
96
+ min-width: 0;
97
+ }
98
+ section {
99
+ min-height: 70vh;
100
+ }
101
+ `,
102
+ })
103
+ export class PortfolioComponent {
104
+ readonly expanded = signal(false);
105
+ readonly projects = Array.from({ length: 12 }, (_, index) => ({
106
+ id: index + 1,
107
+ name: `Project ${index + 1}`,
108
+ }));
109
+ }
110
+ ```
111
+
112
+ The detail region has no constrained height or scrolling overflow, so it uses document scrolling. The sidebar scrolls independently and sits outside the region; its elements are not detail anchors.
113
+
114
+ `headerSelector` measures the matching header and observes height changes. `headerOffset` adds a gap below it. `scroll.offset()` exposes the sum, which positions the sidebar and limits its height. Use a selector identifying the intended header uniquely.
115
+
116
+ Resize and header-height corrections are enabled by default and adjust the current anchor instantly. Set `preserveOnResize: false` in `scrollOptions` if those corrections are unwanted; the measured offset still remains available.
117
+
118
+ ### Add asynchronously loaded details
119
+
120
+ Replace the static collection with your data source and bind it to the detail region. For a resource:
121
+
122
+ ```html
123
+ <div class="details" magicScroll #scroll="magicScroll" [scrollSource]="projects">
124
+ @if (projects.hasValue()) { @for (project of projects.value(); track project.id) {
125
+ <section [id]="'project-' + project.id">{{ project.name }}</section>
126
+ } }
127
+ </div>
128
+ ```
129
+
130
+ Update the sidebar to render the same loaded collection. Initial restoration then waits for the data rather than consuming the fragment before the detail anchors exist.
131
+
132
+ ## Scope and related recipes
133
+
134
+ The layout follows the zwoPRO-inspired demo, but leaves application forms and store architecture out of the example. It uses the document for detail scrolling; CSS still determines whether an ancestor becomes the scroll container.
135
+
136
+ - [Angular rxResource](./rx-resource.md), [NgRx Signal Store](./ngrx-signal-store.md) and [TanStack Query](./tanstack-query.md) implement the corresponding data sources.
137
+ - [Anchor navigation](./scroll-to-anchor.md) explains explicit anchor selection and per-click overrides.
138
+ - [Angular Material Drawer](./material-drawer.md) uses a constrained content container instead of document scrolling.
@@ -0,0 +1,96 @@
1
+ ---
2
+ title: TanStack Query
3
+ description: Bind a TanStack Angular Query result directly to scrollSource.
4
+ ---
5
+
6
+ ## Goal
7
+
8
+ Restore an anchor after a query loads and keep the current section aligned when cached data is refreshed.
9
+
10
+ ## Solution
11
+
12
+ If your application uses `@tanstack/angular-query-experimental`, its reactive `data()` and `isLoading()` getters already match `ScrollDataSource`. Pass the query result directly; the library does not depend on TanStack.
13
+
14
+ Register the query client alongside your existing Angular Router providers in `app.config.ts`:
15
+
16
+ <!-- recipe-check: tanstack-query-0.ts -->
17
+
18
+ ```ts
19
+ import { ApplicationConfig } from '@angular/core';
20
+ import { provideRouter } from '@angular/router';
21
+ import { provideTanStackQuery, QueryClient } from '@tanstack/angular-query-experimental';
22
+
23
+ export const appConfig: ApplicationConfig = {
24
+ providers: [provideRouter([]), provideTanStackQuery(new QueryClient())],
25
+ };
26
+ ```
27
+
28
+ Use your actual routes in place of `[]`. Then render a query in your component:
29
+
30
+ <!-- recipe-check: tanstack-query-1.ts -->
31
+
32
+ ```ts
33
+ import { Component } from '@angular/core';
34
+ import { injectQuery } from '@tanstack/angular-query-experimental';
35
+ import { MagicScrollDirective } from '@devzwo/ngx-magic-scroll';
36
+
37
+ interface Project {
38
+ id: number;
39
+ name: string;
40
+ }
41
+
42
+ @Component({
43
+ selector: 'app-query-projects',
44
+ imports: [MagicScrollDirective],
45
+ template: `
46
+ <div magicScroll [scrollSource]="projects">
47
+ @if (projects.isLoading()) {
48
+ <p role="status">Loading projects…</p>
49
+ }
50
+ @if (projects.isError()) {
51
+ <p role="alert">Could not load projects.</p>
52
+ <button (click)="projects.refetch()">Retry</button>
53
+ }
54
+ @if (projects.data()?.length === 0) {
55
+ <p>No projects.</p>
56
+ }
57
+ @for (project of projects.data(); track project.id) {
58
+ <section [id]="'project-' + project.id">
59
+ <h2>{{ project.name }}</h2>
60
+ </section>
61
+ }
62
+ </div>
63
+ `,
64
+ styles: `
65
+ section {
66
+ min-height: 70vh;
67
+ }
68
+ `,
69
+ })
70
+ export class QueryProjectsComponent {
71
+ readonly projects = injectQuery(() => ({
72
+ queryKey: ['projects'],
73
+ queryFn: async (): Promise<Project[]> => {
74
+ const response = await fetch('/api/projects');
75
+ if (!response.ok) throw new Error('Could not load projects.');
76
+ return response.json();
77
+ },
78
+ }));
79
+ }
80
+ ```
81
+
82
+ The application endpoint `/api/projects` returns a project array. A failed response throws so TanStack can expose its error state. The query retains responsibility for retries, caching and refetching.
83
+
84
+ The library waits while the query is initially loading or `data()` is undefined. Cached data can be ready immediately. Keep the rendered list in place during a background fetch: `isFetching()` also includes background activity, whereas `isLoading()` describes the initial fetch without data. A disabled query without data still waits because its data is undefined.
85
+
86
+ When ready data changes after initialization, `restoreOnDataChange` preserves the current anchor by default. This is separate from cache policy and does not cause a request.
87
+
88
+ ## Scope and related recipes
89
+
90
+ This recipe uses one ordinary query. It does not retrieve extra pages, restore filter state or force a refetch before initial restoration.
91
+
92
+ - [Paginated lists](./paginated-lists.md#tanstack-infinite-query) adds an Infinite Query and a saved loading range, following the application-list pattern in the HNE frontend.
93
+ - [Return to a list](./return-to-list.md) adds detail navigation.
94
+ - [Angular Material Drawer](./material-drawer.md) places the same query-backed list inside a scrollable drawer layout.
95
+
96
+ See the official [TanStack Angular quick start](https://tanstack.com/query/latest/docs/framework/angular/quick-start) and [query guide](https://tanstack.com/query/latest/docs/framework/angular/guides/queries) for setup and query behavior. The Angular package is experimental; use the APIs provided by your installed version.