import * as React from "react"; import { IconBold, IconCheck, IconChevronDown, IconItalic, IconLink, IconList, IconSearch, IconStrikethrough, } from "@tabler/icons-react"; import { cn, Kbd, motion } from "@colineapp/ui"; import { LiquidGlass, useLiquidGlassTabs, type LiquidGlassHandle } from "@colineapp/liquid-glass/react"; import { useDraggable } from "./stage"; /* The package's surfaces, built the way its README says to. Every glass surface here is a positioned, rounded element with first and nothing painted over it. */ const EASE = [0.22, 1, 0.36, 1] as const; /** * Grows a floating surface's shadow in and out with its glass. 0.1.0's * materialize() and dematerialize() leave the shadow as it is, so a closing * menu kept a dark outline until it unmounted; the next release does this * itself. */ function growShadow(glass: LiquidGlassHandle | null, direction: "in" | "out", duration: number) { const surface = glass?.element.parentElement; if (!surface) return; const shadow = getComputedStyle(surface).boxShadow; if (!shadow || shadow === "none") return; const keyframes = [{ boxShadow: "none" }, { boxShadow: shadow }]; surface.animate(direction === "in" ? keyframes : keyframes.reverse(), { duration, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: direction === "in" ? "backwards" : "forwards", }); } // ── Pill tabs ─────────────────────────────────────────── export function GlassTabs({ options, value, onChange, className, label, }: { options: string[]; value: string; onChange: (value: string) => void; className?: string; label: string; }) { const track = React.useRef(null); useLiquidGlassTabs(track, { appearance: "class" }); const index = options.indexOf(value); return (
{ const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; if (!step) return; event.preventDefault(); const next = options[(index + step + options.length) % options.length]; if (next === undefined) return; onChange(next); track.current?.querySelector(`[data-tab="${next}"]`)?.focus(); }} > {options.map((option) => ( ))}
); } // ── Search field ──────────────────────────────────────── export function GlassSearch({ className, placeholder = "Search" }: { className?: string; placeholder?: string }) { const [value, setValue] = React.useState(""); return ( ); } // ── Toolbar ───────────────────────────────────────────── const TOOLS = [ { id: "bold", label: "Bold", icon: IconBold }, { id: "italic", label: "Italic", icon: IconItalic }, { id: "strike", label: "Strikethrough", icon: IconStrikethrough }, { id: "link", label: "Link", icon: IconLink }, { id: "list", label: "List", icon: IconList }, ] as const; export function GlassToolbar({ className }: { className?: string }) { const [on, setOn] = React.useState>(() => new Set(["bold"])); return (
{TOOLS.map(({ id, label, icon: Icon }) => { const active = on.has(id); return ( ); })}
); } // ── Menu ──────────────────────────────────────────────── const SORTS = ["Date added", "Name", "Size", "Kind"]; /** Glass menu: the contents fade and zoom, the glass materializes, and the surface only moves. */ export function GlassMenu({ className }: { className?: string }) { const [open, setOpen] = React.useState(false); const [sort, setSort] = React.useState(SORTS[0]!); const [active, setActive] = React.useState(-1); const root = React.useRef(null); const glass = React.useRef(null); const [leaving, setLeaving] = React.useState(false); const close = React.useCallback(() => { setLeaving(true); growShadow(glass.current, "out", 160); void glass.current?.dematerialize({ duration: 160, scale: 0.96 }).then(() => { setOpen(false); setLeaving(false); setActive(-1); }); }, []); React.useLayoutEffect(() => { if (!open || leaving) return; void glass.current?.materialize({ duration: 240, scale: 0.96 }); growShadow(glass.current, "in", 240); }, [open, leaving]); React.useEffect(() => { if (!open) return; const onDown = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) close(); }; const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") close(); }; window.addEventListener("pointerdown", onDown); window.addEventListener("keydown", onKey); return () => { window.removeEventListener("pointerdown", onDown); window.removeEventListener("keydown", onKey); }; }, [open, close]); return (
{open ? (
{SORTS.map((item, index) => ( ))}
) : null}
); } // ── Dialog ────────────────────────────────────────────── /** A glass dialog inside its stage: floating="dialog", materializing as it zooms in. */ export function GlassDialog({ className }: { className?: string }) { const [open, setOpen] = React.useState(false); const [leaving, setLeaving] = React.useState(false); const [hover, setHover] = React.useState(false); const glass = React.useRef(null); const trigger = React.useRef(null); const first = React.useRef(null); const close = React.useCallback(() => { setLeaving(true); growShadow(glass.current, "out", 180); void glass.current?.dematerialize({ duration: 180, scale: 0.97 }).then(() => { setOpen(false); setLeaving(false); trigger.current?.focus(); }); }, []); React.useLayoutEffect(() => { if (!open || leaving) return; void glass.current?.materialize({ duration: 280, scale: 0.95 }); growShadow(glass.current, "in", 280); first.current?.focus(); }, [open, leaving]); return (
<> {open ? (
event.key === "Escape" && close()}>
) : null}
); } // ── Card ──────────────────────────────────────────────── /** Regular glass over text: drag it and the rim bends the letters it crosses. */ export function GlassCard({ initial = { x: 0.5, y: 0.5 } }: { initial?: { x: number; y: number } }) { const drag = useDraggable({ initial }); return (
Drag me over the text The rim bends each letter as it crosses.
); }