import React, { useRef, useEffect, useState, useMemo } from 'react'; import styled, { css } from 'styled-components'; import type { ResolvedBannerConfig } from '@redocly/config'; import type { JSX } from 'react'; import { useThemeHooks, useBannerTelemetry } from '@redocly/theme/core/hooks'; import { getNavbarElement } from '@redocly/theme/core/utils'; import { MarkdownLinkContext } from '@redocly/theme/core/contexts'; import { Markdown } from '@redocly/theme/components/Markdown/Markdown'; import { CloseIcon } from '@redocly/theme/icons/CloseIcon/CloseIcon'; import { Button } from '@redocly/theme/components/Button/Button'; const ANIMATION_DURATION = 0.4; type BannerProps = { className?: string; }; export type DisplayBanner = ResolvedBannerConfig & { color: NonNullable; dismissible: NonNullable; }; function toDisplayBanner(banner: ResolvedBannerConfig): DisplayBanner { return { ...banner, color: banner.color ?? 'info', dismissible: banner.dismissible ?? false, }; } function setBannerHeight(height: number): void { document.documentElement.style.setProperty('--banner-height', `${height}px`); } export function Banner({ className }: BannerProps): JSX.Element | null { const { useBanner, useMarkdocRenderer } = useThemeHooks(); const { banner, dismissBanner } = useBanner(); const [displayBanner, setDisplayBanner] = useState(undefined); const [isVisible, setIsVisible] = useState(false); const { sendBannerViewedMessage, sendBannerDismissedMessage, sendBannerLinkClickedMessage } = useBannerTelemetry(displayBanner); const markdownContent = useMarkdocRenderer(displayBanner?.ast); const bannerRef = useRef(null); useEffect(() => { if (banner) { setDisplayBanner(toDisplayBanner(banner)); requestAnimationFrame(() => { requestAnimationFrame(() => { setIsVisible(true); }); }); } else { setIsVisible(false); const timer = setTimeout(() => { setDisplayBanner(undefined); }, 400); return () => clearTimeout(timer); } }, [banner]); useEffect(() => { if (!displayBanner) { const timer = setTimeout(() => { setBannerHeight(0); }, 400); return () => clearTimeout(timer); } const bannerElement = bannerRef.current; if (!bannerElement) return; if (!isVisible) { setBannerHeight(0); return; } const updateHeight = (): void => { const height = bannerElement.getBoundingClientRect().height; setBannerHeight(height); }; updateHeight(); if (window.location.hash) { setTimeout( () => { const hash = window.location.hash; const el = document.getElementById(hash.slice(1)); if (el) { const navbar = getNavbarElement(); const navbarHeight = navbar?.getBoundingClientRect().height ?? 0; const elementTop = el.getBoundingClientRect().top + window.scrollY; const scrollPosition = elementTop - navbarHeight; if (Math.abs(window.scrollY - scrollPosition) > 1) { window.scrollTo({ top: scrollPosition }); } } }, ANIMATION_DURATION * 1000 + 100, ); } const resizeObserver = new ResizeObserver(updateHeight); resizeObserver.observe(bannerElement); return () => { resizeObserver.disconnect(); }; }, [displayBanner, isVisible]); useEffect(() => { if (isVisible) { sendBannerViewedMessage(); } }, [isVisible, sendBannerViewedMessage]); const markdownLinkContextValue = useMemo( () => ({ onMarkdownLinkClick: sendBannerLinkClickedMessage, }), [sendBannerLinkClickedMessage], ); if (!displayBanner) { return null; } return ( {markdownContent} {displayBanner.dismissible && ( } onClick={() => { dismissBanner(displayBanner.hash); sendBannerDismissedMessage(); }} aria-label="Dismiss banner" /> )} ); } const BannerContent = styled.div<{ $color: DisplayBanner['color'] }>` flex: 1; display: flex; align-items: center; justify-content: center; p { margin: 0; color: var(--banner-text-color); text-align: center; a:not([role='button']) { color: var(--banner-link-color); text-decoration: ${({ $color }) => $color && `var(--banner-${$color}-link-decoration)`}; &:hover, &:focus { text-decoration: ${({ $color }) => $color && `var(--banner-${$color}-link-decoration-hover)`}; } &:visited { text-decoration: ${({ $color }) => $color && `var(--banner-${$color}-link-decoration)`}; } } [data-component-name='Button/Button'] { --button-font-size: var(--banner-button-font-size); --button-border-radius: var(--banner-button-border-radius); --button-padding: var(--banner-button-padding-inline); --button-line-height: var(--banner-button-line-height); --button-icon-size: var(--banner-button-icon-size); --button-icon-padding: var(--banner-button-icon-padding); --button-icon-left-padding: var(--banner-button-icon-left-padding); --button-icon-right-padding: var(--banner-button-icon-right-padding); margin: var(--banner-button-margin); } } p > * { vertical-align: bottom; } `; const BannerWrapper = styled.div<{ $color?: DisplayBanner['color']; $isVisible?: boolean }>` display: flex; align-items: center; justify-content: space-between; padding: var(--banner-padding); color: var(--banner-text-color); min-height: var(--banner-min-height); position: absolute; top: 0; left: 0; right: 0; width: 100%; z-index: var(--z-index-overlay); transform: ${({ $isVisible }) => ($isVisible ? 'translateY(0)' : 'translateY(-100%)')}; transition: transform ${ANIMATION_DURATION}s ease-out; ${({ $color }) => $color && css` background-color: var(--banner-${$color}-bg-color); ${BannerContent} { p { color: var(--banner-${$color}-text-color); } a:not([role='button']) { color: var(--banner-${$color}-link-color); } } `} `; const DismissButton = styled(Button)` width: var(--banner-button-size); height: var(--banner-button-size); padding: var(--banner-button-padding); `;