Skip to content
design
Components

Setting control

The rows of a settings page, a switch, a field, a select or any kit control, each with the save state beside it.

On this page

Anchor watch

Anchor alarm
Sound the alarm on deck and below when the vessel leaves the swing circle.
How far the vessel may move from the anchor position before the alarm sounds.
m
Spoken after the alarm tone; leave it empty for the standard announcement.
The language the alarm is spoken in on deck.

Reaching the crew

Call the skipper's phone
Ring Maya's phone if nobody acknowledges within two minutes. In this example the save fails.
Send the position to the crew
Message the anchor position to the crew's phones every hour. In this example the save is on its way.
Saving…
Shore power alarm
Available once the shore power monitor reports on the network.
The token Bosun sends watch alerts with.
From the vessel's registration; change it on the vessel profile.
An anchor watch settings page: switches that save, fail, refuse input and wait on a save, a number field sized to its digits, text, secret and read-only fields, and a select, each save chip on its row's edge.
Code
import { SectionHeading } from "@bosunkit/components/section-heading";
import {
  SettingField,
  SettingSelect,
  SettingToggle,
  type SaveState,
  type SettingSelectOption,
} from "@bosunkit/components/setting-control";
import { Anchor, Radio } from "lucide-react";
import {
  useEffect,
  useId,
  useRef,
  useState,
  type Dispatch,
  type ReactElement,
  type ReactNode,
  type RefObject,
  type SetStateAction,
} from "react";

export const description: string =
  "An anchor watch settings page: switches that save, fail, refuse input and wait on a save, a number field sized " +
  "to its digits, text, secret and read-only fields, and a select, each save chip on its row's edge.";

interface Saved<Value> {
  readonly value: Value;
  readonly state: SaveState;
}

/** How long the example's save takes to come back. */
const SAVE_MS: number = 900;

/**
 * A setting whose save comes back after SAVE_MS: it lands, or it fails and the control returns to the value that is
 * saved on the vessel.
 */
function useSetting<Value>(initial: Value, fails: boolean = false): [Saved<Value>, (next: Value) => void] {
  const [setting, setSetting]: [Saved<Value>, Dispatch<SetStateAction<Saved<Value>>>] = useState<Saved<Value>>({
    value: initial,
    state: "idle",
  });
  const saved: RefObject<Value> = useRef<Value>(initial);
  const timer: RefObject<ReturnType<typeof setTimeout> | undefined> = useRef<ReturnType<typeof setTimeout> | undefined>(
    undefined,
  );
  useEffect((): (() => void) => {
    return (): void => {
      clearTimeout(timer.current);
    };
  }, []);

  function save(next: Value): void {
    setSetting({ value: next, state: "saving" });
    clearTimeout(timer.current);
    // Stands in for the PUT to the vessel's settings.
    timer.current = setTimeout((): void => {
      if (fails) {
        setSetting({ value: saved.current, state: "error" });
        return;
      }
      saved.current = next;
      setSetting({ value: next, state: "saved" });
    }, SAVE_MS);
  }

  return [setting, save];
}

const LANGUAGES: readonly SettingSelectOption[] = [
  { value: "en", label: "English" },
  { value: "nl", label: "Nederlands" },
  { value: "fr", label: "Français" },
  { value: "de", label: "Deutsch" },
  { value: "es", label: "Español" },
  { value: "it", label: "Italiano" },
];

function ignore(): void {
  // A disabled or read-only control saves nothing.
}

function Group({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }): ReactElement {
  const headingId: string = useId();
  return (
    <section aria-labelledby={headingId} style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)" }}>
      <SectionHeading level={3} id={headingId} icon={icon}>
        {title}
      </SectionHeading>
      {children}
    </section>
  );
}

