From da4c7e7ed675c3bf405668739c3012d140856109 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 15 May 2024 05:34:42 +0200 Subject: Adding upstream version 126.0. Signed-off-by: Daniel Baumann --- .../themes/shared/design-system/tokens-config.js | 547 +++++++++++++++++++++ 1 file changed, 547 insertions(+) create mode 100644 toolkit/themes/shared/design-system/tokens-config.js (limited to 'toolkit/themes/shared/design-system/tokens-config.js') diff --git a/toolkit/themes/shared/design-system/tokens-config.js b/toolkit/themes/shared/design-system/tokens-config.js new file mode 100644 index 0000000000..81d92d8faa --- /dev/null +++ b/toolkit/themes/shared/design-system/tokens-config.js @@ -0,0 +1,547 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env node */ + +const StyleDictionary = require("style-dictionary"); +const { createPropertyFormatter } = StyleDictionary.formatHelpers; +const TOKEN_SECTIONS = { + "Attention Dot": "attention-dot", + "Background Color": "background-color", + Border: "border", + "Box Shadow": "box-shadow", + Button: "button", + Checkbox: "checkbox", + Color: ["brand-color", "color", "platform-color"], + "Focus Outline": "focus-outline", + "Font Size": "font-size", + "Font Weight": "font-weight", + Icon: "icon", + "Input - Text": "input-text", + Link: "link", + "Outline Color": "outline-color", + Size: "size", + Space: "space", + Text: "text", + Unspecified: "", +}; +const TSHIRT_ORDER = [ + "circle", + "xxxsmall", + "xxsmall", + "xsmall", + "small", + "medium", + "large", + "xlarge", + "xxlarge", + "xxxlarge", +]; +const STATE_ORDER = [ + "base", + "default", + "root", + "hover", + "active", + "focus", + "disabled", +]; + +/** + * Adds the Mozilla Public License header in one comment and + * how to make changes in the generated output files via the + * design-tokens.json file in another comment. Also imports + * tokens-shared.css when applicable. + * + * @param {string} surface + * Desktop surface, either "brand" or "platform". Determines + * whether or not we need to import tokens-shared.css. + * @returns {string} Formatted comment header string + */ +let customFileHeader = ({ surface, platform }) => { + let licenseString = [ + "/* This Source Code Form is subject to the terms of the Mozilla Public", + " * License, v. 2.0. If a copy of the MPL was not distributed with this", + " * file, You can obtain one at http://mozilla.org/MPL/2.0/. */", + ].join("\n"); + + let commentString = [ + "/* DO NOT EDIT this file directly, instead modify design-tokens.json", + " * and run `npm run build` to see your changes. */", + ].join("\n"); + + let cssImport = surface + ? `@import url("chrome://global/skin/design-system/tokens-shared.css");\n\n` + : ""; + let layerString = + !surface && !platform + ? `@layer tokens-foundation, tokens-prefers-contrast, tokens-forced-colors;\n\n` + : ""; + + return [ + licenseString + "\n\n" + commentString + "\n\n" + cssImport + layerString, + ]; +}; + +const NEST_MEDIA_QUERIES_COMMENT = `/* Bug 1879900: Can't nest media queries inside of :host, :root selector + until Bug 1879349 lands */`; + +const MEDIA_QUERY_PROPERTY_MAP = { + "forced-colors": "forcedColors", + "prefers-contrast": "prefersContrast", +}; + +function formatBaseTokenNames(str) { + return str.replaceAll(/(?\w+)-base(?=\b)/g, "$"); +} + +/** + * Creates a surface-specific formatter. The formatter is used to build + * our different CSS files, including "prefers-contrast" and "forced-colors" + * media queries. See more at + * https://amzn.github.io/style-dictionary/#/formats?id=formatter + * + * @param {string} surface + * Which desktop area we are generating CSS for. + * Either "brand" (i.e. in-content) or "platform" (i.e. chrome). + * @returns {Function} - Formatter function that returns a CSS string. + */ +const createDesktopFormat = surface => args => { + return formatBaseTokenNames( + customFileHeader({ surface }) + + formatTokens({ + surface, + args, + }) + + formatTokens({ + mediaQuery: "prefers-contrast", + surface, + args, + }) + + formatTokens({ + mediaQuery: "forced-colors", + surface, + args, + }) + ); +}; + +/** + * Formats a subset of tokens into CSS. Wraps token CSS in a media query when + * applicable. + * + * @param {object} tokenArgs + * @param {string} [tokenArgs.mediaQuery] + * Media query formatted CSS should be wrapped in. This is used + * to determine what property we are parsing from the token values. + * @param {string} [tokenArgs.surface] + * Specifies a desktop surface, either "brand" or "platform". + * @param {object} tokenArgs.args + * Formatter arguments provided by style-dictionary. See more at + * https://amzn.github.io/style-dictionary/#/formats?id=formatter + * @returns {string} Tokens formatted into a CSS string. + */ +function formatTokens({ mediaQuery, surface, args }) { + let prop = MEDIA_QUERY_PROPERTY_MAP[mediaQuery] ?? "default"; + let dictionary = Object.assign({}, args.dictionary); + let tokens = []; + + dictionary.allTokens.forEach(token => { + let originalVal = getOriginalTokenValue(token, prop, surface); + if (originalVal != undefined) { + let formattedToken = transformTokenValue(token, originalVal, dictionary); + tokens.push(formattedToken); + } + }); + + if (!tokens.length) { + return ""; + } + + dictionary.allTokens = dictionary.allProperties = tokens; + + let formattedVars = formatVariables({ + format: "css", + dictionary, + outputReferences: args.options.outputReferences, + formatting: { + indentation: mediaQuery ? " " : " ", + }, + }); + + let layer = `tokens-${mediaQuery ?? "foundation"}`; + // Weird spacing below is unfortunately necessary for formatting the built CSS. + if (mediaQuery) { + return ` +${NEST_MEDIA_QUERIES_COMMENT} +@layer ${layer} { + @media (${mediaQuery}) { + :root, + :host(.anonymous-content-host) { +${formattedVars} + } + } +} +`; + } + + return `@layer ${layer} { + :root, + :host(.anonymous-content-host) { +${formattedVars} + } +} +`; +} + +/** + * Finds the original value of a token for a given media query and surface. + * + * @param {object} token - Token object parsed by style-dictionary. + * @param {string} prop - Name of the property we're querying for. + * @param {string} surface + * The desktop surface we're generating CSS for, either "brand" or "platform". + * @returns {string} The original token value based on our parameters. + */ +function getOriginalTokenValue(token, prop, surface) { + if (surface) { + return token.original.value[surface]?.[prop]; + } else if (prop == "default" && typeof token.original.value != "object") { + return token.original.value; + } + return token.original.value?.[prop]; +} + +/** + * Updates a token's value to the relevant original value after resolving + * variable references. + * + * @param {object} token - Token object parsed from JSON by style-dictionary. + * @param {string} originalVal + * Original value of the token for the combination of surface and media query. + * @param {object} dictionary + * Object of transformed tokens and helper fns provided by style-dictionary. + * @returns {object} Token object with an updated value. + */ +function transformTokenValue(token, originalVal, dictionary) { + let value = originalVal; + if (dictionary.usesReference(value)) { + dictionary.getReferences(value).forEach(ref => { + value = value.replace(`{${ref.path.join(".")}}`, `var(--${ref.name})`); + }); + } + return { ...token, value }; +} + +/** + * Creates a light-dark transform that works for a given surface. Registers + * the transform with style-dictionary and returns the transform's name. + * + * @param {string} surface + * The desktop surface we're generating CSS for, either "brand", "platform", + * or "shared". + * @returns {string} Name of the transform that was registered. + */ +const createLightDarkTransform = surface => { + let name = `lightDarkTransform/${surface}`; + + // Matcher function for determining if a token's value needs to undergo + // a light-dark transform. + let matcher = token => { + if (surface != "shared") { + return ( + token.original.value[surface]?.light && + token.original.value[surface]?.dark + ); + } + return token.original.value.light && token.original.value.dark; + }; + + // Function that uses the token's original value to create a new "default" + // light-dark value and updates the original value object. + let transformer = token => { + if (surface != "shared") { + let lightDarkVal = `light-dark(${token.original.value[surface].light}, ${token.original.value[surface].dark})`; + token.original.value[surface].default = lightDarkVal; + return token.value; + } + let value = `light-dark(${token.original.value.light}, ${token.original.value.dark})`; + token.original.value.default = value; + return value; + }; + + StyleDictionary.registerTransform({ + type: "value", + transitive: true, + name, + matcher, + transformer, + }); + + return name; +}; + +/** + * Format the tokens dictionary to a string. This mostly defers to + * StyleDictionary.createPropertyFormatter but first it sorts the tokens based + * on the groupings in TOKEN_SECTIONS and adds comment headers to CSS output. + * + * @param {object} options + * Options for tokens to format. + * @param {string} options.format + * The format to output. Supported: "css" + * @param {object} options.dictionary + * The tokens dictionary. + * @param {string} options.outputReferences + * Whether to output variable references. + * @param {object} options.formatting + * The formatting settings to be passed to createPropertyFormatter. + * @returns {string} The formatted tokens. + */ +function formatVariables({ format, dictionary, outputReferences, formatting }) { + let lastSection = []; + let propertyFormatter = createPropertyFormatter({ + outputReferences, + dictionary, + format, + formatting, + }); + + let outputParts = []; + let remainingTokens = [...dictionary.allTokens]; + let isFirst = true; + + function tokenParts(name) { + let lastDash = name.lastIndexOf("-"); + let suffix = name.substring(lastDash + 1); + if (TSHIRT_ORDER.includes(suffix) || STATE_ORDER.includes(suffix)) { + return [name.substring(0, lastDash), suffix]; + } + return [name, ""]; + } + + for (let [label, selector] of Object.entries(TOKEN_SECTIONS)) { + let sectionMatchers = Array.isArray(selector) ? selector : [selector]; + let sectionParts = []; + + remainingTokens = remainingTokens.filter(token => { + if ( + sectionMatchers.some(m => + m.test ? m.test(token.name) : token.name.startsWith(m) + ) + ) { + sectionParts.push(token); + return false; + } + return true; + }); + + if (sectionParts.length) { + sectionParts.sort((a, b) => { + let aName = formatBaseTokenNames(a.name); + let bName = formatBaseTokenNames(b.name); + let [aToken, aSuffix] = tokenParts(aName); + let [bToken, bSuffix] = tokenParts(bName); + if (aSuffix || bSuffix) { + if (aToken == bToken) { + let aSize = TSHIRT_ORDER.indexOf(aSuffix); + let bSize = TSHIRT_ORDER.indexOf(bSuffix); + if (aSize != -1 && bSize != -1) { + return aSize - bSize; + } + let aState = STATE_ORDER.indexOf(aSuffix); + let bState = STATE_ORDER.indexOf(bSuffix); + if (aState != -1 && bState != -1) { + return aState - bState; + } + } + } + return aToken.localeCompare(bToken, undefined, { numeric: true }); + }); + + let headingParts = []; + if (!isFirst) { + headingParts.push(""); + } + isFirst = false; + + let sectionLevel = "**"; + let labelParts = label.split("/"); + for (let i = 0; i < labelParts.length; i++) { + if (labelParts[i] != lastSection[i]) { + headingParts.push( + `${formatting.indentation}/${sectionLevel} ${labelParts[i]} ${sectionLevel}/` + ); + } + sectionLevel += "*"; + } + lastSection = labelParts; + + outputParts = outputParts.concat( + headingParts.concat(sectionParts.map(propertyFormatter)) + ); + } + } + + return outputParts.join("\n"); +} + +// Easy way to grab variable values later for display. +let variableLookupTable = {}; + +function storybookJSFormat(args) { + let dictionary = Object.assign({}, args.dictionary); + let resolvedTokens = dictionary.allTokens.map(token => { + let tokenVal = resolveReferences(dictionary, token.original); + return { + name: token.name, + ...tokenVal, + }; + }); + dictionary.allTokens = dictionary.allProperties = resolvedTokens; + + let parsedData = JSON.parse( + formatBaseTokenNames( + StyleDictionary.format["javascript/module-flat"]({ + ...args, + dictionary, + }) + ) + .trim() + .replaceAll(/(^module\.exports\s*=\s*|\;$)/g, "") + ); + let storybookTables = formatTokensTablesData(parsedData); + + return `${customFileHeader({ platform: "storybook" })} + export const storybookTables = ${JSON.stringify(storybookTables)}; + + export const variableLookupTable = ${JSON.stringify(variableLookupTable)}; + `; +} + +function resolveReferences(dictionary, originalVal) { + let resolvedValues = {}; + Object.entries(originalVal).forEach(([key, value]) => { + if (typeof value === "object" && value != null) { + resolvedValues[key] = resolveReferences(dictionary, value); + } else { + let resolvedVal = getValueWithReferences(dictionary, value); + resolvedValues[key] = resolvedVal; + } + }); + return resolvedValues; +} + +function getValueWithReferences(dictionary, value) { + let valWithRefs = value; + if (dictionary.usesReference(value)) { + dictionary.getReferences(value).forEach(ref => { + valWithRefs = valWithRefs.replace( + `{${ref.path.join(".")}}`, + `var(--${ref.name})` + ); + }); + } + return valWithRefs; +} + +function formatTokensTablesData(tokensData) { + let tokensTables = {}; + Object.entries(tokensData).forEach(([key, value]) => { + variableLookupTable[key] = value; + let formattedToken = { + value, + name: `--${key}`, + }; + + let tableName = getTableName(key); + if (tokensTables[tableName]) { + tokensTables[tableName].push(formattedToken); + } else { + tokensTables[tableName] = [formattedToken]; + } + }); + return tokensTables; +} + +const SINGULAR_TABLE_CATEGORIES = [ + "button", + "color", + "link", + "size", + "space", + "opacity", + "outline", + "padding", + "margin", +]; + +function getTableName(tokenName) { + let replacePattern = /^(button-|input-text-|focus-|checkbox-)/; + if (tokenName.match(replacePattern)) { + tokenName = tokenName.replace(replacePattern, ""); + } + let [category, type] = tokenName.split("-"); + return SINGULAR_TABLE_CATEGORIES.includes(category) || !type + ? category + : `${category}-${type}`; +} + +module.exports = { + source: ["design-tokens.json"], + format: { + "css/variables/shared": createDesktopFormat(), + "css/variables/brand": createDesktopFormat("brand"), + "css/variables/platform": createDesktopFormat("platform"), + "javascript/storybook": storybookJSFormat, + }, + platforms: { + css: { + options: { + outputReferences: true, + showFileHeader: false, + }, + transforms: [ + ...StyleDictionary.transformGroup.css, + ...["shared", "platform", "brand"].map(createLightDarkTransform), + ], + files: [ + { + destination: "tokens-shared.css", + format: "css/variables/shared", + }, + { + destination: "tokens-brand.css", + format: "css/variables/brand", + filter: token => + typeof token.original.value == "object" && + token.original.value.brand, + }, + { + destination: "tokens-platform.css", + format: "css/variables/platform", + filter: token => + typeof token.original.value == "object" && + token.original.value.platform, + }, + ], + }, + storybook: { + options: { + outputReferences: true, + showFileHeader: false, + }, + transforms: [ + ...StyleDictionary.transformGroup.css, + ...["shared", "platform", "brand"].map(createLightDarkTransform), + ], + files: [ + { + destination: "tokens-storybook.mjs", + format: "javascript/storybook", + }, + ], + }, + }, +}; -- cgit v1.2.3