Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
});
});
99 changes: 99 additions & 0 deletions projects/design-angular-kit/src/lib/services/breakpoint.service.ts
Original file line number Diff line number Diff line change
@@ -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<BootstrapBreakpoint>('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');
}
}
3 changes: 3 additions & 0 deletions projects/design-angular-kit/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,6 @@ export * from './lib/utils/regex';

// Validators
export * from './lib/validators/it-validators';

// Services
export * from './lib/services/breakpoint.service';
1 change: 1 addition & 0 deletions src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<h4>Breakpoint corrente</h4>
<div class="row mb-3">
<div class="col-12 col-md-6">
<table class="table">
<tbody>
<tr>
<th>currentBreakpoint()</th>
<td>
<strong>{{ currentBreakpoint() }}</strong>
</td>
</tr>
<tr>
<th>currentMinWidth()</th>
<td>{{ currentMinWidth() }}px</td>
</tr>
<tr>
<th>isAbove('lg')</th>
<td>{{ isDesktop() }}</td>
</tr>
<tr>
<th>isBelow('md')</th>
<td>{{ isMobile() }}</td>
</tr>
</tbody>
</table>
</div>
</div>

<p>Ridimensiona la finestra del browser per vedere i valori aggiornarsi in tempo reale.</p>
Original file line number Diff line number Diff line change
@@ -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');
}
Original file line number Diff line number Diff line change
@@ -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 %}

<it-breakpoint-example></it-breakpoint-example>

<it-source-display html="{$ sanitize(html) $}" typescript="{$ sanitize(typescript) $}"></it-source-display>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Component } from '@angular/core';

@Component({
selector: 'it-breakpoint-examples',
templateUrl: './breakpoint-examples.component.html',
standalone: false,
})
export class BreakpointExamplesComponent {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<h1 class="bd-title">Breakpoint Service</h1>
<p class="bd-lead">Servizio Angular che espone Signal reattivi per il breakpoint corrente della viewport Bootstrap Italia.</p>
<it-tab-container>
<it-tab-item label="Esempi" active="true" class="pt-3">
<it-breakpoint-examples></it-breakpoint-examples>
</it-tab-item>
</it-tab-container>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Component } from '@angular/core';

@Component({
selector: 'it-breakpoint-index',
templateUrl: './breakpoint-index.component.html',
standalone: false,
})
export class BreakpointIndexComponent {}
11 changes: 11 additions & 0 deletions src/app/breakpoint/breakpoint-routing.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
13 changes: 13 additions & 0 deletions src/app/breakpoint/breakpoint.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading