"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
* (
*
*
{/* 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 (
);
}
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 (
);
}
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;
};