Files
loyaly-merchant/src/shared/hooks/useBreakpoint.ts

126 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import {useSyncExternalStore} from 'react';
/**
* Shell breakpoints.
*
* These are LAYOUT decisions — they choose whether Loyaly AI is an inline
* panel or a sheet, whether a table renders as rows or cards. Those are React
* tree differences that CSS cannot express.
*
* Anything that CAN be done in CSS should be: use Grid's responsive `columns`
* or a Tailwind breakpoint instead of reaching for this hook. Every consumer
* here costs a client-side re-render on resize.
*/
export type Breakpoint =
| 'mobile'
| 'tablet'
| 'laptop'
| 'desktop'
| 'ultrawide';
/**
* Ordered widest-first — `read()` returns the first match.
*
* mobile <640 phones
* tablet 640–1024
* laptop 1024–1440
* desktop 1440–1920
* ultrawide >1920 content gets capped rather than stretched
*/
export const BREAKPOINT_MIN = {
ultrawide: 1920,
desktop: 1440,
laptop: 1024,
tablet: 640,
mobile: 0,
} as const;
const QUERIES: [Breakpoint, string][] = [
['ultrawide', `(min-width: ${BREAKPOINT_MIN.ultrawide}px)`],
['desktop', `(min-width: ${BREAKPOINT_MIN.desktop}px)`],
['laptop', `(min-width: ${BREAKPOINT_MIN.laptop}px)`],
['tablet', `(min-width: ${BREAKPOINT_MIN.tablet}px)`],
];
function read(): Breakpoint {
for (const [name, q] of QUERIES) {
if (window.matchMedia(q).matches) return name;
}
return 'mobile';
}
function subscribe(onChange: () => void) {
const mqls = QUERIES.map(([, q]) => window.matchMedia(q));
mqls.forEach((m) => m.addEventListener('change', onChange));
return () => mqls.forEach((m) => m.removeEventListener('change', onChange));
}
/**
* SSR resolves to 'desktop'. The shell is flex-based, so when a narrow client
* corrects on the first frame the only visible change is the Loyaly AI panel
* dropping out — no reflow of the content column.
*/
export function useBreakpoint(): Breakpoint {
return useSyncExternalStore(subscribe, read, () => 'desktop');
}
/**
* True when the side nav renders as an inline rail. Below this AppShell moves
* it into the drawer, which is also the point where the workspace shell has to
* put the top bar back at the shell root — see (workspace)/layout.tsx.
*
* Must stay in sync with AppShell's own `mobileNav.breakpoint`, which is
* 'sm' (640px) — the same line as the mobile/tablet boundary.
*/
export function isSideNavInline(bp: Breakpoint): boolean {
return bp !== 'mobile';
}
/**
* True when Loyaly AI renders as a third column.
*
* Tablet is excluded deliberately: at 640–1024 a 320px panel leaves under
* 400px of workspace once the rail is out, which is narrower than a single
* chart needs. There it becomes a sheet, same as on mobile.
*/
export function isPanelInline(bp: Breakpoint): boolean {
return bp === 'laptop' || bp === 'desktop' || bp === 'ultrawide';
}
/** Loyaly AI panel width. Laptop trades panel width for workspace. */
export function assistantWidth(bp: Breakpoint): number {
if (bp === 'ultrawide') return 420;
if (bp === 'desktop') return 380;
return 320;
}
/**
* Columns for a four-up metric row.
*
* Four KPIs must lay out 4-up, 2×2, or stacked — never 3+1, which reads as a
* broken grid. Grid's `repeat: 'fit'` cannot express that: at a ~800px
* workspace it yields exactly three tracks whatever minWidth you choose, and
* the fourth card drops to an orphan row. So the count is computed.
*/
export function metricColumns(bp: Breakpoint, isPanelOpen: boolean): number {
if (bp === 'mobile') return 1;
if (bp === 'tablet') return 2;
if (bp === 'laptop') return 2;
// Desktop and ultrawide fit four unless Loyaly AI is taking 380–420px.
return isPanelInline(bp) && isPanelOpen ? 2 : 4;
}
/**
* Max content width above `desktop`.
*
* On a 2560px monitor an uncapped workspace stretches a 30-day line chart
* across ~1900px, which flattens every trend it is supposed to show, and runs
* body text past the ~90ch where reading breaks down. Capping and centring
* costs nothing below the cap.
*/
export function contentMaxWidth(bp: Breakpoint): number | undefined {
return bp === 'ultrawide' ? 1600 : undefined;
}