export default function Showcase(): ReactElement {
  const [alarm, saveAlarm]: [Saved<boolean>, (next: boolean) => void] = useSetting<boolean>(true);
  const [phone, savePhone]: [Saved<boolean>, (next: boolean) => void] = useSetting<boolean>(false, true);
  const [radius, saveRadius]: [Saved<string>, (next: string) => void] = useSetting<string>("45");
  const [message, saveMessage]: [Saved<string>, (next: string) => void] = useSetting<string>("");
  const [language, saveLanguage]: [Saved<string>, (next: string) => void] = useSetting<string>("en");
  const [token, saveToken]: [Saved<string>, (next: string) => void] = useSetting<string>("7311029847:AAF-anchor");

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-xl)", width: "100%" }}>
      <Group icon={<Anchor />} title="Anchor watch">
        <SettingToggle
          label="Anchor alarm"
          description="Sound the alarm on deck and below when the vessel leaves the swing circle."
          checked={alarm.value}
          state={alarm.state}
          onCheckedChange={saveAlarm}
        />
        <SettingField
          label="Swing radius"
          description="How far the vessel may move from the anchor position before the alarm sounds."
          type="number"
          value={radius.value}
          unit="m"
          maxDigits={3}
          placeholder="45"
          state={radius.state}
          onSave={saveRadius}
        />
        <SettingField
          label="Alarm announcement"
          description="Spoken after the alarm tone; leave it empty for the standard announcement."
          value={message.value}
          placeholder="Anchor drag. All hands on deck."
          state={message.state}
          onSave={saveMessage}
        />
        <SettingSelect
          label="Announcement language"
          description="The language the alarm is spoken in on deck."
          value={language.value}
          options={LANGUAGES}
          state={language.state}
          onSave={saveLanguage}
        />
      </Group>
      <Group icon={<Radio />} title="Reaching the crew">
        <SettingToggle
          label="Call the skipper's phone"
          description="Ring Maya's phone if nobody acknowledges within two minutes. In this example the save fails."
          checked={phone.value}
          state={phone.state}
          onCheckedChange={savePhone}
        />
        <SettingToggle
          label="Send the position to the crew"
          description="Message the anchor position to the crew's phones every hour. In this example the save is on its way."
          checked
          state="saving"
          onCheckedChange={ignore}
        />
        <SettingToggle
          label="Shore power alarm"
          description="Available once the shore power monitor reports on the network."
          checked={false}
          state="idle"
          onCheckedChange={ignore}
          disabled
        />
        <SettingField
          label="Telegram bot token"
          description="The token Bosun sends watch alerts with."
          type="password"
          value={token.value}
          state={token.state}
          onSave={saveToken}
        />
        <SettingField
          label="MMSI"
          description="From the vessel's registration; change it on the vessel profile."
          value="224123456"
          state="idle"
          onSave={ignore}
          readOnly
        />
      </Group>
    </div>
  );
}

A setting control is one row of a settings page: the setting's name and what it does on the left, the control on the right, ending at the row's right edge, and the save state under it. The switch, the field and the select save as the crew change them, with no save button, and each shows the same save chip so the crew see the write land or fail without leaving the row. A secret, a key in a password field or a password in a setting row, is the exception: it sits in a form of its own and saves when the crew press Save. The chip sits on the row's bottom edge, in the space between two rows, so nothing in the row moves when a save starts, lands or fails. The module also exports the chip, and SettingRow, the same row for any other kit control.

Examples

Toggle

The control changes at once and the chip follows the write: saving while it is in flight, then saved, which fades.

Anchor watch
Sound the alarm when the vessel moves outside her swing circle.
A switch that saves as it is pressed: it turns at once, shows saving, then saved.
Code
import { SettingToggle, type SaveState } from "@bosunkit/components/setting-control";
import { useEffect, useRef, useState, type Dispatch, type ReactElement, type RefObject, type SetStateAction } from "react";

export const description: string =
  "A switch that saves as it is pressed: it turns at once, shows saving, then saved.";

export default function Toggle(): ReactElement {
  const [checked, setChecked]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
  const [state, setState]: [SaveState, Dispatch<SetStateAction<SaveState>>] = useState<SaveState>("idle");
  const timer: RefObject<ReturnType<typeof setTimeout> | undefined> = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  useEffect((): (() => void) => (): void => {
    clearTimeout(timer.current);
  }, []);

  function toggle(next: boolean): void {
    setChecked(next);
    setState("saving");
    clearTimeout(timer.current);
    // Stands in for the PUT to the vessel's settings.
    timer.current = setTimeout((): void => {
      setState("saved");
    }, 700);
  }

  return (
    <SettingToggle
      label="Anchor watch"
      description="Sound the alarm when the vessel moves outside her swing circle."
      checked={checked}
      state={state}
      onCheckedChange={toggle}
    />
  );
}

Toggle saving

Enabled
Saving…
The switch while its change is being saved.
Code
import { SettingToggle } from "@bosunkit/components/setting-control";
import type { ReactElement } from "react";

export const description: string = "The switch while its change is being saved.";

function ignore(): void {
  // The example saves nothing.
}

export default function ToggleSaving(): ReactElement {
  return <SettingToggle label="Enabled" checked state="saving" onCheckedChange={ignore} />;
}

Field

How bosun names the vessel on the radio and in messages.
A text field that saves on blur or Enter, with no save button.
Code
import { SettingField, type SaveState } from "@bosunkit/components/setting-control";
import { useEffect, useRef, useState, type Dispatch, type ReactElement, type RefObject, type SetStateAction } from "react";

export const description: string = "A text field that saves on blur or Enter, with no save button.";

export default function Field(): ReactElement {
  const [value, setValue]: [string, Dispatch<SetStateAction<string>>] = useState<string>("Althea");
  const [state, setState]: [SaveState, Dispatch<SetStateAction<SaveState>>] = useState<SaveState>("idle");
  const timer: RefObject<ReturnType<typeof setTimeout> | undefined> = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  useEffect((): (() => void) => (): void => {
    clearTimeout(timer.current);
  }, []);

  function save(next: string): void {
    setValue(next);
    setState("saving");
    clearTimeout(timer.current);
    // Stands in for the PUT to the vessel's settings.
    timer.current = setTimeout((): void => {
      setState("saved");
    }, 700);
  }

  return (
    <SettingField
      label="Vessel name"
      description="How bosun names the vessel on the radio and in messages."
      value={value}
      state={state}
      onSave={save}
    />
  );
}

Number field

A field for a number takes maxDigits, the most characters its value can have. It is then just wide enough for them, its digits right-aligned in equal widths, with the unit inside its right edge.

How far the vessel may move from the anchor position before the alarm sounds.
m
A number field sized to its three digits, right-aligned, with its unit inside its right edge.
Code
import { SettingField, type SaveState } from "@bosunkit/components/setting-control";
import {
  useEffect,
  useRef,
  useState,
  type Dispatch,
  type ReactElement,
  type RefObject,
  type SetStateAction,
} from "react";

export const description: string =
  "A number field sized to its three digits, right-aligned, with its unit inside its right edge.";

export default function NumberField(): ReactElement {
  const [value, setValue]: [string, Dispatch<SetStateAction<string>>] = useState<string>("45");
  const [state, setState]: [SaveState, Dispatch<SetStateAction<SaveState>>] = useState<SaveState>("idle");
  const timer: RefObject<ReturnType<typeof setTimeout> | undefined> = useRef<ReturnType<typeof setTimeout> | undefined>(
    undefined,
  );
  useEffect(
    (): (() => void) => (): void => {
      clearTimeout(timer.current);
    },
    [],
  );

  function save(next: string): void {
    setValue(next);
    setState("saving");
    clearTimeout(timer.current);
    // Stands in for the PUT to the vessel's settings.
    timer.current = setTimeout((): void => {
      setState("saved");
    }, 700);
  }

  return (
    <SettingField
      label="Swing radius"
      description="How far the vessel may move from the anchor position before the alarm sounds."
      type="number"
      value={value}
      unit="m"
      maxDigits={3}
      state={state}
      onSave={save}
    />
  );
}

Select

Used when a crew member has not chosen a language of their own.
A list of choices for a setting that takes one of a few values, saved when chosen.
Code
import { SettingSelect, type SaveState } from "@bosunkit/components/setting-control";
import { useEffect, useRef, useState, type Dispatch, type ReactElement, type RefObject, type SetStateAction } from "react";

export const description: string = "A list of choices for a setting that takes one of a few values, saved when chosen.";

export default function Select(): ReactElement {
  const [value, setValue]: [string, Dispatch<SetStateAction<string>>] = useState<string>("en");
  const [state, setState]: [SaveState, Dispatch<SetStateAction<SaveState>>] = useState<SaveState>("idle");
  const timer: RefObject<ReturnType<typeof setTimeout> | undefined> = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  useEffect((): (() => void) => (): void => {
    clearTimeout(timer.current);
  }, []);

  function save(next: string): void {
    setValue(next);
    setState("saving");
    clearTimeout(timer.current);
    // Stands in for the PUT to the vessel's settings.
    timer.current = setTimeout((): void => {
      setState("saved");
    }, 700);
  }

  return (
    <SettingSelect
      label="Vessel default language"
      description="Used when a crew member has not chosen a language of their own."
      value={value}
      options={[
        { value: "en", label: "English" },
        { value: "nl", label: "Nederlands" },
        { value: "fr", label: "Français" },
        { value: "de", label: "Deutsch" },
        { value: "es", label: "Español" },
        { value: "it", label: "Italiano" },
      ]}
      state={state}
      onSave={save}
    />
  );
}

Saved

Saved
The green chip a control shows once its change is saved, which fades.
Code
import { SaveChip } from "@bosunkit/components/setting-control";
import type { ReactElement } from "react";

export const description: string = "The green chip a control shows once its change is saved, which fades.";

export default function Saved(): ReactElement {
  return <SaveChip state="saved" />;
}

Save failed

When a save fails, the control goes back to the saved value, so it never shows a setting the vessel does not have, and the amber chip stays until the next attempt.

Shore power alarm
Press the switch: this example's save always fails.
A switch whose save fails: it turns back to the saved value and the amber chip stays until the next attempt.
Code
import { SettingToggle, type SaveState } from "@bosunkit/components/setting-control";
import { useEffect, useRef, useState, type Dispatch, type ReactElement, type RefObject, type SetStateAction } from "react";

export const description: string =
  "A switch whose save fails: it turns back to the saved value and the amber chip stays until the next attempt.";

export default function SaveFailed(): ReactElement {
  const [checked, setChecked]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
  const [state, setState]: [SaveState, Dispatch<SetStateAction<SaveState>>] = useState<SaveState>("idle");
  const timer: RefObject<ReturnType<typeof setTimeout> | undefined> = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  useEffect((): (() => void) => (): void => {
    clearTimeout(timer.current);
  }, []);

  function toggle(next: boolean): void {
    const previous: boolean = checked;
    setChecked(next);
    setState("saving");
    clearTimeout(timer.current);
    // Stands in for a PUT the vessel rejects: the switch returns to the value that is still saved.
    timer.current = setTimeout((): void => {
      setChecked(previous);
      setState("error");
    }, 900);
  }

  return (
    <SettingToggle
      label="Shore power alarm"
      description="Press the switch: this example's save always fails."
      checked={checked}
      state={state}
      onCheckedChange={toggle}
    />
  );
}

Nesting kit controls

SettingRow hosts a kit control in the same row: the vessel's MMSI, its home port, a password. The row names the control with its label, saves when the control commits a value (focus leaves it, or Enter is pressed in an input), and never saves one that fails its rules. It keeps what the crew typed and says under the control why nothing was saved, so a Wi-Fi password that misses a rule is not sent to the vessel and is not lost either. Each control keeps its own help: the MMSI says which station the number names, the password lists its rules.

The label aligns with the first field, with help and actions beneath that field. Hosted controls and saved-secret summaries share a column width. When the row is too narrow for two columns, the control fills the width below its label, keeping the label, field and guidance on the same left edge.

