Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/__fixtures__/inherits-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { Theme, Context } from '../shared/theme';
import { colorMode, createStubPropertiesMap, densityMode } from './common';

// Fixtures for visual contexts inheritance via inheritsMode.
// These contexts inherit tokens from the specified non-default mode ("dark", "compact").

export { colorMode, densityMode };

export const topNavigationContext: Context = {
id: 'top-navigation',
selector: '.top-navigation',
inheritsMode: 'dark',
// Own override applied on top of the inherited dark values.
tokens: { bgColor: 'navy' },
};

export const compactTableContext: Context = {
id: 'compact-table',
selector: '.compact-table',
inheritsMode: 'compact',
// No own overrides: pure inheritance of the compact density state.
tokens: {},
};

export const theme: Theme = {
id: 'inheritance',
selector: 'body',
tokens: {
fontFamily: 'Arial',
textColor: { light: 'black', dark: 'white' },
bgColor: { light: 'white', dark: 'black' },
linkColor: { light: 'blue', dark: 'cyan' },
spaceScaled: { comfortable: '20px', compact: '4px' },
},
tokenModeMap: { textColor: 'color', bgColor: 'color', linkColor: 'color', spaceScaled: 'density' },
contexts: { 'top-navigation': topNavigationContext, 'compact-table': compactTableContext },
modes: { color: colorMode, density: densityMode },
};

export const secondaryTheme: Theme = {
...theme,
id: 'inheritance-secondary',
selector: '.secondary',
// The secondary theme tweaks the dark link color.
tokens: {
...theme.tokens,
linkColor: { light: 'green', dark: 'lime' },
},
contexts: { 'top-navigation': { ...topNavigationContext } },
};

export const propertiesMap = createStubPropertiesMap(theme);

// Visual context that uses legacy defaultMode property. This must not trigger selectors
// dedup, applied when inheritsMode is used.
export const legacyContext: Context = {
id: 'legacy-top-navigation',
selector: '.legacy-top-navigation',
defaultMode: 'dark',
tokens: {
// Dark values copied into the context (mode-invariant), mirroring how
// components author `defaultMode` contexts via pickState('dark').
textColor: { light: 'white', dark: 'white' },
bgColor: { light: 'navy', dark: 'navy' },
linkColor: { light: 'cyan', dark: 'cyan' },
},
};

