Status strip
The global status strip, with the stream's state, the vessel's activity, the sensors' integrity, risk, do not disturb, theme and brightness, and the UTC clock.
On this page
Code
import { StatusStrip, type SensorSummary, type StreamConnection } from "@bosunkit/components/status-strip";
import { BosunkitProvider, PresentationScope, useThemePreference } from "@bosunkit/provider";
import { DEFAULT_THEME_STATE, type Theme, type ThemeState, type ThemeStore } from "@bosunkit/tokens/theme";
import { useEffect, useState, type Dispatch, type ReactElement, type ReactNode, type SetStateAction } from "react";
export const description: string =
"The strip underway at night on a live script: the stream connects, runs and drops, the sensors span all four " +
"integrity states, the risk is elevated and worsening, the night theme is suggested, and do not disturb counts " +
"what it holds back.";
export const readsClock: boolean = true;
/**
* A theme store for this example alone: the night suggestion and the brightness menu change the example's theme and
* brightness, not the page's. An application passes none, and the strip changes the application's theme.
*/
function createExampleThemeStore(): ThemeStore {
let state: ThemeState = DEFAULT_THEME_STATE;
const listeners: Set<() => void> = new Set<() => void>();
function update(next: Partial<ThemeState>): void {
state = { ...state, ...next };
for (const listener of listeners) {
listener();
}
}
return {
get: (): ThemeState => state,
getServerSnapshot: (): ThemeState => DEFAULT_THEME_STATE,
subscribe: (listener: () => void): (() => void) => {
listeners.add(listener);
return (): void => {
listeners.delete(listener);
};
},
setPreference: (preference: ThemeState["preference"]): void => {
update({ preference });
},
setBrightness: (percent: number): void => {
update({ brightness: Math.min(100, Math.max(0, percent)) });
},
setSizeClass: (sizeClass: ThemeState["sizeClass"]): void => {
update({ sizeClass });
},
};
}
/** Where the example's stream is: connecting for two seconds, then live and reconnecting in turn. */
const CONNECTING_SECONDS: number = 2;
const LIVE_SECONDS: number = 12;
const RECONNECTING_SECONDS: number = 9;
/** How often an alert arrives while do not disturb is on, and how often a new unacknowledged one appears. */
const HELD_BACK_EVERY_SECONDS: number = 7;
const NEW_ALERT_EVERY_SECONDS: number = 30;
function connectionAt(seconds: number): StreamConnection {
if (seconds < CONNECTING_SECONDS) {
return "connecting";
}
return (seconds - CONNECTING_SECONDS) % (LIVE_SECONDS + RECONNECTING_SECONDS) < LIVE_SECONDS
? "live"
: "reconnecting";
}
/** The second of the stream's last event: this one while live, the last live one while reconnecting. */
function lastEventSecond(seconds: number): number | null {
if (seconds < CONNECTING_SECONDS) {
return null;
}
const cycle: number = LIVE_SECONDS + RECONNECTING_SECONDS;
const intoCycle: number = (seconds - CONNECTING_SECONDS) % cycle;
return intoCycle < LIVE_SECONDS ? seconds : seconds - intoCycle + LIVE_SECONDS - 1;
}
function sensorsAt(seconds: number): readonly SensorSummary[] {
return [
{ name: "gps", integrity: "fresh", ageSeconds: 1 },
{ name: "compass", integrity: "degraded", reason: "heading frozen" },
{ name: "depth", integrity: "stale", ageSeconds: 95 + seconds },
{ name: "wind", integrity: "invalid", reason: "no data from WS320" },
{ name: "speed", integrity: "fresh", ageSeconds: 1 },
{ name: "ais", integrity: "fresh", ageSeconds: 2 },
];
}
/** The example in the theme its store holds; auto leaves it in the page's theme. */
function ThemedByStore({ children }: { children: ReactNode }): ReactElement {
const { preference }: ThemeState = useThemePreference();
const theme: Theme | undefined = preference === "auto" ? undefined : preference;
return <PresentationScope theme={theme}>{children}</PresentationScope>;
}
export default function Showcase(): ReactElement {
const [themeStore]: [ThemeStore, Dispatch<SetStateAction<ThemeStore>>] =
useState<ThemeStore>(createExampleThemeStore);
const [startedAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now());
const [seconds, setSeconds]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
// The second do not disturb was turned on, or null while it is off.
const [dndSince, setDndSince]: [number | null, Dispatch<SetStateAction<number | null>>] = useState<number | null>(
null,
);
const [suggestNight, setSuggestNight]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
useEffect((): (() => void) => {
const timer: ReturnType<typeof setInterval> = setInterval((): void => {
setSeconds((current: number): number => current + 1);
}, 1000);
return (): void => {
clearInterval(timer);
};
}, []);
const connection: StreamConnection = connectionAt(seconds);
const lastSecond: number | null = lastEventSecond(seconds);
const missed: number = dndSince === null ? 0 : Math.floor((seconds - dndSince) / HELD_BACK_EVERY_SECONDS);
// One alert is unacknowledged from the start, and another arrives every NEW_ALERT_EVERY_SECONDS.
const alertIds: string[] = Array.from(
{ length: 1 + Math.floor(seconds / NEW_ALERT_EVERY_SECONDS) },
(_: unknown, index: number): string => `alert-${String(index)}`,
);
return (
<BosunkitProvider themeStore={themeStore}>
<ThemedByStore>
<div style={{ width: "100%", background: "var(--bg)" }}>
<StatusStrip
connection={connection}
lastEventAt={lastSecond === null ? null : startedAt + lastSecond * 1000}
activity="underway"
sensors={sensorsAt(seconds)}
risk={{ level: "elevated", trend: "worsening" }}
doNotDisturb={{
enabled: dndSince !== null,
missed,
onToggle: (): void => {
setDndSince(dndSince === null ? seconds : null);
},
}}
nightSuggested={suggestNight}
onDismissSuggestion={(): void => {
setSuggestNight(false);
}}
unacknowledgedAlertIds={alertIds}
/>
</div>
</ThemedByStore>
</BosunkitProvider>
);
}
The status strip is the app's global strip. It shows the live data stream's state, the vessel's activity, the sensors' integrity, the risk, do not disturb, the night suggestion, theme and brightness, and the UTC clock, as quiet chips whose chrome stays dim (HMI rule 6). While the stream is not live, it counts the seconds since the stream's last event from its own clock, the clock that also drives its UTC time, so the two tick together. Underway, it asks for a second tap before do not disturb silences alerts.
Examples
Live
Code
import { StatusStrip, type SensorSummary } from "@bosunkit/components/status-strip";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string = "Underway with the stream live and every sensor fresh, so no chip asks for attention.";
export const readsClock: boolean = true;
const SENSORS: readonly SensorSummary[] = ["gps", "compass", "depth", "wind", "speed"].map(
(name: string): SensorSummary => ({ name, integrity: "fresh", ageSeconds: 1 }),
);
function ignore(): void {
// The example changes nothing on a tap.
}
export default function Live(): ReactElement {
const [lastEventAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now());
return (
<StatusStrip
connection="live"
lastEventAt={lastEventAt}
activity="underway"
sensors={SENSORS}
risk={{ level: "normal", trend: "stable" }}
doNotDisturb={{ enabled: false, missed: 0, onToggle: ignore }}
nightSuggested={false}
onDismissSuggestion={ignore}
unacknowledgedAlertIds={[]}
/>
);
}
Reconnecting
Code
import { StatusStrip, type SensorSummary } from "@bosunkit/components/status-strip";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"The stream reconnecting since its last event 12 seconds ago, two sensors stale, and the risk high and worsening.";
export const readsClock: boolean = true;
const SENSORS: readonly SensorSummary[] = [
{ name: "gps", integrity: "fresh", ageSeconds: 1 },
{ name: "compass", integrity: "fresh", ageSeconds: 1 },
{ name: "depth", integrity: "stale", ageSeconds: 190 },
{ name: "wind", integrity: "stale", reason: "no wind reading for 45 seconds", ageSeconds: 45 },
{ name: "speed", integrity: "fresh", ageSeconds: 1 },
];
function ignore(): void {
// The example changes nothing on a tap.
}
export default function Reconnecting(): ReactElement {
const [lastEventAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>(
(): number => Date.now() - 12_000,
);
return (
<StatusStrip
connection="reconnecting"
lastEventAt={lastEventAt}
activity="underway"
sensors={SENSORS}
risk={{ level: "high", trend: "worsening" }}
doNotDisturb={{ enabled: false, missed: 0, onToggle: ignore }}
nightSuggested={false}
onDismissSuggestion={ignore}
unacknowledgedAlertIds={[]}
/>
);
}
Do not disturb
Code
import { StatusStrip, type SensorSummary } from "@bosunkit/components/status-strip";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string = "Do not disturb on at anchor, with the count of the two alerts it has held back.";
export const readsClock: boolean = true;
const SENSORS: readonly SensorSummary[] = ["gps", "compass", "depth", "wind", "speed"].map(
(name: string): SensorSummary => ({ name, integrity: "fresh", ageSeconds: 1 }),
);
function ignore(): void {
// The example changes nothing on a tap.
}
export default function DoNotDisturb(): ReactElement {
const [lastEventAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now());
return (
<StatusStrip
connection="live"
lastEventAt={lastEventAt}
activity="anchored"
sensors={SENSORS}
risk={{ level: "normal", trend: "stable" }}
doNotDisturb={{ enabled: true, missed: 2, onToggle: ignore }}
nightSuggested={false}
onDismissSuggestion={ignore}
unacknowledgedAlertIds={[]}
/>
);
}
On a narrow screen
The strip follows the width of the screen it 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; the strip is as wide as its chips, so it cannot
measure itself. On a tablet, a screen up to the 68.75em breakpoint (1,100 px at 16 px), it drops the theme caption,
the date, the time zone and the risk chip, and keeps the brightness control and the clock. On a phone, up to the 40em
breakpoint (640 px), it also wraps onto a second row and drops the words beside the stream's dot and do not disturb.
Outside a declared screen it keeps its wide form. Its chips grow to the touch target under a coarse pointer, whatever
the screen.
Code
import { StatusStrip, type SensorSummary } from "@bosunkit/components/status-strip";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"A 360 px screen: the strip drops its date, time zone, theme and stream captions, and wraps onto a second row.";
export const readsClock: boolean = true;
const SENSORS: readonly SensorSummary[] = ["gps", "compass", "depth", "wind", "speed"].map(
(name: string): SensorSummary => ({ name, integrity: "fresh", ageSeconds: 1 }),
);
function ignore(): void {
// The example changes nothing on a tap.
}
export default function OnANarrowScreen(): ReactElement {
const [lastEventAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now());
return (
<div style={{ container: "bk-screen / inline-size", width: "100%", maxWidth: 360 }}>
<StatusStrip
connection="live"
lastEventAt={lastEventAt}
activity="anchored"
sensors={SENSORS}
risk={{ level: "normal", trend: "stable" }}
doNotDisturb={{ enabled: false, missed: 0, onToggle: ignore }}
nightSuggested={false}
onDismissSuggestion={ignore}
unacknowledgedAlertIds={[]}
/>
</div>
);
}
Without motion
Reset in the brilliance menu restores 50% brightness and keeps the theme, so at night the strip stays in Night. Holding the brightness chip restores full brightness. Both changes persist in the browser's local storage.
The sensors and brightness menus drop in from their chip over the medium motion
duration, and only fade under reduced motion. They close at once: a new alert closes the brightness menu, which on a
phone opens over the alert zone's row, and no exit may keep it covering the alert. motion={false} opens them at
once too.
Code
import { StatusStrip, type SensorSummary } from "@bosunkit/components/status-strip";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"With `motion={false}` the sensors and brightness menus open at once; by default they drop in from their chip.";
export const readsClock: boolean = true;
const SENSORS: readonly SensorSummary[] = [
{ name: "gps", integrity: "fresh", ageSeconds: 1 },
{ name: "compass", integrity: "fresh", ageSeconds: 1 },
{ name: "depth", integrity: "stale", ageSeconds: 42 },
];
function ignore(): void {
// The example changes nothing on a tap.
}
export default function WithoutMotion(): ReactElement {
const [lastEventAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now());
return (
<StatusStrip
connection="live"
lastEventAt={lastEventAt}
activity="underway"
sensors={SENSORS}
risk={null}
doNotDisturb={{ enabled: false, missed: 0, onToggle: ignore }}
nightSuggested={false}
onDismissSuggestion={ignore}
unacknowledgedAlertIds={[]}
motion={false}
/>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| connectionrequired | StreamConnection | – | The live data stream's state. |
| lastEventAtrequired | number | null | – | When the stream's last event arrived, in epoch milliseconds; null while connecting with nothing received yet. While the stream is not live, the strip counts the whole seconds since then from its own clock. |
| activityrequired | string | null | – | The vessel's activity (underway, anchored, moored); null or unknown hides the chip. |
| sensorsrequired | readonly SensorSummary[] | – | The data-integrity instruments, in the order the sensors list shows them. |
| riskrequired | StatusStripRisk | null | – | The vessel's risk and its trend; null when bosund has not reported one. |
| doNotDisturbrequired | StatusStripDoNotDisturb | – | Do not disturb. |
| nightSuggestedrequired | boolean | – | Whether to suggest the night theme: it is night and the crew have not dismissed the suggestion today. The strip also hides it while the night theme is on. |
| onDismissSuggestionrequired | () => void | – | Dismisses the night suggestion for the rest of the day. |
| unacknowledgedAlertIdsrequired | readonly string[] | – | The ids of the unacknowledged alerts; a new one closes the brightness menu, so no alert arrives under it. |
| className | string | undefined | – | Classes added to the strip's own. |
| motion | boolean | undefined | true | Whether the sensors and brightness menus drop in from their chips when they open: on by default; they only fade
under reduced motion. They close at once, so an alert arriving under the brightness menu is never covered by an
exit. false opens them at once. |
| ref | Ref<HTMLDivElement> | undefined | – | The strip's root element. |
Related
- Data integrity: the four states the sensors chip summarizes.
- Alarms and severity: the alerts do not disturb holds back.
- Themes: the themes the strip's theme menu switches between.