Ctrl K
ComunidadGuías…

src/components/dives/dive-form-preset-save-as.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { ChevronDown } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  clampActiveIndex,
  menuQuery,
  nextActiveIndex,
} from "@/components/ui/creatable-combobox";
import { dialogFormSubmit } from "@/lib/dialog-form";
import {
  ALL_FIELDS_PRESET_NAME,
  isAllFieldsPresetName,
  type DiveFormPreset,
} from "@/lib/api/dive-form-presets";

/**
 * The API compares preset names case-insensitively - a create under a name already
 * taken is a 422 - so this has to match the same way, or "recreational" would take
 * the create branch and come back rejected.
 */
function findByName(presets: readonly DiveFormPreset[], name: string) {
  const wanted = name.trim().toLowerCase();
  if (!wanted) return undefined;
  return presets.find((preset) => preset.name.toLowerCase() === wanted);
}

interface DiveFormPresetSaveAsProps {
  presets: readonly DiveFormPreset[];
  /**
   * The name to start in the box - the preset the fields on screen already
   * match, or `""` for none.
   *
   * Read **at mount only**. The dialog recomputes it as switches are flipped and
   * relies on this component being mounted when it opens, so that the seed is
   * the set as it was opened on rather than as it stands mid-edit.
   */
  initialName: string;
  disabled: boolean;
  /**
   * The `id` of the form, for the Save button the dialog renders in its footer -
   * outside this component, so it submits through the `form` attribute.
   */
  formId: string;
  /** Whether Save would do anything right now, for that footer button's `disabled`. */
  onSaveableChange: (saveable: boolean) => void;
  /** Save. The name is trimmed and matched already; `existing` is what it named. */
  onSave: (name: string, existing: DiveFormPreset | undefined) => void;
}

/**
 * "Save as" at the foot of the Fields tab: a name with the account's presets on a
 * dropdown, submitted by the dialog's footer Save button, which either writes a new
 * preset or replaces an existing one's fields.
 *
 * **One control, not two.** A "Save current fields as a preset" button and an
 * "Update with current fields" button per row are the same act under two names, and
 * the second scales with the list. Typing a name that matches nothing creates;
 * typing or picking one that matches replaces - which is why the line under the field
 * says which of the two the button is about to do, rather than leaving an overwrite to
 * be discovered.
 *
 * **It opens on the preset the fields already match**, where they match one, so the
 * ordinary "I changed two switches and want them saved back" ends in one tap on Save
 * rather than in retyping a name that is already on the trigger three feet away. The
 * line under the field then reads "Replaces…", which is the same sentence it would
 * have shown had the name been typed, and Save is the same button doing the same
 * thing - nothing here is special-cased for having been seeded. A set matching no
 * preset opens empty.
 *
 * **A seeded name is not a filter.** The dropdown opens on the whole list while the
 * text is one this component wrote - the seed, or a picked row - and narrows only
 * once the diver types. It shares `menuQuery` with `CreatableCombobox` so there is
 * one rule for both; the "Replaces…/Creates…" line below the field is keyed on the
 * text itself, since that is a statement about what Save will do rather than about
 * the menu.
 *
 * **Written out rather than reaching for `CreatableCombobox`.** That component commits
 * on blur - unmatched text with no `onCreate` resolves to "clear", and the effect that
 * syncs text from the selected id then empties the field - so typing a new name and
 * *clicking* anything would wipe it before the click landed. The field here holds what
 * was typed until the button is pressed, which is the whole contract.
 */
