diff --git a/projects/design-angular-kit/src/lib/services/breakpoint.service.spec.ts b/projects/design-angular-kit/src/lib/services/breakpoint.service.spec.ts new file mode 100644 index 000000000..218b9ec5b --- /dev/null +++ b/projects/design-angular-kit/src/lib/services/breakpoint.service.spec.ts @@ -0,0 +1,58 @@ +import { TestBed } from '@angular/core/testing'; + +import { ItBreakpointService } from './breakpoint.service'; + +describe('ItBreakpointService', () => { + let service: ItBreakpointService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ItBreakpointService); + }); + + afterEach(() => { + service.ngOnDestroy(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should expose currentBreakpoint as a signal', () => { + const bp = service.currentBreakpoint(); + expect(['xs', 'sm', 'md', 'lg', 'xl', 'xxl']).toContain(bp); + }); + + it('should expose currentMinWidth as a computed signal', () => { + const width = service.currentMinWidth(); + expect(typeof width).toBe('number'); + expect(width).toBeGreaterThanOrEqual(0); + }); + + it('should return a computed signal from isAbove()', () => { + const isAboveXs = service.isAbove('xs'); + // Every viewport is at or above xs (0px) + expect(isAboveXs()).toBe(true); + }); + + it('should return a computed signal from isBelow()', () => { + const isBelowXs = service.isBelow('xs'); + // No viewport is below xs (0px) + expect(isBelowXs()).toBe(false); + }); + + it('should have consistent isAbove/isBelow for current breakpoint', () => { + const bp = service.currentBreakpoint(); + const isAboveCurrent = service.isAbove(bp); + expect(isAboveCurrent()).toBe(true); + }); + + it('should not throw on destroy', () => { + expect(() => service.ngOnDestroy()).not.toThrow(); + }); + + it('should survive double destroy', () => { + service.ngOnDestroy(); + expect(() => service.ngOnDestroy()).not.toThrow(); + }); +}); diff --git a/projects/design-angular-kit/src/lib/services/breakpoint.service.ts b/projects/design-angular-kit/src/lib/services/breakpoint.service.ts new file mode 100644 index 000000000..39dfdb15a --- /dev/null +++ b/projects/design-angular-kit/src/lib/services/breakpoint.service.ts @@ -0,0 +1,99 @@ +import { Injectable, OnDestroy, signal, computed } from '@angular/core'; + +export type BootstrapBreakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; + +const BREAKPOINTS: { name: BootstrapBreakpoint; minWidth: number }[] = [ + { name: 'xxl', minWidth: 1400 }, + { name: 'xl', minWidth: 1200 }, + { name: 'lg', minWidth: 992 }, + { name: 'md', minWidth: 768 }, + { name: 'sm', minWidth: 576 }, + { name: 'xs', minWidth: 0 }, +]; + +/** + * Service that exposes Angular Signals reflecting the current Bootstrap Italia + * viewport breakpoint. Uses `window.matchMedia` for efficient, listener-based + * breakpoint detection without polling. + * + * @example + * ```ts + * @Component({ ... }) + * export class MyComponent { + * private breakpointService = inject(ItBreakpointService); + * currentBreakpoint = this.breakpointService.currentBreakpoint; + * isDesktop = this.breakpointService.isAbove('lg'); + * } + * ``` + */ +@Injectable({ providedIn: 'root' }) +export class ItBreakpointService implements OnDestroy { + private readonly _currentBreakpoint = signal('xs'); + private readonly _mediaQueries: { mql: MediaQueryList; handler: (e: MediaQueryListEvent) => void }[] = []; + + /** Signal emitting the current Bootstrap breakpoint name. */ + readonly currentBreakpoint = this._currentBreakpoint.asReadonly(); + + /** Signal emitting the min-width in pixels for the current breakpoint. */ + readonly currentMinWidth = computed(() => { + const bp = this._currentBreakpoint(); + return BREAKPOINTS.find(b => b.name === bp)?.minWidth ?? 0; + }); + + constructor() { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return; + } + + for (const bp of BREAKPOINTS) { + if (bp.minWidth === 0) continue; // xs has no media query — it's the fallback + + const mql = window.matchMedia(`(min-width: ${bp.minWidth}px)`); + const handler = () => this._recalculate(); + mql.addEventListener('change', handler); + this._mediaQueries.push({ mql, handler }); + } + + this._recalculate(); + } + + ngOnDestroy(): void { + for (const { mql, handler } of this._mediaQueries) { + mql.removeEventListener('change', handler); + } + this._mediaQueries.length = 0; + } + + /** + * Returns a computed Signal that is `true` when the viewport is at or above + * the given breakpoint. + */ + isAbove(breakpoint: BootstrapBreakpoint) { + const threshold = BREAKPOINTS.find(b => b.name === breakpoint)?.minWidth ?? 0; + return computed(() => this.currentMinWidth() >= threshold); + } + + /** + * Returns a computed Signal that is `true` when the viewport is below + * the given breakpoint. + */ + isBelow(breakpoint: BootstrapBreakpoint) { + const threshold = BREAKPOINTS.find(b => b.name === breakpoint)?.minWidth ?? 0; + return computed(() => this.currentMinWidth() < threshold); + } + + private _recalculate(): void { + for (const bp of BREAKPOINTS) { + if (bp.minWidth === 0) { + this._currentBreakpoint.set('xs'); + return; + } + const mql = this._mediaQueries.find(q => q.mql.media === `(min-width: ${bp.minWidth}px)`); + if (mql?.mql.matches) { + this._currentBreakpoint.set(bp.name); + return; + } + } + this._currentBreakpoint.set('xs'); + } +} diff --git a/projects/design-angular-kit/src/public_api.ts b/projects/design-angular-kit/src/public_api.ts index 344db7a64..3fb592546 100644 --- a/projects/design-angular-kit/src/public_api.ts +++ b/projects/design-angular-kit/src/public_api.ts @@ -148,3 +148,6 @@ export * from './lib/utils/regex'; // Validators export * from './lib/validators/it-validators'; + +// Services +export * from './lib/services/breakpoint.service'; diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 8deba7cb9..8cf909894 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -80,6 +80,7 @@ const routes: Routes = [ { path: 'chip', loadChildren: () => import('src/app/chip/chip.module').then(m => m.ChipModule) }, { path: 'dimmer', loadChildren: () => import('src/app/dimmer/dimmer.module').then(m => m.DimmerModule) }, { path: 'callout', loadChildren: () => import('src/app/callout/callout.module').then(m => m.CalloutModule) }, + { path: 'breakpoint', loadChildren: () => import('src/app/breakpoint/breakpoint.module').then(m => m.BreakpointModule) }, { path: 'steppers', loadChildren: () => import('src/app/steppers/steppers.module').then(m => m.SteppersModule) }, { path: 'notifications', loadChildren: () => import('src/app/notifications/notifications.module').then(m => m.NotificationsModule) }, { path: 'rating', loadChildren: () => import('src/app/rating/rating.module').then(m => m.RatingModule) }, diff --git a/src/app/breakpoint/breakpoint-example/breakpoint-example.component.html b/src/app/breakpoint/breakpoint-example/breakpoint-example.component.html new file mode 100644 index 000000000..1bbaa7371 --- /dev/null +++ b/src/app/breakpoint/breakpoint-example/breakpoint-example.component.html @@ -0,0 +1,29 @@ +