A secret is a form of its own with a Save button, and is saved when the crew press Save or Enter, never when focus leaves the field: a new guest Wi-Fi password changes the network every guest is on, so the crew decide when, and a password manager offers to save what a form submits. A password the vessel cannot hand back is not shown again. The row says that a password is saved and offers a change button; the field appears empty when the crew choose to change it, and goes away, emptied, once the save lands. The crew network's password takes WPA2's lengths, 8 to 63 characters, leaves out the vessel's and the networks' names, and tells the password managers the same limits, so a generated password is accepted at the first try; it is filed under the network's name. The guest network's password is checked against the same rules. A refusal names the rule and what to do: "Not saved. The password has 6 characters. Use at least 8." The marina's password has no rules of ours to check.

The guest network's password is meant to be handed on, so the vessel hands it back and the row shows it: the field is a password input with purpose="shared", plain text with no show button, so the password stays in view before, during and after Save. It keeps the checklist, the refusals and the Save button, and the password managers' opt-outs, since it is not a login of the crew's. Beside it are a Copy password button and a QR code a guest's phone joins from (WIFI:T:WPA;S:Althea Guest;P:…;;, drawn by QR code). The code holds the saved password, not a draft being typed, and is drawn dark on a light panel in every theme, so a phone's camera reads it in Night as well. The crew network's password, which the instruments and the crew's devices use, stays hidden behind the show button while it is typed and is never shown again once saved.

Vessel identity

The vessel's own number, from its radio licence.
Ship station, United Kingdom
Where the vessel is kept; the anchor alarm and the tide table start from here.

Wi-Fi

The password visitors use on the Althea Guest network; shown, so the crew can hand it on.
  • At least 8 charactersdone
  • Doesn't contain Althea or Althea Guestdone
  • Not a common or leaked passwordchecking
Crew Wi-Fi password
The password of the Althea network the crew's devices and the instruments use.
A password is saved
Upstream Wi-Fi password
The password of Hamble Point Marina's network, which bosun joins for its internet.
A password is saved
Kit controls inside setting rows: the vessel's MMSI and home port, and its Wi-Fi passwords. A value that fails its rules is not saved and the row says why. The guest password is shown, with a copy button and a code a phone's camera joins from; the crew network's password is never shown again once saved.
Code
import { Button } from "@bosunkit/components/button";
import { isValidMmsi, MmsiInput } from "@bosunkit/components/mmsi-input";
import { checkNewPassword, PasswordInput } from "@bosunkit/components/password-input";
import { PositionInput, type Position } from "@bosunkit/components/position-input";
import { QrCode, wifiNetworkText } from "@bosunkit/components/qr-code";
import { SectionHeading } from "@bosunkit/components/section-heading";
import { SettingRow, type SaveState, type SettingRowControl } from "@bosunkit/components/setting-control";
import type { MessageKey, Translator } from "@bosunkit/i18n";
import { useTranslator } from "@bosunkit/provider";
import { Anchor, Copy, Wifi } from "lucide-react";
import {
  useEffect,
  useId,
  useRef,
  useState,
  type Dispatch,
  type ReactElement,
  type ReactNode,
  type RefObject,
  type SetStateAction,
} from "react";

export const description: string =
  "Kit controls inside setting rows: the vessel's MMSI and home port, and its Wi-Fi passwords. A value that fails " +
  "its rules is not saved and the row says why. The guest password is shown, with a copy button and a code a " +
  "phone's camera joins from; the crew network's password is never shown again once saved.";

/** How long the example's save takes to come back. */
const SAVE_MS: number = 900;

/**
 * A WPA2 passphrase: 8 to 63 printable ASCII characters, without the vessel's or the network's name. Lengths and a
 * character set, as NIST SP 800-63B-4 asks; no character classes.
 */
const GUEST_RULES: {
  readonly minLength: number;
  readonly maxLength: number;
  readonly contextWords: readonly string[];
} = { minLength: 8, maxLength: 63, contextWords: ["Althea", "Althea Guest"] };

const WPA2_RULES: string = "minlength: 8; maxlength: 63; allowed: ascii-printable;";

/** The crew network's password: the same lengths, without the vessel's or either network's name. */
const CREW_RULES: {
  readonly minLength: number;
  readonly maxLength: number;
  readonly contextWords: readonly string[];
} = { minLength: 8, maxLength: 63, contextWords: ["Althea", "Althea Guest"] };

