feat(site-builder): Element/Site tabs in the right panel

Refs #36
This commit is contained in:
shadowdao committed 2026-10-07 11:25:28 -07:00
1 parent 2180f9a776
commit e5119174e7
4 files changed
+213 -8

No files matched your search

+16 -1
View File
@@ -30,7 +30,12 @@ import {
Main GuidedStyles component
================================================================ */
export const GuidedStyles: React.FC = () => {
export const GuidedStyles: React.FC<{
/** What to render with nothing selected: the Site Design panel (default,
* used by the mobile sheet) or a placeholder (desktop RightPanel, which
* has a dedicated Site tab). */
emptyFallback?: 'site' | 'placeholder';
}> = ({ emptyFallback = 'site' }) => {
const { open: openSitesmith } = useSitesmithModal();
const { query } = useEditor();
@@ -57,6 +62,16 @@ export const GuidedStyles: React.FC = () => {
});
if (!selected) {
if (emptyFallback === 'placeholder') {
return (
<div className="right-panel-placeholder" data-testid="element-placeholder">
<p>Select something on the canvas to style it.</p>
<p className="right-panel-placeholder-hint">
Looking for site colours, fonts or favicon? Open the Site tab.
</p>
</div>
);
}
return <SiteDesignPanel />;
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, test, expect, beforeEach } from 'vitest';
import React from 'react';
import { renderEditorHarness } from '../../test-utils/editorHarness';
import { SiteDesignProvider } from '../../state/SiteDesignContext';
import { PageProvider } from '../../state/PageContext';
import { SitesmithProvider } from '../../state/SitesmithContext';
import { Heading } from '../../components/basic/Heading';
import { getPref, setPref } from '../../utils/local-prefs';
import { RightPanel } from './RightPanel';
function mount() {
const h = renderEditorHarness();
h.mountChild(
<PageProvider>
<SiteDesignProvider>
<SitesmithProvider>
<RightPanel />
</SitesmithProvider>
</SiteDesignProvider>
</PageProvider>,
);
return h;
}
const tab = (h: ReturnType<typeof mount>, name: string) =>
Array.from(h.container.querySelectorAll('[role="tab"]')).find(
(t) => t.textContent === name,
) as HTMLButtonElement;
const addHeading = (h: ReturnType<typeof mount>): string => {
let id = '';
h.act(() => {
const tree = h.query.parseReactElement(<Heading />).toNodeTree();
h.actions.addNodeTree(tree, 'ROOT');
id = tree.rootNodeId;
});
return id;
};
describe('RightPanel tabs', () => {
beforeEach(() => window.localStorage.clear());
test('nothing selected: Element shows placeholder, Site shows Site Design', () => {
const h = mount();
expect(h.container.querySelectorAll('[role="tablist"] [role="tab"]')).toHaveLength(2);
expect(tab(h, 'Element').getAttribute('aria-selected')).toBe('true');
expect(h.container.querySelector('[data-testid="element-placeholder"]')).not.toBeNull();
expect(h.container.textContent).not.toContain('Primary Color');
h.act(() => tab(h, 'Site').click());
expect(tab(h, 'Site').getAttribute('aria-selected')).toBe('true');
expect(h.container.querySelector('[data-testid="element-placeholder"]')).toBeNull();
expect(h.container.textContent).toContain('Primary');
h.unmount();
});
test('selecting a Heading with Element active shows the Text panel', () => {
const h = mount();
const id = addHeading(h);
h.act(() => h.actions.selectNode(id));
expect(h.container.querySelector('[data-testid="element-placeholder"]')).toBeNull();
expect(h.container.textContent).toContain('Text Color');
expect(h.container.textContent).not.toContain('Primary Color');
h.unmount();
});
test('switching to Site while a node is selected still shows Site Design, and stays on a later selection', () => {
const h = mount();
const id = addHeading(h);
h.act(() => h.actions.selectNode(id));
h.act(() => tab(h, 'Site').click());
expect(h.container.textContent).toContain('Primary');
expect(h.container.textContent).not.toContain('Text Color');
h.act(() => h.actions.selectNode(undefined));
h.act(() => h.actions.selectNode(id));
expect(tab(h, 'Site').getAttribute('aria-selected')).toBe('true');
h.unmount();
});
test('auto-switches Site -> Element on empty->selected when Site came from the saved pref only', () => {
setPref('rightPanelTab', 'site');
const h = mount();
expect(tab(h, 'Site').getAttribute('aria-selected')).toBe('true');
const id = addHeading(h);
h.act(() => h.actions.selectNode(id));
expect(tab(h, 'Element').getAttribute('aria-selected')).toBe('true');
h.unmount();
});
test('deselecting keeps the active tab', () => {
const h = mount();
const id = addHeading(h);
h.act(() => h.actions.selectNode(id));
h.act(() => h.actions.selectNode(undefined));
expect(tab(h, 'Element').getAttribute('aria-selected')).toBe('true');
expect(h.container.querySelector('[data-testid="element-placeholder"]')).not.toBeNull();
h.unmount();
});
test('pref round-trips', () => {
const h = mount();
h.act(() => tab(h, 'Site').click());
expect(getPref('rightPanelTab', 'element')).toBe('site');
h.unmount();
const h2 = mount();
expect(tab(h2, 'Site').getAttribute('aria-selected')).toBe('true');
h2.act(() => tab(h2, 'Element').click());
expect(getPref('rightPanelTab', 'x')).toBe('element');
h2.unmount();
});
});
+66 -7
View File
@@ -1,18 +1,77 @@
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useEditor } from '@craftjs/core';
import { GuidedStyles } from './GuidedStyles';
import { SiteDesignPanel } from './SiteDesignPanel';
import { getPref, setPref } from '../../utils/local-prefs';
type RightTab = 'element' | 'site';
const PREF_KEY = 'rightPanelTab';
/**
* Right panel -- shows component settings when an element is selected,
* or Site Design Tokens (including Head code) when nothing is selected.
* Right panel -- two tabs: "Element" (GuidedStyles for the selection) and
* "Site" (Site Design tokens). Site is always one click away, so reaching
* site colours no longer requires deselecting everything.
*/
export const RightPanel: React.FC = () => {
const { hasSelection } = useEditor((state) => ({
hasSelection: !!state.events.selected && state.events.selected.size > 0,
}));
const [activeTab, setActiveTab] = useState<RightTab>(() =>
getPref<string>(PREF_KEY, 'element') === 'site' ? 'site' : 'element',
);
// True once the user has clicked the Site tab this session; from then on a
// new selection no longer yanks them back to Element.
const pickedSite = useRef(false);
const hadSelection = useRef(hasSelection);
useEffect(() => {
if (hasSelection && !hadSelection.current && !pickedSite.current) {
setActiveTab('element');
}
hadSelection.current = hasSelection;
}, [hasSelection]);
const pick = (tab: RightTab) => {
if (tab === 'site') pickedSite.current = true;
setActiveTab(tab);
setPref(PREF_KEY, tab);
};
return (
<div className="panel-right">
<div className="panel-tabs">
<button className="panel-tab active">Styles</button>
<div className="panel-tabs" role="tablist" aria-label="Right panel">
<button
type="button"
role="tab"
id="right-tab-element"
aria-selected={activeTab === 'element'}
aria-controls="right-tabpanel"
className={`panel-tab${activeTab === 'element' ? ' active' : ''}`}
onClick={() => pick('element')}
>
Element
</button>
<button
type="button"
role="tab"
id="right-tab-site"
aria-selected={activeTab === 'site'}
aria-controls="right-tabpanel"
className={`panel-tab${activeTab === 'site' ? ' active' : ''}`}
onClick={() => pick('site')}
>
Site
</button>
</div>
<div className="panel-content">
<GuidedStyles />
<div
className="panel-content"
role="tabpanel"
id="right-tabpanel"
aria-labelledby={activeTab === 'site' ? 'right-tab-site' : 'right-tab-element'}
>
{activeTab === 'site' ? <SiteDesignPanel /> : <GuidedStyles emptyFallback="placeholder" />}
</div>
</div>
);
+18
View File
@@ -2184,3 +2184,21 @@ body {
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px) + 12px);
}
}
/* #36 right panel tabs */
.right-panel-placeholder {
padding: 24px 12px;
text-align: center;
color: var(--color-text-muted);
font-size: 13px;
line-height: 1.5;
}
.right-panel-placeholder p {
margin: 0 0 8px;
}
.right-panel-placeholder-hint {
font-size: 12px;
opacity: 0.8;
}