Skip to content
design
Components

Crew picker

A name field that offers the saved crew and hands the chosen record back to fill the rest of a form.

On this page
Date of birth
A crew member's details built from the maritime inputs: choose a saved crew member in the name field and every field of the group fills from their record.
Code
import { CrewPicker, type CrewPickerMember } from "@bosunkit/components/crew-picker";
import { DateOfBirthInput } from "@bosunkit/components/date-of-birth-input";
import { EmailInput } from "@bosunkit/components/email-input";
import { OutlinedField, OutlinedInput } from "@bosunkit/components/outlined-field";
import { PhoneInput } from "@bosunkit/components/phone-input";
import { Select } from "@bosunkit/components/select";
import {
  useState,
  type ChangeEvent,
  type CSSProperties,
  type Dispatch,
  type ReactElement,
  type SetStateAction,
} from "react";

export const description: string =
  "A crew member's details as bosund's crew_member form holds them, with the passport number its guest form adds: " +
  "focus the name to see the saved crew, type to narrow them, and choose one to fill every field of the group from " +
  "their record. The people are fictional.";

/** One crew member's details, as the form edits them. */
interface CrewDetails {
  readonly name: string;
  readonly role: string;
  readonly dateOfBirth: string;
  readonly nationality: string;
  readonly passport: string;
  readonly phone: string;
  readonly email: string;
  readonly emergencyName: string;
  readonly emergencyPhone: string;
}

/** A saved crew member: the picker's row and the record that fills the group. */
interface SavedCrew extends CrewPickerMember {
  readonly details: CrewDetails;
}

const EMPTY: CrewDetails = {
  name: "",
  role: "crew",
  dateOfBirth: "",
  nationality: "",
  passport: "",
  phone: "",
  email: "",
  emergencyName: "",
  emergencyPhone: "",
};

const ROLES: readonly { readonly value: string; readonly label: string; readonly detail: string }[] = [
  { value: "skipper", label: "Skipper", detail: "Runs the vessel" },
  { value: "mate", label: "Mate", detail: "Second in command" },
  { value: "crew", label: "Crew", detail: "Sailing crew" },
  { value: "guest", label: "Guest", detail: "Not on the crew list" },
];

const ROLE_LABELS: Readonly<Record<string, string>> = Object.fromEntries(
  ROLES.map((role: { readonly value: string; readonly label: string }): [string, string] => [role.value, role.label]),
);

function saved(id: string, details: CrewDetails): SavedCrew {
  return { id, name: details.name, detail: ROLE_LABELS[details.role], dateOfBirth: details.dateOfBirth, details };
}

const CREW: readonly SavedCrew[] = [
  saved("c1", {
    name: "Ines Varga",
    role: "skipper",
    dateOfBirth: "1979-05-14",
    nationality: "Hungarian",
    passport: "HX0418827",
    phone: "+447700900123",
    email: "ines.varga@example.org",
    emergencyName: "Bence Varga",
    emergencyPhone: "+447700900456",
  }),
  saved("c2", {
    name: "Tomás Ørsted",
    role: "mate",
    dateOfBirth: "1988-11-02",
    nationality: "Danish",
    passport: "DK2290315",
    phone: "+4520123456",
    email: "tomas.orsted@example.org",
    emergencyName: "Lene Ørsted",
    emergencyPhone: "+4520654321",
  }),
  saved("c3", {
    name: "Mara Quist",
    role: "guest",
    dateOfBirth: "2001-02-27",
    nationality: "Dutch",
    passport: "NQ7731904",
    phone: "+31612345678",
    email: "mara.quist@example.org",
    emergencyName: "Joris Quist",
    emergencyPhone: "+31687654321",
  }),
];

const GRID: CSSProperties = {
  width: "100%",
  maxWidth: 640,
  display: "grid",
  gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
  gap: "var(--space-lg)",
  alignContent: "start",
};

