feat(site-builder): add LayerFocusContext for layers-to-array-editor focus
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { useContextMenu } from '../hooks/useContextMenu';
|
||||
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { MobileChromeProvider } from '../state/MobileChromeContext';
|
||||
import { LayerFocusProvider } from '../panels/left/LayerFocusContext';
|
||||
import { DeviceMode } from '../types';
|
||||
|
||||
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
|
||||
@@ -83,6 +84,7 @@ export const EditorShell: React.FC = () => {
|
||||
// doesn't render MobilePanelBar and TopBar's desktop branch behaves
|
||||
// identically to before (same booleans, just sourced from context).
|
||||
<MobileChromeProvider>
|
||||
<LayerFocusProvider>
|
||||
<div className="editor-app">
|
||||
<TopBar
|
||||
device={device}
|
||||
@@ -107,6 +109,7 @@ export const EditorShell: React.FC = () => {
|
||||
onClose={hideMenu}
|
||||
/>
|
||||
</div>
|
||||
</LayerFocusProvider>
|
||||
</MobileChromeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { LayerFocusProvider, useLayerFocus } from './LayerFocusContext';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const Probe: React.FC = () => {
|
||||
const { focus, requestFocus } = useLayerFocus();
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => requestFocus('n1', 'features', 2)}>request</button>
|
||||
<span data-testid="state">{focus ? `${focus.nodeId}:${focus.prop}:${focus.index}:${focus.nonce}` : 'none'}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function render() {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(<LayerFocusProvider><Probe /></LayerFocusProvider>);
|
||||
});
|
||||
}
|
||||
|
||||
describe('LayerFocusContext', () => {
|
||||
test('starts with no focus request', () => {
|
||||
render();
|
||||
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('none');
|
||||
});
|
||||
|
||||
test('requestFocus publishes the target', () => {
|
||||
render();
|
||||
act(() => { (container.querySelector('button') as HTMLButtonElement).click(); });
|
||||
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('n1:features:2:1');
|
||||
});
|
||||
|
||||
test('repeating the same request bumps the nonce so consumers re-fire', () => {
|
||||
render();
|
||||
const btn = container.querySelector('button') as HTMLButtonElement;
|
||||
act(() => { btn.click(); });
|
||||
act(() => { btn.click(); });
|
||||
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('n1:features:2:2');
|
||||
});
|
||||
|
||||
test('useLayerFocus outside a provider is inert rather than a crash', () => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
expect(() => {
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(<Probe />);
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Carries "the user clicked a virtual Layers row -- scroll that array item's
|
||||
* card into view" from the Layers panel (left) to the array editors (right).
|
||||
*
|
||||
* Deliberately a request, not a command: consumers that don't implement
|
||||
* scrolling simply ignore it. Selecting the parent node always happens in
|
||||
* LayersPanel itself, so a click is useful even with no consumer at all.
|
||||
*/
|
||||
|
||||
export interface LayerFocusRequest {
|
||||
nodeId: string;
|
||||
prop: string;
|
||||
index: number;
|
||||
/** Bumped on every request so an identical repeat still re-fires effects. */
|
||||
nonce: number;
|
||||
}
|
||||
|
||||
export interface LayerFocusValue {
|
||||
focus: LayerFocusRequest | null;
|
||||
requestFocus(nodeId: string, prop: string, index: number): void;
|
||||
}
|
||||
|
||||
const LayerFocusContext = createContext<LayerFocusValue>({
|
||||
focus: null,
|
||||
requestFocus: () => {},
|
||||
});
|
||||
|
||||
export const useLayerFocus = (): LayerFocusValue => useContext(LayerFocusContext);
|
||||
|
||||
export const LayerFocusProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [focus, setFocus] = useState<LayerFocusRequest | null>(null);
|
||||
const nonceRef = useRef(0);
|
||||
|
||||
const requestFocus = useCallback((nodeId: string, prop: string, index: number) => {
|
||||
nonceRef.current += 1;
|
||||
setFocus({ nodeId, prop, index, nonce: nonceRef.current });
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ focus, requestFocus }), [focus, requestFocus]);
|
||||
|
||||
return <LayerFocusContext.Provider value={value}>{children}</LayerFocusContext.Provider>;
|
||||
};
|
||||
Reference in New Issue
Block a user