/** The guest password, shown so the crew can read it out, copy it, or let a guest's phone scan it. */
function GuestJoin({ password }: { password: string }): ReactElement {
  const [copied, setCopied]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
  return (
    <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-md)" }}>
      <QrCode
        value={wifiNetworkText({ ssid: "Althea Guest", password })}
        label="Code to join Althea Guest with a phone's camera"
        size="8em"
      />
      <div style={{ display: "grid", gap: "var(--space-xs)", justifyItems: "start" }}>
        <Button
          type="button"
          size="sm"
          onClick={(): void => {
            navigator.clipboard.writeText(password).then(
              (): void => {
                setCopied("Copied the guest password.");
              },
              (): void => {
                setCopied("Couldn't copy. Select the password in the field and copy it.");
              },
            );
          }}
        >
          <Copy aria-hidden="true" size={14} /> Copy password
        </Button>
        <span role="status" style={{ fontSize: "0.75em" }}>
          {copied}
        </span>
      </div>
    </div>
  );
}

/** Whether two positions are the same place; a position the fields do not hold whole is `null`. */
function samePosition(first: Position | null, second: Position | null): boolean {
  return (
    first === second ||
    (first !== null && second !== null && first.latitude === second.latitude && first.longitude === second.longitude)
  );
}

/** A setting's save lifecycle: a save comes back as saved after SAVE_MS, standing in for the PUT to the vessel. */
function useSave<Value>(onLanded: (saved: Value) => void): [SaveState, (next: Value) => void] {
  const [state, setState]: [SaveState, Dispatch<SetStateAction<SaveState>>] = useState<SaveState>("idle");
  const timer: RefObject<ReturnType<typeof setTimeout> | undefined> = useRef<ReturnType<typeof setTimeout> | undefined>(
    undefined,
  );
  useEffect((): (() => void) => {
    return (): void => {
      clearTimeout(timer.current);
    };
  }, []);

  function save(next: Value): void {
    setState("saving");
    clearTimeout(timer.current);
    timer.current = setTimeout((): void => {
      onLanded(next);
      setState("saved");
    }, SAVE_MS);
  }

  return [state, save];
}

function Group({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }): ReactElement {
  const headingId: string = useId();
  return (
    <section aria-labelledby={headingId} style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)" }}>
      <SectionHeading level={3} id={headingId} icon={icon}>
        {title}
      </SectionHeading>
      {children}
    </section>
  );
}

