"use client";
import { ArrowUpRight, BellOff } from "lucide-react";
import { motion, type Transition, useReducedMotion } from "motion/react";
import {
type FocusEvent,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { ActionSwapText } from "@/components/motion/action-swap";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import { useHoverGesture } from "@/lib/hooks/use-hover-gesture";
import { useTapGesture } from "@/lib/hooks/use-tap-gesture";
import { cn } from "@/lib/utils";
export type NotificationStackItem = {
id: string;
title: ReactNode;
description?: ReactNode;
trailing?: ReactNode;
};
export type NotificationStackClassNames = {
stack?: string;
card?: string;
content?: string;
title?: string;
description?: string;
trailing?: string;
footer?: string;
count?: string;
};
export interface NotificationStackProps {
items: NotificationStackItem[];
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onViewAll?: () => void;
maxVisible?: number;
collapsedLabel?: string;
expandedLabel?: string;
emptyLabel?: string;
className?: string;
classNames?: NotificationStackClassNames;
}
const STACK_PEEK = 8;
const STACK_INSET = 12;
function useControllableExpanded({
expanded,
defaultExpanded,
onExpandedChange,
}: {
expanded?: boolean;
defaultExpanded: boolean;
onExpandedChange?: (expanded: boolean) => void;
}) {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const isControlled = expanded !== undefined;
const value = expanded ?? internalExpanded;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalExpanded(next);
onExpandedChange?.(next);
},
[isControlled, onExpandedChange],
);
return [value, setValue] as const;
}
function NotificationCardContent({
item,
classNames,
}: {
item: NotificationStackItem;
classNames?: NotificationStackClassNames;
}) {
return (
{item.title}
{item.trailing ? (
{item.trailing}
) : null}
{item.description ? (
{item.description}
) : null}
);
}
export function NotificationStack({
items,
expanded,
defaultExpanded = false,
onExpandedChange,
onViewAll,
maxVisible = 3,
collapsedLabel = "Notifications",
expandedLabel = "View all",
emptyLabel = "All caught up",
className,
classNames,
}: NotificationStackProps) {
const reduce = useReducedMotion();
const hasFocus = useRef(false);
const rootRef = useRef(null);
const hover = useHoverGesture();
// What the last gesture on the stack was, and whether it was already
// expanded when that gesture started. A click reports neither.
const tap = useTapGesture();
const [isExpanded, setIsExpanded] = useControllableExpanded({
expanded,
defaultExpanded,
onExpandedChange,
});
// Set by the tap that expands the stack, and the reason the outside-tap
// dismisser exists at all — a hovering pointer has its own way out.
const [tapExpanded, setTapExpanded] = useState(false);
const collapse = useCallback(() => {
setTapExpanded(false);
setIsExpanded(false);
}, [setIsExpanded]);
// A pointer leaving the stack is what collapses it, and a finger never
// leaves: without this the stack stays open for good once tapped, and when
// `onViewAll` is set the next tap follows the link instead of closing. The
// tap that lands somewhere else stands in for the pointer leaving, and it is
// consumed rather than passed through — the expanded stack covers the page,
// so the tap that dismisses it is aimed at nothing else.
useDismiss(tapExpanded && isExpanded, collapse, rootRef, {
behavior: "consume",
});
const visibleItems = items.slice(0, Math.max(1, maxVisible));
const primaryItem = visibleItems[0];
const transition: Transition = reduce ? { duration: 0 } : SPRING_LAYOUT;
const cardTransition: Transition = reduce
? { duration: 0 }
: { duration: 0.32, ease: EASE_OUT };
const backgroundTransition: Transition = reduce
? { duration: 0 }
: { duration: 0.26, ease: EASE_OUT };
if (!primaryItem) {
return (
{emptyLabel}
);
}
const handleBlur = (event: FocusEvent) => {
if (event.currentTarget.contains(event.relatedTarget)) return;
hasFocus.current = false;
collapse();
};
const handleKeyDown = (event: KeyboardEvent) => {
// A key press is the start of a keyboard activation, never part of a tap:
// whatever a taken-away gesture left behind must not be read as one.
tap.drop();
if (event.key !== "Escape") return;
event.preventDefault();
collapse();
// Focus stays where the keyboard put it. Blurring here collapsed the stack
// *and* threw the user back to the top of the document.
};
const handleClick = () => {
const gesture = tap.take();
// Read from where the gesture started, not from now: a browser that
// focuses the stack on contact expands it mid-tap, and the first tap would
// then follow `onViewAll` instead of opening the list it was meant to.
const wasExpanded = gesture ? gesture.state : isExpanded;
if (!wasExpanded) {
setIsExpanded(true);
if (gesture && gesture.pointerType !== "mouse") setTapExpanded(true);
return;
}
if (onViewAll) {
onViewAll();
return;
}
collapse();
};
return (
) => {
if (hover.enter(event)) setIsExpanded(true);
}}
onPointerLeave={(event: PointerEvent) => {
if (hover.leave(event) && !hasFocus.current) collapse();
}}
onPointerDown={(event: PointerEvent) => {
tap.start(event, isExpanded);
}}
// The platform can take the gesture away mid-press — a scroll, a system
// swipe — and no click follows it.
onPointerCancel={tap.drop}
onFocus={() => {
hasFocus.current = true;
setIsExpanded(true);
}}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onClick={handleClick}
className={cn(
"relative z-10 block w-full max-w-[22rem] cursor-pointer rounded-3xl text-left text-foreground outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
)}
>
{/* This invisible first card gives the button its compact intrinsic footprint. */}
{visibleItems.map((item, index) => {
const isPrimary = index === 0;
return (
);
})}
{items.length}
{isExpanded ? (
{expandedLabel}
) : (
collapsedLabel
)}
);
}