Files
furniture/src/components/Palette.tsx
T
2026-10-17 12:43:25 -04:00

177 lines
5.1 KiB
TypeScript

import { useState, type PointerEvent, type ReactNode } from "react";
import type { FurnitureType, FurnitureTypeConfig } from "../types";
import { ShapeIcon } from "./Icons";
const CLICK_THRESHOLD_PX = 5;
interface Props {
types: FurnitureTypeConfig[]; // in palette order
remaining: Record<FurnitureType, number>;
onStartDrag: (type: FurnitureType, clientX: number, clientY: number) => void;
onOpenMenu: (type: FurnitureType) => void;
onNewCustom: () => void;
panel: ReactNode; // when set, replaces the tiles (type menu, item panel, custom form)
}
const tile: React.CSSProperties = {
width: "100%",
height: 84,
boxSizing: "border-box",
background: "#fff",
border: "1px solid #cfcfcf",
borderRadius: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 6,
cursor: "grab",
userSelect: "none",
touchAction: "none",
font: "12px system-ui, sans-serif",
color: "#444",
};
const MIN_W = 152;
const MAX_W = 600;
interface TileProps {
config: FurnitureTypeConfig;
remaining: number;
onStartDrag: Props["onStartDrag"];
onOpenMenu: Props["onOpenMenu"];
}
/** Press + move > threshold = drag a new item out. Press + release without moving = open menu. */
function Tile({ config, remaining, onStartDrag, onOpenMenu }: TileProps) {
const type = config.id;
const empty = remaining <= 0;
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
e.preventDefault();
const { clientX: x0, clientY: y0 } = e;
let dragging = false;
const move = (ev: globalThis.PointerEvent) => {
if (dragging || empty) return;
if (Math.hypot(ev.clientX - x0, ev.clientY - y0) >= CLICK_THRESHOLD_PX) {
dragging = true;
cleanup();
onStartDrag(type, ev.clientX, ev.clientY);
}
};
const up = (ev: globalThis.PointerEvent) => {
cleanup();
// Out-of-stock tiles can't be dragged, so any release over the tile counts as a click.
if (Math.hypot(ev.clientX - x0, ev.clientY - y0) < CLICK_THRESHOLD_PX || empty) {
onOpenMenu(type);
}
};
const cleanup = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
return (
<div
role="button"
aria-label={`${config.name} (${remaining} remaining)`}
style={{ ...tile, position: "relative", opacity: empty ? 0.45 : 1, filter: empty ? "grayscale(1)" : undefined, cursor: empty ? "pointer" : "grab" }}
onPointerDown={onPointerDown}
>
<span
style={{
position: "absolute",
top: 6,
right: 6,
minWidth: 20,
height: 20,
padding: "0 6px",
boxSizing: "border-box",
borderRadius: 10,
background: empty ? "#888" : "#2b2b2b",
color: "#fff",
font: "600 11px/20px system-ui, sans-serif",
textAlign: "center",
}}
>
{remaining}
</span>
<ShapeIcon shape={config.shape} />
{config.name}
</div>
);
}
function NewCustomTile({ onClick }: { onClick: () => void }) {
return (
<div
role="button"
aria-label="New custom furniture"
onClick={onClick}
style={{ ...tile, border: "1.5px dashed #9a9a9a", background: "transparent", cursor: "pointer" }}
>
<span style={{ font: "300 30px/30px system-ui, sans-serif", color: "#666" }}>+</span>
Custom
</div>
);
}
export function Palette({ types, remaining, onStartDrag, onOpenMenu, onNewCustom, panel }: Props) {
const [width, setWidth] = useState(264);
const startResize = (e: PointerEvent) => {
e.preventDefault();
const startX = e.clientX;
const startW = width;
const move = (ev: globalThis.PointerEvent) =>
setWidth(Math.min(MAX_W, Math.max(MIN_W, startW + ev.clientX - startX)));
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
return (
<aside
style={{
position: "relative",
width,
flexShrink: 0,
borderRight: "1px solid #d4d4d4",
background: "#ececec",
}}
>
<div
style={{
height: "100%",
boxSizing: "border-box",
overflowY: "auto",
padding: 16,
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))",
alignContent: "start",
gap: 12,
}}
>
{panel ?? (
<>
{types.map((cfg) => (
<Tile key={cfg.id} config={cfg} remaining={remaining[cfg.id]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} />
))}
<NewCustomTile onClick={onNewCustom} />
</>
)}
</div>
<div
onPointerDown={startResize}
style={{ position: "absolute", top: 0, right: -3, width: 6, height: "100%", cursor: "col-resize", touchAction: "none" }}
/>
</aside>
);
}