export default function NestingControls(): ReactElement {
  const translator: Translator<MessageKey> = useTranslator();
  const [mmsi, setMmsi]: [string, Dispatch<SetStateAction<string>>] = useState<string>("235012345");
  const [mmsiState, saveMmsi]: [SaveState, (next: string) => void] = useSave<string>(setMmsi);
  const [homePort, setHomePort]: [Position | null, Dispatch<SetStateAction<Position | null>>] =
    useState<Position | null>({ latitude: 50.8472, longitude: -1.3 });
  const [homePortState, saveHomePort]: [SaveState, (next: Position | null) => void] = useSave<Position | null>(
    setHomePort,
  );
  // The guest password is handed to guests, so the vessel hands it back and the row shows it.
  const [guestPassword, setGuestPassword]: [string, Dispatch<SetStateAction<string>>] =
    useState<string>("harbor-lantern-41");
  const [guestState, saveGuest]: [SaveState, (next: string) => void] = useSave<string>(setGuestPassword);
  // The crew network's password is never handed back, so the page only knows whether one is saved.
  const [crewSaved, setCrewSaved]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
  const [crewState, saveCrew]: [SaveState, (next: string) => void] = useSave<string>((): void => {
    setCrewSaved(true);
  });
  const [marinaSaved, setMarinaSaved]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
  const [marinaState, saveMarina]: [SaveState, (next: string) => void] = useSave<string>((): void => {
    setMarinaSaved(true);
  });

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-xl)", width: "100%" }}>
      <Group icon={<Anchor />} title="Vessel identity">
        <SettingRow<string>
          label="MMSI"
          description="The vessel's own number, from its radio licence."
          value={mmsi}
          state={mmsiState}
          validate={(draft: string): string | undefined =>
            isValidMmsi(draft) ? undefined : "Type all nine digits of the vessel's MMSI."
          }
          onSave={saveMmsi}
        >
          {(control: SettingRowControl<string>): ReactElement => (
            <MmsiInput
              id={control.id}
              value={control.draft}
              onValueChange={control.onDraftChange}
              disabled={control.disabled}
              aria-describedby={control.describedBy}
            />
          )}
        </SettingRow>
        <SettingRow<Position | null>
          label="Home port"
          description="Where the vessel is kept; the anchor alarm and the tide table start from here."
          value={homePort}
          isEqual={samePosition}
          state={homePortState}
          validate={(draft: Position | null): string | undefined =>
            draft === null ? "Give the latitude and the longitude, both whole." : undefined
          }
          onSave={saveHomePort}
        >
          {(control: SettingRowControl<Position | null>): ReactElement => (
            <PositionInput
              id={control.id}
              value={control.draft}
              onValueChange={control.onDraftChange}
              disabled={control.disabled}
            />
          )}
        </SettingRow>
      </Group>
      <Group icon={<Wifi />} title="Wi-Fi">
        <SettingRow<string>
          label="Guest Wi-Fi password"
          description="The password visitors use on the Althea Guest network; shown, so the crew can hand it on."
          value={guestPassword}
          saved={false}
          state={guestState}
          validate={(draft: string): string | undefined => checkNewPassword(draft, GUEST_RULES, translator).refusal}
          onSave={saveGuest}
        >
          {(control: SettingRowControl<string>): ReactElement => (
            <>
              <PasswordInput
                id={control.id}
                purpose="shared"
                minLength={GUEST_RULES.minLength}
                maxLength={GUEST_RULES.maxLength}
                contextWords={GUEST_RULES.contextWords}
                value={control.draft}
                onValueChange={control.onDraftChange}
                disabled={control.disabled}
                aria-describedby={control.describedBy}
              />
              <GuestJoin password={guestPassword} />
            </>
          )}
        </SettingRow>
        <SettingRow<string>
          label="Crew Wi-Fi password"
          description="The password of the Althea network the crew's devices and the instruments use."
          value=""
          saved={crewSaved}
          savedSummary="A password is saved"
          changeLabel="Change password"
          state={crewState}
          validate={(draft: string): string | undefined => checkNewPassword(draft, CREW_RULES, translator).refusal}
          onSave={saveCrew}
        >
          {(control: SettingRowControl<string>): ReactElement => (
            <PasswordInput
              id={control.id}
              purpose="new"
              username="Althea"
              passwordRules={WPA2_RULES}
              minLength={CREW_RULES.minLength}
              maxLength={CREW_RULES.maxLength}
              contextWords={CREW_RULES.contextWords}
              value={control.draft}
              onValueChange={control.onDraftChange}
              disabled={control.disabled}
              aria-describedby={control.describedBy}
            />
          )}
        </SettingRow>
        <SettingRow<string>
          label="Upstream Wi-Fi password"
          description="The password of Hamble Point Marina's network, which bosun joins for its internet."
          value=""
          saved={marinaSaved}
          savedSummary="A password is saved"
          changeLabel="Change password"
          state={marinaState}
          onSave={saveMarina}
        >
          {(control: SettingRowControl<string>): ReactElement => (
            <PasswordInput
              id={control.id}
              purpose="current"
              username="Hamble Point Marina"
              value={control.draft}
              onValueChange={control.onDraftChange}
              disabled={control.disabled}
              aria-describedby={control.describedBy}
            />
          )}
        </SettingRow>
      </Group>
    </div>
  );
}

Props

SaveChip

The chip that says what just happened to a change. It shows nothing while the control is idle.

PropTypeDefaultDescription
staterequiredSaveState–Where the save is: nothing is shown while idle.

SettingToggle

A switch for a setting that is on or off.

