Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion app/components/[category]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ async function ExampleBlock({
<TabsTrigger value="source">Code</TabsTrigger>
</TabsList>
<TabsContent value="preview" className="mt-4">
<div className="flex min-h-[260px] items-center justify-center rounded-2xl border border-border bg-card py-10">
<div className="flex min-h-[260px] items-center justify-center py-10">
{Preview ? <Preview /> : null}
</div>
</TabsContent>
Expand Down
119 changes: 119 additions & 0 deletions components/motion/availability-scheduler/copy-menu.tsx
Original file line number Diff line number Diff line change
@@ -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<Set<DayKey>>(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 (
<MorphPopover open={open} onOpenChange={setOpen}>
<Tooltip content="Copy times">
<IconButton
label={`Copy ${fromLabel} hours to other days`}
reduce={reduce}
expanded={open}
onClick={() => setOpen(!open)}
>
<AnimatePresence mode="popLayout" initial={false}>
{copied ? (
<motion.span
key="done"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
transition={SPRING_PRESS}
className="text-foreground"
>
<Check className="h-4 w-4" />
</motion.span>
) : (
<motion.span
key="copy"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
transition={SPRING_PRESS}
>
<Copy className="h-4 w-4" />
</motion.span>
)}
</AnimatePresence>
</IconButton>
</Tooltip>

<MorphPopoverContent align="end" className="w-52 p-2">
<p className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Copy times to
</p>
<div className="flex flex-col">
{others.map((d) => (
<Checkbox
key={d.key}
checked={picked.has(d.key)}
onCheckedChange={() => 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"
/>
))}
</div>
<div className="mt-1 flex items-center gap-2 border-t border-border px-1 pt-2">
<button
type="button"
onClick={() => apply(others.map((d) => d.key))}
className="flex-1 rounded-lg px-2 py-1.5 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:bg-muted"
>
Every day
</button>
<button
type="button"
onClick={() => apply([...picked])}
disabled={picked.size === 0}
className="flex-1 rounded-lg bg-primary px-2 py-1.5 text-xs font-semibold text-primary-foreground outline-none transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
>
Apply
</button>
</div>
</MorphPopoverContent>
</MorphPopover>
);
}
191 changes: 191 additions & 0 deletions components/motion/availability-scheduler/day-row.tsx
Original file line number Diff line number Diff line change
@@ -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<TimeRange>) => {
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 = (
<>
<Tooltip content="Add time">
<IconButton
label={`Add time range to ${label}`}
reduce={reduce}
onClick={addRange}
>
<Plus className="h-4 w-4" />
</IconButton>
</Tooltip>
<CopyMenu fromLabel={label} reduce={reduce} onApply={onCopy} />
</>
);

return (
<motion.div
layout={reduce ? false : "position"}
transition={SPRING_LAYOUT}
style={{ zIndex: depth }}
className="relative flex flex-col gap-3 py-4 sm:flex-row sm:items-start sm:gap-4"
>
{/* toggle + label; actions ride along on mobile */}
<div className="flex items-center justify-between sm:w-36 sm:shrink-0 sm:justify-start sm:pt-1">
<div className="flex items-center gap-2.5">
<Switch
checked={state.enabled}
onCheckedChange={setEnabled}
className="scale-90"
/>
<span className="text-sm font-medium text-foreground">{label}</span>
</div>
<div className="flex items-center gap-1 sm:hidden">{actions}</div>
</div>

{/* ranges or unavailable */}
<div className="flex min-w-0 flex-1 flex-col gap-2">
<AnimatePresence initial={false} mode="popLayout">
{state.enabled ? (
state.ranges.map((r, i) => (
<motion.div
key={r.id}
layout={reduce ? false : "position"}
style={{ zIndex: state.ranges.length - i }}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: -6, filter: "blur(4px)" }
}
animate={
reduce
? { opacity: 1 }
: { opacity: 1, y: 0, filter: "blur(0px)" }
}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, y: -4, filter: "blur(4px)" }
}
transition={SPRING_LAYOUT}
className="relative flex items-center gap-2"
>
<div className="min-w-0 flex-1 sm:max-w-[132px]">
<TimeSelect
value={r.start}
options={options}
onChange={(v) => updateRange(r.id, { start: v })}
/>
</div>
<span className="text-muted-foreground">–</span>
<div className="min-w-0 flex-1 sm:max-w-[132px]">
<TimeSelect
value={r.end}
options={options}
onChange={(v) => updateRange(r.id, { end: v })}
/>
</div>
<Tooltip content="Remove">
<IconButton
label="Remove time range"
reduce={reduce}
onClick={() => removeRange(r.id)}
>
<X className="h-4 w-4" />
</IconButton>
</Tooltip>
</motion.div>
))
) : (
<motion.span
key="unavailable"
layout={reduce ? false : "position"}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={SPRING_LAYOUT}
className="py-1 text-sm text-muted-foreground sm:py-2"
>
Unavailable
</motion.span>
)}
</AnimatePresence>
</div>

{/* actions (desktop) */}
<div className="hidden shrink-0 items-center gap-1 pt-0.5 sm:flex">
{actions}
</div>
</motion.div>
);
}
46 changes: 46 additions & 0 deletions components/motion/availability-scheduler/icon-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<motion.button
{...rest}
type="button"
aria-label={label}
aria-expanded={expanded}
onClick={onClick}
disabled={disabled}
whileTap={reduce || disabled ? undefined : { scale: 0.86 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40",
className,
)}
>
{children}
</motion.button>
);
}
Loading
Loading