Skip to content

Commit 15c8caa

Browse files
authored
feat: navscroll component
1 parent 7e81151 commit 15c8caa

29 files changed

+900
-18
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { AsyncPipe } from '@angular/common';
2+
import { ChangeDetectionStrategy, Component, DestroyRef, EventEmitter, inject, Input, OnInit, Output, ViewChild } from '@angular/core';
3+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
4+
import {
5+
IsActiveMatchOptions,
6+
NavigationEnd,
7+
Router,
8+
Event as RouterEvent,
9+
RouterLink,
10+
RouterLinkActive,
11+
RouterLinkWithHref,
12+
Scroll,
13+
} from '@angular/router';
14+
import { AsyncSubject, filter, switchMap, tap } from 'rxjs';
15+
import { ItNavscrollListItemsComponent } from './navscroll-list-items.component';
16+
import { NavscrollItem } from './navscroll.model';
17+
import { NavscrollStore } from './navscroll.store';
18+
19+
const ROUTER_LINK_ACTIVE_OPTIONS: IsActiveMatchOptions = {
20+
fragment: 'exact',
21+
paths: 'exact',
22+
queryParams: 'exact',
23+
matrixParams: 'exact',
24+
};
25+
26+
@Component({
27+
selector: 'it-navscroll-list-item',
28+
standalone: true,
29+
imports: [RouterLink, RouterLinkActive, RouterLinkWithHref, ItNavscrollListItemsComponent, AsyncPipe],
30+
changeDetection: ChangeDetectionStrategy.OnPush,
31+
template: `
32+
<a
33+
class="nav-link"
34+
[class.active]="active | async"
35+
[routerLink]="[]"
36+
routerLinkActive
37+
[fragment]="item?.href"
38+
[routerLinkActiveOptions]="routerLinkActiveOptions"
39+
ariaCurrentWhenActive="page"
40+
#rtl="routerLinkActive"
41+
(click)="clickHandler($event)"
42+
><span>{{ item?.title }}</span></a
43+
>
44+
`,
45+
})
46+
export class ItNavscrollListItemComponent implements OnInit {
47+
@Input() item!: NavscrollItem;
48+
49+
@Output() readonly checkActive = new EventEmitter<NavscrollItem>();
50+
51+
@ViewChild('rtl')
52+
readonly rtl: any;
53+
54+
readonly routerLinkActiveOptions = ROUTER_LINK_ACTIVE_OPTIONS;
55+
56+
readonly #initIsActive = new AsyncSubject<NavscrollItem>();
57+
58+
readonly active = this.#initIsActive.asObservable().pipe(switchMap(item => this.#store.isActive$(item)));
59+
60+
readonly #router = inject(Router);
61+
62+
readonly #store = inject(NavscrollStore);
63+
64+
readonly #destroyRef = inject(DestroyRef);
65+
66+
ngOnInit() {
67+
this.#initIsActiveSub();
68+
this.#router.events
69+
.pipe(
70+
takeUntilDestroyed(this.#destroyRef),
71+
filter((event: RouterEvent) => {
72+
const isNavigationEndEvent = event instanceof NavigationEnd;
73+
const isScrollEvent = event instanceof Scroll && (event as Scroll).routerEvent instanceof NavigationEnd;
74+
return isNavigationEndEvent || isScrollEvent;
75+
}),
76+
tap(() => {
77+
if (this.rtl?.isActive) {
78+
this.#store.setActive(this.item);
79+
}
80+
})
81+
)
82+
.subscribe();
83+
}
84+
85+
clickHandler(event: Event) {
86+
event.preventDefault();
87+
this.#store.selectMenuItem();
88+
this.#router.navigate([], { fragment: this.item.href });
89+
}
90+
91+
#initIsActiveSub() {
92+
this.#initIsActive.next(this.item);
93+
this.#initIsActive.complete();
94+
}
95+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { JsonPipe, NgTemplateOutlet } from '@angular/common';
2+
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
3+
import { RouterLink, RouterLinkActive, RouterLinkWithHref } from '@angular/router';
4+
import { ItNavscrollListItemComponent } from './navscroll-list-item.component';
5+
import { NavscrollItems } from './navscroll.model';
6+
7+
@Component({
8+
selector: 'it-navscroll-list-items',
9+
standalone: true,
10+
imports: [NgTemplateOutlet, RouterLink, RouterLinkActive, RouterLinkWithHref, JsonPipe, ItNavscrollListItemComponent],
11+
changeDetection: ChangeDetectionStrategy.OnPush,
12+
template: `
13+
<ul class="link-list">
14+
@for (item of items; track item.href) {
15+
<li class="nav-item">
16+
<it-navscroll-list-item [item]="item"></it-navscroll-list-item>
17+
@if (item.childs?.length) {
18+
<it-navscroll-list-items [items]="item.childs"></it-navscroll-list-items>
19+
}
20+
</li>
21+
}
22+
</ul>
23+
`,
24+
})
25+
export class ItNavscrollListItemsComponent {
26+
@Input() items!: NavscrollItems;
27+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<div class="container py-lg-5">
2+
<div class="row">
3+
<div class="col-12 col-lg-4">
4+
<div class="it-navscroll-sticky" [ngClass]="{ 'it-navscroll-sticky-mobile': isMobile | async }" data-bs-stackable="true">
5+
<nav
6+
class="navbar it-navscroll-wrapper navbar-expand-lg"
7+
[class.it-top-navscroll]="alignment === 'top'"
8+
[class.it-bottom-navscroll]="alignment === 'bottom'"
9+
[class.it-left-side]="borderPosition === 'left'"
10+
[class.it-right-side]="borderPosition === 'right'"
11+
[class.theme-dark-mobile]="theme === 'dark'"
12+
[class.theme-dark-desktop]="theme === 'dark'">
13+
<button
14+
class="custom-navbar-toggler"
15+
type="button"
16+
aria-controls="navbarNav"
17+
aria-expanded="false"
18+
aria-label="Toggle navigation"
19+
data-bs-toggle="navbarcollapsible"
20+
data-bs-target="#navbarNav"
21+
#toggleButtonRef>
22+
<span class="it-list"></span>{{ selectedTitle | async }}
23+
</button>
24+
<div class="progress custom-navbar-progressbar">
25+
<div
26+
class="progress-bar it-navscroll-progressbar"
27+
role="progressbar"
28+
[style.width.%]="progressBarValue | async"
29+
[attr.aria-valuenow]="progressBarValue | async"
30+
aria-valuemin="0"
31+
aria-valuemax="100"></div>
32+
</div>
33+
<div class="navbar-collapsable" id="navbarNav">
34+
<div class="overlay"></div>
35+
<div class="close-div visually-hidden">
36+
<button class="btn close-menu" type="button"><span class="it-close"></span>Chiudi</button>
37+
</div>
38+
<button type="button" class="it-back-button btn w-100 text-start">
39+
<svg class="icon icon-sm icon-primary align-top">
40+
<use
41+
href="/bootstrap-italia/dist/svg/sprites.svg#it-chevron-left"
42+
xlink:href="/bootstrap-italia/dist/svg/sprites.svg#it-chevron-left"></use>
43+
</svg>
44+
<span>Indietro</span>
45+
</button>
46+
<div class="menu-wrapper">
47+
<div class="link-list-wrapper">
48+
<h3>{{ header }}</h3>
49+
<div class="progress">
50+
<div
51+
class="progress-bar it-navscroll-progressbar"
52+
role="progressbar"
53+
[style.width.%]="progressBarValue | async"
54+
[attr.aria-valuenow]="progressBarValue | async"
55+
aria-valuemin="0"
56+
aria-valuemax="100"></div>
57+
</div>
58+
<it-navscroll-list-items [items]="items"></it-navscroll-list-items>
59+
</div>
60+
</div>
61+
</div>
62+
</nav>
63+
</div>
64+
</div>
65+
<div class="col-12 col-lg-8 it-page-sections-container">
66+
<ng-container
67+
*ngTemplateOutlet="pageSectionsTemplate ? pageSectionsTemplate : defaultPageSectionsTemplate; context: { items: items }">
68+
</ng-container>
69+
</div>
70+
</div>
71+
</div>
72+
73+
<ng-template #defaultPageSectionsTemplate let-items="items">
74+
@for (item of items; track item.href) {
75+
<ng-container *ngTemplateOutlet="paragraphTemplate; context: { item: item, level: 1 }"></ng-container>
76+
}
77+
</ng-template>
78+
79+
<ng-template #paragraphTemplate let-item="item" let-level="level" let-nextLevel="level+1">
80+
@switch (level) {
81+
@case (1) {
82+
<h2 class="it-page-section" id="{{ item.href }}">{{ item.title }}</h2>
83+
}
84+
@case (2) {
85+
<h3 class="it-page-section" id="{{ item.href }}">{{ item.title }}</h3>
86+
}
87+
@case (3) {
88+
<h4 class="it-page-section" id="{{ item.href }}">{{ item.title }}</h4>
89+
}
90+
@case (4) {
91+
<h5 class="it-page-section" id="{{ item.href }}">{{ item.title }}</h5>
92+
}
93+
@default {
94+
<h6 class="it-page-section" id="{{ item.href }}">{{ item.title }}</h6>
95+
}
96+
}
97+
<p>{{ item.text }}</p>
98+
@for (item of item.childs; track item.href) {
99+
<ng-container *ngTemplateOutlet="paragraphTemplate; context: { item: item, level: nextLevel }"></ng-container>
100+
}
101+
</ng-template>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.it-navscroll-sticky {
2+
// data-bs-toggle="sticky"
3+
position: sticky;
4+
top: 0;
5+
}
6+
7+
.it-navscroll-sticky-mobile {
8+
z-index: 1020;
9+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { AsyncPipe, NgClass, NgTemplateOutlet, ViewportScroller } from '@angular/common';
2+
import {
3+
ChangeDetectionStrategy,
4+
Component,
5+
DestroyRef,
6+
ElementRef,
7+
HostListener,
8+
inject,
9+
Input,
10+
OnInit,
11+
TemplateRef,
12+
ViewChild,
13+
} from '@angular/core';
14+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
15+
import { RouterLink, RouterLinkActive, RouterLinkWithHref } from '@angular/router';
16+
import { delay, filter, map, tap, withLatestFrom } from 'rxjs';
17+
import { ItNavscrollListItemsComponent } from './navscroll-list-items.component';
18+
import { NavscrollItem } from './navscroll.model';
19+
import { NavscrollStore } from './navscroll.store';
20+
21+
/**
22+
* Navscroll
23+
* @description Show a list of links to anchor of the document.
24+
*/
25+
@Component({
26+
selector: 'it-navscroll',
27+
standalone: true,
28+
imports: [
29+
ItNavscrollListItemsComponent,
30+
AsyncPipe,
31+
NgTemplateOutlet,
32+
RouterLink,
33+
RouterLinkActive,
34+
RouterLinkWithHref,
35+
AsyncPipe,
36+
NgClass,
37+
],
38+
templateUrl: './navscroll.component.html',
39+
styleUrl: './navscroll.component.scss',
40+
changeDetection: ChangeDetectionStrategy.OnPush,
41+
providers: [NavscrollStore],
42+
})
43+
export class ItNavscrollComponent implements OnInit {
44+
/**
45+
* Header of the Navscroll
46+
*/
47+
@Input() readonly header = '';
48+
/**
49+
* A list of links
50+
*/
51+
@Input() readonly items!: Array<NavscrollItem>;
52+
/**
53+
* Border position
54+
* @default left
55+
*/
56+
@Input() readonly borderPosition: 'left' | 'right' = 'left';
57+
/**
58+
* Alignment
59+
* @default top
60+
*/
61+
@Input() readonly alignment: 'top' | 'bottom' = 'top';
62+
63+
/**
64+
* Theme
65+
* @default light
66+
*/
67+
@Input() readonly theme: 'light' | 'dark' = 'light';
68+
69+
/**
70+
* Custom template for the content section
71+
*/
72+
@Input()
73+
pageSectionsTemplate?: TemplateRef<any>;
74+
75+
@HostListener('window:scroll', ['$event']) // for window scroll events
76+
onScroll() {
77+
const sectionContainer = this.#elementRef.nativeElement.querySelector('.it-page-sections-container');
78+
this.#store.updateProgressBar(sectionContainer);
79+
}
80+
81+
@HostListener('window:resize', ['$event'])
82+
onResize() {
83+
this.#setMobile();
84+
}
85+
86+
@ViewChild('toggleButtonRef')
87+
readonly toggleButtonRef!: ElementRef<HTMLButtonElement>;
88+
89+
readonly #store = inject(NavscrollStore);
90+
91+
readonly #scroller = inject(ViewportScroller);
92+
93+
readonly #destroyRef = inject(DestroyRef);
94+
95+
readonly #elementRef = inject(ElementRef);
96+
97+
readonly selectedTitle = this.#store.selected.pipe(map(selected => selected?.title ?? ''));
98+
99+
readonly progressBarValue = this.#store.progressBar;
100+
101+
readonly isMobile = this.#store.isMobile;
102+
103+
constructor() {
104+
this.#store.menuItemSelected
105+
.pipe(
106+
takeUntilDestroyed(),
107+
withLatestFrom(this.isMobile),
108+
tap(v => {
109+
const isMobile = v[1];
110+
if (isMobile) {
111+
this.toggleButtonRef.nativeElement.click();
112+
}
113+
})
114+
)
115+
.subscribe();
116+
}
117+
118+
ngOnInit(): void {
119+
this.#initViewScrollerSubscription();
120+
this.#store.init(this.items);
121+
this.#setMobile();
122+
}
123+
124+
#initViewScrollerSubscription() {
125+
this.#store.selected
126+
.pipe(
127+
takeUntilDestroyed(this.#destroyRef),
128+
filter(selected => Boolean(selected)),
129+
map(v => v as NavscrollItem),
130+
delay(0), //WA
131+
tap({
132+
next: ({ href }) => {
133+
this.#scroller.scrollToAnchor(href);
134+
},
135+
})
136+
)
137+
.subscribe();
138+
}
139+
140+
#setMobile() {
141+
this.#store.setMobile(window);
142+
}
143+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export interface NavscrollItem {
2+
title: string;
3+
text: string;
4+
href: string;
5+
childs: NavscrollItems;
6+
}
7+
8+
export type NavscrollItems = Array<NavscrollItem>;
9+
10+
export interface NavscrollItemActive {
11+
active: boolean;
12+
}

0 commit comments

Comments
 (0)