import { computed, toValue } from 'vue'; import type { ComputedRef, MaybeRefOrGetter } from 'vue'; import { isFunction, isNumber } from '@robonen/stdlib'; import { defaultWindow } from '@/types'; import type { ConfigurableWindow } from '@/types'; import { useMediaQuery } from '@/composables/browser/useMediaQuery'; import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery'; import { pxValue } from '@robonen/platform/browsers'; /** * A breakpoints map: name → viewport width. Numbers are treated as pixels; * strings keep their unit (`"48em"`, `"30rem"`, `"1024px"`). Values may be * reactive (refs or getters). */ export type Breakpoints = Record>; /** * Which edge a generated shortcut property (e.g. `breakpoints.lg`) reacts to. * * - `'min-width'` (mobile-first) — `lg` is `true` when the viewport is at least * the `lg` width. * - `'max-width'` (desktop-first) — `lg` is `true` when the viewport is at most * the `lg` width. */ export type UseBreakpointsStrategy = 'min-width' | 'max-width'; export interface UseBreakpointsOptions extends ConfigurableWindow, Pick { /** * The query strategy used by the generated shortcut properties. * * @default 'min-width' */ strategy?: UseBreakpointsStrategy; } export type UseBreakpointsReturn = Record> & { /** Reactive: viewport width is greater than or equal to breakpoint `k` (`min-width`). */ greaterOrEqual: (k: MaybeRefOrGetter) => ComputedRef; /** Reactive: viewport width is smaller than or equal to breakpoint `k` (`max-width`). */ smallerOrEqual: (k: MaybeRefOrGetter) => ComputedRef; /** Reactive: viewport width is strictly greater than breakpoint `k`. */ greater: (k: MaybeRefOrGetter) => ComputedRef; /** Reactive: viewport width is strictly smaller than breakpoint `k`. */ smaller: (k: MaybeRefOrGetter) => ComputedRef; /** Reactive: viewport width is within `[a, b)`. */ between: (a: MaybeRefOrGetter, b: MaybeRefOrGetter) => ComputedRef; /** Snapshot: viewport width is strictly greater than breakpoint `k`. */ isGreater: (k: MaybeRefOrGetter) => boolean; /** Snapshot: viewport width is greater than or equal to breakpoint `k`. */ isGreaterOrEqual: (k: MaybeRefOrGetter) => boolean; /** Snapshot: viewport width is strictly smaller than breakpoint `k`. */ isSmaller: (k: MaybeRefOrGetter) => boolean; /** Snapshot: viewport width is smaller than or equal to breakpoint `k`. */ isSmallerOrEqual: (k: MaybeRefOrGetter) => boolean; /** Snapshot: viewport width is within `[a, b)`. */ isInBetween: (a: MaybeRefOrGetter, b: MaybeRefOrGetter) => boolean; /** Reactive: all currently active breakpoints, ordered small → large. */ current: () => ComputedRef; /** Reactive: the single active breakpoint per `strategy` (largest for `min-width`, smallest for `max-width`), or `''` when none. */ active: () => ComputedRef; }; /** * Add `delta` to the numeric portion of a CSS length, preserving its unit. * Used to build the strict (`> / <`) variants from inclusive media queries via * a small ±0.1 nudge. */ function increaseWithUnit(target: number | string, delta: number): number | string { if (isNumber(target)) return target + delta; const value = target.match(/^-?\d+(?:\.\d+)?/)?.[0] ?? ''; const unit = target.slice(value.length); const result = Number.parseFloat(value) + delta; if (Number.isNaN(result)) return target; return result + unit; } /** * @name useBreakpoints * @category Browser * @description Reactive viewport breakpoints derived from a breakpoints map. * SSR-safe (resolves width queries from `ssrWidth` before `matchMedia` exists), * reactive to breakpoint values, and built on a single `useMediaQuery` per * comparison. Comes with presets: `breakpointsTailwind`, `breakpointsBootstrapV5`, * `breakpointsAntDesign`, `breakpointsVuetifyV3`. * * @param {Breakpoints} breakpoints The breakpoints map (`name → width`) * @param {UseBreakpointsOptions} [options={}] Options (`strategy`, custom `window`, `ssrWidth`) * @returns {UseBreakpointsReturn} Shortcut refs per breakpoint plus comparison helpers * * @example * const bp = useBreakpoints(breakpointsTailwind); * const isDesktop = bp.greaterOrEqual('lg'); * const isMobile = bp.smaller('md'); * bp.lg; // ComputedRef — true when viewport >= 1024px * * @example * const bp = useBreakpoints({ mobile: 0, tablet: 640, desktop: 1024 }); * const active = bp.active(); // ComputedRef<'mobile' | 'tablet' | 'desktop' | ''> * * @since 0.0.15 */ export function useBreakpoints( breakpoints: Breakpoints, options: UseBreakpointsOptions = {}, ): UseBreakpointsReturn { const { window = defaultWindow, strategy = 'min-width', ssrWidth } = options; const mediaOptions: UseMediaQueryOptions = { window, ssrWidth }; const ssrSupport = isNumber(ssrWidth); function getValue(k: MaybeRefOrGetter, delta?: number): string { let v = toValue(breakpoints[toValue(k)]); if (delta !== undefined) v = increaseWithUnit(v, delta); return isNumber(v) ? `${v}px` : v; } // Synchronous (non-reactive) match for the `is*` snapshot helpers. function match(edge: 'min' | 'max', size: string): boolean { const supported = window && isFunction(window.matchMedia); if (!supported) return ssrSupport ? (edge === 'min' ? ssrWidth >= pxValue(size) : ssrWidth <= pxValue(size)) : false; return window.matchMedia(`(${edge}-width: ${size})`).matches; } const greaterOrEqual = (k: MaybeRefOrGetter): ComputedRef => useMediaQuery(() => `(min-width: ${getValue(k)})`, mediaOptions); const smallerOrEqual = (k: MaybeRefOrGetter): ComputedRef => useMediaQuery(() => `(max-width: ${getValue(k)})`, mediaOptions); const greater = (k: MaybeRefOrGetter): ComputedRef => useMediaQuery(() => `(min-width: ${getValue(k, 0.1)})`, mediaOptions); const smaller = (k: MaybeRefOrGetter): ComputedRef => useMediaQuery(() => `(max-width: ${getValue(k, -0.1)})`, mediaOptions); const between = (a: MaybeRefOrGetter, b: MaybeRefOrGetter): ComputedRef => useMediaQuery(() => `(min-width: ${getValue(a)}) and (max-width: ${getValue(b, -0.1)})`, mediaOptions); const keys = Object.keys(breakpoints) as K[]; // Generated shortcut properties (`bp.lg`). Lazily created getters so we only // spin up a `useMediaQuery` watcher for the breakpoints actually accessed. const shortcuts = keys.reduce((acc, k) => { Object.defineProperty(acc, k, { get: () => strategy === 'min-width' ? greaterOrEqual(k) : smallerOrEqual(k), enumerable: true, configurable: true, }); return acc; }, {} as Record>); function current(): ComputedRef { const points = keys .map(k => [k, shortcuts[k], pxValue(getValue(k))] as const) .sort((a, b) => a[2] - b[2]); return computed(() => points.filter(([, matches]) => matches.value).map(([k]) => k)); } return Object.assign(shortcuts, { greaterOrEqual, smallerOrEqual, greater, smaller, between, isGreater: (k: MaybeRefOrGetter): boolean => match('min', getValue(k, 0.1)), isGreaterOrEqual: (k: MaybeRefOrGetter): boolean => match('min', getValue(k)), isSmaller: (k: MaybeRefOrGetter): boolean => match('max', getValue(k, -0.1)), isSmallerOrEqual: (k: MaybeRefOrGetter): boolean => match('max', getValue(k)), isInBetween: (a: MaybeRefOrGetter, b: MaybeRefOrGetter): boolean => match('min', getValue(a)) && match('max', getValue(b, -0.1)), current, active(): ComputedRef { const bps = current(); return computed(() => bps.value.length === 0 ? '' : bps.value.at(strategy === 'min-width' ? -1 : 0)!); }, }); } /** * Tailwind CSS default breakpoints. * * @see https://tailwindcss.com/docs/responsive-design */ export const breakpointsTailwind = { sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536, }; /** * Bootstrap v5 default breakpoints. * * @see https://getbootstrap.com/docs/5.0/layout/breakpoints/ */ export const breakpointsBootstrapV5 = { xs: 0, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1400, }; /** * Ant Design default breakpoints. * * @see https://ant.design/components/grid#col */ export const breakpointsAntDesign = { xs: 480, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1600, }; /** * Vuetify v3 default breakpoints. * * @see https://vuetifyjs.com/en/features/display-and-platform/ */ export const breakpointsVuetifyV3 = { xs: 0, sm: 600, md: 960, lg: 1280, xl: 1920, xxl: 2560, };