{ "name": "banner", "type": "registry:ui", "files": [ { "path": "ui/banner.tsx", "type": "registry:ui", "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { X } from \"lucide-react\";\nimport * as React from \"react\";\nimport * as ReactDOM from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\nimport { useAsRef } from \"@/registry/bases/base/hooks/use-as-ref\";\nimport { useLazyRef } from \"@/registry/bases/base/hooks/use-lazy-ref\";\nimport { Button } from \"@/registry/bases/base/ui/button\";\n\nconst BANNER_ANIMATION_DURATION = 400;\nconst DEFAULT_BANNER_PRIORITY = 0;\nconst DEFAULT_BANNER_DISMISSIBLE = true;\n\ntype BannerVariant = \"default\" | \"info\" | \"success\" | \"warning\" | \"destructive\";\ntype BannerSide = \"top\" | \"bottom\";\ntype BannerStrategy = \"fixed\" | \"static\" | \"sticky\" | \"absolute\";\n\ninterface DivProps\n extends useRender.ComponentProps<\"div\">,\n React.ComponentProps<\"div\"> {}\n\ninterface BannerRenderProps {\n id: string;\n variant?: BannerVariant;\n dismissible: boolean;\n onClose: () => void;\n onRemove: () => void;\n}\n\ntype BannerContent =\n | React.ReactNode\n | ((props: BannerRenderProps) => React.ReactNode);\n\ninterface BannerData {\n id: string;\n content: BannerContent;\n variant?: BannerVariant;\n priority?: number;\n duration?: number;\n dismissible?: boolean;\n onDismiss?: () => void;\n}\n\ninterface StoreState {\n banners: BannerData[];\n removing: Set;\n heights: Map;\n}\n\ninterface Store {\n subscribe: (callback: () => void) => () => void;\n getState: () => StoreState;\n notify: () => void;\n onBannerAdd: (banner: Omit) => string;\n onBannerRemove: (id: string) => void;\n onBannersClear: () => void;\n onRemovingChange: (id: string, value: boolean) => void;\n onHeightChange: (id: string, height: number) => void;\n onHeightRemove: (id: string) => void;\n}\n\nconst StoreContext = React.createContext(null);\n\nfunction useStoreContext(consumerName: string) {\n const context = React.useContext(StoreContext);\n if (!context) {\n throw new Error(`\\`${consumerName}\\` must be used within \\`Banners\\``);\n }\n return context;\n}\n\nfunction useStore(store: Store, selector: (state: StoreState) => T): T {\n return React.useSyncExternalStore(\n store.subscribe,\n () => selector(store.getState()),\n () => selector(store.getState()),\n );\n}\n\ninterface BannerContextValue {\n id?: string;\n variant?: BannerVariant | null;\n dismissible?: boolean;\n onClose?: () => void;\n}\n\nconst BannerContext = React.createContext(null);\n\nfunction useBannerContext(consumerName: string) {\n const context = React.useContext(BannerContext);\n if (!context) {\n throw new Error(`\\`${consumerName}\\` must be used within \\`Banner\\``);\n }\n return context;\n}\n\nfunction useBanner() {\n const { id, variant, dismissible, onClose } = useBannerContext(\"useBanner\");\n const storeContext = React.useContext(StoreContext);\n\n return React.useMemo(() => {\n const onRemove =\n id && storeContext ? () => storeContext.onBannerRemove(id) : undefined;\n\n return {\n id,\n variant,\n dismissible,\n onClose,\n onRemove,\n };\n }, [id, variant, dismissible, onClose, storeContext]);\n}\n\ninterface BannersProps {\n children?: React.ReactNode;\n maxVisible?: number;\n side?: BannerSide;\n strategy?: BannerStrategy;\n container?: Element | DocumentFragment | null;\n}\n\nfunction Banners(props: BannersProps) {\n const {\n children,\n maxVisible = 1,\n side = \"top\",\n strategy = \"fixed\",\n container: containerProp,\n } = props;\n\n const stateRef = useLazyRef(() => ({\n banners: [],\n removing: new Set(),\n heights: new Map(),\n }));\n const listenersRef = useLazyRef void>>(() => new Set());\n const timeoutsRef = useLazyRef>>(\n () => new Map(),\n );\n\n const store: Store = React.useMemo(\n () => ({\n subscribe: (cb) => {\n listenersRef.current.add(cb);\n return () => listenersRef.current.delete(cb);\n },\n getState: () => stateRef.current,\n notify: () => {\n for (const listener of listenersRef.current) {\n listener();\n }\n },\n onBannerAdd: (banner) => {\n const id = crypto.randomUUID();\n const newBanner: BannerData = { ...banner, id };\n const priority = banner.priority ?? DEFAULT_BANNER_PRIORITY;\n\n const banners = [...stateRef.current.banners];\n const insertIndex = banners.findIndex(\n (b) => (b.priority ?? DEFAULT_BANNER_PRIORITY) < priority,\n );\n\n if (insertIndex === -1) {\n banners.push(newBanner);\n } else {\n banners.splice(insertIndex, 0, newBanner);\n }\n\n stateRef.current.banners = banners;\n store.notify();\n\n if (banner.duration && banner.duration > 0) {\n const timeoutId = setTimeout(() => {\n store.onRemovingChange(id, true);\n timeoutsRef.current.delete(id);\n }, banner.duration);\n timeoutsRef.current.set(id, timeoutId);\n }\n\n return id;\n },\n onBannerRemove: (id) => {\n const banner = stateRef.current.banners.find((b) => b.id === id);\n if (!banner) return;\n\n const timeoutId = timeoutsRef.current.get(id);\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutsRef.current.delete(id);\n }\n\n const newRemoving = new Set(stateRef.current.removing);\n newRemoving.delete(id);\n stateRef.current.removing = newRemoving;\n\n banner.onDismiss?.();\n stateRef.current.banners = stateRef.current.banners.filter(\n (b) => b.id !== id,\n );\n store.notify();\n },\n onBannersClear: () => {\n for (const timeoutId of timeoutsRef.current.values()) {\n clearTimeout(timeoutId);\n }\n timeoutsRef.current.clear();\n stateRef.current.removing = new Set();\n stateRef.current.heights = new Map();\n stateRef.current.banners = [];\n store.notify();\n },\n onRemovingChange: (id, value) => {\n const newSet = new Set(stateRef.current.removing);\n if (value) {\n newSet.add(id);\n } else {\n newSet.delete(id);\n }\n stateRef.current.removing = newSet;\n store.notify();\n },\n onHeightChange: (id, height) => {\n if (stateRef.current.heights.get(id) === height) return;\n const newHeights = new Map(stateRef.current.heights);\n newHeights.set(id, height);\n stateRef.current.heights = newHeights;\n store.notify();\n },\n onHeightRemove: (id) => {\n if (!stateRef.current.heights.has(id)) return;\n const newHeights = new Map(stateRef.current.heights);\n newHeights.delete(id);\n stateRef.current.heights = newHeights;\n store.notify();\n },\n }),\n [stateRef, listenersRef, timeoutsRef],\n );\n\n const banners = useStore(store, (state) => state.banners);\n const heights = useStore(store, (state) => state.heights);\n const visibleBanners = banners.slice(0, maxVisible);\n\n const withPortal = strategy === \"fixed\" || strategy === \"absolute\";\n const container = withPortal\n ? (containerProp ?? globalThis.document?.body ?? null)\n : null;\n\n const totalHeight = React.useMemo(() => {\n let total = 0;\n for (const banner of visibleBanners) {\n total += heights.get(banner.id) ?? 0;\n }\n return total;\n }, [visibleBanners, heights]);\n\n const bannerContainer = visibleBanners.length > 0 && (\n 0 ? totalHeight : \"auto\",\n transition: `height ${BANNER_ANIMATION_DURATION}ms cubic-bezier(0.32, 0.72, 0, 1)`,\n }}\n >\n {visibleBanners.map((banner, index) => (\n \n ))}\n \n );\n\n return (\n \n {strategy === \"static\" || strategy === \"sticky\" ? (\n <>\n {side === \"top\" && bannerContainer}\n {children}\n {side === \"bottom\" && bannerContainer}\n \n ) : (\n <>\n {children}\n {container &&\n bannerContainer &&\n ReactDOM.createPortal(bannerContainer, container)}\n \n )}\n \n );\n}\n\nfunction useBanners() {\n const store = useStoreContext(\"useBanners\");\n const banners = useStore(store, (state) => state.banners);\n\n return React.useMemo(\n () => ({\n onBannerAdd: store.onBannerAdd,\n onBannerRemove: store.onBannerRemove,\n onBannersClear: store.onBannersClear,\n banners,\n }),\n [store, banners],\n );\n}\n\nconst bannerVariants = cva(\n \"pointer-events-auto relative flex w-full items-center gap-3 border-b px-4 py-3 text-sm motion-reduce:transition-none\",\n {\n variants: {\n variant: {\n default: \"bg-card text-card-foreground\",\n info: \"bg-blue-50 text-blue-900 dark:bg-blue-950 dark:text-blue-50\",\n success:\n \"bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-50\",\n warning:\n \"bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-50\",\n destructive: \"bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-50\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n },\n);\n\ninterface BannerImplProps {\n banner: BannerData;\n side: BannerSide;\n index: number;\n}\n\nfunction BannerImpl(props: BannerImplProps) {\n const { banner, side, index } = props;\n\n const store = useStoreContext(\"BannerImpl\");\n const removing = useStore(store, (state) => state.removing.has(banner.id));\n const banners = useStore(store, (state) => state.banners);\n const heights = useStore(store, (state) => state.heights);\n\n const [mounted, setMounted] = React.useState(false);\n const bannerRef = React.useRef(null);\n const offsetBeforeRemoveRef = React.useRef(0);\n\n const offset = React.useMemo(() => {\n let total = 0;\n for (const b of banners) {\n if (b.id === banner.id) break;\n total += heights.get(b.id) ?? 0;\n }\n return total;\n }, [banners, heights, banner.id]);\n\n if (!removing) {\n offsetBeforeRemoveRef.current = offset;\n }\n\n React.useEffect(() => {\n const frame = requestAnimationFrame(() => setMounted(true));\n return () => cancelAnimationFrame(frame);\n }, []);\n\n React.useLayoutEffect(() => {\n if (!bannerRef.current || removing) return;\n const height = bannerRef.current.getBoundingClientRect().height;\n store.onHeightChange(banner.id, height);\n }, [store, banner.id, removing]);\n\n React.useEffect(() => {\n if (!removing) return;\n store.onHeightRemove(banner.id);\n const timeoutId = setTimeout(\n () => store.onBannerRemove(banner.id),\n BANNER_ANIMATION_DURATION,\n );\n return () => clearTimeout(timeoutId);\n }, [removing, store, banner.id]);\n\n const onClose = React.useCallback(\n () => store.onRemovingChange(banner.id, true),\n [store, banner.id],\n );\n\n const onRemove = React.useCallback(\n () => store.onBannerRemove(banner.id),\n [store, banner.id],\n );\n\n const dismissible = banner.dismissible ?? DEFAULT_BANNER_DISMISSIBLE;\n\n const contextValue = React.useMemo(\n () => ({ id: banner.id, variant: banner.variant, dismissible, onClose }),\n [banner.id, banner.variant, dismissible, onClose],\n );\n\n const renderProps = React.useMemo(\n () => ({\n id: banner.id,\n variant: banner.variant,\n dismissible,\n onClose,\n onRemove,\n }),\n [banner.id, banner.variant, dismissible, onClose, onRemove],\n );\n\n const currentOffset = removing ? offsetBeforeRemoveRef.current : offset;\n const isTop = side === \"top\";\n\n function getTransform() {\n if (!mounted) return isTop ? \"translateY(-100%)\" : \"translateY(100%)\";\n if (removing) {\n return isTop\n ? `translateY(calc(${currentOffset}px - 100%))`\n : `translateY(calc(-${currentOffset}px + 100%))`;\n }\n return isTop\n ? `translateY(${currentOffset}px)`\n : `translateY(-${currentOffset}px)`;\n }\n\n return (\n \n \n {typeof banner.content === \"function\"\n ? banner.content(renderProps)\n : banner.content}\n \n \n );\n}\n\ninterface BannerProps extends DivProps, VariantProps {\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n onDismiss?: () => void;\n priority?: number;\n duration?: number;\n dismissible?: boolean;\n}\n\nfunction Banner(props: BannerProps) {\n const {\n className,\n variant = \"default\",\n open: openProp,\n defaultOpen = true,\n onOpenChange,\n onDismiss,\n priority,\n duration,\n dismissible = DEFAULT_BANNER_DISMISSIBLE,\n children,\n render,\n ...rootProps\n } = props;\n\n const store = React.useContext(StoreContext);\n\n const isInsideStore = store !== null;\n const isControlled = openProp !== undefined;\n\n const openRef = useLazyRef(() => openProp ?? defaultOpen);\n const listenersRef = useLazyRef void>>(() => new Set());\n const bannerIdRef = React.useRef(null);\n const onDismissRef = useAsRef(onDismiss);\n const onOpenChangeRef = useAsRef(onOpenChange);\n\n if (isControlled) {\n openRef.current = openProp;\n }\n\n const subscribe = React.useCallback(\n (cb: () => void) => {\n listenersRef.current.add(cb);\n return () => listenersRef.current.delete(cb);\n },\n [listenersRef],\n );\n\n const getSnapshot = React.useCallback(() => openRef.current, [openRef]);\n\n const open = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n\n React.useEffect(() => {\n if (!isInsideStore || !store || !open) return;\n\n const id = store.onBannerAdd({\n content: children,\n variant: variant ?? undefined,\n priority,\n dismissible,\n duration,\n onDismiss: () => {\n onDismissRef.current?.();\n onOpenChangeRef.current?.(false);\n },\n });\n bannerIdRef.current = id;\n\n return () => {\n if (bannerIdRef.current) {\n store.onBannerRemove(bannerIdRef.current);\n bannerIdRef.current = null;\n }\n };\n }, [\n isInsideStore,\n store,\n open,\n children,\n variant,\n priority,\n dismissible,\n duration,\n onDismissRef,\n onOpenChangeRef,\n ]);\n\n const onClose = React.useCallback(() => {\n if (!isControlled) {\n openRef.current = false;\n for (const listener of listenersRef.current) {\n listener();\n }\n }\n onOpenChangeRef.current?.(false);\n }, [isControlled, openRef, listenersRef, onOpenChangeRef]);\n\n const contextValue = React.useMemo(\n () => ({\n variant,\n dismissible,\n onClose,\n }),\n [variant, dismissible, onClose],\n );\n\n const rendered = useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n role: \"status\",\n \"aria-live\": \"polite\",\n className: cn(bannerVariants({ variant }), className),\n children,\n },\n rootProps,\n ),\n render,\n state: {\n slot: \"banner\",\n state: \"open\",\n },\n });\n\n if (!open || isInsideStore) return null;\n\n return (\n \n {rendered}\n \n );\n}\n\nfunction BannerIcon(props: DivProps) {\n const { className, render, ...iconProps } = props;\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n className: cn(\"flex shrink-0 items-center [&>svg]:size-4\", className),\n },\n iconProps,\n ),\n render,\n state: {\n slot: \"banner-icon\",\n },\n });\n}\n\nfunction BannerContent(props: DivProps) {\n const { className, render, ...contentProps } = props;\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n className: cn(\"flex min-w-0 flex-1 flex-col gap-1\", className),\n },\n contentProps,\n ),\n render,\n state: {\n slot: \"banner-content\",\n },\n });\n}\n\nfunction BannerTitle(props: React.ComponentProps<\"div\">) {\n const { className, ...titleProps } = props;\n\n return (\n \n );\n}\n\nfunction BannerDescription(props: React.ComponentProps<\"div\">) {\n const { className, ...descriptionProps } = props;\n\n return (\n \n );\n}\n\nfunction BannerActions(props: DivProps) {\n const { className, render, ...actionsProps } = props;\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n className: cn(\"flex items-center gap-2\", className),\n },\n actionsProps,\n ),\n render,\n state: {\n slot: \"banner-actions\",\n },\n });\n}\n\nfunction BannerClose(props: React.ComponentProps) {\n const { onClick: onClickProp, disabled, children, ...closeProps } = props;\n\n const { dismissible = DEFAULT_BANNER_DISMISSIBLE, onClose } =\n useBannerContext(\"BannerClose\");\n\n const isDisabled = disabled ?? !dismissible;\n\n const onClick: React.ComponentProps[\"onClick\"] =\n React.useCallback(\n (event) => {\n onClickProp?.(event);\n if (event.defaultPrevented || isDisabled) return;\n onClose?.();\n },\n [onClickProp, isDisabled, onClose],\n );\n\n return (\n \n {children ?? }\n \n );\n}\n\nexport {\n Banner,\n BannerActions,\n BannerClose,\n BannerContent,\n BannerDescription,\n BannerIcon,\n Banners,\n BannerTitle,\n useBanner,\n useBanners,\n};\n", "target": "" } ], "registryDependencies": [ "button", "@diceui/use-as-ref", "@diceui/use-lazy-ref" ], "dependencies": [ "@base-ui/react" ] }