UNPKG

@spark-web/theme

Version:

--- title: Theme isExperimentalPackage: true ---

733 lines (671 loc) 20.7 kB
import { parseToRgb, toColorString, parseToHsl, getLuminance } from 'polished'; import { createContext, useContext } from 'react'; import _objectSpread from '@babel/runtime/helpers/esm/objectSpread2'; import _objectWithoutProperties from '@babel/runtime/helpers/esm/objectWithoutProperties'; import { getCapHeight, precomputeValues } from '@capsizecss/core'; import mapValues from 'lodash/mapValues'; import _defineProperty from '@babel/runtime/helpers/esm/defineProperty'; import _typeof from '@babel/runtime/helpers/esm/typeof'; import facepaint from 'facepaint'; import omit from 'lodash/omit'; var smoothSaturation = function smoothSaturation(saturation, luminance) { var isBright = luminance > 0.6; if (isBright) { return saturation * 0.8; } return saturation * 0.45; }; var smoothLightness = function smoothLightness(lightness, luminance) { var isBright = luminance > 0.6; if (isBright) { return 0.95 - lightness * 0.03; } return 0.95 - lightness * 0.06; }; function getLightVariant(color) { var _parseToHsl3 = parseToHsl(color), hue = _parseToHsl3.hue, saturation = _parseToHsl3.saturation, lightness = _parseToHsl3.lightness; var luminance = getLuminance(color); return toColorString({ hue: hue, saturation: smoothSaturation(saturation, luminance), lightness: smoothLightness(lightness, luminance) }); } var isLight = function isLight(inputColor) { var _parseToRgb = parseToRgb(inputColor), red = _parseToRgb.red, green = _parseToRgb.green, blue = _parseToRgb.blue; // Convert RGB to YIQ to better take into account the // luminance of the separate color channels: // // Further reading: // - YIQ: // https://en.wikipedia.org/wiki/YIQ // - Calculating contrast: // https://24ways.org/2010/calculating-color-contrast/ var yiq = (red * 299 + green * 587 + blue * 114) / 1000; // Colour is considered `light` if greater than the midpoint: // eg. 256 / 2 = 128. return yiq >= 128; }; var breakpointNames = ['mobile', 'tablet', 'desktop', 'wide']; var breakpoints = { mobile: 0, tablet: 740, desktop: 992, wide: 1200 }; /** * Utilities related to responsive props. Emotion's * [facepaint](https://github.com/emotion-js/facepaint) ultimately generates * media queries for the resolved styles. */ var makeThemeUtils = function makeThemeUtils() { // NOTE: the `mobile` key is used to represent "below tablet" in certain // cases, but it SHOULD NOT create a media query: facepaint will apply the // first property in the array without a query. var validBreakpoints = Object.values(breakpointQuery); return { mapResponsiveProp: mapResponsiveProp, mapResponsiveScale: mapResponsiveScale, optimizeResponsiveArray: optimizeResponsiveArray, responsiveRange: responsiveRange, responsiveStyles: responsiveStyles, resolveResponsiveProps: facepaint(validBreakpoints) }; }; // Responsive props // ------------------------------ /** * Helper for mapping keys/breakpoint map to a theme scale e.g. * * @example * mapResponsiveProp('small', { small: 8, large: 16 }) // 8 * mapResponsiveProp( * { mobile:'small', tablet:'large' }, * { small: 8, large: 16 } * ) // [8,16] */ var mapResponsiveScale = function mapResponsiveScale(value, scaleDefinition) { if (value === undefined) { return value; } if (_typeof(value) === 'object') { return resolveResponsiveScale(value, scaleDefinition); } return scaleDefinition[value]; }; function resolveResponsiveScale(value, scaleDefinition) { var valueArray = []; for (var i = 0; i < breakpointNames.length; i++) { var breakpoint = breakpointNames[i]; var keyForBreakpoint = value[breakpoint]; // NOTE: media queries are applied by index (facepaint). nullish value // ensures array length always matches breakpoints valueArray.push(keyForBreakpoint ? scaleDefinition[keyForBreakpoint] : null); } return valueArray; } var mapResponsiveProp = function mapResponsiveProp(value) { if (_typeof(value) === 'object') { return resolveResponsiveProp(value); } return value; }; function resolveResponsiveProp(value) { var valueArray = []; for (var i = 0; i < breakpointNames.length; i++) { var _value$breakpoint; var breakpoint = breakpointNames[i]; valueArray.push((_value$breakpoint = value[breakpoint]) !== null && _value$breakpoint !== void 0 ? _value$breakpoint : null); } return valueArray; } function createResponsiveMapFn(lookupMap) { return function mapResponsiveValue(prop) { if (typeof prop == 'undefined') { return prop; } if (_typeof(prop) === 'object') { var mobile = prop.mobile, tablet = prop.tablet, desktop = prop.desktop, wide = prop.wide; return { mobile: mobile ? lookupMap[mobile] : undefined, tablet: tablet ? lookupMap[tablet] : undefined, desktop: desktop ? lookupMap[desktop] : undefined, wide: wide ? lookupMap[wide] : undefined }; } return lookupMap[prop]; }; } // Responsive range // ------------------------------ var responsiveRange = function responsiveRange(props) { var above = props.above, below = props.below; if (!above && !below) { return [false, false, false, false]; } var startIndex = above ? breakpointNames.indexOf(above) + 1 : 0; var endIndex = below ? breakpointNames.indexOf(below) - 1 : breakpointNames.length - 1; var range = breakpointNames.slice(startIndex, endIndex + 1); var includeMobile = range.indexOf('mobile') >= 0; var includeTablet = range.indexOf('tablet') >= 0; var includeDesktop = range.indexOf('desktop') >= 0; var includeWide = range.indexOf('wide') >= 0; return [includeMobile, includeTablet, includeDesktop, includeWide]; }; var optimizeResponsiveArray = function optimizeResponsiveArray(value) { var _values$, _values$2, _values$3, _values$4; var lastValue; var values = value.map(function (v) { if (v !== lastValue && v !== null) { lastValue = v; return v; } return null; }); return [(_values$ = values[0]) !== null && _values$ !== void 0 ? _values$ : null, (_values$2 = values[1]) !== null && _values$2 !== void 0 ? _values$2 : null, (_values$3 = values[2]) !== null && _values$3 !== void 0 ? _values$3 : null, (_values$4 = values[3]) !== null && _values$4 !== void 0 ? _values$4 : null]; }; // ============================== // Experiment // ============================== var breakpointQuery = mapValues(omit(breakpoints, 'mobile'), function (bp) { return "@media screen and (min-width: ".concat(bp, "px)"); }); var makeMediaQuery = function makeMediaQuery(breakpoint) { return function (styles) { return !styles || Object.keys(styles).length === 0 ? {} : _defineProperty({}, breakpointQuery[breakpoint], styles); }; }; var mediaQuery = { tablet: makeMediaQuery('tablet'), desktop: makeMediaQuery('desktop'), wide: makeMediaQuery('wide') }; var responsiveStyles = function responsiveStyles(_ref2) { var mobile = _ref2.mobile, tablet = _ref2.tablet, desktop = _ref2.desktop, wide = _ref2.wide; return _objectSpread(_objectSpread({}, mobile), tablet || desktop || wide ? _objectSpread(_objectSpread(_objectSpread({}, mediaQuery.tablet(tablet !== null && tablet !== void 0 ? tablet : {})), mediaQuery.desktop(desktop !== null && desktop !== void 0 ? desktop : {})), mediaQuery.wide(wide !== null && wide !== void 0 ? wide : {})) : {}); }; var _excluded = ["fontSize", "lineHeight"], _excluded2 = ["border", "color", "sizing", "spacing", "typography"]; /** * Afford consumers the simplicity of pixel values for token declarations while * supporting our users' browser preferences. */ function pxToRem(value) { var px = typeof value === 'string' ? parseFloat(value) : value; // NOTE: assume default browser settings of 16px root var modifier = 1 / 16; return "".concat(px * modifier, "rem"); } /** * Calculate leading and trim styles using * [Capsize](https://seek-oss.github.io/capsize/) to ensure vertical spacing * around text elements behaves as expected. */ function fontSizeToCapHeight(definition, fontMetrics) { var rowHeight = 4; // TODO: move to theme? var capHeight = getCapHeight({ fontSize: definition.fontSize, fontMetrics: fontMetrics }); var _precomputeValues = precomputeValues({ fontSize: definition.fontSize, leading: definition.rows * rowHeight, fontMetrics: fontMetrics }), fontSize = _precomputeValues.fontSize, lineHeight = _precomputeValues.lineHeight, trims = _objectWithoutProperties(_precomputeValues, _excluded); return { fontSize: pxToRem(fontSize), lineHeight: pxToRem(lineHeight), capHeight: pxToRem(capHeight), trims: trims }; } function responsiveTypography(definition, fontMetrics) { var mobile = definition.mobile, tablet = definition.tablet; return { mobile: fontSizeToCapHeight(mobile, fontMetrics), tablet: fontSizeToCapHeight(tablet, fontMetrics) }; } /** * Apply "capsized" style rules to the typographic tokens, making them available * in the theme. */ function decorateTypography(typography) { var heading = typography.heading, text = typography.text, fontFamily = typography.fontFamily; return _objectSpread(_objectSpread({}, typography), {}, { heading: _objectSpread(_objectSpread({}, heading), {}, { level: _objectSpread({}, mapValues(heading.level, function (definition) { return responsiveTypography(definition, fontFamily.display.fontMetrics); })) }), text: _objectSpread(_objectSpread({}, text), mapValues(text, function (definition) { return responsiveTypography(definition, fontFamily.sans.fontMetrics); })) }); } function decorateTokens(tokens) { var border = tokens.border, color = tokens.color, sizing = tokens.sizing, spacing = tokens.spacing, typography = tokens.typography, restTokens = _objectWithoutProperties(tokens, _excluded2); var decoratedTokens = _objectSpread({ breakpoint: breakpoints, border: _objectSpread(_objectSpread({}, border), {}, { radius: _objectSpread(_objectSpread({}, border.radius), {}, { full: 9999 // prefer over percentage to avoid ovals for irregular rectangles }) }), color: _objectSpread(_objectSpread({}, color), {}, { background: _objectSpread(_objectSpread({}, color.background), {}, { infoLight: getLightVariant(color.background.info), criticalLight: getLightVariant(color.background.critical), positiveLight: getLightVariant(color.background.positive), cautionLight: getLightVariant(color.background.caution) }) }), spacing: _objectSpread(_objectSpread({}, spacing), {}, { none: 0 }), sizing: _objectSpread(_objectSpread({}, sizing), {}, { none: 0, full: '100%' }), typography: decorateTypography(typography) }, restTokens); return decoratedTokens; } // Export // ------------------------------ function makeBrighteTheme(tokens) { var decoratedTokens = decorateTokens(tokens); return _objectSpread(_objectSpread({}, decoratedTokens), {}, { backgroundLightness: mapValues(decoratedTokens.color.background, function (background) { return isLight(background) ? 'light' : 'dark'; }), utils: makeThemeUtils() }); } var white = '#ffffff'; var colors = { neutral: { '0': white, '50': '#fafcfe', '100': '#f1f4fb', '200': '#dce1ec', '300': '#c7cedb', '500': '#98a2b8', '600': '#646f84', '700': '#1a2a3a' }, primary: { '0': white, '50': '#f5fdf9', '100': '#edfaf5', '200': '#c8eada', '300': '#9acbb8', '500': '#00c28d', '600': '#00a87b', '700': '#108663' }, secondary: { '0': white, '50': '#fef5eb', '100': '#fff0e0', '500': '#ffbb66', '600': '#ffaa44', '700': '#e58f27' }, green: { '0': white, '50': '#f6fbf8', '100': '#f0f9f1', '200': '#cde9d2', '300': '#b1dab9', '500': '#1e9c65', '600': '#2c855d', '700': '#327e59' }, blue: { '0': white, '50': '#f6fafd', '100': '#f3f8fc', '200': '#d0e4ff', '300': '#b7d2f4', '500': '#2b8aed', '600': '#0677d6', '700': '#106fb8' }, red: { '0': white, '50': '#fef8f8', '100': '#fff4f4', '200': '#ffdad7', '300': '#fec1b5', '500': '#f53841', '600': '#e61e32', '700': '#c81b0e' }, yellow: { '0': white, '50': '#fefaf6', '100': '#fff5eb', '200': '#fdddc4', '300': '#face9b', '500': '#ffaa44', '600': '#be5c1c', '700': '#ad541a' } }; // the design team, but the shape shouldn't change too much. var aesteticoFontMetrics = { capHeight: 666, ascent: 980, descent: -340, lineGap: 0, unitsPerEm: 1000 }; // Typography // ------------------------------ // Tokens // ------------------------------ var defaultTokens = { name: 'Brighte web: light', // tweak for breakpoints typography: { fontFamily: { sans: { fontMetrics: aesteticoFontMetrics, name: '"Aestetico", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"' }, display: { fontMetrics: aesteticoFontMetrics, name: '"Aestetico", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"' } }, fontWeight: { regular: 400, semibold: 600 }, heading: { level: { '1': { mobile: { fontSize: 35, rows: 9 }, tablet: { fontSize: 35, rows: 11 } }, '2': { mobile: { fontSize: 23, rows: 8 }, tablet: { fontSize: 23, rows: 9 } }, '3': { mobile: { fontSize: 19, rows: 6 }, tablet: { fontSize: 19, rows: 7 } }, '4': { mobile: { fontSize: 17, rows: 5 }, tablet: { fontSize: 17, rows: 7 } } } }, text: { xsmall: { mobile: { fontSize: 13, rows: 5 }, tablet: { fontSize: 13, rows: 5 } }, small: { mobile: { fontSize: 15, rows: 5 }, tablet: { fontSize: 15, rows: 5 } }, standard: { mobile: { fontSize: 17, rows: 6 }, tablet: { fontSize: 17, rows: 6 } }, large: { mobile: { fontSize: 19, rows: 7 }, tablet: { fontSize: 19, rows: 7 } } } }, border: { radius: { small: 4, medium: 8, large: 16 }, width: { standard: 1, large: 2 }, color: { neutral: colors.neutral[700], standard: colors.neutral[200], standardInverted: colors.neutral[0], field: colors.neutral[200], fieldHover: colors.neutral[300], fieldAccent: colors.primary[500], fieldDisabled: colors.neutral[300], // tones primary: colors.primary[500], primaryHover: colors.primary[600], primaryActive: colors.primary[700], secondary: colors.secondary[500], secondaryHover: colors.secondary[600], secondaryActive: colors.secondary[700], accent: '#8b5cf6', accentMuted: '#997dd8', caution: colors.yellow[600], cautionMuted: colors.yellow[500], critical: colors.red[600], criticalMuted: colors.red[500], info: colors.blue[600], infoMuted: colors.blue[500], positive: colors.green[600], positiveMuted: colors.green[500] } }, color: { foreground: { neutral: colors.neutral[700], neutralInverted: colors.neutral[0], muted: colors.neutral[600], mutedInverted: 'hsla(0, 0%, 100%, 0.75)', link: colors.primary[600], disabled: colors.neutral[500], fieldAccent: colors.primary[500], placeholder: colors.neutral[600], // tones accent: '#8b5cf6', primary: colors.primary[500], primaryHover: colors.primary[600], primaryActive: colors.primary[700], secondary: colors.secondary[500], secondaryHover: colors.secondary[600], secondaryActive: colors.secondary[700], caution: colors.yellow[700], critical: colors.red[700], info: colors.blue[700], positive: colors.green[700] }, background: { muted: colors.neutral[600], disabled: colors.neutral[500], backdrop: 'hsla(0, 0%, 0%, 0.4)', body: colors.neutral[50], surface: colors.primary[0], surfaceMuted: colors.neutral[50], surfacePressed: colors.neutral[300], fieldAccent: colors.neutral[700], input: colors.primary[0], inputPressed: colors.neutral[100], inputDisabled: colors.neutral[50], // tones accent: '#8b5cf6', accentMuted: '#f7f5ff', neutral: colors.neutral[0], neutralLow: colors.neutral[50], primary: colors.primary[600], primaryLow: colors.primary[100], primaryMuted: colors.primary[50], secondary: colors.secondary[600], secondaryLow: colors.secondary[100], secondaryMuted: colors.secondary[50], caution: colors.yellow[600], cautionLow: colors.yellow[100], cautionMuted: colors.yellow[50], critical: colors.red[600], criticalLow: colors.red[100], criticalMuted: colors.red[50], info: colors.blue[600], infoLow: colors.blue[100], infoMuted: colors.blue[50], positive: colors.green[600], positiveLow: colors.green[100], positiveMuted: colors.green[50] }, status: { // tones for statuses can be either foreground or background accent: '#8b5cf6', caution: colors.yellow[500], critical: colors.red[500], info: colors.blue[500], neutral: colors.neutral[700], positive: colors.green[500] } }, backgroundInteractions: { none: colors.neutral[0], primaryActive: colors.primary[700], primaryHover: colors.primary[500], primaryLowHover: colors.primary[100], primaryLowActive: colors.primary[200], secondaryActive: colors.secondary[700], secondaryHover: colors.secondary[500], secondaryLowHover: colors.yellow[100], secondaryLowActive: colors.yellow[200], neutralHover: colors.neutral[100], neutralActive: colors.neutral[200], neutralLowHover: colors.neutral[100], neutralLowActive: colors.neutral[200], cautionLowHover: colors.yellow[200], cautionLowActive: colors.yellow[300], criticalActive: colors.red[700], criticalHover: colors.red[500], criticalLowHover: colors.red[200], criticalLowActive: colors.red[300], infoLowHover: colors.blue[200], infoLowActive: colors.blue[300], positiveHover: colors.green[500], positiveActive: colors.green[700], positiveLowHover: colors.green[200], positiveLowActive: colors.green[300] }, // misc contentWidth: { xsmall: 400, small: 660, medium: 940, large: 1280, xlarge: 1400 }, elevation: { dropdownBlanket: 90, dropdown: 100, sticky: 200, modalBlanket: 290, modal: 300, notification: 400 }, spacing: { xxsmall: 2, xsmall: 4, small: 8, medium: 12, large: 16, xlarge: 24, xxlarge: 32 }, sizing: { xxsmall: 16, xsmall: 24, small: 32, medium: 44, large: 56 }, shadow: { small: '0 1px 2px rgba(0, 0, 0, 0.05)', medium: '0 2px 8px rgba(0, 0, 0, 0.04)', large: '0 6px 12px rgba(0, 0, 0, 0.1)' }, animation: { standard: { duration: 300, easing: 'cubic-bezier(0.2, 0, 0, 1)' } } }; var defaultTheme = makeBrighteTheme(defaultTokens); var ThemeContext = /*#__PURE__*/createContext(defaultTheme); var ThemeProvider = ThemeContext.Provider; var useTheme = function useTheme() { return useContext(ThemeContext); }; export { ThemeProvider, createResponsiveMapFn, defaultTheme, defaultTokens, isLight, makeBrighteTheme, useTheme };