From 8450a450598b23cb57d92cc2914eae4b8c651b02 Mon Sep 17 00:00:00 2001 From: Abel van Beek <50890336+Imable@users.noreply.github.com> Date: Mon, 17 Jun 2024 12:37:03 +0200 Subject: [PATCH] feat: recursively expand nested composite tokens (#1244) * feat: recursively expand nested composite tokens * fix: expand object type check, maintain ref if possible * fix: handle multi-value object tokens like shadows --------- Co-authored-by: Abel van Beek Co-authored-by: jorenbroekema --- .changeset/lucky-buckets-wave.md | 10 ++ __tests__/utils/expandObjectTokens.test.js | 141 +++++++++++++++++++++ lib/utils/expandObjectTokens.js | 34 +++-- package-lock.json | 4 +- 4 files changed, 178 insertions(+), 11 deletions(-) create mode 100644 .changeset/lucky-buckets-wave.md diff --git a/.changeset/lucky-buckets-wave.md b/.changeset/lucky-buckets-wave.md new file mode 100644 index 000000000..32483b3a4 --- /dev/null +++ b/.changeset/lucky-buckets-wave.md @@ -0,0 +1,10 @@ +--- +'style-dictionary': minor +--- + +Some fixes for Expand utility: + +- Array values such as `dashArray` property of `strokeStyle` tokens no longer get expanded unintentionally, `typeof 'object'` check changed to `isPlainObject` check. +- Nested object-value tokens (such as `style` property inside `border` tokens) will now also be expanded. +- When references are involved during expansion, the resolved value is used when the property is an object, if not, then we keep the reference as is. + This is because if the reference is to an object value, the expansion might break the reference. diff --git a/__tests__/utils/expandObjectTokens.test.js b/__tests__/utils/expandObjectTokens.test.js index b570febb7..8efc7d4e3 100644 --- a/__tests__/utils/expandObjectTokens.test.js +++ b/__tests__/utils/expandObjectTokens.test.js @@ -351,6 +351,147 @@ describe('utils', () => { }); }); + it('should expand nested composite tokens', () => { + const refInput = { + black: { + value: '#000', + type: 'color', + }, + stroke: { + value: { + dashArray: ['0.5rem', '0.25rem'], + lineCap: 'round', + }, + type: 'strokeStyle', + }, + border: { + value: { + color: '{black}', + width: '3px', + style: '{stroke}', + }, + type: 'border', + }, + }; + + const expanded = expandTokens(refInput, { + expand: true, + usesDtcg: false, + }); + + expect(expanded).to.eql({ + black: { + value: '#000', + type: 'color', + }, + stroke: { + dashArray: { + value: ['0.5rem', '0.25rem'], + type: 'dimension', + }, + lineCap: { + value: 'round', + type: 'lineCap', + }, + }, + border: { + // color can remain unresolved ref because its resolved value is not an object + color: { value: '{black}', type: 'color' }, + width: { value: '3px', type: 'dimension' }, + // style must be its resolved value because it is an object and potentially gets expanded, + // breaking the original reference + style: { + dashArray: { + value: ['0.5rem', '0.25rem'], + type: 'dimension', + }, + lineCap: { + value: 'round', + type: 'lineCap', + }, + }, + }, + }); + }); + + it('should expand shadow tokens', () => { + const refInput = { + shade: { + type: 'shadow', + value: [ + { + offsetX: '2px', + offsetY: '4px', + blur: '2px', + spread: '0', + color: '#000', + }, + { + offsetX: '10px', + offsetY: '12px', + blur: '4px', + spread: '3px', + color: '#ccc', + }, + ], + }, + }; + + const expanded = expandTokens(refInput, { + expand: true, + usesDtcg: false, + }); + + expect(expanded).to.eql({ + shade: { + 1: { + offsetX: { + type: 'dimension', + value: '2px', + }, + offsetY: { + type: 'dimension', + value: '4px', + }, + blur: { + type: 'dimension', + value: '2px', + }, + spread: { + type: 'dimension', + value: '0', + }, + color: { + type: 'color', + value: '#000', + }, + }, + 2: { + offsetX: { + type: 'dimension', + value: '10px', + }, + offsetY: { + type: 'dimension', + value: '12px', + }, + blur: { + type: 'dimension', + value: '4px', + }, + spread: { + type: 'dimension', + value: '3px', + }, + color: { + type: 'color', + value: '#ccc', + }, + }, + }, + }); + }); + it('should support DTCG format', () => { const input = { border: { diff --git a/lib/utils/expandObjectTokens.js b/lib/utils/expandObjectTokens.js index 83bbbefa8..1d2e08f5d 100644 --- a/lib/utils/expandObjectTokens.js +++ b/lib/utils/expandObjectTokens.js @@ -13,6 +13,7 @@ import { resolveReferences } from './references/resolveReferences.js'; import usesReferences from './references/usesReferences.js'; import { deepmerge } from './deepmerge.js'; +import isPlainObject from 'is-plain-obj'; /** * @typedef {import('../../types/DesignToken.d.ts').DesignToken} DesignToken @@ -53,6 +54,10 @@ export const DTCGTypesMap = { letterSpacing: 'dimension', lineHeight: 'number', }, + // https://design-tokens.github.io/community-group/format/#object-value + strokeStyle: { + dashArray: 'dimension', + }, }; /** @@ -198,7 +203,7 @@ export function expandToken(token, opts, platform) { function expandTokensRecurse(slice, original, opts, platform) { for (const key in slice) { const token = slice[key]; - if (typeof token !== 'object' || token === null) { + if (!isPlainObject(token) || token === null) { continue; } const uses$ = opts.usesDtcg; @@ -207,17 +212,28 @@ function expandTokensRecurse(slice, original, opts, platform) { // if our token is a ref, we have to resolve it first in order to expand its value if (typeof value === 'string' && usesReferences(value)) { value = resolveReferences(value, original, { usesDtcg: uses$ }); - token[uses$ ? '$value' : 'value'] = value; } - if (typeof value === 'object' && shouldExpand(token, opts, platform)) { - // TODO: Support nested objects, e.g. a border can have a style prop (strokeStyle) which itself - // can also be an object value with dashArray and lineCap props. - // More info: https://design-tokens.github.io/community-group/format/#example-border-composite-token-examples - slice[key] = expandToken(token, opts, platform); + + if ( + isPlainObject(value) || + // support multi-value arrays where each item is an object, e.g. shadow tokens + (Array.isArray(value) && value.every((sub) => isPlainObject(sub))) + ) { + // if the resolved value is an object, then we must assume it could get expanded and + // we must set the value to the resolved value, since the reference might be broken after expansion + slice[key][uses$ ? '$value' : 'value'] = value; + + if (shouldExpand(token, opts, platform)) { + slice[key] = expandToken(token, opts, platform); + } } - } else { - expandTokensRecurse(token, original, opts, platform); } + // We might expect an else statement here on the line above, but we also want + // to recurse if a value is present so that we support expanding nested object values, + // e.g. a border can have a style prop (strokeStyle) which itself + // can also be an object value with dashArray and lineCap props. + // More info: https://design-tokens.github.io/community-group/format/#example-border-composite-token-examples + expandTokensRecurse(slice[key], original, opts, platform); } } diff --git a/package-lock.json b/package-lock.json index c63f12074..6922e1849 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "style-dictionary", - "version": "4.0.0-prerelease.35", + "version": "4.0.0-prerelease.36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "style-dictionary", - "version": "4.0.0-prerelease.35", + "version": "4.0.0-prerelease.36", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": {