/* eslint-disable complexity */ import { useState, useEffect, useCallback } from 'react' import { isBrowser } from '../../utils' export type BreakpointKeys = 'xs' | 'sm' | 'md' | 'lg' | 'xl' type BreakpointValues = Record type BreakpointsList = { [key: string]: number } const DEFAULT_BREAKPOINT_VALUES: BreakpointValues = { xs: 0, sm: 480, md: 768, lg: 1024, xl: 1440, } const createMediaQueries = ({ sm, md, lg, xl }: BreakpointValues) => ({ xs: `(max-width: ${sm - 0.02}px)`, sm: `(min-width: ${sm}px) and (max-width: ${md - 0.02}px)`, md: `(min-width: ${md}px) and (max-width: ${lg - 0.02}px)`, lg: `(min-width: ${lg}px) and (max-width: ${xl - 0.02}px)`, xl: `(min-width: ${xl}px)`, }) class BreakpointProvider { breakpoints: Record<'values', BreakpointValues> = { values: { ...DEFAULT_BREAKPOINT_VALUES }, } mediaQueries: { [key in BreakpointKeys]: string } = createMediaQueries(DEFAULT_BREAKPOINT_VALUES) /** * Stops `xs`/`sm`/`md` from matching, for ``. * `lg` widens to the desktop floor — left at 1024px it made 768–1023.98px * match nothing, so `useBreakpoint(['md', 'lg', 'xl'])` was `false` there. * * `values.lg` drops to the same floor so the two APIs cannot disagree: the * media queries decide `useBreakpoint`, the pixel values decide * `useScreens`/`isScreenSize` — one desktop floor, one answer. */ disableMobileBreakpoints() { const { xl } = this.breakpoints.values this.breakpoints.values.xs = 768 this.breakpoints.values.sm = 768 this.breakpoints.values.lg = 768 this.mediaQueries.xs = '' this.mediaQueries.sm = '' this.mediaQueries.md = '' this.mediaQueries.lg = `(max-width: ${xl - 0.02}px)` } /** * Restores the responsive defaults, in place so the exported * `breakpointsList` alias stays live. `disableMobileBreakpoints()` is * process-wide and one-way — tests mounting `responsive={false}` call this * in an `afterEach` to avoid leaking. */ reset() { Object.assign(this.breakpoints.values, DEFAULT_BREAKPOINT_VALUES) Object.assign( this.mediaQueries, createMediaQueries(DEFAULT_BREAKPOINT_VALUES) ) } } export const PicassoBreakpoints = new BreakpointProvider() export const breakpointsList: BreakpointsList = PicassoBreakpoints.breakpoints.values export const screens = (...sizes: BreakpointKeys[]) => { const validSizes = sizes.filter(size => PicassoBreakpoints.mediaQueries[size]) if (validSizes.length === 0) { return '' } const mediaQueries = validSizes .map(size => PicassoBreakpoints.mediaQueries[size]) .join(', ') return `@media ${mediaQueries}` } const screenSizeToBreakpointKey = (size: number): BreakpointKeys => { /** * Gets a screen size nickname that corresponds to the given screen size. * * For the list of breakpoint names and pixel-values we use in designs, check * https://picasso.toptal.net/?path=/story/utils-breakpoints--breakpoints * * @param {number} size Screen size */ const { sm, md, lg, xl } = PicassoBreakpoints.breakpoints.values if (size < sm) { return 'xs' } else if (size >= sm && size < md) { return 'sm' } else if (size >= md && size < lg) { return 'md' } else if (size >= lg && size < xl) { return 'lg' } return 'xl' } export const isScreenSize = ( size: keyof BreakpointsList, currentSize?: number ): boolean => { const sizeToUse = currentSize || window.innerWidth const foundBreakpoint = screenSizeToBreakpointKey(sizeToUse) return size === foundBreakpoint } export const useScreenSize = () => { const [size, setSize] = useState(isBrowser() ? window.innerWidth : 0) const updateSize = () => setSize(window.innerWidth) useEffect(() => { window.addEventListener('resize', updateSize) return () => { window.removeEventListener('resize', updateSize) } }, []) return size } const useMediaQueryMatch = (query: string): boolean => { // `screens()` produces an `@media …` string, but matchMedia expects the bare // query. const mediaQuery = query.replace(/^@media\s?/, '') const getMatches = () => isBrowser() && typeof window.matchMedia === 'function' && mediaQuery ? window.matchMedia(mediaQuery).matches : false const [matches, setMatches] = useState(getMatches) useEffect(() => { if ( !isBrowser() || typeof window.matchMedia !== 'function' || !mediaQuery ) { return undefined } const mediaQueryList = window.matchMedia(mediaQuery) const onChange = () => setMatches(mediaQueryList.matches) onChange() // Safari < 14 and the jsdom test polyfill only implement the legacy // MediaQueryList.addListener API; prefer the modern event API when present. if (typeof mediaQueryList.addEventListener === 'function') { mediaQueryList.addEventListener('change', onChange) return () => mediaQueryList.removeEventListener('change', onChange) } mediaQueryList.addListener(onChange) return () => mediaQueryList.removeListener(onChange) }, [mediaQuery]) return matches } export const useBreakpoint = (sizes: BreakpointKeys[] | BreakpointKeys) => { const mediaQueryString = screens(...([] as BreakpointKeys[]).concat(sizes)) const matches = useMediaQueryMatch(mediaQueryString) if (!mediaQueryString) { return false } return matches } /** * Returns a function that picks a value from a {screenSize=>anyValue} object map. * * The function returned accepts 2 arguments: * 1. An object mapping values to screen size nicknames, e.g. * { sm: 'secondary', lg: 'positive' } * 2. A default value to use if no keys match in the object * * The function returns a value from the first argument that corresponds to the current * screen size, or the default value, if no corresponding key found. * * The returned function is memoized per screen size name. * * @example Varying both `variant` prop and button text with using the hook * const screens = useScreens() * */ export const useScreens = () => { // Get current screen size in pixels, e.g. 800 const currentSize = useScreenSize() // Convert the retrieved screen size in pixels (e.g. 800) // to its corresponding screen size name (e.g. 'large') const screenKey = screenSizeToBreakpointKey(currentSize) // For every screenKey value, memoize the instance of a function // that picks a property from an object by screen name, // and return this memoized version of the function. return useCallback( ( valuesByScreen: Partial>, defaultValue: T | undefined = undefined ) => { if (screenKey in valuesByScreen) { return valuesByScreen[screenKey] } return defaultValue }, // eslint-disable-next-line react-hooks/exhaustive-deps [screenKey] ) } export default PicassoBreakpoints.breakpoints