Skip to content
design
Components

Input

A text input in the kit's style, with an optional icon and unit inside its edges.

On this page
Nine digits, as the VHF shows it.

In UTC, as the log is kept.

hPa

93 of 120 characters

hr

The engine is off; the hours fill in when it runs.

A logbook entry in inputs of every kind, each named by its field's label, with units, icons, hints and an error.
Code
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import { MmsiInput } from "@bosunkit/components/mmsi-input";
import { PositionInput } from "@bosunkit/components/position-input";
import { Clock, NotebookPen, Search } from "lucide-react";
import {
  useId,
  useRef,
  useState,
  type ChangeEvent,
  type CSSProperties,
  type Dispatch,
  type ReactElement,
  type RefObject,
  type SetStateAction,
} from "react";

export const description: string =
  "A logbook entry: text, a call sign checked as it is typed, MMSI, time, a number with its unit inside the field, a " +
  "search with an icon and harbor suggestions, a long remark with a count, read-only and disabled fields, and a ref " +
  "that moves the cursor to the remark when it is empty.";

const REMARK_LIMIT: number = 120;
const CALL_SIGN: RegExp = /^[A-Z0-9]*$/;

const ROW: CSSProperties = { display: "flex", alignItems: "center", gap: "var(--space-sm)" };

const HARBORS: readonly string[] = ["Brest", "Camaret-sur-Mer", "Douarnenez", "Morgat", "Audierne", "Concarneau"];

export default function Showcase(): ReactElement {
  const id: string = useId();
  const remarkInput: RefObject<HTMLInputElement | null> = useRef<HTMLInputElement | null>(null);
  const [callSign, setCallSign]: [string, Dispatch<SetStateAction<string>>] = useState<string>("PD7AL-");
  const [mmsi, setMmsi]: [string, Dispatch<SetStateAction<string>>] = useState<string>("24412345");
  const [remark, setRemark]: [string, Dispatch<SetStateAction<string>>] = useState<string>(
    "Second reef in the main off Pointe Saint-Mathieu as the wind rose to 24 kn from the southwest",
  );
  const [logged, setLogged]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
  const callSignError: string | undefined = CALL_SIGN.test(callSign)
    ? undefined
    : "A call sign has only letters and digits, such as PD7AL.";

  return (
    <div
      style={{
        width: "100%",
        maxWidth: 600,
        display: "grid",
        gridTemplateColumns: "repeat(auto-fit, minmax(170px, 1fr))",
        gap: "var(--space-md)",
      }}
    >
      <FormField label="Vessel">
        <Input defaultValue="SV Althea" autoComplete="off" />
      </FormField>
      <FormField label="Call sign" error={callSignError}>
        <Input
          value={callSign}
          autoCapitalize="characters"
          spellCheck={false}
          maxLength={7}
          onChange={(event: ChangeEvent<HTMLInputElement>): void => {
            setCallSign(event.target.value.toUpperCase());
          }}
        />
      </FormField>
      <FormField label="MMSI" htmlFor={`${id}-mmsi`}>
        <MmsiInput id={`${id}-mmsi`} value={mmsi} onValueChange={setMmsi} hint="Nine digits, as the VHF shows it." />
      </FormField>

      <FormField label="Time" hint="In UTC, as the log is kept.">
        <Input type="time" defaultValue="02:20" icon={<Clock />} />
      </FormField>
      <FormField label="Barometer">
        <Input type="number" inputMode="decimal" step={0.1} min={900} max={1100} defaultValue="1004.2" unit="hPa" />
      </FormField>
      <FormField label="Next harbor">
        <Input
          type="search"
          list={`${id}-harbors`}
          placeholder="Search harbors"
          defaultValue="Camaret-sur-Mer"
          icon={<Search />}
        />
        <datalist id={`${id}-harbors`}>
          {HARBORS.map((harbor: string): ReactElement => (
            <option key={harbor} value={harbor} />
          ))}
        </datalist>
      </FormField>

      <div style={{ gridColumn: "1 / -1" }}>
        <FormField label="Remark" required hint={`${String(remark.length)} of ${String(REMARK_LIMIT)} characters`}>
          <Input
            ref={remarkInput}
            placeholder="What happened on watch, in one sentence"
            maxLength={REMARK_LIMIT}
            value={remark}
            onChange={(event: ChangeEvent<HTMLInputElement>): void => {
              setRemark(event.target.value);
            }}
          />
        </FormField>
      </div>

      <FormField label="Position" htmlFor={`${id}-position`}>
        <PositionInput id={`${id}-position`} readOnly value={{ latitude: 48.22367, longitude: -4.49783 }} />
      </FormField>
      <FormField label="Engine hours" hint="The engine is off; the hours fill in when it runs.">
        <Input disabled value="1,284.6" unit="hr" />
      </FormField>
      <div style={{ ...ROW, alignSelf: "end", flexWrap: "wrap" }}>
        <Button
          variant="primary"
          onClick={(): void => {
            if (remark.trim() === "") {
              remarkInput.current?.focus();
              return;
            }
            setLogged((count: number): number => count + 1);
            setRemark("");
          }}
        >
          <NotebookPen size={14} aria-hidden="true" />
          Log entry
        </Button>
        {logged > 0 && <Badge variant="green">{String(logged)} logged</Badge>}
      </div>
    </div>
  );
}

