feature/responsive #22

Merged
ilia merged 49 commits from feature/responsive into main 2026-02-09 06:49:25 +00:00
8 changed files with 321 additions and 15 deletions
Showing only changes of commit a26bcbecff - Show all commits

View File

@@ -12,6 +12,7 @@
*/
import { BreadcrumbHeader } from '$entities/Breadcrumb';
import favicon from '$shared/assets/favicon.svg';
import { ResponsiveProvider } from '$shared/lib';
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
import { TypographyMenu } from '$widgets/TypographySettings';
@@ -42,6 +43,7 @@ let { children }: Props = $props();
>
</svelte:head>
<ResponsiveProvider>
<div id="app-root" class="min-h-screen flex flex-col bg-background">
<header>
<BreadcrumbHeader />
@@ -57,3 +59,4 @@ let { children }: Props = $props();
<!-- </ScrollArea> -->
<footer></footer>
</div>
</ResponsiveProvider>

View File

@@ -15,6 +15,7 @@ export interface Control {
export class TypographyControlManager {
#controls = new SvelteMap<string, Control>();
#sizeMultiplier = $state(1);
constructor(configs: ControlModel[]) {
configs.forEach(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => {
@@ -37,7 +38,8 @@ export class TypographyControlManager {
}
get size() {
return this.#controls.get('font_size')?.instance.value;
const size = this.#controls.get('font_size')?.instance.value;
return size === undefined ? undefined : size * this.#sizeMultiplier;
}
get height() {
@@ -47,6 +49,10 @@ export class TypographyControlManager {
get spacing() {
return this.#controls.get('letter_spacing')?.instance.value;
}
set multiplier(value: number) {
this.#sizeMultiplier = value;
}
}
/**

View File

@@ -3,8 +3,32 @@
Contains controls for setting up font properties.
-->
<script lang="ts">
import type { ResponsiveManager } from '$shared/lib';
import { ComboControl } from '$shared/ui';
import { getContext } from 'svelte';
import { controlManager } from '../model';
const responsive = getContext<ResponsiveManager>('responsive');
$effect(() => {
if (!responsive) {
return;
}
switch (true) {
case responsive.isMobile:
controlManager.multiplier = 0.5;
break;
case responsive.isTablet:
controlManager.multiplier = 0.75;
break;
case responsive.isDesktop:
controlManager.multiplier = 1;
break;
default:
controlManager.multiplier = 1;
break;
}
});
</script>
<div class="py-2 px-10 flex flex-row items-center gap-2">

View File

@@ -0,0 +1,231 @@
// $shared/lib/createResponsiveManager.svelte.ts
/**
* Breakpoint definitions following common device sizes
* Customize these values to match your design system
*/
export interface Breakpoints {
/** Mobile devices (portrait phones) */
mobile: number;
/** Tablet portrait */
tabletPortrait: number;
/** Tablet landscape */
tablet: number;
/** Desktop */
desktop: number;
/** Large desktop */
desktopLarge: number;
}
/**
* Default breakpoints (matches common Tailwind-like breakpoints)
*/
const DEFAULT_BREAKPOINTS: Breakpoints = {
mobile: 640, // sm
tabletPortrait: 768, // md
tablet: 1024, // lg
desktop: 1280, // xl
desktopLarge: 1536, // 2xl
};
/**
* Orientation type
*/
export type Orientation = 'portrait' | 'landscape';
/**
* Creates a reactive responsive manager that tracks viewport size and breakpoints.
*
* Provides reactive getters for:
* - Current breakpoint detection (isMobile, isTablet, etc.)
* - Viewport dimensions (width, height)
* - Device orientation (portrait/landscape)
* - Custom breakpoint matching
*
* @param customBreakpoints - Optional custom breakpoint values
* @returns Responsive manager instance with reactive properties
*
* @example
* ```svelte
* <script lang="ts">
* const responsive = createResponsiveManager();
* </script>
*
* {#if responsive.isMobile}
* <MobileNav />
* {:else if responsive.isTablet}
* <TabletNav />
* {:else}
* <DesktopNav />
* {/if}
*
* <p>Width: {responsive.width}px</p>
* <p>Orientation: {responsive.orientation}</p>
* ```
*/
export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>) {
const breakpoints: Breakpoints = {
...DEFAULT_BREAKPOINTS,
...customBreakpoints,
};
// Reactive state
let width = $state(typeof window !== 'undefined' ? window.innerWidth : 0);
let height = $state(typeof window !== 'undefined' ? window.innerHeight : 0);
// Derived breakpoint states
const isMobile = $derived(width < breakpoints.mobile);
const isTabletPortrait = $derived(
width >= breakpoints.mobile && width < breakpoints.tabletPortrait,
);
const isTablet = $derived(
width >= breakpoints.tabletPortrait && width < breakpoints.desktop,
);
const isDesktop = $derived(
width >= breakpoints.desktop && width < breakpoints.desktopLarge,
);
const isDesktopLarge = $derived(width >= breakpoints.desktopLarge);
// Convenience groupings
const isMobileOrTablet = $derived(width < breakpoints.desktop);
const isTabletOrDesktop = $derived(width >= breakpoints.tabletPortrait);
// Orientation
const orientation = $derived<Orientation>(height > width ? 'portrait' : 'landscape');
const isPortrait = $derived(orientation === 'portrait');
const isLandscape = $derived(orientation === 'landscape');
// Touch device detection (best effort)
const isTouchDevice = $derived(
typeof window !== 'undefined'
&& ('ontouchstart' in window || navigator.maxTouchPoints > 0),
);
/**
* Initialize responsive tracking
* Call this in an $effect or component mount
*/
function init() {
if (typeof window === 'undefined') return;
const handleResize = () => {
width = window.innerWidth;
height = window.innerHeight;
};
// Use ResizeObserver for more accurate tracking
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(document.documentElement);
// Fallback to window resize event
window.addEventListener('resize', handleResize, { passive: true });
// Initial measurement
handleResize();
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', handleResize);
};
}
/**
* Check if current width matches a custom breakpoint
* @param min - Minimum width (inclusive)
* @param max - Maximum width (exclusive)
*/
function matches(min: number, max?: number): boolean {
if (max !== undefined) {
return width >= min && width < max;
}
return width >= min;
}
/**
* Get the current breakpoint name
*/
const currentBreakpoint = $derived<keyof Breakpoints | 'xs'>(
(() => {
if (isMobile) return 'mobile';
if (isTabletPortrait) return 'tabletPortrait';
if (isTablet) return 'tablet';
if (isDesktop) return 'desktop';
if (isDesktopLarge) return 'desktopLarge';
return 'xs'; // Fallback for very small screens
})(),
);
return {
// Dimensions
get width() {
return width;
},
get height() {
return height;
},
// Standard breakpoints
get isMobile() {
return isMobile;
},
get isTabletPortrait() {
return isTabletPortrait;
},
get isTablet() {
return isTablet;
},
get isDesktop() {
return isDesktop;
},
get isDesktopLarge() {
return isDesktopLarge;
},
// Convenience groupings
get isMobileOrTablet() {
return isMobileOrTablet;
},
get isTabletOrDesktop() {
return isTabletOrDesktop;
},
// Orientation
get orientation() {
return orientation;
},
get isPortrait() {
return isPortrait;
},
get isLandscape() {
return isLandscape;
},
// Device capabilities
get isTouchDevice() {
return isTouchDevice;
},
// Current breakpoint
get currentBreakpoint() {
return currentBreakpoint;
},
// Methods
init,
matches,
// Breakpoint values (for custom logic)
breakpoints,
};
}
export const responsiveManager = createResponsiveManager();
if (typeof window !== 'undefined') {
responsiveManager.init();
}
/**
* Type for the responsive manager instance
*/
export type ResponsiveManager = ReturnType<typeof createResponsiveManager>;

View File

@@ -33,3 +33,9 @@ export {
} from './createCharacterComparison/createCharacterComparison.svelte';
export { createPersistentStore } from './createPersistentStore/createPersistentStore.svelte';
export {
createResponsiveManager,
type ResponsiveManager,
responsiveManager,
} from './createResponsiveManager/createResponsiveManager.svelte';

View File

@@ -6,6 +6,7 @@ export {
createEntityStore,
createFilter,
createPersistentStore,
createResponsiveManager,
createTypographyControl,
createVirtualizer,
type Entity,
@@ -14,6 +15,8 @@ export {
type FilterModel,
type LineData,
type Property,
type ResponsiveManager,
responsiveManager,
type TypographyControl,
type VirtualItem,
type Virtualizer,
@@ -23,3 +26,5 @@ export {
export { splitArray } from './utils';
export { springySlideFade } from './transitions';
export { ResponsiveProvider } from './providers';

View File

@@ -0,0 +1,30 @@
<!--
Component: ResponsiveProvider
Provides a responsive manager to all children
-->
<script lang="ts">
import {
type ResponsiveManager,
createResponsiveManager,
} from '$shared/lib/helpers';
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
}
let { children }: Props = $props();
const responsive = createResponsiveManager();
// Initialize and cleanup
$effect(() => {
return responsive.init();
});
// Provide to all children
setContext('responsive', responsive);
</script>
{@render children()}

View File

@@ -0,0 +1 @@
export { default as ResponsiveProvider } from './ResponsiveProvider/ResponsiveProvider.svelte';