export default function Showcase(): ReactElement {
  const [details, setDetails]: [CrewDetails, Dispatch<SetStateAction<CrewDetails>>] = useState<CrewDetails>(EMPTY);

  function set(field: keyof CrewDetails, value: string): void {
    setDetails({ ...details, [field]: value });
  }

  return (
    <form
      style={GRID}
      onSubmit={(event: { preventDefault: () => void }): void => {
        event.preventDefault();
      }}
    >
      <CrewPicker
        label="Full name"
        required
        placeholder="e.g. MARIA LOPEZ"
        help="As on the passport. Choose a saved crew member to fill the rest of the form."
        crew={CREW}
        value={details.name}
        onValueChange={(name: string): void => {
          set("name", name);
        }}
        onSelect={(member: SavedCrew): void => {
          setDetails(member.details);
        }}
      />
      <OutlinedField label="Role" labelFor="crew-role" filled>
        <Select.Root
          value={details.role}
          onValueChange={(role: string | null): void => {
            set("role", role ?? "crew");
          }}
        >
          <Select.Trigger id="crew-role" className="outlined-field__control">
            <Select.Value>{ROLE_LABELS[details.role]}</Select.Value>
          </Select.Trigger>
          <Select.Portal>
            <Select.Positioner alignItemWithTrigger={true}>
              <Select.Popup>
                <Select.List>
                  {ROLES.map(
                    (role: { readonly value: string; readonly label: string; readonly detail: string }): ReactElement => (
                      <Select.Item key={role.value} value={role.value}>
                        {role.label}
                        <span style={{ marginInlineStart: "var(--space-sm)", color: "var(--text-dim)", fontSize: 11 }}>
                          {role.detail}
                        </span>
                      </Select.Item>
                    ),
                  )}
                </Select.List>
              </Select.Popup>
            </Select.Positioner>
          </Select.Portal>
        </Select.Root>
      </OutlinedField>
      <DateOfBirthInput
        label="Date of birth"
        today="2026-10-11"
        value={details.dateOfBirth}
        onValueChange={(dateOfBirth: string): void => {
          set("dateOfBirth", dateOfBirth);
        }}
      />
      <OutlinedInput
        label="Nationality"
        value={details.nationality}
        onChange={(event: ChangeEvent<HTMLInputElement>): void => {
          set("nationality", event.target.value);
        }}
      />
      <OutlinedInput
        label="Passport number"
        value={details.passport}
        autoCapitalize="characters"
        onChange={(event: ChangeEvent<HTMLInputElement>): void => {
          set("passport", event.target.value.toUpperCase());
        }}
      />
      <PhoneInput
        label="Phone"
        defaultCountry="GB"
        value={details.phone}
        onValueChange={(phone: string): void => {
          set("phone", phone);
        }}
      />
      <EmailInput
        label="Email"
        value={details.email}
        onValueChange={(email: string): void => {
          set("email", email);
        }}
      />
      <OutlinedInput
        label="Emergency contact"
        value={details.emergencyName}
        onChange={(event: ChangeEvent<HTMLInputElement>): void => {
          set("emergencyName", event.target.value);
        }}
      />
      <PhoneInput
        label="Emergency phone"
        defaultCountry="GB"
        value={details.emergencyPhone}
        onValueChange={(phone: string): void => {
          set("emergencyPhone", phone);
        }}
      />
    </form>
  );
}

A crew picker is a name field that lists the saved crew while it has focus. The list sits under the field, headed "Saved crew": each member is a tinted row with a person, the name in capitals, and a line with what the page says about them (a role or a category) and their date of birth in ISO 8601. Typing narrows the list to the members whose name or line holds every word typed, without regard to case or accents. Arrow keys move through the rows, Enter or a press chooses one, and Escape closes the list. With no match, the list says a new name can be typed.

Choosing a member fills the field with their name and hands their whole record to onSelect, typed by the page's own member type, so the page fills the rest of the group. A ? button shows the field's help under it.

The showcase is a crew member's details as bosund's crew_member form holds them (name, role, date of birth, nationality, phone, email, emergency contact and phone), with the passport number its guest form adds, built from the date of birth, phone, email and outlined inputs. The names and numbers in the examples are fictional.

Examples

Empty

Focus the field to see the saved crew under it, each row a person, a name in capitals and a line with the role and the date of birth. The people are fictional.
Code
import { CrewPicker, type CrewPickerMember } from "@bosunkit/components/crew-picker";
import type { ReactElement } from "react";

export const description: string =
  "Focus the field to see the saved crew under it, each row a person, a name in capitals and a line with the role " +
  "and the date of birth. The people are fictional.";

const CREW: readonly CrewPickerMember[] = [
  { id: "c1", name: "Ines Varga", detail: "Skipper", dateOfBirth: "1979-05-14" },
  { id: "c2", name: "Tomás Ørsted", detail: "Mate", dateOfBirth: "1988-11-02" },
  { id: "c3", name: "Mara Quist", detail: "Guest", dateOfBirth: "2001-02-27" },
];

