Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/light-dark-theme-text-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openworkflowspec/text-editor": minor
---

Light and Dark Theme support for the text-editor
20 changes: 20 additions & 0 deletions packages/text-editor/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
kumaradityaraj marked this conversation as resolved.
updateGlobals({ colorMode: argColorMode });
} else if (globalChanged) {
updateArgs({ colorMode: globalColorMode });
}
lastArgColorMode.current = argColorMode;
lastGlobalColorMode.current = globalColorMode;
}, [argColorMode, globalColorMode, updateArgs, updateGlobals]);
return <Story />;
};

Expand Down
1 change: 1 addition & 0 deletions packages/text-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/text-editor/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
15 changes: 15 additions & 0 deletions packages/text-editor/src/TextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -27,14 +29,17 @@ export type TextEditorProps = {
language: TextEditorLanguage;
onContentChange?: (content: string) => void;
isReadOnly?: boolean;
colorMode?: ColorMode;
Comment thread
kumaradityaraj marked this conversation as resolved.
};

export const TextEditor = ({
content,
language,
onContentChange,
isReadOnly = false,
colorMode = "system",
}: TextEditorProps) => {
const resolvedColorMode = useResolvedColorMode(colorMode);
const containerRef = React.useRef<HTMLDivElement>(null);
const editorRef = React.useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const isApplyingExternalContentRef = React.useRef(false);
Expand All @@ -50,6 +55,9 @@ export const TextEditor = ({
readOnly: isReadOnly,
automaticLayout: true,
renderLineHighlight: "none",
...(resolvedColorMode && {
theme: resolvedColorMode === "dark" ? "vs-dark" : "vs",
}),
});

editorRef.current = editor;
Expand Down Expand Up @@ -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]);
Comment thread
kumaradityaraj marked this conversation as resolved.

return (
<div
data-testid="text-editor-container"
Expand Down
70 changes: 70 additions & 0 deletions packages/text-editor/src/hooks/useResolvedColorMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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.
*/

import { useCallback, useSyncExternalStore } from "react";
import { ColorMode, ResolvedColorMode } from "../types/colorMode";

const DARK_MEDIA_QUERY = "(prefers-color-scheme: dark)";

function normalizeColorMode(colorMode: string): ColorMode {
return colorMode === "light" || colorMode === "dark" || colorMode === "system"
? colorMode
: "system";
}

function getMediaQueryList(): MediaQueryList | null {
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
return window.matchMedia(DARK_MEDIA_QUERY);
}
return null;
}

function getSystemColorMode(): ResolvedColorMode {
return getMediaQueryList()?.matches ? "dark" : "light";
}

function getServerColorMode(): ResolvedColorMode {
return "light";
}

function noopUnsubscribe(): void {}

export function useResolvedColorMode(colorMode: ColorMode): ResolvedColorMode {
const normalized = normalizeColorMode(colorMode);

const subscribe = useCallback(
(onStoreChanges: () => 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),
Comment thread
kumaradityaraj marked this conversation as resolved.
);
}
18 changes: 18 additions & 0 deletions packages/text-editor/src/types/colorMode.ts
Original file line number Diff line number Diff line change
@@ -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";
1 change: 1 addition & 0 deletions packages/text-editor/stories/features/TextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const TextEditor = ({ ...props }: TextEditorProps) => {
language={props.language}
onContentChange={props.onContentChange}
isReadOnly={props.isReadOnly}
colorMode={props.colorMode}
/>
</div>
);
Expand Down
3 changes: 3 additions & 0 deletions packages/text-editor/tests/__mocks__/monaco-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
137 changes: 136 additions & 1 deletion packages/text-editor/tests/text-editor/TextEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +27,7 @@ import {
mockModel,
mockSetModelLanguage,
simulateEditorContentChange,
mockSetTheme,
} from "../__mocks__/monaco-editor";
import { TextEditor, type TextEditorProps } from "../../src/TextEditor";

Expand Down Expand Up @@ -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<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
};

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");
});
});
});
Loading