"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(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) => { // 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 `
` 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. { event?.preventDefault(); if (!saveable) return; onSave(trimmed, existing); })} >
= 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" /> {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.
{/* Buttons directly inside the listbox, with no `
  • ` between: a `
      ` 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) => ( ))}
  • )}

    {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}".`}

    ); }