diff --git a/build-tools/tasks/generate-environment.js b/build-tools/tasks/generate-environment.js index f43ecd1b4a..6a12bac23c 100644 --- a/build-tools/tasks/generate-environment.js +++ b/build-tools/tasks/generate-environment.js @@ -1,45 +1,69 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 + +// Generates the `internal/environment` files for each theme. +// For every theme we write the same set of values in three forms: +// - environment.json raw values +// - environment.js runtime module, e.g. `export var THEME = "default";` +// - environment.d.ts type declarations, e.g. `export const THEME: string;` + const path = require('path'); const { writeFile } = require('../utils/files'); const themes = require('../utils/themes'); const workspace = require('../utils/workspace'); const ALWAYS_VISUAL_REFRESH = process.env.ALWAYS_VISUAL_REFRESH === 'true'; -const INCLUDE_ONE_THEME = process.env.INCLUDE_ONE_THEME === 'true'; -function writeEnvironmentFile(theme) { - const filepath = 'internal/environment'; - const values = { +// The build-time constants exposed to the component source for a given theme. +function getEnvironmentValues(theme) { + return { PACKAGE_SOURCE: workspace.packageSource, PACKAGE_VERSION: workspace.packageVersion, GIT_SHA: workspace.gitCommitVersion, THEME: theme.name, SYSTEM: 'core', ALWAYS_VISUAL_REFRESH: !!theme.alwaysVisualRefresh || ALWAYS_VISUAL_REFRESH, - INCLUDE_ONE_THEME: INCLUDE_ONE_THEME, + INCLUDED_THEMES: theme.includedThemes ?? [], }; - const basePath = path.join(theme.outputPath, filepath); +} + +function toTypeScriptType(value) { + if (Array.isArray(value)) { + return 'string[]'; + } + if (typeof value === 'boolean') { + return 'boolean'; + } + return 'string'; +} + +// Source for environment.js, e.g. `export var THEME = "default";` +function toModuleSource(values) { + return Object.entries(values) + .map(([name, value]) => `export var ${name} = ${JSON.stringify(value)};`) + .join('\n'); +} + +// Source for environment.d.ts, e.g. `export const THEME: string;` +function toDeclarationsSource(values) { + return Object.entries(values) + .map(([name, value]) => `export const ${name}: ${toTypeScriptType(value)};`) + .join('\n'); +} + +function writeEnvironmentFile(theme) { + const values = getEnvironmentValues(theme); + const basePath = path.join(theme.outputPath, 'internal/environment'); console.log('Environment values\n', JSON.stringify(values, null, 2)); writeFile(`${basePath}.json`, JSON.stringify(values, null, 2)); - writeFile( - `${basePath}.js`, - Object.entries(values) - .map(([key, value]) => `export var ${key} = ${JSON.stringify(value)};`) - .join('\n') - ); - writeFile( - `${basePath}.d.ts`, - Object.keys(values) - .map(key => `export const ${key}: string;`) - .join('\n') - ); + writeFile(`${basePath}.js`, toModuleSource(values)); + writeFile(`${basePath}.d.ts`, toDeclarationsSource(values)); } module.exports = function generateEnvironment() { - themes.forEach(theme => writeEnvironmentFile(theme)); + themes.forEach(writeEnvironmentFile); return Promise.resolve(); }; diff --git a/build-tools/utils/themes.js b/build-tools/utils/themes.js index 6564bf32d4..0817937478 100644 --- a/build-tools/utils/themes.js +++ b/build-tools/utils/themes.js @@ -3,7 +3,36 @@ const path = require('path'); const workspace = require('./workspace'); -const INCLUDE_ONE_THEME = process.env.INCLUDE_ONE_THEME === 'true'; +// Secondary themes are layered on top of the primary theme as opt-in overrides. Each entry maps a +// theme id to its style-dictionary entry point. To add a new theme, register it here, then include +// it via the THEMES env var. +const SECONDARY_THEME_PATHS = { + 'visual-refresh': './visual-refresh-secondary/index.js', + 'one-theme': './one-theme/index.js', +}; + +// Secondary themes included when THEMES is not set. Keeps visual refresh in, one-theme opt-in. +const DEFAULT_THEMES = 'visual-refresh'; + +// Resolves which secondary themes to compile from the comma-separated THEMES env var, e.g. +// `THEMES=visual-refresh,one-theme`. Throws if a requested theme is not registered above. +function resolveIncludedThemes() { + const themeIds = (process.env.THEMES ?? DEFAULT_THEMES) + .split(',') + .map(id => id.trim()) + .filter(Boolean); + + for (const id of themeIds) { + if (!SECONDARY_THEME_PATHS[id]) { + const availableThemes = Object.keys(SECONDARY_THEME_PATHS).join(', '); + throw new Error(`Unknown theme "${id}" in THEMES env var. Available themes: ${availableThemes}.`); + } + } + + return themeIds; +} + +const includedThemes = resolveIncludedThemes(); const themes = [ // This is the default Cloudscape theme, which is best used with Visual Refresh enabled (by default) @@ -15,10 +44,8 @@ const themes = [ designTokensPackageJson: { name: '@cloudscape-design/design-tokens' }, outputPath: path.join(workspace.targetPath, 'components'), primaryThemePath: './classic/index.js', - secondaryThemePaths: [ - './visual-refresh-secondary/index.js', - ...(INCLUDE_ONE_THEME ? ['./one-theme/index.js'] : []), - ], + includedThemes, + secondaryThemePaths: includedThemes.map(id => SECONDARY_THEME_PATHS[id]), }, ]; diff --git a/pages/app/app-context.tsx b/pages/app/app-context.tsx index 4807fdf5fe..634c9b9325 100644 --- a/pages/app/app-context.tsx +++ b/pages/app/app-context.tsx @@ -12,7 +12,7 @@ interface AppUrlParams { density: Density; direction: 'ltr' | 'rtl'; visualRefresh: boolean; - oneTheme: boolean; + theme?: string; motionDisabled: boolean; appLayoutWidget: boolean; mode?: Mode; @@ -33,7 +33,6 @@ const appContextDefaults: AppContextType = { density: Density.Comfortable, direction: 'ltr', visualRefresh: THEME === 'default', - oneTheme: false, motionDisabled: false, appLayoutWidget: false, }, diff --git a/pages/app/components/header.tsx b/pages/app/components/header.tsx index a8ddc58445..b99df8af24 100644 --- a/pages/app/components/header.tsx +++ b/pages/app/components/header.tsx @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React, { useContext } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import clsx from 'clsx'; import AppContext from '../app-context'; @@ -11,11 +11,13 @@ import styles from './header.scss'; export default function Header({ sticky }: { sticky?: boolean }) { const { mode } = useContext(AppContext); + // Preserve the query string (theme, density, motion, …) when navigating back to the index. + const { search } = useLocation(); return ( <> {/* #h selector for compatibility with global navigation */}
- Demo Assets + Demo Assets
diff --git a/pages/app/components/index-page.tsx b/pages/app/components/index-page.tsx index d8d6748af9..8afccbbf07 100644 --- a/pages/app/components/index-page.tsx +++ b/pages/app/components/index-page.tsx @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import { PACKAGE_VERSION } from '~components/internal/environment'; import AwsUiLink from '~components/link'; @@ -38,10 +38,12 @@ function createPagesTree(pages: string[]) { } function TreeItemView({ item }: { item: TreeItem }) { + // Preserve the query string (theme, density, motion, …) when navigating to a page. + const { search } = useLocation(); return (
  • {item.href ? ( - + {item.name} ) : ( diff --git a/pages/app/components/theme-switcher.tsx b/pages/app/components/theme-switcher.tsx index 26ade0902b..7f7674d7f6 100644 --- a/pages/app/components/theme-switcher.tsx +++ b/pages/app/components/theme-switcher.tsx @@ -4,52 +4,47 @@ import React, { useContext } from 'react'; import { Density, Mode } from '@cloudscape-design/global-styles'; -import { ALWAYS_VISUAL_REFRESH, INCLUDE_ONE_THEME } from '~components/internal/environment'; +import { ALWAYS_VISUAL_REFRESH } from '~components/internal/environment'; import SpaceBetween from '~components/space-between'; import AppContext from '../app-context'; +import { CLASSIC_THEME_ID, includedThemes, resolveActiveTheme } from '../theme-config'; export default function ThemeSwitcher() { const { mode, urlParams, setUrlParams, setMode } = useContext(AppContext); - function activateTheme(theme: 'visualRefresh' | 'oneTheme' | 'classic') { - setUrlParams({ - visualRefresh: theme === 'visualRefresh', - oneTheme: theme === 'oneTheme', - }); + const activeTheme = resolveActiveTheme(urlParams.theme, Boolean(urlParams.visualRefresh)); + + function activateTheme(themeId: string) { + setUrlParams({ theme: themeId }); window.location.reload(); } - const vrSwitchProps: React.InputHTMLAttributes = { - id: 'visual-refresh-toggle', - type: 'checkbox', - }; - - if (ALWAYS_VISUAL_REFRESH) { - vrSwitchProps.checked = true; - vrSwitchProps.readOnly = true; - } else { - vrSwitchProps.checked = urlParams.visualRefresh && !urlParams.oneTheme; - vrSwitchProps.onChange = event => activateTheme(event.target.checked ? 'visualRefresh' : 'classic'); - } + // Built from the themes compiled into this build. Classic is the baseline, except in + // always-visual-refresh builds where visual refresh is forced on and classic isn't selectable. + const themeOptions = [ + ...(ALWAYS_VISUAL_REFRESH ? [] : [{ id: CLASSIC_THEME_ID, label: 'Classic' }]), + ...includedThemes, + ]; + const selectedThemeId = activeTheme?.id ?? (ALWAYS_VISUAL_REFRESH ? 'visual-refresh' : CLASSIC_THEME_ID); return ( - {INCLUDE_ONE_THEME && ( - - )}