diff --git a/src/filters/index.ts b/src/filters/index.ts index d94656209..e70c393a7 100644 --- a/src/filters/index.ts +++ b/src/filters/index.ts @@ -12,3 +12,4 @@ export {isNumber} from './isNumber.js' export {isShadow} from './isShadow.js' export {isSource} from './isSource.js' export {isTypography} from './isTypography.js' +export {isTransition} from './isTransition.js' diff --git a/src/filters/isTransition.test.ts b/src/filters/isTransition.test.ts new file mode 100644 index 000000000..8d1429e67 --- /dev/null +++ b/src/filters/isTransition.test.ts @@ -0,0 +1,22 @@ +import {getMockToken} from '../test-utilities/index.js' +import {isTransition} from './isTransition.js' + +describe('Filter: isTransition', () => { + it('returns true if $type property is `transition`', () => { + expect(isTransition(getMockToken({$type: 'transition'}))).toStrictEqual(true) + }) + + it('returns false if $type property is not `transition`', () => { + expect(isTransition(getMockToken({$type: 'pumpkin'}))).toStrictEqual(false) + }) + + it('returns false if $type property is missing', () => { + expect(isTransition(getMockToken({alpha: 0.4}))).toStrictEqual(false) + }) + + it('returns false if $type property is falsy', () => { + expect(isTransition(getMockToken({$type: false}))).toStrictEqual(false) + expect(isTransition(getMockToken({$type: undefined}))).toStrictEqual(false) + expect(isTransition(getMockToken({$type: null}))).toStrictEqual(false) + }) +}) diff --git a/src/filters/isTransition.ts b/src/filters/isTransition.ts new file mode 100644 index 000000000..a7351b47b --- /dev/null +++ b/src/filters/isTransition.ts @@ -0,0 +1,10 @@ +import type {TransformedToken} from 'style-dictionary/types' + +/** + * @description Checks if token is of $type `transition` + * @param token [TransformedToken](https://github.com/amzn/style-dictionary/blob/main/types/TransformedToken.d.ts) + * @returns boolean + */ +export const isTransition = (token: TransformedToken): boolean => { + return token.$type === 'transition' +} diff --git a/src/platforms/css.ts b/src/platforms/css.ts index 14f61654c..4fd5e39e5 100644 --- a/src/platforms/css.ts +++ b/src/platforms/css.ts @@ -35,6 +35,7 @@ export const css: PlatformInitializer = (outputFile, prefix, buildPath, options) 'shadow/css', 'border/css', 'typography/css', + 'transition/css', 'fontFamily/css', 'fontWeight/number', 'gradient/css', diff --git a/src/primerStyleDictionary.ts b/src/primerStyleDictionary.ts index 35af51de3..e6dbaabda 100644 --- a/src/primerStyleDictionary.ts +++ b/src/primerStyleDictionary.ts @@ -25,6 +25,7 @@ import { dimensionToRemPxArray, floatToPixel, floatToPixelUnitless, + transitionToCss, } from './transformers/index.js' import { javascriptCommonJs, @@ -152,6 +153,8 @@ PrimerStyleDictionary.registerTransform(borderToCss) PrimerStyleDictionary.registerTransform(typographyToCss) +PrimerStyleDictionary.registerTransform(transitionToCss) + PrimerStyleDictionary.registerTransform(fontWeightToNumber) PrimerStyleDictionary.registerTransform(fontFamilyToCss) diff --git a/src/schemas/designToken.ts b/src/schemas/designToken.ts index a01f59ae0..8d6c8a8cb 100644 --- a/src/schemas/designToken.ts +++ b/src/schemas/designToken.ts @@ -13,6 +13,7 @@ import {shadowToken} from './shadowToken.js' import {durationToken} from './durationToken.js' import {cubicBezierToken} from './cubicBezierToken.js' import {gradientToken} from './gradientToken.js' +import {transitionToken} from './transitionToken.js' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: TODO: fix this @@ -34,6 +35,7 @@ export const designToken = z.record( numberToken, durationToken, stringToken, + transitionToken, ]), designToken, ]) diff --git a/src/schemas/transitionToken.ts b/src/schemas/transitionToken.ts new file mode 100644 index 000000000..81b355cc5 --- /dev/null +++ b/src/schemas/transitionToken.ts @@ -0,0 +1,20 @@ +import {z} from 'zod' +import {baseToken} from './baseToken.js' +import {referenceValue} from './referenceValue.js' +import {durationToken} from './durationToken.js' +import {cubicBezierToken} from './cubicBezierToken.js' +import {tokenType} from './tokenType.js' + +export const transitionToken = baseToken + .extend({ + $value: z.union([ + z.object({ + duration: z.union([durationToken.shape.$value, referenceValue]), + timingFunction: z.union([cubicBezierToken.shape.$value, referenceValue]), + delay: z.union([durationToken.shape.$value, referenceValue]).optional(), + }), + referenceValue, + ]), + $type: tokenType('transition'), + }) + .strict() diff --git a/src/schemas/transitionTokenSchema.test.ts b/src/schemas/transitionTokenSchema.test.ts new file mode 100644 index 000000000..92d67d1d5 --- /dev/null +++ b/src/schemas/transitionTokenSchema.test.ts @@ -0,0 +1,138 @@ +import {transitionToken} from './transitionToken.js' + +describe('Schema: transitionToken', () => { + const validToken = { + $value: { + duration: '300ms', + timingFunction: [0.4, 0, 0.2, 1], + delay: '0ms', + }, + $type: 'transition', + $description: 'Standard transition', + } + + it('parses valid transition tokens', () => { + expect(transitionToken.safeParse(validToken).success).toStrictEqual(true) + }) + + it('parses valid transition tokens without delay', () => { + const tokenWithoutDelay = { + $value: { + duration: '300ms', + timingFunction: [0.4, 0, 0.2, 1], + }, + $type: 'transition', + $description: 'Standard transition without delay', + } + expect(transitionToken.safeParse(tokenWithoutDelay).success).toStrictEqual(true) + }) + + it('parses valid transition tokens with reference values', () => { + const tokenWithReferences = { + $value: { + duration: '{duration.medium}', + timingFunction: '{timing.easeInOut}', + delay: '{duration.small}', + }, + $type: 'transition', + $description: 'Transition with reference values', + } + expect(transitionToken.safeParse(tokenWithReferences).success).toStrictEqual(true) + }) + + it('parses valid transition tokens with direct reference', () => { + const tokenWithDirectReference = { + $value: '{transition.standard}', + $type: 'transition', + $description: 'Transition with direct reference', + } + expect(transitionToken.safeParse(tokenWithDirectReference).success).toStrictEqual(true) + }) + + it('fails on invalid properties', () => { + // additional element + expect( + transitionToken.safeParse({ + ...validToken, + extra: 'value', + }).success, + ).toStrictEqual(false) + // missing value + expect( + transitionToken.safeParse({ + $type: 'transition', + }).success, + ).toStrictEqual(false) + // missing type + expect( + transitionToken.safeParse({ + $value: { + duration: '300ms', + timingFunction: [0.4, 0, 0.2, 1], + }, + }).success, + ).toStrictEqual(false) + }) + + it('fails on invalid duration value', () => { + expect( + transitionToken.safeParse({ + ...validToken, + $value: { + ...validToken.$value, + duration: 'invalid', + }, + }).success, + ).toStrictEqual(false) + }) + + it('fails on invalid timing value', () => { + expect( + transitionToken.safeParse({ + ...validToken, + $value: { + ...validToken.$value, + timingFunction: [0.4, 0, 0.2], // Missing one value + }, + }).success, + ).toStrictEqual(false) + }) + + it('fails on invalid delay value', () => { + expect( + transitionToken.safeParse({ + ...validToken, + $value: { + ...validToken.$value, + delay: 'invalid', + }, + }).success, + ).toStrictEqual(false) + }) + + it('fails on wrong type', () => { + // invalid string + expect( + transitionToken.safeParse({ + ...validToken, + $type: 'motion', + }).success, + ).toStrictEqual(false) + // undefined + expect( + transitionToken.safeParse({ + ...validToken, + $type: undefined, + }).success, + ).toStrictEqual(false) + // no type + expect( + transitionToken.safeParse({ + $value: { + duration: '300ms', + timingFunction: [0.4, 0, 0.2, 1], + }, + }).success, + ).toStrictEqual(false) + }) +}) diff --git a/src/schemas/validTokenType.ts b/src/schemas/validTokenType.ts index 155399a43..49636485a 100644 --- a/src/schemas/validTokenType.ts +++ b/src/schemas/validTokenType.ts @@ -14,6 +14,7 @@ const validTypes = [ 'gradient', 'number', 'string', + 'transition', 'custom-viewportRange', ] as const diff --git a/src/test-utilities/getMockToken.ts b/src/test-utilities/getMockToken.ts index 885e29fd2..18a14df85 100644 --- a/src/test-utilities/getMockToken.ts +++ b/src/test-utilities/getMockToken.ts @@ -36,9 +36,9 @@ export const getMockToken = ( [key: keyof TransformedToken]: unknown }, options?: getMockTokenOptions, -) => { +): TransformedToken => { return { ...removeProps(mockTokenDefaults, options?.remove), ...valueOverrides, - } + } as TransformedToken } diff --git a/src/transformers/cubicBezierToCss.ts b/src/transformers/cubicBezierToCss.ts index c80343168..c7a0544a1 100644 --- a/src/transformers/cubicBezierToCss.ts +++ b/src/transformers/cubicBezierToCss.ts @@ -14,13 +14,17 @@ export const cubicBezierToCss: Transform = { filter: isCubicBezier, transform: (token: TransformedToken, _config: PlatformConfig) => { const value = token.$value ?? token.value - // throw value of more or less than 4 items in array - if (value.length !== 4 || value.some((item: unknown) => typeof item !== 'number')) { - throw new Error( - `Invalid cubicBezier token ${token.path.join('.')}, must be an array with 4 numbers, but got this instead: ${JSON.stringify(value)}`, - ) - } - // return value - return `cubic-bezier(${value.join(',')})` + return cubicBezierArrayToCss(value, token.path) }, } + +export const cubicBezierArrayToCss = (value: number[], path: string[]) => { + // throw value of more or less than 4 items in array + if (value.length !== 4 || value.some((item: unknown) => typeof item !== 'number')) { + throw new Error( + `Invalid cubicBezier token ${path.join('.')}, must be an array with 4 numbers, but got this instead: ${JSON.stringify(value)}`, + ) + } + // return value + return `cubic-bezier(${value.join(',')})` +} diff --git a/src/transformers/index.ts b/src/transformers/index.ts index 0490bfc28..f84f2cfc6 100644 --- a/src/transformers/index.ts +++ b/src/transformers/index.ts @@ -23,3 +23,4 @@ export {namePathToKebabCase} from './namePathToKebabCase.js' export {namePathToSlashNotation} from './namePathToSlashNotation.js' export {shadowToCss} from './shadowToCss.js' export {typographyToCss} from './typographyToCss.js' +export {transitionToCss} from './transitionToCss.js' diff --git a/src/transformers/transitionToCss.test.ts b/src/transformers/transitionToCss.test.ts new file mode 100644 index 000000000..2e197887d --- /dev/null +++ b/src/transformers/transitionToCss.test.ts @@ -0,0 +1,85 @@ +import {getMockToken} from '../test-utilities/getMockToken.js' +import {transitionToCss} from './transitionToCss.js' + +describe('transitionToCss', () => { + it('should transform basic transition token to CSS', () => { + const token = getMockToken({ + $value: { + duration: '300ms', + timingFunction: [0.4, 0, 0.2, 1], + }, + $type: 'transition', + }) + + expect(transitionToCss.transform(token, {}, {})).toBe('300ms cubic-bezier(0.4,0,0.2,1)') + }) + + it('should transform transition token with delay to CSS', () => { + const token = getMockToken({ + $value: { + duration: '300ms', + timingFunction: [0.4, 0, 0.2, 1], + delay: '100ms', + }, + $type: 'transition', + }) + + expect(transitionToCss.transform(token, {}, {})).toBe('300ms cubic-bezier(0.4,0,0.2,1) 100ms') + }) + + it('should transform transition token with resolved references', () => { + const token = { + $value: { + duration: '300ms', + timingFunction: [0.4, 0, 0.2, 1], + delay: '0ms', + }, + $type: 'transition', + } + const resolvedRefs = { + duration: '300ms', + timingFunction: [0.4, 0, 0.2, 1], + delay: '0ms', + } + + expect(transitionToCss.transform(token, resolvedRefs)).toBe('300ms cubic-bezier(0.4,0,0.2,1) 0ms') + }) + + it('should handle missing optional delay', () => { + const token = getMockToken({ + $value: { + duration: '300ms', + timingFunction: [0.4, 0, 0.2, 1], + }, + $type: 'transition', + }) + + expect(transitionToCss.transform(token, {}, {})).toBe('300ms cubic-bezier(0.4,0,0.2,1)') + }) + + it('should throw error for missing required properties', () => { + const token = getMockToken({ + $value: { + duration: '300ms', + }, + $type: 'transition', + }) + expect(() => transitionToCss.transform(token, {}, {})).toThrow( + 'Missing property: timingFunction on token with value {"duration":"300ms"}', + ) + }) + + it('should throw error for invalid timing value', () => { + const token = getMockToken({ + $value: { + duration: '300ms', + timingFunction: [0.4, 0, 0.2], // Missing one value + }, + $type: 'transition', + }) + + expect(() => transitionToCss.transform(token, {}, {})).toThrow( + 'Invalid cubicBezier token path, must be an array with 4 numbers, but got this instead: [0.4,0,0.2]', + ) + }) +}) diff --git a/src/transformers/transitionToCss.ts b/src/transformers/transitionToCss.ts new file mode 100644 index 000000000..1fd807160 --- /dev/null +++ b/src/transformers/transitionToCss.ts @@ -0,0 +1,32 @@ +import {isTransition} from '../filters/index.js' +import {cubicBezierArrayToCss} from './cubicBezierToCss.js' +import {checkRequiredTokenProperties} from './utilities/checkRequiredTokenProperties.js' +import {getTokenValue} from './utilities/getTokenValue.js' +import type {Transform, TransformedToken} from 'style-dictionary/types' + +/** + * @description converts transition tokens to CSS transition string + * @type value transformer — [StyleDictionary.ValueTransform](https://github.com/amzn/style-dictionary/blob/main/types/Transform.d.ts) + * @matcher matches all tokens of $type `transition` + * @transformer returns css transition `string` + */ +export const transitionToCss: Transform = { + name: 'transition/css', + type: 'value', + transitive: true, + filter: isTransition, + transform: (token: TransformedToken) => { + // extract value + const value = getTokenValue(token) + + // if value is a string, it's probably a reference, return as is + if (typeof value === 'string') { + return value + } + + // check required properties + checkRequiredTokenProperties(value, ['duration', 'timingFunction']) + + return `${value.duration} ${cubicBezierArrayToCss(value.timingFunction, token.path)} ${value.delay ? value.delay : ''}`.trim() + }, +}