Skip to content
design
Components

Alert zone

The top bar's alert zone, with the unacknowledged count, the worst alert's title and age, and an ACK button.

On this page
A top bar over a board of five alerts, where ACK works through the unacknowledged ones by priority and a long title ends in an ellipsis.
Code
import { AlertCard } from "@bosunkit/components/alert-card";
import { AlertZone, type AlertZoneAlert } from "@bosunkit/components/alert-zone";
import { Card } from "@bosunkit/components/card";
import { AlertCardGrid } from "@bosunkit/components/alert-card-grid";
import type { Priority } from "@bosunkit/components/priority";
import { SectionDivider } from "@bosunkit/components/section-divider";
import { Anchor } from "lucide-react";
import { useEffect, useId, useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A top bar over a board of five alerts: the zone shows the worst unacknowledged one, a long title ends in an " +
  "ellipsis, ACK works through the queue by priority and moves each card under Monitoring on the board, and the " +
  "board raises its alerts again once all are answered.";

export const readsClock: boolean = true;

interface BoardAlert extends AlertZoneAlert {
  readonly acknowledged: boolean;
}

/** How the board ranks priorities: the zone shows the highest, then the latest. */
const RANK: Readonly<Record<Priority, number>> = { emergency: 4, alarm: 3, warning: 2, caution: 1, none: 0 };

/** How long the board stays quiet before the example raises its alerts again. */
const RAISE_AGAIN_MS: number = 6000;

function raise(now: number): BoardAlert[] {
  function minutesAgo(minutes: number): number {
    return now - minutes * 60_000;
  }
  return [
    { id: "a1", title: "Anchor drag", priority: "alarm", raisedAt: minutesAgo(2), acknowledged: false },
    {
      id: "w1",
      title: "Wind above reef threshold off the Dragonera passage",
      priority: "warning",
      raisedAt: minutesAgo(14),
      acknowledged: false,
    },
    {
      id: "w2",
      title: "Engine coolant 96 °C",
      priority: "warning",
      raisedAt: minutesAgo(97),
      acknowledged: false,
    },
    { id: "c1", title: "House bank 38 %", priority: "caution", raisedAt: minutesAgo(41), acknowledged: false },
    { id: "c2", title: "Holding tank 80 %", priority: "caution", raisedAt: minutesAgo(185), acknowledged: true },
  ];
}

/** The time an alert fired, as the board shows it. */
function firedAt(alert: BoardAlert): string | undefined {
  if (alert.raisedAt === undefined) {
    return undefined;
  }
  const fired: Date = new Date(alert.raisedAt);
  return `${String(fired.getHours()).padStart(2, "0")}:${String(fired.getMinutes()).padStart(2, "0")}`;
}

function card(alert: BoardAlert): ReactElement {
  return (
    <AlertCard
      key={alert.id}
      priority={alert.priority}
      title={alert.title}
      time={firedAt(alert)}
      acknowledged={alert.acknowledged}
      immediate={!alert.acknowledged && RANK[alert.priority] >= RANK.alarm}
    />
  );
}

/** The worst unacknowledged alert: the highest priority, then the latest to fire. */
function worst(alerts: readonly BoardAlert[]): BoardAlert | null {
  const open: BoardAlert[] = alerts.filter((alert: BoardAlert): boolean => !alert.acknowledged);
  open.sort((first: BoardAlert, second: BoardAlert): number => {
    const byPriority: number = RANK[second.priority] - RANK[first.priority];
    return byPriority !== 0 ? byPriority : (second.raisedAt ?? 0) - (first.raisedAt ?? 0);
  });
  return open[0] ?? null;
}

export default function Showcase(): ReactElement {
  const [alerts, setAlerts]: [BoardAlert[], Dispatch<SetStateAction<BoardAlert[]>>] = useState<BoardAlert[]>(
    (): BoardAlert[] => raise(Date.now()),
  );
  // The page renders this example twice, as the hero and as the first example, so the board's id is generated.
  const boardId: string = useId();
  const shown: BoardAlert | null = worst(alerts);
  const open: BoardAlert[] = alerts.filter((alert: BoardAlert): boolean => !alert.acknowledged);
  const answered: BoardAlert[] = alerts.filter((alert: BoardAlert): boolean => alert.acknowledged);
  const count: number = open.length;

  useEffect((): (() => void) | undefined => {
    if (count > 0) {
      return undefined;
    }
    const timer: ReturnType<typeof setTimeout> = setTimeout((): void => {
      setAlerts(raise(Date.now()));
    }, RAISE_AGAIN_MS);
    return (): void => {
      clearTimeout(timer);
    };
  }, [count]);

  async function acknowledge(): Promise<void> {
    if (shown === null) {
      return;
    }
    // Stands in for the request to bosund; the zone moves on once it answers.
    await new Promise<void>((resolve: () => void): void => {
      setTimeout(resolve, 250);
    });
    setAlerts((current: BoardAlert[]): BoardAlert[] =>
      current.map((alert: BoardAlert): BoardAlert =>
        alert.id === shown.id ? { ...alert, acknowledged: true } : alert,
      ),
    );
  }

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-lg)", width: "100%" }}>
      <Card surface="raised">
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "var(--space-md)" }}>
          <span style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-sm)", flexShrink: 0 }}>
            <Anchor size={16} aria-hidden="true" />
            <span style={{ fontFamily: "var(--font-mono)", fontSize: 12, fontWeight: 600 }}>SV Althea</span>
            <span style={{ fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--text-dim)" }}>Cala Portals</span>
          </span>
          <div style={{ minWidth: 0, maxWidth: 420, display: "flex", justifyContent: "flex-end" }}>
            <AlertZone count={count} alert={shown} acknowledgeAction={acknowledge} boardHref={`#${boardId}`} />
          </div>
        </div>
      </Card>

      <div id={boardId}>
        <AlertCardGrid>
          <SectionDivider key="open" label={`Unacknowledged (${String(count)})`} variant="immediate" />
          {open.map(card)}
          <SectionDivider key="monitoring" label="Monitoring" variant="monitoring" />
          {answered.map(card)}
        </AlertCardGrid>
      </div>
    </div>
  );
}

