refactor(helpers): modernize reactive helpers and add tests
This commit is contained in:
@@ -1,66 +1,96 @@
|
||||
// $shared/lib/createResponsiveManager.svelte.ts
|
||||
/**
|
||||
* Responsive breakpoint tracking using Svelte 5 runes
|
||||
*
|
||||
* Provides reactive viewport dimensions and breakpoint detection that
|
||||
* automatically updates on window resize. Includes touch device detection
|
||||
* and orientation tracking.
|
||||
*
|
||||
* Default breakpoints match Tailwind CSS:
|
||||
* - xs: < 640px (mobile)
|
||||
* - sm: 640px (mobile)
|
||||
* - md: 768px (tablet portrait)
|
||||
* - lg: 1024px (tablet)
|
||||
* - xl: 1280px (desktop)
|
||||
* - 2xl: 1536px (desktop large)
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { responsiveManager } from '$shared/lib/helpers';
|
||||
*
|
||||
* // Singleton is auto-initialized
|
||||
* </script>
|
||||
*
|
||||
* {#if responsiveManager.isMobile}
|
||||
* <MobileNav />
|
||||
* {:else}
|
||||
* <DesktopNav />
|
||||
* {/if}
|
||||
*
|
||||
* <p>Viewport: {responsiveManager.width}x{responsiveManager.height}</p>
|
||||
* <p>Breakpoint: {responsiveManager.currentBreakpoint}</p>
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Breakpoint definitions following common device sizes
|
||||
* Customize these values to match your design system
|
||||
* Breakpoint definitions for responsive design
|
||||
*
|
||||
* Values represent the minimum width (in pixels) for each breakpoint.
|
||||
* Customize to match your design system's breakpoints.
|
||||
*/
|
||||
export interface Breakpoints {
|
||||
/** Mobile devices (portrait phones) */
|
||||
/** Mobile devices - default 640px */
|
||||
mobile: number;
|
||||
/** Tablet portrait */
|
||||
/** Tablet portrait - default 768px */
|
||||
tabletPortrait: number;
|
||||
/** Tablet landscape */
|
||||
/** Tablet landscape - default 1024px */
|
||||
tablet: number;
|
||||
/** Desktop */
|
||||
/** Desktop - default 1280px */
|
||||
desktop: number;
|
||||
/** Large desktop */
|
||||
/** Large desktop - default 1536px */
|
||||
desktopLarge: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default breakpoints (matches common Tailwind-like breakpoints)
|
||||
* Default breakpoint values (Tailwind CSS compatible)
|
||||
*/
|
||||
const DEFAULT_BREAKPOINTS: Breakpoints = {
|
||||
mobile: 640, // sm
|
||||
tabletPortrait: 768, // md
|
||||
tablet: 1024, // lg
|
||||
desktop: 1280, // xl
|
||||
desktopLarge: 1536, // 2xl
|
||||
mobile: 640,
|
||||
tabletPortrait: 768,
|
||||
tablet: 1024,
|
||||
desktop: 1280,
|
||||
desktopLarge: 1536,
|
||||
};
|
||||
|
||||
/**
|
||||
* Orientation type
|
||||
* Device orientation type
|
||||
*/
|
||||
export type Orientation = 'portrait' | 'landscape';
|
||||
|
||||
/**
|
||||
* Creates a reactive responsive manager that tracks viewport size and breakpoints.
|
||||
* Creates a responsive manager for tracking viewport state
|
||||
*
|
||||
* Provides reactive getters for:
|
||||
* - Current breakpoint detection (isMobile, isTablet, etc.)
|
||||
* - Viewport dimensions (width, height)
|
||||
* - Device orientation (portrait/landscape)
|
||||
* - Custom breakpoint matching
|
||||
* Tracks viewport dimensions, calculates breakpoint states, and detects
|
||||
* device capabilities (touch, orientation). Uses ResizeObserver for
|
||||
* accurate tracking and falls back to window resize events.
|
||||
*
|
||||
* @param customBreakpoints - Optional custom breakpoint values
|
||||
* @returns Responsive manager instance with reactive properties
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* const responsive = createResponsiveManager();
|
||||
* </script>
|
||||
* ```ts
|
||||
* // Use defaults
|
||||
* const responsive = createResponsiveManager();
|
||||
*
|
||||
* {#if responsive.isMobile}
|
||||
* <MobileNav />
|
||||
* {:else if responsive.isTablet}
|
||||
* <TabletNav />
|
||||
* {:else}
|
||||
* <DesktopNav />
|
||||
* {/if}
|
||||
* // Custom breakpoints
|
||||
* const custom = createResponsiveManager({
|
||||
* mobile: 480,
|
||||
* desktop: 1024
|
||||
* });
|
||||
*
|
||||
* <p>Width: {responsive.width}px</p>
|
||||
* <p>Orientation: {responsive.orientation}</p>
|
||||
* // In component
|
||||
* $: isMobile = responsive.isMobile;
|
||||
* $: cols = responsive.isDesktop ? 3 : 1;
|
||||
* ```
|
||||
*/
|
||||
export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>) {
|
||||
@@ -69,7 +99,7 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
||||
...customBreakpoints,
|
||||
};
|
||||
|
||||
// Reactive state
|
||||
// Reactive viewport dimensions
|
||||
let width = $state(typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||
let height = $state(typeof window !== 'undefined' ? window.innerHeight : 0);
|
||||
|
||||
@@ -90,12 +120,12 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
||||
const isMobileOrTablet = $derived(width < breakpoints.desktop);
|
||||
const isTabletOrDesktop = $derived(width >= breakpoints.tabletPortrait);
|
||||
|
||||
// Orientation
|
||||
// Orientation detection
|
||||
const orientation = $derived<Orientation>(height > width ? 'portrait' : 'landscape');
|
||||
const isPortrait = $derived(orientation === 'portrait');
|
||||
const isLandscape = $derived(orientation === 'landscape');
|
||||
|
||||
// Touch device detection (best effort)
|
||||
// Touch device detection (best effort heuristic)
|
||||
const isTouchDevice = $derived(
|
||||
typeof window !== 'undefined'
|
||||
&& ('ontouchstart' in window || navigator.maxTouchPoints > 0),
|
||||
@@ -103,7 +133,11 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
||||
|
||||
/**
|
||||
* Initialize responsive tracking
|
||||
* Call this in an $effect or component mount
|
||||
*
|
||||
* Sets up ResizeObserver on document.documentElement and falls back
|
||||
* to window resize event listener. Returns cleanup function.
|
||||
*
|
||||
* @returns Cleanup function to remove listeners
|
||||
*/
|
||||
function init() {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -130,9 +164,17 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current width matches a custom breakpoint
|
||||
* Check if current viewport matches a custom breakpoint range
|
||||
*
|
||||
* @param min - Minimum width (inclusive)
|
||||
* @param max - Maximum width (exclusive)
|
||||
* @param max - Optional maximum width (exclusive)
|
||||
* @returns true if viewport width matches the range
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* responsive.matches(768, 1024); // true for tablet only
|
||||
* responsive.matches(1280); // true for desktop and larger
|
||||
* ```
|
||||
*/
|
||||
function matches(min: number, max?: number): boolean {
|
||||
if (max !== undefined) {
|
||||
@@ -142,7 +184,7 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current breakpoint name
|
||||
* Current breakpoint name based on viewport width
|
||||
*/
|
||||
const currentBreakpoint = $derived<keyof Breakpoints | 'xs'>(
|
||||
(() => {
|
||||
@@ -158,16 +200,17 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
||||
case isDesktopLarge:
|
||||
return 'desktopLarge';
|
||||
default:
|
||||
return 'xs'; // Fallback for very small screens
|
||||
return 'xs';
|
||||
}
|
||||
})(),
|
||||
);
|
||||
|
||||
return {
|
||||
// Dimensions
|
||||
/** Viewport width in pixels */
|
||||
get width() {
|
||||
return width;
|
||||
},
|
||||
/** Viewport height in pixels */
|
||||
get height() {
|
||||
return height;
|
||||
},
|
||||
@@ -227,6 +270,12 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton responsive manager instance
|
||||
*
|
||||
* Auto-initializes on the client side. Use this throughout the app
|
||||
* rather than creating multiple instances.
|
||||
*/
|
||||
export const responsiveManager = createResponsiveManager();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Tests for createResponsiveManager
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { createResponsiveManager } from './createResponsiveManager.svelte';
|
||||
|
||||
describe('createResponsiveManager', () => {
|
||||
describe('initialization', () => {
|
||||
it('should create with default breakpoints', () => {
|
||||
const manager = createResponsiveManager();
|
||||
|
||||
expect(manager.breakpoints).toEqual({
|
||||
mobile: 640,
|
||||
tabletPortrait: 768,
|
||||
tablet: 1024,
|
||||
desktop: 1280,
|
||||
desktopLarge: 1536,
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge custom breakpoints with defaults', () => {
|
||||
const manager = createResponsiveManager({ mobile: 480, desktop: 1200 });
|
||||
|
||||
expect(manager.breakpoints.mobile).toBe(480);
|
||||
expect(manager.breakpoints.desktop).toBe(1200);
|
||||
expect(manager.breakpoints.tablet).toBe(1024); // default preserved
|
||||
});
|
||||
|
||||
it('should have initial width and height from window', () => {
|
||||
const manager = createResponsiveManager();
|
||||
|
||||
// In test environment, window dimensions come from jsdom/mocks
|
||||
expect(typeof manager.width).toBe('number');
|
||||
expect(typeof manager.height).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matches', () => {
|
||||
it('should return true when width is above min', () => {
|
||||
const manager = createResponsiveManager();
|
||||
|
||||
// width is 0 in node env (no window), so matches(0) should be true
|
||||
expect(manager.matches(0)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when width is below min', () => {
|
||||
const manager = createResponsiveManager();
|
||||
|
||||
// width is 0, so matches(100) should be false
|
||||
expect(manager.matches(100)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle range with max', () => {
|
||||
const manager = createResponsiveManager();
|
||||
|
||||
// width is 0, so matches(0, 100) should be true (0 >= 0 && 0 < 100)
|
||||
expect(manager.matches(0, 100)).toBe(true);
|
||||
// matches(1, 100) should be false (0 >= 1 is false)
|
||||
expect(manager.matches(1, 100)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('breakpoint states at width 0', () => {
|
||||
it('should report isMobile when width is 0', () => {
|
||||
const manager = createResponsiveManager();
|
||||
|
||||
expect(manager.isMobile).toBe(true);
|
||||
expect(manager.isTabletPortrait).toBe(false);
|
||||
expect(manager.isTablet).toBe(false);
|
||||
expect(manager.isDesktop).toBe(false);
|
||||
expect(manager.isDesktopLarge).toBe(false);
|
||||
});
|
||||
|
||||
it('should report correct convenience groupings', () => {
|
||||
const manager = createResponsiveManager();
|
||||
|
||||
expect(manager.isMobileOrTablet).toBe(true);
|
||||
expect(manager.isTabletOrDesktop).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('orientation', () => {
|
||||
it('should detect portrait when height > width', () => {
|
||||
// Default: width=0, height=0 => not portrait (0 > 0 is false)
|
||||
const manager = createResponsiveManager();
|
||||
|
||||
expect(manager.orientation).toBe('landscape');
|
||||
expect(manager.isLandscape).toBe(true);
|
||||
expect(manager.isPortrait).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should return undefined in non-browser environment', () => {
|
||||
const manager = createResponsiveManager();
|
||||
const cleanup = manager.init();
|
||||
|
||||
// In node test env, window is undefined so init returns early
|
||||
expect(cleanup).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user