Files
apparel-designer/src/components/sidebar/Sidebar.jsx
2026-05-23 03:28:58 -05:00

330 lines
14 KiB
JavaScript

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 { 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 Photo', icon: UploadIcon },
{ id: 'stickers', label: 'Stickers', icon: StickersIcon },
{ id: 'emoji', label: 'Emoji', icon: EmojiIcon },
{ id: 'text', label: 'Text', icon: TextIcon },
];
/**
* 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} recentUploads={recentUploads} onRemoveUpload={onRemoveUpload} />;
case 'stickers': return <StickersTab onAddSticker={onAddSticker} />;
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 (
<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="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>
);
}