Replaces the plain single-line input (HtmlBlock's `code` prop, via
GenericPropsEditor) and the plain <textarea> (HeadCodeModal's site-wide
head code) with a proper syntax-highlighted, tab-completing code editor.
- New src/ui/CodeEditor.tsx: reusable CodeMirror 6 editor (state/view/
commands/autocomplete/language/lang-html/lang-css/lang-javascript/
theme-one-dark). All @codemirror/* packages are pulled in via a single
dynamic import() inside the component so they land in separate lazy
chunks instead of the main bundle -- confirmed via `npm run build`:
main editor.js grew by only ~5.5KB (657KB -> 663KB raw) while ~570KB of
CodeMirror source split into index*.js chunks that only load when a
code editor modal is actually opened. While that import is in flight,
or if it ever fails, the component renders a plain <textarea> so typing
never breaks.
- GenericPropsEditor.tsx: special-cases the `code` prop (used only by
HtmlBlock today) into an "Edit HTML" button that opens the CodeEditor
in a modal (language="html"), instead of rendering it as a single-line
text input alongside the component's other string props.
- HeadCodeModal.tsx: swaps its <textarea> for CodeEditor (language="html"
-- head code is HTML with embedded <script>/<style>), keeping the
existing SiteDesignContext.updateDesign({ headCode }) wiring.
GuidedStyles.tsx untouched: HtmlBlock's displayName ("HTML") already
matches GuidedStyles' `isUtility` regex and routes to GenericPropsEditor,
so no dispatcher change was needed.
HtmlBlock.tsx untouched: purifyHtml/toHtml sanitization is unchanged, as
specified. Skipped the optional AnimationControl/VisibilityControl
addition -- HtmlBlock has no dedicated StylePanel (it shares
GenericPropsEditor with Divider/Spacer/every unmatched type), and doing
it well would mean adding real Animation/Visibility widgets to that
shared editor for every consumer, which is a bigger change than "trivial"
for this package's scope.
Tests: CodeEditor.test.tsx and HeadCodeModal.test.tsx both exploit the
fact that dynamic import() always resolves on a later microtask, so
asserting against the DOM immediately after the initial synchronous
render deterministically exercises the <textarea> fallback path (value
display, onChange wiring, language prop plumbing) without needing to
mock @codemirror/*.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
86 lines
3.0 KiB
TypeScript
86 lines
3.0 KiB
TypeScript
import { describe, test, expect, vi, afterEach } from 'vitest';
|
|
import React from 'react';
|
|
import { createRoot, Root } from 'react-dom/client';
|
|
import { act } from 'react-dom/test-utils';
|
|
import { HeadCodeModal } from './HeadCodeModal';
|
|
import { SiteDesignProvider, useSiteDesign } from '../../state/SiteDesignContext';
|
|
|
|
/* ---------- DOM test harness -- same react-dom/client + `act` pattern used
|
|
throughout src/ui/*.test.tsx (no @testing-library/react in this repo). ---------- */
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
function render(ui: React.ReactElement) {
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
act(() => {
|
|
root = createRoot(container);
|
|
root.render(ui);
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
if (container) {
|
|
act(() => { root.unmount(); });
|
|
container.remove();
|
|
}
|
|
document.body.style.overflow = '';
|
|
});
|
|
|
|
// Exposes the current headCode so assertions can read it back after a
|
|
// simulated edit -- CodeEditor writes through `updateDesign`, this just
|
|
// surfaces the result.
|
|
function Harness({ onReady }: { onReady: (headCode: string) => void }) {
|
|
const { design } = useSiteDesign();
|
|
onReady(design.headCode);
|
|
return null;
|
|
}
|
|
|
|
describe('HeadCodeModal', () => {
|
|
// HeadCodeModal portals its content to document.body (see the comment in
|
|
// HeadCodeModal.tsx), so the rendered DOM lives outside `container` --
|
|
// query document.body instead.
|
|
|
|
test('renders the CodeEditor (fallback textarea path) seeded with the current headCode', () => {
|
|
render(
|
|
<SiteDesignProvider>
|
|
<HeadCodeModal open onClose={vi.fn()} />
|
|
</SiteDesignProvider>,
|
|
);
|
|
// CodeMirror loads via async dynamic import (see CodeEditor.test.tsx);
|
|
// synchronously after mount the fallback textarea is what's live.
|
|
const textarea = document.body.querySelector<HTMLTextAreaElement>('[data-testid="code-editor-fallback"]');
|
|
expect(textarea).not.toBeNull();
|
|
expect(textarea!.value).toBe('');
|
|
expect(textarea!.dataset.language).toBe('html');
|
|
});
|
|
|
|
test('typing in the editor writes through to SiteDesignContext.headCode', () => {
|
|
let latestHeadCode = '';
|
|
render(
|
|
<SiteDesignProvider>
|
|
<HeadCodeModal open onClose={vi.fn()} />
|
|
<Harness onReady={(v) => { latestHeadCode = v; }} />
|
|
</SiteDesignProvider>,
|
|
);
|
|
|
|
const textarea = document.body.querySelector<HTMLTextAreaElement>('[data-testid="code-editor-fallback"]')!;
|
|
act(() => {
|
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')!.set!;
|
|
setter.call(textarea, '<meta name="x" content="y">');
|
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
});
|
|
|
|
expect(latestHeadCode).toBe('<meta name="x" content="y">');
|
|
});
|
|
|
|
test('does not render when closed', () => {
|
|
render(
|
|
<SiteDesignProvider>
|
|
<HeadCodeModal open={false} onClose={vi.fn()} />
|
|
</SiteDesignProvider>,
|
|
);
|
|
expect(document.body.querySelector('[data-testid="code-editor-fallback"]')).toBeNull();
|
|
});
|
|
});
|