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 { LENS_GEOMETRY } from "@colineapp/liquid-glass"; 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}
); } // ── Lens ──────────────────────────────────────────────── /** The tabs' lens on its own: drag it over the text; pressed, it magnifies more. */ export function GlassLens({ size = 132, initial = { x: 0.5, y: 0.45 } }: { size?: number; initial?: { x: number; y: number } }) { // Magnifies from the moment the glass exists, and more while it's held. const lens = React.useMemo(() => { let current: LiquidGlassHandle | null = null; return { get current() { return current; }, set current(value: LiquidGlassHandle | null) { current = value; value?.magnify(1.12); }, }; }, []); const drag = useDraggable({ initial }); React.useEffect(() => { lens.current?.magnify(drag.dragging ? 1.22 : 1.12); }, [drag.dragging, lens]); return (
); }