1 parent
2180f9a776
commit
e5119174e7
4 files changed
+213
-8
No files matched your search
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user