Breakpoint corrente

+
+
+ + + + + + + + + + + + + + + + + + + +
currentBreakpoint() + {{ currentBreakpoint() }} +
currentMinWidth(){{ currentMinWidth() }}px
isAbove('lg'){{ isDesktop() }}
isBelow('md'){{ isMobile() }}
+
+
+ +

Ridimensiona la finestra del browser per vedere i valori aggiornarsi in tempo reale.

diff --git a/src/app/breakpoint/breakpoint-example/breakpoint-example.component.ts b/src/app/breakpoint/breakpoint-example/breakpoint-example.component.ts new file mode 100644 index 000000000..3635cc49b --- /dev/null +++ b/src/app/breakpoint/breakpoint-example/breakpoint-example.component.ts @@ -0,0 +1,16 @@ +import { Component, inject } from '@angular/core'; +import { ItBreakpointService } from 'design-angular-kit'; + +@Component({ + selector: 'it-breakpoint-example', + templateUrl: './breakpoint-example.component.html', + standalone: false, +}) +export class BreakpointExampleComponent { + private readonly breakpointService = inject(ItBreakpointService); + + currentBreakpoint = this.breakpointService.currentBreakpoint; + currentMinWidth = this.breakpointService.currentMinWidth; + isDesktop = this.breakpointService.isAbove('lg'); + isMobile = this.breakpointService.isBelow('md'); +} diff --git a/src/app/breakpoint/breakpoint-examples/breakpoint-examples.component.tpl b/src/app/breakpoint/breakpoint-examples/breakpoint-examples.component.tpl new file mode 100644 index 000000000..12ca395d4 --- /dev/null +++ b/src/app/breakpoint/breakpoint-examples/breakpoint-examples.component.tpl @@ -0,0 +1,13 @@ +{% from "../../macro.template.njk" import sanitize as sanitize %} + +{% set html %} + {% include "../breakpoint-example/breakpoint-example.component.html" %} +{% endset %} + +{% set typescript %} + {% include "../breakpoint-example/breakpoint-example.component.ts" %} +{% endset %} + + + + diff --git a/src/app/breakpoint/breakpoint-examples/breakpoint-examples.component.ts b/src/app/breakpoint/breakpoint-examples/breakpoint-examples.component.ts new file mode 100644 index 000000000..8a57bcfc3 --- /dev/null +++ b/src/app/breakpoint/breakpoint-examples/breakpoint-examples.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'it-breakpoint-examples', + templateUrl: './breakpoint-examples.component.html', + standalone: false, +}) +export class BreakpointExamplesComponent {} diff --git a/src/app/breakpoint/breakpoint-index/breakpoint-index.component.html b/src/app/breakpoint/breakpoint-index/breakpoint-index.component.html new file mode 100644 index 000000000..ed9a2c3d8 --- /dev/null +++ b/src/app/breakpoint/breakpoint-index/breakpoint-index.component.html @@ -0,0 +1,7 @@ +

