From 70585ebf10fbdaa7ebff353894e6a62c9a56c718 Mon Sep 17 00:00:00 2001 From: Kumar Aditya Raj Date: Fri, 18 Sep 2026 15:41:57 +0530 Subject: [PATCH 1/3] Theme support for text editor Signed-off-by: Kumar Aditya Raj --- .changeset/light-dark-theme-text-editor.md | 5 ++ packages/text-editor/.storybook/preview.tsx | 20 ++++++ packages/text-editor/README.md | 1 + packages/text-editor/package.json | 2 +- packages/text-editor/src/TextEditor.tsx | 15 ++++ .../src/hooks/useResolvedColorMode.ts | 70 +++++++++++++++++++ packages/text-editor/src/types/colorMode.ts | 18 +++++ .../stories/features/TextEditor.tsx | 1 + .../tests/__mocks__/monaco-editor.ts | 3 + .../tests/text-editor/TextEditor.test.tsx | 42 +++++++++++ 10 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 .changeset/light-dark-theme-text-editor.md create mode 100644 packages/text-editor/src/hooks/useResolvedColorMode.ts create mode 100644 packages/text-editor/src/types/colorMode.ts diff --git a/.changeset/light-dark-theme-text-editor.md b/.changeset/light-dark-theme-text-editor.md new file mode 100644 index 00000000..6d5a7a7b --- /dev/null +++ b/.changeset/light-dark-theme-text-editor.md @@ -0,0 +1,5 @@ +--- +"@openworkflowspec/text-editor": minor +--- + +Light and Dark Theme support for the text-editor diff --git a/packages/text-editor/.storybook/preview.tsx b/packages/text-editor/.storybook/preview.tsx index 5be65130..488401b9 100644 --- a/packages/text-editor/.storybook/preview.tsx +++ b/packages/text-editor/.storybook/preview.tsx @@ -14,9 +14,29 @@ * 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/package.json b/packages/text-editor/package.json index 545e6891..958bc15a 100644 --- a/packages/text-editor/package.json +++ b/packages/text-editor/package.json @@ -1,7 +1,7 @@ { "name": "@openworkflowspec/text-editor", "version": "1.1.0", - "private": true, + "private": false, "description": "React Open Workflow text editor component backed by Monaco", "keywords": [], "homepage": "https://github.com/open-workflow-specification/editor", 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..852aa8db 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,45 @@ 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); + }); + }); }); From 96510dc11fbcadbae34b279acbbfc63e1fc78175 Mon Sep 17 00:00:00 2001 From: Kumar Aditya Raj Date: Fri, 18 Sep 2026 15:59:58 +0530 Subject: [PATCH 2/3] suggested changes Signed-off-by: Kumar Aditya Raj --- packages/text-editor/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/text-editor/README.md b/packages/text-editor/README.md index 60d6ae67..5394f4fa 100644 --- a/packages/text-editor/README.md +++ b/packages/text-editor/README.md @@ -35,7 +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. | +| `colorMode` | `light, dark, system` | — | `system` | Controls the editor theme. | ## Sizing From 64e0b42c05ef9e1bcb9ea1181b5041569a4073f5 Mon Sep 17 00:00:00 2001 From: Kumar Aditya Raj Date: Fri, 18 Sep 2026 16:10:37 +0530 Subject: [PATCH 3/3] Test cases added Signed-off-by: Kumar Aditya Raj --- .../tests/text-editor/TextEditor.test.tsx | 95 ++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/packages/text-editor/tests/text-editor/TextEditor.test.tsx b/packages/text-editor/tests/text-editor/TextEditor.test.tsx index 852aa8db..57a707ce 100644 --- a/packages/text-editor/tests/text-editor/TextEditor.test.tsx +++ b/packages/text-editor/tests/text-editor/TextEditor.test.tsx @@ -14,9 +14,10 @@ * limitations under the License. */ -import { render } from "@testing-library/react"; +import { render, renderHook, act } from "@testing-library/react"; import * as React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useResolvedColorMode } from "../../src/hooks/useResolvedColorMode"; import { mockEditorCreate, @@ -227,4 +228,96 @@ describe("TextEditor", () => { expect(mockEditorCreate).toHaveBeenCalledTimes(1); }); }); + + describe("useResolvedColorMode", () => { + let mediaQueryListeners: Array<(event: MediaQueryListEvent) => void>; + let mediaQueryList: { + matches: boolean; + addEventListener: ReturnType; + removeEventListener: ReturnType; + }; + + beforeEach(() => { + mediaQueryListeners = []; + + mediaQueryList = { + matches: false, + addEventListener: vi.fn((_: string, listener: (event: MediaQueryListEvent) => void) => { + mediaQueryListeners.push(listener); + }), + removeEventListener: vi.fn(), + }; + + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn(() => mediaQueryList), + }); + }); + + it("resolves system mode to light when the system prefers light", () => { + mediaQueryList.matches = false; + + const { result } = renderHook(() => useResolvedColorMode("system")); + + expect(result.current).toBe("light"); + }); + + it("resolves system mode to dark when the system prefers dark", () => { + mediaQueryList.matches = true; + + const { result } = renderHook(() => useResolvedColorMode("system")); + + expect(result.current).toBe("dark"); + }); + + it("updates when the system color mode changes", () => { + mediaQueryList.matches = false; + + const { result } = renderHook(() => useResolvedColorMode("system")); + + expect(result.current).toBe("light"); + + act(() => { + mediaQueryList.matches = true; + + mediaQueryListeners.forEach((listener) => + listener({ matches: true } as MediaQueryListEvent), + ); + }); + + expect(result.current).toBe("dark"); + + act(() => { + mediaQueryList.matches = false; + + mediaQueryListeners.forEach((listener) => + listener({ matches: false } as MediaQueryListEvent), + ); + }); + + expect(result.current).toBe("light"); + }); + + it("subscribes to system color mode changes", () => { + renderHook(() => useResolvedColorMode("system")); + + expect(mediaQueryList.addEventListener).toHaveBeenCalledWith("change", expect.any(Function)); + }); + + it("removes the system color mode listener on unmount", () => { + const { unmount } = renderHook(() => useResolvedColorMode("system")); + + const listener = mediaQueryList.addEventListener.mock.calls[0][1]; + + unmount(); + + expect(mediaQueryList.removeEventListener).toHaveBeenCalledWith("change", listener); + }); + + it("uses the server fallback for system mode", () => { + const { result } = renderHook(() => useResolvedColorMode("system")); + + expect(result.current).toBe("light"); + }); + }); });