The alert zone sits in the top bar and shows the one alert the crew owe an acknowledgement: the worst unacknowledged one, by priority and then by the latest. It carries the unacknowledged count with the priority's shape, the alert's title as a link to the board, the alert's age and an ACK button. The age is brought up to date every 30 seconds. While every alert is acknowledged the zone is empty, because the bar asks for attention only when an acknowledgement is owed (MSC.191 §5.7).

If an acknowledgement fails, the zone keeps the alert and says so in a line under its row, in its live region, so the crew hear why the alert remains; ACK is offered again. ACK is pending from the press until the board shows the acknowledgement and the zone moves to the next alert, so a second press neither sends again nor acknowledges the alert that takes the first one's place; if the board has not shown it 10 seconds after bosund answered, the zone says so and offers ACK again.

Examples

Empty

Every alert acknowledged: the zone is empty and asks the crew for nothing.
Code
import { AlertZone } from "@bosunkit/components/alert-zone";
import type { ReactElement } from "react";

export const description: string = "Every alert acknowledged: the zone is empty and asks the crew for nothing.";

export const readsClock: boolean = true;

async function acknowledge(): Promise<void> {
  // The example acknowledges nothing.
}

export default function Empty(): ReactElement {
  return <AlertZone count={0} alert={null} acknowledgeAction={acknowledge} boardHref="/situation" />;
}

One alarm

One unacknowledged alarm: the count with the alarm shape, the title linking to the board, its age and ACK.
Code
import { AlertZone } from "@bosunkit/components/alert-zone";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "One unacknowledged alarm: the count with the alarm shape, the title linking to the board, its age and ACK.";

export const readsClock: boolean = true;

async function acknowledge(): Promise<void> {
  // The example acknowledges nothing.
}

export default function OneAlarm(): ReactElement {
  const [raisedAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now() - 2 * 60_000);
  return (
    <AlertZone
      count={1}
      alert={{ id: "a9", title: "Anchor Drag", priority: "alarm", raisedAt }}
      acknowledgeAction={acknowledge}
      boardHref="/situation"
    />
  );
}

Three warnings

