aa2938cb34
Genuinely type composable any usages (useStepper/useStorage/useForm/ createEventHook/useSorted/etc.) as proper generics/unknown; keep idiomatic any-function and overload-impl signatures with comments; skipped test -> .todo.
252 lines
8.8 KiB
TypeScript
252 lines
8.8 KiB
TypeScript
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<K extends string = string>
|
|
= Record<K, MaybeRefOrGetter<number | string>>;
|
|
|
|
/**
|
|
* 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<UseMediaQueryOptions, 'ssrWidth'> {
|
|
/**
|
|
* The query strategy used by the generated shortcut properties.
|
|
*
|
|
* @default 'min-width'
|
|
*/
|
|
strategy?: UseBreakpointsStrategy;
|
|
}
|
|
|
|
export type UseBreakpointsReturn<K extends string = string>
|
|
= Record<K, ComputedRef<boolean>> & {
|
|
/** Reactive: viewport width is greater than or equal to breakpoint `k` (`min-width`). */
|
|
greaterOrEqual: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
|
|
/** Reactive: viewport width is smaller than or equal to breakpoint `k` (`max-width`). */
|
|
smallerOrEqual: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
|
|
/** Reactive: viewport width is strictly greater than breakpoint `k`. */
|
|
greater: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
|
|
/** Reactive: viewport width is strictly smaller than breakpoint `k`. */
|
|
smaller: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
|
|
/** Reactive: viewport width is within `[a, b)`. */
|
|
between: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
|
|
/** Snapshot: viewport width is strictly greater than breakpoint `k`. */
|
|
isGreater: (k: MaybeRefOrGetter<K>) => boolean;
|
|
/** Snapshot: viewport width is greater than or equal to breakpoint `k`. */
|
|
isGreaterOrEqual: (k: MaybeRefOrGetter<K>) => boolean;
|
|
/** Snapshot: viewport width is strictly smaller than breakpoint `k`. */
|
|
isSmaller: (k: MaybeRefOrGetter<K>) => boolean;
|
|
/** Snapshot: viewport width is smaller than or equal to breakpoint `k`. */
|
|
isSmallerOrEqual: (k: MaybeRefOrGetter<K>) => boolean;
|
|
/** Snapshot: viewport width is within `[a, b)`. */
|
|
isInBetween: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) => boolean;
|
|
/** Reactive: all currently active breakpoints, ordered small → large. */
|
|
current: () => ComputedRef<K[]>;
|
|
/** Reactive: the single active breakpoint per `strategy` (largest for `min-width`, smallest for `max-width`), or `''` when none. */
|
|
active: () => ComputedRef<K | ''>;
|
|
};
|
|
|
|
/**
|
|
* 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<K>} breakpoints The breakpoints map (`name → width`)
|
|
* @param {UseBreakpointsOptions} [options={}] Options (`strategy`, custom `window`, `ssrWidth`)
|
|
* @returns {UseBreakpointsReturn<K>} 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<boolean> — 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<K extends string>(
|
|
breakpoints: Breakpoints<K>,
|
|
options: UseBreakpointsOptions = {},
|
|
): UseBreakpointsReturn<K> {
|
|
const { window = defaultWindow, strategy = 'min-width', ssrWidth } = options;
|
|
const mediaOptions: UseMediaQueryOptions = { window, ssrWidth };
|
|
const ssrSupport = isNumber(ssrWidth);
|
|
|
|
function getValue(k: MaybeRefOrGetter<K>, 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<K>): ComputedRef<boolean> =>
|
|
useMediaQuery(() => `(min-width: ${getValue(k)})`, mediaOptions);
|
|
|
|
const smallerOrEqual = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
|
|
useMediaQuery(() => `(max-width: ${getValue(k)})`, mediaOptions);
|
|
|
|
const greater = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
|
|
useMediaQuery(() => `(min-width: ${getValue(k, 0.1)})`, mediaOptions);
|
|
|
|
const smaller = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
|
|
useMediaQuery(() => `(max-width: ${getValue(k, -0.1)})`, mediaOptions);
|
|
|
|
const between = (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
|
|
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<K, ComputedRef<boolean>>);
|
|
|
|
function current(): ComputedRef<K[]> {
|
|
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<K>): boolean => match('min', getValue(k, 0.1)),
|
|
isGreaterOrEqual: (k: MaybeRefOrGetter<K>): boolean => match('min', getValue(k)),
|
|
isSmaller: (k: MaybeRefOrGetter<K>): boolean => match('max', getValue(k, -0.1)),
|
|
isSmallerOrEqual: (k: MaybeRefOrGetter<K>): boolean => match('max', getValue(k)),
|
|
isInBetween: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>): boolean =>
|
|
match('min', getValue(a)) && match('max', getValue(b, -0.1)),
|
|
current,
|
|
active(): ComputedRef<K | ''> {
|
|
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,
|
|
};
|