Breakpoint Service

+

Servizio Angular che espone Signal reattivi per il breakpoint corrente della viewport Bootstrap Italia.

+ + + + + diff --git a/src/app/breakpoint/breakpoint-index/breakpoint-index.component.ts b/src/app/breakpoint/breakpoint-index/breakpoint-index.component.ts new file mode 100644 index 000000000..33aa2c8e5 --- /dev/null +++ b/src/app/breakpoint/breakpoint-index/breakpoint-index.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'it-breakpoint-index', + templateUrl: './breakpoint-index.component.html', + standalone: false, +}) +export class BreakpointIndexComponent {} diff --git a/src/app/breakpoint/breakpoint-routing.module.ts b/src/app/breakpoint/breakpoint-routing.module.ts new file mode 100644 index 000000000..8a9330e7c --- /dev/null +++ b/src/app/breakpoint/breakpoint-routing.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { BreakpointIndexComponent } from './breakpoint-index/breakpoint-index.component'; + +const routes: Routes = [{ path: '', component: BreakpointIndexComponent }]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class BreakpointRoutingModule {} diff --git a/src/app/breakpoint/breakpoint.module.ts b/src/app/breakpoint/breakpoint.module.ts new file mode 100644 index 000000000..c9fbaf44a --- /dev/null +++ b/src/app/breakpoint/breakpoint.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { BreakpointRoutingModule } from './breakpoint-routing.module'; +import { BreakpointIndexComponent } from './breakpoint-index/breakpoint-index.component'; +import { BreakpointExampleComponent } from './breakpoint-example/breakpoint-example.component'; +import { BreakpointExamplesComponent } from './breakpoint-examples/breakpoint-examples.component'; + +@NgModule({ + imports: [CommonModule, SharedModule, BreakpointRoutingModule], + declarations: [BreakpointIndexComponent, BreakpointExampleComponent, BreakpointExamplesComponent], +}) +export class BreakpointModule {}