Skip to content
Draft
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
52 changes: 52 additions & 0 deletions packages/ratio-ui-codeeditor/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@eventuras/ratio-ui-codeeditor",
"version": "0.0.0",
"description": "Alpha spike — CodeMirror 6 code editor with ratio-ui styling",
"license": "MPL-2.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./CodeEditor": {
"types": "./dist/CodeEditor/index.d.ts",
"import": "./dist/CodeEditor/index.js"
}
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc && vite build",
"lint": "eslint ."
},
"dependencies": {
"@codemirror/lang-javascript": "^6",
"@codemirror/lang-json": "^6",
"@codemirror/lang-xml": "^6",
"@codemirror/language": "^6",
"@codemirror/lint": "^6",
"@codemirror/state": "^6",
"@codemirror/view": "^6",
"@lezer/highlight": "^1",
"codemirror": "^6"
},
"devDependencies": {
"@ratio-ui/eslint-config": "workspace:*",
"@ratio-ui/typescript-config": "workspace:*",
"@ratio-ui/vite-config": "workspace:*",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"typescript": "6.0.3",
"vite": "8.1.3"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
137 changes: 137 additions & 0 deletions packages/ratio-ui-codeeditor/src/CodeEditor/CodeEditor.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// ratio-ui · design system for knowledge sharing
// SPDX-FileCopyrightText: 2026 Losol AS
// SPDX-License-Identifier: MPL-2.0

import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { CodeEditor, type EditorDiagnostic } from './CodeEditor';

/**
* A CodeMirror 6 editor wearing ratio-ui — spike for the FHIR-resource editor:
* write a JSON/XML resource, upload it, and see the server's `OperationOutcome`
* as **inline diagnostics** (squiggles + gutter marks). Colors follow the app's
* light/dark mode; toggle it to compare.
*/
const meta = {
title: 'Code/CodeEditor',
component: CodeEditor,
parameters: { layout: 'padded' },
tags: ['autodocs'],
args: { value: '' },
} satisfies Meta<typeof CodeEditor>;

export default meta;
type Story = StoryObj<typeof meta>;

const FHIR_PATIENT_JSON = `{
"resourceType": "Patient",
"id": "example",
"active": true,
"name": [
{
"use": "official",
"family": "Chalmers",
"given": ["Peter", "James"]
}
],
"gender": "unknown-value",
"birthDate": "1974-12-25"
}`;

// As if mapped from a FHIR OperationOutcome (line numbers → editor positions).
const FHIR_JSON_ISSUES: EditorDiagnostic[] = [
{
line: 12,
severity: 'error',
message:
'Patient.gender: "unknown-value" is not valid — required binding administrative-gender (male | female | other | unknown).',
},
{
line: 5,
severity: 'warning',
message: 'Patient.name: consider adding a text representation (name.text) for display.',
},
];

const FHIR_PATIENT_XML = `<Patient xmlns="http://hl7.org/fhir">
<id value="example"/>
<active value="true"/>
<name>
<use value="official"/>
<family value="Chalmers"/>
<given value="Peter"/>
</name>
<gender value="unknown-value"/>
<birthDate value="1974-12-25"/>
</Patient>`;

const FHIR_XML_ISSUES: EditorDiagnostic[] = [
{
line: 9,
severity: 'error',
message: 'Patient.gender: "unknown-value" is not in the required value set (administrative-gender).',
},
];

const TS_SAMPLE = `import type { EditorDiagnostic } from '@eventuras/ratio-ui-codeeditor';

// Map a FHIR OperationOutcome issue to an inline editor diagnostic.
export function toDiagnostic(issue: {
severity: 'error' | 'warning' | 'information';
diagnostics?: string;
line: number;
}): EditorDiagnostic {
return {
line: issue.line,
severity: issue.severity === 'information' ? 'info' : issue.severity,
message: issue.diagnostics ?? 'Validation issue',
};
}`;

/** FHIR Patient (JSON) with a validation error + a warning shown inline. */
export const FhirJson: Story = {
render: function FhirJsonStory() {
const [value, setValue] = useState(FHIR_PATIENT_JSON);
return (
<div style={{ maxWidth: 760 }}>
<CodeEditor
value={value}
onChange={setValue}
language="json"
diagnostics={FHIR_JSON_ISSUES}
aria-label="FHIR Patient resource (JSON)"
/>
</div>
);
},
};

/** The same resource as FHIR XML, with an inline validation error. */
export const FhirXml: Story = {
render: function FhirXmlStory() {
const [value, setValue] = useState(FHIR_PATIENT_XML);
return (
<div style={{ maxWidth: 760 }}>
<CodeEditor
value={value}
onChange={setValue}
language="xml"
diagnostics={FHIR_XML_ISSUES}
aria-label="FHIR Patient resource (XML)"
/>
</div>
);
},
};

/** TypeScript — showing js/ts highlighting in the same ratio-ui theme. */
export const TypeScript: Story = {
render: function TypeScriptStory() {
const [value, setValue] = useState(TS_SAMPLE);
return (
<div style={{ maxWidth: 760 }}>
<CodeEditor value={value} onChange={setValue} language="ts" aria-label="TypeScript example" />
</div>
);
},
};
155 changes: 155 additions & 0 deletions packages/ratio-ui-codeeditor/src/CodeEditor/CodeEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// ratio-ui · design system for knowledge sharing
// SPDX-FileCopyrightText: 2026 Losol AS
// SPDX-License-Identifier: MPL-2.0

'use client';

import React, { useEffect, useRef } from 'react';
import { basicSetup } from 'codemirror';
import { EditorView } from '@codemirror/view';
import { EditorState, Compartment, type Extension } from '@codemirror/state';
import { json } from '@codemirror/lang-json';
import { xml } from '@codemirror/lang-xml';
import { javascript } from '@codemirror/lang-javascript';
import { lintGutter, setDiagnostics, type Diagnostic } from '@codemirror/lint';
import { ratioChrome, highlightExtension, isDarkMode } from './theme';

export type CodeEditorLanguage = 'js' | 'ts' | 'json' | 'xml';

/** A diagnostic to render inline — e.g. mapped from a FHIR `OperationOutcome`. */
export interface EditorDiagnostic {
/** 1-based line number the issue points at. */
line: number;
message: string;
severity?: 'error' | 'warning' | 'info';
}

export interface CodeEditorProps {
value: string;
onChange?: (value: string) => void;
/** @default 'json' */
language?: CodeEditorLanguage;
/** Externally-provided diagnostics rendered as inline squiggles + gutter marks. */
diagnostics?: EditorDiagnostic[];
readOnly?: boolean;
className?: string;
'aria-label'?: string;
}

function languageExtension(language: CodeEditorLanguage): Extension {
switch (language) {
case 'json':
return json();
case 'xml':
return xml();
case 'ts':
return javascript({ typescript: true });
default:
return javascript();
}
}

function toCmDiagnostics(state: EditorState, diagnostics: EditorDiagnostic[]): Diagnostic[] {
const { doc } = state;
return diagnostics
.filter((d) => d.line >= 1 && d.line <= doc.lines)
.map((d) => {
const line = doc.line(d.line);
return { from: line.from, to: line.to, severity: d.severity ?? 'error', message: d.message };
});
}

/**
* A CodeMirror 6 editor dressed in ratio-ui: chrome from the `--code-*` tokens,
* github light/dark syntax colors that follow the app's mode, JSON/XML/JS/TS
* languages, and inline `diagnostics` (squiggles + gutter). Controlled via
* `value` / `onChange`.
*/
Comment on lines +62 to +67
export function CodeEditor({
value,
onChange,
language = 'json',
diagnostics,
readOnly = false,
className,
'aria-label': ariaLabel,
}: Readonly<CodeEditorProps>) {
const host = useRef<HTMLDivElement>(null);
const view = useRef<EditorView | null>(null);
const language_ = useRef(new Compartment());
const readOnly_ = useRef(new Compartment());
const highlight_ = useRef(new Compartment());
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;

// Create the editor once.
useEffect(() => {
if (!host.current) return;
const editor = new EditorView({
parent: host.current,
state: EditorState.create({
doc: value,
extensions: [
basicSetup,
language_.current.of(languageExtension(language)),
readOnly_.current.of(EditorState.readOnly.of(readOnly)),
lintGutter(),
ratioChrome,
highlight_.current.of(highlightExtension(isDarkMode())),
EditorView.updateListener.of((u) => {
if (u.docChanged) onChangeRef.current?.(u.state.doc.toString());
}),
],
}),
});
editor.dom.classList.add('ratio-cm');
if (ariaLabel) editor.contentDOM.setAttribute('aria-label', ariaLabel);
view.current = editor;

// Follow the app's light/dark mode: re-highlight when data-theme flips.
let observer: MutationObserver | undefined;
if (typeof MutationObserver !== 'undefined') {
observer = new MutationObserver(() => {
editor.dispatch({ effects: highlight_.current.reconfigure(highlightExtension(isDarkMode())) });
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'data-color-scheme'],
});
}

return () => {
observer?.disconnect();
editor.destroy();
view.current = null;
};
// Created once; prop changes are synced by the effects below.
}, []);

// NOTE (spike): `value` is the initial document only — the editor is
// uncontrolled after mount. This isolates whether the controlled value-sync
// was what wiped highlighting on edit. A robust external-value sync comes
// later (or remount via a `key`).

// Reconfigure language / read-only.
useEffect(() => {
view.current?.dispatch({ effects: language_.current.reconfigure(languageExtension(language)) });
}, [language]);
useEffect(() => {
view.current?.dispatch({
effects: readOnly_.current.reconfigure(EditorState.readOnly.of(readOnly)),
});
}, [readOnly]);

// Push externally-provided diagnostics.
useEffect(() => {
const v = view.current;
if (!v) return;
v.dispatch(setDiagnostics(v.state, toCmDiagnostics(v.state, diagnostics ?? [])));
}, [diagnostics]);

return <div ref={host} className={className} />;
}

CodeEditor.displayName = 'CodeEditor';
export default CodeEditor;
10 changes: 10 additions & 0 deletions packages/ratio-ui-codeeditor/src/CodeEditor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// ratio-ui · design system for knowledge sharing
// SPDX-FileCopyrightText: 2026 Losol AS
// SPDX-License-Identifier: MPL-2.0

export {
CodeEditor,
type CodeEditorProps,
type CodeEditorLanguage,
type EditorDiagnostic,
} from './CodeEditor';
Loading
Loading