PropTypeDefaultDescription
labelrequiredstring–The setting's name; it also names the switch.
descriptionstring | undefined–What the setting does, under the name.
checkedrequiredboolean–Whether the setting is on.
staterequiredSaveState–Where the save of the last change is.
onCheckedChangeSwitchProps["onCheckedChange"]–Called with the value the crew chose.
disabledboolean | undefined–Whether the switch refuses input.

SettingField

An input that saves on blur or Enter. With maxDigits it is sized to a number of that many characters. With type="password" it holds a key or token another service issued, such as a Telegram bot token: a password input with purpose="secret", which the password managers leave alone, in a form of its own that saves when the crew press Save.

PropTypeDefaultDescription
idstring | undefined–The input's id, which the label points at; setting- and the label's words, hyphenated, by default.
labelrequiredstring–The setting's name; it also names the field.
descriptionstring | undefined–What the setting does, under the name.
valuerequiredstring–The saved value.
staterequiredSaveState–Where the save of the last change is.
onSaverequired(next: string) => void–Called on blur or Enter (on Save for a password), only when the value actually changed.
type"text" | "number" | "password" | undefined"text"The kind of input; text by default. password is a secret another service issued, such as an API key or a bot token: a PasswordInput with purpose="secret", which the password managers leave alone, in a form of its own saved by its Save button rather than on blur.
placeholderstring | undefined–The hint shown in the empty field.
unitstring | undefined–The unit: after the field, or inside its right edge when maxDigits sizes the field.
maxDigitsnumber | undefined–The most characters the value can have, a sign or decimal point counted as one: the field is then just wide enough for them, its digits right-aligned in equal widths, as a number in a column of figures is. Full width otherwise.
readOnlyboolean | undefined–Whether the field shows the value without letting the crew change it.

SettingSelect

A native select for a setting that takes one of a few values. It calls onSave only when the chosen value differs from the saved one.

PropTypeDefaultDescription
idstring | undefined–The select's id, which the label points at; setting- and the label's words, hyphenated, by default.
labelrequiredstring–The setting's name; it also names the list.
descriptionstring | undefined–What the setting does, under the name.
valuerequiredstring–The saved value.
optionsrequiredreadonly SettingSelectOption[]–The choices.
staterequiredSaveState–Where the save of the last change is.
onSaverequired(next: string) => void–Called with the chosen value, only when it differs from the saved one.

SettingRow

A row that hosts any kit control. Its child is a function that receives the id for the control's input, the draft and onDraftChange, commit for a control whose own change is the commit, disabled and the id of the refusal for aria-describedby. A password the crew choose, with its rules and its checklist, goes in a SettingRow, so every control with its own help, rules and shape fits a row the same way.

PropTypeDefaultDescription
labelrequiredstring–The setting's name; it also names the hosted control.
descriptionstring | undefined–What the setting does, under the name.
valuerequiredValue–The saved value; for a secret the vessel does not hand back, the empty value.
staterequiredSaveState–Where the save of the last change is.
onSaverequired(next: Value) => void–Called with a value that changed and that validate accepts; never with an invalid one.
validate((draft: Value) => string | undefined) | undefined–Why a value cannot be saved, in the crew's words and ending with what to do, or undefined when it can. The row shows it under the control as "Not saved. ..." and keeps the draft.
isEqual((first: Value, second: Value) => boolean) | undefinedObject.isWhether two values are the same setting; Object.is by default.
savedboolean | undefined–Whether the vessel holds a value it never hands back, such as a password; set it, true or false, for any secret. The control then sits in a form of its own with a Save button, saved only when the form is submitted, so a password manager sees the password submitted and the crew decide when the network changes. Once a value is saved the control is hidden behind savedSummary and a changeLabel button until the crew choose to change it, and it is empty again, and hidden, once a save lands.
savedSummarystring | undefined–What the row says in place of the control while a secret is saved: "A password is saved".
changeLabelstring | undefined–The button that brings the control back to replace a saved secret: "Change password".
disabledboolean | undefined–Whether the hosted control refuses input.
childrenrequired(control: SettingRowControl<Value>) => ReactNode–The kit control to host, drawn with the row's id, draft and callbacks.