Ctrl K
ComunidadGuías…

src/components/dives/dive-form-field-switches.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
  DIVE_FORM_ALWAYS_ON_FIELDS,
  DIVE_FORM_FIELD_GROUPS,
  DIVE_FORM_FIELD_REGISTRY,
  isMixtureField,
  type DiveFormFieldEntry,
  type DiveFormFieldGroup,
  type DiveFormFieldKey,
} from "@/lib/dive-form-fields";
import type { DiveFormVisibility } from "@/hooks/useDiveFormVisibility";

/**
 * What every switch's label wears, in place of `Label`'s own `leading-none`.
 *
 * At 14px that line box is 14px and the text's ink is 17px, so anything with a
 * descender hangs ~1.5px below the element's own box. That is invisible until
 * the box is *painted on its own*, which is what a row whose switch is
 * `disabled` arranges: `Label` dims itself through `peer-disabled:opacity-70`,
 * and iOS rasterises an opacity layer to the element's box and throws away what
 * pokes out of it. The subscript in "O₂" came back with its bottom sliced off
 * flat, while the identical glyph one row down in "ppO₂ limit" - enabled, so
 * never composited - was untouched. "Volume" beside it is dimmed too and looked
 * fine, having nothing below the baseline to lose, which is what makes this
 * read as a font bug rather than a layout one.
 *
 * `leading-5` is 20px, which contains the ink and is exactly the switch's `h-5`,
 * so no row in this list changes height by gaining it.
 */
const SWITCH_LABEL = "font-normal leading-5";

/**
 * The hideable fields a group lists ahead of everything else, in order, where it has
 * any.
 *
 * `type` leads Dive info because the form renders it first, above the always-on rows.
 *
 * `mixtures` is the switch that decides whether the Tanks section
 * is on the form at all, and every other row in that group is downstream of it - the
 * always-on cylinder columns as much as the per-cylinder ones, which this list disables
 * outright while it is off. Listing it after them would put the reason
 * they are unavailable below the rows it explains. It is the form's own order too: the
 * section exists before any cylinder in it does. `mixture.gear_item_uuid` follows it,
 * ahead of the always-on Volume and O₂, because a tank's Cylinder row sits above them.
 *
 * The registry stays in form order for the guard's sake; presentation order is this
 * file's business, which is why the exception lives here and not beside it.
 */
const LEADING_GROUP_FIELDS: Partial<
  Record<DiveFormFieldGroup, readonly DiveFormFieldKey[]>
> = {
  "Dive info": ["type"],
  Tanks: ["mixtures", "mixture.gear_item_uuid"],
};

/** What the switches read and write - the dialog hands them a draft of it. */
export type DiveFormFieldsView = Pick<
  DiveFormVisibility,
  "hidden" | "isHidden" | "isRevealed" | "isVisible" | "setHidden"
>;

/**
 * Every input the dive form can render, grouped as the form groups them, one switch
 * each.
 *
 * **A switch shows the _effective_ state and edits a draft of the _stored_ one.**
 * Turning one on marks the key visible; turning it off marks it hidden *and* drops it
 * from the revealed set, so a field an edit load put on screen can still be put away
 * from here.
 * A key visible only because it was revealed says so beside its label.
 *
 * **The always-shown rows read like every other row** - same label colour, no note -
 * because the switch being on and unavailable is the whole of what marks them, and a
 * diver looking for Dive time should find it where they would look rather than in a gap.
 */
