"use client";
import * as React from "react";
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
type Side = "leading" | "trailing";
export interface SwipeAction {
id: string;
label: string;
icon?: React.ReactNode;
/** Background of the revealed action. */
color?: string;
/** Text / icon colour on that background. */
textColor?: string;
onSelect?: () => void;
/** Slide the row out before calling onSelect (archive, delete). Otherwise the row springs back. */
dismiss?: boolean;
/** Ask first, e.g. "Delete this message?". Applies to swipes, taps and the menu. */
confirm?: string;
/** Label of the confirm button. Defaults to the action label. */
confirmLabel?: string;
/** Styled as destructive in the menu and confirmation. */
destructive?: boolean;
/** Announced after the action runs. Defaults to the label. */
announcement?: string;
}
export interface SwipeActionsProps
extends Omit<
React.ComponentPropsWithoutRef<"div">,
"children" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
> {
children: React.ReactNode;
/** Revealed by swiping right. The first (outermost) one is the full-swipe action. */
leadingActions?: SwipeAction[];
/** Revealed by swiping left. The last (outermost) one is the full-swipe action. */
trailingActions?: SwipeAction[];
/** Short name of the row, used for the menu button: "Actions for Ana Ruiz". */
label?: string;
/** Swiping past the threshold runs the outermost action on release. */
fullSwipe?: boolean;
/** Full-swipe threshold as a fraction of the row width. */
fullSwipeThreshold?: number;
/** Width of each revealed action in px. */
actionWidth?: number;
/** Show labels under the action icons. */
showLabels?: boolean;
/** Spring for snapping open, closed and back. */
spring?: Spring;
/** Focus ring and menu highlight colour. */
accent?: string;
/** Classes for the sliding surface (it needs an opaque background). */
contentClassName?: string;
disabled?: boolean;
}
export interface SwipeActionsGroupProps extends React.ComponentPropsWithoutRef<"div"> {
/** Accessible name of the list. */
label?: string;
}
interface GroupContextValue {
openId: string | null;
setOpenId: (id: string | null) => void;
announce: (message: string) => void;
}
const GroupContext = React.createContext<GroupContextValue | null>(null);
const EDGE = 8;
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/** Rubber band: distance past a limit is compressed, approaching `dimension`. */
function rubber(overshoot: number, dimension: number) {
return (1 - 1 / ((overshoot * 0.55) / dimension + 1)) * dimension;
}
function menuItems(menu: HTMLElement) {
return Array.from(menu.querySelectorAll<HTMLElement>("[role=menuitem]"));
}
/**
* Wraps SwipeActions rows: renders a list, keeps only one row open at a time and owns the live
* region, so announcements survive rows that are removed by their action.
*/
export function SwipeActionsGroup({ label = "List", children, className = "", ...props }: SwipeActionsGroupProps) {
const [openId, setOpenId] = React.useState<string | null>(null);
const [message, setMessage] = React.useState("");
const announce = React.useCallback((next: string) => {
// Re-announce identical messages by toggling an invisible suffix.
setMessage((previous) => (previous === next ? `${next}\u00a0` : next));
}, []);
const value = React.useMemo(() => ({ openId, setOpenId, announce }), [openId, announce]);
return (
<GroupContext.Provider value={value}>
<div {...props} role="list" aria-label={label} className={className}>
{children}
</div>
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{message}
</span>
</GroupContext.Provider>
);
}
/**
* A list row that reveals actions when swiped (the mobile mail pattern): leading actions on a right
* swipe, trailing on a left swipe, a full swipe runs the outermost action, and it springs back.
* Every action is also reachable without swiping through the row's "Actions" menu button.
*/
export function SwipeActions({
children,
leadingActions = [],
trailingActions = [],
label,
fullSwipe = true,
fullSwipeThreshold = 0.5,
actionWidth = 76,
showLabels = true,
spring = { stiffness: 420, damping: 34, mass: 0.9 },
accent = "#FF7448",
contentClassName = "bg-white dark:bg-[#17191c]",
disabled = false,
className = "",
style,
...props
}: SwipeActionsProps) {
const group = React.useContext(GroupContext);
const uid = React.useId();
const reduceMotion = usePrefersReducedMotion();
const rowRef = React.useRef<HTMLDivElement>(null);
const menuButtonRef = React.useRef<HTMLButtonElement>(null);
const menuRef = React.useRef<HTMLDivElement | null>(null);
const cancelRef = React.useRef<HTMLButtonElement>(null);
// Set when focus should return to the menu button once the (inert-making) confirmation is gone.
const refocusMenu = React.useRef(false);
const x = useMotionValue(0);
const leadingWidth = useTransform(x, (v) => Math.max(0, v));
const trailingWidth = useTransform(x, (v) => Math.max(0, -v));
const [openSide, setOpenSide] = React.useState<Side | null>(null);
const [armed, setArmed] = React.useState<Side | null>(null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [confirming, setConfirming] = React.useState<SwipeAction | null>(null);
const [localMessage, setLocalMessage] = React.useState("");
const drag = React.useRef({
active: false,
decided: false,
pointerId: -1,
startX: 0,
startY: 0,
origin: 0,
width: 0,
armed: null as Side | null,
samples: [] as { x: number; t: number }[],
suppressClick: false,
});
const mounted = React.useRef(true);
// True while a dismissing action slides the row out (nothing may pull it back meanwhile).
const dismissing = React.useRef(false);
React.useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
// In a group, this row only counts as open while it is the group's open row.
const effectiveSide = group && group.openId !== uid ? null : openSide;
const openLeading = leadingActions.length * actionWidth;
const openTrailing = trailingActions.length * actionWidth;
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
const snapTo = React.useCallback(
(target: number) => {
animate(x, target, transition);
},
// transition is rebuilt each render from spring + reduceMotion
// eslint-disable-next-line react-hooks/exhaustive-deps
[x, reduceMotion, spring.stiffness, spring.damping, spring.mass],
);
const setOpen = (side: Side | null) => {
setOpenSide(side);
if (group) {
if (side) group.setOpenId(uid);
else if (group.openId === uid) group.setOpenId(null);
}
};
const close = () => {
snapTo(0);
setOpen(null);
};
// Another row in the group opened: slide this one closed.
const groupOpenId = group?.openId;
React.useEffect(() => {
if (group && groupOpenId !== uid && x.get() !== 0 && !drag.current.active && !dismissing.current) animate(x, 0, transition);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupOpenId]);
const announce = (message: string) => {
if (group) group.announce(message);
else setLocalMessage((previous) => (previous === message ? `${message}\u00a0` : message));
};
/** Runs an action, sliding the row out first when it dismisses. */
const run = (action: SwipeAction, side: Side) => {
if (confirming && !action.dismiss) refocusMenu.current = true;
setConfirming(null);
setOpen(null);
announce(action.announcement ?? action.label);
if (action.dismiss) {
// Keep keyboard users in the list: move focus to a neighbouring row's menu button.
const row = rowRef.current;
if (row && row.contains(document.activeElement)) {
const buttons = Array.from((row.closest("[role=list]") ?? document).querySelectorAll<HTMLElement>("[data-swipe-menu]"));
const index = buttons.indexOf(menuButtonRef.current as HTMLElement);
(buttons[index + 1] ?? buttons[index - 1])?.focus({ preventScroll: true });
}
const width = rowRef.current?.offsetWidth ?? 400;
const out = side === "leading" ? width : -width;
if (reduceMotion) {
action.onSelect?.();
return;
}
dismissing.current = true;
animate(x, out, { duration: 0.22, ease: [0.4, 0, 1, 1] }).then(() => {
action.onSelect?.();
// If the row was not removed, bring it back.
window.setTimeout(() => {
dismissing.current = false;
if (mounted.current) snapTo(0);
}, 500);
});
} else {
snapTo(0);
action.onSelect?.();
}
};
const trigger = (action: SwipeAction, side: Side) => {
if (disabled) return;
if (action.confirm) {
snapTo(0);
setOpen(null);
setConfirming(action);
return;
}
run(action, side);
};
// Focus the safe choice when a confirmation appears; Escape cancels it (only while it is shown).
React.useEffect(() => {
if (!confirming) {
if (refocusMenu.current) menuButtonRef.current?.focus({ preventScroll: true });
refocusMenu.current = false;
return;
}
cancelRef.current?.focus({ preventScroll: true });
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.isComposing) return;
event.preventDefault();
event.stopPropagation();
refocusMenu.current = true;
setConfirming(null);
};
document.addEventListener("keydown", onKey, true);
return () => document.removeEventListener("keydown", onKey, true);
}, [confirming]);
// While swiped open: Escape (with focus in the row or nowhere) or a press outside closes it.
React.useEffect(() => {
if (!effectiveSide) return;
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.isComposing) return;
const active = document.activeElement;
if (active && active !== document.body && !rowRef.current?.contains(active)) return;
event.preventDefault();
event.stopPropagation();
animate(x, 0, transition);
setOpenSide(null);
group?.setOpenId(null);
};
const onPointer = (event: PointerEvent) => {
if (rowRef.current?.contains(event.target as Node)) return;
animate(x, 0, transition);
setOpenSide(null);
};
document.addEventListener("keydown", onKey, true);
document.addEventListener("pointerdown", onPointer, true);
return () => {
document.removeEventListener("keydown", onKey, true);
document.removeEventListener("pointerdown", onPointer, true);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effectiveSide]);
/* --------------------------------- drag --------------------------------- */
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
// A swipe may start anywhere on the row, including the menu button (a drag never clicks it).
if (disabled || confirming || event.button !== 0) return;
const d = drag.current;
d.active = false;
d.decided = false;
d.pointerId = event.pointerId;
d.startX = event.clientX;
d.startY = event.clientY;
d.origin = x.get();
d.width = rowRef.current?.offsetWidth ?? 360;
d.armed = null;
d.samples = [{ x: event.clientX, t: event.timeStamp }];
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (d.pointerId !== event.pointerId) return;
const dx = event.clientX - d.startX;
const dy = event.clientY - d.startY;
if (!d.decided) {
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
d.decided = true;
// Vertical intent: let the page scroll and forget this gesture.
if (Math.abs(dy) > Math.abs(dx)) {
d.pointerId = -1;
return;
}
d.active = true;
x.stop();
event.currentTarget.setPointerCapture(event.pointerId);
if (group && group.openId !== uid) group.setOpenId(uid);
}
if (!d.active) return;
const raw = d.origin + dx;
let value = raw;
if (raw > 0) {
const limit = !leadingActions.length ? 0 : fullSwipe ? d.width : openLeading;
if (raw > limit) value = limit + rubber(raw - limit, limit ? 80 : 48);
} else {
const limit = !trailingActions.length ? 0 : fullSwipe ? d.width : openTrailing;
if (-raw > limit) value = -(limit + rubber(-raw - limit, limit ? 80 : 48));
}
x.set(value);
d.samples.push({ x: event.clientX, t: event.timeStamp });
if (d.samples.length > 5) d.samples.shift();
// Arm the full-swipe action past the threshold; only re-render when that flips.
let nextArmed: Side | null = null;
if (fullSwipe) {
const threshold = (side: Side) => Math.max(d.width * fullSwipeThreshold, (side === "leading" ? openLeading : openTrailing) + 24);
if (value > 0 && leadingActions.length && value > threshold("leading")) nextArmed = "leading";
if (value < 0 && trailingActions.length && -value > threshold("trailing")) nextArmed = "trailing";
}
if (nextArmed !== d.armed) {
d.armed = nextArmed;
setArmed(nextArmed);
}
};
const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (d.pointerId !== event.pointerId) return;
d.pointerId = -1;
if (!d.active) return;
d.active = false;
d.suppressClick = true;
const first = d.samples[0];
const last = d.samples[d.samples.length - 1];
const velocity = last && first && last.t > first.t ? (last.x - first.x) / (last.t - first.t) : 0; // px / ms
const value = x.get();
const fired = d.armed;
d.armed = null;
setArmed(null);
if (fired === "leading" && leadingActions[0]) return trigger(leadingActions[0], "leading");
if (fired === "trailing" && trailingActions.length) return trigger(trailingActions[trailingActions.length - 1], "trailing");
if (value > 0 && leadingActions.length && (value > openLeading / 2 || velocity > 0.45) && velocity > -0.45) {
snapTo(openLeading);
setOpen("leading");
} else if (value < 0 && trailingActions.length && (-value > openTrailing / 2 || velocity < -0.45) && velocity < 0.45) {
snapTo(-openTrailing);
setOpen("trailing");
} else {
close();
}
};
// Swallow the click that ends a drag, and use a tap on an open row to close it.
const onClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
const d = drag.current;
if (d.suppressClick) {
d.suppressClick = false;
event.preventDefault();
event.stopPropagation();
return;
}
if (effectiveSide && !(event.target as HTMLElement).closest("[data-swipe-menu]")) {
event.preventDefault();
event.stopPropagation();
close();
}
};
/* --------------------------------- menu --------------------------------- */
const allActions: { action: SwipeAction; side: Side }[] = [
...leadingActions.map((action) => ({ action, side: "leading" as const })),
...trailingActions.map((action) => ({ action, side: "trailing" as const })),
];
const positionMenu = React.useCallback(() => {
const menu = menuRef.current;
const anchor = menuButtonRef.current;
if (!menu || !anchor) return;
const r = anchor.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const w = menu.offsetWidth;
const h = menu.offsetHeight;
const above = r.bottom + 6 + h > vh - EDGE && r.top - 6 - h > EDGE;
const left = Math.max(EDGE, Math.min(r.right - w, vw - w - EDGE));
const top = above ? r.top - 6 - h : Math.min(r.bottom + 6, vh - h - EDGE);
menu.style.left = `${Math.round(left)}px`;
menu.style.top = `${Math.round(top)}px`;
menu.style.transformOrigin = `${Math.round(r.right - left - r.width / 2)}px ${above ? "100%" : "0%"}`;
}, []);
const closeMenu = React.useCallback((returnFocus: boolean) => {
setMenuOpen(false);
if (returnFocus) menuButtonRef.current?.focus({ preventScroll: true });
}, []);
const mountMenu = React.useCallback(
(node: HTMLDivElement | null) => {
if (!node) return;
menuRef.current = node;
if (typeof node.showPopover === "function" && !node.matches(":popover-open")) {
try {
node.showPopover();
} catch {
// No popover support: position: fixed still applies.
}
}
},
[],
);
// Position and focus on every open — also when reopening during the exit animation, where
// AnimatePresence reuses the exiting node and the callback ref doesn't run again.
React.useEffect(() => {
const menu = menuRef.current;
if (!menuOpen || !menu) return;
positionMenu();
menuItems(menu)[0]?.focus({ preventScroll: true });
}, [menuOpen, positionMenu]);
React.useEffect(() => {
if (!menuOpen) return;
let frame = 0;
const onPointer = (event: PointerEvent) => {
const target = event.target as Node;
if (menuRef.current?.contains(target) || menuButtonRef.current?.contains(target)) return;
closeMenu(false);
};
const onMove = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(positionMenu);
};
document.addEventListener("pointerdown", onPointer, true);
window.addEventListener("scroll", onMove, { capture: true, passive: true });
window.addEventListener("resize", onMove);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener("pointerdown", onPointer, true);
window.removeEventListener("scroll", onMove, { capture: true });
window.removeEventListener("resize", onMove);
};
}, [menuOpen, closeMenu, positionMenu]);
const onMenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const items = menuItems(event.currentTarget);
const index = items.indexOf(document.activeElement as HTMLElement);
const focusAt = (i: number) => items[(i + items.length) % items.length]?.focus({ preventScroll: true });
switch (event.key) {
case "ArrowDown":
event.preventDefault();
focusAt(index + 1);
break;
case "ArrowUp":
event.preventDefault();
focusAt(index < 0 ? -1 : index - 1);
break;
case "Home":
event.preventDefault();
focusAt(0);
break;
case "End":
event.preventDefault();
focusAt(-1);
break;
case "Escape":
// Close only the menu, not a surrounding dialog.
event.preventDefault();
event.stopPropagation();
closeMenu(true);
break;
case "Tab":
event.preventDefault();
closeMenu(true);
break;
case "Enter":
case " ":
if (index >= 0) {
event.preventDefault();
items[index].click();
}
break;
}
};
const chooseFromMenu = (action: SwipeAction, side: Side) => {
closeMenu(true);
trigger(action, side);
};
/* -------------------------------- render -------------------------------- */
const renderActions = (actions: SwipeAction[], side: Side) => {
const primaryIndex = side === "leading" ? 0 : actions.length - 1;
const isArmed = armed === side;
return actions.map((action, i) => {
const primary = i === primaryIndex;
const collapsed = isArmed && !primary;
return (
<button
key={action.id}
type="button"
tabIndex={effectiveSide === side ? 0 : -1}
onClick={() => trigger(action, side)}
className={`flex min-w-0 basis-0 items-center overflow-hidden outline-hidden transition-[flex-grow,padding] duration-200 ease-out focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-solid focus-visible:outline-white ${
primary && isArmed ? (side === "leading" ? "justify-end pr-6" : "justify-start pl-6") : "justify-center"
}`}
style={{ flexGrow: collapsed ? 0.0001 : 1, background: action.color ?? "#71717A", color: action.textColor ?? "#FFFFFF" }}
>
<span className="flex w-[var(--sa-w)] shrink-0 flex-col items-center justify-center gap-1" style={{ ["--sa-w" as string]: `${actionWidth}px` }}>
<motion.span
aria-hidden="true"
className="grid size-5 place-items-center [&_svg]:size-5"
animate={{ scale: primary && isArmed && !reduceMotion ? 1.22 : 1 }}
transition={{ type: "spring", stiffness: 700, damping: 12 }}
>
{action.icon}
</motion.span>
<span className={showLabels ? "max-w-full truncate px-1 text-[11px] font-medium tracking-tight" : "sr-only"}>{action.label}</span>
</span>
</button>
);
});
};
return (
<div
{...props}
ref={rowRef}
role={group ? "listitem" : undefined}
className={`relative overflow-hidden ${className}`}
style={{ ["--sa-accent" as string]: accent, ...style }}
>
{/* Revealed actions. Inert unless their side is open: the menu is the accessible path. */}
{leadingActions.length ? (
<motion.div className="absolute inset-y-0 left-0 flex" style={{ width: leadingWidth }} inert={effectiveSide !== "leading"}>
{renderActions(leadingActions, "leading")}
</motion.div>
) : null}
{trailingActions.length ? (
<motion.div className="absolute inset-y-0 right-0 flex" style={{ width: trailingWidth }} inert={effectiveSide !== "trailing"}>
{renderActions(trailingActions, "trailing")}
</motion.div>
) : null}
<motion.div
className={`relative flex touch-pan-y select-none items-center ${contentClassName}`}
style={{ x }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onClickCapture={onClickCapture}
inert={!!confirming}
>
<div className="min-w-0 flex-1">{children}</div>
{allActions.length ? (
<button
ref={menuButtonRef}
type="button"
data-swipe-menu=""
aria-label={label ? `Actions for ${label}` : "Actions"}
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-controls={menuOpen ? `${uid}-menu` : undefined}
disabled={disabled}
onClick={() => (menuOpen ? closeMenu(true) : setMenuOpen(true))}
onKeyDown={(event) => {
if (event.key === "ArrowDown" && !menuOpen) {
event.preventDefault();
setMenuOpen(true);
}
}}
className="mr-2 grid size-8 shrink-0 place-items-center rounded-full text-zinc-400 outline-hidden transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--sa-accent)] aria-expanded:bg-zinc-900/[0.06] aria-expanded:text-zinc-900 disabled:opacity-40 dark:text-zinc-500 dark:hover:bg-white/[0.08] dark:hover:text-zinc-100 dark:aria-expanded:bg-white/[0.08] dark:aria-expanded:text-zinc-100"
>
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="currentColor">
<circle cx="3.5" cy="8" r="1.25" />
<circle cx="8" cy="8" r="1.25" />
<circle cx="12.5" cy="8" r="1.25" />
</svg>
</button>
) : null}
</motion.div>
{/* Inline confirmation for destructive actions. */}
<AnimatePresence>
{confirming ? (
<motion.div
key="confirm"
role="group"
aria-labelledby={`${uid}-confirm`}
className="absolute inset-0 flex items-center justify-between gap-3 bg-white px-4 dark:bg-[#1c1e22]"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0 : 0.16 }}
>
<span aria-hidden="true" className="absolute inset-y-0 left-0 w-[3px]" style={{ background: confirming.color ?? "#E5484D" }} />
<p id={`${uid}-confirm`} className="min-w-0 truncate text-[13px] font-medium tracking-tight text-zinc-900 dark:text-zinc-50">
{confirming.confirm}
</p>
<div className="flex shrink-0 items-center gap-1.5">
<button
ref={cancelRef}
type="button"
onClick={() => {
refocusMenu.current = true;
setConfirming(null);
}}
className="h-8 rounded-full px-3 text-[13px] font-medium tracking-tight text-zinc-600 outline-hidden hover:bg-zinc-900/[0.06] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--sa-accent)] dark:text-zinc-300 dark:hover:bg-white/[0.08]"
>
Cancel
</button>
<button
type="button"
onClick={() => {
const side: Side = trailingActions.includes(confirming) ? "trailing" : "leading";
run(confirming, side);
}}
className="h-8 rounded-full px-3 text-[13px] font-medium tracking-tight text-white outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sa-accent)]"
style={{ background: confirming.color ?? "#E5484D" }}
>
{confirming.confirmLabel ?? confirming.label}
</button>
</div>
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence>
{menuOpen ? (
<motion.div
key="menu"
ref={mountMenu}
id={`${uid}-menu`}
role="menu"
aria-label={label ? `Actions for ${label}` : "Actions"}
tabIndex={-1}
popover="manual"
onKeyDown={onMenuKeyDown}
className="fixed inset-auto m-0 min-w-44 overflow-hidden rounded-xl border border-zinc-900/10 bg-white p-1 text-[13px] text-zinc-800 shadow-[0_24px_48px_-20px_rgb(0_0_0/0.4),0_2px_6px_-2px_rgb(0_0_0/0.08)] outline-hidden dark:border-white/10 dark:bg-[#1a1d21] dark:text-zinc-200 dark:shadow-[0_24px_48px_-16px_rgb(0_0_0/0.75)]"
style={{ ["--sa-accent" as string]: accent }}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}
transition={reduceMotion ? { duration: 0.08 } : { type: "spring", stiffness: 600, damping: 36 }}
>
{allActions.map(({ action, side }) => (
<div
key={`${side}-${action.id}`}
role="menuitem"
tabIndex={-1}
onClick={() => chooseFromMenu(action, side)}
onPointerMove={(event) => {
if (event.pointerType === "mouse" && document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true });
}}
className={`flex cursor-default items-center gap-2.5 rounded-lg px-2.5 py-2 outline-hidden transition-colors duration-75 focus:bg-[color-mix(in_oklab,var(--sa-accent)_14%,transparent)] ${
action.destructive ? "text-[#D93D42] dark:text-[#FF8A8E]" : "focus:text-zinc-950 dark:focus:text-white"
}`}
>
<span
aria-hidden="true"
className="grid size-5 shrink-0 place-items-center rounded-md [&_svg]:size-3.5"
style={{ background: action.color ?? "#71717A", color: action.textColor ?? "#FFFFFF" }}
>
{action.icon}
</span>
<span className="font-medium tracking-tight">{action.label}</span>
{action.confirm ? <span className="ml-auto pl-3 font-mono text-[10px] text-zinc-400 dark:text-zinc-500">…</span> : null}
</div>
))}
</motion.div>
) : null}
</AnimatePresence>
{!group ? (
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{localMessage}
</span>
) : null}
</div>
);
}