diff --git a/packages/text-editor/.storybook/preview.tsx b/packages/text-editor/.storybook/preview.tsx index 5be65130..bd56a658 100644 --- a/packages/text-editor/.storybook/preview.tsx +++ b/packages/text-editor/.storybook/preview.tsx @@ -14,9 +14,31 @@ * limitations under the License. */ +import * as React from "react"; import type { Preview, Decorator } from "@storybook/react-vite"; +import { useArgs, useGlobals } from "storybook/preview-api"; const withColorMode: Decorator = (Story) => { + const [{ colorMode: argColorMode }, updateArgs] = useArgs(); + const [{ colorMode: globalColorMode }, updateGlobals] = useGlobals(); + + const lastArgColorMode = React.useRef(argColorMode); + const lastGlobalColorMode = React.useRef(globalColorMode); + + React.useEffect(() => { + const argsChanged = argColorMode !== lastArgColorMode.current; + const globalChanged = globalColorMode !== lastGlobalColorMode.current; + + if (argsChanged) { + updateGlobals({ colorMode: argColorMode }); + } else if (globalChanged) { + updateArgs({ colorMode: globalColorMode }); + } + + lastArgColorMode.current = argColorMode; + lastGlobalColorMode.current = globalColorMode; + }, [argColorMode, globalColorMode, updateArgs, updateGlobals]); + return ; }; diff --git a/packages/text-editor/README.md b/packages/text-editor/README.md index 5a7d4d53..60d6ae67 100644 --- a/packages/text-editor/README.md +++ b/packages/text-editor/README.md @@ -35,6 +35,7 @@ React text editor component for Open Workflow documents, based on [Monaco Editor | `language` | `TextEditorLanguage` | ✅ | — | Document language: `json` or `yaml`. | | `isReadOnly` | `boolean` | — | `false` | Prevents editing when enabled. | | `onContentChange` | `(content: string) => void` | — | `undefined` | Called when the user modifies the document. | +| `colorMode`. | `light, dark, system` | — | `system`. | Controls the editor theme. | ## Sizing diff --git a/packages/text-editor/src/TextEditor.tsx b/packages/text-editor/src/TextEditor.tsx index db1db2c4..5a4a60e3 100644 --- a/packages/text-editor/src/TextEditor.tsx +++ b/packages/text-editor/src/TextEditor.tsx @@ -19,6 +19,8 @@ import * as monaco from "monaco-editor/editor"; import "monaco-editor/features/register.all"; import "monaco-editor/languages/features/json/register"; import "monaco-editor/languages/definitions/yaml/register"; +import { ColorMode } from "./types/colorMode"; +import { useResolvedColorMode } from "./hooks/useResolvedColorMode"; export type TextEditorLanguage = "json" | "yaml"; @@ -27,6 +29,7 @@ export type TextEditorProps = { language: TextEditorLanguage; onContentChange?: (content: string) => void; isReadOnly?: boolean; + colorMode?: ColorMode; }; export const TextEditor = ({ @@ -34,7 +37,9 @@ export const TextEditor = ({ language, onContentChange, isReadOnly = false, + colorMode = "system", }: TextEditorProps) => { + const resolvedColorMode = useResolvedColorMode(colorMode); const containerRef = React.useRef(null); const editorRef = React.useRef(null); const isApplyingExternalContentRef = React.useRef(false); @@ -50,6 +55,9 @@ export const TextEditor = ({ readOnly: isReadOnly, automaticLayout: true, renderLineHighlight: "none", + ...(resolvedColorMode && { + theme: resolvedColorMode === "dark" ? "vs-dark" : "vs", + }), }); editorRef.current = editor; @@ -103,6 +111,13 @@ export const TextEditor = ({ editorRef.current?.updateOptions({ readOnly: isReadOnly }); }, [isReadOnly]); + React.useEffect(() => { + if (!editorRef.current) { + return; + } + monaco.editor.setTheme(resolvedColorMode === "dark" ? "vs-dark" : "vs"); + }, [resolvedColorMode]); + return (
void) => { + if (normalized !== "system") { + return noopUnsubscribe; + } + + const mediaQuery = getMediaQueryList(); + if (mediaQuery == null) { + return noopUnsubscribe; + } + mediaQuery.addEventListener("change", onStoreChanges); + return () => { + mediaQuery.removeEventListener("change", onStoreChanges); + }; + }, + [normalized], + ); + + return useSyncExternalStore( + subscribe, + () => (normalized === "system" ? getSystemColorMode() : normalized), + () => (normalized === "system" ? getServerColorMode() : normalized), + ); +} diff --git a/packages/text-editor/src/types/colorMode.ts b/packages/text-editor/src/types/colorMode.ts new file mode 100644 index 00000000..650b6f3d --- /dev/null +++ b/packages/text-editor/src/types/colorMode.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021-Present The Open Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type ColorMode = "light" | "dark" | "system"; +export type ResolvedColorMode = "light" | "dark"; diff --git a/packages/text-editor/stories/features/TextEditor.tsx b/packages/text-editor/stories/features/TextEditor.tsx index 03c07c56..ddb75805 100644 --- a/packages/text-editor/stories/features/TextEditor.tsx +++ b/packages/text-editor/stories/features/TextEditor.tsx @@ -25,6 +25,7 @@ export const TextEditor = ({ ...props }: TextEditorProps) => { language={props.language} onContentChange={props.onContentChange} isReadOnly={props.isReadOnly} + colorMode={props.colorMode} />
); diff --git a/packages/text-editor/tests/__mocks__/monaco-editor.ts b/packages/text-editor/tests/__mocks__/monaco-editor.ts index 245b1d01..607c28ca 100644 --- a/packages/text-editor/tests/__mocks__/monaco-editor.ts +++ b/packages/text-editor/tests/__mocks__/monaco-editor.ts @@ -72,9 +72,12 @@ export const simulateEditorContentChange = (value: string) => { state.listener?.(); }; +export const mockSetTheme = vi.fn(); + export default { editor: { create: mockEditorCreate, + setTheme: mockSetTheme, setModelLanguage: mockSetModelLanguage, }, }; diff --git a/packages/text-editor/tests/text-editor/TextEditor.test.tsx b/packages/text-editor/tests/text-editor/TextEditor.test.tsx index 8883fe24..ac04080a 100644 --- a/packages/text-editor/tests/text-editor/TextEditor.test.tsx +++ b/packages/text-editor/tests/text-editor/TextEditor.test.tsx @@ -26,6 +26,7 @@ import { mockModel, mockSetModelLanguage, simulateEditorContentChange, + mockSetTheme, } from "../__mocks__/monaco-editor"; import { TextEditor, type TextEditorProps } from "../../src/TextEditor"; @@ -185,4 +186,44 @@ describe("TextEditor", () => { expect(mockEditorCreate).toHaveBeenCalledTimes(2); }); }); + describe("theme", () => { + it("uses the light Monaco theme for light color mode", () => { + renderEditor({ colorMode: "light" }); + + expect(mockSetTheme).toHaveBeenCalledTimes(1); + expect(mockSetTheme).toHaveBeenCalledWith("vs"); + }); + + it("uses the dark Monaco theme for dark color mode", () => { + renderEditor({ colorMode: "dark" }); + + expect(mockSetTheme).toHaveBeenCalledTimes(1); + expect(mockSetTheme).toHaveBeenCalledWith("vs-dark"); + }); + + it("updates the Monaco theme when color mode changes", () => { + const { rerenderEditor } = renderEditor({ + colorMode: "light", + }); + + expect(mockSetTheme).toHaveBeenCalledWith("vs"); + + mockSetTheme.mockClear(); + + rerenderEditor({ colorMode: "dark" }); + + expect(mockSetTheme).toHaveBeenCalledTimes(1); + expect(mockSetTheme).toHaveBeenCalledWith("vs-dark"); + }); + + it("does not recreate Monaco when color mode changes", () => { + const { rerenderEditor } = renderEditor({ + colorMode: "light", + }); + + rerenderEditor({ colorMode: "dark" }); + + expect(mockEditorCreate).toHaveBeenCalledTimes(1); + }); + }); });