diff --git a/AGENTS.md b/AGENTS.md index de2d3cf..8a747ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ Before building a new component, check this list. If it exists, import it. If it | `shared-layout-bg` | `components/motion/shared-layout-bg.tsx` | Pill that glides between hovered items via shared layout | | `dock` | `components/motion/dock.tsx` | macOS-style dock with grouped actions and gliding active pill | | `tooltip` | `components/motion/tooltip.tsx` | Hover/focus tooltip with blur enter/exit and spring spawn | -| `popover` | `components/motion/popover.tsx` | Composable gooey popover (`Popover`, `PopoverTrigger`, `PopoverContent`); panel oozes out of the trigger via an SVG goo filter (liquid neck that stretches/pinches) with crisp content fading in on top. Inline-anchored, click or hover trigger, controlled/uncontrolled | +| `popover` | `components/motion/popover.tsx`, `popover-morph.tsx` | Composable popover, two variants. **Gooey** (`Popover`, `PopoverTrigger`, `PopoverContent`, install `@beui/popover`): panel oozes out of the trigger via an SVG goo filter (liquid neck that stretches/pinches) with crisp content fading in on top. **Morph** (`MorphPopover`, `MorphPopoverTrigger`, `MorphPopoverContent`, install `@beui/popover-morph`): panel laid out full size but clipped to the corner nearest the trigger, then unclips as one piece with a drop-shadow that hugs the shape; side/align aware. Both inline-anchored, click trigger, controlled/uncontrolled | | `morphing-modal` | `components/motion/morphing-modal.tsx` | Panel that morphs height across inner views with blur cross-fade | | `text-reveal` | `components/motion/text-reveal.tsx` | Word or character reveal with spring slide-up and blur | | `text-shimmer` | `components/motion/text-shimmer.tsx` | Gradient sweep across text for loading or emphasis | @@ -78,6 +78,7 @@ Before building a new component, check this list. If it exists, import it. If it | `prediction-market` | `components/motion/prediction-market.tsx` | Trade ticket with buy/sell modes, outcome prices and rolling amount entry | | `otp-input` | `components/motion/otp-input.tsx` | One-time-code input with gliding focus ring, roll-in digits, error shake and success draw | | `bloom-menu` | `components/motion/bloom-menu.tsx` | Button that morphs open into a menu and blooms iris-out from center via shared layout + clip-path, with radially staggered items | +| `availability-scheduler` | `components/motion/availability-scheduler/` | Weekly availability editor (`AvailabilityScheduler`): each day springs between available/unavailable via a shared-layout toggle, time ranges add/remove with blur-slide + layout reflow, times pick from a self-contained scrollable dropdown, and a copy menu clones a day's hours to selected days or every day; controlled/uncontrolled, reduced-motion safe | ### Site chrome (`components/app/` — not part of the library) diff --git a/app/components/[category]/[slug]/page.tsx b/app/components/[category]/[slug]/page.tsx index 1c439e4..baf993e 100644 --- a/app/components/[category]/[slug]/page.tsx +++ b/app/components/[category]/[slug]/page.tsx @@ -268,7 +268,7 @@ async function ExampleBlock({ Code -
+
{Preview ? : null}
diff --git a/components/motion/availability-scheduler/copy-menu.tsx b/components/motion/availability-scheduler/copy-menu.tsx new file mode 100644 index 0000000..14b8d48 --- /dev/null +++ b/components/motion/availability-scheduler/copy-menu.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import { Check, Copy } from "lucide-react"; +import { useState } from "react"; +import { Checkbox } from "@/components/motion/checkbox"; +import { + MorphPopover, + MorphPopoverContent, +} from "@/components/motion/popover-morph"; +import { Tooltip } from "@/components/motion/tooltip"; +import { SPRING_PRESS } from "@/lib/ease"; +import { IconButton } from "./icon-button"; +import { type DayKey, WEEKDAYS } from "./types"; + +// Copy this day's hours to other days: a morph popover with a day picker. +export function CopyMenu({ + fromLabel, + reduce, + onApply, +}: { + fromLabel: string; + reduce: boolean; + onApply: (targets: DayKey[]) => void; +}) { + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + const [picked, setPicked] = useState>(new Set()); + const others = WEEKDAYS.filter((d) => d.label !== fromLabel); + + const toggle = (k: DayKey) => + setPicked((prev) => { + const next = new Set(prev); + if (next.has(k)) next.delete(k); + else next.add(k); + return next; + }); + + const apply = (targets: DayKey[]) => { + if (!targets.length) return; + onApply(targets); + setOpen(false); + setPicked(new Set()); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }; + + return ( + + + setOpen(!open)} + > + + {copied ? ( + + + + ) : ( + + + + )} + + + + + +

