diff --git a/change/@microsoft-adaptive-ui-881bac8a-4800-4331-9138-0c658998c854.json b/change/@microsoft-adaptive-ui-881bac8a-4800-4331-9138-0c658998c854.json new file mode 100644 index 00000000000..25d9be6701a --- /dev/null +++ b/change/@microsoft-adaptive-ui-881bac8a-4800-4331-9138-0c658998c854.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Update design tokens to use new resolve function", + "packageName": "@microsoft/adaptive-ui", + "email": "nicholasrice@users.noreply.github.com", + "dependentChangeType": "prerelease" +} diff --git a/change/@microsoft-fast-element-2a4431cf-f1de-44ed-bae4-392f10b74b86.json b/change/@microsoft-fast-element-2a4431cf-f1de-44ed-bae4-392f10b74b86.json new file mode 100644 index 00000000000..0d90ad06cb9 --- /dev/null +++ b/change/@microsoft-fast-element-2a4431cf-f1de-44ed-bae4-392f10b74b86.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Fix ExpressionObserer bug where watcher was not reset if the binding threw", + "packageName": "@microsoft/fast-element", + "email": "nicholasrice@users.noreply.github.com", + "dependentChangeType": "prerelease" +} diff --git a/change/@microsoft-fast-foundation-d9cdf375-e6b7-4a6e-87e6-81dc37d60605.json b/change/@microsoft-fast-foundation-d9cdf375-e6b7-4a6e-87e6-81dc37d60605.json new file mode 100644 index 00000000000..32298b8b42d --- /dev/null +++ b/change/@microsoft-fast-foundation-d9cdf375-e6b7-4a6e-87e6-81dc37d60605.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Refactor DesignToken to provide a resolve function to derived token values, implements WebComponent implementation on top of isomorphic DesignToken infrastructure", + "packageName": "@microsoft/fast-foundation", + "email": "nicholasrice@users.noreply.github.com", + "dependentChangeType": "prerelease" +} diff --git a/packages/utilities/adaptive-ui/docs/api-report.md b/packages/utilities/adaptive-ui/docs/api-report.md index d4f90cbdb4d..f5d4c97c454 100644 --- a/packages/utilities/adaptive-ui/docs/api-report.md +++ b/packages/utilities/adaptive-ui/docs/api-report.md @@ -7,6 +7,7 @@ import { CSSDesignToken } from '@microsoft/fast-foundation'; import { CSSDirective } from '@microsoft/fast-element'; import { DesignToken } from '@microsoft/fast-foundation'; +import { DesignTokenResolver } from '@microsoft/fast-foundation'; import { Direction } from '@microsoft/fast-web-utilities'; // @public (undocumented) @@ -111,7 +112,7 @@ export const bodyFont: CSSDesignToken; // @public export interface ColorRecipe { - evaluate(element: HTMLElement, reference?: Swatch): Swatch; + evaluate(resolver: DesignTokenResolver, reference?: Swatch): Swatch; } // @public @@ -185,7 +186,7 @@ export const elevationFlyoutSize: DesignToken; // @public export interface ElevationRecipe { - evaluate(element: HTMLElement, size: number): string; + evaluate(resolver: DesignTokenResolver, size: number): string; } // @public (undocumented) @@ -238,7 +239,7 @@ export function idealColorDeltaSwatchSet(palette: Palette, reference: Swatch, mi // @public export interface InteractiveColorRecipe { - evaluate(element: HTMLElement, reference?: Swatch): InteractiveSwatchSet; + evaluate(resolver: DesignTokenResolver, reference?: Swatch): InteractiveSwatchSet; } // @public diff --git a/packages/utilities/adaptive-ui/src/color/recipe.ts b/packages/utilities/adaptive-ui/src/color/recipe.ts index f78689d56b3..85182c1ede3 100644 --- a/packages/utilities/adaptive-ui/src/color/recipe.ts +++ b/packages/utilities/adaptive-ui/src/color/recipe.ts @@ -1,3 +1,4 @@ +import { DesignTokenResolver } from "@microsoft/fast-foundation"; import { Swatch } from "./swatch.js"; /** @@ -9,10 +10,10 @@ export interface ColorRecipe { /** * Evaluate a single color value. * - * @param element - The element for which to evaluate the color recipe + * @param resolver - A function that resolves design tokens * @param reference - The reference color, implementation defaults to `fillColor`, but sometimes overridden for nested color recipes */ - evaluate(element: HTMLElement, reference?: Swatch): Swatch; + evaluate(resolver: DesignTokenResolver, reference?: Swatch): Swatch; } /** @@ -24,10 +25,10 @@ export interface InteractiveColorRecipe { /** * Evaluate an interactive color set. * - * @param element - The element for which to evaluate the color recipe + * @param resolver - A function that resolves design tokens * @param reference - The reference color, implementation defaults to `fillColor`, but sometimes overridden for nested color recipes */ - evaluate(element: HTMLElement, reference?: Swatch): InteractiveSwatchSet; + evaluate(resolver: DesignTokenResolver, reference?: Swatch): InteractiveSwatchSet; } /** diff --git a/packages/utilities/adaptive-ui/src/design-tokens/color.ts b/packages/utilities/adaptive-ui/src/design-tokens/color.ts index 3bd0df31251..ebdb43e66bc 100644 --- a/packages/utilities/adaptive-ui/src/design-tokens/color.ts +++ b/packages/utilities/adaptive-ui/src/design-tokens/color.ts @@ -1,4 +1,5 @@ import { parseColorHexRGB } from "@microsoft/fast-colors"; +import { DesignTokenResolver } from "@microsoft/fast-foundation"; import { blackOrWhiteByContrast } from "../color/index.js"; import { ColorRecipe, @@ -67,43 +68,43 @@ export const accentFillFocusDelta = createNonCss( export const accentFillRecipe = createNonCss( "accent-fill-recipe" ).withDefault({ - evaluate: (element: HTMLElement, reference?: Swatch): InteractiveSwatchSet => + evaluate: (resolve: DesignTokenResolver, reference?: Swatch): InteractiveSwatchSet => contrastAndDeltaSwatchSet( - accentPalette.getValueFor(element), - reference || fillColor.getValueFor(element), - accentFillMinContrast.getValueFor(element), - accentFillRestDelta.getValueFor(element), - accentFillHoverDelta.getValueFor(element), - accentFillActiveDelta.getValueFor(element), - accentFillFocusDelta.getValueFor(element) + resolve(accentPalette), + reference || resolve(fillColor), + resolve(accentFillMinContrast), + resolve(accentFillRestDelta), + resolve(accentFillHoverDelta), + resolve(accentFillActiveDelta), + resolve(accentFillFocusDelta) ), }); /** @public */ export const accentFillRest = create("accent-fill-rest").withDefault( - (element: HTMLElement) => { - return accentFillRecipe.getValueFor(element).evaluate(element).rest; + (resolve: DesignTokenResolver) => { + return resolve(accentFillRecipe).evaluate(resolve).rest; } ); /** @public */ export const accentFillHover = create("accent-fill-hover").withDefault( - (element: HTMLElement) => { - return accentFillRecipe.getValueFor(element).evaluate(element).hover; + (resolve: DesignTokenResolver) => { + return resolve(accentFillRecipe).evaluate(resolve).hover; } ); /** @public */ export const accentFillActive = create("accent-fill-active").withDefault( - (element: HTMLElement) => { - return accentFillRecipe.getValueFor(element).evaluate(element).active; + (resolve: DesignTokenResolver) => { + return resolve(accentFillRecipe).evaluate(resolve).active; } ); /** @public */ export const accentFillFocus = create("accent-fill-focus").withDefault( - (element: HTMLElement) => { - return accentFillRecipe.getValueFor(element).evaluate(element).focus; + (resolve: DesignTokenResolver) => { + return resolve(accentFillRecipe).evaluate(resolve).focus; } ); @@ -113,12 +114,12 @@ export const accentFillFocus = create("accent-fill-focus").withDefault( export const foregroundOnAccentRecipe = createNonCss( "foreground-on-accent-recipe" ).withDefault({ - evaluate: (element: HTMLElement): InteractiveSwatchSet => + evaluate: (resolve: DesignTokenResolver): InteractiveSwatchSet => blackOrWhiteByContrastSet( - accentFillRest.getValueFor(element), - accentFillHover.getValueFor(element), - accentFillActive.getValueFor(element), - accentFillFocus.getValueFor(element), + resolve(accentFillRest), + resolve(accentFillHover), + resolve(accentFillActive), + resolve(accentFillFocus), ContrastTarget.NormalText, false ), @@ -128,32 +129,32 @@ export const foregroundOnAccentRecipe = createNonCss( export const foregroundOnAccentRest = create( "foreground-on-accent-rest" ).withDefault( - (element: HTMLElement) => - foregroundOnAccentRecipe.getValueFor(element).evaluate(element).rest + (resolve: DesignTokenResolver) => + resolve(foregroundOnAccentRecipe).evaluate(resolve).rest ); /** @public */ export const foregroundOnAccentHover = create( "foreground-on-accent-hover" ).withDefault( - (element: HTMLElement) => - foregroundOnAccentRecipe.getValueFor(element).evaluate(element).hover + (resolve: DesignTokenResolver) => + resolve(foregroundOnAccentRecipe).evaluate(resolve).hover ); /** @public */ export const foregroundOnAccentActive = create( "foreground-on-accent-active" ).withDefault( - (element: HTMLElement) => - foregroundOnAccentRecipe.getValueFor(element).evaluate(element).active + (resolve: DesignTokenResolver) => + resolve(foregroundOnAccentRecipe).evaluate(resolve).active ); /** @public */ export const foregroundOnAccentFocus = create( "foreground-on-accent-focus" ).withDefault( - (element: HTMLElement) => - foregroundOnAccentRecipe.getValueFor(element).evaluate(element).focus + (resolve: DesignTokenResolver) => + resolve(foregroundOnAccentRecipe).evaluate(resolve).focus ); // Accent Foreground @@ -187,46 +188,46 @@ export const accentForegroundFocusDelta = createNonCss( export const accentForegroundRecipe = createNonCss( "accent-foreground-recipe" ).withDefault({ - evaluate: (element: HTMLElement, reference?: Swatch): InteractiveSwatchSet => + evaluate: (resolve: DesignTokenResolver, reference?: Swatch): InteractiveSwatchSet => contrastAndDeltaSwatchSet( - accentPalette.getValueFor(element), - reference || fillColor.getValueFor(element), - accentForegroundMinContrast.getValueFor(element), - accentForegroundRestDelta.getValueFor(element), - accentForegroundHoverDelta.getValueFor(element), - accentForegroundActiveDelta.getValueFor(element), - accentForegroundFocusDelta.getValueFor(element) + resolve(accentPalette), + reference || resolve(fillColor), + resolve(accentForegroundMinContrast), + resolve(accentForegroundRestDelta), + resolve(accentForegroundHoverDelta), + resolve(accentForegroundActiveDelta), + resolve(accentForegroundFocusDelta) ), }); /** @public */ export const accentForegroundRest = create("accent-foreground-rest").withDefault( - (element: HTMLElement) => - accentForegroundRecipe.getValueFor(element).evaluate(element).rest + (resolve: DesignTokenResolver) => + resolve(accentForegroundRecipe).evaluate(resolve).rest ); /** @public */ export const accentForegroundHover = create( "accent-foreground-hover" ).withDefault( - (element: HTMLElement) => - accentForegroundRecipe.getValueFor(element).evaluate(element).hover + (resolve: DesignTokenResolver) => + resolve(accentForegroundRecipe).evaluate(resolve).hover ); /** @public */ export const accentForegroundActive = create( "accent-foreground-active" ).withDefault( - (element: HTMLElement) => - accentForegroundRecipe.getValueFor(element).evaluate(element).active + (resolve: DesignTokenResolver) => + resolve(accentForegroundRecipe).evaluate(resolve).active ); /** @public */ export const accentForegroundFocus = create( "accent-foreground-focus" ).withDefault( - (element: HTMLElement) => - accentForegroundRecipe.getValueFor(element).evaluate(element).focus + (resolve: DesignTokenResolver) => + resolve(accentForegroundRecipe).evaluate(resolve).focus ); // Neutral Foreground @@ -260,15 +261,15 @@ export const neutralForegroundFocusDelta = createNonCss( export const neutralForegroundRecipe = createNonCss( "neutral-foreground-recipe" ).withDefault({ - evaluate: (element: HTMLElement, reference?: Swatch): InteractiveSwatchSet => + evaluate: (resolve: DesignTokenResolver, reference?: Swatch): InteractiveSwatchSet => contrastAndDeltaSwatchSet( - neutralPalette.getValueFor(element), - reference || fillColor.getValueFor(element), - neutralForegroundMinContrast.getValueFor(element), - neutralForegroundRestDelta.getValueFor(element), - neutralForegroundHoverDelta.getValueFor(element), - neutralForegroundActiveDelta.getValueFor(element), - neutralForegroundFocusDelta.getValueFor(element) + resolve(neutralPalette), + reference || resolve(fillColor), + resolve(neutralForegroundMinContrast), + resolve(neutralForegroundRestDelta), + resolve(neutralForegroundHoverDelta), + resolve(neutralForegroundActiveDelta), + resolve(neutralForegroundFocusDelta) ), }); @@ -276,32 +277,32 @@ export const neutralForegroundRecipe = createNonCss( export const neutralForegroundRest = create( "neutral-foreground-rest" ).withDefault( - (element: HTMLElement) => - neutralForegroundRecipe.getValueFor(element).evaluate(element).rest + (resolve: DesignTokenResolver) => + resolve(neutralForegroundRecipe).evaluate(resolve).rest ); /** @public */ export const neutralForegroundHover = create( "neutral-foreground-hover" ).withDefault( - (element: HTMLElement) => - neutralForegroundRecipe.getValueFor(element).evaluate(element).hover + (resolve: DesignTokenResolver) => + resolve(neutralForegroundRecipe).evaluate(resolve).hover ); /** @public */ export const neutralForegroundActive = create( "neutral-foreground-active" ).withDefault( - (element: HTMLElement) => - neutralForegroundRecipe.getValueFor(element).evaluate(element).active + (resolve: DesignTokenResolver) => + resolve(neutralForegroundRecipe).evaluate(resolve).active ); /** @public */ export const neutralForegroundFocus = create( "neutral-foreground-focus" ).withDefault( - (element: HTMLElement) => - neutralForegroundRecipe.getValueFor(element).evaluate(element).focus + (resolve: DesignTokenResolver) => + resolve(neutralForegroundRecipe).evaluate(resolve).focus ); // Neutral Foreground Hint @@ -310,10 +311,10 @@ export const neutralForegroundFocus = create( export const neutralForegroundHintRecipe = createNonCss( "neutral-foreground-hint-recipe" ).withDefault({ - evaluate: (element: HTMLElement, reference?: Swatch): Swatch => + evaluate: (resolve: DesignTokenResolver, reference?: Swatch): Swatch => contrastSwatch( - neutralPalette.getValueFor(element), - reference || fillColor.getValueFor(element), + resolve(neutralPalette), + reference || resolve(fillColor), ContrastTarget.NormalText ), }); @@ -321,8 +322,8 @@ export const neutralForegroundHintRecipe = createNonCss( /** @public */ export const neutralForegroundHint = create( "neutral-foreground-hint" -).withDefault((element: HTMLElement) => - neutralForegroundHintRecipe.getValueFor(element).evaluate(element) +).withDefault((resolve: DesignTokenResolver) => + resolve(neutralForegroundHintRecipe).evaluate(resolve) ); // Neutral Fill @@ -351,39 +352,35 @@ export const neutralFillFocusDelta = createNonCss( export const neutralFillRecipe = createNonCss( "neutral-fill-recipe" ).withDefault({ - evaluate: (element: HTMLElement, reference?: Swatch): InteractiveSwatchSet => + evaluate: (resolve: DesignTokenResolver, reference?: Swatch): InteractiveSwatchSet => deltaSwatchSet( - neutralPalette.getValueFor(element), - reference || fillColor.getValueFor(element), - neutralFillRestDelta.getValueFor(element), - neutralFillHoverDelta.getValueFor(element), - neutralFillActiveDelta.getValueFor(element), - neutralFillFocusDelta.getValueFor(element) + resolve(neutralPalette), + reference || resolve(fillColor), + resolve(neutralFillRestDelta), + resolve(neutralFillHoverDelta), + resolve(neutralFillActiveDelta), + resolve(neutralFillFocusDelta) ), }); /** @public */ export const neutralFillRest = create("neutral-fill-rest").withDefault( - (element: HTMLElement) => - neutralFillRecipe.getValueFor(element).evaluate(element).rest + (resolve: DesignTokenResolver) => resolve(neutralFillRecipe).evaluate(resolve).rest ); /** @public */ export const neutralFillHover = create("neutral-fill-hover").withDefault( - (element: HTMLElement) => - neutralFillRecipe.getValueFor(element).evaluate(element).hover + (resolve: DesignTokenResolver) => resolve(neutralFillRecipe).evaluate(resolve).hover ); /** @public */ export const neutralFillActive = create("neutral-fill-active").withDefault( - (element: HTMLElement) => - neutralFillRecipe.getValueFor(element).evaluate(element).active + (resolve: DesignTokenResolver) => resolve(neutralFillRecipe).evaluate(resolve).active ); /** @public */ export const neutralFillFocus = create("neutral-fill-focus").withDefault( - (element: HTMLElement) => - neutralFillRecipe.getValueFor(element).evaluate(element).focus + (resolve: DesignTokenResolver) => resolve(neutralFillRecipe).evaluate(resolve).focus ); // Neutral Fill Input @@ -412,45 +409,45 @@ export const neutralFillInputFocusDelta = createNonCss( export const neutralFillInputRecipe = createNonCss( "neutral-fill-input-recipe" ).withDefault({ - evaluate: (element: HTMLElement, reference?: Swatch): InteractiveSwatchSet => + evaluate: (resolve: DesignTokenResolver, reference?: Swatch): InteractiveSwatchSet => deltaSwatchSet( - neutralPalette.getValueFor(element), - reference || fillColor.getValueFor(element), - neutralFillInputRestDelta.getValueFor(element), - neutralFillInputHoverDelta.getValueFor(element), - neutralFillInputActiveDelta.getValueFor(element), - neutralFillInputFocusDelta.getValueFor(element) + resolve(neutralPalette), + reference || resolve(fillColor), + resolve(neutralFillInputRestDelta), + resolve(neutralFillInputHoverDelta), + resolve(neutralFillInputActiveDelta), + resolve(neutralFillInputFocusDelta) ), }); /** @public */ export const neutralFillInputRest = create("neutral-fill-input-rest").withDefault( - (element: HTMLElement) => - neutralFillInputRecipe.getValueFor(element).evaluate(element).rest + (resolve: DesignTokenResolver) => + resolve(neutralFillInputRecipe).evaluate(resolve).rest ); /** @public */ export const neutralFillInputHover = create( "neutral-fill-input-hover" ).withDefault( - (element: HTMLElement) => - neutralFillInputRecipe.getValueFor(element).evaluate(element).hover + (resolve: DesignTokenResolver) => + resolve(neutralFillInputRecipe).evaluate(resolve).hover ); /** @public */ export const neutralFillInputActive = create( "neutral-fill-input-active" ).withDefault( - (element: HTMLElement) => - neutralFillInputRecipe.getValueFor(element).evaluate(element).active + (resolve: DesignTokenResolver) => + resolve(neutralFillInputRecipe).evaluate(resolve).active ); /** @public */ export const neutralFillInputFocus = create( "neutral-fill-input-focus" ).withDefault( - (element: HTMLElement) => - neutralFillInputRecipe.getValueFor(element).evaluate(element).focus + (resolve: DesignTokenResolver) => + resolve(neutralFillInputRecipe).evaluate(resolve).focus ); // Neutral Fill Secondary @@ -479,14 +476,14 @@ export const neutralFillSecondaryFocusDelta = createNonCss( export const neutralFillSecondaryRecipe = createNonCss( "neutral-fill-secondary-recipe" ).withDefault({ - evaluate: (element: HTMLElement, reference?: Swatch): InteractiveSwatchSet => + evaluate: (resolve: DesignTokenResolver, reference?: Swatch): InteractiveSwatchSet => deltaSwatchSet( - neutralPalette.getValueFor(element), - reference || fillColor.getValueFor(element), - neutralFillSecondaryRestDelta.getValueFor(element), - neutralFillSecondaryHoverDelta.getValueFor(element), - neutralFillSecondaryActiveDelta.getValueFor(element), - neutralFillSecondaryFocusDelta.getValueFor(element) + resolve(neutralPalette), + reference || resolve(fillColor), + resolve(neutralFillSecondaryRestDelta), + resolve(neutralFillSecondaryHoverDelta), + resolve(neutralFillSecondaryActiveDelta), + resolve(neutralFillSecondaryFocusDelta) ), }); @@ -494,32 +491,32 @@ export const neutralFillSecondaryRecipe = createNonCss( export const neutralFillSecondaryRest = create( "neutral-fill-secondary-rest" ).withDefault( - (element: HTMLElement) => - neutralFillSecondaryRecipe.getValueFor(element).evaluate(element).rest + (resolve: DesignTokenResolver) => + resolve(neutralFillSecondaryRecipe).evaluate(resolve).rest ); /** @public */ export const neutralFillSecondaryHover = create( "neutral-fill-secondary-hover" ).withDefault( - (element: HTMLElement) => - neutralFillSecondaryRecipe.getValueFor(element).evaluate(element).hover + (resolve: DesignTokenResolver) => + resolve(neutralFillSecondaryRecipe).evaluate(resolve).hover ); /** @public */ export const neutralFillSecondaryActive = create( "neutral-fill-secondary-active" ).withDefault( - (element: HTMLElement) => - neutralFillSecondaryRecipe.getValueFor(element).evaluate(element).active + (resolve: DesignTokenResolver) => + resolve(neutralFillSecondaryRecipe).evaluate(resolve).active ); /** @public */ export const neutralFillSecondaryFocus = create( "neutral-fill-secondary-focus" ).withDefault( - (element: HTMLElement) => - neutralFillSecondaryRecipe.getValueFor(element).evaluate(element).focus + (resolve: DesignTokenResolver) => + resolve(neutralFillSecondaryRecipe).evaluate(resolve).focus ); // Neutral Fill Stealth @@ -548,14 +545,14 @@ export const neutralFillStealthFocusDelta = createNonCss( export const neutralFillStealthRecipe = createNonCss( "neutral-fill-stealth-recipe" ).withDefault({ - evaluate: (element: HTMLElement, reference?: Swatch): InteractiveSwatchSet => + evaluate: (resolve: DesignTokenResolver, reference?: Swatch): InteractiveSwatchSet => deltaSwatchSet( - neutralPalette.getValueFor(element), - reference || fillColor.getValueFor(element), - neutralFillStealthRestDelta.getValueFor(element), - neutralFillStealthHoverDelta.getValueFor(element), - neutralFillStealthActiveDelta.getValueFor(element), - neutralFillStealthFocusDelta.getValueFor(element) + resolve(neutralPalette), + reference || resolve(fillColor), + resolve(neutralFillStealthRestDelta), + resolve(neutralFillStealthHoverDelta), + resolve(neutralFillStealthActiveDelta), + resolve(neutralFillStealthFocusDelta) ), }); @@ -563,32 +560,32 @@ export const neutralFillStealthRecipe = createNonCss( export const neutralFillStealthRest = create( "neutral-fill-stealth-rest" ).withDefault( - (element: HTMLElement) => - neutralFillStealthRecipe.getValueFor(element).evaluate(element).rest + (resolve: DesignTokenResolver) => + resolve(neutralFillStealthRecipe).evaluate(resolve).rest ); /** @public */ export const neutralFillStealthHover = create( "neutral-fill-stealth-hover" ).withDefault( - (element: HTMLElement) => - neutralFillStealthRecipe.getValueFor(element).evaluate(element).hover + (resolve: DesignTokenResolver) => + resolve(neutralFillStealthRecipe).evaluate(resolve).hover ); /** @public */ export const neutralFillStealthActive = create( "neutral-fill-stealth-active" ).withDefault( - (element: HTMLElement) => - neutralFillStealthRecipe.getValueFor(element).evaluate(element).active + (resolve: DesignTokenResolver) => + resolve(neutralFillStealthRecipe).evaluate(resolve).active ); /** @public */ export const neutralFillStealthFocus = create( "neutral-fill-stealth-focus" ).withDefault( - (element: HTMLElement) => - neutralFillStealthRecipe.getValueFor(element).evaluate(element).focus + (resolve: DesignTokenResolver) => + resolve(neutralFillStealthRecipe).evaluate(resolve).focus ); // Neutral Fill Strong @@ -622,15 +619,15 @@ export const neutralFillStrongFocusDelta = createNonCss( export const neutralFillStrongRecipe = createNonCss( "neutral-fill-strong-recipe" ).withDefault({ - evaluate: (element: HTMLElement, reference?: Swatch): InteractiveSwatchSet => + evaluate: (resolve: DesignTokenResolver, reference?: Swatch): InteractiveSwatchSet => contrastAndDeltaSwatchSet( - neutralPalette.getValueFor(element), - reference || fillColor.getValueFor(element), - neutralFillStrongMinContrast.getValueFor(element), - neutralFillStrongRestDelta.getValueFor(element), - neutralFillStrongHoverDelta.getValueFor(element), - neutralFillStrongActiveDelta.getValueFor(element), - neutralFillStrongFocusDelta.getValueFor(element) + resolve(neutralPalette), + reference || resolve(fillColor), + resolve(neutralFillStrongMinContrast), + resolve(neutralFillStrongRestDelta), + resolve(neutralFillStrongHoverDelta), + resolve(neutralFillStrongActiveDelta), + resolve(neutralFillStrongFocusDelta) ), }); @@ -638,32 +635,32 @@ export const neutralFillStrongRecipe = createNonCss( export const neutralFillStrongRest = create( "neutral-fill-strong-rest" ).withDefault( - (element: HTMLElement) => - neutralFillStrongRecipe.getValueFor(element).evaluate(element).rest + (resolve: DesignTokenResolver) => + resolve(neutralFillStrongRecipe).evaluate(resolve).rest ); /** @public */ export const neutralFillStrongHover = create( "neutral-fill-strong-hover" ).withDefault( - (element: HTMLElement) => - neutralFillStrongRecipe.getValueFor(element).evaluate(element).hover + (resolve: DesignTokenResolver) => + resolve(neutralFillStrongRecipe).evaluate(resolve).hover ); /** @public */ export const neutralFillStrongActive = create( "neutral-fill-strong-active" ).withDefault( - (element: HTMLElement) => - neutralFillStrongRecipe.getValueFor(element).evaluate(element).active + (resolve: DesignTokenResolver) => + resolve(neutralFillStrongRecipe).evaluate(resolve).active ); /** @public */ export const neutralFillStrongFocus = create( "neutral-fill-strong-focus" ).withDefault( - (element: HTMLElement) => - neutralFillStrongRecipe.getValueFor(element).evaluate(element).focus + (resolve: DesignTokenResolver) => + resolve(neutralFillStrongRecipe).evaluate(resolve).focus ); // Neutral Stroke @@ -692,40 +689,40 @@ export const neutralStrokeFocusDelta = createNonCss( export const neutralStrokeRecipe = createNonCss( "neutral-stroke-recipe" ).withDefault({ - evaluate: (element: HTMLElement, reference?: Swatch): InteractiveSwatchSet => { + evaluate: ( + resolve: DesignTokenResolver, + reference?: Swatch + ): InteractiveSwatchSet => { return deltaSwatchSet( - neutralPalette.getValueFor(element), - reference || fillColor.getValueFor(element), - neutralStrokeRestDelta.getValueFor(element), - neutralStrokeHoverDelta.getValueFor(element), - neutralStrokeActiveDelta.getValueFor(element), - neutralStrokeFocusDelta.getValueFor(element) + resolve(neutralPalette), + reference || resolve(fillColor), + resolve(neutralStrokeRestDelta), + resolve(neutralStrokeHoverDelta), + resolve(neutralStrokeActiveDelta), + resolve(neutralStrokeFocusDelta) ); }, }); /** @public */ export const neutralStrokeRest = create("neutral-stroke-rest").withDefault( - (element: HTMLElement) => - neutralStrokeRecipe.getValueFor(element).evaluate(element).rest + (resolve: DesignTokenResolver) => resolve(neutralStrokeRecipe).evaluate(resolve).rest ); /** @public */ export const neutralStrokeHover = create("neutral-stroke-hover").withDefault( - (element: HTMLElement) => - neutralStrokeRecipe.getValueFor(element).evaluate(element).hover + (resolve: DesignTokenResolver) => resolve(neutralStrokeRecipe).evaluate(resolve).hover ); /** @public */ export const neutralStrokeActive = create("neutral-stroke-active").withDefault( - (element: HTMLElement) => - neutralStrokeRecipe.getValueFor(element).evaluate(element).active + (resolve: DesignTokenResolver) => + resolve(neutralStrokeRecipe).evaluate(resolve).active ); /** @public */ export const neutralStrokeFocus = create("neutral-stroke-focus").withDefault( - (element: HTMLElement) => - neutralStrokeRecipe.getValueFor(element).evaluate(element).focus + (resolve: DesignTokenResolver) => resolve(neutralStrokeRecipe).evaluate(resolve).focus ); // Neutral Stroke Divider @@ -739,20 +736,18 @@ export const neutralStrokeDividerRestDelta = createNonCss( export const neutralStrokeDividerRecipe = createNonCss( "neutral-stroke-divider-recipe" ).withDefault({ - evaluate: (element: HTMLElement, reference?: Swatch): Swatch => + evaluate: (resolve: DesignTokenResolver, reference?: Swatch): Swatch => deltaSwatch( - neutralPalette.getValueFor(element), - reference || fillColor.getValueFor(element), - neutralStrokeDividerRestDelta.getValueFor(element) + resolve(neutralPalette), + reference || resolve(fillColor), + resolve(neutralStrokeDividerRestDelta) ), }); /** @public */ export const neutralStrokeDividerRest = create( "neutral-stroke-divider-rest" -).withDefault(element => - neutralStrokeDividerRecipe.getValueFor(element).evaluate(element) -); +).withDefault(resolve => resolve(neutralStrokeDividerRecipe).evaluate(resolve)); // Neutral Stroke Input @@ -780,14 +775,17 @@ export const neutralStrokeInputFocusDelta = createNonCss( export const neutralStrokeInputRecipe = createNonCss( "neutral-stroke-input-recipe" ).withDefault({ - evaluate: (element: HTMLElement, reference?: Swatch): InteractiveSwatchSet => { + evaluate: ( + resolve: DesignTokenResolver, + reference?: Swatch + ): InteractiveSwatchSet => { return deltaSwatchSet( - neutralPalette.getValueFor(element), - reference || fillColor.getValueFor(element), - neutralStrokeInputRestDelta.getValueFor(element), - neutralStrokeInputHoverDelta.getValueFor(element), - neutralStrokeInputActiveDelta.getValueFor(element), - neutralStrokeInputFocusDelta.getValueFor(element) + resolve(neutralPalette), + reference || resolve(fillColor), + resolve(neutralStrokeInputRestDelta), + resolve(neutralStrokeInputHoverDelta), + resolve(neutralStrokeInputActiveDelta), + resolve(neutralStrokeInputFocusDelta) ); }, }); @@ -796,32 +794,32 @@ export const neutralStrokeInputRecipe = createNonCss( export const neutralStrokeInputRest = create( "neutral-stroke-input-rest" ).withDefault( - (element: HTMLElement) => - neutralStrokeInputRecipe.getValueFor(element).evaluate(element).rest + (resolve: DesignTokenResolver) => + resolve(neutralStrokeInputRecipe).evaluate(resolve).rest ); /** @public */ export const neutralStrokeInputHover = create( "neutral-stroke-input-hover" ).withDefault( - (element: HTMLElement) => - neutralStrokeInputRecipe.getValueFor(element).evaluate(element).hover + (resolve: DesignTokenResolver) => + resolve(neutralStrokeInputRecipe).evaluate(resolve).hover ); /** @public */ export const neutralStrokeInputActive = create( "neutral-stroke-input-active" ).withDefault( - (element: HTMLElement) => - neutralStrokeInputRecipe.getValueFor(element).evaluate(element).active + (resolve: DesignTokenResolver) => + resolve(neutralStrokeInputRecipe).evaluate(resolve).active ); /** @public */ export const neutralStrokeInputFocus = create( "neutral-stroke-input-focus" ).withDefault( - (element: HTMLElement) => - neutralStrokeInputRecipe.getValueFor(element).evaluate(element).focus + (resolve: DesignTokenResolver) => + resolve(neutralStrokeInputRecipe).evaluate(resolve).focus ); // Neutral Stroke Strong @@ -855,15 +853,15 @@ export const neutralStrokeStrongFocusDelta = createNonCss( export const neutralStrokeStrongRecipe = createNonCss( "neutral-stroke-strong-recipe" ).withDefault({ - evaluate: (element: HTMLElement, reference?: Swatch): InteractiveSwatchSet => + evaluate: (resolve: DesignTokenResolver, reference?: Swatch): InteractiveSwatchSet => contrastAndDeltaSwatchSet( - neutralPalette.getValueFor(element), - reference || fillColor.getValueFor(element), - neutralStrokeStrongMinContrast.getValueFor(element), - neutralStrokeStrongRestDelta.getValueFor(element), - neutralStrokeStrongHoverDelta.getValueFor(element), - neutralStrokeStrongActiveDelta.getValueFor(element), - neutralStrokeStrongFocusDelta.getValueFor(element) + resolve(neutralPalette), + reference || resolve(fillColor), + resolve(neutralStrokeStrongMinContrast), + resolve(neutralStrokeStrongRestDelta), + resolve(neutralStrokeStrongHoverDelta), + resolve(neutralStrokeStrongActiveDelta), + resolve(neutralStrokeStrongFocusDelta) ), }); @@ -871,32 +869,32 @@ export const neutralStrokeStrongRecipe = createNonCss( export const neutralStrokeStrongRest = create( "neutral-stroke-strong-rest" ).withDefault( - (element: HTMLElement) => - neutralStrokeStrongRecipe.getValueFor(element).evaluate(element).rest + (resolve: DesignTokenResolver) => + resolve(neutralStrokeStrongRecipe).evaluate(resolve).rest ); /** @public */ export const neutralStrokeStrongHover = create( "neutral-stroke-strong-hover" ).withDefault( - (element: HTMLElement) => - neutralStrokeStrongRecipe.getValueFor(element).evaluate(element).hover + (resolve: DesignTokenResolver) => + resolve(neutralStrokeStrongRecipe).evaluate(resolve).hover ); /** @public */ export const neutralStrokeStrongActive = create( "neutral-stroke-strong-active" ).withDefault( - (element: HTMLElement) => - neutralStrokeStrongRecipe.getValueFor(element).evaluate(element).active + (resolve: DesignTokenResolver) => + resolve(neutralStrokeStrongRecipe).evaluate(resolve).active ); /** @public */ export const neutralStrokeStrongFocus = create( "neutral-stroke-strong-focus" ).withDefault( - (element: HTMLElement) => - neutralStrokeStrongRecipe.getValueFor(element).evaluate(element).focus + (resolve: DesignTokenResolver) => + resolve(neutralStrokeStrongRecipe).evaluate(resolve).focus ); // Focus Stroke Outer @@ -905,19 +903,15 @@ export const neutralStrokeStrongFocus = create( export const focusStrokeOuterRecipe = createNonCss( "focus-stroke-outer-recipe" ).withDefault({ - evaluate: (element: HTMLElement): Swatch => - blackOrWhiteByContrast( - fillColor.getValueFor(element), - ContrastTarget.NormalText, - true - ), + evaluate: (resolve: DesignTokenResolver): Swatch => + blackOrWhiteByContrast(resolve(fillColor), ContrastTarget.NormalText, true), }); /** @public */ export const focusStrokeOuter = create( "focus-stroke-outer" -).withDefault((element: HTMLElement) => - focusStrokeOuterRecipe.getValueFor(element).evaluate(element) +).withDefault((resolve: DesignTokenResolver) => + resolve(focusStrokeOuterRecipe).evaluate(resolve) ); // Focus Stroke Inner @@ -926,9 +920,9 @@ export const focusStrokeOuter = create( export const focusStrokeInnerRecipe = createNonCss( "focus-stroke-inner-recipe" ).withDefault({ - evaluate: (element: HTMLElement): Swatch => + evaluate: (resolve: DesignTokenResolver): Swatch => blackOrWhiteByContrast( - focusStrokeOuter.getValueFor(element), + resolve(focusStrokeOuter), ContrastTarget.NormalText, false ), @@ -937,6 +931,6 @@ export const focusStrokeInnerRecipe = createNonCss( /** @public */ export const focusStrokeInner = create( "focus-stroke-inner" -).withDefault((element: HTMLElement) => - focusStrokeInnerRecipe.getValueFor(element).evaluate(element) +).withDefault((resolve: DesignTokenResolver) => + resolve(focusStrokeInnerRecipe).evaluate(resolve) ); diff --git a/packages/utilities/adaptive-ui/src/design-tokens/create.ts b/packages/utilities/adaptive-ui/src/design-tokens/create.ts index dae16a1d6bb..6b1e552be4d 100644 --- a/packages/utilities/adaptive-ui/src/design-tokens/create.ts +++ b/packages/utilities/adaptive-ui/src/design-tokens/create.ts @@ -5,5 +5,5 @@ export const { create } = DesignToken; /** @internal */ export function createNonCss(name: string): DesignToken { - return DesignToken.create({ name, cssCustomPropertyName: null }); + return DesignToken.create({ name }); } diff --git a/packages/utilities/adaptive-ui/src/design-tokens/elevation.ts b/packages/utilities/adaptive-ui/src/design-tokens/elevation.ts index c880c51bc07..e521ff16599 100644 --- a/packages/utilities/adaptive-ui/src/design-tokens/elevation.ts +++ b/packages/utilities/adaptive-ui/src/design-tokens/elevation.ts @@ -1,3 +1,4 @@ +import { DesignTokenResolver } from "@microsoft/fast-foundation"; import { ElevationRecipe } from "../elevation/recipe.js"; import { create, createNonCss } from "./create.js"; @@ -7,7 +8,7 @@ import { create, createNonCss } from "./create.js"; export const elevationRecipe = createNonCss( "elevation-recipe" ).withDefault({ - evaluate: (element: HTMLElement, size: number): string => { + evaluate: (resolve: DesignTokenResolver, size: number): string => { let ambientOpacity = 0.12; let directionalOpacity = 0.14; @@ -45,37 +46,29 @@ export const elevationCardFocusSize = createNonCss( /** @public */ export const elevationCardRest = create( "elevation-card-rest" -).withDefault((element: HTMLElement) => - elevationRecipe - .getValueFor(element) - .evaluate(element, elevationCardRestSize.getValueFor(element)) +).withDefault((resolve: DesignTokenResolver) => + resolve(elevationRecipe).evaluate(resolve, resolve(elevationCardRestSize)) ); /** @public */ export const elevationCardHover = create( "elevation-card-hover" -).withDefault((element: HTMLElement) => - elevationRecipe - .getValueFor(element) - .evaluate(element, elevationCardHoverSize.getValueFor(element)) +).withDefault((resolve: DesignTokenResolver) => + resolve(elevationRecipe).evaluate(resolve, resolve(elevationCardHoverSize)) ); /** @public */ export const elevationCardActive = create( "elevation-card-active" -).withDefault((element: HTMLElement) => - elevationRecipe - .getValueFor(element) - .evaluate(element, elevationCardActiveSize.getValueFor(element)) +).withDefault((resolve: DesignTokenResolver) => + resolve(elevationRecipe).evaluate(resolve, resolve(elevationCardActiveSize)) ); /** @public */ export const elevationCardFocus = create( "elevation-card-focus" -).withDefault((element: HTMLElement) => - elevationRecipe - .getValueFor(element) - .evaluate(element, elevationCardFocusSize.getValueFor(element)) +).withDefault((resolve: DesignTokenResolver) => + resolve(elevationRecipe).evaluate(resolve, resolve(elevationCardFocusSize)) ); /** @public */ @@ -86,10 +79,8 @@ export const elevationTooltipSize = createNonCss( /** @public */ export const elevationTooltip = create( "elevation-tooltip" -).withDefault((element: HTMLElement) => - elevationRecipe - .getValueFor(element) - .evaluate(element, elevationTooltipSize.getValueFor(element)) +).withDefault((resolve: DesignTokenResolver) => + resolve(elevationRecipe).evaluate(resolve, resolve(elevationTooltipSize)) ); /** @public */ @@ -100,10 +91,8 @@ export const elevationFlyoutSize = createNonCss( /** @public */ export const elevationFlyout = create( "elevation-flyout" -).withDefault((element: HTMLElement) => - elevationRecipe - .getValueFor(element) - .evaluate(element, elevationFlyoutSize.getValueFor(element)) +).withDefault((resolve: DesignTokenResolver) => + resolve(elevationRecipe).evaluate(resolve, resolve(elevationFlyoutSize)) ); /** @public */ @@ -112,10 +101,6 @@ export const elevationDialogSize = createNonCss( ).withDefault(128); /** @public */ -export const elevationDialog = create( - "elevation-dialog" -).withDefault((element: HTMLElement) => - elevationRecipe - .getValueFor(element) - .evaluate(element, elevationDialogSize.getValueFor(element)) +export const elevationDialog = create("elevation-dialog").withDefault(resolve => + resolve(elevationRecipe).evaluate(resolve, resolve(elevationDialogSize)) ); diff --git a/packages/utilities/adaptive-ui/src/design-tokens/palette.ts b/packages/utilities/adaptive-ui/src/design-tokens/palette.ts index 099d4ec468f..b582dbf78ba 100644 --- a/packages/utilities/adaptive-ui/src/design-tokens/palette.ts +++ b/packages/utilities/adaptive-ui/src/design-tokens/palette.ts @@ -1,4 +1,5 @@ import { parseColorHexRGB } from "@microsoft/fast-colors"; +import { DesignTokenResolver } from "@microsoft/fast-foundation"; import { Palette, Swatch, SwatchRGB } from "../color/index.js"; import { PaletteRGB } from "../color/palette-rgb.js"; import { create, createNonCss } from "./create.js"; @@ -11,15 +12,15 @@ export const neutralBaseColor = create("neutral-base-color").withDefault /** @public */ export const neutralBaseSwatch = createNonCss( "neutral-base-swatch" -).withDefault((element: HTMLElement) => - SwatchRGB.from(parseColorHexRGB(neutralBaseColor.getValueFor(element))!) +).withDefault((resolve: DesignTokenResolver) => + SwatchRGB.from(parseColorHexRGB(resolve(neutralBaseColor))!) ); /** @public */ export const neutralPalette = createNonCss( "neutral-palette" -).withDefault((element: HTMLElement) => - PaletteRGB.from(neutralBaseSwatch.getValueFor(element) as SwatchRGB) +).withDefault((resolve: DesignTokenResolver) => + PaletteRGB.from(resolve(neutralBaseSwatch) as SwatchRGB) ); /** @public */ @@ -28,13 +29,13 @@ export const accentBaseColor = create("accent-base-color").withDefault(" /** @public */ export const accentBaseSwatch = createNonCss( "accent-base-swatch" -).withDefault((element: HTMLElement) => - SwatchRGB.from(parseColorHexRGB(accentBaseColor.getValueFor(element))!) +).withDefault((resolve: DesignTokenResolver) => + SwatchRGB.from(parseColorHexRGB(resolve(accentBaseColor))!) ); /** @public */ export const accentPalette = createNonCss( "accent-palette" -).withDefault((element: HTMLElement) => - PaletteRGB.from(accentBaseSwatch.getValueFor(element) as SwatchRGB) +).withDefault((resolve: DesignTokenResolver) => + PaletteRGB.from(resolve(accentBaseSwatch) as SwatchRGB) ); diff --git a/packages/utilities/adaptive-ui/src/design-tokens/type.ts b/packages/utilities/adaptive-ui/src/design-tokens/type.ts index 470e245db04..023bbdfd548 100644 --- a/packages/utilities/adaptive-ui/src/design-tokens/type.ts +++ b/packages/utilities/adaptive-ui/src/design-tokens/type.ts @@ -1,4 +1,4 @@ -import { DesignToken } from "@microsoft/fast-foundation"; +import { DesignToken, DesignTokenResolver } from "@microsoft/fast-foundation"; import { create } from "./create.js"; /** @@ -30,10 +30,10 @@ export const fontWeight = create("font-weight").withDefault( function fontVariations( sizeToken: DesignToken -): (element: HTMLElement) => string { - return (element: HTMLElement): string => { - const size = sizeToken.getValueFor(element); - const weight = fontWeight.getValueFor(element); +): (resolve: DesignTokenResolver) => string { + return (resolve: DesignTokenResolver): string => { + const size = resolve(sizeToken); + const weight = resolve(fontWeight); if (size.endsWith("px")) { const px = Number.parseFloat(size.replace("px", "")); if (px <= 12) { diff --git a/packages/utilities/adaptive-ui/src/elevation/recipe.ts b/packages/utilities/adaptive-ui/src/elevation/recipe.ts index e84875f829b..bf5543071f4 100644 --- a/packages/utilities/adaptive-ui/src/elevation/recipe.ts +++ b/packages/utilities/adaptive-ui/src/elevation/recipe.ts @@ -1,3 +1,5 @@ +import { DesignTokenResolver } from "@microsoft/fast-foundation"; + /** * A recipe that evaluates to an elevation treatment, commonly, but not limited to, a box . * @@ -7,8 +9,8 @@ export interface ElevationRecipe { /** * Evaluate an elevation treatment. * - * @param element - The element for which to evaluate the recipe + * @param resolver - A function that resolves design tokens * @param size - The size of the elevation */ - evaluate(element: HTMLElement, size: number): string; + evaluate(resolver: DesignTokenResolver, size: number): string; } diff --git a/packages/web-components/fast-element/src/observation/observable.ts b/packages/web-components/fast-element/src/observation/observable.ts index 9badd008395..60ad7932fff 100644 --- a/packages/web-components/fast-element/src/observation/observable.ts +++ b/packages/web-components/fast-element/src/observation/observable.ts @@ -221,8 +221,12 @@ export const Observable = FAST.getById(KernelServiceId.observable, () => { const previousWatcher = watcher; watcher = this.needsRefresh ? this : void 0; this.needsRefresh = this.isVolatileBinding; - const result = this.binding(source, context ?? ExecutionContext.default); - watcher = previousWatcher; + let result; + try { + result = this.binding(source, context ?? ExecutionContext.default); + } finally { + watcher = previousWatcher; + } return result; } diff --git a/packages/web-components/fast-foundation/custom-elements-manifest.config.mjs b/packages/web-components/fast-foundation/custom-elements-manifest.config.mjs index 4a57f79bf44..51e73f982b4 100644 --- a/packages/web-components/fast-foundation/custom-elements-manifest.config.mjs +++ b/packages/web-components/fast-foundation/custom-elements-manifest.config.mjs @@ -8,6 +8,7 @@ export default { "src/*.ts", "src/__test__/*", "src/di/*", + "src/design-token/*", "src/**/*.md", "src/**/*.spec.ts", "src/**/index.ts", diff --git a/packages/web-components/fast-foundation/docs/api-report.md b/packages/web-components/fast-foundation/docs/api-report.md index ed65568f8e7..f0b2092b2ef 100644 --- a/packages/web-components/fast-foundation/docs/api-report.md +++ b/packages/web-components/fast-foundation/docs/api-report.md @@ -260,13 +260,18 @@ export { composedParent } // @beta export type ConstructableFormAssociated = Constructable; -// @public -export interface CSSDesignToken | symbol | ({ - createCSS?(): string; -} & Record)> extends DesignToken, CSSDirective { +// @public (undocumented) +export class CSSDesignToken extends DesignToken implements CSSDirective { + constructor(configuration: CSSDesignTokenConfiguration); + createCSS(): string; readonly cssCustomProperty: string; } +// @public (undocumented) +export interface CSSDesignTokenConfiguration extends DesignTokenConfiguration { + cssCustomPropertyName: string; +} + // @public @deprecated export type CSSDisplayPropertyValue = "block" | "contents" | "flex" | "grid" | "inherit" | "initial" | "inline" | "inline-block" | "inline-flex" | "inline-grid" | "inline-table" | "list-item" | "none" | "run-in" | "table" | "table-caption" | "table-cell" | "table-column" | "table-column-group" | "table-footer-group" | "table-header-group" | "table-row" | "table-row-group"; @@ -464,49 +469,64 @@ export interface DelegatesARIAToolbar extends ARIAGlobalStatesAndProperties { } // @public -export type DerivedDesignTokenValue = T extends Function ? never : (target: HTMLElement) => T; +export type DerivedDesignTokenValue = (resolve: DesignTokenResolver) => T; -// @public -export interface DesignToken | symbol | {}> { - readonly appliedTo: HTMLElement[]; - deleteValueFor(element: HTMLElement): this; - getValueFor(element: HTMLElement): StaticDesignTokenValue; - readonly name: string; - setValueFor(element: HTMLElement, value: DesignTokenValue | DesignToken): void; - subscribe(subscriber: DesignTokenSubscriber, target?: HTMLElement): void; - unsubscribe(subscriber: DesignTokenSubscriber, target?: HTMLElement): void; - withDefault(value: DesignTokenValue | DesignToken): this; +// @public (undocumented) +export class DesignToken { + get $value(): T | undefined; + constructor(configuration: DesignTokenConfiguration); + // (undocumented) + static create(name: string): CSSDesignToken; + // (undocumented) + static create(config: DesignTokenConfiguration): DesignToken; + // (undocumented) + static create(config: CSSDesignTokenConfiguration): CSSDesignToken; + get default(): T | undefined; + deleteValueFor(target: FASTElement): this; + getValueFor(target: FASTElement): T; + name: string; + static registerRoot(target?: FASTElement | Document): void; + // Warning: (ae-forgotten-export) The symbol "DesignTokenValue" needs to be exported by the entry point index.d.ts + setValueFor(target: FASTElement, value: DesignToken | DesignTokenValue): void; + subscribe(subscriber: DesignTokenSubscriber): void; + static unregisterRoot(target?: FASTElement | Document): void; + unsubscribe(subscriber: DesignTokenSubscriber): void; + withDefault(value: DesignToken | DesignTokenValue): this; + // Warning: (ae-forgotten-export) The symbol "DesignTokenResolutionStrategy" needs to be exported by the entry point index.d.ts + static withStrategy(strategy: DesignTokenResolutionStrategy): void; } -// @public -export const DesignToken: Readonly<{ - create: typeof create; - notifyConnection(element: HTMLElement): boolean; - notifyDisconnection(element: HTMLElement): boolean; - registerRoot(target?: HTMLElement | Document): void; - unregisterRoot(target?: HTMLElement | Document): void; -}>; - -// @public +// @public (undocumented) export interface DesignTokenChangeRecord> { - target: HTMLElement; + target: FASTElement | "default"; token: T; } // @public export interface DesignTokenConfiguration { - cssCustomPropertyName?: string | null; name: string; } -// @public -export interface DesignTokenSubscriber> { +// @public (undocumented) +export const enum DesignTokenMutationType { + // (undocumented) + add = 0, + // (undocumented) + change = 1, // (undocumented) - handleChange(record: DesignTokenChangeRecord): void; + delete = 2 } +// Warning: (ae-forgotten-export) The symbol "DesignToken" needs to be exported by the entry point index.d.ts +// +// @public +export type DesignTokenResolver = (token: DesignToken_2) => T; + // @public -export type DesignTokenValue = StaticDesignTokenValue | DerivedDesignTokenValue; +export interface DesignTokenSubscriber> { + // (undocumented) + handleChange(token: T, record: DesignTokenChangeRecord): void; +} // @public export function dialogTemplate(): ElementViewTemplate; @@ -2511,7 +2531,7 @@ export type StartOptions = { export function startSlotTemplate(options: StartOptions): ViewTemplate; // @public -export type StaticDesignTokenValue = T extends Function ? never : T; +export type StaticDesignTokenValue = T extends (...args: any[]) => any ? DerivedDesignTokenValue : T; // @alpha (undocumented) export const supportsElementInternals: boolean; @@ -2670,7 +2690,6 @@ export type YearFormat = typeof YearFormat[keyof typeof YearFormat]; // dist/dts/calendar/calendar.d.ts:51:5 - (ae-incompatible-release-tags) The symbol "dataGrid" is marked as @public, but its signature references "TemplateElementDependency" which is marked as @beta // dist/dts/data-grid/data-grid-row.template.d.ts:9:5 - (ae-incompatible-release-tags) The symbol "dataGridCell" is marked as @public, but its signature references "TemplateElementDependency" which is marked as @beta // dist/dts/data-grid/data-grid.template.d.ts:9:5 - (ae-incompatible-release-tags) The symbol "dataGridRow" is marked as @public, but its signature references "TemplateElementDependency" which is marked as @beta -// dist/dts/design-token/design-token.d.ts:91:5 - (ae-forgotten-export) The symbol "create" needs to be exported by the entry point index.d.ts // dist/dts/menu-item/menu-item.d.ts:20:5 - (ae-incompatible-release-tags) The symbol "anchoredRegion" is marked as @public, but its signature references "TemplateElementDependency" which is marked as @beta // dist/dts/picker/picker.template.d.ts:9:5 - (ae-incompatible-release-tags) The symbol "anchoredRegion" is marked as @public, but its signature references "TemplateElementDependency" which is marked as @beta // dist/dts/picker/picker.template.d.ts:10:5 - (ae-incompatible-release-tags) The symbol "pickerMenu" is marked as @public, but its signature references "TemplateElementDependency" which is marked as @beta diff --git a/packages/web-components/fast-foundation/package.json b/packages/web-components/fast-foundation/package.json index d0e692937d4..54de2314d49 100644 --- a/packages/web-components/fast-foundation/package.json +++ b/packages/web-components/fast-foundation/package.json @@ -27,6 +27,10 @@ "default": "./dist/esm/index.js" }, "./custom-elements.json": "./dist/custom-elements.json", + "./design-token-core": { + "types": "./dist/dts/design-token/core/exports.d.ts", + "default": "./dist/esm/design-token/core/exports.js" + }, "./package.json": "./package.json" }, "scripts": { diff --git a/packages/web-components/fast-foundation/src/design-token/core/design-token-node.spec.ts b/packages/web-components/fast-foundation/src/design-token/core/design-token-node.spec.ts new file mode 100644 index 00000000000..ce1b99556fc --- /dev/null +++ b/packages/web-components/fast-foundation/src/design-token/core/design-token-node.spec.ts @@ -0,0 +1,953 @@ +import { Observable, Subscriber } from "@microsoft/fast-element"; +import { makeObservable } from "@microsoft/fast-element/utilities"; +import chai, { expect } from "chai"; +import spies from "chai-spies"; +import { DesignTokenChangeRecordImpl as DesignTokenChangeRecord, DesignTokenMutationType, DesignTokenNode, DesignTokenResolver } from "./design-token-node.js"; +import type { DesignToken as IDesignToken } from "./design-token.js" + +chai.use(spies); + +function createChangeHandler() { + const handleChange = chai.spy(() => {}) + const subscriber: Subscriber = { handleChange } + return { handleChange, subscriber } +} + +function createNode(parent?: DesignTokenNode) { + const node = new DesignTokenNode(); + + if (parent) { + parent.appendChild(node); + } + + return node; +} + +class DesignToken implements IDesignToken { + $value: T | undefined = undefined; +} + +describe("DesignTokenNode", () => { + describe("appending a child", () => { + it("should assign the `parent` property of the child to the caller", () => { + const parent = new DesignTokenNode(); + const child = new DesignTokenNode(); + + expect(child.parent).to.be.null; + parent.appendChild(child); + expect(child.parent).to.equal(parent); + }); + + it("should add the child to the `children` property of the caller", () => { + const parent = new DesignTokenNode(); + const child = new DesignTokenNode(); + + expect(parent.children.includes(child)).to.be.false; + parent.appendChild(child); + expect(parent.children.includes(child)).to.be.true; + }); + + it("should re-parent the child if the child is already a child of another node", () => { + const parent = new DesignTokenNode(); + const child = new DesignTokenNode(); + const newParent = new DesignTokenNode(); + + parent.appendChild(child); + newParent.appendChild(child); + + expect(child.parent).to.equal(newParent); + expect(parent.children.includes(child)).to.be.false; + expect(newParent.children.includes(child)).to.be.true; + }); + }); + describe("removing a child", () => { + it("should assign the `parent` property of the child to null if the child is a child of the parent", () => { + const parent = new DesignTokenNode(); + const child = new DesignTokenNode(); + + parent.appendChild(child); + expect(child.parent).to.equal(parent); + parent.removeChild(child); + expect(child.parent).to.be.null; + }); + it("should remove the child from the `children` set if the item is a child of the parent", () => { + const parent = new DesignTokenNode(); + const child = new DesignTokenNode(); + + parent.appendChild(child); + expect(parent.children.includes(child)).to.be.true; + parent.removeChild(child); + expect(child.parent).to.be.null; + }); + it("should no-op when called with an item that is not a child of the parent", () => { + const parent = new DesignTokenNode(); + const child = new DesignTokenNode(); + const tangent = new DesignTokenNode(); + + parent.appendChild(child); + expect(parent.children.includes(child)).to.be.true; + expect(child.parent).to.equal(parent); + + tangent.removeChild(child); + expect(parent.children.includes(child)).to.be.true; + expect(child.parent).to.equal(parent); + }); + }); + describe("setting a token to a static value", () => { + it("should support getting and setting falsey values", () => { + const target = new DesignTokenNode(); + [false, null, 0, "", NaN].forEach(value => { + + const token = new DesignToken(); + target.setTokenValue(token, value) + + if (typeof value === "number" && isNaN(value)) { + expect(isNaN(target.getTokenValue(token) as number)).to.equal(true) + } else { + expect(target.getTokenValue(token)).to.equal(value); + } + }); + }); + + it("should return the value set for an ancestor if a value has not been set for the target", () => { + const ancestor = new DesignTokenNode(); + const target = new DesignTokenNode(); + ancestor.appendChild(target); + const token = new DesignToken(); + ancestor.setTokenValue(token, 12) + + expect(target.getTokenValue(token)).to.equal(12); + }); + + it("sound return the nearest ancestor's value after an intermediary value is set where no value was set prior", () => { + const grandparent = new DesignTokenNode(); + const parent = new DesignTokenNode(); + const target = new DesignTokenNode(); + + grandparent.appendChild(parent); + parent.appendChild(target); + + const token = new DesignToken(); + + grandparent.setTokenValue(token, 12); + + expect(target.getTokenValue(token)).to.equal(12); + + parent.setTokenValue(token, 14) + + expect(target.getTokenValue(token)).to.equal(14); + }); + + it("should return the new ancestor's value after being re-parented", () => { + const parentA = new DesignTokenNode(); + const parentB = new DesignTokenNode(); + const target = new DesignTokenNode(); + parentA.appendChild(target) + + const token = new DesignToken(); + + parentA.setTokenValue(token, 12); + parentB.setTokenValue(token, 14); + + expect(target.getTokenValue(token)).to.equal(12); + parentB.appendChild(target); + + expect(target.getTokenValue(token)).to.equal(14); + }); + }) + describe("setting a token to a derived value", () => { + it("should support getting and setting falsey values", () => { + const target = new DesignTokenNode(); + [false, null, 0, "", NaN].forEach(value => { + + const token = new DesignToken(); + target.setTokenValue(token, () => value as any); + + if (typeof value === "number" && isNaN(value)) { + expect(isNaN(target.getTokenValue(token) as number)).to.equal(true) + } else { + expect(target.getTokenValue(token)).to.equal(value); + } + }) + }); + + it("should get the return value of a derived value", () => { + const target = new DesignTokenNode(); + const token = new DesignToken(); + target.setTokenValue(token, () => 12) + + expect(target.getTokenValue(token)).to.equal(12); + }); + it("should get an updated value when other design tokens used in a derived property are changed", () => { + const target = new DesignTokenNode(); + const tokenA = new DesignToken(); + const tokenB = new DesignToken(); + + target.setTokenValue(tokenA, 6); + target.setTokenValue(tokenB, (resolve) => resolve(tokenA) * 2); + + expect(target.getTokenValue(tokenB)).to.equal(12); + + target.setTokenValue(tokenA, 7) + + expect(target.getTokenValue(tokenB)).to.equal(14); + }); + it("should use the closest value of a dependent token when getting a token for a target", () => { + const ancestor = new DesignTokenNode() + const parent = new DesignTokenNode(); + const target = new DesignTokenNode(); + + ancestor.appendChild(parent) + parent.appendChild(target); + const tokenA = new DesignToken(); + const tokenB = new DesignToken(); + + ancestor.setTokenValue(tokenA, 7); + parent.setTokenValue(tokenA, 6) + ancestor.setTokenValue(tokenB, (resolve) => resolve(tokenA) * 2); + + expect(target.getTokenValue(tokenB)).to.equal(12); + }); + + it("should update value of a dependent token when getting a token for a target", () => { + const ancestor = new DesignTokenNode(); + const parent = new DesignTokenNode(); + const target = new DesignTokenNode(); + ancestor.appendChild(parent); + parent.appendChild(target); + const tokenA = new DesignToken(); + const tokenB = new DesignToken(); + + ancestor.setTokenValue(tokenA, 7); + parent.setTokenValue(tokenA, 6) + ancestor.setTokenValue(tokenB, (resolve) => resolve(tokenA) * 2) + + expect(target.getTokenValue(tokenB)).to.equal(12); + + parent.setTokenValue(tokenA, 7); + + expect(target.getTokenValue(tokenB)).to.equal(14); + }); + + it("should get an updated value when a used design token is set for a node closer to the target", () => { + const ancestor = new DesignTokenNode() + const parent = new DesignTokenNode(); + const target = new DesignTokenNode(); + ancestor.appendChild(parent); + parent.appendChild(target); + + const tokenA = new DesignToken(); + const tokenB = new DesignToken(); + + ancestor.setTokenValue(tokenA, 6) + ancestor.setTokenValue(tokenB, (resolve) => resolve( tokenA ) * 2) + + expect(target.getTokenValue(tokenB)).to.equal(12); + + target.setTokenValue(tokenA, 7) + + expect(target.getTokenValue(tokenB)).to.equal(14); + }); + it("should resolve a value for the token being assigned from the parent node", () => { + const token = new DesignToken(); + const parent = createNode(); + const child = createNode(parent); + + parent.setTokenValue(token, 12); + child.setTokenValue(token, (resolve) => { + return resolve(token) * 2; + }); + + expect(child.getTokenValue(token)).to.equal(24); + }); + it("should error if attempting to resolve the token being assigned and there is no parent node", () => { + const token = new DesignToken(); + const target = new DesignTokenNode(); + + expect(() => { + target.setTokenValue(token, (resolve) => { + return resolve(token) * 2; + }); + }).to.throw() + }); + it("should error if attempting to resolve the token being assigned and the token is not assigned for any ancestor", () => { + const token = new DesignToken(); + const ancestor = createNode(); + const parent = createNode(ancestor); + const descendent = createNode(parent); + + expect(() => { + descendent.setTokenValue(token, (resolve) => { + return resolve(token) * 2; + }); + }).to.throw() + }); + }) + + describe("getting a token value", () => { + it("should throw if no token value has been set for the token in a node tree", () => { + const token = new DesignToken(); + const node = new DesignTokenNode(); + + expect(() => node.getTokenValue(token)).to.throw; + }); + it("should return the assigned value when a node is assigned a static value", () => { + const token = new DesignToken(); + const node = new DesignTokenNode(); + node.setTokenValue(token, 12); + + expect(node.getTokenValue(token)).to.equal(12); + }); + it("should return the resolved value when a node is assigned a derived value", () => { + const token = new DesignToken(); + const node = new DesignTokenNode(); + node.setTokenValue(token, () => 12); + + expect(node.getTokenValue(token)).to.equal(12); + }); + it("should resolve a static value from an ancestor node assigned a static value when the descendent node does not have the token assigned a value", () => { + const token = new DesignToken(); + const ancestor = createNode(); + const parent = createNode(ancestor); + const descendent = createNode(parent); + + ancestor.setTokenValue(token, 12); + + expect(descendent.getTokenValue(token)).to.equal(12); + }); + it("should resolve a static value from an ancestor node assigned a derived value when the descendent node does not have the token assigned a value", () => { + const token = new DesignToken(); + const ancestor = createNode(); + const parent = createNode(ancestor); + const descendent = createNode(parent); + + ancestor.setTokenValue(token, () => 12); + + expect(descendent.getTokenValue(token)).to.equal(12); + }); + }) + + describe("getAssignedTokensForNode", () => { + it("should return an empty set if no tokens are set for a node", () => { + const node = new DesignTokenNode(); + + expect(DesignTokenNode.getAssignedTokensForNode(node).length).to.equal(0); + }); + it("should return an array that contains the tokens set for the node", () => { + const node = new DesignTokenNode(); + const token = new DesignToken(); + node.setTokenValue(token, 12); + const assigned = DesignTokenNode.getAssignedTokensForNode(node); + + expect(assigned.includes(token)).to.be.true; + expect(assigned.length).to.equal(1); + }); + it("should return an array that does not contain tokens set for ancestor nodes", () => { + const parent = new DesignTokenNode(); + const node = new DesignTokenNode(); + parent.appendChild(node); + const token = new DesignToken(); + parent.setTokenValue(token, 12); + const assigned = DesignTokenNode.getAssignedTokensForNode(node); + + expect(assigned.includes(token)).to.be.false; + expect(assigned.length).to.equal(0); + }); + }); + describe("getAssignedTokensForNodeTree", () => { + it("should return an empty set if no tokens are set for a node or it's ancestors", () => { + const node = new DesignTokenNode(); + const parent = new DesignTokenNode(); + parent.appendChild(node); + + expect(DesignTokenNode.composeAssignedTokensForNode(node).length).to.equal(0); + }); + it("should return an array that contains the tokens set for the node", () => { + const node = new DesignTokenNode(); + const parent = new DesignTokenNode(); + parent.appendChild(node); + const token = new DesignToken(); + node.setTokenValue(token, 12); + const assigned = DesignTokenNode.composeAssignedTokensForNode(node); + + expect(assigned.includes(token)).to.be.true; + expect(assigned.length).to.equal(1); + }); + it("should return an array that does contains tokens set for ancestor nodes", () => { + const parent = new DesignTokenNode(); + const node = new DesignTokenNode(); + parent.appendChild(node); + const token = new DesignToken(); + parent.setTokenValue(token, 12); + const assigned = DesignTokenNode.composeAssignedTokensForNode(node); + + expect(assigned.includes(token)).to.be.true; + expect(assigned.length).to.equal(1); + }); + }); + + describe("should notify", () => { + it("the token with the node that has the token assigned a static value", () => { + const token = new DesignToken(); + const node = new DesignTokenNode(); + const { subscriber, handleChange } = createChangeHandler(); + + Observable.getNotifier(token).subscribe(subscriber); + node.setTokenValue(token, 12); + + expect(handleChange).to.have.been.called.once; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(node, DesignTokenMutationType.add, token, 12)) + expect(node.getTokenValue(token)).to.equal(12); + }); + it("the token for the node assigned a static value when the value assigned is the same as the inherited static value", () => { + const token = new DesignToken(); + const parent = createNode(); + const child = createNode(parent); + const { handleChange, subscriber } = createChangeHandler() + + parent.setTokenValue(token, 12); + Observable.getNotifier(token).subscribe(subscriber); + + child.setTokenValue(token, 12); + + expect(handleChange).to.have.been.called.once; + expect(handleChange).to.have.been.called.with(token, new DesignTokenChangeRecord(child, DesignTokenMutationType.add, token, 12)); + }); + it("the token with the node that has the token assigned a derived value", () => { + const token = new DesignToken(); + const node = new DesignTokenNode(); + const { subscriber, handleChange } = createChangeHandler(); + + Observable.getNotifier(token).subscribe(subscriber); + const value = () => 12; + node.setTokenValue(token, value); + + expect(handleChange).to.have.been.called.once; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(node, DesignTokenMutationType.add, token, value)) + expect(node.getTokenValue(token)).to.equal(12); + }); + it("the token with the node that has the token reassigned a static value from a derived value", () => { + const token = new DesignToken(); + const node = new DesignTokenNode(); + const { subscriber, handleChange } = createChangeHandler(); + + Observable.getNotifier(token).subscribe(subscriber); + const value = () => 12; + node.setTokenValue(token, value); + node.setTokenValue(token, 14); + + expect(handleChange).to.have.been.called.twice; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(node, DesignTokenMutationType.add, token, value)); + expect(handleChange).to.have.been.second.called.with.exactly(token, new DesignTokenChangeRecord(node, DesignTokenMutationType.change, token, 14)); + expect(node.getTokenValue(token)).to.equal(14); + }); + it("the token with the node that has the token reassigned a derived value from a static value", () => { + const token = new DesignToken(); + const node = new DesignTokenNode(); + const { subscriber, handleChange } = createChangeHandler(); + + Observable.getNotifier(token).subscribe(subscriber); + node.setTokenValue(token, 12); + const value = () => 14; + node.setTokenValue(token, value); + expect(handleChange).to.have.been.called.twice; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(node, DesignTokenMutationType.add, token, 12)); + expect(handleChange).to.have.been.second.called.with.exactly(token, new DesignTokenChangeRecord(node, DesignTokenMutationType.change, token, value)); + expect(node.getTokenValue(token)).to.equal(14); + }); + it("the token with the node that has the token assigned a static value which is then deleted", () => { + const token = new DesignToken(); + const node = new DesignTokenNode(); + const { subscriber, handleChange } = createChangeHandler(); + + Observable.getNotifier(token).subscribe(subscriber); + node.setTokenValue(token, 12); + node.deleteTokenValue(token); + + expect(handleChange).to.have.been.called.twice; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(node, DesignTokenMutationType.add, token, 12)); + expect(handleChange).to.have.been.second.called.with.exactly(token, new DesignTokenChangeRecord(node, DesignTokenMutationType.delete, token)); + expect(() => node.getTokenValue(token)).to.throw(); + }); + it("the token with the node that has the token assigned a derived value which is then deleted", () => { + const token = new DesignToken(); + const node = new DesignTokenNode(); + const { subscriber, handleChange } = createChangeHandler(); + + Observable.getNotifier(token).subscribe(subscriber); + const value = () => 12; + node.setTokenValue(token, value); + node.deleteTokenValue(token); + + expect(handleChange).to.have.been.called.twice; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(node, DesignTokenMutationType.add, token, value)); + expect(handleChange).to.have.been.second.called.with.exactly(token, new DesignTokenChangeRecord(node, DesignTokenMutationType.delete, token)); + expect(() => node.getTokenValue(token)).to.throw(); + }); + it("the token with the node that has a token assigned a derived value and a dependency of the derived value changes for the node", () => { + const token = new DesignToken(); + const dependency = new DesignToken(); + const node = new DesignTokenNode(); + const { subscriber, handleChange } = createChangeHandler(); + + const value = (resolve: DesignTokenResolver) => resolve(dependency) * 2; + node.setTokenValue(dependency, 6); + node.setTokenValue(token, value); + + Observable.getNotifier(token).subscribe(subscriber); + + expect(node.getTokenValue(token)).to.equal(12); + + node.setTokenValue(dependency, 7); + + expect(handleChange).to.have.been.called.once; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(node, DesignTokenMutationType.change, token, value)); + expect(node.getTokenValue(token)).to.equal(14); + }); + it("the token with the descendent node that has a token assigned a static value that is a dependency of a value assigned for an ancestor", () => { + const token = new DesignToken(); + const dependency = new DesignToken(); + const ancestor = createNode(); + const parent = createNode(ancestor); + const descendent = createNode(parent); + const { subscriber, handleChange } = createChangeHandler(); + + ancestor.setTokenValue(dependency, 6); + const value = (resolve: DesignTokenResolver) => resolve(dependency) * 2; + ancestor.setTokenValue(token, value); + + Observable.getNotifier(token).subscribe(subscriber); + + expect(descendent.getTokenValue(token)).to.equal(12); + + descendent.setTokenValue(dependency, 7); + + expect(handleChange).to.have.been.called.once; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(descendent, DesignTokenMutationType.change, token, value)); + expect(ancestor.getTokenValue(token)).to.equal(12); + expect(parent.getTokenValue(token)).to.equal(12); + expect(descendent.getTokenValue(token)).to.equal(14); + }); + it("the token with the descendent node that has a token assigned a derived value that is a dependency of a value assigned for an ancestor", () => { + const token = new DesignToken(); + const dependency = new DesignToken(); + const ancestor = createNode(); + const parent = createNode(ancestor); + const descendent = createNode(parent); + const { subscriber, handleChange } = createChangeHandler(); + + ancestor.setTokenValue(dependency, 6); + const value = (resolve: DesignTokenResolver) => resolve(dependency) * 2 + ancestor.setTokenValue(token, value); + + Observable.getNotifier(token).subscribe(subscriber); + + expect(descendent.getTokenValue(token)).to.equal(12); + + descendent.setTokenValue(dependency, () => 7); + + expect(handleChange).to.have.been.called.once; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(descendent, DesignTokenMutationType.change, token, value)); + expect(ancestor.getTokenValue(token)).to.equal(12); + expect(parent.getTokenValue(token)).to.equal(12); + expect(descendent.getTokenValue(token)).to.equal(14); + }); + it("the token with the descendent node that has a token reassigned a static value that is a dependency of a value assigned for an ancestor", () => { + const token = new DesignToken(); + const dependency = new DesignToken(); + const ancestor = createNode(); + const parent = createNode(ancestor); + const descendent = createNode(parent); + const { subscriber, handleChange } = createChangeHandler(); + + const value = (resolve: DesignTokenResolver) => resolve(dependency) * 2; + ancestor.setTokenValue(dependency, 6); + ancestor.setTokenValue(token, value); + + expect(descendent.getTokenValue(token)).to.equal(12); + + descendent.setTokenValue(dependency, 7); + Observable.getNotifier(token).subscribe(subscriber); + + descendent.setTokenValue(dependency, 8) + + expect(handleChange).to.have.been.called.once; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(descendent, DesignTokenMutationType.change, token, value)); + expect(ancestor.getTokenValue(token)).to.equal(12); + expect(parent.getTokenValue(token)).to.equal(12); + expect(descendent.getTokenValue(token)).to.equal(16); + }); + it("the token with the descendent node that has a token reassigned a derived value that is a dependency of a value assigned for an ancestor", () => { + const token = new DesignToken(); + const dependency = new DesignToken(); + const ancestor = createNode(); + const parent = createNode(ancestor); + const descendent = createNode(parent); + const { subscriber, handleChange } = createChangeHandler(); + + ancestor.setTokenValue(dependency, 6); + const value = (resolve: DesignTokenResolver) => resolve(dependency) * 2; + ancestor.setTokenValue(token, value); + + expect(descendent.getTokenValue(token)).to.equal(12); + + descendent.setTokenValue(dependency, () => 7); + Observable.getNotifier(token).subscribe(subscriber); + + descendent.setTokenValue(dependency, () => 8) + + expect(handleChange).to.have.been.called.once; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(descendent, DesignTokenMutationType.change, token, value)); + expect(ancestor.getTokenValue(token)).to.equal(12); + expect(parent.getTokenValue(token)).to.equal(12); + expect(descendent.getTokenValue(token)).to.equal(16); + }); + it("the token with a descendent node when a ancestor and descendent both have a dependency assigned and the ancestor is reassigned a token to a derived value that resolves the dependency and results in a value change", () => { + const token = new DesignToken(); + const dependency = new DesignToken(); + const ancestor = createNode(); + const parent = createNode(ancestor); + const descendent = createNode(parent); + const { subscriber, handleChange } = createChangeHandler(); + + ancestor.setTokenValue(dependency, 5); + ancestor.setTokenValue(token, 12); + descendent.setTokenValue(dependency, 7); + Observable.getNotifier(token).subscribe(subscriber); + + expect(descendent.getTokenValue(token)).to.equal(12); + + const value = (resolve: DesignTokenResolver) => resolve(dependency) * 2 + ancestor.setTokenValue(token, value); + + expect(handleChange).to.have.been.called.twice; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(ancestor, DesignTokenMutationType.change, token, value)); + expect(handleChange).to.have.been.second.called.with.exactly(token, new DesignTokenChangeRecord(descendent, DesignTokenMutationType.add, token, value)); + expect(ancestor.getTokenValue(token)).to.equal(10); + expect(parent.getTokenValue(token)).to.equal(10); + expect(descendent.getTokenValue(token)).to.equal(14); + }); + it("the token with the descendent node that has a token assigned a static value deleted that is a dependency of a value assigned for an ancestor", () => { + const token = new DesignToken(); + const dependency = new DesignToken(); + const ancestor = createNode(); + const parent = createNode(ancestor); + const descendent = createNode(parent); + const { subscriber, handleChange } = createChangeHandler(); + + ancestor.setTokenValue(dependency, 6); + const value = (resolve: DesignTokenResolver) => resolve(dependency) * 2; + ancestor.setTokenValue(token, value); + descendent.setTokenValue(dependency, 7); + Observable.getNotifier(token).subscribe(subscriber); + expect(descendent.getTokenValue(token)).to.equal(14); + + descendent.deleteTokenValue(dependency); + + expect(handleChange).to.have.been.called.once; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(descendent, DesignTokenMutationType.change, token, value)); + expect(ancestor.getTokenValue(token)).to.equal(12); + expect(parent.getTokenValue(token)).to.equal(12); + expect(descendent.getTokenValue(token)).to.equal(12); + }); + it("the token with the descendent node that has a token assigned a derived value deleted that is a dependency of a value assigned for an ancestor", () => { + const token = new DesignToken(); + const dependency = new DesignToken(); + const ancestor = createNode(); + const parent = createNode(ancestor); + const descendent = createNode(parent); + const { subscriber, handleChange } = createChangeHandler(); + + ancestor.setTokenValue(dependency, 6); + const value = (resolve: DesignTokenResolver) => resolve(dependency) * 2; + ancestor.setTokenValue(token, value); + descendent.setTokenValue(dependency, () => 7); + Observable.getNotifier(token).subscribe(subscriber); + expect(descendent.getTokenValue(token)).to.equal(14); + + descendent.deleteTokenValue(dependency); + + expect(handleChange).to.have.been.called.once; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(descendent, DesignTokenMutationType.change, token, value)); + expect(ancestor.getTokenValue(token)).to.equal(12); + expect(parent.getTokenValue(token)).to.equal(12); + expect(descendent.getTokenValue(token)).to.equal(12); + }); + it("should the token for ancestor, parent, and descendent nodes when parent and descendent are assigned a value that depends on the token and the ancestor's value is changed", () => { + const token = new DesignToken(); + const ancestor = createNode(); + const parent = createNode(ancestor); + const descendent = createNode(parent); + const { subscriber, handleChange } = createChangeHandler(); + + ancestor.setTokenValue(token, 6); + const parentValue = (resolve: DesignTokenResolver) => resolve(token) * 2; + parent.setTokenValue(token, parentValue); + const descendentValue = (resolve: DesignTokenResolver) => resolve(token) * 2; + descendent.setTokenValue(token, descendentValue); + Observable.getNotifier(token).subscribe(subscriber); + + expect(descendent.getTokenValue(token)).to.equal(6 * 2 *2); + + ancestor.setTokenValue(token, 7); + expect(handleChange).to.have.been.called.exactly(3) + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(ancestor, DesignTokenMutationType.change, token, 7)) + expect(handleChange).to.have.been.second.called.with.exactly(token, new DesignTokenChangeRecord(parent, DesignTokenMutationType.change, token, parentValue)) + expect(handleChange).to.have.been.nth(3).called.with.exactly(token, new DesignTokenChangeRecord(descendent, DesignTokenMutationType.change, token, descendentValue)) + expect(ancestor.getTokenValue(token)).to.equal(7); + expect(parent.getTokenValue(token)).to.equal(7 * 2); + expect(descendent.getTokenValue(token)).to.equal(7 * 2 * 2); + }); + /** + * Appending nodes + */ + it("the token with the descendent node that has a dependency assigned when the node is appended to an ancestor with a derived value assigned that depends on the dependency", () => { + const ancestor = createNode(); + const parent = createNode(ancestor); + const descendent = createNode(); + const dependency = new DesignToken(); + const token = new DesignToken(); + const { subscriber, handleChange } = createChangeHandler(); + + ancestor.setTokenValue(dependency, 6); + const value = (resolve: DesignTokenResolver) => resolve(dependency) * 2; + ancestor.setTokenValue(token, value); + descendent.setTokenValue(dependency, 7); + + Observable.getNotifier(token).subscribe(subscriber); + + parent.appendChild(descendent); + + expect(handleChange).to.have.been.called.once; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(descendent, DesignTokenMutationType.add, token, value)); + expect(descendent.getTokenValue(token)).to.equal(14); + }); + /** + * Removing nodes + */ + it("the token with the descendent node that has a dependency assigned when the node is appended to an ancestor with a derived value assigned that depends on the dependency and is then removed", () => { + const ancestor = createNode(); + const parent = createNode(ancestor); + const descendent = createNode(); + const dependency = new DesignToken(); + const token = new DesignToken(); + const { subscriber, handleChange } = createChangeHandler(); + + ancestor.setTokenValue(dependency, 6); + ancestor.setTokenValue(token, (resolve) => resolve(dependency) * 2); + descendent.setTokenValue(dependency, 7); + + parent.appendChild(descendent); + Observable.getNotifier(token).subscribe(subscriber); + + parent.removeChild(descendent) + + expect(handleChange).to.have.been.called.once; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(descendent, DesignTokenMutationType.delete, token)); + expect(() => descendent.getTokenValue(token)).to.throw; + expect(2).to.equal(2) + }); + /** + * Moving node + */ + it("the token with the descendent node that has a dependency assigned when the node is re-parented to an ancestor with a different derived value assigned that depends on the dependency", () => { + const ancestorA = createNode(); + const ancestorB = createNode(); + const parentA = createNode(ancestorA); + const parentB = createNode(ancestorB); + const descendent = createNode(parentA); + const dependency = new DesignToken(); + const token = new DesignToken(); + const { subscriber, handleChange } = createChangeHandler(); + + ancestorA.setTokenValue(dependency, 6); + ancestorA.setTokenValue(token, (resolve) => resolve(dependency) * 2); + ancestorB.setTokenValue(dependency, 7); + const value = ( resolve: DesignTokenResolver ) => resolve(dependency) * 3; + ancestorB.setTokenValue(token, value); + descendent.setTokenValue(dependency, 7); + + Observable.getNotifier(token).subscribe(subscriber); + + parentB.appendChild(descendent); + + expect(handleChange).to.have.been.called.twice; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(descendent, DesignTokenMutationType.delete, token)); + expect(handleChange).to.have.been.second.called.with.exactly(token, new DesignTokenChangeRecord(descendent, DesignTokenMutationType.add, token, value)); + expect(descendent.getTokenValue(token)).to.equal(21); + }); + + /** + * Observable values + */ + it("the token with the node assigned a derived value when an observable value used by the value is changed", () => { + const node = createNode(); + const token = new DesignToken(); + const dependencies: { value: number } = makeObservable({ value: 6}); + const { subscriber, handleChange} = createChangeHandler(); + + const value = () => dependencies.value * 2; + node.setTokenValue(token, value); + Observable.getNotifier(token).subscribe(subscriber) + + expect(node.getTokenValue(token)).to.equal(12); + + dependencies.value = 7; + + expect(node.getTokenValue(token)).to.equal(14); + expect(handleChange).to.have.been.called.once; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(node, DesignTokenMutationType.change, token, value)); + }); + it("the token with the ancestor and descendent node when the ancestor is assigned a derived value using an observable and a token, where both nodes contain a value set for the dependency", () => { + const ancestor = createNode(); + const parent = createNode(ancestor); + const descendent = createNode(parent); + const token = new DesignToken(); + const dependency = new DesignToken(); + const observableDependency: { value: number } = makeObservable({ value: 6}); + const { subscriber, handleChange} = createChangeHandler(); + + ancestor.setTokenValue(dependency, 4); + const value = (resolve: DesignTokenResolver) => observableDependency.value * 2 + resolve(dependency) + ancestor.setTokenValue(token, value); + descendent.setTokenValue(dependency, 8); + Observable.getNotifier(token).subscribe(subscriber) + + expect(ancestor.getTokenValue(token)).to.equal(16); + expect(descendent.getTokenValue(token)).to.equal(20); + + observableDependency.value = 7; + + expect(ancestor.getTokenValue(token)).to.equal(18); + expect(descendent.getTokenValue(token)).to.equal(22); + expect(handleChange).to.have.been.called.twice; + expect(handleChange).to.have.been.first.called.with.exactly(token, new DesignTokenChangeRecord(ancestor, DesignTokenMutationType.change, token, value)); + expect(handleChange).to.have.been.second.called.with.exactly(token, new DesignTokenChangeRecord(descendent, DesignTokenMutationType.change, token, value)); + }); + }); + + describe("should not notify", () => { + it("the token when the static value assigned to a node is the same value as was previously assigned", () => { + const token = new DesignToken(); + const node = new DesignTokenNode(); + const handleChange = chai.spy(() => {}) + const subscriber: Subscriber = { handleChange } + node.setTokenValue(token, 12); + Observable.getNotifier(token).subscribe(subscriber); + + node.setTokenValue(token, 12); + + expect(handleChange).not.to.have.been.called(); + }); + it("the token when the derived value assigned to a node results in the same value as the previously assigned static value", () => { + const token = new DesignToken(); + const node = new DesignTokenNode(); + const handleChange = chai.spy(() => {}) + const subscriber: Subscriber = { handleChange } + node.setTokenValue(token, 12); + Observable.getNotifier(token).subscribe(subscriber); + + node.setTokenValue(token, () => 12); + + expect(handleChange).not.to.have.been.called(); + }); + it("the token when the derived value assigned to a node results in the same value as the previously assigned derived value", () => { + const token = new DesignToken(); + const node = new DesignTokenNode(); + const handleChange = chai.spy(() => {}) + const subscriber: Subscriber = { handleChange } + function a() { + return 12; + } + + function b() { + return 12; + } + + node.setTokenValue(token, a); + Observable.getNotifier(token).subscribe(subscriber); + + node.setTokenValue(token, b); + + expect(a).not.to.equal(b) + expect(handleChange).not.to.have.been.called(); + }); + + it("the token when a dependency of a derived token value is set for a descendent but there is an intermediary value set that is a static value", () => { + const token = new DesignToken(); + const dependency = new DesignToken(); + const ancestor = createNode(); + const parent = createNode(ancestor); + const child = createNode(parent); + const { subscriber, handleChange } = createChangeHandler(); + ancestor.setTokenValue(dependency, 12); + ancestor.setTokenValue(token, (resolve) => resolve(dependency) * 2); + parent.setTokenValue(token, 25); + + Observable.getNotifier(token).subscribe(subscriber) + child.setTokenValue(dependency, 13); + + expect(handleChange).not.to.have.been.called; + expect(child.getTokenValue(token)).to.equal(25); + }); + it("the token when a dependency of a derived token value is set for a descendent but there is an intermediary value set that is a derived value that does not depend on the dependent token", () => { + const token = new DesignToken(); + const dependency = new DesignToken(); + const ancestor = createNode(); + const parent = createNode(ancestor); + const child = createNode(parent); + const { subscriber, handleChange } = createChangeHandler(); + ancestor.setTokenValue(dependency, 12); + ancestor.setTokenValue(token, (resolve) => resolve(dependency) * 2); + parent.setTokenValue(token, () => 25); + + Observable.getNotifier(token).subscribe(subscriber) + child.setTokenValue(dependency, 13); + + expect(handleChange).not.to.have.been.called; + expect(child.getTokenValue(token)).to.equal(25); + }); + it("the token when a derived value using an observable value is deleted and then the observable value is changed", () => { + const token = new DesignToken(); + const node = new DesignTokenNode(); + const handleChange = chai.spy(() => {}) + const subscriber: Subscriber = { handleChange } + node.setTokenValue(token, 12); + const dependencies = makeObservable({value: 6}); + + node.setTokenValue(token, () => dependencies.value * 2); + node.deleteTokenValue(token); + Observable.getNotifier(token).subscribe(subscriber); + + dependencies.value = 7; + + expect(handleChange).not.to.have.been.called(); + }); + it("the token when a derived value using an observable value is re-assigned and then the observable value is changed", () => { + const token = new DesignToken(); + const node = new DesignTokenNode(); + const handleChange = chai.spy(() => {}) + const subscriber: Subscriber = { handleChange } + node.setTokenValue(token, 12); + const dependencies = makeObservable({value: 6}); + + node.setTokenValue(token, () => dependencies.value * 2); + node.setTokenValue(token, () => 14); + Observable.getNotifier(token).subscribe(subscriber); + dependencies.value = 7; + + expect(handleChange).not.to.have.been.called(); + }); + }); + + + describe("Original tests", () => { + describe("getting and setting a simple value", () => { + + + }); + describe("getting and setting derived values", () => { + + }); + }) +}); diff --git a/packages/web-components/fast-foundation/src/design-token/core/design-token-node.ts b/packages/web-components/fast-foundation/src/design-token/core/design-token-node.ts new file mode 100644 index 00000000000..9ed6f0fab0e --- /dev/null +++ b/packages/web-components/fast-foundation/src/design-token/core/design-token-node.ts @@ -0,0 +1,694 @@ +import { + Disposable, + ExpressionNotifier, + Observable, + Subscriber, +} from "@microsoft/fast-element"; +import type { DesignToken } from "./design-token.js"; + +/** + * A function that resolves the value of a DesignToken. + * @public + */ +export type DesignTokenResolver = (token: DesignToken) => T; + +/** + * A {@link DesignToken} value that is derived. These values can depend on other {@link DesignToken}s + * or arbitrary observable properties. + * @public + */ +export type DerivedDesignTokenValue = (resolve: DesignTokenResolver) => T; + +/** + * A design token value with no observable dependencies + * @public + */ +export type StaticDesignTokenValue = T extends (...args: any[]) => any + ? DerivedDesignTokenValue + : T; + +/** + * The type that a {@link DesignToken} can be set to. + * @public + */ +export type DesignTokenValue = StaticDesignTokenValue | DerivedDesignTokenValue; + +class DerivedValueEvaluator { + private readonly binding: ExpressionNotifier; + private notifier = Observable.getNotifier(this); + public readonly dependencies = new Set>(); + private static cache = new WeakMap< + DerivedDesignTokenValue, + DerivedValueEvaluator + >(); + + constructor(public readonly value: DerivedDesignTokenValue) { + this.binding = Observable.binding(value, this); + this.binding.setMode(false); + } + + public static getOrCreate( + value: DerivedDesignTokenValue + ): DerivedValueEvaluator { + let v = DerivedValueEvaluator.cache.get(value); + + if (v) { + return v; + } + v = new DerivedValueEvaluator(value); + DerivedValueEvaluator.cache.set(value, v); + + return v; + } + + public evaluate(node: DesignTokenNode, tokenContext: DesignToken): T { + const resolve = (token: DesignToken): T => { + this.dependencies.add(token); + if (tokenContext === token) { + if (node.parent) { + return node.parent.getTokenValue(token); + } + + throw new Error( + "DesignTokenNode has encountered a circular token reference. Avoid this by setting the token value for an ancestor node." + ); + } else { + return node.getTokenValue(token); + } + }; + + return this.binding.observe(resolve); + } + + public handleChange() { + this.notifier.notify(undefined); + } +} + +class DerivedValue implements Disposable { + value: T; + constructor( + public readonly token: DesignToken, + public readonly evaluator: DerivedValueEvaluator, + public readonly node: DesignTokenNode, + private subscriber?: Subscriber + ) { + this.value = evaluator.evaluate(node, token); + + if (this.subscriber) { + Observable.getNotifier(this.evaluator).subscribe(this.subscriber); + } + } + + public dispose(): void { + if (this.subscriber) { + Observable.getNotifier(this.evaluator).unsubscribe(this.subscriber); + } + } + + public update() { + this.value = this.evaluator.evaluate(this.node, this.token); + + return this; + } +} + +/** + * @public + */ +export interface DesignTokenChangeRecord { + readonly target: DesignTokenNode; + readonly type: DesignTokenMutationType; + readonly token: DesignToken; +} + +/** + * @internal + */ +export class DesignTokenChangeRecordImpl implements DesignTokenChangeRecord { + constructor( + public readonly target: DesignTokenNode, + public readonly type: DesignTokenMutationType, + public readonly token: DesignToken, + public readonly value?: DesignTokenValue + ) {} + + public notify() { + Observable.getNotifier(this.token).notify(this); + } +} + +/** + * @public + */ +export const enum DesignTokenMutationType { + add, + change, + delete, +} + +/** + * @public + */ +export class DesignTokenNode { + private _parent: DesignTokenNode | null = null; + private _children: Set = new Set(); + private _values: Map, DesignTokenValue> = new Map(); + private _derived: Map, DerivedValue> = new Map(); + private dependencyGraph: Map, Set>> = new Map(); + private static _notifications: DesignTokenChangeRecordImpl[] = []; + + /** + * Determines if a value is a {@link DerivedDesignTokenValue} + * @param value - The value to test + */ + private static isDerivedTokenValue( + value: DesignTokenValue + ): value is DerivedDesignTokenValue { + return typeof value === "function"; + } + + /** + * Determines if a token has a derived value for a node. + */ + private static isDerivedFor(node: DesignTokenNode, token: DesignToken) { + return node._derived.has(token); + } + + /** + * Collects token/value pairs for all derived token / values set on upstream nodes. + */ + private static collectDerivedContext( + node: DesignTokenNode + ): Map, DerivedValue> { + const collected = new Map, DerivedValue>(); + // Exit early if there is no parent + if (node.parent === null) { + return collected; + } + + let ignored = DesignTokenNode.getAssignedTokensForNode(node); + let current: DesignTokenNode | null = node.parent; + + do { + const assigned = DesignTokenNode.getAssignedTokensForNode(current); + for (let i = 0, l = assigned.length; i < l; i++) { + const token = assigned[i]; + + if ( + !ignored.includes(token) && + DesignTokenNode.isDerivedFor(current, token) + ) { + collected.set(token, current!._derived.get(token)!); + } + } + + ignored = Array.from(new Set(ignored.concat(assigned))); + current = current.parent; + } while (current !== null); + + return collected; + } + + /** + * Resolves the local value for a token if it is assigned, otherwise returns undefined. + */ + private static getLocalTokenValue( + node: DesignTokenNode, + token: DesignToken + ): StaticDesignTokenValue | undefined { + return !DesignTokenNode.isAssigned(node, token) + ? undefined + : DesignTokenNode.isDerivedFor(node, token) + ? node._derived.get(token)!.value + : node._values.get(token); + } + + private static getOrCreateDependencyGraph( + node: DesignTokenNode, + token: DesignToken + ): Set> { + let dependents = node.dependencyGraph.get(token); + + if (dependents) { + return dependents; + } + + dependents = new Set>(); + node.dependencyGraph.set(token, dependents); + + return dependents; + } + + /** + * Emit all queued notifications + */ + private static notify() { + for (const record of this._notifications) { + record.notify(); + } + + this._notifications = []; + } + + private static queueNotification(...records: DesignTokenChangeRecordImpl[]) { + this._notifications.push(...records); + } + + /** + * Retrieves all tokens assigned directly to a node. + * @param node - the node to retrieve assigned tokens for + * @returns + */ + public static getAssignedTokensForNode(node: DesignTokenNode): DesignToken[] { + return Array.from(node._values.keys()); + } + + /** + * Retrieves all tokens assigned to the node and ancestor nodes. + * @param node - the node to compose assigned tokens for + */ + public static composeAssignedTokensForNode( + node: DesignTokenNode + ): DesignToken[] { + const tokens = new Set(DesignTokenNode.getAssignedTokensForNode(node)); + let current = node.parent; + + while (current !== null) { + const assignedTokens = DesignTokenNode.getAssignedTokensForNode(current); + + for (const token of assignedTokens) { + tokens.add(token); + } + + current = current.parent; + } + + return Array.from(tokens); + } + + /** + * Tests if a token is assigned directly to a node + * @param node - The node to test + * @param token - The token to test + * @returns + */ + public static isAssigned(node: DesignTokenNode, token: DesignToken) { + return node._values.has(token); + } + + /** + * The parent node + */ + public get parent(): DesignTokenNode | null { + return this._parent; + } + + public get children(): DesignTokenNode[] { + return Array.from(this._children); + } + + /** + * Appends a child to the node, notifying for any tokens set for the node's context. + */ + public appendChild(child: DesignTokenNode) { + if (child.parent !== null) { + child.parent.removeChild(child); + } + + const context = DesignTokenNode.composeAssignedTokensForNode(this); + const derivedContext = DesignTokenNode.collectDerivedContext(this); + child._parent = this; + this._children.add(child); + + for (const token of context) { + child.dispatch( + new DesignTokenChangeRecordImpl( + this, + DesignTokenMutationType.add, + token, + derivedContext.get(token)?.evaluator.value + ) + ); + } + DesignTokenNode.notify(); + } + + /** + * Appends a child to the node, notifying for any tokens set for the node's context. + */ + public removeChild(child: DesignTokenNode) { + if (child.parent === this) { + const context = DesignTokenNode.composeAssignedTokensForNode(this); + + child._parent = null; + this._children.delete(child); + + for (const token of context) { + child.dispatch( + new DesignTokenChangeRecordImpl( + this, + DesignTokenMutationType.delete, + token + ) + ); + } + + DesignTokenNode.notify(); + } + } + + /** + * Dispose of the node, removing parent/child relationships and + * unsubscribing all observable binding subscribers. Does not emit + * notifications. + */ + public dispose() { + if (this.parent) { + this.parent._children.delete(this); + this._parent = null; + } + + for (const [, derived] of this._derived) { + derived.dispose(); + } + } + + /** + * Sets a token to a value + */ + public setTokenValue(token: DesignToken, value: DesignTokenValue) { + const changeType = + DesignTokenNode.isAssigned(this, token) || + DesignTokenNode.isDerivedFor(this, token) + ? DesignTokenMutationType.change + : DesignTokenMutationType.add; + const prev = DesignTokenNode.getLocalTokenValue(this, token); + this._values.set(token, value); + if (DesignTokenNode.isDerivedFor(this, token)) { + this.tearDownDerivedTokenValue(token); + } + const isDerived = DesignTokenNode.isDerivedTokenValue(value); + const derivedContext = DesignTokenNode.collectDerivedContext(this); + let result: StaticDesignTokenValue; + + if (isDerived) { + const evaluator = this.setupDerivedTokenValue(token, value, true); + result = evaluator.value; + } else { + result = value; + } + + if (prev !== result) { + DesignTokenNode.queueNotification( + new DesignTokenChangeRecordImpl(this, changeType, token, value) + ); + } + + this.dispatch(new DesignTokenChangeRecordImpl(this, changeType, token, value)); + + derivedContext.forEach((derivedValue, token) => { + // Skip over any derived values already established locally, because + // those will get updated via this.notifyDerived and this.notifyStatic + if (!DesignTokenNode.isDerivedFor(this, token)) { + const prev = DesignTokenNode.getLocalTokenValue(this, token); + derivedValue = this.setupDerivedTokenValue( + token, + derivedValue.evaluator.value + ); + const result = derivedValue.value; + if (prev !== result) { + DesignTokenNode.queueNotification( + new DesignTokenChangeRecordImpl( + this, + DesignTokenMutationType.change, + token, + derivedValue.evaluator.value + ) + ); + } + + this.dispatch( + new DesignTokenChangeRecordImpl( + this, + DesignTokenMutationType.add, + token, + derivedValue.evaluator.value + ) + ); + } + }); + + DesignTokenNode.notify(); + } + + /** + * Returns the resolve value for a token + */ + public getTokenValue(token: DesignToken): T { + /* eslint-disable-next-line */ + let node: DesignTokenNode | null = this; + let value; + + while (node !== null) { + if (DesignTokenNode.isDerivedFor(node, token)) { + value = node._derived.get(token)!.value; + break; + } + + if (DesignTokenNode.isAssigned(node, token)) { + value = node._values.get(token); + break; + } + + node = node._parent; + } + + if (value !== undefined) { + return value; + } else { + throw new Error(`No value set for token ${token} in node tree.`); + } + } + + /** + * Deletes the token value for a node + */ + public deleteTokenValue(token: DesignToken): void { + if (DesignTokenNode.isAssigned(this, token)) { + const prev = DesignTokenNode.getLocalTokenValue(this, token); + this._values.delete(token); + this.tearDownDerivedTokenValue(token); + let newValue: T | undefined; + try { + newValue = this.getTokenValue(token); + } catch (e) { + newValue = undefined; + } + + DesignTokenNode.queueNotification( + new DesignTokenChangeRecordImpl( + this, + DesignTokenMutationType.delete, + token + ) + ); + + if (prev !== newValue) { + this.dispatch( + new DesignTokenChangeRecordImpl( + this, + DesignTokenMutationType.delete, + token + ) + ); + } + + DesignTokenNode.notify(); + } + } + + /** + * Notifies that a token has been mutated + */ + private dispatch(record: DesignTokenChangeRecordImpl) { + if (this !== record.target) { + const { token } = record; + // If the node is assigned the token being dispatched and the assigned value does not depend on the token + // (circular token reference) then terminate the dispatch. + const isAssigned = DesignTokenNode.isAssigned(this, token); + const containsCircularForToken = + isAssigned && this._derived.get(token)?.evaluator.dependencies.has(token); + if (isAssigned && !containsCircularForToken) { + return; + } + + // Delete token evaluations if the token is not assigned explicitly but is derived for the node and + // the record is a delete type. + if ( + record.type === DesignTokenMutationType.delete && + !isAssigned && + DesignTokenNode.isDerivedFor(this, token) + ) { + this.tearDownDerivedTokenValue(token); + DesignTokenNode.queueNotification( + new DesignTokenChangeRecordImpl( + this, + DesignTokenMutationType.delete, + token + ) + ); + } + + if (containsCircularForToken) { + record = new DesignTokenChangeRecordImpl( + this, + DesignTokenMutationType.change, + token, + this._derived.get(token)?.evaluator.value + ); + } + + const { value } = record; + + if (value && DesignTokenNode.isDerivedTokenValue(value)) { + const dependencies = DerivedValueEvaluator.getOrCreate(value) + .dependencies; + // If this is not the originator, check to see if this node + // has any dependencies of the token value. If so, we need to evaluate for this node + let evaluate = false; + + for (const dependency of dependencies) { + if (DesignTokenNode.isAssigned(this, dependency)) { + evaluate = true; + break; + } + } + + if (evaluate) { + const prev = this._derived.get(token)?.value; + const derivedValue = this.setupDerivedTokenValue(token, value); + + if (prev !== derivedValue.value) { + const type = + prev === undefined + ? DesignTokenMutationType.add + : DesignTokenMutationType.change; + const notification = new DesignTokenChangeRecordImpl( + this, + type, + token, + derivedValue.evaluator.value + ); + DesignTokenNode.queueNotification(notification); + record = notification; + } + } + } + } + + this.collectLocalChangeRecords(record).forEach(_record => { + DesignTokenNode.queueNotification(_record); + this.dispatch(_record); + }); + + this.notifyChildren(record); + } + + /** + * Generate change-records for local dependencies of a change record + */ + private collectLocalChangeRecords( + record: DesignTokenChangeRecordImpl + ): Map, DesignTokenChangeRecordImpl> { + const collected = new Map, DesignTokenChangeRecordImpl>(); + for (const dependent of DesignTokenNode.getOrCreateDependencyGraph( + this, + record.token + )) { + if (dependent.value !== dependent.update().value) { + collected.set( + dependent.token, + new DesignTokenChangeRecordImpl( + this, + DesignTokenMutationType.change, + dependent.token, + dependent.evaluator.value + ) + ); + } + } + + return collected; + } + + /** + * + * Notify children of changes to the node + */ + private notifyChildren(...records: DesignTokenChangeRecordImpl[]) { + if (this.children.length) { + for (let i = 0, l = this.children.length; i < l; i++) { + for (let j = 0; j < records.length; j++) { + this.children[i].dispatch(records[j]); + } + } + } + } + + private tearDownDerivedTokenValue(token: DesignToken) { + if (DesignTokenNode.isDerivedFor(this, token)) { + const value = this._derived.get(token)!; + + value.dispose(); + + this._derived.delete(token); + + value.evaluator.dependencies.forEach(dependency => { + DesignTokenNode.getOrCreateDependencyGraph(this, dependency).delete( + value + ); + }); + } + } + + private setupDerivedTokenValue( + token: DesignToken, + value: DerivedDesignTokenValue, + subscribeNode = false + ) { + const deriver = new DerivedValue( + token, + DerivedValueEvaluator.getOrCreate(value), + this, + subscribeNode + ? { + handleChange: () => { + if (deriver.value !== deriver.update().value) { + const record = new DesignTokenChangeRecordImpl( + this, + DesignTokenMutationType.change, + deriver.token, + deriver.evaluator.value + ); + DesignTokenNode.queueNotification(record); + + this.dispatch(record); + DesignTokenNode.notify(); + } + }, + } + : undefined + ); + + this._derived.set(token, deriver); + + deriver.evaluator.dependencies.forEach(dependency => { + if (dependency !== token) { + DesignTokenNode.getOrCreateDependencyGraph(this, dependency).add(deriver); + } + }); + + return deriver; + } +} diff --git a/packages/web-components/fast-foundation/src/design-token/core/design-token.ts b/packages/web-components/fast-foundation/src/design-token/core/design-token.ts new file mode 100644 index 00000000000..2aafcd6b14f --- /dev/null +++ b/packages/web-components/fast-foundation/src/design-token/core/design-token.ts @@ -0,0 +1,6 @@ +/** + * @public + */ +export interface DesignToken { + readonly $value: T | undefined; +} diff --git a/packages/web-components/fast-foundation/src/design-token/core/exports.ts b/packages/web-components/fast-foundation/src/design-token/core/exports.ts new file mode 100644 index 00000000000..d4b0f9e8bb1 --- /dev/null +++ b/packages/web-components/fast-foundation/src/design-token/core/exports.ts @@ -0,0 +1,10 @@ +export { DesignToken } from "./design-token.js"; +export { + DesignTokenNode, + DesignTokenResolver, + DerivedDesignTokenValue, + StaticDesignTokenValue, + DesignTokenValue, + DesignTokenMutationType, + DesignTokenChangeRecord, +} from "./design-token-node.js"; diff --git a/packages/web-components/fast-foundation/src/design-token/custom-property-manager.ts b/packages/web-components/fast-foundation/src/design-token/custom-property-manager.ts index 2765e613032..e97a70f71da 100644 --- a/packages/web-components/fast-foundation/src/design-token/custom-property-manager.ts +++ b/packages/web-components/fast-foundation/src/design-token/custom-property-manager.ts @@ -8,12 +8,6 @@ import { Updates, } from "@microsoft/fast-element"; -export const defaultElement = document.createElement("div"); - -function isFastElement(element: HTMLElement | FASTElement): element is FASTElement { - return element instanceof FASTElement; -} - interface PropertyTarget { setProperty(name: string, value: string | null): void; removeProperty(name: string): void; @@ -34,7 +28,7 @@ abstract class QueuedStyleSheetTarget implements PropertyTarget { */ class ConstructableStyleSheetTarget extends QueuedStyleSheetTarget { protected target: PropertyTarget; - constructor(source: HTMLElement & FASTElement) { + constructor(source: FASTElement) { super(); const sheet = new CSSStyleSheet(); @@ -97,7 +91,7 @@ class StyleElementStyleSheetTarget implements PropertyTarget { } } - constructor(target: HTMLElement & FASTElement) { + constructor(target: FASTElement) { const controller = target.$fastController; this.style = document.createElement("style") as HTMLStyleElement; @@ -148,24 +142,6 @@ class StyleElementStyleSheetTarget implements PropertyTarget { } } -/** - * Handles setting properties for a normal HTMLElement - */ -class ElementStyleSheetTarget implements PropertyTarget { - private target: PropertyTarget; - constructor(source: HTMLElement) { - this.target = source.style; - } - - setProperty(name: string, value: any) { - Updates.enqueue(() => this.target.setProperty(name, value)); - } - - removeProperty(name: string) { - Updates.enqueue(() => this.target.removeProperty(name)); - } -} - /** * Controls emission for default values. This control is capable * of emitting to multiple {@link PropertyTarget | PropertyTargets}, @@ -174,65 +150,50 @@ class ElementStyleSheetTarget implements PropertyTarget { * @internal */ export class RootStyleSheetTarget implements PropertyTarget { - private static roots = new Set(); + private static roots = new Set(); private static properties: Record = {}; public setProperty(name: string, value: any): void { RootStyleSheetTarget.properties[name] = value; for (const target of RootStyleSheetTarget.roots.values()) { - PropertyTargetManager.getOrCreate( - RootStyleSheetTarget.normalizeRoot(target) - ).setProperty(name, value); + PropertyTargetManager.getOrCreate(target).setProperty(name, value); } } public removeProperty(name: string): void { delete RootStyleSheetTarget.properties[name]; for (const target of RootStyleSheetTarget.roots.values()) { - PropertyTargetManager.getOrCreate( - RootStyleSheetTarget.normalizeRoot(target) - ).removeProperty(name); + PropertyTargetManager.getOrCreate(target).removeProperty(name); } } - public static registerRoot(root: HTMLElement | Document) { + public static registerRoot(root: FASTElement | Document) { const { roots } = RootStyleSheetTarget; if (!roots.has(root)) { roots.add(root); - const target = PropertyTargetManager.getOrCreate(this.normalizeRoot(root)); + const target = PropertyTargetManager.getOrCreate(root); for (const key in RootStyleSheetTarget.properties) { target.setProperty(key, RootStyleSheetTarget.properties[key]); } } } - public static unregisterRoot(root: HTMLElement | Document) { + public static unregisterRoot(root: FASTElement | Document) { const { roots } = RootStyleSheetTarget; if (roots.has(root)) { roots.delete(root); - const target = PropertyTargetManager.getOrCreate( - RootStyleSheetTarget.normalizeRoot(root) - ); + const target = PropertyTargetManager.getOrCreate(root); for (const key in RootStyleSheetTarget.properties) { target.removeProperty(key); } } } - - /** - * Returns the document when provided the default element, - * otherwise is a no-op - * @param root - the root to normalize - */ - private static normalizeRoot(root: HTMLElement | Document) { - return root === defaultElement ? document : root; - } } // Caches PropertyTarget instances const propertyTargetCache: WeakMap< - HTMLElement | Document, + FASTElement | Document, PropertyTarget > = new WeakMap(); // Use Constructable StyleSheets for FAST elements when supported, otherwise use @@ -247,7 +208,7 @@ const propertyTargetCtor: Constructable = ElementStyles.supports * @internal */ export const PropertyTargetManager = Object.freeze({ - getOrCreate(source: HTMLElement | Document): PropertyTarget { + getOrCreate(source: FASTElement | Document): PropertyTarget { if (propertyTargetCache.has(source)) { /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ return propertyTargetCache.get(source)!; @@ -255,16 +216,12 @@ export const PropertyTargetManager = Object.freeze({ let target: PropertyTarget; - if (source === defaultElement) { - target = new RootStyleSheetTarget(); - } else if (source instanceof Document) { + if (source instanceof Document) { target = ElementStyles.supportsAdoptedStyleSheets ? new DocumentStyleSheetTarget() : new HeadStyleElementStyleSheetTarget(); - } else if (isFastElement(source as HTMLElement)) { - target = new propertyTargetCtor(source); } else { - target = new ElementStyleSheetTarget(source as HTMLElement); + target = new propertyTargetCtor(source); } propertyTargetCache.set(source, target); diff --git a/packages/web-components/fast-foundation/src/design-token/design-token.ts b/packages/web-components/fast-foundation/src/design-token/design-token.ts deleted file mode 100644 index e31acb994ab..00000000000 --- a/packages/web-components/fast-foundation/src/design-token/design-token.ts +++ /dev/null @@ -1,930 +0,0 @@ -import { - Behavior, - CSSDirective, - Disposable, - ExecutionContext, - Expression, - ExpressionObserver, - FASTElement, - observable, - Observable, - Subscriber, -} from "@microsoft/fast-element"; -import { composedContains, composedParent } from "@microsoft/fast-element/utilities"; -import { - PropertyTargetManager, - RootStyleSheetTarget, -} from "./custom-property-manager.js"; -import type { - DerivedDesignTokenValue, - DesignTokenConfiguration, - DesignTokenValue, - StaticDesignTokenValue, -} from "./interfaces.js"; -import { defaultElement } from "./custom-property-manager.js"; -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -/** - * Describes a DesignToken instance. - * @public - */ -export interface DesignToken< - T extends string | number | boolean | BigInteger | null | Array | symbol | {} -> { - /** - * The name of the token - */ - readonly name: string; - - /** - * A list of elements for which the DesignToken has a value set - */ - readonly appliedTo: HTMLElement[]; - - /** - * Get the token value for an element. - * @param element - The element to get the value for - * @returns - The value set for the element, or the value set for the nearest element ancestor. - */ - getValueFor(element: HTMLElement): StaticDesignTokenValue; - - /** - * Sets the token to a value for an element. - * @param element - The element to set the value for. - * @param value - The value. - */ - setValueFor(element: HTMLElement, value: DesignTokenValue | DesignToken): void; - - /** - * Removes a value set for an element. - * @param element - The element to remove the value from - */ - deleteValueFor(element: HTMLElement): this; - - /** - * Associates a default value to the token - */ - withDefault(value: DesignTokenValue | DesignToken): this; - - /** - * Subscribes a subscriber to change records for a token. If an element is provided, only - * change records for that element will be emitted. - */ - subscribe(subscriber: DesignTokenSubscriber, target?: HTMLElement): void; - - /** - * Unsubscribes a subscriber from change records for a token. - */ - unsubscribe(subscriber: DesignTokenSubscriber, target?: HTMLElement): void; -} - -/** - * A {@link (DesignToken:interface)} that emits a CSS custom property. - * @public - */ -export interface CSSDesignToken< - T extends - | string - | number - | boolean - | BigInteger - | null - | Array - | symbol - | ({ - createCSS?(): string; - } & Record) -> extends DesignToken, CSSDirective { - /** - * The {@link (DesignToken:interface)} formatted as a CSS custom property if the token is - * configured to write a CSS custom property. - */ - readonly cssCustomProperty: string; -} - -/** - * Change record provided to to a {@link DesignTokenSubscriber} when a token changes for a target. - * @public - */ -export interface DesignTokenChangeRecord> { - /** - * The element for which the value was changed - */ - target: HTMLElement; - - /** - * The token that was changed - */ - token: T; -} - -/** - * A subscriber that should receive {@link DesignTokenChangeRecord | change records} when a token changes for a target - * @public - */ -export interface DesignTokenSubscriber> { - handleChange(record: DesignTokenChangeRecord): void; -} - -/** - * Implementation of {@link (DesignToken:interface)} - */ -class DesignTokenImpl - implements CSSDirective, DesignToken { - public readonly name: string; - public readonly cssCustomProperty: string | undefined; - public readonly id: string; - private cssVar: string | undefined; - private subscribers = new WeakMap< - HTMLElement | this, - Set> - >(); - private _appliedTo = new Set(); - public get appliedTo() { - return [...this._appliedTo]; - } - - public static from( - nameOrConfig: string | DesignTokenConfiguration - ): DesignTokenImpl { - return new DesignTokenImpl({ - name: typeof nameOrConfig === "string" ? nameOrConfig : nameOrConfig.name, - cssCustomPropertyName: - typeof nameOrConfig === "string" - ? nameOrConfig - : nameOrConfig.cssCustomPropertyName === void 0 - ? nameOrConfig.name - : nameOrConfig.cssCustomPropertyName, - }); - } - - public static isCSSDesignToken( - token: DesignToken | CSSDesignToken - ): token is CSSDesignToken { - return typeof (token as CSSDesignToken).cssCustomProperty === "string"; - } - - public static isDerivedDesignTokenValue( - value: any - ): value is DerivedDesignTokenValue { - return typeof value === "function"; - } - - public static uniqueId: () => string = (() => { - let id = 0; - return () => { - id++; - return id.toString(16); - }; - })(); - - /** - * Gets a token by ID. Returns undefined if the token was not found. - * @param id - The ID of the token - * @returns - */ - public static getTokenById(id: string): DesignTokenImpl | undefined { - return DesignTokenImpl.tokensById.get(id); - } - - /** - * Token storage by token ID - */ - private static tokensById = new Map>(); - - private getOrCreateSubscriberSet( - target: HTMLElement | this = this - ): Set> { - return ( - this.subscribers.get(target) || - (this.subscribers.set(target, new Set()) && this.subscribers.get(target)!) - ); - } - - constructor(configuration: Required) { - this.name = configuration.name; - - if (configuration.cssCustomPropertyName !== null) { - this.cssCustomProperty = `--${configuration.cssCustomPropertyName}`; - this.cssVar = `var(${this.cssCustomProperty})`; - } - - this.id = DesignTokenImpl.uniqueId(); - DesignTokenImpl.tokensById.set(this.id, this); - } - - public createCSS(): string { - return this.cssVar || ""; - } - - public getValueFor(element: HTMLElement): StaticDesignTokenValue { - const value = DesignTokenNode.getOrCreate(element).get(this); - - if (value !== undefined) { - return value; - } - - throw new Error( - `Value could not be retrieved for token named "${this.name}". Ensure the value is set for ${element} or an ancestor of ${element}.` - ); - } - - public setValueFor( - element: HTMLElement, - value: DesignTokenValue | DesignToken - ): this { - this._appliedTo.add(element); - if (value instanceof DesignTokenImpl) { - value = this.alias(value); - } - - DesignTokenNode.getOrCreate(element).set(this, value as DesignTokenValue); - return this; - } - - public deleteValueFor(element: HTMLElement): this { - this._appliedTo.delete(element); - - if (DesignTokenNode.existsFor(element)) { - DesignTokenNode.getOrCreate(element).delete(this); - } - - return this; - } - - public withDefault(value: DesignTokenValue | DesignToken) { - this.setValueFor(defaultElement, value); - return this; - } - - public subscribe( - subscriber: DesignTokenSubscriber, - target?: HTMLElement - ): void { - const subscriberSet = this.getOrCreateSubscriberSet(target); - - if (target && !DesignTokenNode.existsFor(target)) { - DesignTokenNode.getOrCreate(target); - } - - if (!subscriberSet.has(subscriber)) { - subscriberSet.add(subscriber); - } - } - - public unsubscribe( - subscriber: DesignTokenSubscriber, - target?: HTMLElement - ): void { - const list = this.subscribers.get(target || this); - - if (list && list.has(subscriber)) { - list.delete(subscriber); - } - } - - /** - * Notifies subscribers that the value for an element has changed. - * @param element - The element to emit a notification for - */ - public notify(element: HTMLElement) { - const record = Object.freeze({ token: this, target: element }); - - if (this.subscribers.has(this)) { - this.subscribers.get(this)!.forEach(sub => sub.handleChange(record)); - } - - if (this.subscribers.has(element)) { - this.subscribers.get(element)!.forEach(sub => sub.handleChange(record)); - } - } - - /** - * Alias the token to the provided token. - * @param token - the token to alias to - */ - private alias(token: DesignToken): DerivedDesignTokenValue { - return (((target: HTMLElement) => - token.getValueFor(target)) as unknown) as DerivedDesignTokenValue; - } -} - -class CustomPropertyReflector { - public startReflection(token: CSSDesignToken, target: HTMLElement) { - token.subscribe(this, target); - this.handleChange({ token, target }); - } - - public stopReflection(token: CSSDesignToken, target: HTMLElement) { - token.unsubscribe(this, target); - this.remove(token, target); - } - - public handleChange(record: DesignTokenChangeRecord) { - const { token, target } = record; - this.add(token, target); - } - - private add(token: CSSDesignToken, target: HTMLElement) { - PropertyTargetManager.getOrCreate(target).setProperty( - token.cssCustomProperty, - this.resolveCSSValue( - DesignTokenNode.getOrCreate(target).get(token as DesignTokenImpl) - ) - ); - } - - private remove(token: CSSDesignToken, target: HTMLElement) { - PropertyTargetManager.getOrCreate(target).removeProperty(token.cssCustomProperty); - } - - private resolveCSSValue(value: any) { - return value && typeof value.createCSS === "function" ? value.createCSS() : value; - } -} - -/** - * A light wrapper around BindingObserver to handle value caching and - * token notification - */ -class DesignTokenBindingObserver implements Disposable { - public readonly dependencies = new Set>(); - private observer: ExpressionObserver>; - constructor( - public readonly source: Expression>, - public readonly token: DesignTokenImpl, - public readonly node: DesignTokenNode - ) { - this.observer = Observable.binding(source, this, false); - - // This is a little bit hacky because it's using internal APIs of BindingObserverImpl. - // BindingObserverImpl queues updates to batch it's notifications which doesn't work for this - // scenario because the DesignToken.getValueFor API is not async. Without this, using DesignToken.getValueFor() - // after DesignToken.setValueFor() when setting a dependency of the value being retrieved can return a stale - // value. Assigning .handleChange to .call forces immediate invocation of this classes handleChange() method, - // allowing resolution of values synchronously. - // TODO: https://github.com/microsoft/fast/issues/5110 - (this.observer as any).handleChange = (this.observer as any).call; - this.handleChange(); - } - - public dispose(): void { - this.observer.dispose(); - } - - /** - * @internal - */ - public handleChange() { - this.node.store.set( - this.token, - - (this.observer.observe( - this.node.target, - ExecutionContext.default - ) as unknown) as StaticDesignTokenValue - ); - } -} - -/** - * Stores resolved token/value pairs and notifies on changes - */ -class Store { - private values = new Map, any>(); - set(token: DesignTokenImpl, value: StaticDesignTokenValue) { - if (this.values.get(token) !== value) { - this.values.set(token, value); - Observable.getNotifier(this).notify(token.id); - } - } - - get(token: DesignTokenImpl): StaticDesignTokenValue | undefined { - Observable.track(this, token.id); - return this.values.get(token); - } - - delete(token: DesignTokenImpl) { - this.values.delete(token); - } - - all() { - return this.values.entries(); - } -} - -const nodeCache = new WeakMap(); -const childToParent = new WeakMap(); - -/** - * A node responsible for setting and getting token values, - * emitting values to CSS custom properties, and maintaining - * inheritance structures. - */ -class DesignTokenNode implements Behavior, Subscriber { - /** - * Returns a DesignTokenNode for an element. - * Creates a new instance if one does not already exist for a node, - * otherwise returns the cached instance - * - * @param target - The HTML element to retrieve a DesignTokenNode for - */ - public static getOrCreate(target: HTMLElement): DesignTokenNode { - return nodeCache.get(target) || new DesignTokenNode(target); - } - - /** - * Determines if a DesignTokenNode has been created for a target - * @param target - The element to test - */ - public static existsFor(target: HTMLElement): boolean { - return nodeCache.has(target); - } - - /** - * Searches for and return the nearest parent DesignTokenNode. - * Null is returned if no node is found or the node provided is for a default element. - */ - public static findParent(node: DesignTokenNode): DesignTokenNode | null { - if (!(defaultElement === node.target)) { - let parent = composedParent(node.target); - - while (parent !== null) { - if (nodeCache.has(parent)) { - return nodeCache.get(parent)!; - } - - parent = composedParent(parent); - } - - return DesignTokenNode.getOrCreate(defaultElement); - } - - return null; - } - - /** - * Finds the closest node with a value explicitly assigned for a token, otherwise null. - * @param token - The token to look for - * @param start - The node to start looking for value assignment - * @returns - */ - public static findClosestAssignedNode( - token: DesignTokenImpl, - start: DesignTokenNode - ): DesignTokenNode | null { - let current: DesignTokenNode | null = start; - do { - if (current.has(token)) { - return current; - } - - current = current.parent - ? current.parent - : current.target !== defaultElement - ? DesignTokenNode.getOrCreate(defaultElement) - : null; - } while (current !== null); - - return null; - } - - /** - * Responsible for reflecting tokens to CSS custom properties - */ - public static cssCustomPropertyReflector = new CustomPropertyReflector(); - - /** - * Stores all resolved token values for a node - */ - public readonly store: Store = new Store(); - - /** - * The parent DesignTokenNode, or null. - */ - public get parent(): DesignTokenNode | null { - return childToParent.get(this) || null; - } - - /** - * All children assigned to the node - */ - @observable - private children: Array = []; - - /** - * All values explicitly assigned to the node in their raw form - */ - private assignedValues: Map, DesignTokenValue> = new Map(); - - /** - * Tokens currently being reflected to CSS custom properties - */ - private reflecting = new Set>(); - - /** - * Binding observers for assigned and inherited derived values. - */ - private bindingObservers = new Map< - DesignTokenImpl, - DesignTokenBindingObserver - >(); - - /** - * Emits notifications to token when token values - * change the DesignTokenNode - */ - private tokenValueChangeHandler: Subscriber = { - handleChange: (source: Store, arg: string) => { - const token = DesignTokenImpl.getTokenById(arg); - - if (token) { - // Notify any token subscribers - token.notify(this.target); - - if (DesignTokenImpl.isCSSDesignToken(token)) { - const parent = this.parent; - const reflecting = this.isReflecting(token); - - if (parent) { - const parentValue = parent.get(token); - const sourceValue = source.get(token); - if (parentValue !== sourceValue && !reflecting) { - this.reflectToCSS(token); - } else if (parentValue === sourceValue && reflecting) { - this.stopReflectToCSS(token); - } - } else if (!reflecting) { - this.reflectToCSS(token); - } - } - } - }, - }; - - constructor(public readonly target: HTMLElement | (HTMLElement & FASTElement)) { - nodeCache.set(target, this); - - // Map store change notifications to token change notifications - Observable.getNotifier(this.store).subscribe(this.tokenValueChangeHandler); - - if (target instanceof FASTElement) { - (target as FASTElement).$fastController.addBehaviors([this]); - } else if (target.isConnected) { - this.bind(); - } - } - - /** - * Checks if a token has been assigned an explicit value the node. - * @param token - the token to check. - */ - public has(token: DesignTokenImpl): boolean { - return this.assignedValues.has(token); - } - - /** - * Gets the value of a token for a node - * @param token - The token to retrieve the value for - * @returns - */ - public get(token: DesignTokenImpl): StaticDesignTokenValue | undefined { - const value = this.store.get(token); - - if (value !== undefined) { - return value; - } - - const raw = this.getRaw(token); - - if (raw !== undefined) { - this.hydrate(token, raw); - return this.get(token); - } - } - - /** - * Retrieves the raw assigned value of a token from the nearest assigned node. - * @param token - The token to retrieve a raw value for - * @returns - */ - public getRaw(token: DesignTokenImpl): DesignTokenValue | undefined { - if (this.assignedValues.has(token)) { - return this.assignedValues.get(token); - } - - return DesignTokenNode.findClosestAssignedNode(token, this)?.getRaw(token); - } - - /** - * Sets a token to a value for a node - * @param token - The token to set - * @param value - The value to set the token to - */ - public set(token: DesignTokenImpl, value: DesignTokenValue): void { - if (DesignTokenImpl.isDerivedDesignTokenValue(this.assignedValues.get(token))) { - this.tearDownBindingObserver(token); - } - - this.assignedValues.set(token, value); - - if (DesignTokenImpl.isDerivedDesignTokenValue(value)) { - this.setupBindingObserver(token, value); - } else { - this.store.set(token, value); - } - } - - /** - * Deletes a token value for the node. - * @param token - The token to delete the value for - */ - public delete(token: DesignTokenImpl): void { - this.assignedValues.delete(token); - this.tearDownBindingObserver(token); - const upstream = this.getRaw(token); - - if (upstream) { - this.hydrate(token, upstream); - } else { - this.store.delete(token); - } - } - - /** - * Invoked when the DesignTokenNode.target is attached to the document - */ - public bind(): void { - const parent = DesignTokenNode.findParent(this); - - if (parent) { - parent.appendChild(this); - } - - for (const key of this.assignedValues.keys()) { - key.notify(this.target); - } - } - - /** - * Invoked when the DesignTokenNode.target is detached from the document - */ - public unbind(): void { - if (this.parent) { - const parent = childToParent.get(this)!; - parent.removeChild(this); - } - } - - /** - * Appends a child to a parent DesignTokenNode. - * @param child - The child to append to the node - */ - public appendChild(child: DesignTokenNode): void { - if (child.parent) { - childToParent.get(child)!.removeChild(child); - } - const reParent = this.children.filter(x => child.contains(x)); - - childToParent.set(child, this); - this.children.push(child); - - reParent.forEach(x => child.appendChild(x)); - - Observable.getNotifier(this.store).subscribe(child); - - // How can we not notify *every* subscriber? - for (const [token, value] of this.store.all()) { - child.hydrate( - token, - this.bindingObservers.has(token) ? this.getRaw(token) : value - ); - } - } - - /** - * Removes a child from a node. - * @param child - The child to remove. - */ - public removeChild(child: DesignTokenNode): boolean { - const childIndex = this.children.indexOf(child); - - if (childIndex !== -1) { - this.children.splice(childIndex, 1); - } - - Observable.getNotifier(this.store).unsubscribe(child); - return child.parent === this ? childToParent.delete(child) : false; - } - - /** - * Tests whether a provided node is contained by - * the calling node. - * @param test - The node to test - */ - public contains(test: DesignTokenNode): boolean { - return composedContains(this.target, test.target); - } - - /** - * Instructs the node to reflect a design token for the provided token. - * @param token - The design token to reflect - */ - public reflectToCSS(token: CSSDesignToken) { - if (!this.isReflecting(token)) { - this.reflecting.add(token); - DesignTokenNode.cssCustomPropertyReflector.startReflection( - token, - this.target - ); - } - } - - /** - * Stops reflecting a DesignToken to CSS - * @param token - The design token to stop reflecting - */ - public stopReflectToCSS(token: CSSDesignToken) { - if (this.isReflecting(token)) { - this.reflecting.delete(token); - - DesignTokenNode.cssCustomPropertyReflector.stopReflection(token, this.target); - } - } - - /** - * Determines if a token is being reflected to CSS for a node. - * @param token - The token to check for reflection - * @returns - */ - public isReflecting(token: CSSDesignToken): boolean { - return this.reflecting.has(token); - } - - /** - * Handle changes to upstream tokens - * @param source - The parent DesignTokenNode - * @param property - The token ID that changed - */ - public handleChange(source: Store, property: string) { - const token = DesignTokenImpl.getTokenById(property); - - if (!token) { - return; - } - - this.hydrate(token, this.getRaw(token)); - } - - /** - * Hydrates a token with a DesignTokenValue, making retrieval available. - * @param token - The token to hydrate - * @param value - The value to hydrate - */ - public hydrate(token: DesignTokenImpl, value: DesignTokenValue) { - if (!this.has(token)) { - const observer = this.bindingObservers.get(token); - - if (DesignTokenImpl.isDerivedDesignTokenValue(value)) { - if (observer) { - // If the binding source doesn't match, we need - // to update the binding - if ((observer.source as any) !== value) { - this.tearDownBindingObserver(token); - this.setupBindingObserver(token, value); - } - } else { - this.setupBindingObserver(token, value); - } - } else { - if (observer) { - this.tearDownBindingObserver(token); - } - - this.store.set(token, value as StaticDesignTokenValue); - } - } - } - - /** - * Sets up a binding observer for a derived token value that notifies token - * subscribers on change. - * - * @param token - The token to notify when the binding updates - * @param source - The binding source - */ - private setupBindingObserver( - token: DesignTokenImpl, - source: DerivedDesignTokenValue - ): DesignTokenBindingObserver { - const binding = new DesignTokenBindingObserver(source as any, token, this); - - this.bindingObservers.set(token, binding); - return binding; - } - - /** - * Tear down a binding observer for a token. - */ - private tearDownBindingObserver(token: DesignTokenImpl): boolean { - if (this.bindingObservers.has(token)) { - this.bindingObservers.get(token)!.dispose(); - this.bindingObservers.delete(token); - return true; - } - - return false; - } -} -/* eslint-disable @typescript-eslint/no-unused-vars */ -function create( - nameOrConfig: string | DesignTokenConfiguration -): never; -function create( - nameOrConfig: string | DesignTokenConfiguration -): never; -function create(nameOrConfig: string): CSSDesignToken; -function create( - nameOrConfig: - | Omit - | (DesignTokenConfiguration & Record<"cssCustomPropertyName", string>) -): CSSDesignToken; -function create( - nameOrConfig: DesignTokenConfiguration & Record<"cssCustomPropertyName", null> -): DesignToken; -function create(nameOrConfig: string | DesignTokenConfiguration): any { - return DesignTokenImpl.from(nameOrConfig); -} -/* eslint-enable @typescript-eslint/no-unused-vars */ -/** - * Factory object for creating {@link (DesignToken:interface)} instances. - * @public - */ -export const DesignToken = Object.freeze({ - create, - - /** - * Informs DesignToken that an HTMLElement for which tokens have - * been set has been connected to the document. - * - * The browser does not provide a reliable mechanism to observe an HTMLElement's connectedness - * in all scenarios, so invoking this method manually is necessary when: - * - * 1. Token values are set for an HTMLElement. - * 2. The HTMLElement does not inherit from FASTElement. - * 3. The HTMLElement is not connected to the document when token values are set. - * - * @param element - The element to notify - * @returns - true if notification was successful, otherwise false. - */ - notifyConnection(element: HTMLElement): boolean { - if (!element.isConnected || !DesignTokenNode.existsFor(element)) { - return false; - } - - DesignTokenNode.getOrCreate(element).bind(); - - return true; - }, - - /** - * Informs DesignToken that an HTMLElement for which tokens have - * been set has been disconnected to the document. - * - * The browser does not provide a reliable mechanism to observe an HTMLElement's connectedness - * in all scenarios, so invoking this method manually is necessary when: - * - * 1. Token values are set for an HTMLElement. - * 2. The HTMLElement does not inherit from FASTElement. - * - * @param element - The element to notify - * @returns - true if notification was successful, otherwise false. - */ - notifyDisconnection(element: HTMLElement): boolean { - if (element.isConnected || !DesignTokenNode.existsFor(element)) { - return false; - } - - DesignTokenNode.getOrCreate(element).unbind(); - return true; - }, - - /** - * Registers and element or document as a DesignToken root. - * {@link CSSDesignToken | CSSDesignTokens} with default values assigned via - * {@link (DesignToken:interface).withDefault} will emit CSS custom properties to all - * registered roots. - * @param target - The root to register - */ - registerRoot(target: HTMLElement | Document = defaultElement) { - RootStyleSheetTarget.registerRoot(target); - }, - - /** - * Unregister an element or document as a DesignToken root. - * @param target - The root to deregister - */ - unregisterRoot(target: HTMLElement | Document = defaultElement) { - RootStyleSheetTarget.unregisterRoot(target); - }, -}); -/* eslint-enable @typescript-eslint/no-non-null-assertion */ diff --git a/packages/web-components/fast-foundation/src/design-token/exports.ts b/packages/web-components/fast-foundation/src/design-token/exports.ts new file mode 100644 index 00000000000..dc075873a42 --- /dev/null +++ b/packages/web-components/fast-foundation/src/design-token/exports.ts @@ -0,0 +1,14 @@ +export { + DesignToken, + CSSDesignToken, + DesignTokenConfiguration, + CSSDesignTokenConfiguration, + DesignTokenChangeRecord, + DesignTokenSubscriber, +} from "./fast-design-token.js"; +export type { + StaticDesignTokenValue, + DerivedDesignTokenValue, + DesignTokenResolver, + DesignTokenMutationType, +} from "./core/exports.js"; diff --git a/packages/web-components/fast-foundation/src/design-token/design-token.spec.ts b/packages/web-components/fast-foundation/src/design-token/fast-design-token.spec.ts similarity index 79% rename from packages/web-components/fast-foundation/src/design-token/design-token.spec.ts rename to packages/web-components/fast-foundation/src/design-token/fast-design-token.spec.ts index 1e10097a113..635d5068168 100644 --- a/packages/web-components/fast-foundation/src/design-token/design-token.spec.ts +++ b/packages/web-components/fast-foundation/src/design-token/fast-design-token.spec.ts @@ -1,19 +1,28 @@ -import { css, FASTElement, html, Observable, Updates } from "@microsoft/fast-element"; +import { css, customElement, FASTElement, html, Observable, Updates } from "@microsoft/fast-element"; import chia, { expect } from "chai"; -import { uniqueElementName } from "@microsoft/fast-element/testing"; -import { CSSDesignToken, DesignToken, DesignTokenChangeRecord, DesignTokenSubscriber } from "./design-token.js"; import spies from "chai-spies"; +import { uniqueElementName } from "@microsoft/fast-element/testing"; +import type { DesignTokenResolver } from "./core/design-token-node.js"; +import { CSSDesignToken, DesignToken, DesignTokenSubscriber } from "./fast-design-token.js"; chia.use(spies); -const elementName = uniqueElementName("token-test"); +const elementName = uniqueElementName(); -FASTElement.define(class extends FASTElement { }, { - name: elementName, +function uniqueTokenName() { + return uniqueElementName() + "token"; +} + +@customElement({ + name: `fast-${elementName}`, template: html`` -}); +}) +class MyElement extends FASTElement {} +function createElement(): FASTElement & HTMLElement { + return document.createElement(`fast-${elementName}`) as any; +} function addElement(parent = document.body): FASTElement & HTMLElement { - const el = document.createElement(elementName) as any; + const el = createElement(); parent.appendChild(el); return el; } @@ -43,12 +52,23 @@ describe("A DesignToken", () => { class Foo { } const _class: DesignToken = DesignToken.create("class"); const sym: DesignToken = DesignToken.create("symbol") - }) + }); + + describe("should have a create method", () => { + it("that creates a CSSDesignToken when invoked with a string value", () => { + expect(DesignToken.create("name") instanceof CSSDesignToken).to.be.true; + }); + it("that creates a CSSDesignToken when invoked with a CSSDesignTokenConfiguration", () => { + expect(DesignToken.create({name: "name", cssCustomPropertyName: "css"}) instanceof CSSDesignToken).to.be.true; + }); + it("that creates a DesignToken when invoked with a DesignTokenConfiguration", () => { + expect(DesignToken.create({name: "name"}) instanceof CSSDesignToken).to.be.false; + }); + }); describe("that is a CSSDesignToken", () => { it("should have a createCSS() method that returns a string with the name property formatted as a CSS variable", () => { - const add = () => void 0; - expect(DesignToken.create("implicit").createCSS(add)).to.equal("var(--implicit)"); + expect(DesignToken.create("implicit").createCSS()).to.equal("var(--implicit)"); }); it("should have a readonly cssCustomProperty property that is the name formatted as a CSS custom property", () => { expect(DesignToken.create("implicit").cssCustomProperty).to.equal("--implicit"); @@ -60,17 +80,17 @@ describe("A DesignToken", () => { describe("that is not a CSSDesignToken", () => { it("should not have a cssCustomProperty property", () => { - expect("cssCustomProperty" in DesignToken.create({name: "test", cssCustomPropertyName: null})).to.equal(false); + expect("cssCustomProperty" in DesignToken.create({name: uniqueTokenName()})).to.equal(false); }); it("should not have a cssVar property", () => { - expect("cssVar" in DesignToken.create({name: "test", cssCustomPropertyName: null})).to.equal(false); + expect("cssVar" in DesignToken.create({name: uniqueTokenName()})).to.equal(false); }); }); describe("getting and setting a simple value", () => { it("should throw if the token value has never been set on the element or it's any ancestors", () => { const target = addElement(); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); expect(() => token.getValueFor(target)).to.throw(); removeElement(target); @@ -78,7 +98,7 @@ describe("A DesignToken", () => { it("should return the value set for the element if one has been set", () => { const target = addElement(); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.setValueFor(target, 12); expect(token.getValueFor(target)).to.equal(12); @@ -88,7 +108,7 @@ describe("A DesignToken", () => { it("should return the value set for an ancestor if a value has not been set for the target", () => { const ancestor = addElement(); const target = addElement(ancestor); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.setValueFor(ancestor, 12); expect(token.getValueFor(target)).to.equal(12); @@ -100,7 +120,7 @@ describe("A DesignToken", () => { const parent = addElement(grandparent); const target = addElement(parent); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.setValueFor(grandparent, 12); @@ -118,7 +138,7 @@ describe("A DesignToken", () => { const parentA = addElement(); const parentB = addElement(); const target = addElement(parentA); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.setValueFor(parentA, 12); token.setValueFor(parentB, 14); @@ -136,7 +156,7 @@ describe("A DesignToken", () => { const target = addElement(); [false, null, 0, "", NaN].forEach(value => { - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.setValueFor(target, value); if (typeof value === "number" && isNaN(value)) { @@ -152,17 +172,27 @@ describe("A DesignToken", () => { describe("that is a CSSDesignToken", () => { it("should set the CSS custom property for the element", async () => { const target = addElement(); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.setValueFor(target, 12); await Updates.next(); expect(window.getComputedStyle(target).getPropertyValue(token.cssCustomProperty)).to.equal('12'); removeElement(target) }); + it("should be a CSSDirective", async () => { + const token = DesignToken.create(uniqueTokenName()).withDefault(12); + const sheet = css`:host{--property: ${token};}`; + const element = addElement(); + element.$fastController.addStyles(sheet) + + await Updates.next(); + expect(window.getComputedStyle(element).getPropertyValue("--property").trim()).to.equal('12'); + removeElement(element); + }); }); describe("that is not a CSSDesignToken", () => { it("should not set a CSS custom property for the element", () => { const target = addElement(); - const token = DesignToken.create({ name: "test", cssCustomPropertyName: null }); + const token = DesignToken.create({ name: uniqueTokenName()}); token.setValueFor(target, 12); expect(window.getComputedStyle(target).getPropertyValue(("--test"))).to.equal(''); removeElement(target) @@ -172,7 +202,7 @@ describe("A DesignToken", () => { describe("getting and setting derived values", () => { it("should get the return value of a derived value", () => { const target = addElement(); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.setValueFor(target, () => 12); expect(token.getValueFor(target)).to.equal(12); @@ -180,7 +210,7 @@ describe("A DesignToken", () => { }); it("should get an updated value when observable properties used in a derived property are changed", async () => { const target = addElement(); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); const dependencies: { value: number } = {} as { value: number } Observable.defineProperty(dependencies, "value"); dependencies.value = 6 @@ -201,7 +231,7 @@ describe("A DesignToken", () => { const tokenB = DesignToken.create("B"); tokenA.setValueFor(target, 6); - tokenB.setValueFor(target, (target) => tokenA.getValueFor(target) * 2); + tokenB.setValueFor(target, (resolve) => resolve( tokenA ) * 2); expect(tokenB.getValueFor(target)).to.equal(12); @@ -220,7 +250,7 @@ describe("A DesignToken", () => { tokenA.setValueFor(ancestor, 7); tokenA.setValueFor(parent, 6); - tokenB.setValueFor(ancestor, (target) => tokenA.getValueFor(target) * 2); + tokenB.setValueFor(ancestor, (resolve) => resolve( tokenA ) * 2); const value = tokenB.getValueFor(target); expect(value).to.equal(12); @@ -236,7 +266,7 @@ describe("A DesignToken", () => { tokenA.setValueFor(ancestor, 7); tokenA.setValueFor(parent, 6); - tokenB.setValueFor(ancestor, (target ) => tokenA.getValueFor(target) * 2); + tokenB.setValueFor(ancestor, (resolve) => resolve( tokenA ) * 2); expect(tokenB.getValueFor(target)).to.equal(12); @@ -255,7 +285,7 @@ describe("A DesignToken", () => { const tokenB = DesignToken.create("B"); tokenA.setValueFor(ancestor, 6); - tokenB.setValueFor(ancestor, (target) => tokenA.getValueFor(target) * 2); + tokenB.setValueFor(ancestor, (resolve) => resolve( tokenA ) * 2); expect(tokenB.getValueFor(target)).to.equal(12); @@ -268,7 +298,7 @@ describe("A DesignToken", () => { describe("that is a CSSDesignToken", () => { it("should set a CSS custom property equal to the resolved value of a derived token value", async () => { const target = addElement(); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.setValueFor(target, (target) => 12); @@ -283,7 +313,7 @@ describe("A DesignToken", () => { const tokenB = DesignToken.create("B"); tokenA.setValueFor(target, 6); - tokenB.setValueFor(target, (target) => tokenA.getValueFor(target) * 2); + tokenB.setValueFor(target, (resolve) => resolve( tokenA ) * 2); await Updates.next(); @@ -297,7 +327,7 @@ describe("A DesignToken", () => { const tokenB = DesignToken.create("B"); tokenA.setValueFor(target, 6); - tokenB.setValueFor(target, (target) => tokenA.getValueFor(target) * 2); + tokenB.setValueFor(target, (resolve) => resolve( tokenA ) * 2); await Updates.next(); expect(window.getComputedStyle(target).getPropertyValue(tokenB.cssCustomProperty)).to.equal('12'); @@ -316,7 +346,7 @@ describe("A DesignToken", () => { const tokenB = DesignToken.create("B"); tokenA.setValueFor(parent, 6); - tokenB.setValueFor(parent, (target) => tokenA.getValueFor(target) * 2); + tokenB.setValueFor(parent, (resolve) => resolve( tokenA ) * 2); tokenA.setValueFor(target, 7); await Updates.next(); @@ -329,13 +359,13 @@ describe("A DesignToken", () => { it("should set a CSS custom property equal to the resolved value for an element in a shadow DOM of a derived token value with a dependent token", async () => { const parent = addElement(); const child = addElement(parent); - const target = document.createElement("div"); + const target = createElement() child.shadowRoot!.appendChild(target); const tokenA = DesignToken.create("A"); const tokenB = DesignToken.create("B"); tokenA.setValueFor(parent, 6); - tokenB.setValueFor(parent, (target) => tokenA.getValueFor(target) * 2); + tokenB.setValueFor(parent, (resolve) => resolve( tokenA ) * 2); tokenA.setValueFor(target, 7); await Updates.next(); @@ -353,7 +383,7 @@ describe("A DesignToken", () => { tokenA.setValueFor(parent, 6); tokenA.setValueFor(target, 7); - tokenB.setValueFor(parent, (target) => tokenA.getValueFor(target) * 2); + tokenB.setValueFor(parent, (resolve) => resolve( tokenA ) * 2); await Updates.next(); @@ -363,7 +393,7 @@ describe("A DesignToken", () => { }); it("should revert a CSS custom property back to a previous value when the Design Token value is reverted", async () => { - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); const target = addElement(); token.setValueFor(target, 12); @@ -383,7 +413,7 @@ describe("A DesignToken", () => { describe("that is not a CSSDesignToken", () => { it("should not emit a CSS custom property", () => { const target = addElement(); - const token = DesignToken.create({name: "test", cssCustomPropertyName: null}); + const token = DesignToken.create({name: uniqueTokenName()}); token.setValueFor(target, (target) => 12); @@ -397,7 +427,7 @@ describe("A DesignToken", () => { const target = addElement(); [false, null, 0, "", NaN].forEach(value => { - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.setValueFor(target, () => value as any); if (typeof value === "number" && isNaN(value)) { @@ -446,7 +476,7 @@ describe("A DesignToken", () => { const target = addElement(); tokenA.setValueFor(target, 6); - tokenB.setValueFor(target, (target) => tokenA.getValueFor(target) * 2); + tokenB.setValueFor(target, (resolve) => resolve( tokenA ) * 2); tokenC.setValueFor(target, tokenB); expect(tokenC.getValueFor(target)).to.equal(12); @@ -459,7 +489,7 @@ describe("A DesignToken", () => { }); describe("that is a CSSDesignToken", () => { - it("should emit a CSS custom property", () => { + it("should emit a CSS custom property", async () => { const tokenA = DesignToken.create("token-a"); const tokenB = DesignToken.create("token-b"); const target = addElement(); @@ -467,6 +497,7 @@ describe("A DesignToken", () => { tokenA.setValueFor(target, 12); tokenB.setValueFor(target, tokenA); + await Updates.next(); expect(window.getComputedStyle(target).getPropertyValue(tokenB.cssCustomProperty)).to.equal("12"); removeElement(target); @@ -496,7 +527,7 @@ describe("A DesignToken", () => { const target = addElement(); tokenA.setValueFor(target, 6); - tokenB.setValueFor(target, (target) => tokenA.getValueFor(target) * 2); + tokenB.setValueFor(target, (resolve) => resolve( tokenA ) * 2); tokenC.setValueFor(target, tokenB); await Updates.next(); @@ -510,13 +541,12 @@ describe("A DesignToken", () => { removeElement(target); }); - it("should support accessing the token for being assigned from the derived value", () => { + it("should support accessing the token being assigned from the derived value, resolving to a parent value", () => { const tokenA = DesignToken.create("token-a"); const parent = addElement(); const child = addElement(parent); - tokenA.withDefault(6); - const recipe = (el: HTMLElement) => tokenA.getValueFor(el.parentElement!) * 2; - tokenA.setValueFor(parent, recipe); + const recipe = (resolve: DesignTokenResolver) => resolve( tokenA ) * 2; + tokenA.setValueFor(parent, 12); tokenA.setValueFor(child, recipe); expect(tokenA.getValueFor(parent)).to.equal(12); @@ -532,8 +562,8 @@ describe("A DesignToken", () => { const child = addElement(parent); tokenA.setValueFor(grandparent, 3); - tokenB.setValueFor(grandparent, (el: HTMLElement) => tokenA.getValueFor(el) * 2); - tokenC.setValueFor(grandparent, (el) => tokenB.getValueFor(el) * 2) + tokenB.setValueFor(grandparent, (resolve) => resolve( tokenA ) * 2); + tokenC.setValueFor(grandparent, (resolve) => resolve( tokenB ) * 2); await Updates.next(); @@ -546,102 +576,11 @@ describe("A DesignToken", () => { expect(tokenC.getValueFor(child)).to.equal(16); expect(window.getComputedStyle(child).getPropertyValue(tokenC.cssCustomProperty)).to.equal("16"); }); - it("should update tokens when an element for which a token with static dependencies is set is appended to the DOM", async () => { - - - const tokenA = DesignToken.create("token-a"); - const tokenB = DesignToken.create("token-b"); - - tokenA.withDefault(6); - tokenB.withDefault(el => tokenA.getValueFor(el) * 2); - - const element = document.createElement(elementName); - - tokenA.setValueFor(element, 7); - - document.body.appendChild(element); - - await Updates.next(); - - expect(window.getComputedStyle(element).getPropertyValue(tokenB.cssCustomProperty)).to.equal('14'); - }); - it("should update tokens and notify when an element for which a token with dynamic dependencies is set is appended to the DOM", async () => { - const tokenA = DesignToken.create("token-a"); - const tokenB = DesignToken.create("token-b"); - - tokenA.withDefault(() => 6); - tokenB.withDefault(el => tokenA.getValueFor(el) * 2); - - const parent = document.createElement(elementName); - const child = document.createElement(elementName); - parent.appendChild(child); - - const handleChange = chia.spy(() => {}); - const subscriber = { handleChange }; - expect(tokenB.getValueFor(child)).to.equal(12); - - tokenB.subscribe(subscriber, child); - - document.body.appendChild(parent); - - await Updates.next(); - - expect(handleChange).not.to.have.been.called(); - - tokenA.setValueFor(parent, () => 7); - expect(tokenB.getValueFor(child)).to.equal(14); - await Updates.next(); - expect(handleChange).to.have.been.called.once; - }); - it("should notify a subscriber for a token after being appended to a parent with a different token value than the previous context", async () => { - const tokenA = DesignToken.create("token-a"); - const tokenB = DesignToken.create("token-b"); - - tokenA.withDefault(() => 6); - tokenB.withDefault(el => tokenA.getValueFor(el) * 2); - - const parent = document.createElement(elementName); - const child = document.createElement(elementName); - document.body.appendChild(parent); - tokenA.setValueFor(parent, () => 7); - - const handleChange = chia.spy(() => {}); - const subscriber = { handleChange }; - - expect(tokenB.getValueFor(child)).to.equal(12); - tokenB.subscribe(subscriber, child); - - expect(handleChange).not.to.have.been.called() - parent.appendChild(child); - - expect(tokenB.getValueFor(child)).to.equal(14); - expect(handleChange).to.have.been.called.once - }); - it("should notify a subscriber for a token after being appended to a parent with a different token value than the previous context", async () => { - const tokenA = DesignToken.create("token-a"); - tokenA.withDefault(6); - - const parent = document.createElement(elementName); - const child = document.createElement(elementName); - document.body.appendChild(parent); - tokenA.setValueFor(parent, 7); - - const handleChange = chia.spy(() => {}); - const subscriber = { handleChange }; - - expect(tokenA.getValueFor(child)).to.equal(6); - tokenA.subscribe(subscriber, child); - expect(handleChange).not.to.have.been.called() - parent.appendChild(child); - - expect(handleChange).to.have.been.called.once; - expect(tokenA.getValueFor(child)).to.equal(7); - }); - }) + }); describe("deleting simple values", () => { it("should throw when deleted and no parent token value is set", () => { const target = addElement(); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.setValueFor(target, 12); @@ -655,7 +594,7 @@ describe("A DesignToken", () => { it("should allow getting a value that was set upstream", () => { const parent = addElement() const target = addElement(parent); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.setValueFor(parent, 12); token.setValueFor(target, 14); @@ -671,7 +610,7 @@ describe("A DesignToken", () => { describe("deleting derived values", () => { it("should throw when deleted and no parent token value is set", () => { const target = addElement(); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.setValueFor(target, () => 12); @@ -685,7 +624,7 @@ describe("A DesignToken", () => { it("should allow getting a value that was set upstream", () => { const parent = addElement() const target = addElement(parent); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.setValueFor(parent, () => 12); token.setValueFor(target, () => 14); @@ -706,7 +645,7 @@ describe("A DesignToken", () => { tokenA.setValueFor(parent, 7); tokenA.setValueFor(target, 6); - tokenB.setValueFor(target, (element) => tokenA.getValueFor(element) * 2); + tokenB.setValueFor(target, (resolve) => resolve( tokenA ) * 2); expect(tokenB.getValueFor(target)).to.equal(12); @@ -720,7 +659,7 @@ describe("A DesignToken", () => { describe("when used as a CSSDirective", () => { it("should set a CSS custom property for the element when the token is set for the element", async () => { const target = addElement(); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.setValueFor(target, 12); const styles = css`:host{width: calc(${token} * 1px);}` target.$fastController.addStyles(styles); @@ -732,7 +671,7 @@ describe("A DesignToken", () => { it("should set a CSS custom property for the element when the token is set for an ancestor element", async () => { const parent = addElement() const target = addElement(parent); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.setValueFor(parent, 12); const styles = css`:host{width: calc(${token} * 1px);}` target.$fastController.addStyles(styles); @@ -746,7 +685,7 @@ describe("A DesignToken", () => { describe("with a default value set", () => { it("should return the default value if no value is set for a target", () => { const target = addElement(); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.withDefault(2) expect(token.getValueFor(target)).to.equal(2); @@ -755,7 +694,7 @@ describe("A DesignToken", () => { it("should return the default value for a descendent if no value is set for a target", () => { const parent = addElement() const target = addElement(parent); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.withDefault(2) expect(token.getValueFor(target)).to.equal(2); @@ -763,7 +702,7 @@ describe("A DesignToken", () => { }); it("should return the value set and not the default if value is set", () => { const target = addElement(); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.withDefault(4) token.setValueFor(target, 2) @@ -772,7 +711,7 @@ describe("A DesignToken", () => { }); it("should get a new default value if a new default is provided", () => { const target = addElement(); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); token.withDefault(2); token.withDefault(4); @@ -786,11 +725,11 @@ describe("A DesignToken", () => { const ancestor = addElement(); const parent = addElement(ancestor); const target = addElement(parent); - const token = DesignToken.create("test"); - const spy = new Map([[ancestor, false], [parent, false], [ target, false ]]); + const token = DesignToken.create(uniqueTokenName()); + const spy = new Map([[ancestor, false], [parent, false], [ target, false ]]); const subscriber: DesignTokenSubscriber = { - handleChange(record: DesignTokenChangeRecord) { + handleChange(token, record) { spy.set(record.target, true) } } @@ -803,32 +742,32 @@ describe("A DesignToken", () => { token.setValueFor(ancestor, 12); expect(spy.get(ancestor)).to.be.true; - expect(spy.get(parent)).to.be.false; - expect(spy.get(target)).to.be.false; + // expect(spy.get(parent)).to.be.false; + // expect(spy.get(target)).to.be.false; - token.setValueFor(parent, 14); - expect(spy.get(ancestor)).to.be.true; - expect(spy.get(parent)).to.be.true; - expect(spy.get(target)).to.be.false; + // token.setValueFor(parent, 14); + // expect(spy.get(ancestor)).to.be.true; + // expect(spy.get(parent)).to.be.true; + // expect(spy.get(target)).to.be.false; - token.setValueFor(target, 16); - expect(spy.get(target)).to.be.true; - expect(spy.get(parent)).to.be.true; - expect(spy.get(target)).to.be.true; + // token.setValueFor(target, 16); + // expect(spy.get(target)).to.be.true; + // expect(spy.get(parent)).to.be.true; + // expect(spy.get(target)).to.be.true; removeElement(ancestor); }); it("should notify a target-subscriber if the value is changed for the provided target", () => { const parent = addElement(); const target = addElement(parent); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); const handleChange = chia.spy(() => {}); const subscriber: DesignTokenSubscriber = { handleChange } - token.subscribe(subscriber, target); + token.subscribe(subscriber, /*target*/); token.setValueFor(parent, 12); expect(handleChange).to.have.been.called.once; @@ -842,10 +781,10 @@ describe("A DesignToken", () => { it("should not notify a subscriber after unsubscribing", () => { let invoked = false; const target = addElement(); - const token = DesignToken.create("test"); + const token = DesignToken.create(uniqueTokenName()); const subscriber: DesignTokenSubscriber = { - handleChange(record: DesignTokenChangeRecord) { + handleChange(token, record) { invoked = true; } } @@ -861,13 +800,13 @@ describe("A DesignToken", () => { it("should infer DesignToken and CSSDesignToken token types on subscription record", () => { type AssertCSSDesignToken = T extends CSSDesignToken ? T : never; - DesignToken.create("css").subscribe({handleChange(record) { + DesignToken.create("css").subscribe({handleChange(token, record) { const test: AssertCSSDesignToken = record.token; }}); type AssertDesignToken = T extends CSSDesignToken ? never : T; - DesignToken.create({name: "no-css", cssCustomPropertyName: null}).subscribe({handleChange(record) { + DesignToken.create({name: "no-css"}).subscribe({handleChange(token, record) { const test: AssertDesignToken = record.token; }}) }); @@ -877,7 +816,7 @@ describe("A DesignToken", () => { const tokenB = DesignToken.create("b"); tokenA.withDefault(6); - tokenB.withDefault((el) => tokenA.getValueFor(el) * 2); + tokenB.withDefault((resolve) => resolve( tokenA ) * 2); const handleChange = chia.spy(() => {}) const subscriber = { @@ -898,8 +837,8 @@ describe("A DesignToken", () => { const tokenC = DesignToken.create("c"); tokenA.withDefault(6); - tokenB.withDefault((el) => tokenA.getValueFor(el) * 2); - tokenC.withDefault((el) => tokenB.getValueFor(el) * 2); + tokenB.withDefault((resolve) => resolve( tokenA ) * 2); + tokenC.withDefault((resolve) => resolve( tokenB ) * 2); const handleChange = chia.spy(() => {}) const subscriber = { @@ -921,7 +860,7 @@ describe("A DesignToken", () => { const target = addElement(); tokenA.withDefault(6); - tokenB.withDefault((el) => tokenA.getValueFor(el) * 2); + tokenB.withDefault((resolve) => resolve( tokenA ) * 2); const handleChange = chia.spy(() => {}) const subscriber = { @@ -943,15 +882,14 @@ describe("A DesignToken", () => { const target = addElement(parent) tokenA.withDefault(6); - tokenB.withDefault((el) => tokenA.getValueFor(el) * 2); + tokenB.withDefault((resolve) => resolve( tokenA ) * 2); const handleChange = chia.spy(() => {}) const subscriber = { handleChange } - - tokenB.subscribe(subscriber, target); + tokenB.subscribe(subscriber, /*target*/); tokenA.setValueFor(parent, 7); await Updates.next(); @@ -966,7 +904,7 @@ describe("A DesignToken", () => { const target = addElement(parent) tokenA.withDefault(() => 6); - tokenB.withDefault((el) => tokenA.getValueFor(el) * 2); + tokenB.withDefault((resolve) => resolve( tokenA ) * 2); const handleChange = chia.spy(() => {}) const subscriber = { @@ -974,7 +912,7 @@ describe("A DesignToken", () => { } - tokenB.subscribe(subscriber, target); + tokenB.subscribe(subscriber, /*target*/); tokenA.setValueFor(parent, () => 7); await Updates.next(); @@ -990,7 +928,7 @@ describe("A DesignToken", () => { const child = addElement(parent) tokenA.withDefault(() => 6); - tokenB.withDefault((el) => tokenA.getValueFor(el) * 2); + tokenB.withDefault((resolve) => resolve( tokenA ) * 2); const handleChange = chia.spy(() => {}) const subscriber = { @@ -998,7 +936,7 @@ describe("A DesignToken", () => { } - tokenB.subscribe(subscriber, child); + tokenB.subscribe(subscriber, /*child*/); tokenA.setValueFor(grandparent, () => 7); await Updates.next(); diff --git a/packages/web-components/fast-foundation/src/design-token/fast-design-token.ts b/packages/web-components/fast-foundation/src/design-token/fast-design-token.ts new file mode 100644 index 00000000000..d6f3db33a4b --- /dev/null +++ b/packages/web-components/fast-foundation/src/design-token/fast-design-token.ts @@ -0,0 +1,421 @@ +import { + AddBehavior, + Behavior, + ComposableStyles, + CSSDirective, + cssDirective, + FASTElement, + Observable, + Subscriber, + SubscriberSet, +} from "@microsoft/fast-element"; +import { composedContains, composedParent } from "@microsoft/fast-element/utilities"; +import { + PropertyTargetManager, + RootStyleSheetTarget, +} from "./custom-property-manager.js"; +import { + DesignTokenChangeRecord as CoreDesignTokenChangeRecord, + DerivedDesignTokenValue, + DesignTokenMutationType, + DesignTokenNode, + DesignTokenResolver, + DesignTokenValue, +} from "./core/design-token-node.js"; + +/** + * @public + */ +export interface DesignTokenChangeRecord> { + /** + * The element for which the value was changed + */ + target: FASTElement | "default"; + + /** + * The token that was changed + */ + token: T; +} + +/** + * A subscriber that should receive {@link DesignTokenChangeRecord | change records} when a token changes for a target + * @public + */ +export interface DesignTokenSubscriber> { + handleChange(token: T, record: DesignTokenChangeRecord): void; +} + +/** + * Describes a {@link DesignToken} configuration + * @public + */ +export interface DesignTokenConfiguration { + /** + * The name of the {@link DesignToken}. + */ + name: string; +} + +/** + * @public + */ +export interface CSSDesignTokenConfiguration extends DesignTokenConfiguration { + /** + * The name of the CSS custom property to associate to the {@link CSSDesignToken} + */ + cssCustomPropertyName: string; +} + +/** + * @public + */ +export class DesignToken { + /** + * The name of the {@link DesignToken} + */ + public name: string; + + /** + * The default value of the token (alias of {@link DesignToken.default}) + */ + public get $value() { + return this.default; + } + + /** + * The default value of the token, or undefined if it has not been set. + */ + public get default(): T | undefined { + return FASTDesignTokenNode.defaultNode.getTokenValue(this); + } + + private _subscribers: SubscriberSet | undefined; + private get subscribers() { + if (this._subscribers) { + return this._subscribers; + } + this._subscribers = new SubscriberSet(this); + return this._subscribers; + } + + constructor(configuration: DesignTokenConfiguration) { + this.name = configuration.name; + + Observable.getNotifier(this).subscribe(this.subscriberNotifier); + } + + private static isCSSDesignTokenConfiguration( + config: CSSDesignTokenConfiguration | DesignTokenConfiguration + ): config is CSSDesignTokenConfiguration { + return ( + typeof (config as CSSDesignTokenConfiguration).cssCustomPropertyName === + "string" + ); + } + + /** + * + * @param name - Factory function for creating a {@link DesignToken} or {@link CSSDesignToken} + */ + public static create(name: string): CSSDesignToken; + public static create(config: DesignTokenConfiguration): DesignToken; + public static create(config: CSSDesignTokenConfiguration): CSSDesignToken; + public static create(config: any): any { + if (typeof config === "string") { + return new CSSDesignToken({ name: config, cssCustomPropertyName: config }); + } else { + return DesignToken.isCSSDesignTokenConfiguration(config) + ? new CSSDesignToken(config) + : new DesignToken(config); + } + } + + /** + * Configures the strategy for resolving hierarchical relationships between FASTElement targets. + */ + public static withStrategy(strategy: DesignTokenResolutionStrategy): void { + FASTDesignTokenNode.withStrategy(strategy); + } + + /** + * Registers and element or document as a DesignToken root. + * {@link CSSDesignToken | CSSDesignTokens} with default values assigned via + * {@link DesignToken.withDefault} will emit CSS custom properties to all + * registered roots. + * @param target - The root to register + */ + public static registerRoot(target: FASTElement | Document = document) { + RootStyleSheetTarget.registerRoot(target); + } + + /** + * Unregister an element or document as a DesignToken root. + * @param target - The root to deregister + */ + public static unregisterRoot(target: FASTElement | Document = document) { + RootStyleSheetTarget.unregisterRoot(target); + } + + /** + * Retrieves the value of the token for a target element. + */ + public getValueFor(target: FASTElement): T { + return FASTDesignTokenNode.getOrCreate(target).getTokenValue(this); + } + + /** + * Sets the value of the token for a target element. + */ + public setValueFor( + target: FASTElement, + value: DesignToken | DesignTokenValue + ): void { + FASTDesignTokenNode.getOrCreate(target).setTokenValue( + this, + this.normalizeValue(value) + ); + } + + /** + * Deletes the value of the token for a target element. + */ + public deleteValueFor(target: FASTElement): this { + FASTDesignTokenNode.getOrCreate(target).deleteTokenValue(this); + return this; + } + + /** + * Sets the default value of the token. + */ + public withDefault(value: DesignToken | DesignTokenValue): this { + FASTDesignTokenNode.defaultNode.setTokenValue(this, this.normalizeValue(value)); + return this; + } + + /** + * Subscribes a subscriber to notifications for the token. + */ + public subscribe(subscriber: DesignTokenSubscriber): void { + this.subscribers.subscribe(subscriber); + } + + /** + * Unsubscribes a subscriber to notifications for the token. + */ + public unsubscribe(subscriber: DesignTokenSubscriber): void { + this.subscribers.unsubscribe(subscriber); + } + + /** + * Alias the token to the provided token. + * @param token - the token to alias to + */ + private alias(token: DesignToken): DerivedDesignTokenValue { + return ((resolve: DesignTokenResolver) => + resolve(token)) as DerivedDesignTokenValue; + } + + private normalizeValue(value: DesignToken | DesignTokenValue) { + if (value instanceof DesignToken) { + value = this.alias(value); + } + + return value as DesignTokenValue; + } + + private subscriberNotifier: Subscriber = { + handleChange: ( + source: DesignToken, + change: CoreDesignTokenChangeRecord + ) => { + const record: DesignTokenChangeRecord = { + target: + change.target === FASTDesignTokenNode.defaultNode + ? "default" + : (change.target as FASTDesignTokenNode).target, + token: this, + }; + this.subscribers.notify(record); + }, + }; +} + +/** + * @public + */ +@cssDirective() +export class CSSDesignToken extends DesignToken implements CSSDirective { + /** + * The CSS Custom property name of the token. + */ + public readonly cssCustomProperty: string; + private cssVar: string; + + /** + * The DesignToken represented as a string that can be used in CSS. + */ + public createCSS(): string { + return this.cssVar; + } + private cssReflector: Subscriber = { + handleChange: ( + source: DesignToken, + record: CoreDesignTokenChangeRecord + ) => { + const target = + record.target === FASTDesignTokenNode.defaultNode + ? FASTDesignTokenNode.rootStyleSheetTarget + : record.target instanceof FASTDesignTokenNode + ? PropertyTargetManager.getOrCreate(record.target.target) + : null; + if (target) { + if (record.type === DesignTokenMutationType.delete) { + target.removeProperty(this.cssCustomProperty!); + } else { + target.setProperty( + this.cssCustomProperty!, + this.resolveCSSValue(record.target.getTokenValue(this)) as any + ); + } + } + }, + }; + + constructor(configuration: CSSDesignTokenConfiguration) { + super(configuration); + this.cssCustomProperty = `--${configuration.cssCustomPropertyName}`; + this.cssVar = `var(${this.cssCustomProperty})`; + Observable.getNotifier(this).subscribe(this.cssReflector); + } + + private resolveCSSValue(value: any) { + return value && typeof value.createCSS === "function" ? value.createCSS() : value; + } +} + +export interface DesignTokenResolutionStrategy { + /** + * Determines if a 'child' element is contained by a 'parent'. + * @param child - The child element + * @param parent - The parent element + */ + contains(parent: FASTElement, child: FASTElement): boolean; + + /** + * Finds the nearest FASTElement parent node + * @param element - The element to find the parent of + */ + parent(element: FASTElement): FASTElement | null; + + /** + * Binds the strategy to the element + */ + bind(element: FASTElement): void; + + /** + * Un-binds the strategy to the element + */ + unbind(element: FASTElement): void; +} + +const defaultDesignTokenResolutionStrategy: DesignTokenResolutionStrategy = { + contains: composedContains, + parent(element: FASTElement): FASTElement | null { + let parent: HTMLElement | null = composedParent(element); + + while (parent !== null) { + if (parent instanceof FASTElement) { + return parent as FASTElement; + } + + parent = composedParent(parent); + } + + return null; + }, + bind() {}, + unbind() {}, +}; + +class FASTDesignTokenNode extends DesignTokenNode implements Behavior { + private static _strategy: DesignTokenResolutionStrategy; + private static get strategy() { + if (this._strategy === undefined) { + FASTDesignTokenNode.withStrategy(defaultDesignTokenResolutionStrategy); + } + + return this._strategy; + } + public static defaultNode = new DesignTokenNode(); + public static rootStyleSheetTarget = new RootStyleSheetTarget(); + private static cache = new WeakMap(); + + public bind(target: FASTElement) { + let parent = FASTDesignTokenNode.findParent(target); + + if (parent === null) { + parent = FASTDesignTokenNode.defaultNode; + } + + if (parent !== this.parent) { + const reparent = []; + for (const child of parent.children) { + if ( + child instanceof FASTDesignTokenNode && + FASTDesignTokenNode.strategy.contains(target, child.target) + ) { + reparent.push(child); + } + } + + parent.appendChild(this); + + for (const child of reparent) { + this.appendChild(child); + } + } + } + + public unbind(): void { + FASTDesignTokenNode.cache.delete(this.target); + this.dispose(); + } + + public static getOrCreate(target: FASTElement) { + let found = FASTDesignTokenNode.cache.get(target); + + if (found) { + return found; + } + + found = new FASTDesignTokenNode(target); + FASTDesignTokenNode.cache.set(target, found); + target.$fastController.addBehaviors([FASTDesignTokenNode.strategy, found]); + + return found; + } + + public static withStrategy(strategy: DesignTokenResolutionStrategy) { + this._strategy = strategy; + } + + private static findParent(target: FASTElement): DesignTokenNode | null { + let current = FASTDesignTokenNode.strategy.parent(target); + + while (current !== null) { + const node = FASTDesignTokenNode.cache.get(current as FASTElement); + if (node) { + return node; + } + + current = FASTDesignTokenNode.strategy.parent(current); + } + + return null; + } + + constructor(public readonly target: FASTElement) { + super(); + } +} diff --git a/packages/web-components/fast-foundation/src/design-token/interfaces.ts b/packages/web-components/fast-foundation/src/design-token/interfaces.ts deleted file mode 100644 index c4d207f5227..00000000000 --- a/packages/web-components/fast-foundation/src/design-token/interfaces.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * A {@link (DesignToken:interface)} value that is derived. These values can depend on other {@link (DesignToken:interface)}s - * or arbitrary observable properties. - * @public - */ -export type DerivedDesignTokenValue = T extends Function - ? never - : (target: HTMLElement) => T; - -/** - * A design token value with no observable dependencies - * @public - */ -export type StaticDesignTokenValue = T extends Function ? never : T; - -/** - * The type that a {@link (DesignToken:interface)} can be set to. - * @public - */ -export type DesignTokenValue = StaticDesignTokenValue | DerivedDesignTokenValue; - -/** - * Describes a {@link (DesignToken:interface)} configuration - * @public - */ -export interface DesignTokenConfiguration { - /** - * The name of the {@link (DesignToken:interface)}. - */ - name: string; - - /** - * The name of the CSS custom property to associate to the {@link (DesignToken:interface)}, or null - * if not CSS custom property should be associated. - */ - cssCustomPropertyName?: string | null; -} diff --git a/packages/web-components/fast-foundation/src/index.ts b/packages/web-components/fast-foundation/src/index.ts index 7e203afd592..f01b9b0ded3 100644 --- a/packages/web-components/fast-foundation/src/index.ts +++ b/packages/web-components/fast-foundation/src/index.ts @@ -12,18 +12,7 @@ export * from "./card/index.js"; export * from "./checkbox/index.js"; export * from "./combobox/index.js"; export * from "./data-grid/index.js"; -export { - DesignToken, - CSSDesignToken, - DesignTokenChangeRecord, - DesignTokenSubscriber, -} from "./design-token/design-token.js"; -export { - StaticDesignTokenValue, - DerivedDesignTokenValue, - DesignTokenValue, - DesignTokenConfiguration, -} from "./design-token/interfaces.js"; +export * from "./design-token/exports.js"; export * from "./dialog/index.js"; export { reflectAttributes } from "./directives/reflect-attributes.js"; export * from "./disclosure/index.js";