@patreon/studio
Version:
Patreon Studio Design System
246 lines (245 loc) • 8.79 kB
JavaScript
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