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 { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import { MobileChromeProvider } from '../state/MobileChromeContext';
|
import { MobileChromeProvider } from '../state/MobileChromeContext';
|
||||||
|
import { LayerFocusProvider } from '../panels/left/LayerFocusContext';
|
||||||
import { DeviceMode } from '../types';
|
import { DeviceMode } from '../types';
|
||||||
|
|
||||||
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
|
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
|
// doesn't render MobilePanelBar and TopBar's desktop branch behaves
|
||||||
// identically to before (same booleans, just sourced from context).
|
// identically to before (same booleans, just sourced from context).
|
||||||
<MobileChromeProvider>
|
<MobileChromeProvider>
|
||||||
|
<LayerFocusProvider>
|
||||||
<div className="editor-app">
|
<div className="editor-app">
|
||||||
<TopBar
|
<TopBar
|
||||||
device={device}
|
device={device}
|
||||||
@@ -107,6 +109,7 @@ export const EditorShell: React.FC = () => {
|
|||||||
onClose={hideMenu}
|
onClose={hideMenu}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</LayerFocusProvider>
|
||||||
</MobileChromeProvider>
|
</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