src/components/dives/mixture-fields.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import { useEffect, useState } from "react";
import {
Control,
FieldValues,
Path,
UseFieldArrayReturn,
useFieldArray,
useFormContext,
useWatch,
} from "react-hook-form";
import { AddRowButton, RepeatableRow } from "@/components/ui/repeatable-row";
import { Input } from "@/components/ui/input";
import { DecimalInput } from "@/components/ui/decimal-input";
import { NativeSelect } from "@/components/ui/native-select";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { AlertTriangle } from "lucide-react";
import { DiveMixtureInput } from "@/lib/validations/dive";
import {
DEFAULT_MIXTURE,
GAS_ROLE_LABELS,
PPO2_WORKING,
diveModWarning,
gasHintParts,
} from "@/lib/dive-mixtures";
import { GAS_ROLES, TANK_USAGE } from "@/lib/api/dives";
import { VolumeCombobox } from "@/components/dives/volume-combobox";
import { CylinderCombobox } from "@/components/dives/cylinder-combobox";
import type { FormControlA11yProps } from "@/components/ui/form";
import type { GearItemInfo } from "@/lib/api/gear";
import {
isVolumeFromVessel,
linkedVessel,
tankVolume,
} from "@/lib/tank-vessel";
import { MixtureTankIcon } from "@/components/dives/mixture-tank-icon";
import { DiveUnitNumberField } from "@/components/dives/dive-unit-number-field";
import { useEntryUnits } from "@/hooks/useEntryUnits";
import type { DiveFormFieldKey } from "@/lib/dive-form-fields";
export { DEFAULT_MIXTURE };
// The minimal field shape `MixtureFields` needs: any form values type that
// has a `mixtures` array (both `DiveCreateInput` and `DiveUpdateInput` from
// `lib/validations/dive.ts` qualify). Keeping this generic - rather than
// falling back to `Control<any, any, any>` - preserves type safety between
// the create/update form shapes at the `control` prop boundary.
//
// `max_depth` is here for the same reason `mixtures` is: `MixtureGasHint` reads it
// to place a mix's END/EAD and to decide whether its MOD has been exceeded. Optional
// because the hint degrades to just the gas name and MOD without it, which is what a
// dive whose depth hasn't been filled in yet should show.
export interface MixtureFieldsValues extends FieldValues {
mixtures?: DiveMixtureInput[];
max_depth?: number | null;
}
// The `useFieldArray` return type for `mixtures`, keyed to `MixtureFieldsValues`
// rather than any particular concrete form type. Exported so a single instance
// can be created once at a common ancestor (the create/edit dive pages) and
// passed down to both `MixtureFields` and `DiveFileImport` - `useFieldArray`
// doesn't reliably keep multiple separate instances watching the same
// `control`/`name` in sync with each other (e.g. `replace()` called on one
// instance doesn't shrink another instance's `fields` when the new array is
// shorter - see DECISIONS.md), so there must only ever be one.
export type MixtureFieldArray = UseFieldArrayReturn<
MixtureFieldsValues,
"mixtures"
>;
// Creates the single `mixtures` field array instance a page needs, already
// cast to `MixtureFieldArray` - pass `form.control` in directly. Call this
// once per form, at the same level as the `useForm()` call, and pass the
// result down to both `DiveFormFields`/`MixtureFields` and `DiveFileImport`
// (e.g. via `DiveForm`) - see `MixtureFieldArray`'s doc comment above for
// why there must only ever be one instance per form.
export function useMixtureFieldArray<TFieldValues extends MixtureFieldsValues>(
control: Control<TFieldValues>,
): MixtureFieldArray {
// Narrowing `control` to `MixtureFieldsValues` is sound: it's exactly the
// shape `TFieldValues` is constrained to extend, and only affects this
// hook's internal typing, not what callers pass in.
return useFieldArray<MixtureFieldsValues, "mixtures">({
control: control as unknown as Control<MixtureFieldsValues>,
name: "mixtures",
});
}
// What one cylinder's gas works out to, live, under the boxes it was typed into:
// the gas's name, how deep it can be breathed, and - when the dive logs this as its
// only cylinder - how deep it *feels* (END) or decompresses like (EAD).
//
// Its own component rather than inline in the `fields.map()` below because it needs
// three `useWatch` subscriptions per row, and hooks cannot be called from a loop
// body. Watching only these fields - rather than reading `form.watch()` wholesale -
// also keeps a keystroke in the O₂ box from re-rendering every other tank's card.
//
// Renders nothing until there is something true to say. A half-typed O₂ field is
// `undefined`, and a hint that flickers "EAN3" into "EAN32" as the diver types would
// be worse than one that waits.
//
// `isOnlyMixture` gates everything depth-dependent. The name and the MOD are
// properties of the gas alone and always hold; END and EAD are statements about a
// depth this gas was breathed at, which is only known when there is one cylinder to
// breathe. Printing "EAD 22.6 m at 45.91 m" against a deco bottle staged for the
// ascent describes a breath nobody took - see `diveModWarning`.
function MixtureGasHint({
control,
index,
isOnlyMixture,
}: {
control: Control<MixtureFieldsValues>;
index: number;
isOnlyMixture: boolean;
}) {
const oxygen = useWatch({ control, name: `mixtures.${index}.oxygen` });
const helium = useWatch({ control, name: `mixtures.${index}.helium` });
const po2Limit = useWatch({ control, name: `mixtures.${index}.po2_limit` });
const maxDepth = useWatch({ control, name: "max_depth" });
// The hint's MOD/END/EAD are *depths*, so they follow the depth entry units
// rather than the account's: a diver typing depths in feet must not be warned
// about a MOD in metres mid-entry. Everything outside this form still renders
// in account units.
const units = useEntryUnits().entryUnits("depth");
// `depth` is null unless this is the only cylinder, which is what keeps END/EAD
// off a staged deco bottle - `gasHintParts` documents the rule.
const parts = gasHintParts({
// `""` is the cleared state on all three of these, not a fraction of zero or a
// limit of zero - normalized here so `gasHintParts` deals only in numbers and
// nulls, the way every other caller hands it values. On the two fractions that
// is what keeps a cleared O₂ box from naming the cylinder "EAN0" while the
// diver is still deciding what was in it.
oxygen: oxygen === "" ? null : oxygen,
helium: helium === "" ? null : helium,
depth: isOnlyMixture ? maxDepth : null,
ppO2: po2Limit === "" ? null : po2Limit,
// The depths in the hint are converted; `maxDepth` itself is metric form
// state and stays that way.
units,
});
if (parts.length === 0) return null;
return <p className="text-xs text-muted-foreground">{parts.join(" · ")}</p>;
}
// The cylinder drawn beside a tank's title, as the dive page draws it, redrawn as its
// volume, mix and usage are typed. `""` is the form's cleared state, unrecorded here
// as it is on the wire, so a blank box draws the single in air colours.
//
// Its own component for `MixtureGasHint`'s reason. Whether it is the left of a
// parallel pair depends on the rows above it, so it watches their usage and nothing
// else of theirs.
function MixtureTitleIcon({
control,
index,
}: {
control: Control<MixtureFieldsValues>;
index: number;
}) {
const volume = useWatch({ control, name: `mixtures.${index}.volume` });
const oxygen = useWatch({ control, name: `mixtures.${index}.oxygen` });
const helium = useWatch({ control, name: `mixtures.${index}.helium` });
const usages = useWatch({
control,
name: Array.from(
{ length: index + 1 },
(_, row) => `mixtures.${row}.usage` as const,
),
});
const left = usages.findIndex((usage) => usage === "parallel") === index;
return (
<MixtureTankIcon
volume={volume === "" ? null : volume}
oxygen={oxygen === "" ? null : oxygen}
helium={helium === "" ? null : helium}
left={left}
className="size-6 shrink-0 stroke-[1.5] text-foreground"
/>
);
}
// Which of the diver's cylinders a tank is. A pick copies the vessel's capacity into
// Volume, which then reads it off the vessel; unlinking leaves the figure the box was
// showing, editable again. A vessel another tank links is not offered.
//
// Its own component for `MixtureGasHint`'s reason, and it writes three fields of the
// row at once, which is why it reaches the form through its context.
function MixtureCylinderPicker({
control,
index,
count,
until,
...slotProps
}: {
control: Control<MixtureFieldsValues>;
index: number;
count: number;
until?: string;
} & FormControlA11yProps) {
const { getValues, setValue } = useFormContext<MixtureFieldsValues>();
const gearItemUuid = useWatch({
control,
name: `mixtures.${index}.gear_item_uuid`,
});
const gearItem = useWatch({ control, name: `mixtures.${index}.gear_item` });
const links = useWatch({
control,
name: Array.from(
{ length: count },
(_, other) => `mixtures.${other}.gear_item_uuid` as const,
),
});
const vessel = linkedVessel({
gear_item_uuid: gearItemUuid,
gear_item: gearItem,
});
const linkedElsewhere = links.filter(
(uuid, other): uuid is string => other !== index && !!uuid,
);
const write = { shouldDirty: true, shouldValidate: true } as const;
const handleChange = (picked: GearItemInfo | null) => {
if (picked) {
setValue(`mixtures.${index}.gear_item_uuid`, picked.uuid, write);
setValue(`mixtures.${index}.gear_item`, picked, write);
if (picked.volume != null) {
setValue(`mixtures.${index}.volume`, picked.volume, write);
}
return;
}
// The figure the read-only box was showing stays, now the tank's own.
const shown = tankVolume(getValues(`mixtures.${index}`) ?? {});
setValue(`mixtures.${index}.volume`, shown, write);
setValue(`mixtures.${index}.gear_item_uuid`, "", write);
setValue(`mixtures.${index}.gear_item`, null, write);
};
return (
<CylinderCombobox
{...slotProps}
value={vessel}
onChange={handleChange}
excludeIds={linkedElsewhere}
until={until}
/>
);
}
// The Volume box, read-only while the tank's link stands to a vessel recording its
// capacity, and then showing the figure the save sends - `tankVolume` says which.
function MixtureVolumeInput({
control,
index,
value,
onChange,
until,
...slotProps
}: {
control: Control<MixtureFieldsValues>;
index: number;
value: number | "" | undefined;
onChange: (value: number | "") => void;
until?: string;
} & FormControlA11yProps) {
const gearItemUuid = useWatch({
control,
name: `mixtures.${index}.gear_item_uuid`,
});
const gearItem = useWatch({ control, name: `mixtures.${index}.gear_item` });
const link = {
volume: value,
gear_item_uuid: gearItemUuid,
gear_item: gearItem,
};
const fromVessel = isVolumeFromVessel(link);
return (
<VolumeCombobox
{...slotProps}
value={fromVessel ? tankVolume(link) : value}
onChange={onChange}
readOnly={fromVessel}
until={until}
/>
);
}
// The ppO₂ ceilings a cylinder is actually planned to, offered in place of a free
// number box. The schema's band is 0.4-2.0 - wide because it exists to catch a unit
// error (a Suunto JSON export writes 140000 Pa for 1.4 bar), not because a diver picks
// from all of it - and every value in it that a diver would ever choose is one of
// these seven: 1.4 for a back gas, 1.6 for a deco bottle, and the conservative steps
// below them that a few agencies and most CCR plans use.
//
// Strings rather than numbers, which is the one thing here worth stating: an
// `<option>`'s value is a string either way, and going through `Number` in both
// directions puts float formatting between the stored value and the option that has to
// match it - `String(1.0)` is `"1"`, so a `1.0` option labelled `"1.0"` would never
// match its own value. Written as the tokens they are displayed as, the label, the
// value and the round trip are all the same characters.
//
// **The narrowing is a one-way door, and that is accepted.** `ppO2LimitChoices` below
// keeps an unlisted recorded value selectable, but only while it is still the field's
// value: change an imported 1.45 to 1.4 and there is no way back to it, and nothing
// here can reach the 0.4-0.9 band the API's `ck_dive_mixture_po2_limit_range` allows.
// The floor is 1.0 on purpose rather than by inheritance - below that a figure is a
// CCR *setpoint* rather than a limit a MOD is worked out at, and this field feeds a
// MOD. A diver who genuinely needs a number this list doesn't offer has lost
// something; a diver who fat-fingers 14 into a free text box gets an unreadable 422 on
// save, and there are many more of the second.
const PPO2_LIMIT_OPTIONS = ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6"];
// The options to offer for a cylinder currently holding `value`.
//
// Normally just the list above. A limit that came from a file and isn't on it -
// anything in the schema's band is possible on the wire - is inserted in its place,
// because a `<select>` whose value matches no option renders blank while the form goes
// on holding the value: the box would say "not recorded" over a cylinder that records
// 1.45, and the diver's only way to find out would be to save and watch the MOD not
// move.
function ppO2LimitChoices(value: number | "" | undefined): string[] {
if (typeof value !== "number" || !Number.isFinite(value)) {
return PPO2_LIMIT_OPTIONS;
}
if (PPO2_LIMIT_OPTIONS.some((option) => Number(option) === value)) {
return PPO2_LIMIT_OPTIONS;
}
return [...PPO2_LIMIT_OPTIONS, String(value)].sort(
(first, second) => Number(first) - Number(second),
);
}
// The usage options as the form spells them, which is not how the dive page does.
// `TANK_USAGE_LABELS` is one word each because the tank card quotes back a choice
// already made; here the diver is making it, and "Parallel" alone does not say what it
// claims about the dive. The parenthetical is the definition the diver is being asked
// to agree to - the flag changes what the API computes, so choosing it by guessing at
// the word is the one outcome worth spending width to prevent.
const TANK_USAGE_OPTION_LABELS: Record<(typeof TANK_USAGE)[number], string> = {
parallel: "Parallel (sidemount / independent)",
staged: "Staged (own depth)",
};
// How long the gas warning has to hold still before it is announced. Long enough to
// cover typing a two-digit depth without a pause being mistaken for a finished edit.
const ANNOUNCE_SETTLE_MS = 700;
// The one oxygen-exposure warning the form can honestly make, under the whole set of
// cylinders rather than under any one of them. `diveModWarning` carries the reasoning
// for why a multi-cylinder dive usually gets a claim about the dive and not about a
// tank - and for the one set that doesn't, a parallel pair holding a single gas, where
// the sentence is about that gas because there is only one and it was breathed
// throughout. Both readings arrive here as one string either way.
//
// Unlike `MixtureGasHint` above, this deliberately watches the whole `mixtures`
// array: its answer depends on every cylinder, so there is no narrower subscription
// that would still be correct. It re-renders one `<p>` per keystroke, which is why
// the two are separate components - keeping this subscription out of the per-tank
// hint is what stops that breadth reaching the field cards.
function MixtureSetWarning({
control,
}: {
control: Control<MixtureFieldsValues>;
}) {
const mixtures = useWatch({ control, name: "mixtures" });
const maxDepth = useWatch({ control, name: "max_depth" });
// A depth again, for the same reason as `MixtureGasHint` above.
const units = useEntryUnits().entryUnits("depth");
const warning = diveModWarning(mixtures ?? [], maxDepth, units);
// Announced from a region that is always mounted and `sr-only` when there is
// nothing to say. A `role="status"` that mounts together with its text is
// typically not announced at all - screen readers register the region on
// insertion and read *subsequent* changes - which is the same trap
// `dive-file-import.tsx` documents.
//
// Settled rather than live, which that file did not have to handle: its note is
// computed once at import, while this sentence quotes the depth and so changes
// on every keystroke in that box. Bound directly, a polite region would queue an
// announcement per digit; the timer lets the diver finish typing "45" first.
const [announced, setAnnounced] = useState("");
useEffect(() => {
const id = setTimeout(
() => setAnnounced(warning ?? ""),
ANNOUNCE_SETTLE_MS,
);
return () => clearTimeout(id);
}, [warning]);
return (
<>
{/* `aria-hidden` so the sentence is not also read here, in the copy that
tracks every keystroke. */}
{warning && (
<p
className="mt-4 flex items-start gap-1.5 text-xs text-warning"
aria-hidden
>
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-px" />
<span>{warning}</span>
</p>
)}
<p role="status" className="sr-only">
{announced}
</p>
</>
);
}
export interface MixtureFieldsProps<TFieldValues extends MixtureFieldsValues> {
control: Control<TFieldValues>;
fieldArray: MixtureFieldArray;
// Which per-cylinder inputs this form renders. A hidden one is left out of every
// tank card, and its stored value is untouched by that. Defaults to "all of them"
// so a caller with no Fields dialog behind it - the render tests - gets the whole
// card.
isVisible?: (key: DiveFormFieldKey) => boolean;
// The dive's start time, which bounds each volume dropdown's recent volumes.
until?: string;
}
export function MixtureFields<TFieldValues extends MixtureFieldsValues>({
control,
fieldArray,
isVisible = () => true,
until,
}: MixtureFieldsProps<TFieldValues>) {
const { fields, append, remove } = fieldArray;
// Narrowing to `MixtureFieldsValues` is sound for the reason it is in
// `useMixtureFieldArray`: it is exactly the shape `TFieldValues` is constrained
// to extend.
const mixturesControl = control as unknown as Control<MixtureFieldsValues>;
return (
<div className="space-y-4">
{fields.map((field, index) => (
// Removable on every row, tank 1 included: "this dive records no gas" is
// a state the API supports outright (`DiveCreate.mixtures` is
// `default_factory=list`) and `dive-mixtures-card.tsx` describes as "the
// common case for a dive logged by hand".
<RepeatableRow
key={field.id}
title={
<span className="flex items-center gap-2">
<MixtureTitleIcon control={mixturesControl} index={index} />
Tank {index + 1}
</span>
}
removeLabel={`Remove tank ${index + 1}`}
onRemove={() => remove(index)}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{isVisible("mixture.gear_item_uuid") && (
<FormField
control={control}
name={`mixtures.${index}.gear_item_uuid` as Path<TFieldValues>}
render={() => (
// A row of its own above the boxes, which is what keeps the
// pairs below intact: it is a fact about the whole tank, and in
// a pair it would push every box after it a column along.
<FormItem className="md:col-span-2">
<FormLabel>Cylinder</FormLabel>
<FormControl
render={
<MixtureCylinderPicker
control={mixturesControl}
index={index}
count={fields.length}
until={until}
/>
}
/>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={control}
name={`mixtures.${index}.volume` as Path<TFieldValues>}
render={({ field }) => (
// Half width, paired with the ppO₂ limit beside it, not
// `md:col-span-2`: eight boxes divide into four rows either way,
// and a full-width Volume leaves Usage alone on the last row.
//
// Volume | ppO₂, O₂ | He, start | end, Role | Usage: four full
// rows under the Cylinder row, and each pair is two facts about
// the same thing - what the cylinder holds and what it was
// planned to, the mix, the gauge readings, what it was for and
// how it was carried.
<FormItem>
<FormLabel>Volume (L)</FormLabel>
<FormControl
render={
<MixtureVolumeInput
control={mixturesControl}
index={index}
value={field.value}
onChange={field.onChange}
until={until}
/>
}
/>
<FormMessage />
</FormItem>
)}
/>
{isVisible("mixture.po2_limit") && (
<FormField
control={control}
name={`mixtures.${index}.po2_limit` as Path<TFieldValues>}
render={({ field }) => {
const choices = ppO2LimitChoices(field.value);
return (
<FormItem>
<FormLabel>ppO₂ limit (bar)</FormLabel>
{/* `""` has to reach react-hook-form as the live cleared
value rather than `undefined`, which it re-displays the
default over.
A picker rather than the number box this started as
because the field has an actual vocabulary. Every value
it could usefully hold is one of seven, while the box
accepted any two decimals in a 0.4-2.0 band - so the
only things free entry bought were typos and a 422 on
save. */}
<FormControl
render={
<NativeSelect
{...field}
// From the offered list rather than from the raw value,
// so the two can't disagree about formatting: `1.0` on
// the form has to find the `"1.0"` option, and
// `String(1.0)` is `"1"`.
value={
choices.find(
(option) => Number(option) === field.value,
) ?? ""
}
onChange={(e) => {
const raw = e.target.value;
field.onChange(raw === "" ? "" : parseFloat(raw));
}}
/>
}
>
{/* The fallback is named rather than pre-selected, so a
cylinder with no recorded limit still says what the
MOD beneath it was worked out from. Selecting 1.4
here would make it claim a limit the diver never
chose - see `DEFAULT_MIXTURE`. */}
<option value="">
Not recorded ({PPO2_WORKING} default)
</option>
{choices.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)}
<FormField
control={control}
name={`mixtures.${index}.oxygen` as Path<TFieldValues>}
render={({ field: { onChange, value, ...field } }) => (
<FormItem>
<FormLabel>O₂ (%)</FormLabel>
{/* `step="any"` (on a mouse's number input), not the API's own
two decimals: `oxygen` is a `Float` column, and while the
form's prefill rounds to two places, a logbook import keeps
whatever precision the file recorded. A `step` finer than
the data is a constraint that cancels the save - see
DECISIONS.md, "`step` is a claim about the column".
`min`/`max` stay: 0-100 is a fact about a percentage.
An emptied box is `""`, never `NaN` or `undefined` -
the same sentinel the two pressures below use, and for
the same reason: an unrecorded mix is a state a cylinder
can be in, so clearing this box has to submit "not
recorded" rather than draw a "required" message.
`undefined` is the one spelling that cannot work,
because react-hook-form re-displays a field's default the
moment its value resolves to it. */}
<FormControl
render={
<DecimalInput
min={0}
max={100}
{...field}
value={value}
onValueChange={onChange}
/>
}
/>
<FormMessage />
</FormItem>
)}
/>
{isVisible("mixture.helium") && (
<FormField
control={control}
name={`mixtures.${index}.helium` as Path<TFieldValues>}
render={({ field: { onChange, value, ...field } }) => (
<FormItem>
<FormLabel>He (%)</FormLabel>
{/* `step="any"` and `""` on an emptied box, for the reasons
O₂ above gives. Blank is not 0 % helium here any more
than it is on the wire: a file that recorded no analysis
said nothing about helium, and writing a 0 for it would
be the app inventing the one fact that separates nitrox
from trimix.
*Hiding* the column does write a 0, and that is not the
same act: emptying the box is the diver declining to
answer, while hiding it is them saying they dive air and
nitrox - which is the claim that made the column
hideable. See "A hidden helium is `0`, not blank" in
DECISIONS.md. */}
<FormControl
render={
<DecimalInput
min={0}
max={100}
{...field}
value={value}
onValueChange={onChange}
/>
}
/>
<FormMessage />
</FormItem>
)}
/>
)}
{isVisible("mixture.start_pressure") && (
// `emptyValue=""`, unlike every other number box in the dive form:
// these two pressures are the fields DECISIONS.md names as spelling
// cleared that way, and the submit path converts the sentinel at the
// edge.
<DiveUnitNumberField
control={control}
name={`mixtures.${index}.start_pressure` as Path<TFieldValues>}
label="Start pressure"
dimension="pressure"
min={0}
emptyValue=""
/>
)}
{isVisible("mixture.end_pressure") && (
<DiveUnitNumberField
control={control}
name={`mixtures.${index}.end_pressure` as Path<TFieldValues>}
label="End pressure"
dimension="pressure"
min={0}
emptyValue=""
/>
)}
{isVisible("mixture.usage") && (
<FormField
control={control}
name={`mixtures.${index}.usage` as Path<TFieldValues>}
render={({ field }) => (
<FormItem>
<FormLabel>Usage</FormLabel>
{/* Beside Role deliberately: the two are the cylinder's
answers to "what for" and "how", and a diver setting one
is usually about to consider the other. Per row rather
than once for the dive, so a mixed set - a parallel pair
plus a staged bottle - stays expressible, which is the
shape the API refuses by design and can only refuse if
the form can say it. */}
<FormControl
render={
<NativeSelect
{...field}
value={field.value ?? ""}
// `""` straight through, same sentinel and same
// react-hook-form trap as Role below.
onChange={(e) => field.onChange(e.target.value)}
/>
}
>
<option value="">Not recorded</option>
{TANK_USAGE.map((usage) => (
<option key={usage} value={usage}>
{TANK_USAGE_OPTION_LABELS[usage]}
</option>
))}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{isVisible("mixture.role") && (
<FormField
control={control}
name={`mixtures.${index}.role` as Path<TFieldValues>}
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<FormControl
render={
<NativeSelect
{...field}
value={field.value ?? ""}
// `""` straight through, not `|| undefined`: react-hook-form
// re-displays a field's default whenever its value resolves to
// `undefined`, so mapping the "Not recorded" option to it made
// choosing that option snap back to the imported role. Same
// sentinel and same reason as `po2_limit` above; converted at
// the edge by `normalizeMixtures`.
onChange={(e) => field.onChange(e.target.value)}
/>
}
>
<option value="">Not recorded</option>
{GAS_ROLES.map((role) => (
<option key={role} value={role}>
{GAS_ROLE_LABELS[role]}
</option>
))}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
<MixtureGasHint
control={mixturesControl}
index={index}
isOnlyMixture={fields.length === 1}
/>
</RepeatableRow>
))}
{/* Not an error, and worded so it doesn't read as one: a dive with no
cylinders is a complete record, and most hand-logged dives are exactly
that. The line exists so the card says something rather than showing a
heading over nothing. */}
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No tanks recorded for this dive.
</p>
)}
{/* One block with the warning under it, which spaces itself: the always-mounted
`sr-only` status after the button would otherwise be the container's last
child and leave the button its `space-y-4` margin over the section's end. */}
<div>
{/* Under the tank cards rather than in the section header, so it sits where
the next tank will appear: the button and the card it adds are then in
reading order, and on a multi-cylinder dive the diver is already
scrolled to it after filling in the last one. */}
<AddRowButton
// `shouldFocus: false`, which is not react-hook-form's default. Its
// default focuses the first field of the new row that registered a
// focusable ref - and `VolumeCombobox` registers none, so the focus
// skipped past Volume and landed on whichever box came next: the ppO₂
// limit `<select>` where that column is on screen. On iOS, focusing a
// `<select>` opens its picker wheel, so one tap on this button added a
// tank *and* opened a dropdown the diver never asked for - which reads
// as the tap having gone through to the field underneath.
onClick={() => append({ ...DEFAULT_MIXTURE }, { shouldFocus: false })}
>
Add tank
</AddRowButton>
<MixtureSetWarning control={mixturesControl} />
</div>
</div>
);
}