Moxie-Multimedia / frontend /src /lib /shape-context.tsx
turtle89431's picture
Upload folder using huggingface_hub
7312c42 verified
Raw History Blame Contribute Delete
4.12 kB
"use client";
import {
createContext,
useContext,
useState,
useEffect,
useRef,
useCallback,
useMemo,
type ReactNode,
} from "react";
type ShapeVariant = "pill" | "rounded";
const shapeOrder: ShapeVariant[] = ["rounded", "pill"];
interface ShapeClasses {
item: string;
bg: string;
focusRing: string;
mergedBg: string;
container: string;
button: string;
input: string;
// Numeric counterparts of `bg` / `mergedBg`, in px. Needed where individual
// corners are animated (e.g. the selected-background merge/split animation),
// which requires per-corner numeric border-radii rather than a class.
bgRadius: number;
mergedRadius: number;
}
const shapeMap: Record<ShapeVariant, ShapeClasses> = {
pill: {
item: "rounded-[20px]",
bg: "rounded-[20px]",
// +2px over `item` because the focus ring sits 2px outside the element
// (top/left -2, width/height +4); this keeps the corners concentric so a
// pill element gets a pill ring (matches the rounded-mode 8px→10px bump).
focusRing: "rounded-[22px]",
mergedBg: "rounded-2xl",
container: "rounded-3xl",
button: "rounded-[20px]",
input: "rounded-[20px]",
bgRadius: 20,
mergedRadius: 16,
},
rounded: {
item: "rounded-lg",
bg: "rounded-lg",
focusRing: "rounded-[10px]",
mergedBg: "rounded-lg",
container: "rounded-xl",
button: "rounded-lg",
input: "rounded-lg",
bgRadius: 8,
mergedRadius: 8,
},
};
interface ShapeContextValue {
shape: ShapeVariant;
setShape: (shape: ShapeVariant) => void;
classes: ShapeClasses;
}
const ShapeContext = createContext<ShapeContextValue | null>(null);
function useShape(): ShapeClasses {
const ctx = useContext(ShapeContext);
if (!ctx) return shapeMap.pill;
return ctx.classes;
}
function useShapeContext() {
const ctx = useContext(ShapeContext);
if (!ctx) throw new Error("useShapeContext must be used within a ShapeProvider");
return ctx;
}
function ShapeProvider({
children,
defaultShape = "pill",
}: {
children: ReactNode;
defaultShape?: ShapeVariant;
}) {
const [shape, setShapeState] = useState<ShapeVariant>(defaultShape);
const transitionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Run a state change under the `.transitioning` guard (added + reflow-flushed
// first so the 180ms border-radius cross-fade applies). Clearing the previous
// timeout first keeps a double-press from removing the class mid-fade.
const transitionShape = useCallback((callback: () => void) => {
const root = document.documentElement;
root.classList.add("transitioning");
void root.offsetHeight;
callback();
if (transitionTimeoutRef.current) clearTimeout(transitionTimeoutRef.current);
transitionTimeoutRef.current = setTimeout(
() => root.classList.remove("transitioning"),
200
);
}, []);
const setShape = useCallback(
(next: ShapeVariant) => {
transitionShape(() => setShapeState(next));
},
[transitionShape]
);
// Global keyboard shortcut: R to cycle radius
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "r" && e.key !== "R") return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement)?.isContentEditable) return;
e.preventDefault();
transitionShape(() => {
setShapeState((prev) => {
const idx = shapeOrder.indexOf(prev);
return shapeOrder[(idx + 1) % shapeOrder.length];
});
});
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [transitionShape]);
const value = useMemo(
() => ({ shape, setShape, classes: shapeMap[shape] }),
[shape, setShape]
);
return (
<ShapeContext.Provider value={value}>
{children}
</ShapeContext.Provider>
);
}
export { ShapeProvider, useShape, useShapeContext, shapeMap };
export type { ShapeVariant, ShapeClasses };