export function DiveFormPresetSaveAs({
  presets,
  initialName,
  disabled,
  formId,
  onSaveableChange,
  onSave,
}: DiveFormPresetSaveAsProps) {
  // Seeded once and then this component's own, which is the whole contract with
  // `initialName` - see the comment on the prop.
  const [name, setName] = useState(initialName);
  // Whether `name` is the diver's own typing rather than the seed or a picked
  // row - see `menuQuery`, and the note about it above.
  const [typed, setTyped] = useState(false);
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const inputRef = useRef<HTMLInputElement>(null);
  const inputId = useId();
  const listId = useId();
  const optionId = (index: number) => `${listId}-option-${index}`;

  const trimmed = name.trim();
  const existing = findByName(presets, trimmed);
  const reserved = isAllFieldsPresetName(trimmed);
  const query = menuQuery({ text: name, typed }).toLowerCase();
  const matches = presets.filter((preset) =>
    preset.name.toLowerCase().includes(query),
  );
  const saveable = !disabled && trimmed.length > 0 && !reserved;
  // What `aria-expanded` reports, and so what the dialog's Escape handler reads: a
  // list with no rows is not on screen, and must not take an Escape meant for it.
  const listShown = isOpen && matches.length > 0;
  const highlighted = clampActiveIndex(activeIndex, matches.length);

  useEffect(() => {
    onSaveableChange(saveable);
  }, [onSaveableChange, saveable]);

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

  const pick = (preset: DiveFormPreset) => {
    setName(preset.name);
    setTyped(false);
    close();
    inputRef.current?.focus();
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    // Escape with the list open closes the list only: the dialog's own Escape
    // handler, which runs first, lets an expanded combobox keep it.
    if (event.key === "Escape" && listShown) {
      close();
      return;
    }
    if (event.key === "ArrowDown" || event.key === "ArrowUp") {
      event.preventDefault();
      if (!isOpen) {
        setIsOpen(true);
        return;
      }
      setActiveIndex((current) =>
        nextActiveIndex(
          clampActiveIndex(current, matches.length),
          event.key === "ArrowDown" ? 1 : -1,
          matches.length,
        ),
      );
      return;
    }
    // Enter takes the highlighted row when there is one, and otherwise falls
    // through to the form's own submit - which is the Save button.
    if (event.key === "Enter" && isOpen && highlighted >= 0) {
      event.preventDefault();
      pick(matches[highlighted]);
    }
  };

  return (
    // A `<form>` so Enter saves, and `dialogFormSubmit` so that submit stops here
    // rather than reaching the dive form's own `onSubmit` through the React tree -
    // see "A dialog's submit event bubbles into the form that opened it" in
    // DECISIONS.md.
    <form
      id={formId}
      className="space-y-1 border-t pt-4"
      onSubmit={dialogFormSubmit((event) => {
        event?.preventDefault();
        if (!saveable) return;
        onSave(trimmed, existing);
      })}
    >
      <Label htmlFor={inputId}>Save as</Label>
      <div className="relative">
        <Input
          id={inputId}
          ref={inputRef}
          role="combobox"
          aria-expanded={listShown}
          aria-controls={listId}
          // Without this the arrow keys move a highlight only a sighted diver can
          // follow: focus never leaves the input, so a screen reader is told which
          // row is active by this and by nothing else. Same contract as
          // `VolumeCombobox` and `CreatableCombobox` - see "Dropdowns are navigable
          // with Up/Down and Enter" in DECISIONS.md.
          aria-activedescendant={
            listShown && highlighted >= 0 ? optionId(highlighted) : undefined
          }
          aria-autocomplete="list"
          autoComplete="off"
          value={name}
          maxLength={255}
          placeholder="New preset name, or an existing one"
          disabled={disabled}
          onChange={(event) => {
            setName(event.target.value);
            setTyped(true);
            setIsOpen(true);
            setActiveIndex(-1);
          }}
          onFocus={() => setIsOpen(true)}
          // Closing on blur has to outlast the mousedown that picked a row, so
          // the rows commit on `mousedown` and this runs after them.
          onBlur={close}
          onKeyDown={handleKeyDown}
          className="pr-9"
        />
        <button
          type="button"
          tabIndex={-1}
          aria-hidden
          disabled={disabled}
          onMouseDown={(event) => {
            // Before the input's blur, so the toggle really toggles instead of
            // reopening what the blur has just closed.
            event.preventDefault();
            setIsOpen((open) => !open);
            inputRef.current?.focus();
          }}
          className="absolute inset-y-0 right-0 flex items-center px-2 text-muted-foreground"
        >
          <ChevronDown className="h-4 w-4" />
        </button>
        {listShown && (
          // Opens *upward*. This row is the last thing in a dialog that scrolls
          // its own content, so a list dropping below the input is clipped by the
          // dialog's `overflow-y-auto` rather than floating over it - measured at
          // 65px cut off with three presets. Nothing here can outgrow that: the
          // control only ever sits at the foot of the Fields tab.
          <div
            id={listId}
            role="listbox"
            className="absolute bottom-full z-50 mb-1 max-h-60 w-full overflow-auto overscroll-contain rounded-md border bg-popover p-1 shadow-md"
          >
            {/* Buttons directly inside the listbox, with no `<li>` between: a
                  `<ul>` would make the listbox own `listitem`s rather than the
                  `option`s it is declared to own. The other two dropdowns here are
                  built the same way. */}
            {matches.map((preset, index) => (
              <button
                key={preset.uuid}
                type="button"
                id={optionId(index)}
                role="option"
                aria-selected={index === highlighted}
                onMouseDown={(event) => {
                  event.preventDefault();
                  pick(preset);
                }}
                className={`w-full rounded-sm px-2 py-1.5 touch:py-3 text-left text-sm ${
                  index === highlighted ? "bg-accent" : ""
                }`}
              >
                {preset.name}
              </button>
            ))}
          </div>
        )}
      </div>
      <p className="text-xs text-muted-foreground">
        {trimmed.length === 0
          ? "Keep the fields above under a name you can come back to."
          : reserved
            ? `"${ALL_FIELDS_PRESET_NAME}" is built in and always shows every field.`
            : existing
              ? `Replaces the fields saved in "${existing.name}".`
              : `Creates a new preset called "${trimmed}".`}
      </p>
    </form>
  );
}