UNPKG

@patreon/studio

Version:

Patreon Studio Design System

246 lines (245 loc) 8.79 kB
import { css } from 'styled-components'; import { breakpointNames } from '~/utilities/breakpoint-definitions'; import { mediaForBreakpoint } from '~/utilities/breakpoints'; // Symbols are used to make it difficult to access responsive values directly const isResponsiveKey = Symbol(); const isResponsiveValue = Symbol(); const responsiveValue = Symbol(); /** * An opaque object that wraps a responsive value. we use symbols to make it * difficult to access responsive value directly. */ export class OpaqueResponsive { [isResponsiveKey] = isResponsiveValue; [responsiveValue]; constructor(value) { this[responsiveValue] = value ?? {}; } } /** * Creates an opaque object that wraps a responsive value */ export function wrapResponsive(value) { // if the value is undefined, we create a responsive object with the value set to undefined if (value === undefined || value === null || value === false) { return new OpaqueResponsive({}); } // If the value is already a wrapped responsive value, return it as is if (value instanceof OpaqueResponsive) { return value; } // here we duck-type the value to see if it is a responsive object // we check if the object has any of the breakpoints or if it is an empty object if (isResponsive(value)) { return new OpaqueResponsive(value); } // if the value is not a responsive object, we create a responsive object from the single value return new OpaqueResponsive({ xs: value }); } /** * Unwraps a responsive value from a wrapped responsive object and throws an error * if the value is not a OpaqueResponsive object. */ export function unwrapResponsive(value) { if (!(isResponsiveKey in value) || value[isResponsiveKey] !== isResponsiveValue) { throw new Error(`Invalid responsive value: ${value}`); } return value[responsiveValue]; } /** * Merge multiple responsive values into a single responsive value * where the values are merged together */ export function mergeResponsive(values) { const merged = {}; for (const opaqueResponsiveValue of values) { const unwrappedResponsive = unwrapResponsive(opaqueResponsiveValue); for (const breakpointName of breakpointNames) { if (unwrappedResponsive[breakpointName] !== undefined) { merged[breakpointName] = unwrappedResponsive[breakpointName]; } } } return wrapResponsive(merged); } /** * Merge multiple responsive values into a unified responsive value * with keys that are merged together */ export function mergeNamedResponsive(values) { // build up a new object with the merged values const merged = {}; // keep track of all the values so we can group them by breakpoint later const allValues = {}; // keep track of the latest values so we can cascade them later const latestValues = {}; for (const key in values) { if (key !== undefined) { allValues[key] = unwrapResponsive(values[key]); latestValues[key] = allValues[key]?.xs; } } for (const breakpointName of breakpointNames) { let usesBreakpoint = false; for (const key in allValues) { if (key !== undefined) { const unwrappedValue = allValues[key]; if (unwrappedValue?.[breakpointName] !== undefined) { usesBreakpoint = true; latestValues[key] = unwrappedValue[breakpointName]; } } } // only add the latest values if this breakpoint shows // up in any ofthe responsive values if (usesBreakpoint) { merged[breakpointName] = { ...latestValues }; } } return wrapResponsive(merged); } /** * Merge multiple responsive values into a single responsive value * where the values are merged together with values cascading from * smaller breakpoints to larger breakpoints like in CSS media queries * * For example, if we have the following values: * - { xs: 10, sm: 20, md: 30 } * - { xs: 5, md: 15, lg: 15 } * - { xxl: 30 } * * we would get the following merged values: * - { xs: 5, md: 15, xxl: 30 } */ export function mergeResponsivePreferringLastValue(values) { const merged = {}; const unwrappedValues = values.map(unwrapResponsive); const latestValues = []; let lastHead; for (const breakpointName of breakpointNames) { let head; for (let i = 0; i < unwrappedValues.length; i++) { const unwrappedValue = unwrappedValues[i]; if (unwrappedValue[breakpointName] !== undefined) { latestValues[i] = unwrappedValue[breakpointName]; } head = latestValues[i] ?? head; } if (head !== undefined && lastHead !== head) { merged[breakpointName] = head; lastHead = head; } } return wrapResponsive(merged); } /** * Map a responsive value to a new value */ export function mapResponsive(values, mapper) { const unwrappedValues = unwrapResponsive(values); return wrapResponsive(Object.keys(unwrappedValues).reduce((acc, key) => { const breakpoint = key; const value = unwrappedValues[breakpoint]; if (value !== undefined) { acc[breakpoint] = mapper(value, breakpoint); } return acc; }, {})); } /** * Reduce multiple responsive values into a single responsive value */ export function reduceResponsive(values, reducer, initialValue) { const unwrappedValues = unwrapResponsive(values); return Object.keys(unwrappedValues).reduce((acc, key) => { const value = unwrappedValues[key]; if (value !== undefined) { return reducer(acc, value, key); } return acc; }, initialValue); } /** * Predicate to check if an opaque object has values */ export function hasResponsiveValue(value) { const unwrapped = unwrapResponsive(value); return Object.keys(unwrapped).length > 0; } /** * Predicate to check if a value is a responsive object */ export function isResponsive(value) { return ((value && typeof value === 'object' && ('xs' in value || 'sm' in value || 'md' in value || 'lg' in value || 'xl' in value || 'xxl' in value || Object.keys(value).length === 0)) ?? false); } /** * Converts a value or responsive value into a CSS string using the * mediaForBreakpoint helper to generate media queries for each breakpoint * and the mapValue function to generate the CSS for each value */ export function cssForResponsive(values, mapValue) { // Normalize the value into a responsive object const responsiveValues = unwrapResponsive(values); // create a string to store responsive css let cssString = css ``; // generate css for each breakpoint, we need to do this work // from smallest to largest breakpoint to ensure that the // media queries are generated in the correct order for (const breakpointName of breakpointNames) { const value = responsiveValues[breakpointName]; if (value !== undefined) { const mappedValue = mapValue(value, breakpointName, responsiveValues); if (mappedValue === undefined) { continue; } // we don't need a media query for the base/xs breakpoint if (breakpointName === 'xs') { cssString = css ` ${mappedValue} `; // for all other breakpoints we need to generate a media query } else { cssString = css ` ${cssString}; @media ${mediaForBreakpoint(breakpointName)} { ${mappedValue}; } `; } } } return cssString; } export function cssForResponsiveProp(propName, propValue, transform) { return cssForResponsive(propValue, (value) => { if (value === undefined) { return undefined; } // when the value is not a string, we require a transform function // to convert the value into a string. TS is unable to infer this // requirement once inside the function, so we need to check it here. // We should not reach the throw here unless tsc fails. if (typeof value !== 'string') { if (!transform) { throw new Error('A transform function is required for non-string values.'); } return css ` ${propName}: ${transform(value)}; `; } return css ` ${propName}: ${transform?.(value) ?? value}; `; }); } //# sourceMappingURL=index.js.map