1'use client';
2
3import { cva, type VariantProps } from 'class-variance-authority';
4import { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion } from 'motion/react';
5import * as React from 'react';
6import { cn } from '../lib/cn';
7import { springs } from '../lib/motion';
8import { assignRef, getFocusable } from '../lib/overlay';
9import { useControllableState } from '../lib/use-controllable-state';
10
11type IconProps = React.SVGProps<SVGSVGElement> & {
12 size?: number | string;
13};
14
15function AlertCircleIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {
16 return (
17 <svg
18 xmlns="http://www.w3.org/2000/svg"
19 width={size}
20 height={size}
21 viewBox="0 0 24 24"
22 fill="none"
23 stroke="currentColor"
24 strokeWidth={strokeWidth}
25 strokeLinecap="round"
26 strokeLinejoin="round"
27 aria-hidden="true"
28 {...props}
29 >
30 <circle cx="12" cy="12" r="9.5" />
31 <path d="M12 7.5V13" />
32 <path d="M12 16.5h.01" />
33 </svg>
34 );
35}
36
37function AlertTriangleIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {
38 return (
39 <svg
40 xmlns="http://www.w3.org/2000/svg"
41 width={size}
42 height={size}
43 viewBox="0 0 24 24"
44 fill="none"
45 stroke="currentColor"
46 strokeWidth={strokeWidth}
47 strokeLinecap="round"
48 strokeLinejoin="round"
49 aria-hidden="true"
50 {...props}
51 >
52 <path d="M10.3 4.2 2.9 17a2 2 0 0 0 1.7 3h14.8a2 2 0 0 0 1.7-3L13.7 4.2a2 2 0 0 0-3.4 0Z" />
53 <path d="M12 9v4.5" />
54 <path d="M12 16.5h.01" />
55 </svg>
56 );
57}
58
59function CheckCircleIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {
60 return (
61 <svg
62 xmlns="http://www.w3.org/2000/svg"
63 width={size}
64 height={size}
65 viewBox="0 0 24 24"
66 fill="none"
67 stroke="currentColor"
68 strokeWidth={strokeWidth}
69 strokeLinecap="round"
70 strokeLinejoin="round"
71 aria-hidden="true"
72 {...props}
73 >
74 <circle cx="12" cy="12" r="9.5" />
75 <path d="m8 12 2.7 2.7L16 9.3" />
76 </svg>
77 );
78}
79
80function InfoIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {
81 return (
82 <svg
83 xmlns="http://www.w3.org/2000/svg"
84 width={size}
85 height={size}
86 viewBox="0 0 24 24"
87 fill="none"
88 stroke="currentColor"
89 strokeWidth={strokeWidth}
90 strokeLinecap="round"
91 strokeLinejoin="round"
92 aria-hidden="true"
93 {...props}
94 >
95 <circle cx="12" cy="12" r="9.5" />
96 <path d="M12 11v5.5" />
97 <path d="M12 7.5h.01" />
98 </svg>
99 );
100}
101
102function XIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {
103 return (
104 <svg
105 xmlns="http://www.w3.org/2000/svg"
106 width={size}
107 height={size}
108 viewBox="0 0 24 24"
109 fill="none"
110 stroke="currentColor"
111 strokeWidth={strokeWidth}
112 strokeLinecap="round"
113 strokeLinejoin="round"
114 aria-hidden="true"
115 {...props}
116 >
117 <path d="M18 6 6 18" />
118 <path d="m6 6 12 12" />
119 </svg>
120 );
121}
122
123const bannerVariants = cva(
124 'relative flex w-full items-center justify-center gap-3 border-b px-4 py-2 text-sm rounded-md',
125 {
126 variants: {
127 variant: {
128 default: 'bg-inverse text-inverse-foreground border-inverse',
129 info: 'bg-info text-info-foreground border-info',
130 warning: 'bg-warning text-warning-foreground border-warning',
131 danger: 'bg-destructive text-destructive-foreground border-destructive',
132 success: 'bg-success text-success-foreground border-success',
133 },
134 },
135 defaultVariants: {
136 variant: 'default',
137 },
138 },
139);
140
141type BannerVariant = NonNullable<VariantProps<typeof bannerVariants>['variant']>;
142
143type BannerRole = 'region' | 'status' | 'alert';
144
145type BannerProps = Omit<HTMLMotionProps<'div'>, 'children' | 'role' | 'ref'> &
146 VariantProps<typeof bannerVariants> & {
147 ref?: React.Ref<HTMLDivElement>;
148 icon?: React.ReactNode;
149 open?: boolean;
150 defaultOpen?: boolean;
151 onOpenChange?: (open: boolean) => void;
152 onClose?: () => void;
153 sticky?: boolean;
154 dismissible?: boolean;
155 dismissLabel?: string;
156 role?: BannerRole;
157 label?: string;
158 className?: string;
159 children?: React.ReactNode;
160 };
161
162type DivWithRefProps = React.HTMLAttributes<HTMLDivElement> & {
163 ref?: React.Ref<HTMLDivElement>;
164};
165
166const VARIANT_ICONS: Record<BannerVariant, React.ComponentType<IconProps> | undefined> = {
167 default: undefined,
168 info: InfoIcon,
169 warning: AlertTriangleIcon,
170 danger: AlertCircleIcon,
171 success: CheckCircleIcon,
172};
173
174const BANNER_INITIAL = { opacity: 0, y: -8 } as const;
175const BANNER_ANIMATE = { opacity: 1, y: 0 } as const;
176const BANNER_TRANSITION = { ...springs.smooth, opacity: { duration: 0.2 } };
177
178const BANNER_EXIT = {
179 opacity: 0,
180 height: 0,
181 paddingTop: 0,
182 paddingBottom: 0,
183 marginBottom: 0,
184 overflow: 'hidden' as const,
185} as const;
186
187const BANNER_EXIT_TRANSITION = { duration: 0.2, ease: 'easeIn' } as const;
188const REDUCED_MOTION_PROPS = {
189 initial: { opacity: 0 },
190 animate: { opacity: 1 },
191 exit: { opacity: 0 },
192 transition: { duration: 0.12 },
193} as const;
194
195const BannerContent = ({ className, ref, ...props }: DivWithRefProps) => (
196 <div ref={ref} className={cn('flex-1 space-y-2', className)} {...props} />
197);
198BannerContent.displayName = 'BannerContent';
199
200const BannerTitle = ({ className, ref, ...props }: DivWithRefProps) => (
201 <div
202 ref={ref}
203 className={cn('leading-none font-semibold tracking-tight', className)}
204 {...props}
205 />
206);
207BannerTitle.displayName = 'BannerTitle';
208
209const BannerDescription = ({ className, ref, ...props }: DivWithRefProps) => (
210 <div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
211);
212BannerDescription.displayName = 'BannerDescription';
213
214type BannerActionProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
215 ref?: React.Ref<HTMLAnchorElement>;
216};
217
218const BannerAction = ({ className, children, ref, ...props }: BannerActionProps) => (
219 <a
220 ref={ref}
221 className={cn(
222 'shrink-0 rounded-md border border-current/20 px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors hover:bg-current/10 focus-visible:ring-2 focus-visible:ring-current focus-visible:outline-none',
223 className,
224 )}
225 {...props}
226 >
227 {children}
228 </a>
229);
230BannerAction.displayName = 'BannerAction';
231
232function findFocusAfter(node: HTMLElement) {
233 const candidates = getFocusable(document.body).filter((element) => !node.contains(element));
234 const next = candidates.find(
235 (element) => node.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING,
236 );
237 return next ?? candidates.at(-1) ?? null;
238}
239
240const BannerRoot = ({
241 className,
242 variant = 'default',
243 icon,
244 open: openProp,
245 defaultOpen = true,
246 onOpenChange,
247 onClose,
248 sticky = false,
249 dismissible = true,
250 dismissLabel = 'Dismiss banner',
251 role = 'region',
252 label = 'Announcement',
253 children,
254 ref,
255 ...props
256}: BannerProps) => {
257 const [open, setOpen] = useControllableState({
258 value: openProp,
259 defaultValue: defaultOpen,
260 onChange: onOpenChange,
261 });
262 const shouldReduceMotion = useReducedMotion();
263 const resolvedVariant: BannerVariant = variant ?? 'default';
264 const VariantIcon = icon === undefined ? VARIANT_ICONS[resolvedVariant] : null;
265 const nodeRef = React.useRef<HTMLDivElement | null>(null);
266 const focusTargetRef = React.useRef<HTMLElement | null>(null);
267 const ariaLabel =
268 props['aria-label'] ?? (role === 'region' && !props['aria-labelledby'] ? label : undefined);
269
270 const setRefs = React.useCallback(
271 (node: HTMLDivElement | null) => {
272 nodeRef.current = node;
273 assignRef(ref, node);
274 },
275 [ref],
276 );
277
278 const handleClose = React.useCallback(() => {
279 const node = nodeRef.current;
280 focusTargetRef.current =
281 node && node.contains(document.activeElement) ? findFocusAfter(node) : null;
282 setOpen(false);
283 onClose?.();
284 }, [onClose, setOpen]);
285
286 React.useEffect(() => {
287 const target = focusTargetRef.current;
288 focusTargetRef.current = null;
289 if (open || !target?.isConnected) return;
290 const active = document.activeElement;
291 const focusIsLost =
292 !active || active === document.body || (nodeRef.current?.contains(active) ?? false);
293 if (focusIsLost) target.focus();
294 }, [open]);
295
296 return (
297 <AnimatePresence>
298 {open && (
299 <motion.div
300 {...props}
301 ref={setRefs}
302 role={role}
303 aria-label={ariaLabel}
304 initial={shouldReduceMotion ? REDUCED_MOTION_PROPS.initial : BANNER_INITIAL}
305 animate={shouldReduceMotion ? REDUCED_MOTION_PROPS.animate : BANNER_ANIMATE}
306 exit={
307 shouldReduceMotion
308 ? { ...BANNER_EXIT, transition: REDUCED_MOTION_PROPS.transition }
309 : { ...BANNER_EXIT, transition: BANNER_EXIT_TRANSITION }
310 }
311 transition={shouldReduceMotion ? REDUCED_MOTION_PROPS.transition : BANNER_TRANSITION}
312 className={cn(
313 bannerVariants({ variant: resolvedVariant }),
314 sticky && 'sticky top-0 z-50',
315 className,
316 )}
317 >
318 {(icon || VariantIcon) && (
319 <div aria-hidden="true" className="shrink-0 select-none">
320 {icon ?? (VariantIcon && <VariantIcon size={16} />)}
321 </div>
322 )}
323
324 <div className="flex flex-1 items-center gap-3">{children}</div>
325
326 {dismissible && (
327 <button
328 type="button"
329 onClick={handleClose}
330 className="focus-visible:ring-ring -m-1.5 shrink-0 cursor-pointer rounded-md p-2.5 opacity-70 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:outline-none"
331 aria-label={dismissLabel}
332 >
333 <XIcon size={14} aria-hidden="true" />
334 </button>
335 )}
336 </motion.div>
337 )}
338 </AnimatePresence>
339 );
340};
341BannerRoot.displayName = 'Banner';
342
343const Banner = Object.assign(BannerRoot, {
344 Content: BannerContent,
345 Title: BannerTitle,
346 Description: BannerDescription,
347 Action: BannerAction,
348});
349
350export { Banner, bannerVariants };
351export type { BannerProps, BannerVariant };