diff --git a/craft/src/panels/right/GuidedStyles.tsx b/craft/src/panels/right/GuidedStyles.tsx index 7a09988..b0087b9 100644 --- a/craft/src/panels/right/GuidedStyles.tsx +++ b/craft/src/panels/right/GuidedStyles.tsx @@ -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 ( +
+

Select something on the canvas to style it.

+

+ Looking for site colours, fonts or favicon? Open the Site tab. +

+
+ ); + } return ; } diff --git a/craft/src/panels/right/RightPanel.test.tsx b/craft/src/panels/right/RightPanel.test.tsx new file mode 100644 index 0000000..b4a0b6c --- /dev/null +++ b/craft/src/panels/right/RightPanel.test.tsx @@ -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( + + + + + + + , + ); + return h; +} + +const tab = (h: ReturnType, name: string) => + Array.from(h.container.querySelectorAll('[role="tab"]')).find( + (t) => t.textContent === name, + ) as HTMLButtonElement; + +const addHeading = (h: ReturnType): string => { + let id = ''; + h.act(() => { + const tree = h.query.parseReactElement().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(); + }); +}); diff --git a/craft/src/panels/right/RightPanel.tsx b/craft/src/panels/right/RightPanel.tsx index 6c3216a..973dfbe 100644 --- a/craft/src/panels/right/RightPanel.tsx +++ b/craft/src/panels/right/RightPanel.tsx @@ -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(() => + getPref(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 (
-
- +
+ +
-
- +
+ {activeTab === 'site' ? : }
); diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index adf849c..4c44d3c 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -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; +}