export const legacyTheme: Theme = {
...theme,
id: 'legacy',
contexts: {
'legacy-top-navigation': legacyContext,
},
};
2 changes: 1 addition & 1 deletion src/build/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export async function buildThemedComponentsInternal(params: BuildThemedComponent
const neededTokens = findNeededTokens(scssDir, variablesMap, exposed);

const resolution = resolveTheme(primary);
const defaults = reduce(resolution, primary, defaultsReducer());
const defaults = reduce(resolution, primary, defaultsReducer(null));

const propertiesMap = calculatePropertiesMap([primary, ...secondary], variablesMap);
const styleTask = buildStyles(
Expand Down
4 changes: 2 additions & 2 deletions src/build/tasks/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import { join } from 'path';
import { ThemePreset } from '../../shared/theme';
import { getMode } from '../../shared/theme/utils';
import { getThemeMode } from '../../shared/theme/utils';
import { getContexts } from '../../shared/theme/validate';
import { writeFile } from '../file';

Expand Down Expand Up @@ -44,7 +44,7 @@ export declare const preset: ThemePreset;

function renderTypedOverrideInterface(preset: ThemePreset): string {
const tokens = preset.themeable.map((token) => {
const mode = getMode(preset.theme, token);
const mode = getThemeMode(preset.theme, token);
if (mode) {
const states = Object.keys(mode.states);
return `${token}?: GlobalValue | TypedModeValueOverride<${states.map((state) => `'${state}'`).join(' | ')}>`;
Expand Down
2 changes: 1 addition & 1 deletion src/shared/declaration/__integ__/layer-override.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { preset as inputPreset } from '../../../build/__tests__/__fixtures__/tem
const propertiesMap = calculatePropertiesMap([inputPreset.theme], inputPreset.variablesMap);
const preset: ThemePreset = { ...inputPreset, propertiesMap };

const resolution = reduce(resolveTheme(preset.theme), preset.theme, defaultsReducer());
const resolution = reduce(resolveTheme(preset.theme), preset.theme, defaultsReducer(null));
const allTokens = Object.keys(preset.theme.tokens);

// getInlineStylesheets produces CSS with :global() wrappers (CSS modules syntax).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`visual context inheritance > legacy theme output snapshot (no dedup) 1`] = `
"@layer awsui-base-theme {
body{
--fontFamily-css:Arial;
--textColor-css:black;
--bgColor-css:white;
--linkColor-css:blue;
--spaceScaled-css:20px;
}
@media not print {.dark{
--textColor-css:white;
--bgColor-css:black;
--linkColor-css:cyan;
}}
.compact{
--spaceScaled-css:4px;
}
.legacy-top-navigation{
--textColor-css:white;
--bgColor-css:navy;
--linkColor-css:cyan;
}
}"
`;

exports[`visual context inheritance > multi theme output snapshot 1`] = `
"@layer awsui-base-theme {
body{
--fontFamily-css:Arial;
--textColor-css:black;
--bgColor-css:white;
--linkColor-css:blue;
--spaceScaled-css:20px;
}
@media not print {.dark,.top-navigation{
--textColor-css:white;
--bgColor-css:black;
--linkColor-css:cyan;
}}
.compact,.compact-table{
--spaceScaled-css:4px;
}
@media not print {.top-navigation{
--bgColor-css:navy;
}}
.secondary{
--linkColor-css:green;
}
@media not print {.dark.secondary,.secondary .top-navigation,.secondary.top-navigation{
--linkColor-css:lime;
}}
@media not print {.secondary .top-navigation,.secondary.top-navigation{
--bgColor-css:navy;
}}
}"
`;

exports[`visual context inheritance > single theme output snapshot 1`] = `
"@layer awsui-base-theme {
body{
--fontFamily-css:Arial;
--textColor-css:black;
--bgColor-css:white;
--linkColor-css:blue;
--spaceScaled-css:20px;
}
@media not print {.dark,.top-navigation{
--textColor-css:white;
--bgColor-css:black;
--linkColor-css:cyan;
}}
.compact,.compact-table{
--spaceScaled-css:4px;
}
@media not print {.top-navigation{
--bgColor-css:navy;
}}
}"
`;
160 changes: 160 additions & 0 deletions src/shared/declaration/__tests__/inheritance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we keep defaultMode as backwards-compatible property, should we add a test that guarantees it's previous output (not producing the same behavior as inheritedMode)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// SPDX-License-Identifier: Apache-2.0

import { describe, test, expect } from 'vitest';
import * as fixtures from '../../../__fixtures__/inherits-mode';
import { createBuildDeclarations } from '../index';
import { Theme } from '../../theme';

const usedTokens = Object.keys(fixtures.theme.tokens);

function render(theme: Theme, secondary: Theme[] = []): string {
return createBuildDeclarations(theme, secondary, fixtures.propertiesMap, (s) => s, usedTokens);
}

function renderLegacy(): string {
return createBuildDeclarations(fixtures.legacyTheme, [], fixtures.propertiesMap, (s) => s, usedTokens);
}

/**
* Extracts the body of the rule whose selector is exactly `selector`. The
* selector must be a full selector (preceded by a rule/block boundary), so that
* looking up `.top-navigation` does not accidentally match the shared
* `.dark,.top-navigation` rule.
*/
function matchExactRule(css: string, selector: string): null | string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = css.match(new RegExp(`(?:^|[\\n{}])${escaped}\\{([^}]*)\\}`));
return match ? match[1].trim() : null;
}

describe('visual context inheritance', () => {
test('single theme output snapshot', () => {
expect(render(fixtures.theme, [])).toMatchSnapshot();
});

test('multi theme output snapshot', () => {
expect(render(fixtures.theme, [fixtures.secondaryTheme])).toMatchSnapshot();
});

test('legacy theme output snapshot (no dedup)', () => {
expect(renderLegacy()).toMatchSnapshot();
});

test('top-navigation context inherits from dark mode via selector aliases', () => {
const css = render(fixtures.theme);
expect(css).toMatch(/\.dark,\.top-navigation/);
});

test(`shared dark + top-navigation rule stays within mode's media rule`, () => {
const css = render(fixtures.theme);
expect(css).toMatch(/@media not print \{\.dark,\.top-navigation\{/);
});

test('top-navigation override rule only includes unique tokens', () => {
const css = render(fixtures.theme);

// bgColor is the only token top-navigation overrides relative to dark.
const standalone = matchExactRule(css, '.top-navigation');
expect(standalone).not.toBeNull();
expect(standalone).toBe('--bgColor-css:navy;');

// The override rule stays inside the inherited mode's media rule.
expect(css).toMatch(/@media not print \{\.top-navigation\{/);

// The standalone rule comes after the shared rule, so it wins the cascade.
expect(Array.from(css.matchAll(/\.top-navigation/g))).toHaveLength(2);
expect(css.indexOf('{.dark,.top-navigation')).toBeLessThan(css.indexOf('{.top-navigation'));
});

test('shared compact rule is media-free (compact has no media query)', () => {
const css = render(fixtures.theme);
const shared = matchExactRule(css, '.compact,.compact-table');
expect(shared).not.toBeNull();
expect(shared).toContain('--spaceScaled-css:4px;');
// Not wrapped in any media query.
expect(Array.from(css.matchAll(/\.compact-table/g))).toHaveLength(1);
expect(css).toMatch(/(^|\n)\.compact,\.compact-table\{/);
});

test('compact override rule is not emitted (it has no unique tokens)', () => {
const css = render(fixtures.theme);
expect(css).not.toMatch(/(^|\n|\})\.compact-table\{/);
});

test('non-inherited orthogonal combinations are not created', () => {
const css = render(fixtures.theme);
expect(css).not.toContain('.dark,.compact-table');
expect(css).not.toContain('.compact,.top-navigation');
});

test('secondary dark mode rule is shared with the secondary context selector', () => {
const css = render(fixtures.theme, [fixtures.secondaryTheme]);
expect(css).toMatch(/\.dark\.secondary[^{]*\.top-navigation/);
});

test('the secondary inheriting context retains the token instead of leaking the primary value', () => {
const primary: Theme = {
id: 'primary',
selector: 'body',
tokens: {
paletteLight: 'plight',
paletteDark: 'pdark',
btnBg: { light: '{paletteLight}', dark: '{paletteDark}' },
},
tokenModeMap: { btnBg: 'color' },
contexts: { ctx: { id: 'ctx', selector: '.ctx', inheritsMode: 'dark', tokens: {} } },
modes: { color: fixtures.colorMode },
};
const secondary: Theme = {
id: 'secondary',
selector: '.secondary',
tokens: {
paletteLight: 'plight',
paletteDark: 'pdark',
borderColor: { light: '{paletteLight}', dark: '{paletteDark}' },
// Mode-invariant reference in the secondary theme: the emitted var string
// is identical in light and dark, so it would be deduplicated away.
btnBg: '{borderColor}',
},
tokenModeMap: { borderColor: 'color', btnBg: 'color' },
contexts: { ctx: { id: 'ctx', selector: '.ctx', inheritsMode: 'dark', tokens: {} } },
modes: { color: fixtures.colorMode },
};
const usedTokens = ['paletteLight', 'paletteDark', 'borderColor', 'btnBg'];
const propertiesMap = usedTokens.reduce((acc, t) => ({ ...acc, [t]: `--${t}-css` }), {});
const css = createBuildDeclarations(primary, [secondary], propertiesMap, (s) => s, usedTokens);

// Primary .ctx carries the --btnBg-css with the primary dark value.
expect(matchExactRule(css, '.dark,.ctx')).toBe('--btnBg-css:var(--paletteDark-css);');
// Secondary .ctx re-declares --btnBg-css with the secondary value so it wins over the primary.
expect(matchExactRule(css, '.secondary .ctx,.ctx.secondary')).toBe('--btnBg-css:var(--borderColor-css);');
});

test('inheritance is ignored if inherited mode state does not exist or is default', () => {
const theme: Theme = {
id: 'theme',
selector: 'body',
tokens: {
btnBg: { light: 'black', dark: 'white' },
btnFg: 'yellow',
},
tokenModeMap: { btnBg: 'color', btnFg: 'color' },
contexts: {
u: { id: 'unknown', selector: '.unknown', inheritsMode: '?', tokens: { btnFg: 'green' } },
default: { id: 'default', selector: '.default', inheritsMode: 'light', tokens: { btnFg: 'purple' } },
},
modes: { color: fixtures.colorMode },
};
const usedTokens = ['btnBg', 'btnFg'];
const propertiesMap = usedTokens.reduce((acc, t) => ({ ...acc, [t]: `--${t}-css` }), {});
const css = createBuildDeclarations(theme, [], propertiesMap, (s) => s, usedTokens);

expect(matchExactRule(css, 'body')).toBe('--btnBg-css:black;\n\t--btnFg-css:yellow;');
expect(matchExactRule(css, '.dark')).toBe('--btnBg-css:white;');
expect(matchExactRule(css, '.unknown')).toBe('--btnFg-css:green;');
expect(matchExactRule(css, '.default')).toBe('--btnFg-css:purple;');
expect(Array.from(css.matchAll(/\.unknown/g))).toHaveLength(1);
expect(Array.from(css.matchAll(/\.default/g))).toHaveLength(1);
});
});
Loading
Loading