From fc44ab9d86b171fca1969e185934d52fe29fb7f7 Mon Sep 17 00:00:00 2001 From: Hanna Jones Date: Mon, 29 Jan 2024 10:15:45 -0500 Subject: [PATCH] FIDEFE-4626 - Add HCM media queries to built CSS (#3) * FIDEFE-4626 - Add HCM media queries to built CSS * remove some of the 'default' references from JSON, fix test fixtures * address review comments --- .../design-system/build/css/tokens-shared.css | 30 ++++- toolkit/themes/shared/design-system/config.js | 103 +++++++++++++++++- .../shared/design-system/design-tokens.json | 74 ++++++++++--- .../themes/shared/design-system/package.json | 2 +- .../shared/design-system/tests/config.test.js | 84 ++++++++++---- 5 files changed, 250 insertions(+), 43 deletions(-) diff --git a/toolkit/themes/shared/design-system/build/css/tokens-shared.css b/toolkit/themes/shared/design-system/build/css/tokens-shared.css index ccdd9f40aed99..2b1742c850d54 100644 --- a/toolkit/themes/shared/design-system/build/css/tokens-shared.css +++ b/toolkit/themes/shared/design-system/build/css/tokens-shared.css @@ -1,7 +1,5 @@ :root { - --text-deemphasized: color-mix(in srgb, currentColor 60%, transparent); - --text-platform: currentColor; - --text-color: CanvasText; + --text-color-deemphasized: color-mix(in srgb, currentColor 60%, transparent); --color-white: #ffffff; --color-yellow-05: #ffebcd; --color-yellow-80: #5a3100; @@ -32,9 +30,33 @@ --color-blue-50: #0060df; --color-blue-30: #73a7f3; --color-black-a10: rgba(0, 0, 0, 0.1); - --text-brand: light-dark(var(--color-gray-100), var(--color-gray-05)); + --border-width: 1px; + --border-radius-medium: 8px; + --border-radius-small: 4px; + --border-radius-circle: 9999px; --color-background-warning: light-dark(var(--color-yellow-05), var(--color-blue-80)); --color-background-success: light-dark(var(--color-green-05), var(--color-yellow-80)); --color-background-information: light-dark(var(--color-blue-05), var(--color-blue-80)); --color-background-critical: light-dark(var(--color-red-05), var(--color-red-80)); } + +@media (prefers-contrast) { + :root { + --text-color-deemphasized: inherit; + --text-color-default: CanvasText; + --border-interactive-color-disabled: GrayText; + --border-interactive-color-active: AccentColor; + --border-interactive-color-hover: SelectedItem; + --border-interactive-color-default: AccentColor; + --border-color: var(--text-color-default); + } +} + +@media (forced-colors) { + :root { + --border-interactive-color-disabled: GrayText; + --border-interactive-color-active: ButtonText; + --border-interactive-color-hover: ButtonText; + --border-interactive-color-default: ButtonText; + } +} diff --git a/toolkit/themes/shared/design-system/config.js b/toolkit/themes/shared/design-system/config.js index 4bc3afd9ea187..ae68229844782 100644 --- a/toolkit/themes/shared/design-system/config.js +++ b/toolkit/themes/shared/design-system/config.js @@ -1,4 +1,3 @@ - /* 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/. */ @@ -6,6 +5,101 @@ /* eslint-env node */ const StyleDictionary = require("style-dictionary"); +const { formattedVariables } = StyleDictionary.formatHelpers; + +const MEDIA_QUERY_PROPERTY_MAP = { + "forced-colors": "forcedColors", + "prefers-contrast": "prefersContrast", +}; + +/** + * Formats built CSS to include "prefers-contrast" and "forced-colors" media + * queries. + * + * @param {object} args + * Formatter arguments provided by style-dictionary. See more at + * https://amzn.github.io/style-dictionary/#/formats?id=formatter + * @returns {string} + * Formatted CSS including media queries. + */ +function hcmFormatter(args) { + return ( + formatTokens({ args }) + + formatTokens({ mediaQuery: "prefers-contrast", args }) + + formatTokens({ mediaQuery: "forced-colors", 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 {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, args }) { + let prop = MEDIA_QUERY_PROPERTY_MAP[mediaQuery] ?? "value"; + let dictionary = Object.assign({}, args.dictionary); + let tokens = []; + + dictionary.allTokens.forEach(token => { + let value = token[prop] || token.original.value[prop] + if (value && typeof value !== "object") { + let formattedToken = transformTokenValue(token, prop, dictionary); + tokens.push(formattedToken); + } + }); + + dictionary.allTokens = dictionary.allProperties = tokens; + + let formattedVars = formattedVariables({ + format: "css", + dictionary, + outputReferences: args.options.outputReferences, + formatting: { + indentation: mediaQuery ? " " : " ", + }, + }); + + // Weird spacing below is unfortunately necessary formatting the built CSS. + if (mediaQuery) { + return ` +@media (${mediaQuery}) { + :root { +${formattedVars} + } +} +`; + } + + return `:root {\n${formattedVars}\n}\n`; +} + +/** + * Takes a token object and changes "value" based on the supplied prop. Also + * preserves variable references when necessary. + * + * @param {object} token - Token object parsed from JSON by style-dictionary. + * @param {string} prop + * Name of the property used to get the token's new value. + * @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, prop, dictionary) { + let originalVal = token.original.value[prop]; + if (dictionary.usesReference(originalVal)) { + let refs = dictionary.getReferences(originalVal); + return { ...token, value: `var(--${refs[0].name})` }; + } + return { ...token, value: token[prop] || originalVal}; +} module.exports = { source: ["design-tokens.json"], @@ -15,7 +109,7 @@ module.exports = { transitive: true, name: "defaultTransform", matcher: token => token.original.value.default, - transformer: token => token.original.value.default + transformer: token => token.original.value.default, }, lightDarkTransform: { type: "value", @@ -27,6 +121,9 @@ module.exports = { }, }, }, + format: { + "css/variables/hcm": hcmFormatter, + }, platforms: { css: { // The ordering of transforms matter, so if we encountered @@ -42,7 +139,7 @@ module.exports = { files: [ { destination: "tokens-shared.css", - format: "css/variables", + format: "css/variables/hcm", options: { outputReferences: true, showFileHeader: false, diff --git a/toolkit/themes/shared/design-system/design-tokens.json b/toolkit/themes/shared/design-system/design-tokens.json index aa58401118f7a..c4fa0823bc4a8 100644 --- a/toolkit/themes/shared/design-system/design-tokens.json +++ b/toolkit/themes/shared/design-system/design-tokens.json @@ -1,4 +1,53 @@ { + "border": { + "color": { + "value": { + "prefersContrast": "{text.color.default}" + } + }, + "interactive": { + "color": { + "default": { + "value": { + "prefersContrast": "AccentColor", + "forcedColors": "ButtonText" + } + }, + "hover": { + "value": { + "forcedColors": "ButtonText", + "prefersContrast": "SelectedItem" + } + }, + "active": { + "value": { + "prefersContrast": "AccentColor", + "forcedColors": "ButtonText" + } + }, + "disabled": { + "value": { + "prefersContrast": "GrayText", + "forcedColors": "GrayText" + } + } + } + }, + "radius": { + "circle": { + "value": "9999px" + }, + "small": { + "value": "4px" + }, + "medium": { + "value": "8px" + } + }, + "width": { + "value": "1px" + } + }, "color": { "black": { "a10": { @@ -133,21 +182,16 @@ }, "text": { "color": { - "value": "CanvasText" - }, - "brand": { - "value": { - "light": "{color.gray.100}", - "dark": "{color.gray.05}" - } - }, - "platform": { - "value": "currentColor" - }, - "deemphasized": { - "value": { - "default": "color-mix(in srgb, currentColor 60%, transparent)", - "prefersContrast": "inherit" + "default": { + "value": { + "prefersContrast": "CanvasText" + } + }, + "deemphasized": { + "value": { + "default": "color-mix(in srgb, currentColor 60%, transparent)", + "prefersContrast": "inherit" + } } } } diff --git a/toolkit/themes/shared/design-system/package.json b/toolkit/themes/shared/design-system/package.json index 36d2a0eecda3f..26e02b34d6ddd 100644 --- a/toolkit/themes/shared/design-system/package.json +++ b/toolkit/themes/shared/design-system/package.json @@ -7,7 +7,7 @@ "jest/globals": true }, "scripts": { - "test": "jest --testPathPattern=tests", + "test": "jest --testPathPattern=tests --silent", "build": "style-dictionary build" }, "author": "", diff --git a/toolkit/themes/shared/design-system/tests/config.test.js b/toolkit/themes/shared/design-system/tests/config.test.js index 19b4f577cae4e..0fdf2d31e835a 100644 --- a/toolkit/themes/shared/design-system/tests/config.test.js +++ b/toolkit/themes/shared/design-system/tests/config.test.js @@ -7,11 +7,15 @@ const config = require("../config"); const TEST_BUILD_PATH = "tests/build/css/"; -const EXPECTED_CSS_RULES = { - "--color-background-critical": "light-dark(var(--color-red-05), var(--color-red-80))", - "--color-background-information": "light-dark(var(--color-blue-05), var(--color-blue-80))", - "--color-background-success": "light-dark(var(--color-green-05), var(--color-yellow-80))", - "--color-background-warning": "light-dark(var(--color-yellow-05), var(--color-blue-80))", +const BASE_CSS_RULES = { + "--color-background-critical": + "light-dark(var(--color-red-05), var(--color-red-80))", + "--color-background-information": + "light-dark(var(--color-blue-05), var(--color-blue-80))", + "--color-background-success": + "light-dark(var(--color-green-05), var(--color-yellow-80))", + "--color-background-warning": + "light-dark(var(--color-yellow-05), var(--color-blue-80))", "--color-black-a10": "rgba(0, 0, 0, 0.1)", "--color-blue-05": "#deeafc", "--color-blue-30": "#73a7f3", @@ -42,10 +46,35 @@ const EXPECTED_CSS_RULES = { "--color-yellow-30": "#e49c49", "--color-yellow-50": "#cd411e", "--color-yellow-80": "#5a3100", - "--text-brand": "light-dark(var(--color-gray-100), var(--color-gray-05))", - "--text-color": "CanvasText", - "--text-deemphasized": "color-mix(in srgb, currentColor 60%, transparent)", - "--text-platform": "currentColor", + "--text-color-deemphasized": + "color-mix(in srgb, currentColor 60%, transparent)", + "--border-radius-circle": "9999px", + "--border-radius-small": "4px", + "--border-radius-medium": "8px", + "--border-width": "1px", +}; + +const PREFERS_CONTRAST_CSS_RULES = { + "--text-color-deemphasized": "inherit", + "--text-color-default": "CanvasText", + "--border-interactive-color-disabled": "GrayText", + "--border-interactive-color-active": "AccentColor", + "--border-interactive-color-hover": "SelectedItem", + "--border-interactive-color-default": "AccentColor", + "--border-color": "var(--text-color-default)", +}; + +const FORCED_COLORS_CSS_RULES = { + "--border-interactive-color-disabled": "GrayText", + "--border-interactive-color-active": "ButtonText", + "--border-interactive-color-hover": "ButtonText", + "--border-interactive-color-default": "ButtonText", +}; + +const FIXTURE_BY_QUERY = { + base: BASE_CSS_RULES, + "prefers-contrast": PREFERS_CONTRAST_CSS_RULES, + "forced-colors": FORCED_COLORS_CSS_RULES, }; // Use our real config, just modify some values for the test. @@ -56,21 +85,36 @@ testConfig.platforms.css.buildPath = TEST_BUILD_PATH; describe("generated CSS", () => { StyleDictionary.extend(testConfig).buildAllPlatforms(); - describe("css/variables", () => { + describe("css/variables/hcm format", () => { const output = fs.readFileSync(`${TEST_BUILD_PATH}tokens-shared.css`, { encoding: "UTF-8", }); - - it("should produce the expected CSS", () => { - let formattedCSS = output.split("\n").reduce((rulesObj, rule) => { - let [key, val] = rule.split(":"); - if (key && val) { - return { ...rulesObj, [key.trim()]: val.trim().replace(";", "") }; - } - return rulesObj; - }, {}); - expect(formattedCSS).toMatchObject(EXPECTED_CSS_RULES); + let rulesByMediaQuery = output.split("@media"); + + it("should contain three blocks of CSS, including media queries", () => { + expect(rulesByMediaQuery.length).toBe(3); + expect(rulesByMediaQuery[1]).toEqual( + expect.stringContaining("prefers-contrast") + ); + expect(rulesByMediaQuery[2]).toEqual( + expect.stringContaining("forced-colors") + ); + }); + + rulesByMediaQuery.forEach(ruleSet => { + let queryName = ruleSet.trim().match(/(?<=\().+?(?=\) \{)/) || "base"; + it(`should produce the expected ${queryName} CSS rules`, () => { + let formattedCSS = ruleSet.split("\n").reduce((rulesObj, rule) => { + let [key, val] = rule.split(":"); + if (key.trim() && val) { + return { ...rulesObj, [key.trim()]: val.trim().replace(";", "") }; + } + return rulesObj; + }, {}); + + expect(formattedCSS).toStrictEqual(FIXTURE_BY_QUERY[queryName]); + }); }); }); });