export default function Empty(): ReactElement {
  return (
    <div style={{ width: "100%", maxWidth: 360 }}>
      <CrewPicker label="Full name" crew={CREW} placeholder="e.g. MARIA LOPEZ" />
    </div>
  );
}

Filled

A filled name keeps its own case; only the list draws names in capitals.
Code
import { CrewPicker, type CrewPickerMember } from "@bosunkit/components/crew-picker";
import type { ReactElement } from "react";

export const description: string = "A filled name keeps its own case; only the list draws names in capitals.";

const CREW: readonly CrewPickerMember[] = [
  { id: "c1", name: "Ines Varga", detail: "Skipper", dateOfBirth: "1979-05-14" },
];

export default function Filled(): ReactElement {
  return <CrewPicker label="Skipper" crew={CREW} defaultValue="Ines Varga" />;
}

No match

Typing narrows the list without regard to case or accents; with no match the panel says a new name can be typed.
Code
import { CrewPicker, type CrewPickerMember } from "@bosunkit/components/crew-picker";
import type { ReactElement } from "react";

export const description: string =
  "Typing narrows the list without regard to case or accents; with no match the panel says a new name can be typed.";

const CREW: readonly CrewPickerMember[] = [
  { id: "c1", name: "Ines Varga", detail: "Skipper", dateOfBirth: "1979-05-14" },
  { id: "c2", name: "Tomás Ørsted", detail: "Mate", dateOfBirth: "1988-11-02" },
];

export default function NoMatch(): ReactElement {
  return (
    <div style={{ width: "100%", maxWidth: 360 }}>
      <CrewPicker label="Full name" crew={CREW} defaultValue="Jonas" />
    </div>
  );
}

Invalid

An error outlines the field in red and is announced.
Code
import { CrewPicker, type CrewPickerMember } from "@bosunkit/components/crew-picker";
import type { ReactElement } from "react";

export const description: string = "An error outlines the field in red and is announced.";

const CREW: readonly CrewPickerMember[] = [{ id: "c1", name: "Ines Varga", detail: "Skipper" }];

export default function Invalid(): ReactElement {
  return (
    <CrewPicker
      label="Given names"
      required
      crew={CREW}
      error="Enter the given names as on the passport."
      help="As on the passport's photo page."
    />
  );
}

Read-only

A read-only name offers no list.
Code
import { CrewPicker, type CrewPickerMember } from "@bosunkit/components/crew-picker";
import type { ReactElement } from "react";

export const description: string = "A read-only name offers no list.";

const CREW: readonly CrewPickerMember[] = [{ id: "c1", name: "Ines Varga", detail: "Skipper" }];

export default function ReadOnly(): ReactElement {
  return <CrewPicker label="Skipper" crew={CREW} defaultValue="Ines Varga" readOnly />;
}

Disabled

A disabled crew picker.
Code
import { CrewPicker, type CrewPickerMember } from "@bosunkit/components/crew-picker";
import type { ReactElement } from "react";

export const description: string = "A disabled crew picker.";

const CREW: readonly CrewPickerMember[] = [{ id: "c1", name: "Ines Varga", detail: "Skipper" }];

export default function Disabled(): ReactElement {
  return <CrewPicker label="Skipper" crew={CREW} defaultValue="Ines Varga" disabled />;
}

Inside a form field

Inside a form field, named by `aria-label`, with its own heading over the list.
Code
import { CrewPicker, type CrewPickerMember } from "@bosunkit/components/crew-picker";
import { FormField } from "@bosunkit/components/form-field";
import type { ReactElement } from "react";

export const description: string = "Inside a form field, named by `aria-label`, with its own heading over the list.";

const CREW: readonly CrewPickerMember[] = [
  { id: "c2", name: "Tomás Ørsted", detail: "Mate", dateOfBirth: "1988-11-02" },
  { id: "c3", name: "Mara Quist", detail: "Guest", dateOfBirth: "2001-02-27" },
];

export default function InFormField(): ReactElement {
  return (
    <div style={{ width: "100%", maxWidth: 360 }}>
      <FormField label="On watch with">
        <CrewPicker aria-label="On watch with" heading="Crew aboard" crew={CREW} />
      </FormField>
    </div>
  );
}

Paste

