Skip to content
design
Components

Setting chips

A setting that holds a list of names or numbers, shown as removable chips with a field to add one.

On this page
Anchor watch rota
Who Bosun wakes, in this order, when an anchor alarm is not acknowledged within a minute.
Maya LindqvistTomás HerreraIngrid Aalto
Telegram chats
Chat ids that may ask Bosun about the vessel. In this example every save fails.
41288930177120455
Passage harbors
Set by the passage plan, Palma to A Coruña; change them on the passage page.
Palma de MallorcaCartagenaGibraltarLagosCascaisBaionaA Coruña
Three list settings in one card: crew names whose saves land, numeric chat ids whose saves fail and roll back, and read-only harbors.
Code
import { SettingChips } from "@bosunkit/components/setting-chips";
import 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 =
  "Three list settings in one card: crew names whose saves land, Telegram chat ids whose saves fail and roll " +
  "back, and the passage's harbors shown read-only.";

type Entries = (string | number)[];

interface ListSetting {
  readonly values: Entries;
  readonly state: SaveState;
}

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

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

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

  return [setting, save];
}

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

export default function Showcase(): ReactElement {
  const [crew, saveCrew]: [ListSetting, (next: Entries) => void] = useListSetting(
    ["Maya Lindqvist", "Tomás Herrera", "Ingrid Aalto"],
    false,
  );
  const [chats, saveChats]: [ListSetting, (next: Entries) => void] = useListSetting([412_889_301, 77_120_455], true);

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        gap: "var(--space-md)",
        width: "100%",
        padding: "var(--space-md)",
        border: "1px solid var(--border)",
        borderRadius: "var(--radius-md)",
      }}
    >
      <SettingChips
        label="Anchor watch rota"
        description="Who Bosun wakes, in this order, when an anchor alarm is not acknowledged within a minute."
        values={crew.values}
        state={crew.state}
        onSave={saveCrew}
        placeholder="Add a crew member…"
      />
      <SettingChips
        label="Telegram chats"
        description="Chat ids that may ask Bosun about the vessel. In this example every save fails."
        values={chats.values}
        state={chats.state}
        onSave={saveChats}
        numeric
        placeholder="Chat id"
      />
      <SettingChips
        label="Passage harbors"
        description="Set by the passage plan, Palma to A Coruña; change them on the passage page."
        values={["Palma de Mallorca", "Cartagena", "Gibraltar", "Lagos", "Cascais", "Baiona", "A Coruña"]}
        state="idle"
        onSave={ignore}
        readOnly
      />
    </div>
  );
}

Setting chips edit a flat list of strings or numbers. Each entry is a chip with a remove button; Enter or leaving the add field commits a new entry, and an entry already in the list is not added twice. Every change calls onSave with the whole next list, and the save chip beside the control shows where that save is.

Examples

Named values

Disabled
podcastmusic
A list of names, each a chip with a remove button, and a field to add another.
Code
import { SettingChips } from "@bosunkit/components/setting-chips";
import type { ReactElement } from "react";

export const description: string = "A list of names, each a chip with a remove button, and a field to add another.";

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

export default function NamedValues(): ReactElement {
  return <SettingChips label="Disabled" values={["podcast", "music"]} state="idle" onSave={ignore} />;
}

Numeric

Allowed Ids
1234
A list of numbers: a new entry is read as a number, and text that is not one is not added.
Code
import { SettingChips } from "@bosunkit/components/setting-chips";
import type { ReactElement } from "react";

export const description: string =
  "A list of numbers: a new entry is read as a number, and text that is not one is not added.";

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

export default function Numeric(): ReactElement {
  return <SettingChips label="Allowed Ids" values={[1234]} state="idle" onSave={ignore} numeric />;
}

Motion

By default an added entry scales in and a removed one scales out, while the others keep their place; the entries there on the first render do not move. motion takes the kit's motion prop, and motion={false} changes the list at once. A removed entry takes no press while it leaves, so a second tap cannot remove its neighbor. Under reduced motion the entries only fade.

Ports of refuge
FalmouthBrestA Coruña
Ports of refuge, without motion
FalmouthBrest
Type a port and press Enter, or remove one: by default an added port scales in and a removed one scales out while the others keep their place; the list below, with motion={false}, changes at once.
Code
import { SettingChips } from "@bosunkit/components/setting-chips";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "Type a port and press Enter, or remove one: by default an added port scales in and a removed one scales out while " +
  "the others keep their place; the list below, with motion={false}, changes at once.";

type Entries = (string | number)[];

export default function Motion(): ReactElement {
  const [ports, setPorts]: [Entries, Dispatch<SetStateAction<Entries>>] = useState<Entries>([
    "Falmouth",
    "Brest",
    "A Coruña",
  ]);
  const [still, setStill]: [Entries, Dispatch<SetStateAction<Entries>>] = useState<Entries>(["Falmouth", "Brest"]);
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
      <SettingChips label="Ports of refuge" values={ports} state="idle" onSave={setPorts} />
      <SettingChips label="Ports of refuge, without motion" values={still} state="idle" onSave={setStill} motion={false} />
    </div>
  );
}

Props

PropTypeDefaultDescription
labelrequiredstring–The setting's name; it also names the add field.
descriptionstring | undefined–What the setting does, under the name.
valuesrequiredreadonly (string | number)[]–Current entries — strings or numbers, preserved on the wire.
staterequiredSaveState–Where the save of the last change is.
onSaverequired(next: (string | number)[]) => void–Fired with the full next list on add or remove.
numericboolean | undefinedfalseCoerce new entries to numbers (for example telegram allowed_ids).
readOnlyboolean | undefined–Whether the entries show without the add field and the remove buttons.
placeholderstring | undefined–The hint shown in the empty add field; "Add…" when not set.
motionMotionProp | undefined–How the entries move. By default an added entry scales in and a removed one scales out, while the others keep their place; the entries there on the first render do not move. A preset (fade, rise, scale) or { enter, exit } chooses; false turns it off. A removed entry takes no press while it leaves.