"use client"; import { useStatusBlocksLabels } from "@openstatus/ui/components/blocks/status-i18n"; import type { StatusBarData, StatusEventType, StatusReportImpact, StatusType, } from "@openstatus/ui/components/blocks/status.types"; import { statusColors } from "@openstatus/ui/components/blocks/status.utils"; import { HoverCard, HoverCardContent, HoverCardTrigger, } from "@openstatus/ui/components/ui/hover-card"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { useMediaQuery } from "@openstatus/ui/hooks/use-media-query"; import { cn } from "@openstatus/ui/lib/utils"; import { formatDistanceStrict } from "date-fns"; import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; interface StatusBarProps { data: StatusBarData[]; renderCard?: ( data: StatusBarData["card"][number], index: number, ) => React.ReactNode; renderBar?: ( data: StatusBarData["bar"][number], index: number, ) => React.ReactNode; renderEvent?: ( data: StatusBarData["events"][number], index: number, ) => React.ReactNode; /** * Optional Radix Portal container for the day hover-card. Defaults to * `document.body`. Pass a ref when the bar is rendered inside a scoped * subtree (e.g. a status-page preview that overrides `--radius` and other * CSS vars on a wrapper) so the portaled card inherits the same context. */ container?: HTMLElement | null; } interface UseStatusBarProps { dataLength: number; isTouch: boolean; } type InteractionType = "pin" | "hover" | "focus" | null; /** * useStatusBar - Headless hook for managing status bar interactions and keyboard navigation * * This hook provides the core logic for StatusBar interactions, implementing a * headless UI pattern that separates state management from presentation. It handles: * * **Interaction Modes**: * - **hover**: Desktop hover state (shows card on mouse hover, auto-hides on leave) * - **pin**: Pinned state (click to pin card open, click again or Esc to close) * - **focus**: Keyboard focus state (shows card while focused, hides on blur) * * **Keyboard Navigation**: * - **Arrow Left/Right**: Navigate between bars in the same timeline * - **Arrow Up/Down**: Navigate between timelines (different monitors) * - **Enter/Space**: Pin/unpin the active bar * - **Escape**: Close pinned card and remove focus * * **Touch Device Support**: * - Disables hover state on touch devices (detected via `(hover: none)` media query) * - Click interaction works on both touch and non-touch devices * * **Outside Click Handling**: * - Automatically closes pinned cards when clicking outside the component * * @param dataLength - Total number of bars in the timeline * @param isTouch - Whether the device supports touch (no hover capability) * * @returns Hook state and handlers: * - `activeIndex`: Currently active bar index (null if none) * - `isOpen`: Whether a card is currently displayed * - `interactionType`: Current interaction mode ("pin" | "hover" | "focus" | null) * - `containerRef`: Ref for the status bar container (for outside click detection) * - `handlers`: Event handler functions for mouse/keyboard/focus interactions * - `setButtonRef`: Function to register bar element refs (for keyboard navigation) * * @example * ```tsx * function CustomStatusBar({ data }) { * const isTouch = useMediaQuery("(hover: none)"); * const { activeIndex, handlers, setButtonRef, containerRef } = useStatusBar({ * dataLength: data.length, * isTouch, * }); * * return ( *
* {data.map((item, index) => ( * * ))} *
* ); * } * ``` * * @see StatusBar - For the complete implementation using this hook */ function useStatusBar({ dataLength, isTouch }: UseStatusBarProps) { const [activeIndex, setActiveIndex] = useState(null); const [interactionType, setInteractionType] = useState(null); const buttonRefs = useRef<(HTMLElement | null)[]>([]); const hoverTimeoutRef = useRef | null>(null); const containerRef = useRef(null); // Clear hover timeout on unmount useEffect(() => { return () => { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); } }; }, []); // Handle clicks outside to close pinned card useEffect(() => { if (interactionType !== "pin" || activeIndex === null) return; const handleOutsideClick = (e: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(e.target as Node) ) { setActiveIndex(null); setInteractionType(null); } }; document.addEventListener("mousedown", handleOutsideClick); return () => document.removeEventListener("mousedown", handleOutsideClick); }, [interactionType, activeIndex]); const clearHoverTimeout = useCallback(() => { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = null; } }, []); const interactionTypeRef = useRef(null); useEffect(() => { interactionTypeRef.current = interactionType; }, [interactionType]); const handleClick = useCallback( (index: number) => { clearHoverTimeout(); setActiveIndex((prev) => { if (prev === index && interactionTypeRef.current === "pin") { setInteractionType(null); return null; } setInteractionType("pin"); return index; }); }, [clearHoverTimeout], ); const handleHoverStart = useCallback( (index: number) => { // On touch devices, don't show hover state if (isTouch) return; clearHoverTimeout(); setActiveIndex(index); setInteractionType("hover"); }, [isTouch, clearHoverTimeout], ); const handleHoverEnd = useCallback(() => { // Only clear hover state, not pinned or focused if (interactionType !== "hover") return; hoverTimeoutRef.current = setTimeout(() => { setActiveIndex(null); setInteractionType(null); }, 100); }, [interactionType]); const handleFocus = useCallback((index: number) => { setActiveIndex(index); setInteractionType("focus"); }, []); const handleBlur = useCallback((e: React.FocusEvent) => { // Only clear if not moving to another bar const relatedTarget = e.relatedTarget as HTMLElement; const isMovingToAnotherBar = relatedTarget && relatedTarget.closest('[role="toolbar"]') === containerRef.current && relatedTarget.getAttribute("role") === "button"; if (!isMovingToAnotherBar) { setActiveIndex(null); setInteractionType(null); } }, []); const handleKeyDown = useCallback( (e: React.KeyboardEvent, currentIndex: number) => { switch (e.key) { case "Escape": e.preventDefault(); setActiveIndex(null); setInteractionType(null); clearHoverTimeout(); buttonRefs.current[currentIndex]?.blur(); break; case "ArrowLeft": e.preventDefault(); { const newIndex = currentIndex > 0 ? currentIndex - 1 : dataLength - 1; buttonRefs.current[newIndex]?.focus(); } break; case "ArrowRight": e.preventDefault(); { const newIndex = currentIndex < dataLength - 1 ? currentIndex + 1 : 0; buttonRefs.current[newIndex]?.focus(); } break; case "ArrowUp": e.preventDefault(); { // Navigate to previous monitor's status bar const prevMonitor = containerRef.current?.closest( '[data-slot="status-component"]', )?.previousElementSibling; if (prevMonitor) { const prevBar = prevMonitor.querySelector('[role="toolbar"]'); if (prevBar) { const prevButtons = prevBar.querySelectorAll('[role="button"]'); const targetButton = prevButtons[currentIndex] as HTMLElement; targetButton?.focus(); } } } break; case "ArrowDown": e.preventDefault(); { // Navigate to next monitor's status bar const nextMonitor = containerRef.current?.closest( '[data-slot="status-component"]', )?.nextElementSibling; if (nextMonitor) { const nextBar = nextMonitor.querySelector('[role="toolbar"]'); if (nextBar) { const nextButtons = nextBar.querySelectorAll('[role="button"]'); const targetButton = nextButtons[currentIndex] as HTMLElement; targetButton?.focus(); } } } break; case "Enter": case " ": e.preventDefault(); handleClick(currentIndex); break; } }, [dataLength, clearHoverTimeout, handleClick], ); const setButtonRef = useCallback((index: number, el: HTMLElement | null) => { buttonRefs.current[index] = el; }, []); return { activeIndex, isOpen: activeIndex !== null, interactionType, containerRef, handlers: { onClick: handleClick, onHoverStart: handleHoverStart, onHoverEnd: handleHoverEnd, onHoverCardEnter: clearHoverTimeout, onHoverCardLeave: () => { setActiveIndex(null); setInteractionType(null); }, onFocus: handleFocus, onBlur: handleBlur, onKeyDown: handleKeyDown, }, setButtonRef, }; } /** * StatusBar - Interactive uptime timeline with keyboard navigation and hover cards * * Displays a horizontal timeline of status bars, where each bar represents a day's * status with color-coded segments. Interactive hover cards show detailed status * information, events, and incidents for each day. * * **Key Features**: * - **Visual Timeline**: Vertical bars with color-coded segments showing status over time * - **Interactive Cards**: Hover/click to see detailed breakdowns and events * - **Keyboard Navigation**: Full keyboard support with arrow keys, Enter, and Escape * - **Touch Support**: Optimized interactions for touch devices * - **Customizable Rendering**: Override default renderers for bars, cards, and events * - **Accessibility**: ARIA roles, labels, and keyboard navigation * * **Interaction Modes**: * - Desktop: Hover to preview, click to pin, Esc to close * - Touch: Tap to toggle card open/closed * - Keyboard: Arrow keys to navigate, Enter/Space to pin, Esc to close * * **Keyboard Navigation**: * - **Left/Right Arrows**: Move between bars in the timeline * - **Up/Down Arrows**: Navigate between different monitor timelines * - **Enter/Space**: Pin or unpin the active card * - **Escape**: Close card and remove focus * * **Data Structure**: * Each data item represents a day and contains: * - `day`: Date string * - `bar`: Array of segments with status and height percentage * - `card`: Array of status breakdowns to display in hover card * - `events`: Array of incidents/maintenance events for that day * * @param data - Array of status bar data items (one per day) * @param renderCard - Optional custom renderer for card content items * @param renderBar - Optional custom renderer for bar segments * @param renderEvent - Optional custom renderer for event badges * * @example * // Basic usage with default rendering * ```tsx * const uptimeData = [ * { * day: "2024-01-15", * bar: [{ status: "success", height: 100 }], * card: [{ status: "success", value: "100%" }], * events: [] * }, * { * day: "2024-01-16", * bar: [ * { status: "success", height: 80 }, * { status: "error", height: 20 } * ], * card: [ * { status: "success", value: "80%" }, * { status: "error", value: "20%" } * ], * events: [ * { * id: "inc-1", * type: "incident", * name: "API Downtime", * from: new Date("2024-01-16T10:00:00Z"), * to: new Date("2024-01-16T10:30:00Z") * } * ] * } * ]; * * * ``` * * @example * // With custom bar renderer * ```tsx * ( *
* )} * /> * ``` * * @example * // With custom event renderer * ```tsx * ( * *
* {event.name} *
* * )} * /> * ``` * * @see useStatusBar - For the headless hook powering the interactions * @see StatusBarSkeleton - For loading state * @see StatusBarEvent - For event badge rendering */ export function StatusBar({ data, renderCard, renderBar, renderEvent, container, }: StatusBarProps) { const labels = useStatusBlocksLabels(); const isTouch = useMediaQuery("(hover: none)"); const { activeIndex, interactionType, containerRef, handlers, setButtonRef } = useStatusBar({ dataLength: data.length, isTouch, }); return (
{data.map((item, index) => { const isActive = activeIndex === index; const isPinned = isActive && interactionType === "pin"; return ( setButtonRef(index, el)} index={index} item={item} isActive={isActive} isPinned={isPinned} isTouch={isTouch} isLastItem={index === data.length - 1} handlers={handlers} renderCard={renderCard} renderBar={renderBar} renderEvent={renderEvent} container={container} /> ); })}
); } StatusBar.displayName = "StatusBar"; interface StatusBarItemProps { index: number; item: StatusBarData; isActive: boolean; isPinned: boolean; isTouch: boolean; isLastItem: boolean; handlers: ReturnType["handlers"]; renderCard?: StatusBarProps["renderCard"]; renderBar?: StatusBarProps["renderBar"]; renderEvent?: StatusBarProps["renderEvent"]; container?: StatusBarProps["container"]; } const StatusBarItem = forwardRef( ( { index, item, isActive, isPinned, isTouch, isLastItem, handlers, renderCard, renderBar, renderEvent, container, }, ref, ) => { const labels = useStatusBlocksLabels(); return (
handlers.onClick(index)} onFocus={() => handlers.onFocus(index)} onBlur={handlers.onBlur} onMouseEnter={() => handlers.onHoverStart(index)} onMouseLeave={handlers.onHoverEnd} onKeyDown={(e) => handlers.onKeyDown(e, index)} tabIndex={isLastItem && !isActive ? 0 : isActive ? 0 : -1} role="button" aria-label={labels.ariaDayStatus(index + 1)} aria-pressed={isPinned} aria-expanded={isActive} data-slot="status-bar-item" >
{/* Render bar segments */} {item.bar.map((segment, segmentIndex) => { if (renderBar) { return renderBar(segment, segmentIndex); } return (
); })}
{ // Prevent closing on touch devices when clicking the card if (isTouch) { e.preventDefault(); } }} > ); }, ); StatusBarItem.displayName = "StatusBarItem"; export interface StatusBarCardProps { item: StatusBarData; isPinned?: boolean; isTouch?: boolean; renderCard?: StatusBarProps["renderCard"]; renderEvent?: StatusBarProps["renderEvent"]; } /** * StatusBarCard - Internal hover card content component * * Displays detailed status information for a single day in a hover card, including: * - Date header * - Status breakdown percentages * - Events/incidents for that day * - Pin/unpin instructions (when pinned on desktop) * * The card automatically formats the date and renders status items and events * using either custom renderers or default implementations. * * @param item - The status bar data for this day * @param isPinned - Whether the card is currently pinned open * @param isTouch - Whether the device supports touch * @param renderCard - Optional custom renderer for status items * @param renderEvent - Optional custom renderer for events */ export function StatusBarCard({ item, isPinned = false, isTouch = false, renderCard, renderEvent, }: StatusBarCardProps) { const labels = useStatusBlocksLabels(); return (
{labels.formatDateShort(new Date(item.day))}
{item.card.length > 0 && ( <>
{item.card.map((cardItem, cardIndex) => { if (renderCard) { return renderCard(cardItem, cardIndex); } return ( ); })}
)} {item.events.length > 0 && ( <>
{item.events.map((event, eventIndex) => { if (renderEvent) { return renderEvent(event, eventIndex); } return ( ); })}
)} {isPinned && !isTouch && ( <>
{labels.clickAgainToUnpin} Esc
)}
); } StatusBarCard.displayName = "StatusBarCard"; /** * StatusBarSkeleton - Loading skeleton for StatusBar * * Displays a skeleton loader matching the height and width of a StatusBar, * used while status data is being fetched. * * @example * ```tsx * {isLoading ? ( * * ) : ( * * )} * ``` * * @see StatusBar - For the actual status bar component */ export function StatusBarSkeleton({ className, ...props }: React.ComponentProps) { return ( ); } StatusBarSkeleton.displayName = "StatusBarSkeleton"; /** * StatusBarContent - Internal component for status breakdown rows * * Displays a single status item in the hover card with a colored indicator, * status label, and percentage value. Used by StatusBarCard to show the * breakdown of statuses for a day. * * @param status - The status type (success, degraded, error, info, empty) * @param value - The percentage or count value to display * * @example * ```tsx * * * ``` */ function StatusBarContent({ status, value, impact, }: { status: StatusType; value: string; impact?: StatusReportImpact; }) { const labels = useStatusBlocksLabels(); return (
{impact ? labels.componentImpact[impact] : labels.requestStatus[status]}
{value}
); } StatusBarContent.displayName = "StatusBarContent"; /** * StatusBarEvent - Event badge for incidents and maintenance * * Displays an event within a status bar hover card, showing: * - Color-coded indicator (red for incidents, yellow for reports, blue for maintenance) * - Event name with truncation for long names * - Date range (formatted as "Since", "Until", or "Jan 15 - Jan 16") * - Duration (formatted as "2 hours", "ongoing", or "across 3 days" for multiple incidents) * * The component automatically determines the status color based on the event type * (incident → error, report → degraded, maintenance → info) unless an explicit * `status` is passed, e.g. the day's worst report impact. * * Returns null if no start date is provided. * * @param name - Event name or title * @param from - Event start date * @param to - Event end date (null for ongoing events) * @param type - Event type ("incident", "report", or "maintenance") * * @example * ```tsx * * // Displays: [●] API Downtime * // Jan 15, 10:00 AM - 10:30 AM 30 minutes * ``` * * @example * ```tsx * // Ongoing incident * * // Displays: [●] Database connectivity issues * // Since Jan 15, 2:30 PM ongoing * ``` * * @see StatusBarCard - For the card that contains event badges * @see formatDateRange - For date range formatting */ export function StatusBarEvent({ name, from, to, type, isAggregated, status: statusProp, }: { name: string; from?: Date | null; to?: Date | null; type: StatusEventType; isAggregated?: boolean; /** Overrides the type-derived dot color (e.g. the day's worst report impact). */ status?: Exclude; }) { const labels = useStatusBlocksLabels(); if (!from) return null; const status = statusProp ?? (type === "incident" ? "error" : type === "report" ? "degraded" : "info"); return (
{/* NOTE: this is to make the text truncate based on the width of the sibling element */} {/* REMINDER: height needs to be equal the text height */}
{name}
{labels.formatDateRange(from, to ?? undefined)}{" "} {formatDuration({ from, to, name, type, isAggregated, labels })}
); } StatusBarEvent.displayName = "StatusBarEvent"; /** * formatDuration - Internal helper for formatting event durations * * Formats the duration of an event based on start/end dates: * - No start date: returns null * - No end date: returns "ongoing" * - Aggregated incidents (isAggregated): returns "across {duration}" * - Zero seconds duration: returns null (hides duration) * - Otherwise: returns formatted duration (e.g., "2 hours", "3 days") * * @returns Formatted duration string or null */ const formatDuration = ({ from, to, isAggregated, labels, }: React.ComponentProps & { labels: ReturnType; }) => { if (!from) return null; if (!to) return labels.ongoing; const duration = formatDistanceStrict(from, to); if (isAggregated) return labels.durationAcross(duration); if (duration === "0 seconds") return null; return duration; };