The paste button comes first at the end of the field, before the help button. It reads a name, and a date of birth beside it when the text holds one, from a row copied out of a crew list: parts split at line breaks, tabs, commas and semicolons, the date in any form the date of birth input reads. The name fills the field, and onPaste hands the name and the date of birth to the page, which fills its date of birth field. The paste button shows only when the clipboard holds something the field can take, named by what it would paste (Paste Mara Quist, 27 February 2001). To know that, the field reads the clipboard when it takes focus and when the window comes back, and only where the browser lets the page read it without asking: the clipboard-read permission is granted. Where the browser would ask first, or does not know the permission (Firefox, Safari), the field reads nothing and shows no button, so no prompt appears on focus; Ctrl+V and a long press on a phone paste through the same reader. A read-only or disabled field shows no paste button.

Date of birth
  • Mara Quist, 2001-02-27
  • Ines Varga 14 May 1979
  • 2001-02-27
Copy one of these lines, then focus the field: where the browser lets the page read the clipboard, a paste button named by the name appears before the help button; Ctrl+V works everywhere. The name fills the field, and a date of birth beside it fills the date field through onPaste.
Code
import { CrewPicker, type CrewPickerMember, type PastedCrew } from "@bosunkit/components/crew-picker";
import { DateOfBirthInput } from "@bosunkit/components/date-of-birth-input";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "Copy one of these lines, then focus the field: where the browser lets the page read the clipboard, a paste button named by the name appears before the help button; Ctrl+V works everywhere. The name fills the field, and a date of birth beside it fills the date field through onPaste.";

const SAMPLES: readonly string[] = ["Mara Quist, 2001-02-27", "Ines Varga\t14 May 1979", "2001-02-27"];

const CREW: readonly CrewPickerMember[] = [
  { id: "c1", name: "Ines Varga", detail: "Skipper", dateOfBirth: "1979-05-14" },
];

export default function Paste(): ReactElement {
  const [name, setName]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
  const [born, setBorn]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
  return (
    <div style={{ width: "100%", maxWidth: 520, display: "grid", gap: "var(--space-md)" }}>
      <CrewPicker
        label="Full name"
        help="As on the passport."
        crew={CREW}
        value={name}
        onValueChange={setName}
        onPaste={(pasted: PastedCrew): void => {
          setBorn(pasted.dateOfBirth ?? "");
        }}
      />
      <DateOfBirthInput label="Date of birth" today="2026-10-11" value={born} onValueChange={setBorn} />
      <ul style={{ margin: 0, paddingLeft: "var(--space-lg)", fontFamily: "var(--font-mono)", fontSize: 12 }}>
        {SAMPLES.map((sample: string): ReactElement => (
          <li key={sample} style={{ userSelect: "all", whiteSpace: "pre" }}>
            {sample}
          </li>
        ))}
      </ul>
    </div>
  );
}

Props

PropTypeDefaultDescription
labelstring | undefined–The field's name on the outline, translated by the caller; omit it inside a FormField.
valuestring | undefined–The name as typed.
defaultValuestring | undefined–The name it starts with when the caller does not hold value.
onValueChange((value: string) => void) | undefined–Called with the text as the crew type it, and with the member's name when one is chosen.
crewrequiredreadonly Member[]–The saved crew members to offer, in the order to list them.
onSelect((member: Member) => void) | undefined–Called with the chosen member's whole record, so the caller can fill the other fields from it.
onPaste((pasted: PastedCrew) => void) | undefined–Called with what a paste read, the name and a date of birth when the text had one (Mara Quist, 2001-02-27), so the caller can fill the date of birth too. The name fills the field either way.
headingstring | undefined–The heading over the list, translated by the caller; "Saved crew" by default.
helpstring | undefined–Help for filling the field, translated by the caller: a ? button at the end shows it under the field.
hintstring | undefined–Text under the field, translated by the caller.
errorstring | undefined–What is wrong with the name and how to fix it.
requiredboolean | undefined–Whether the field must hold a name.
disabledboolean | undefined–Whether the field takes no input.
readOnlyboolean | undefined–Whether the field shows its name without letting the crew change it.
idstring | undefined–The input's id, for a Label's htmlFor.
namestring | undefined–The form field name.
placeholderstring | undefined–Text shown while the field is empty and focused, translated by the caller (e.g. MARIA LOPEZ).
aria-labelstring | undefined–Names the input when there is no label.
autoCompletestring | undefined"name"The browser's autofill hint; name by default.
classNamestring | undefined–Classes added to the field's root.
testIdstring | undefined–data-testid on the field.
refRef<HTMLInputElement> | undefined–The input element.
motionMotionProp | undefined–false opens the list at once and keeps the error line still; true also moves the error line.