diff --git a/pages/code-editor/cloudscape-theme.page.tsx b/pages/code-editor/cloudscape-theme.page.tsx new file mode 100644 index 0000000000..774c87852e --- /dev/null +++ b/pages/code-editor/cloudscape-theme.page.tsx @@ -0,0 +1,69 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useState } from 'react'; + +import CodeEditor, { CodeEditorProps } from '~components/code-editor'; +import SpaceBetween from '~components/space-between'; + +import ScreenshotArea from '../utils/screenshot-area'; +import { i18nStrings } from './base-props'; +import { sayHelloSample } from './code-samples'; + +import 'ace-builds/css/ace.css'; + +// WIP (v0) demo of the opt-in Cloudscape theme. Its colors are token-driven, so +// the same theme is used for both light and dark visual modes and adapts +// automatically. Toggle the surrounding page to dark mode to see it adapt. +const cloudscapeThemes: CodeEditorProps.AvailableThemes = { + light: ['cloudscape', 'dawn'], + dark: ['cloudscape', 'tomorrow_night_bright'], +}; + +function DemoCodeEditor({ ace, loading }: { ace: any; loading: boolean }) { + const [preferences, setPreferences] = useState>({ + theme: 'cloudscape', + wrapLines: true, + }); + return ( + setPreferences(e.detail)} + loading={loading} + themes={cloudscapeThemes} + i18nStrings={i18nStrings} + /> + ); +} + +export default function CloudscapeThemePage() { + const [ace, setAce] = useState(); + + useEffect(() => { + import('ace-builds').then(loadedAce => { + loadedAce.config.set('basePath', './ace/'); + loadedAce.config.set('themePath', './ace/'); + loadedAce.config.set('modePath', './ace/'); + loadedAce.config.set('workerPath', './ace/'); + loadedAce.config.set('useStrictCSP', true); + setAce(loadedAce); + }); + }, []); + + return ( +
+

Code editor - Cloudscape theme (WIP)

+

+ The cloudscape theme derives its colors from Cloudscape design tokens and adapts to the active + visual mode. Switch the page to dark mode to see the same theme adapt. +

