- utils/breakpoints.ts: single source for 767/768/991/992; head VISIBILITY/RESPONSIVE CSS built from it (flex-stacking rule moved from 768 to the 767 mobile boundary). - utils/responsive-style.ts: styleTablet/styleMobile partial overrides, cascade resolution, pure setters, scoped @media CSS per node. - state/DeviceContext.tsx: real DeviceProvider (owns the one-time mobile forcing from EditorShell); TopBar, overflow menu, ReportIssueModal and Canvas read useDevice() instead of props. - editor/RenderNode.tsx: resolves the effective style for the current device in one place and marks overridden nodes with data-wsb-overridden. Craft passes an opaque <DefaultRender/> to onRender, so the component element is rebuilt with the resolved style (cloneElement cannot work). - utils/html-export.ts: hidden nodes skipped; overrides emitted as one body-level <style data-wsb-responsive> block + data-wsb-r scope attr; injectAttrs skips leading <style> elements. - test-utils/editorHarness.tsx: optional onRender/wrap options. Refs #45 Refs #37 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
78 lines
2.4 KiB
TypeScript
78 lines
2.4 KiB
TypeScript
import { describe, test, expect, afterEach, vi } from 'vitest';
|
|
import React from 'react';
|
|
import { createRoot, Root } from 'react-dom/client';
|
|
import { act } from 'react-dom/test-utils';
|
|
import { DeviceProvider, useDevice } from './DeviceContext';
|
|
import type { DeviceMode } from '../types';
|
|
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
let api: ReturnType<typeof useDevice>;
|
|
|
|
const Probe: React.FC = () => {
|
|
api = useDevice();
|
|
return <span data-testid="device">{api.device}</span>;
|
|
};
|
|
|
|
function mount(ui: React.ReactElement) {
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
act(() => {
|
|
root = createRoot(container);
|
|
root.render(ui);
|
|
});
|
|
}
|
|
|
|
const shown = () => container.querySelector('[data-testid="device"]')!.textContent;
|
|
|
|
function stubMatchMedia(matches: boolean) {
|
|
vi.stubGlobal('matchMedia', (q: string) => ({
|
|
matches, media: q, onchange: null,
|
|
addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent: () => false,
|
|
}));
|
|
}
|
|
|
|
afterEach(() => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('DeviceContext', () => {
|
|
test('without a provider: desktop + a no-op setter', () => {
|
|
mount(<Probe />);
|
|
expect(shown()).toBe('desktop');
|
|
act(() => api.setDevice('mobile'));
|
|
expect(shown()).toBe('desktop');
|
|
});
|
|
|
|
test('provider defaults to desktop on a desktop viewport', () => {
|
|
stubMatchMedia(false);
|
|
mount(<DeviceProvider><Probe /></DeviceProvider>);
|
|
expect(shown()).toBe('desktop');
|
|
});
|
|
|
|
test('setDevice propagates to every consumer', () => {
|
|
stubMatchMedia(false);
|
|
const Other: React.FC = () => <i data-testid="other">{useDevice().device}</i>;
|
|
mount(<DeviceProvider><Probe /><Other /></DeviceProvider>);
|
|
act(() => api.setDevice('tablet'));
|
|
expect(shown()).toBe('tablet');
|
|
expect(container.querySelector('[data-testid="other"]')!.textContent).toBe('tablet');
|
|
});
|
|
|
|
test('a mobile viewport forces the mobile canvas once, then respects the user choice', () => {
|
|
stubMatchMedia(true);
|
|
mount(<DeviceProvider><Probe /></DeviceProvider>);
|
|
expect(shown()).toBe('mobile');
|
|
act(() => api.setDevice('desktop'));
|
|
expect(shown()).toBe('desktop');
|
|
});
|
|
|
|
test('initialDevice seeds the state', () => {
|
|
stubMatchMedia(false);
|
|
mount(<DeviceProvider initialDevice={'tablet' as DeviceMode}><Probe /></DeviceProvider>);
|
|
expect(shown()).toBe('tablet');
|
|
});
|
|
});
|