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

164 lines
4.9 KiB
TypeScript

import { useState, type PointerEvent, type ReactNode } from "react";
import type { FurnitureType } from "../types";
const CLICK_THRESHOLD_PX = 5;
interface Props {
remaining: Record<FurnitureType, number>;
onStartDrag: (type: FurnitureType, clientX: number, clientY: number) => void;
onOpenMenu: (type: FurnitureType) => void;
panel: ReactNode; // when set, replaces the tiles (type settings menu)
}
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",
};
export function ChairIcon({ size = 36 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 36 36">
<rect x="4" y="4" width="28" height="28" rx="3" fill="#8fb4d9" stroke="#3b3b3b" strokeWidth="1.5" />
<rect x="4" y="4" width="28" height="8" rx="3" fill="#4a6f96" />
</svg>
);
}
export function TableIcon({ size = 36 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 36 36">
<rect x="2" y="9" width="32" height="18" rx="3" fill="#d9b382" stroke="#3b3b3b" strokeWidth="1.5" />
</svg>
);
}
const MIN_W = 152;
const MAX_W = 600;
interface TileProps {
type: FurnitureType;
remaining: number;
onStartDrag: Props["onStartDrag"];
onOpenMenu: Props["onOpenMenu"];
}
/** Press + move > threshold = drag a new item out. Press + release without moving = open menu. */
function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
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={`${type} (${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>
{type === "chair" ? <ChairIcon /> : <TableIcon />}
{type === "chair" ? "Chair" : "Table"}
</div>
);
}
export function Palette({ remaining, onStartDrag, onOpenMenu, 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,
boxSizing: "border-box",
flexShrink: 0,
padding: 16,
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))",
alignContent: "start",
gap: 12,
borderRight: "1px solid #d4d4d4",
background: "#ececec",
}}
>
{panel ?? (["chair", "table"] as const).map((type) => (
<Tile key={type} type={type} remaining={remaining[type]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} />
))}
<div
onPointerDown={startResize}
style={{ position: "absolute", top: 0, right: -3, width: 6, height: "100%", cursor: "col-resize", touchAction: "none" }}
/>
</aside>
);
}