+ + + + + +
+ ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..be130aba2f 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -8702,6 +8702,7 @@ The event \`detail\` contains the value of all the preferences as submitted by t "twilight", "vibrant_ink", "cloud_editor_dark", + "cloudscape", ], }, "name": "theme", @@ -9192,7 +9193,9 @@ If set to \`undefined\`, the component uses the following default value: } \`\`\` -You can use any theme provided by Ace.", +You can use any theme provided by Ace. Additionally, you can set \`theme\` to +\`'cloudscape'\` to use the opt-in Cloudscape theme, whose colors are derived +from Cloudscape design tokens and adapt automatically to the active visual mode.", "inlineType": { "name": "Partial", "properties": [ @@ -9241,6 +9244,7 @@ You can use any theme provided by Ace.", "twilight", "vibrant_ink", "cloud_editor_dark", + "cloudscape", ], }, "name": "theme", diff --git a/src/code-editor/__tests__/cloudscape-theme.test.ts b/src/code-editor/__tests__/cloudscape-theme.test.ts new file mode 100644 index 0000000000..8f8c228f4e --- /dev/null +++ b/src/code-editor/__tests__/cloudscape-theme.test.ts @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + CLOUDSCAPE_ACE_THEME_CSS_CLASS, + CLOUDSCAPE_ACE_THEME_ID, + defineCloudscapeAceTheme, + isCloudscapeAceTheme, +} from '../../../lib/components/code-editor/cloudscape-theme'; + +describe('isCloudscapeAceTheme', () => { + it('returns true only for the cloudscape theme id', () => { + expect(isCloudscapeAceTheme(CLOUDSCAPE_ACE_THEME_ID)).toBe(true); + expect(isCloudscapeAceTheme('cloudscape')).toBe(true); + }); + + it('returns false for other themes and undefined', () => { + expect(isCloudscapeAceTheme('dawn')).toBe(false); + expect(isCloudscapeAceTheme('cloud_editor_dark')).toBe(false); + expect(isCloudscapeAceTheme(undefined)).toBe(false); + }); +}); + +describe('defineCloudscapeAceTheme', () => { + it('registers an ace/theme/cloudscape module with token-driven (empty) cssText', () => { + const ace = { define: jest.fn() }; + defineCloudscapeAceTheme(ace); + + expect(ace.define).toHaveBeenCalledTimes(1); + const [moduleId, deps, factory] = ace.define.mock.calls[0]; + expect(moduleId).toBe('ace/theme/cloudscape'); + expect(deps).toEqual(expect.arrayContaining(['require', 'exports', 'module'])); + + const exports: Record = {}; + factory(() => undefined, exports, {}); + expect(exports.cssClass).toBe(CLOUDSCAPE_ACE_THEME_CSS_CLASS); + expect(exports.isDark).toBe(false); + // Colors are supplied by the Cloudscape stylesheet, so no inline CSS. + expect(exports.cssText).toBe(''); + expect(exports.$id).toBe('ace/theme/cloudscape'); + }); + + it('registers the theme at most once per ace instance', () => { + const ace = { define: jest.fn() }; + defineCloudscapeAceTheme(ace); + defineCloudscapeAceTheme(ace); + expect(ace.define).toHaveBeenCalledTimes(1); + }); + + it('is a no-op for an ace instance without a define function', () => { + expect(() => defineCloudscapeAceTheme({})).not.toThrow(); + expect(() => defineCloudscapeAceTheme(null)).not.toThrow(); + expect(() => defineCloudscapeAceTheme(undefined)).not.toThrow(); + }); +}); diff --git a/src/code-editor/__tests__/code-editor.test.tsx b/src/code-editor/__tests__/code-editor.test.tsx index 2e31e602f1..d7abc0af05 100644 --- a/src/code-editor/__tests__/code-editor.test.tsx +++ b/src/code-editor/__tests__/code-editor.test.tsx @@ -101,6 +101,11 @@ describe('Code editor component', () => { expect(editorMock.setTheme).toHaveBeenLastCalledWith('ace/theme/cloud_editor_dark'); }); + it('applies the opt-in Cloudscape theme when selected via preferences', () => { + renderCodeEditor({ preferences: { theme: 'cloudscape', wrapLines: true } }); + expect(editorMock.setTheme).toHaveBeenLastCalledWith('ace/theme/cloudscape'); + }); + it('detects alternative dark mode class', () => { render(
diff --git a/src/code-editor/cloudscape-ace-theme.scss b/src/code-editor/cloudscape-ace-theme.scss new file mode 100644 index 0000000000..badea0e86e --- /dev/null +++ b/src/code-editor/cloudscape-ace-theme.scss @@ -0,0 +1,124 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +/* + WIP (v0): Cloudscape-aligned Ace theme. + + All colors are driven by Cloudscape design tokens. Because these tokens resolve + to CSS custom properties that change with the active visual mode, this single + theme automatically adapts to both light and dark mode. The `.ace-cloudscape` + class is applied by Ace when the `cloudscape` theme is selected (see + `cloudscape-theme.ts`). + + Syntax colors use the Cloudscape charts palette because it provides a set of + perceptually distinct, visual-mode-aware hues. This is a first-cut mapping; + a dedicated code-editor syntax token set is tracked as follow-up work. +*/ + +@use '../internal/styles/tokens' as awsui; + +/* stylelint-disable selector-combinator-disallowed-list, @cloudscape-design/no-implicit-descendant */ + +.code-editor :global .ace_editor.ace-cloudscape { + background-color: awsui.$color-background-container-content; + color: awsui.$color-text-body-default; + + .ace_gutter { + background-color: awsui.$color-background-code-editor-gutter-default; + color: awsui.$color-text-code-editor-gutter-default; + } + + .ace_print-margin { + inline-size: 1px; + background-color: awsui.$color-border-divider-default; + } + + .ace_cursor { + color: awsui.$color-text-body-default; + } + + .ace_marker-layer .ace_selection { + background: awsui.$color-background-item-selected; + } + + .ace_marker-layer .ace_selected-word { + border-block: 1px solid awsui.$color-border-item-focused; + border-inline: 1px solid awsui.$color-border-item-focused; + } + + // Keep the active-line border adaptive across visual modes (the shared + // ace-editor.scss active-line border relies on the `ace_dark` marker class, + // which this token-driven theme does not set). + .ace_marker-layer > .ace_active-line { + border-block-start: 1px solid awsui.$color-border-divider-default; + border-block-end: 1px solid awsui.$color-border-divider-default; + } + + .ace_indent-guide { + border-inline-end: 1px solid awsui.$color-border-divider-default; + } + + /* Syntax tokens */ + .ace_comment { + color: awsui.$color-text-body-secondary; + font-style: italic; + } + + .ace_keyword, + .ace_storage, + .ace_meta { + color: awsui.$color-charts-purple-700; + } + + .ace_keyword.ace_operator, + .ace_punctuation, + .ace_punctuation.ace_operator { + color: awsui.$color-text-body-secondary; + } + + .ace_string, + .ace_string.ace_regexp { + color: awsui.$color-charts-green-700; + } + + .ace_constant, + .ace_constant.ace_numeric, + .ace_constant.ace_language, + .ace_constant.ace_character, + .ace_constant.ace_other { + color: awsui.$color-charts-orange-700; + } + + .ace_support.ace_function, + .ace_entity.ace_name.ace_function { + color: awsui.$color-charts-blue-1-700; + } + + .ace_variable { + color: awsui.$color-text-body-default; + } + + .ace_support.ace_type, + .ace_support.ace_class, + .ace_storage.ace_type, + .ace_entity.ace_name.ace_class { + color: awsui.$color-charts-teal-700; + } + + .ace_entity.ace_name.ace_tag, + .ace_meta.ace_tag { + color: awsui.$color-charts-red-700; + } + + .ace_entity.ace_other.ace_attribute-name { + color: awsui.$color-charts-blue-2-700; + } + + .ace_invalid { + color: awsui.$color-text-status-error; + } +} + +/* stylelint-enable selector-combinator-disallowed-list, @cloudscape-design/no-implicit-descendant */ diff --git a/src/code-editor/cloudscape-theme.ts b/src/code-editor/cloudscape-theme.ts new file mode 100644 index 0000000000..c4975ebc02 --- /dev/null +++ b/src/code-editor/cloudscape-theme.ts @@ -0,0 +1,72 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * WIP (v0): Cloudscape-aligned Ace theme. + * + * Instead of shipping yet another Ace theme with hard-coded hex colors, this + * module registers a lightweight Ace theme whose colors are driven entirely by + * Cloudscape design tokens. The theme registers an empty `cssText` and relies + * on a `cssClass` (`ace-cloudscape`) that the Cloudscape stylesheet + * (`cloudscape-ace-theme.scss`) targets. Because the token custom properties + * change with the active visual mode, the same single theme automatically + * adapts to light and dark mode instead of requiring separate light/dark + * variants. + * + * This is additive and opt-in: consumers get it only when they select the + * `'cloudscape'` theme (via `preferences.theme`) and, if they want it available + * in the preferences modal, add it to their `themes` prop. + */ + +// The Ace theme id (used as `ace/theme/cloudscape`) and the value accepted by +// `CodeEditorProps.Preferences.theme`. +export const CLOUDSCAPE_ACE_THEME_ID = 'cloudscape'; + +// The CSS class Ace applies to the editor container for this theme. The +// Cloudscape stylesheet keys all token-driven colors off this class. +export const CLOUDSCAPE_ACE_THEME_CSS_CLASS = 'ace-cloudscape'; + +const ACE_MODULE_ID = `ace/theme/${CLOUDSCAPE_ACE_THEME_ID}`; + +// Track which Ace instances we've already registered the theme on. Consumers +// provide their own `ace` object, so this keeps registration idempotent per +// instance without leaking across (potentially multiple) Ace builds. +const registeredInstances = new WeakSet(); + +/** + * Registers the Cloudscape Ace theme on the provided Ace instance. Safe to call + * multiple times; registration happens at most once per Ace instance. Must be + * called before `editor.setTheme('ace/theme/cloudscape')` so Ace can resolve + * the module synchronously. + */ +export function defineCloudscapeAceTheme(ace: any): void { + if (!ace || typeof ace.define !== 'function') { + return; + } + if (registeredInstances.has(ace)) { + return; + } + + ace.define( + ACE_MODULE_ID, + ['require', 'exports', 'module'], + function (_require: unknown, exports: Record) { + // `isDark` only controls whether Ace adds the `ace_dark` marker class. + // Our colors come from visual-mode-aware tokens, so we don't depend on it. + exports.isDark = false; + exports.cssClass = CLOUDSCAPE_ACE_THEME_CSS_CLASS; + // Colors are provided by the Cloudscape stylesheet, so no inline CSS here. + exports.cssText = ''; + exports.$id = ACE_MODULE_ID; + } + ); + + registeredInstances.add(ace); +} + +/** + * Returns true when the given theme value is the opt-in Cloudscape theme. + */ +export function isCloudscapeAceTheme(theme: string | undefined): boolean { + return theme === CLOUDSCAPE_ACE_THEME_ID; +} diff --git a/src/code-editor/index.tsx b/src/code-editor/index.tsx index 19eb94d455..06a7cd64d0 100644 --- a/src/code-editor/index.tsx +++ b/src/code-editor/index.tsx @@ -115,7 +115,7 @@ const CodeEditor = forwardRef((props: CodeEditorProps, ref: React.Ref; @@ -132,7 +134,13 @@ type BuiltInLanguage = (typeof AceModes)[number]['value']; export namespace CodeEditorProps { export type Language = LiteralUnion; - export type Theme = (typeof LightThemes)[number]['value'] | (typeof DarkThemes)[number]['value']; + export type BuiltInTheme = (typeof LightThemes)[number]['value'] | (typeof DarkThemes)[number]['value']; + /** + * The opt-in Cloudscape theme. Its colors are derived from Cloudscape design + * tokens and adapt automatically to the active light/dark visual mode. + */ + export type CloudscapeTheme = 'cloudscape'; + export type Theme = BuiltInTheme | CloudscapeTheme; export interface AvailableThemes { light: ReadonlyArray; diff --git a/src/code-editor/styles.scss b/src/code-editor/styles.scss index 897d7ab30c..9ed75d0640 100644 --- a/src/code-editor/styles.scss +++ b/src/code-editor/styles.scss @@ -8,6 +8,7 @@ @use '../internal/styles/foundation' as foundation; @use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; @use './ace-editor'; +@use './cloudscape-ace-theme'; @use './pane'; .code-editor { diff --git a/src/code-editor/use-editor.tsx b/src/code-editor/use-editor.tsx index ed571e5a02..bd6ad6d110 100644 --- a/src/code-editor/use-editor.tsx +++ b/src/code-editor/use-editor.tsx @@ -6,6 +6,7 @@ import { Ace } from 'ace-builds'; import { useCurrentMode } from '@cloudscape-design/component-toolkit/internal'; +import { defineCloudscapeAceTheme, isCloudscapeAceTheme } from './cloudscape-theme'; import { CodeEditorProps } from './interfaces'; import { getAceTheme, getDefaultConfig, getDefaultTheme } from './util'; @@ -115,8 +116,16 @@ export function useSyncEditorWrapLines(editor: null | Ace.Editor, wrapLines?: bo }, [editor, wrapLines]); } -export function useSyncEditorTheme(editor: null | Ace.Editor, theme: CodeEditorProps.Theme) { +export function useSyncEditorTheme(ace: any, editor: null | Ace.Editor, theme: CodeEditorProps.Theme) { useEffect(() => { - editor?.setTheme(getAceTheme(theme)); - }, [editor, theme]); + if (!editor) { + return; + } + // The Cloudscape theme is registered lazily and must exist on the Ace + // instance before it can be applied. + if (isCloudscapeAceTheme(theme)) { + defineCloudscapeAceTheme(ace); + } + editor.setTheme(getAceTheme(theme)); + }, [ace, editor, theme]); }