src/components/dives/dive-form-preset-save-as.tsx
Rama main · réplica de lectura del repositorio original.
"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>
);
}