export function DiveFormFieldSwitches({
  visibility,
}: {
  visibility: DiveFormFieldsView;
}) {
  const toggleField = (key: DiveFormFieldKey, shouldShow: boolean) => {
    const next = new Set(visibility.hidden);
    if (shouldShow) next.delete(key);
    else next.add(key);
    visibility.setHidden([...next], shouldShow ? [] : [key]);
  };

  const gasOnScreen = visibility.isVisible("mixtures");

  // The always-on rows carry no key, so their switches are identified by position in
  // the one list they come from - stable for as long as the list is, which is what an
  // id has to be.
  const alwaysOnRows = DIVE_FORM_ALWAYS_ON_FIELDS.map((entry, index) => ({
    entry,
    id: `dive-form-field-always-${index}`,
  }));

  const rowsFor = (group: DiveFormFieldGroup) => {
    const hideable = DIVE_FORM_FIELD_REGISTRY.filter(
      (entry) => entry.group === group,
    );
    const leadingKeys = LEADING_GROUP_FIELDS[group] ?? [];
    return {
      leading: leadingKeys.flatMap((key) =>
        hideable.filter((entry) => entry.key === key),
      ),
      fields: hideable.filter((entry) => !leadingKeys.includes(entry.key)),
      alwaysOn: alwaysOnRows.filter((row) => row.entry.group === group),
    };
  };

  const fieldRow = (entry: DiveFormFieldEntry) => {
    const fieldId = `dive-form-field-${entry.key}`;
    const perCylinder = isMixtureField(entry.key);
    const revealed =
      visibility.isHidden(entry.key) && visibility.isRevealed(entry.key);
    return (
      <div key={entry.key}>
        <div className="flex items-center gap-2 touch:min-h-11">
          <Switch
            id={fieldId}
            checked={visibility.isVisible(entry.key)}
            // Per-cylinder switches keep their state while the section they
            // belong to is off screen, but there is nothing on screen for them
            // to govern, so they are not offered.
            disabled={perCylinder && !gasOnScreen}
            onCheckedChange={(next) => toggleField(entry.key, next)}
          />
          <Label htmlFor={fieldId} className={SWITCH_LABEL}>
            {entry.label}
          </Label>
        </div>
        {revealed && (
          // Lifted into the row's lower band on touch, so it sits under its own
          // label rather than halfway to the next one. The switch's hit area
          // stays on top of it: a positioned pseudo-element paints over this.
          <p className="pl-11 text-xs text-muted-foreground touch:-mt-3">
            shown because it holds a value
          </p>
        )}
      </div>
    );
  };

  return (
    // Two columns from `md` up, and it is the *sections* that are dealt into them:
    // a section's own switches stay stacked, so a group still reads as one list top
    // to bottom rather than as a pair of half-lists to scan across.
    //
    // CSS multi-column rather than a grid, because the sections are wildly different
    // heights - one row under "Marine life", nine under "Tanks" - and a grid would
    // make every row as tall as its tallest cell and leave the short sections sitting
    // in holes. Multi-column packs by height instead. `break-inside-avoid` is what
    // stops a section being split down the middle of itself, which is the one thing
    // this layout must never do; the margin is per-section rather than `space-y-*` on
    // the parent, since a top margin at the head of a column would misalign it.
    <div className="md:columns-2 md:gap-8">
      {DIVE_FORM_FIELD_GROUPS.map((group) => {
        const { leading, fields, alwaysOn } = rowsFor(group);
        if (leading.length + fields.length + alwaysOn.length === 0) return null;

        return (
          <fieldset
            key={group}
            // On touch a row is the switch's 44px hit area and rows abut, so no
            // switch's area reaches into its neighbour's.
            className="mb-6 space-y-3 break-inside-avoid last:mb-0 touch:space-y-0"
          >
            <legend className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
              {group}
            </legend>
            {leading.map(fieldRow)}
            {alwaysOn.map(({ entry, id: rowId }) => (
              <div
                key={rowId}
                className="flex items-center gap-2 touch:min-h-11"
              >
                <Switch id={rowId} checked disabled />
                <Label htmlFor={rowId} className={SWITCH_LABEL}>
                  {entry.label}
                </Label>
              </div>
            ))}
            {fields.map(fieldRow)}
          </fieldset>
        );
      })}
    </div>
  );
}