Three unacknowledged alerts: the zone counts all three and shows the warning, the worst, though it is the oldest.
Code
import { AlertZone } from "@bosunkit/components/alert-zone";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "Three unacknowledged alerts: the zone counts all three and shows the warning, the worst, though it is the oldest.";

export const readsClock: boolean = true;

async function acknowledge(): Promise<void> {
  // The example acknowledges nothing.
}

export default function ThreeWarnings(): ReactElement {
  const [raisedAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>(
    (): number => Date.now() - 85 * 60_000,
  );
  return (
    <AlertZone
      count={3}
      alert={{ id: "w1", title: "Weather Window", priority: "warning", raisedAt }}
      acknowledgeAction={acknowledge}
      boardHref="/situation"
    />
  );
}

On a narrow screen

On a phone or a tablet, a screen up to the 68.75em breakpoint (1,100 px at 16 px), ACK grows to the 48 px touch target for a thumb, whatever pointer the device reports. The width is the screen's the zone sits in, not the window's: a page, an app's main area or a phone frame declares itself the screen with container: bk-screen / inline-size, and outside one ACK keeps its compact size. A coarse pointer grows it at any width.

A 360 px screen: ACK grows to the 48 px touch target, for a thumb on a phone.
Code
import { AlertZone } from "@bosunkit/components/alert-zone";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string = "A 360 px screen: ACK grows to the 48 px touch target, for a thumb on a phone.";

export const readsClock: boolean = true;

async function acknowledge(): Promise<void> {
  // The example acknowledges nothing.
}

export default function OnANarrowScreen(): ReactElement {
  const [raisedAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now() - 2 * 60_000);
  return (
    <div style={{ container: "bk-screen / inline-size", width: "100%", maxWidth: 360 }}>
      <AlertZone
        count={1}
        alert={{ id: "a9", title: "Anchor Drag", priority: "alarm", raisedAt }}
        acknowledgeAction={acknowledge}
        boardHref="/situation"
      />
    </div>
  );
}

Motion

The zone moves its alert by default. An alert arriving in an empty zone rises in over at most the medium motion duration; its title is in the zone's live region, and announced, from the first frame. When every alert is acknowledged it fades out with the fast exit, and ACK takes no second press while it leaves. A new worst alert, a change of priority and a new count show at once. Under reduced motion the alert only fades in. motion={false} turns it off.

A quiet zone that an anchor drag alarm rises into, its title already in the live region, and that it fades out of when ACK is pressed.
Code
import { AlertZone, type AlertZoneAlert } from "@bosunkit/components/alert-zone";
import { Button } from "@bosunkit/components/button";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A quiet zone that an anchor drag alarm rises into, its title already in the live region, and that it fades out " +
  "of when ACK is pressed.";

export const readsClock: boolean = true;

export default function Motion(): ReactElement {
  const [alert, setAlert]: [AlertZoneAlert | null, Dispatch<SetStateAction<AlertZoneAlert | null>>] =
    useState<AlertZoneAlert | null>(null);
  async function acknowledge(): Promise<void> {
    setAlert(null);
    return Promise.resolve();
  }
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
      <Button
        size="sm"
        disabled={alert !== null}
        onClick={(): void => {
          setAlert({ id: "drag", title: "Anchor Drag", priority: "alarm", raisedAt: Date.now() });
        }}
      >
        The anchor drags
      </Button>
      <AlertZone count={alert === null ? 0 : 1} alert={alert} acknowledgeAction={acknowledge} boardHref="/situation" />
    </div>
  );
}

Props

PropTypeDefaultDescription
countrequirednumber–How many alerts are unacknowledged.
alertrequiredAlertZoneAlert | null–The worst unacknowledged alert (highest priority, then the latest); null when every alert is acknowledged.
acknowledgeActionrequired() => Promise<void>–Acknowledges the shown alert.
boardHrefrequiredstring–Where the alert's title links: the board that lists every alert.
classNamestring | undefined–Classes added to the zone's own.
motionboolean | undefinedtrueWhether the alert moves: on by default. An alert arriving in an empty zone rises in, its title already in the live region and announced; when every alert is acknowledged it fades out with the fast exit, inert while it leaves. A change of the worst alert, its priority or its count shows at once. Under reduced motion it only fades. false turns it off.
refRef<HTMLDivElement> | undefined–The zone's root element.