An input is a native input element in the kit's style. It takes every attribute of one, so type, value, placeholder and onChange work as they do on the element. An input has no visible label of its own: put it in a form field, whose label names it and whose hint and error it reads with its value, or name it with aria-label. It is for a single field outside a form, such as a search, a filter or a setting saved on its own; a field of a form is an outlined field. unit sits inside its right edge and icon inside its left edge; a press on either lands on the input.

An input is 48 px tall, the touch target, on a phone or a tablet (a screen up to the 68.75em breakpoint) or under a coarse pointer, and 44 px on a desktop with a fine pointer, as a button is. The combo box, the schema form's fields and a setting's field are inputs and follow it; a textarea in the input's style keeps the height of its rows.

Examples

Text

A text input takes the attributes of a native input, here a URL type and a placeholder.
Code
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";

export const description: string = "A text input takes the attributes of a native input, here a URL type and a placeholder.";

export default function Text(): ReactElement {
  return (
    <Input
      aria-label="Podcast feed URL"
      type="url"
      defaultValue="https://example.org/feed.xml"
      placeholder="https://…/feed.xml"
    />
  );
}

With a unit

unit takes the unit as SN.1/Circ.243 writes it (m, kn, °T, hPa). It sits inside the right edge, the text stops before it, and it is read after the value through aria-describedby, so the field's name need not repeat it.

m
kn
°T
Units inside the right edge, as SN.1/Circ.243 writes them, read after the value so the name need not repeat them.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";

export const description: string =
  "Units inside the right edge, as SN.1/Circ.243 writes them, read after the value so the name need not repeat them.";

export default function WithUnit(): ReactElement {
  return (
    <div
      style={{
        display: "grid",
        gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))",
        gap: "var(--space-md)",
        width: "100%",
      }}
    >
      <FormField label="Depth alarm">
        <Input type="number" inputMode="decimal" step={0.1} defaultValue="3.0" unit="m" />
      </FormField>
      <FormField label="Speed limit">
        <Input type="number" inputMode="decimal" step={0.1} defaultValue="6.0" unit="kn" />
      </FormField>
      <FormField label="Course to steer">
        <Input type="number" inputMode="numeric" min={0} max={359} defaultValue="218" unit="°T" />
      </FormField>
    </div>
  );
}

With an icon

icon sits inside the left edge, before the text. It is decorative and hidden from assistive technology, so the input's name says what the field is for.

A search field with a magnifier inside its left edge, hidden from assistive technology.
Code
import { Input } from "@bosunkit/components/input";
import { Search } from "lucide-react";
import type { ReactElement } from "react";

export const description: string =
  "A search field with a magnifier inside its left edge, hidden from assistive technology.";

export default function WithIcon(): ReactElement {
  return <Input type="search" aria-label="Search the logbook" placeholder="Search the logbook" icon={<Search />} />;
}

Invalid

An input marked aria-invalid draws a dashed neutral border. Keyboard focus adds a separate outer ring, so the invalid indication stays visible. A form field marks its input and associates the correction message with it.

m
An input whose field shows an error is marked aria-invalid and draws a dashed neutral border, with a separate focus ring.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";

export const description: string =
  "An input whose field shows an error is marked aria-invalid and draws a dashed neutral border, with a separate focus ring.";

export default function Invalid(): ReactElement {
  return (
    <FormField label="Swing radius" error="The anchor watch takes a swing radius from 20 to 200 m; 350 m was entered.">
      <Input type="number" inputMode="decimal" defaultValue="350" unit="m" />
    </FormField>
  );
}

Props

PropTypeDefaultDescription
classNamestring | undefined–Additional input classes.
refRef<HTMLInputElement> | undefined–The input element.
iconReactNode | undefined–An icon inside the field's left edge, before the text, such as a magnifier on a search field. It is decorative: the field's label says what it is for.
unitstring | undefined–The unit of the value, inside the field's right edge (m, kn, °T), as SN.1/Circ.243 writes it. It is read after the value, through aria-describedby, so the field's name need not repeat it.
onValueChange((value: string, eventDetails: Input.ChangeEventDetails) => void) | undefined–Callback fired when the value changes. Use when controlled.
defaultValueField.Control.Props['defaultValue'] | undefined–The default value of the input. Use when uncontrolled.
valueReact.ComponentProps<'input'>['value'] | undefined–The value of the input. Use when controlled.
renderReact.ReactElement | ComponentRenderFn<RenderFunctionProps, State> | undefined–Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.
styleReact.CSSProperties | ((state: State) => React.CSSProperties | undefined) | undefined–Style applied to the element, or a function that returns a style object based on the component's state.

Also takes every attribute of Omit<Primitive.Props, "className">.

Do
m
Name an input with a form field's label, and put its unit inside its edge.
Don't
Don't use the placeholder as the name. It goes as the crew type, and the unit goes with it.