(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..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,
@@ -26,6 +27,7 @@ import {
mockModel,
mockSetModelLanguage,
simulateEditorContentChange,
+ mockSetTheme,
} from "../__mocks__/monaco-editor";
import { TextEditor, type TextEditorProps } from "../../src/TextEditor";
@@ -185,4 +187,137 @@ 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);
+ });
+ });
+
+ 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");
+ });
+ });
});