+ Copy times to +

+
+ {others.map((d) => ( + toggle(d.key)} + label={d.label} + className="w-full flex-row-reverse justify-between rounded-lg px-2 py-1.5 transition-colors hover:bg-muted [&_button]:size-4 [&_button]:rounded-[5px] [&_button]:border [&_button[data-state=unchecked]]:border-border-strong" + /> + ))} +
+
+ + +
+
+
+ ); +} diff --git a/components/motion/availability-scheduler/day-row.tsx b/components/motion/availability-scheduler/day-row.tsx new file mode 100644 index 0000000..99fbe35 --- /dev/null +++ b/components/motion/availability-scheduler/day-row.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import { Plus, X } from "lucide-react"; +import { useRef } from "react"; +import { Switch } from "@/components/motion/switch"; +import { Tooltip } from "@/components/motion/tooltip"; +import { SPRING_LAYOUT } from "@/lib/ease"; +import { CopyMenu } from "./copy-menu"; +import { IconButton } from "./icon-button"; +import { TimeSelect } from "./time-select"; +import { + type DayAvailability, + type DayKey, + type TimeOption, + type TimeRange, + toMinutes, + toValue, +} from "./types"; + +export function DayRow({ + day, + label, + state, + options, + reduce, + depth, + onChange, + onCopy, +}: { + day: DayKey; + label: string; + state: DayAvailability; + options: TimeOption[]; + reduce: boolean; + // Higher = painted above later rows, so a downward-opening panel always sits + // over the rows below it — during both open and close animations. + depth: number; + onChange: (next: DayAvailability) => void; + onCopy: (targets: DayKey[]) => void; +}) { + const idRef = useRef(0); + const nextId = () => `${day}-n${idRef.current++}`; + + const setEnabled = (enabled: boolean) => { + if (enabled && state.ranges.length === 0) { + onChange({ + enabled, + ranges: [{ id: nextId(), start: "09:00", end: "17:00" }], + }); + } else { + onChange({ ...state, enabled }); + } + }; + + const updateRange = (id: string, patch: Partial) => { + onChange({ + ...state, + ranges: state.ranges.map((r) => (r.id === id ? { ...r, ...patch } : r)), + }); + }; + + const addRange = () => { + const last = state.ranges[state.ranges.length - 1]; + const start = last ? Math.min(toMinutes(last.end) + 60, 24 * 60 - 60) : 540; + onChange({ + enabled: true, + ranges: [ + ...state.ranges, + { id: nextId(), start: toValue(start), end: toValue(start + 60) }, + ], + }); + }; + + const removeRange = (id: string) => { + const ranges = state.ranges.filter((r) => r.id !== id); + // Removing the last slot marks the day unavailable. + onChange({ enabled: ranges.length > 0, ranges }); + }; + + const actions = ( + <> + + + + + + + + ); + + return ( + + {/* toggle + label; actions ride along on mobile */} +
+
+ + {label} +
+
{actions}
+
+ + {/* ranges or unavailable */} +
+ + {state.enabled ? ( + state.ranges.map((r, i) => ( + +
+ updateRange(r.id, { start: v })} + /> +
+ +
+ updateRange(r.id, { end: v })} + /> +
+ + removeRange(r.id)} + > + + + +
+ )) + ) : ( + + Unavailable + + )} +
+
+ + {/* actions (desktop) */} +
+ {actions} +
+
+ ); +} diff --git a/components/motion/availability-scheduler/icon-button.tsx b/components/motion/availability-scheduler/icon-button.tsx new file mode 100644 index 0000000..859070f --- /dev/null +++ b/components/motion/availability-scheduler/icon-button.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { motion } from "motion/react"; +import type { ReactNode } from "react"; +import { SPRING_PRESS } from "@/lib/ease"; +import { cn } from "@/lib/utils"; + +export function IconButton({ + onClick, + label, + disabled, + expanded, + reduce, + children, + className, + // Rest props let a wrapping Tooltip inject its hover/focus handlers. + ...rest +}: { + onClick: () => void; + label: string; + disabled?: boolean; + expanded?: boolean; + reduce: boolean; + children: ReactNode; + className?: string; + [key: string]: unknown; +}) { + return ( + + {children} + + ); +} diff --git a/components/motion/availability-scheduler/index.tsx b/components/motion/availability-scheduler/index.tsx new file mode 100644 index 0000000..adee322 --- /dev/null +++ b/components/motion/availability-scheduler/index.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { LayoutGroup, useReducedMotion } from "motion/react"; +import { useCallback, useId, useMemo, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { DayRow } from "./day-row"; +import { + type DayAvailability, + type DayKey, + WEEKDAYS, + type WeekAvailability, + buildOptions, + defaultWeek, +} from "./types"; + +export type { + DayAvailability, + DayKey, + TimeRange, + WeekAvailability, +} from "./types"; +export { defaultWeek } from "./types"; + +export interface AvailabilitySchedulerProps { + value?: WeekAvailability; + defaultValue?: WeekAvailability; + onChange?: (value: WeekAvailability) => void; + /** Minutes between selectable times. Default 30. */ + step?: number; + className?: string; +} + +export function AvailabilityScheduler({ + value, + defaultValue, + onChange, + step = 30, + className, +}: AvailabilitySchedulerProps) { + const reduce = useReducedMotion() ?? false; + const groupId = useId(); + const options = useMemo(() => buildOptions(step), [step]); + const idRef = useRef(0); + + const [internal, setInternal] = useState( + () => defaultValue ?? defaultWeek(), + ); + const controlled = value !== undefined; + const week = controlled ? value : internal; + + const commit = useCallback( + (next: WeekAvailability) => { + if (!controlled) setInternal(next); + onChange?.(next); + }, + [controlled, onChange], + ); + + const setDay = useCallback( + (day: DayKey, next: DayAvailability) => { + commit({ ...week, [day]: next }); + }, + [commit, week], + ); + + const copyDay = useCallback( + (from: DayKey, targets: DayKey[]) => { + const source = week[from]; + const next = { ...week }; + for (const t of targets) { + next[t] = { + enabled: source.enabled, + ranges: source.ranges.map((r) => ({ + ...r, + id: `${t}-c${idRef.current++}`, + })), + }; + } + commit(next); + }, + [commit, week], + ); + + return ( + +
+ {WEEKDAYS.map(({ key, label }, i) => ( + setDay(key, next)} + onCopy={(targets) => copyDay(key, targets)} + /> + ))} +
+
+ ); +} diff --git a/components/motion/availability-scheduler/time-select.tsx b/components/motion/availability-scheduler/time-select.tsx new file mode 100644 index 0000000..6920dd5 --- /dev/null +++ b/components/motion/availability-scheduler/time-select.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/motion/select"; +import type { TimeOption } from "./types"; + +// Time field: the library Select, with the option list capped so the panel +// measures a small height and scrolls instead of unfolding all 48 options. +export function TimeSelect({ + value, + onChange, + options, +}: { + value: string; + onChange: (v: string) => void; + options: TimeOption[]; +}) { + return ( + + ); +} diff --git a/components/motion/availability-scheduler/types.ts b/components/motion/availability-scheduler/types.ts new file mode 100644 index 0000000..13738f6 --- /dev/null +++ b/components/motion/availability-scheduler/types.ts @@ -0,0 +1,68 @@ +export type DayKey = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun"; + +export type TimeRange = { id: string; start: string; end: string }; +export type DayAvailability = { enabled: boolean; ranges: TimeRange[] }; +export type WeekAvailability = Record; +export type TimeOption = { value: string; label: string }; + +export const WEEKDAYS: { key: DayKey; label: string }[] = [ + { key: "mon", label: "Monday" }, + { key: "tue", label: "Tuesday" }, + { key: "wed", label: "Wednesday" }, + { key: "thu", label: "Thursday" }, + { key: "fri", label: "Friday" }, + { key: "sat", label: "Saturday" }, + { key: "sun", label: "Sunday" }, +]; + +// ─── time helpers ──────────────────────────────────────────────────────────── + +export const toMinutes = (v: string) => { + const [h, m] = v.split(":").map(Number); + return h * 60 + m; +}; + +export const toValue = (mins: number) => { + const clamped = Math.max(0, Math.min(24 * 60 - 1, mins)); + const h = Math.floor(clamped / 60); + const m = clamped % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +}; + +export const label12 = (v: string) => { + const [h, m] = v.split(":").map(Number); + const ap = h < 12 ? "AM" : "PM"; + const h12 = h % 12 === 0 ? 12 : h % 12; + return `${h12}:${String(m).padStart(2, "0")} ${ap}`; +}; + +export function buildOptions(step: number): TimeOption[] { + const out: TimeOption[] = []; + for (let m = 0; m < 24 * 60; m += step) { + const value = toValue(m); + out.push({ value, label: label12(value) }); + } + return out; +} + +// Default: Mon–Fri 9–5, weekend off. Fixed ids so SSR and first client render +// agree (new ranges get counter ids afterwards). +export function defaultWeek(): WeekAvailability { + const workday = (day: DayKey): DayAvailability => ({ + enabled: true, + ranges: [{ id: `${day}-0`, start: "09:00", end: "17:00" }], + }); + const off = (day: DayKey): DayAvailability => ({ + enabled: false, + ranges: [{ id: `${day}-0`, start: "09:00", end: "17:00" }], + }); + return { + mon: workday("mon"), + tue: workday("tue"), + wed: workday("wed"), + thu: workday("thu"), + fri: workday("fri"), + sat: off("sat"), + sun: off("sun"), + }; +} diff --git a/components/motion/popover-morph.tsx b/components/motion/popover-morph.tsx new file mode 100644 index 0000000..19cce5b --- /dev/null +++ b/components/motion/popover-morph.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { + cloneElement, + createContext, + isValidElement, + type ReactElement, + type ReactNode, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import { SPRING_PANEL } from "@/lib/ease"; +import { cn } from "@/lib/utils"; + +type Side = "top" | "bottom"; +type Align = "start" | "end"; + +type MorphContextValue = { + open: boolean; + setOpen: (open: boolean) => void; + toggle: () => void; + triggerId: string; + contentId: string; +}; + +const MorphContext = createContext(null); + +function useMorphContext(component: string) { + const ctx = useContext(MorphContext); + if (!ctx) throw new Error(`${component} must be used within `); + return ctx; +} + +export interface MorphPopoverProps { + children: ReactNode; + /** Controlled open state. */ + open?: boolean; + /** Uncontrolled initial open state. */ + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + className?: string; +} + +/** + * A popover whose panel morphs open from the trigger corner: it's laid out at + * full size but clipped to the corner nearest the trigger, then unclips as one + * piece. Closes on outside pointer / Escape. Controlled or uncontrolled. + */ +export function MorphPopover({ + children, + open: controlledOpen, + defaultOpen = false, + onOpenChange, + className, +}: MorphPopoverProps) { + const baseId = useId(); + const rootRef = useRef(null); + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const controlled = controlledOpen !== undefined; + const open = controlled ? controlledOpen : internalOpen; + + const setOpen = useCallback( + (next: boolean) => { + if (!controlled) setInternalOpen(next); + onOpenChange?.(next); + }, + [controlled, onOpenChange], + ); + const toggle = useCallback(() => setOpen(!open), [setOpen, open]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + const onPointer = (e: PointerEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) + setOpen(false); + }; + window.addEventListener("keydown", onKey); + window.addEventListener("pointerdown", onPointer); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("pointerdown", onPointer); + }; + }, [open, setOpen]); + + const ctx = useMemo( + () => ({ + open, + setOpen, + toggle, + triggerId: `${baseId}-trigger`, + contentId: `${baseId}-content`, + }), + [open, setOpen, toggle, baseId], + ); + + return ( + +
+ {children} +
+
+ ); +} + +export interface MorphPopoverTriggerProps { + children: ReactElement; +} + +/** Wraps a single element, toggling the popover on click. */ +export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) { + const ctx = useMorphContext("MorphPopoverTrigger"); + if (!isValidElement(children)) return children; + + const child = children as ReactElement>; + const childOnClick = child.props.onClick as + | ((e: unknown) => void) + | undefined; + + return cloneElement(child, { + id: ctx.triggerId, + onClick: (e: unknown) => { + childOnClick?.(e); + ctx.toggle(); + }, + "aria-haspopup": "menu", + "aria-expanded": ctx.open, + "aria-controls": ctx.open ? ctx.contentId : undefined, + }); +} + +const originFor = (side: Side, align: Align) => + `${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`; + +// A clip that hides everything but the corner nearest the trigger, so the +// panel appears to grow out of it. inset(top right bottom left). +function clipHidden(side: Side, align: Align, radius: number) { + const top = side === "bottom" ? "0%" : "92%"; + const bottom = side === "bottom" ? "92%" : "0%"; + const right = align === "end" ? "0%" : "92%"; + const left = align === "end" ? "92%" : "0%"; + return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`; +} +const clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`; + +export interface MorphPopoverContentProps { + children: ReactNode; + side?: Side; + align?: Align; + /** Gap between trigger and panel, in px. Default 8. */ + sideOffset?: number; + /** Panel corner radius, in px. Default 16. */ + radius?: number; + className?: string; +} + +export function MorphPopoverContent({ + children, + side = "bottom", + align = "end", + sideOffset = 8, + radius = 16, + className, +}: MorphPopoverContentProps) { + const ctx = useMorphContext("MorphPopoverContent"); + const reduce = useReducedMotion() ?? false; + + const posClass = cn( + side === "bottom" ? "top-full" : "bottom-full", + align === "end" ? "right-0" : "left-0", + ); + const marginStyle = + side === "bottom" ? { marginTop: sideOffset } : { marginBottom: sideOffset }; + + // Close animates with the same spring as open, so it morphs back to the + // corner instead of snapping shut. + const wrap = reduce + ? undefined + : { + hidden: { opacity: 0, scale: 0.96 }, + show: { opacity: 1, scale: 1, transition: SPRING_PANEL }, + exit: { opacity: 0, scale: 0.96, transition: SPRING_PANEL }, + }; + const clip = reduce + ? undefined + : { + hidden: { clipPath: clipHidden(side, align, radius) }, + show: { clipPath: clipShown(radius), transition: SPRING_PANEL }, + exit: { clipPath: clipHidden(side, align, radius), transition: SPRING_PANEL }, + }; + + return ( + + {ctx.open ? ( + + + {children} + + + ) : null} + + ); +} diff --git a/components/motion/popover.tsx b/components/motion/popover.tsx index 574d3c3..5d97ef9 100644 --- a/components/motion/popover.tsx +++ b/components/motion/popover.tsx @@ -29,7 +29,11 @@ type Side = "top" | "bottom"; type Align = "start" | "center" | "end"; type TriggerMode = "click" | "hover"; -const GOO_SPRING = { type: "spring", visualDuration: 0.32, bounce: 0.28 } as const; +const GOO_SPRING = { + type: "spring", + visualDuration: 0.32, + bounce: 0.28, +} as const; const HOVER_CLOSE_DELAY = 120; const lerp = (a: number, b: number, t: number) => a + (b - a) * t; @@ -401,7 +405,17 @@ export function PopoverContent({ children, className }: PopoverContentProps) { }, [triggerRef]); const geo = useMemo( - () => buildGeo(sizes.tW, sizes.tH, sizes.cW, sizes.cH, side, align, gap, panelRadius), + () => + buildGeo( + sizes.tW, + sizes.tH, + sizes.cW, + sizes.cH, + side, + align, + gap, + panelRadius, + ), [sizes, side, align, gap, panelRadius], ); geoRef.current = geo; @@ -430,11 +444,20 @@ export function PopoverContent({ children, className }: PopoverContentProps) { <> {/* Goo filter: blur, sharpen the alpha back into solid shapes, then lay the crisp original on top so blobs merge with liquid edges. */} - + Popover goo filter - +
= { - top: "bottom-full left-1/2 mb-2 -translate-x-1/2", - bottom: "top-full left-1/2 mt-2 -translate-x-1/2", - left: "right-full top-1/2 mr-2 -translate-y-1/2", - right: "left-full top-1/2 ml-2 -translate-y-1/2", +// Gap between trigger and tooltip, in px. +const GAP = 8; + +// Centering transform for the fixed-positioned anchor point, per side. +const anchorTransform: Record = { + top: "translate(-50%, -100%)", + bottom: "translate(-50%, 0)", + left: "translate(-100%, -50%)", + right: "translate(0, -50%)", }; const transformOrigin: Record = { @@ -44,10 +57,10 @@ const transformOrigin: Record = { // Offset is in the direction *away* from the trigger — content originates near // the trigger and rises into resting position. const offsetFrom: Record = { - top: { y: 10 }, - bottom: { y: -10 }, - left: { x: 10 }, - right: { x: -10 }, + top: { y: 8 }, + bottom: { y: -8 }, + left: { x: 8 }, + right: { x: -8 }, }; function buildVariants(side: Side): Variants { @@ -55,8 +68,8 @@ function buildVariants(side: Side): Variants { return { initial: { opacity: 0, - scale: 0.85, - filter: "blur(10px)", + scale: 0.9, + filter: "blur(5px)", x: o.x ?? 0, y: o.y ?? 0, }, @@ -71,17 +84,17 @@ function buildVariants(side: Side): Variants { stiffness: 380, damping: 30, mass: 0.7, - opacity: { duration: 0.22, ease: EASE_OUT }, - filter: { duration: 0.3, ease: EASE_OUT }, + opacity: { duration: 0.14, ease: EASE_OUT }, + filter: { duration: 0.18, ease: EASE_OUT }, }, }, exit: { opacity: 0, - scale: 0.92, - filter: "blur(6px)", + scale: 0.94, + filter: "blur(3px)", x: (o.x ?? 0) * 0.6, y: (o.y ?? 0) * 0.6, - transition: { duration: 0.14, ease: EASE_OUT }, + transition: { duration: 0.12, ease: EASE_OUT }, }, }; } @@ -97,67 +110,142 @@ const REDUCED_VARIANTS: Variants = { const WARM_WINDOW_MS = 300; let lastHiddenAt = 0; -export function Tooltip({ content, children, side = "top", delay = 120, className, wrapperClassName }: TooltipProps) { +export function Tooltip({ + content, + children, + side = "top", + delay = 120, + className, + wrapperClassName, +}: TooltipProps) { const [open, setOpen] = useState(false); + const [coords, setCoords] = useState<{ top: number; left: number } | null>( + null, + ); const id = useId(); const timer = useRef | null>(null); + const anchorRef = useRef(null); const reduce = useReducedMotion(); const canHover = useHoverCapable(); - const show = () => { + // Anchor point in viewport coords, on the edge of the trigger facing `side`. + // Position:fixed means these viewport coords place the tooltip directly, so + // it escapes every ancestor's stacking context and overflow. + const place = useCallback(() => { + const el = anchorRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + const point: Record = { + top: { top: r.top - GAP, left: cx }, + bottom: { top: r.bottom + GAP, left: cx }, + left: { top: cy, left: r.left - GAP }, + right: { top: cy, left: r.right + GAP }, + }; + setCoords(point[side]); + }, [side]); + + const show = useCallback(() => { if (!canHover) return; if (timer.current) clearTimeout(timer.current); const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS; - timer.current = setTimeout(() => setOpen(true), warm ? 0 : delay); - }; - const hide = () => { + timer.current = setTimeout( + () => { + place(); + setOpen(true); + }, + warm ? 0 : delay, + ); + }, [canHover, delay, place]); + + const hide = useCallback(() => { if (timer.current) { clearTimeout(timer.current); timer.current = null; } - if (open) lastHiddenAt = Date.now(); - setOpen(false); - }; + setOpen((wasOpen) => { + if (wasOpen) lastHiddenAt = Date.now(); + return false; + }); + }, []); - if (!isValidElement(children)) return children; + // Keep the tooltip pinned to the trigger while it's open and the page scrolls + // or resizes (fixed coords are viewport-relative). + useEffect(() => { + if (!open) return; + const onMove = () => place(); + window.addEventListener("scroll", onMove, true); + window.addEventListener("resize", onMove); + return () => { + window.removeEventListener("scroll", onMove, true); + window.removeEventListener("resize", onMove); + }; + }, [open, place]); - const trigger = cloneElement(children as ReactElement>, { - onMouseEnter: show, - onMouseLeave: hide, - onFocus: show, - onBlur: hide, - "aria-describedby": id, - }); + const variants = useMemo( + () => (reduce ? REDUCED_VARIANTS : buildVariants(side)), + [reduce, side], + ); - const variants = reduce ? REDUCED_VARIANTS : buildVariants(side); + if (!isValidElement(children)) return children; + + const trigger = cloneElement( + children as ReactElement>, + { + onMouseEnter: show, + onMouseLeave: hide, + onFocus: show, + onBlur: hide, + "aria-describedby": id, + }, + ); return ( - - {trigger} - - {open ? ( - - - {content} - - - ) : null} - - + <> + + {trigger} + + {typeof document !== "undefined" + ? createPortal( + + {open && coords ? ( + + + {content} + + + ) : null} + , + document.body, + ) + : null} + ); } diff --git a/components/previews/blocks/availability-scheduler.preview.tsx b/components/previews/blocks/availability-scheduler.preview.tsx new file mode 100644 index 0000000..59cc50d --- /dev/null +++ b/components/previews/blocks/availability-scheduler.preview.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { AvailabilityScheduler } from "@/components/motion/availability-scheduler"; + +export function AvailabilitySchedulerPreview() { + return ( +
+ +
+ ); +} diff --git a/components/previews/index.tsx b/components/previews/index.tsx index 0f4bff2..8462f61 100644 --- a/components/previews/index.tsx +++ b/components/previews/index.tsx @@ -42,6 +42,11 @@ export const previews: Record = { "blocks/wallet-card": dynamic(() => import("./blocks/wallet-card.preview").then((m) => m.WalletCardPreview), ), + "blocks/availability-scheduler": dynamic(() => + import("./blocks/availability-scheduler.preview").then( + (m) => m.AvailabilitySchedulerPreview, + ), + ), "motion/table": dynamic(() => import("./motion/table.preview").then((m) => m.TablePreview), ), @@ -154,6 +159,9 @@ export const previews: Record = { "motion/popover": dynamic(() => import("./motion/popover.preview").then((m) => m.PopoverPreview), ), + "motion/popover-morph": dynamic(() => + import("./motion/popover-morph.preview").then((m) => m.MorphPopoverPreview), + ), "motion/morphing-modal": dynamic(() => import("./motion/morphing-modal.preview").then((m) => m.MorphingModalPreview), ), diff --git a/components/previews/motion/popover-morph.preview.tsx b/components/previews/motion/popover-morph.preview.tsx new file mode 100644 index 0000000..f9dcb2f --- /dev/null +++ b/components/previews/motion/popover-morph.preview.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { ChevronDown, Copy, Pencil, Share2, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { + MorphPopover, + MorphPopoverContent, + MorphPopoverTrigger, +} from "@/components/motion/popover-morph"; + +const ACTIONS = [ + { icon: Pencil, label: "Edit" }, + { icon: Copy, label: "Duplicate" }, + { icon: Share2, label: "Share" }, + { icon: Trash2, label: "Delete" }, +]; + +export function MorphPopoverPreview() { + const [open, setOpen] = useState(false); + + return ( + + + + + + + {ACTIONS.map(({ icon: Icon, label }) => ( + + ))} + + + ); +} diff --git a/lib/registry.ts b/lib/registry.ts index 414f647..868599d 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -193,7 +193,7 @@ export const registry: CategoryEntry[] = [ slug: "popover", name: "Popover", description: - "Gooey popover whose panel oozes out of the trigger through an SVG goo filter — a liquid neck that stretches and pinches — with crisp content fading in on top. Click or hover trigger, controlled or uncontrolled.", + "Gooey popover whose panel oozes out of the trigger through an SVG goo filter — a liquid neck that stretches and pinches — with crisp content fading in on top, plus a Morph variant that clip-morphs open from the trigger corner. Click or hover trigger, controlled or uncontrolled.", file: "components/motion/popover.tsx", badge: "new", launchedAt: "2026-07-07", @@ -204,6 +204,30 @@ export const registry: CategoryEntry[] = [ "svg goo filter", "metaball popover", "hover popover react", + "morph popover react", + "dropdown menu react", + ], + examples: [ + { + slug: "gooey", + name: "Gooey Popover", + description: + "Composable Popover, PopoverTrigger, PopoverContent; the panel oozes out of the trigger through an SVG goo filter with a liquid neck, crisp content fading in on top. Click or hover, controlled or uncontrolled.", + installSlug: "popover", + file: "components/motion/popover.tsx", + previewKey: "motion/popover", + previewFile: "components/previews/motion/popover.preview.tsx", + }, + { + slug: "morph", + name: "Morph Popover", + description: + "Composable MorphPopover, MorphPopoverTrigger, MorphPopoverContent; the panel is laid out full size but clipped to the corner nearest the trigger, then unclips as one piece — a single-surface morph with a drop-shadow that hugs the shape. Side/align aware, controlled or uncontrolled.", + installSlug: "popover-morph", + file: "components/motion/popover-morph.tsx", + previewKey: "motion/popover-morph", + previewFile: "components/previews/motion/popover-morph.preview.tsx", + }, ], }, { @@ -533,6 +557,22 @@ export const registry: CategoryEntry[] = [ name: "Blocks", description: "Composed, product-ready widgets built from beUI motion primitives.", components: [ + { + slug: "availability-scheduler", + name: "Availability Scheduler", + description: "Weekly availability editor where each day springs between available and unavailable, time ranges add and remove with blur-slide motion, times pick from a scrollable dropdown, and a copy menu clones hours to other days.", + file: "components/motion/availability-scheduler/index.tsx", + badge: "new", + launchedAt: "2026-07-10", + keywords: [ + "availability scheduler react", + "weekly hours picker react", + "working hours component", + "cal.com availability component", + "time range picker react", + "business hours editor", + ], + }, { slug: "swap", name: "Multi-chain Swap",