import { computed, shallowRef, toValue, watchEffect } from 'vue'; import type { ComputedRef, MaybeRefOrGetter } from 'vue'; import { isFunction, isNumber } from '@robonen/stdlib'; import { defaultWindow } from '@/types'; import type { ConfigurableWindow } from '@/types'; import { useSupported } from '@/composables/utilities/useSupported'; import { useEventListener } from '@/composables/browser/useEventListener'; import { pxValue } from '@robonen/platform/browsers'; export interface UseMediaQueryOptions extends ConfigurableWindow { /** * The viewport width (in pixels) assumed during SSR, used to resolve * `min-width` / `max-width` queries before `window.matchMedia` is available. * * When provided, the composable returns a best-effort match on the server * (and the first client render) instead of always `false`, avoiding hydration * flicker for width-based queries. Ignored once `matchMedia` is supported. * * @default undefined */ ssrWidth?: number; } /** * Best-effort evaluation of `min-width` / `max-width` media queries against a * known viewport width, for SSR. Comma-separated queries are OR-combined and * `not all` negation is respected. Returns `false` for queries we can't resolve. */ function matchSsrWidth(query: string, width: number): boolean { return query.split(',').some((part) => { const not = part.includes('not all'); const minWidth = part.match(/\(\s*min-width:\s*(-?\d+(?:\.\d*)?[a-z%]+\s*)\)/); const maxWidth = part.match(/\(\s*max-width:\s*(-?\d+(?:\.\d*)?[a-z%]+\s*)\)/); let result = Boolean(minWidth || maxWidth); if (minWidth && result) result = width >= pxValue(minWidth[1]!); if (maxWidth && result) result = width <= pxValue(maxWidth[1]!); return not ? !result : result; }); } /** * @name useMediaQuery * @category Browser * @description Reactive `window.matchMedia`. SSR-safe, reactive to the query, and * with optional SSR width resolution for `min-width` / `max-width` queries. * * @param {MaybeRefOrGetter} query The media query (can be reactive) * @param {UseMediaQueryOptions} [options={}] Options (custom `window`, `ssrWidth`) * @returns {ComputedRef} Readonly ref of whether the query currently matches * * @example * const isLarge = useMediaQuery('(min-width: 1024px)'); * * @example * // Resolve width queries during SSR to avoid hydration flicker * const isWide = useMediaQuery('(min-width: 1024px)', { ssrWidth: 1280 }); * * @since 0.0.14 */ export function useMediaQuery( query: MaybeRefOrGetter, options: UseMediaQueryOptions = {}, ): ComputedRef { const { window = defaultWindow, ssrWidth } = options; const isSupported = useSupported(() => window && 'matchMedia' in window && isFunction(window.matchMedia)); const ssrSupport = shallowRef(isNumber(ssrWidth)); const mediaQuery = shallowRef(); const matches = shallowRef(false); const handler = (event: MediaQueryListEvent) => { matches.value = event.matches; }; watchEffect(() => { // Resolve width-based queries from `ssrWidth` until the real API is ready. if (ssrSupport.value) { ssrSupport.value = !isSupported.value; matches.value = matchSsrWidth(toValue(query), ssrWidth!); return; } if (!isSupported.value) return; mediaQuery.value = window!.matchMedia(toValue(query)); matches.value = mediaQuery.value.matches; }); // Reactive target: re-binds automatically when the query (and thus the // MediaQueryList) changes, and auto-cleans on scope dispose. Passive since // we never call preventDefault. useEventListener(mediaQuery, 'change', handler, { passive: true }); return computed(() => matches.value); }