Major update for v1 and tests
This commit is contained in:
@@ -1,42 +1,329 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ShirtOptionsPanel } from './ShirtOptionsPanel';
|
||||
import { UploadTab } from './UploadTab';
|
||||
import { StickersTab } from './StickersTab';
|
||||
import { EmojiTab } from './EmojiTab';
|
||||
import { TextTab } from './TextTab';
|
||||
import { TemplatesTab } from './TemplatesTab';
|
||||
import { LayersPanel } from '../panels/LayersPanel';
|
||||
import { formatUsd } from '../../constants/shirt';
|
||||
import '../../styles/Sidebar.css';
|
||||
|
||||
// Tab nav for the right rail. Templates are deliberately absent — templates
|
||||
// are loaded exclusively via the `?template=X` URL parameter (see
|
||||
// `readTemplateFromUrl` in App.jsx). The Layers panel used to be a tab here
|
||||
// too; it's now a permanent section rendered beneath the tab card so the
|
||||
// user can browse / reorder / delete elements without losing access to the
|
||||
// upload / stickers / text tools.
|
||||
const TABS = [
|
||||
{ id: 'upload', label: 'Upload', icon: '📁' },
|
||||
{ id: 'stickers', label: 'Stickers', icon: '🎨' },
|
||||
{ id: 'text', label: 'Text', icon: '📝' },
|
||||
{ id: 'templates', label: 'Templates', icon: '📋' },
|
||||
{ id: 'upload', label: 'Upload Photo', icon: UploadIcon },
|
||||
{ id: 'stickers', label: 'Stickers', icon: StickersIcon },
|
||||
{ id: 'emoji', label: 'Emoji', icon: EmojiIcon },
|
||||
{ id: 'text', label: 'Text', icon: TextIcon },
|
||||
];
|
||||
|
||||
export function Sidebar({ onAddImage, onAddSticker, onAddText, onAddTemplate, onSlotImageUpload }) {
|
||||
const [activeTab, setActiveTab] = useState('upload');
|
||||
/**
|
||||
* Right-rail panel.
|
||||
*
|
||||
* Top: Shirt Options card (color/size/price)
|
||||
* Middle: Tab nav + active tab content
|
||||
* Bottom (sticky): Preview-on-Model toggle + Add to Cart button
|
||||
*/
|
||||
export function Sidebar({
|
||||
// Editor wiring
|
||||
onAddImage, onAddSticker, onAddText,
|
||||
recentUploads,
|
||||
onRemoveUpload,
|
||||
// Selection-aware editing surface for the text tab. NOTE:
|
||||
// `selectedElement` is used by the layers panel and the mobile
|
||||
// bottom-sheet compact mode. The Text tab now reads from a
|
||||
// SEPARATE prop (`editingTextElement`) so that *selecting* a text
|
||||
// element on the canvas no longer pulls the Text tab into edit
|
||||
// mode — only an explicit edit gesture (pencil affordance on
|
||||
// desktop, Edit-text toolbar button on mobile) sets that prop.
|
||||
// See the Change 7 follow-up in [[Refinements_2026-05-20_Part2]].
|
||||
selectedElement, editingTextElement, onUpdateSelected, onCommit,
|
||||
// Shirt config
|
||||
shirtColorId, onShirtColorChange,
|
||||
shirtSize, onShirtSizeChange,
|
||||
totalPrice, basePrice,
|
||||
// Cart
|
||||
onAddToCart,
|
||||
cartDisabled = false,
|
||||
cartDisabledReason = null,
|
||||
// Color helpers for the text tab
|
||||
recentColors,
|
||||
onPickColor,
|
||||
// Font helpers for the text tab (recent fonts dropdown)
|
||||
recentFonts,
|
||||
onPickFont,
|
||||
// Layers tab — needs the full elements list and a delete callback. We
|
||||
// already have selectedElement; add elements + onSelectElement +
|
||||
// onDeleteElement so the LayersPanel can show the list and act on it.
|
||||
elements = [],
|
||||
onSelectElement,
|
||||
onDeleteElement,
|
||||
// Layers-panel per-row actions (Change 4). Optional so existing
|
||||
// callers continue to work — the buttons just don't render when
|
||||
// the callback is missing.
|
||||
onDuplicateElement,
|
||||
onUpdateElement,
|
||||
// S3 multi-select wiring. LayersPanel uses these to render the
|
||||
// ✓ chip per row, the bulk-delete affordance, and the drag-reorder
|
||||
// grip. All optional — the panel falls back to single-selection
|
||||
// rendering when they aren't provided.
|
||||
selectedIds,
|
||||
onToggleInSelection,
|
||||
onDeleteMany,
|
||||
onReorderElement,
|
||||
// Controlled active-tab API (Change 7 follow-up). Sidebar used to
|
||||
// own its tab state internally; lifting it to App lets the pencil
|
||||
// affordance / Edit-text gesture set the tab directly (“clicking
|
||||
// the pencil should switch to the Text tab and populate the
|
||||
// form”). Both props are optional so existing call sites that
|
||||
// didn't pass them fall back to internal state — uncontrolled.
|
||||
activeTab: controlledActiveTab,
|
||||
onActiveTabChange,
|
||||
// Desktop focus signal for the Text tab. Threaded straight through
|
||||
// to TextTab — see its docblock for how it's consumed. Optional;
|
||||
// when omitted, TextTab simply never auto-focuses (which is fine
|
||||
// for any caller that doesn't need the pencil-click behaviour).
|
||||
textEditFocusToken,
|
||||
}) {
|
||||
// Controlled or uncontrolled. When the parent supplies `activeTab`
|
||||
// and `onActiveTabChange`, those drive the visible tab; otherwise
|
||||
// we keep our own `useState` and behave as before. Detecting via
|
||||
// `!== undefined` so a deliberate `null` from the parent (= reset
|
||||
// to default) is still respected.
|
||||
const [internalActiveTab, setInternalActiveTab] = useState('upload');
|
||||
const isControlled = controlledActiveTab !== undefined && typeof onActiveTabChange === 'function';
|
||||
const activeTab = isControlled ? controlledActiveTab : internalActiveTab;
|
||||
const setActiveTab = isControlled ? onActiveTabChange : setInternalActiveTab;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Pencil-click focus plumbing (Change 7 follow-up).
|
||||
//
|
||||
// The textarea-focus logic LIVES HERE — in Sidebar — rather than
|
||||
// inside TextTab, on purpose. TextTab is mounted/unmounted as the
|
||||
// user navigates between tabs; pressing the pencil flips the tab
|
||||
// to 'text' AND bumps the focus token in the same React batch,
|
||||
// which means TextTab is mounting FRESH at the moment the token
|
||||
// first changes. Any `useRef(focusToken)` initializer inside
|
||||
// TextTab would capture the new value at mount and the
|
||||
// "transition detection" effect would immediately bail out
|
||||
// (`1 === 1`) — the user has to click the pencil a second time
|
||||
// before TextTab's ref carries an old value.
|
||||
//
|
||||
// Sidebar, by contrast, is persistently mounted on desktop
|
||||
// (App.jsx wraps it in an <aside> that lives for the lifetime of
|
||||
// the editor). Its refs survive every tab switch. The ref
|
||||
// initializer captures the very-first token value (0) at app
|
||||
// boot, before any pencil click can have happened — so the next
|
||||
// bump is correctly detected as a transition.
|
||||
//
|
||||
// textareaRef is forwarded down to TextTab via the `textareaRef`
|
||||
// prop. TextTab attaches it to its <textarea>. When this effect
|
||||
// fires, the next-frame callback runs after React has mounted
|
||||
// TextTab and committed its ref — so textareaRef.current is
|
||||
// populated by the time we call .focus().
|
||||
// ---------------------------------------------------------------
|
||||
const textareaRef = useRef(null);
|
||||
const lastFocusTokenRef = useRef(textEditFocusToken ?? 0);
|
||||
useEffect(() => {
|
||||
if (textEditFocusToken == null) return;
|
||||
if (textEditFocusToken === lastFocusTokenRef.current) return;
|
||||
lastFocusTokenRef.current = textEditFocusToken;
|
||||
// requestAnimationFrame defers past the current commit — if the
|
||||
// same pencil click that bumped this token ALSO switched
|
||||
// activeTab to 'text', TextTab is mounting on the same render
|
||||
// and its textarea ref isn't attached yet at the moment React
|
||||
// fires this effect. Waiting one frame guarantees the textarea
|
||||
// is in the DOM and the ref is set.
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(rafId);
|
||||
}, [textEditFocusToken]);
|
||||
|
||||
// The Text tab's edit mode is gated on `editingTextElement` (NOT
|
||||
// `selectedElement`) so it only activates when the user has
|
||||
// explicitly entered text-editing mode via the pencil affordance
|
||||
// (desktop) or the Edit-text toolbar button (mobile). Selection
|
||||
// alone is no longer enough — see the Change 7 follow-up in
|
||||
// [[Refinements_2026-05-20_Part2]] for the rationale.
|
||||
const selectedTextElement = editingTextElement?.type === 'text' ? editingTextElement : null;
|
||||
|
||||
// NOTE: the auto-switch-to-Text-tab effect that used to live here
|
||||
// moved up to App.jsx — the pencil/Edit-text handler now sets the
|
||||
// active tab directly via the controlled API. The behaviour is
|
||||
// identical from the user's perspective (Text tab pops to the
|
||||
// front on edit gesture, stays put when selection alone changes)
|
||||
// but the routing is one place instead of two. See Change 7
|
||||
// follow-up in [[Refinements_2026-05-20_Part2]].
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'upload': return <UploadTab onAddImage={onAddImage} />;
|
||||
case 'upload': return <UploadTab onAddImage={onAddImage} recentUploads={recentUploads} onRemoveUpload={onRemoveUpload} />;
|
||||
case 'stickers': return <StickersTab onAddSticker={onAddSticker} />;
|
||||
case 'text': return <TextTab onAddText={onAddText} />;
|
||||
case 'templates': return <TemplatesTab onAddTemplate={onAddTemplate} onSlotImageUpload={onSlotImageUpload} />;
|
||||
case 'emoji': return <EmojiTab onAddSticker={onAddSticker} />;
|
||||
case 'text': return (
|
||||
<TextTab
|
||||
onAddText={onAddText}
|
||||
selectedTextElement={selectedTextElement}
|
||||
onUpdateSelected={onUpdateSelected}
|
||||
onCommit={onCommit}
|
||||
recentColors={recentColors}
|
||||
onPickColor={onPickColor}
|
||||
recentFonts={recentFonts}
|
||||
onPickFont={onPickFont}
|
||||
textareaRef={textareaRef}
|
||||
/* Bug 3 fix — forward the full canvas elements list so the
|
||||
* TextTab can derive sensible draft defaults from any text
|
||||
* already on the canvas. The tab filters to text-typed
|
||||
* elements and checks for a shared style; when found, the
|
||||
* draft inherits that style's font / fill / outline. */
|
||||
elements={elements}
|
||||
/>
|
||||
);
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-tabs">
|
||||
{TABS.map((tab) => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className={`sidebar-tab-btn${activeTab === tab.id ? ' active' : ''}`}>
|
||||
<div className="sidebar-tab-icon">{tab.icon}</div>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<aside className="rp" aria-label="Customization options">
|
||||
<div className="rp__scroll">
|
||||
<ShirtOptionsPanel
|
||||
selectedColorId={shirtColorId}
|
||||
onColorChange={onShirtColorChange}
|
||||
selectedSize={shirtSize}
|
||||
onSizeChange={onShirtSizeChange}
|
||||
price={basePrice}
|
||||
/>
|
||||
|
||||
<section className="rp__tools" aria-labelledby="rp-tools-heading">
|
||||
<h2 id="rp-tools-heading" className="visually-hidden">Design tools</h2>
|
||||
|
||||
<div className="rp__tabs" role="tablist" aria-label="Design tools">
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`tab-panel-${tab.id}`}
|
||||
id={`tab-${tab.id}`}
|
||||
className={`rp__tab${isActive ? ' is-active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`tab-panel-${activeTab}`}
|
||||
aria-labelledby={`tab-${activeTab}`}
|
||||
className="rp__tabpanel"
|
||||
>
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Layers section — permanent surface beneath the tools card. The
|
||||
LayersPanel renders its own visible "Layers (N)" titlebar and
|
||||
handles the empty-state message internally, so this section
|
||||
just provides a matching card chrome and a screen-reader
|
||||
heading. */}
|
||||
<section className="rp__layers" aria-labelledby="rp-layers-heading">
|
||||
<h2 id="rp-layers-heading" className="visually-hidden">Layers</h2>
|
||||
<LayersPanel
|
||||
elements={elements}
|
||||
selectedId={selectedElement?.id ?? null}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={onSelectElement}
|
||||
onToggleInSelection={onToggleInSelection}
|
||||
onDelete={onDeleteElement}
|
||||
onDeleteMany={onDeleteMany}
|
||||
onReorder={onReorderElement}
|
||||
onDuplicate={onDuplicateElement}
|
||||
onUpdate={onUpdateElement}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<div className="sidebar-content">{renderTabContent()}</div>
|
||||
</div>
|
||||
|
||||
<div className="rp__cart-bar">
|
||||
{/* Reason text for the disabled cart, when present. Rendered as a
|
||||
sibling of the button rather than a tooltip because (a) the
|
||||
primary surface for the warning is already the canvas-frame
|
||||
chip, this is a follow-up affordance for the user who has
|
||||
scrolled to the cart, and (b) tooltips are unreliable on
|
||||
touch. The button's `disabled` and `aria-disabled` attributes
|
||||
give assistive tech the actionable signal. */}
|
||||
{cartDisabled && cartDisabledReason && (
|
||||
<p className="rp__cart-reason" role="status">
|
||||
{cartDisabledReason}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`rp__add-to-cart${cartDisabled ? ' is-disabled' : ''}`}
|
||||
onClick={onAddToCart}
|
||||
disabled={cartDisabled}
|
||||
aria-disabled={cartDisabled}
|
||||
title={cartDisabled ? cartDisabledReason || undefined : undefined}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 21s-7-4.5-9.3-9A5.4 5.4 0 0 1 12 5.5 5.4 5.4 0 0 1 21.3 12C19 16.5 12 21 12 21z" />
|
||||
</svg>
|
||||
<span>Add to Cart</span>
|
||||
<span className="rp__add-to-cart-price">{formatUsd(totalPrice)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Inline icon set — kept here so each tab definition stays declarative ─── */
|
||||
|
||||
function UploadIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<path d="M17 8l-5-5-5 5" />
|
||||
<path d="M12 3v12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function StickersIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.5 2.5a8 8 0 1 1-12 12L14.5 2.5z" />
|
||||
<path d="M14.5 2.5L21.5 9.5a8 8 0 0 1-7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function EmojiIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
||||
<path d="M9 9h.01" />
|
||||
<path d="M15 9h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function TextIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 7V5h16v2" />
|
||||
<path d="M9 5v14" />
|
||||
<path d="M15 5v14" />
|
||||
<path d="M7 19h4" />
|
||||
<path d="M13 19h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user