Ctrl K
ComunidadGuías…

src/components/dives/volume-combobox.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"use client";

import { useEffect, useId, useMemo, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { nextActiveIndex } from "@/components/ui/creatable-combobox";
import {
  PickerSheet,
  pickerMenuClassName,
  usePickerSheet,
} from "@/components/ui/picker-sheet";
import { parseDecimal } from "@/lib/decimal-entry";
import { cn } from "@/lib/utils";
import type { FormControlA11yProps } from "@/components/ui/form";
import { useUnits } from "@/hooks/useUnits";
import type { UnitSystem } from "@/lib/units";
import { divesAPI } from "@/lib/api/dives";
import {
  VOLUME_GROUPS,
  type VolumeOption,
  type VolumeOptionGroup,
} from "@/lib/cylinder-presets";

/**
 * The groups in the order a diver reading in `units` sees them.
 *
 * The reader's own singles lead, the other family's follow, and the groups
 * belonging to neither sit at the bottom; nothing is dropped. That is the whole
 * of the difference, and it is deliberate that membership is not part of it. A
 * preset list filtered by unit system reads sensible and fails the one diver it
 * exists for: hand a metric diver an AL80 on a boat in Florida and the very
 * preset that tells them it holds 11.1 L is the one that has been hidden, and an
 * imperial diver handed a 12 L in Croatia is stuck the same way. The unfamiliar
 * half is the half worth showing - see DECISIONS.md.
 *
 * Grouping is what keeps the full list browsable, which is the job the split was
 * wrongly doing.
 */
function volumeGroupsFor(units: UnitSystem): VolumeOptionGroup[] {
  const families = VOLUME_GROUPS.filter((group) => group.system !== undefined);
  return [
    ...families.filter((group) => group.system === units),
    ...families.filter((group) => group.system !== units),
    ...VOLUME_GROUPS.filter((group) => group.system === undefined),
  ];
}

/**
 * How a preset is written for a diver reading in `units`.
 *
 * Imperial leads with the cu-ft name where there is one - "AL80 (11.1 L)" rather
 * than "11.1 L (AL80)". That relabel, and the order `volumeGroupsFor` puts the
 * groups in, are the whole of what the unit system does to this field - every
 * preset is offered either way. The stored value stays litres in
 * both systems, because a cylinder's litres are its water capacity while its cubic
 * feet are the gas it holds at a rated pressure the mixture doesn't record:
 * converting one to the other needs a column that doesn't exist, and inventing a
 * factor would be fake maths. That is also why the name has to carry the material -
 * an AL80 and an HP80 are the same "80 cubic feet" at 11.1 L and 10.2 L, so the
 * material is what pins a cu-ft name to a litre figure.
 * A preset with no cu-ft identity is the same string in both systems.
 */
function volumeOptionLabel(option: VolumeOption, units: UnitSystem): string {
  if (units === "metric" || !option.imperialName) return option.label;
  return `${option.imperialName} (${option.value} L)`;
}

export interface VolumeComboboxProps extends FormControlA11yProps {
  /** Litres, or `""` for a cylinder whose size was never recorded. */
  value?: number | "";
  /**
   * Emits `""` when the box is cleared, never `undefined`.
   *
   * `""` is what this form spells cleared as, for the reason `diveMixtureSchema`
   * gives: react-hook-form re-displays a field's default the moment its value
   * resolves to `undefined`, so an emptied box would fill itself back in. The two
   * mixture pressures beside this one already work this way; this field joined them
   * when a cylinder became able to record a mix with no vessel.
   *
   * Unlike `UnitNumberInput`, the sentinel is fixed rather than a prop: the mixture
   * volume is the only field this combobox serves, and a `null`-clearing caller
   * would be a caller that does not exist.
   */
  onChange: (value: number | "") => void;
  /**
   * Called with the row a diver picks, after `onChange` has its litres - never for
   * a typed number, which carries no name. Lets a gear item take the vessel facts
   * a named preset carries; a dive's cylinder records only the volume.
   */
  onPick?: (option: VolumeOption) => void;
  /** The dive's start time; bounds the "Recently used" group like a lookup's `until`. */
  until?: string;
  disabled?: boolean;
  /**
   * Shows `value` and takes no input: no typing, and no menu. A tank linked to a
   * vessel that records its capacity reads the figure off the vessel.
   */
  readOnly?: boolean;
  placeholder?: string;
}

// A combobox for picking a cylinder volume: click one of the common-size presets
// (every one of them, under group headings, never filtered - not by what's typed,
// and not by the diver's unit system either; `volumeGroupsFor` says why), or
// type/commit an arbitrary number (e.g. an odd steel tank, or a value filled in
// from a parsed dive-computer file - see `dive-file-import.tsx`) that isn't one of
// them. The input itself only ever shows the plain number, never a preset's label
// (e.g. "11.1", not "11.1 L (AL80)") - the label is just a hint shown in the
// dropdown to help pick the right preset.
//
// Deliberately a plain input + manually-rendered dropdown (mirroring
// `CreatableCombobox`'s approach) rather than a picker: a parsed volume usually
// matches no preset, and a plain controlled input always displays exactly what
// `value` holds.
export function VolumeCombobox({
  value,
  onChange,
  onPick,
  until,
  disabled,
  readOnly = false,
  placeholder = "Select or enter volume...",
  ...slotProps
}: VolumeComboboxProps) {
  const [isOpen, setIsOpen] = useState(false);
  const units = useUnits();
  // The diver's own recent volumes, fetched on each open so a dive saved in
  // another tab shows up. The last answer stays on screen while the next loads,
  // and a failed fetch keeps whatever it had - the presets work regardless.
  const [recentVolumes, setRecentVolumes] = useState<number[]>([]);
  const recentRef = useRef<number[]>([]);
  // Groups for rendering, and the flat list the keyboard walks. Headings are not
  // in `options` at all, which is what makes the arrow keys skip them - there is
  // no index for a heading to occupy, so `nextActiveIndex` cannot land on one.
  const groups = useMemo(() => {
    let next = 0;
    // Ahead of every preset group, nearest the box, and in addition to them: a
    // recent 11.1 is still listed under "US aluminium" as the AL80. Plain litres,
    // since a stored volume cannot say which of two same-sized presets it was.
    const recent: VolumeOptionGroup[] = recentVolumes.length
      ? [
          {
            label: "Recently used",
            options: recentVolumes.map((value) => ({
              value,
              label: `${value} L`,
            })),
          },
        ]
      : [];
    return [...recent, ...volumeGroupsFor(units)].map((group) => ({
      label: group.label,
      rows: group.options.map((option) => ({ option, index: next++ })),
    }));
  }, [units, recentVolumes]);
  const options = useMemo(
    () => groups.flatMap((group) => group.rows.map((row) => row.option)),
    [groups],
  );
  // Index of the keyboard-highlighted preset, or -1 for none - shares
  // `nextActiveIndex` with `CreatableCombobox` so both dropdowns in the dive
  // form move the same way.
  const [activeIndex, setActiveIndex] = useState(-1);
  useEffect(() => {
    if (!isOpen) return;
    let cancelled = false;
    divesAPI
      .getRecentVolumes(until)
      .then((volumes) => {
        if (cancelled || volumes.join() === recentRef.current.join()) return;
        recentRef.current = volumes;
        setRecentVolumes(volumes);
        // The rows below shift by however much the group grew or shrank, so a
        // highlight would land on a different preset than the one chosen.
        setActiveIndex(-1);
      })
      .catch((error) =>
        console.error("Failed to fetch recent volumes:", error),
      );
    return () => {
      cancelled = true;
    };
  }, [isOpen, until]);
  // What is being typed, while it is being typed; `null` whenever the field is
  // showing `value` itself.
  //
  // This exists because the input is `type="text"` (see the `role` below for
  // why) and the committed value is a `number`, so without it the round trip
  // through `parseDecimal` eats the keystroke that is mid-decimal: typing the "."
  // of "11.1" parses to `11`, which renders as "11", which deletes the "." the
  // diver just pressed and makes a decimal volume unenterable. `type="number"`
  // hid that - a browser reports `value === ""` for a half-typed "11." while
  // still *displaying* it - and it is the one behaviour of that type this field
  // was relying on.
  const [draft, setDraft] = useState<string | null>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const optionRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const listId = useId();
  const optionId = (index: number) => `${listId}-option-${index}`;
  const sheet = usePickerSheet(inputRef, isOpen);

  useEffect(() => {
    if (activeIndex < 0) return;
    optionRefs.current[activeIndex]?.scrollIntoView({ block: "nearest" });
  }, [activeIndex]);

  const closeMenu = () => {
    setIsOpen(false);
    setActiveIndex(-1);
  };

  const handleSelect = (option: VolumeOption) => {
    onChange(option.value);
    onPick?.(option);
    // Dropping the draft is what lets the picked preset reach the input at all:
    // it would otherwise keep rendering whatever half-typed query opened the
    // menu. Same on blur below, which is where "11." settles back to "11".
    setDraft(null);
    closeMenu();
    // Out of the sheet without the keyboard, as `CreatableCombobox` leaves it.
    if (sheet) inputRef.current?.blur();
  };

  return (
    <PickerSheet
      sheet={sheet}
      onDone={() => inputRef.current?.blur()}
      field={
        <Input
          {...slotProps}
          ref={inputRef}
          // `text` + `inputMode`, not `type="number"`. A number input's implicit
          // role is `spinbutton`, and ARIA does not allow `role="combobox"` on it -
          // axe reports `aria-allowed-role`, twice per dive form. Only text,
          // search, tel, url and email may carry the role, which is why
          // `CreatableCombobox` beside this one was already conforming.
          //
          // `inputMode="decimal"` keeps the numeric keypad on a phone, which is the
          // half of `type="number"` that was worth having here. The other half -
          // `step` and `min` - is gone with it and not missed: the arrow keys were
          // already taken over for the menu (see `onKeyDown`), and the real
          // constraint is `z.number().positive()` in the dive schema, not the
          // browser's.
          type="text"
          inputMode="decimal"
          placeholder={placeholder}
          value={draft ?? (typeof value === "number" ? String(value) : "")}
          disabled={disabled}
          readOnly={readOnly}
          onChange={(e) => {
            if (readOnly) return;
            const raw = e.target.value;
            setDraft(raw);
            const parsed = parseDecimal(raw);
            onChange(Number.isNaN(parsed) ? "" : parsed);
          }}
          role="combobox"
          aria-expanded={isOpen}
          aria-controls={listId}
          aria-activedescendant={
            isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined
          }
          onFocus={() => setIsOpen(!readOnly)}
          onClick={() => setIsOpen(!readOnly)}
          onBlur={() => {
            setDraft(null);
            closeMenu();
          }}
          onKeyDown={(e) => {
            if (e.key === "ArrowDown" || e.key === "ArrowUp") {
              // These navigate the preset list, so the caret doesn't get them:
              // `preventDefault` stops the jump to the start or end of the text.
              // Browsing the presets beats nudging a volume by a hundredth of
              // a litre, so the keys never step the value either.
              e.preventDefault();
              if (readOnly) return;
              if (!isOpen) {
                setIsOpen(true);
                return;
              }
              setActiveIndex((current) =>
                nextActiveIndex(
                  current,
                  e.key === "ArrowDown" ? 1 : -1,
                  options.length,
                ),
              );
              return;
            }

            if (e.key === "Enter") {
              e.preventDefault();
              if (isOpen && activeIndex >= 0) {
                handleSelect(options[activeIndex]);
                return;
              }
              inputRef.current?.blur();
              return;
            }

            if (e.key === "Escape") {
              closeMenu();
              inputRef.current?.blur();
            }
          }}
        />
      }
      menu={
        isOpen && (
          <div
            id={listId}
            role="listbox"
            className={pickerMenuClassName(sheet)}
          >
            {groups.map((group) => (
              // `role="group"` named by `aria-label` rather than by pointing at the
              // heading element: a listbox may own `group`s, and a group may own
              // `option`s, but a bare heading node between them is neither. Hiding
              // it keeps the accessible tree to listbox > group > option while the
              // heading stays on screen, where the whole point of it is.
              <div key={group.label} role="group" aria-label={group.label}>
                <div
                  aria-hidden="true"
                  className="px-3 pt-2 pb-1 text-xs font-medium text-muted-foreground"
                >
                  {group.label}
                </div>
                {group.rows.map(({ option, index }) => (
                  <button
                    // Keyed by label, not value: the HP117 and the LP95 are both
                    // 15 L, and two rows keyed alike is a React warning and a
                    // re-order bug. Labels are unique across the table; values are
                    // not.
                    key={option.label}
                    type="button"
                    id={optionId(index)}
                    role="option"
                    aria-selected={index === activeIndex}
                    ref={(el) => {
                      optionRefs.current[index] = el;
                    }}
                    className={cn(
                      "w-full text-left px-3 py-2 touch:py-3 text-sm hover:bg-accent hover:text-accent-foreground",
                      // Tints every row holding the committed value, so 15 L lights
                      // up both the HP117 and the LP95. Correct rather than sloppy:
                      // the mixture records litres and cannot tell those two apart.
                      option.value === value && "bg-accent/50",
                      index === activeIndex &&
                        "bg-accent text-accent-foreground",
                    )}
                    // Prevent the input's onBlur from firing before this click is registered.
                    onMouseDown={(e) => e.preventDefault()}
                    onMouseEnter={() => setActiveIndex(index)}
                    onClick={() => handleSelect(option)}
                  >
                    {volumeOptionLabel(option, units)}
                  </button>
                ))}
              </div>
            ))}
          </div>
        )
      }
    />
  );
}