feat(web): keep overlay content on screen while it animates closed

Sheets and dialogs were closed by nulling the data they render, so they
went blank, flipped titles or tabs, or dropped rows on the first frame
of their exit. Add useClosingValue, which holds the last value until
Base UI's onOpenChangeComplete(false), and move same-tick resets into
onOpenChangeComplete. The edit-provider dialog and stylesheet color
picker stay mounted so their exit transition runs.
This commit is contained in:
Amruth Pillai
2026-09-29 12:44:15 +02:00
parent 0bc128ff3a
commit 08c513ca26
15 changed files with 264 additions and 127 deletions
@@ -33,6 +33,7 @@ type ColorPickerProps = {
onChange?: (value: string) => void;
open?: boolean;
onOpenChange?: React.ComponentProps<typeof Popover>["onOpenChange"];
onOpenChangeComplete?: React.ComponentProps<typeof Popover>["onOpenChangeComplete"];
trigger?: React.ReactNode;
children?: React.ReactNode;
};
@@ -43,6 +44,7 @@ export function ColorPicker({
onChange,
open,
onOpenChange,
onOpenChangeComplete,
trigger,
children,
}: ColorPickerProps) {
@@ -60,7 +62,7 @@ export function ColorPicker({
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<Popover open={open} onOpenChange={onOpenChange} onOpenChangeComplete={onOpenChangeComplete}>
{trigger ?? (
<PopoverTrigger>
<div
@@ -28,6 +28,7 @@ import { Textarea } from "@reactive-resume/ui/components/textarea";
import { toast } from "@reactive-resume/ui/components/toast";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { useClosingValue } from "@/hooks/use-closing-value";
import { useConfirm } from "@/hooks/use-confirm";
import { orpc } from "@/libs/orpc/client";
import { stageSince } from "../next-step";
@@ -52,15 +53,18 @@ type DetailSheetProps = {
*/
export function ApplicationDetailSheet({ application, onOpenChange, onEditDetails }: DetailSheetProps) {
const phone = useBreakpoint() === "mobile";
// Closing keeps the last application on screen until the sheet has slid away.
const [shown, onOpenChangeComplete] = useClosingValue(application);
const { data } = useQuery({
...orpc.applications.getById.queryOptions({ input: { id: application?.id ?? "" } }),
...orpc.applications.getById.queryOptions({ input: { id: shown?.id ?? "" } }),
// Fetch only while open; while closing, the cached copy keeps showing.
enabled: Boolean(application),
...(application ? { placeholderData: application } : {}),
...(shown ? { placeholderData: shown } : {}),
});
const current = data ?? application;
const current = data ?? shown;
return (
<Sheet open={Boolean(application)} onOpenChange={onOpenChange}>
<Sheet open={Boolean(application)} onOpenChange={onOpenChange} onOpenChangeComplete={onOpenChangeComplete}>
<SheetContent
side={phone ? "bottom" : "right"}
closeLabel={t`Close`}
@@ -22,6 +22,7 @@ import {
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { toast } from "@reactive-resume/ui/components/toast";
import { Combobox } from "@/components/ui/combobox";
import { useClosingValue } from "@/hooks/use-closing-value";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "../queries";
import { FileAttachmentField } from "./file-attachment-field";
@@ -86,8 +87,10 @@ type Props = {
application?: Application | null;
};
export function ApplicationFormSheet({ open, onOpenChange, application }: Props) {
export function ApplicationFormSheet({ open, onOpenChange, application: requested }: Props) {
const queryClient = useQueryClient();
// Closing keeps the application (title and fields) on screen until the sheet has slid away.
const [application, onApplicationOpenChangeComplete] = useClosingValue(requested ?? null);
const isEditing = !!application;
const [form, setForm] = useState<FormState>(() => (application ? toForm(application) : emptyForm()));
@@ -127,7 +130,6 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
onSuccess: () => {
invalidate();
toast.add({ type: "success", description: t`Application added to your pipeline.` });
setForm(emptyForm());
onOpenChange(false);
},
onError: () => toast.add({ type: "error", description: t`Couldn't add the application. Please try again.` }),
@@ -195,7 +197,18 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<Sheet
open={open}
onOpenChange={onOpenChange}
onOpenChangeComplete={(next) => {
onApplicationOpenChangeComplete(next);
// After adding, the fields clear once the sheet has closed; closing without saving keeps the draft.
if (!next && create.isSuccess) {
setForm(emptyForm());
create.reset();
}
}}
>
<SheetContent side="right" className="w-full gap-0 data-[side=right]:sm:max-w-lg">
<SheetHeader>
<SheetTitle>{isEditing ? <Trans>Edit application</Trans> : <Trans>Add application</Trans>}</SheetTitle>
@@ -24,6 +24,7 @@ import { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { toast } from "@reactive-resume/ui/components/toast";
import { useClosingValue } from "@/hooks/use-closing-value";
import { useConfirm } from "@/hooks/use-confirm";
import { orpc } from "@/libs/orpc/client";
import { interviewKindOf } from "../../interviews";
@@ -53,6 +54,8 @@ export function Activity({ application, onOpenInterview }: ActivityProps) {
const confirm = useConfirm();
const [note, setNote] = useState("");
const [editing, setEditing] = useState<{ entry: ApplicationTimelineEntry; date: string; text: string } | null>(null);
// Closing keeps the entry's title and fields on screen until the dialog has faded out.
const [shownEdit, onEditOpenChangeComplete] = useClosingValue(editing);
const onError = () => toast.add({ type: "error", description: t`Couldn't update the timeline. Try again.` });
const addNote = useMutation({
@@ -194,22 +197,27 @@ export function Activity({ application, onOpenInterview }: ActivityProps) {
})}
</ol>
<Dialog open={editing !== null} onOpenChange={(open) => !open && setEditing(null)}>
<Dialog
open={editing !== null}
onOpenChange={(open) => !open && setEditing(null)}
onOpenChangeComplete={onEditOpenChangeComplete}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
{editing?.entry.type === "note" ? <Trans>Edit note</Trans> : <Trans>Edit date</Trans>}
{shownEdit?.entry.type === "note" ? <Trans>Edit note</Trans> : <Trans>Edit date</Trans>}
</DialogTitle>
<DialogDescription className="sr-only">
<Trans>Change this timeline entry.</Trans>
</DialogDescription>
</DialogHeader>
{editing && (
{shownEdit && (
<form
id="timeline-entry-form"
className="grid gap-3"
onSubmit={(event) => {
event.preventDefault();
if (!editing) return;
updateEntry.mutate({
id: application.id,
entryId: editing.entry.id,
@@ -218,19 +226,25 @@ export function Activity({ application, onOpenInterview }: ActivityProps) {
});
}}
>
{editing.entry.type === "note" && (
{shownEdit.entry.type === "note" && (
<Textarea
aria-label={t`Note`}
rows={3}
value={editing.text}
onChange={(event) => setEditing({ ...editing, text: event.target.value })}
value={shownEdit.text}
onChange={(event) => {
const text = event.target.value;
setEditing((current) => current && { ...current, text });
}}
/>
)}
<Input
type="date"
aria-label={t`Date`}
value={editing.date}
onChange={(event) => setEditing({ ...editing, date: event.target.value })}
value={shownEdit.date}
onChange={(event) => {
const date = event.target.value;
setEditing((current) => current && { ...current, date });
}}
/>
</form>
)}
@@ -238,7 +252,7 @@ export function Activity({ application, onOpenInterview }: ActivityProps) {
<Button variant="secondary" onClick={() => setEditing(null)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" form="timeline-entry-form" disabled={!editing?.date || updateEntry.isPending}>
<Button type="submit" form="timeline-entry-form" disabled={!shownEdit?.date || updateEntry.isPending}>
<Trans>Save</Trans>
</Button>
</DialogFooter>
@@ -87,7 +87,6 @@ export function ImportApplicationsSheet({ open, onOpenChange }: ImportSheetProps
? t`Imported ${response.imported} applications. ${skipped} rows had no company or role and were skipped.`
: t`Imported ${response.imported} applications.`,
});
reset();
onOpenChange(false);
},
onError: () => toast.add({ type: "error", description: t`Import failed. Check the CSV and try again.` }),
@@ -100,7 +99,16 @@ export function ImportApplicationsSheet({ open, onOpenChange }: ImportSheetProps
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<Sheet
open={open}
onOpenChange={onOpenChange}
// After an import, the rows clear once the sheet has slid away; closing without importing keeps them.
onOpenChangeComplete={(next) => {
if (next || !importMutation.isSuccess) return;
reset();
importMutation.reset();
}}
>
<SheetContent side="right" className="w-full gap-0 data-[side=right]:sm:max-w-lg">
<SheetHeader>
<SheetTitle>
@@ -24,6 +24,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
import { ChipInput } from "@/components/input/chip-input";
import { useDialogStore } from "@/dialogs/store";
import { applicationsListQueryOptions } from "@/features/applications/queries";
import { useClosingValue } from "@/hooks/use-closing-value";
import { useConfirm } from "@/hooks/use-confirm";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
@@ -205,8 +206,10 @@ export function DocumentMenuContent({
type TagsDialogProps = { document: DocumentSummary | null; onClose: () => void };
/** Tags… from the card menu: tags filter the library once any exist. */
export function TagsDialog({ document, onClose }: TagsDialogProps) {
export function TagsDialog({ document: requested, onClose }: TagsDialogProps) {
const queryClient = useQueryClient();
// Closing keeps the document's tags on screen until the dialog has faded out.
const [document, onDocumentOpenChangeComplete] = useClosingValue(requested);
const [tags, setTags] = useState<string[] | null>(null);
const setDocumentTags = useMutation(orpc.documents.setTags.mutationOptions());
@@ -215,7 +218,6 @@ export function TagsDialog({ document, onClose }: TagsDialogProps) {
try {
await setDocumentTags.mutateAsync({ type: document.type, id: document.id, tags: tags ?? document.tags });
await queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
setTags(null);
onClose();
} catch (error) {
failed(error);
@@ -224,11 +226,12 @@ export function TagsDialog({ document, onClose }: TagsDialogProps) {
return (
<Dialog
open={document !== null}
onOpenChange={(open) => {
if (open) return;
setTags(null);
onClose();
open={requested !== null}
onOpenChange={(open) => !open && onClose()}
onOpenChangeComplete={(open) => {
onDocumentOpenChangeComplete(open);
// Unsaved edits are dropped once the dialog has closed, so the chips don't change while it fades.
if (!open) setTags(null);
}}
>
<DialogContent>
@@ -254,9 +257,11 @@ export function TagsDialog({ document, onClose }: TagsDialogProps) {
type LinkApplicationDialogProps = { document: DocumentSummary | null; onClose: () => void };
/** Link to application… for letters: the job the letter is for. */
export function LinkApplicationDialog({ document, onClose }: LinkApplicationDialogProps) {
export function LinkApplicationDialog({ document: requested, onClose }: LinkApplicationDialogProps) {
const queryClient = useQueryClient();
const { data: applications } = useQuery({ ...applicationsListQueryOptions(), enabled: document !== null });
// Closing keeps the list, its highlight and Unlink on screen until the dialog has faded out.
const [document, onOpenChangeComplete] = useClosingValue(requested);
const { data: applications } = useQuery({ ...applicationsListQueryOptions(), enabled: requested !== null });
const link = useMutation(orpc.documents.linkApplication.mutationOptions());
const choose = async (applicationId: string | null) => {
@@ -273,7 +278,11 @@ export function LinkApplicationDialog({ document, onClose }: LinkApplicationDial
const jobs = (applications ?? []).filter((application) => application.status !== "closed");
return (
<Dialog open={document !== null} onOpenChange={(open) => !open && onClose()}>
<Dialog
open={requested !== null}
onOpenChange={(open) => !open && onClose()}
onOpenChangeComplete={onOpenChangeComplete}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -31,6 +31,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
import "react-easy-crop/react-easy-crop.css";
import { ColorPicker } from "@/components/input/color-picker";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useClosingValue } from "@/hooks/use-closing-value";
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
import { getReadableErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
@@ -492,6 +493,8 @@ export function PictureSettings() {
const appOrigin = window.location.origin;
const [cropState, setCropState] = useState<CropState | null>(null);
// Closing keeps the picture in the cropper until the dialog has faded out.
const [shownCrop, onCropOpenChangeComplete] = useClosingValue(cropState);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
@@ -581,7 +584,6 @@ export function PictureSettings() {
};
const closeCropDialog = () => {
if (cropState) URL.revokeObjectURL(cropState.imageSrc);
setCropState(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
@@ -613,6 +615,11 @@ export function PictureSettings() {
onOpenChange={(open) => {
if (!open) closeCropDialog();
}}
onOpenChangeComplete={(open) => {
// The object URL is freed only once the cropper has gone.
if (!open && shownCrop) URL.revokeObjectURL(shownCrop.imageSrc);
onCropOpenChangeComplete(open);
}}
>
<DialogContent>
<DialogHeader>
@@ -624,10 +631,10 @@ export function PictureSettings() {
</DialogDescription>
</DialogHeader>
{cropState && (
{shownCrop && (
<div className="relative h-64 w-full overflow-hidden rounded-md bg-sunken ring-1 ring-line ring-inset">
<Cropper
image={cropState.imageSrc}
image={shownCrop.imageSrc}
crop={crop}
zoom={zoom}
aspect={cropAspect}
@@ -34,6 +34,7 @@ import { Sheet, SheetContent, SheetTitle } from "@reactive-resume/ui/components/
import { ColorPicker } from "@/components/input/color-picker";
import { useIsResumeLocked, useResumeData, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useTheme } from "@/features/theme/provider";
import { useClosingValue } from "@/hooks/use-closing-value";
import { serializeStylesheetColor, toStylesheetPickerColor } from "./color-format";
import { compositionAwareDocumentListener, createSemanticCssEditorExtensions } from "./editor-extensions";
import { formatEditorDocument } from "./formatter";
@@ -143,6 +144,8 @@ export function StylesheetCodeEditor({
left: number;
top: number;
} | null>(null);
// Closing keeps the picker on its swatch until it has faded out.
const [shownColor, onColorOpenChangeComplete] = useClosingValue(selectedColor);
const selectColor = useCallback((token: SemanticCssColorToken, rect: DOMRect) => {
const hostRect = hostRef.current?.getBoundingClientRect();
if (!hostRect) return;
@@ -319,10 +322,11 @@ export function StylesheetCodeEditor({
return (
<div ref={hostRef} className="relative h-full overflow-hidden rounded-md border text-xs" dir="ltr">
{selectedColor && (
<div className="pointer-events-none absolute z-20" style={{ left: selectedColor.left, top: selectedColor.top }}>
{shownColor && (
<div className="pointer-events-none absolute z-20" style={{ left: shownColor.left, top: shownColor.top }}>
<ColorPicker
open
open={selectedColor !== null}
onOpenChangeComplete={onColorOpenChangeComplete}
onOpenChange={(open, details) => {
const target = details.event.target;
if (
@@ -333,7 +337,7 @@ export function StylesheetCodeEditor({
return;
if (!open) setSelectedColor(null);
}}
value={toStylesheetPickerColor(selectedColor.token.value)}
value={toStylesheetPickerColor(shownColor.token.value)}
onChange={updateColor}
trigger={
<PopoverTrigger
@@ -341,10 +345,10 @@ export function StylesheetCodeEditor({
<button
data-semantic-css-color-picker-trigger=""
type="button"
title={t`Edit color ${selectedColor.token.value}`}
aria-label={t`Edit color ${selectedColor.token.value}`}
title={t`Edit color ${shownColor.token.value}`}
aria-label={t`Edit color ${shownColor.token.value}`}
className="pointer-events-auto size-3 rounded-full border border-ink/40"
style={{ backgroundColor: selectedColor.token.value }}
style={{ backgroundColor: shownColor.token.value }}
/>
}
/>
@@ -103,10 +103,9 @@ function DeleteAccountDialog({ open, onOpenChange }: DeleteAccountDialogProps) {
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
onOpenChange(next);
if (!next) setTyped("");
}}
onOpenChange={onOpenChange}
// The typed confirmation clears once the dialog has faded out.
onOpenChangeComplete={(next) => !next && setTyped("")}
>
<AlertDialogContent>
<AlertDialogHeader>
@@ -186,9 +186,8 @@ function NewKeyDialog({ open, onOpenChange, onCreated }: NewKeyDialogProps) {
const [copied, setCopied] = useState(false);
const [, copy] = useCopyToClipboard();
const close = (next: boolean) => {
onOpenChange(next);
if (next) return;
// Everything clears once the dialog has faded out, so the key doesn't turn back into the form while it does.
const reset = () => {
setName("");
setExpiry(0);
setKey(null);
@@ -210,7 +209,7 @@ function NewKeyDialog({ open, onOpenChange, onCreated }: NewKeyDialogProps) {
};
return (
<Dialog open={open} onOpenChange={close}>
<Dialog open={open} onOpenChange={onOpenChange} onOpenChangeComplete={(next) => !next && reset()}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
@@ -246,7 +245,7 @@ function NewKeyDialog({ open, onOpenChange, onCreated }: NewKeyDialogProps) {
</Button>
</div>
<DialogFooter>
<Button onClick={() => close(false)}>
<Button onClick={() => onOpenChange(false)}>
<Trans>Done</Trans>
</Button>
</DialogFooter>
@@ -302,7 +301,7 @@ function NewKeyDialog({ open, onOpenChange, onCreated }: NewKeyDialogProps) {
</p>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => close(false)}>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={!name.trim() || creating} loading={creating}>
+89 -74
View File
@@ -22,6 +22,7 @@ import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { useClosingValue } from "@/hooks/use-closing-value";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { SettingsSection } from "../section";
@@ -77,7 +78,7 @@ export function ProvidersSection() {
)}
<AddProviderDialog open={adding} onOpenChange={setAdding} />
{editing && <EditProviderDialog provider={editing} onClose={() => setEditing(null)} />}
<EditProviderDialog provider={editing} onClose={() => setEditing(null)} />
</SettingsSection>
);
}
@@ -275,7 +276,6 @@ function AddProviderDialog({ open, onOpenChange }: AddProviderDialogProps) {
const tested = await test.mutateAsync({ id: created.id });
if (tested.testStatus === "success") {
onOpenChange(false);
reset();
} else {
// The provider is saved either way; its row keeps the error and a Test button.
setFailure(tested.testError ?? t`The provider didn't answer. Check the key, the model and the base URL.`);
@@ -301,10 +301,9 @@ function AddProviderDialog({ open, onOpenChange }: AddProviderDialogProps) {
return (
<Dialog
open={open}
onOpenChange={(next) => {
onOpenChange(next);
if (!next) reset();
}}
onOpenChange={onOpenChange}
// The fields clear once the dialog has faded out, not while it does.
onOpenChangeComplete={(next) => !next && reset()}
>
<DialogContent>
<DialogHeader>
@@ -355,10 +354,28 @@ function AddProviderDialog({ open, onOpenChange }: AddProviderDialogProps) {
);
}
type EditProviderDialogProps = { provider: SavedProvider; onClose: () => void };
type EditProviderDialogProps = { provider: SavedProvider | null; onClose: () => void };
/** Name, model, base URL and key; whether the app may use it; and removing it. */
function EditProviderDialog({ provider, onClose }: EditProviderDialogProps) {
// Always mounted, so closing plays the exit; the form stays on screen until it has faded out, and the next open
// starts fresh (the typed key is never kept).
const [shown, onOpenChangeComplete] = useClosingValue(provider);
return (
<Dialog
open={provider !== null}
onOpenChange={(open) => !open && onClose()}
onOpenChangeComplete={onOpenChangeComplete}
>
<DialogContent>{shown && <EditProviderForm key={shown.id} provider={shown} onClose={onClose} />}</DialogContent>
</Dialog>
);
}
type EditProviderFormProps = { provider: SavedProvider; onClose: () => void };
function EditProviderForm({ provider, onClose }: EditProviderFormProps) {
const queryClient = useQueryClient();
const [fields, setFields] = useState<ProviderFields>({
label: provider.label,
@@ -407,74 +424,72 @@ function EditProviderDialog({ provider, onClose }: EditProviderDialogProps) {
);
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{provider.label}</DialogTitle>
<DialogDescription>{providerLabel(provider.provider)}</DialogDescription>
</DialogHeader>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
if (changed) void save();
}}
>
<ProviderFieldsForm provider={provider.provider} value={fields} onChange={setFields} keyOptional />
<div className="flex items-center justify-between gap-3 text-sm">
<span className="grid gap-0.5">
<span id={`${provider.id}-use`} className="font-medium">
<Trans>Use this provider</Trans>
</span>
<span id={`${provider.id}-use-hint`} className="text-ink-3 text-xs">
<Trans>Only providers that pass their test can be turned on.</Trans>
</span>
<>
<DialogHeader>
<DialogTitle>{provider.label}</DialogTitle>
<DialogDescription>{providerLabel(provider.provider)}</DialogDescription>
</DialogHeader>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
if (changed) void save();
}}
>
<ProviderFieldsForm provider={provider.provider} value={fields} onChange={setFields} keyOptional />
<div className="flex items-center justify-between gap-3 text-sm">
<span className="grid gap-0.5">
<span id={`${provider.id}-use`} className="font-medium">
<Trans>Use this provider</Trans>
</span>
<Switch
aria-labelledby={`${provider.id}-use`}
aria-describedby={`${provider.id}-use-hint`}
checked={provider.enabled}
disabled={provider.testStatus !== "success" || update.isPending}
onCheckedChange={setEnabled}
/>
</div>
{failure && (
<p role="alert" className="text-danger-text text-sm">
{failure}
</p>
)}
<DialogFooter className="sm:justify-between">
<Button
type="button"
variant="ghost"
className="text-danger-text hover:bg-danger-soft"
loading={remove.isPending}
onClick={() =>
remove.mutate(
{ id: provider.id },
{
onSuccess: () => {
void invalidate();
onClose();
},
onError: (error) =>
setFailure(getOrpcErrorMessage(error, { fallback: t`Couldn't delete the provider.` })),
<span id={`${provider.id}-use-hint`} className="text-ink-3 text-xs">
<Trans>Only providers that pass their test can be turned on.</Trans>
</span>
</span>
<Switch
aria-labelledby={`${provider.id}-use`}
aria-describedby={`${provider.id}-use-hint`}
checked={provider.enabled}
disabled={provider.testStatus !== "success" || update.isPending}
onCheckedChange={setEnabled}
/>
</div>
{failure && (
<p role="alert" className="text-danger-text text-sm">
{failure}
</p>
)}
<DialogFooter className="sm:justify-between">
<Button
type="button"
variant="ghost"
className="text-danger-text hover:bg-danger-soft"
loading={remove.isPending}
onClick={() =>
remove.mutate(
{ id: provider.id },
{
onSuccess: () => {
void invalidate();
onClose();
},
)
}
>
<Trans>Delete provider</Trans>
</Button>
<Button
type="submit"
disabled={!changed || update.isPending || test.isPending}
loading={update.isPending || test.isPending}
>
<Trans>Save and test</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
onError: (error) =>
setFailure(getOrpcErrorMessage(error, { fallback: t`Couldn't delete the provider.` })),
},
)
}
>
<Trans>Delete provider</Trans>
</Button>
<Button
type="submit"
disabled={!changed || update.isPending || test.isPending}
loading={update.isPending || test.isPending}
>
<Trans>Save and test</Trans>
</Button>
</DialogFooter>
</form>
</>
);
}
@@ -0,0 +1,31 @@
// @vitest-environment happy-dom
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { useClosingValue } from "./use-closing-value";
describe("useClosingValue", () => {
it("holds the last value while closing and drops it once the exit completes", () => {
const { result, rerender } = renderHook(({ value }: { value: string | null }) => useClosingValue(value), {
initialProps: { value: "a" as string | null },
});
expect(result.current[0]).toBe("a");
rerender({ value: null });
expect(result.current[0]).toBe("a");
act(() => result.current[1](false));
expect(result.current[0]).toBeNull();
});
it("follows a new value at once and ignores an open completion", () => {
const { result, rerender } = renderHook(({ value }: { value: string | null }) => useClosingValue(value), {
initialProps: { value: "a" as string | null },
});
rerender({ value: "b" });
expect(result.current[0]).toBe("b");
act(() => result.current[1](true));
expect(result.current[0]).toBe("b");
});
});
+18
View File
@@ -0,0 +1,18 @@
import { useState } from "react";
/**
* Keeps an overlay's content on screen while it animates closed. Pass what the overlay shows (null when closed),
* render from the returned value, and hand the returned callback to the overlay Root's `onOpenChangeComplete`.
* The last non-null value is held through the exit animation and dropped once it has finished, so the next open
* mounts fresh.
*/
export function useClosingValue<T>(value: T | null): readonly [T | null, (open: boolean) => void] {
const [held, setHeld] = useState(value);
if (value !== null && value !== held) setHeld(value);
const onOpenChangeComplete = (open: boolean) => {
if (!open) setHeld(null);
};
return [value ?? held, onOpenChangeComplete] as const;
}
@@ -10,6 +10,7 @@ import { useEditorMode } from "@/features/resume/editor/use-editor-mode";
import { DownloadTab } from "@/features/resume/share/download-tab";
import { HistoryTab } from "@/features/resume/share/history-tab";
import { LinkTab } from "@/features/resume/share/link-tab";
import { useClosingValue } from "@/hooks/use-closing-value";
/**
* Share & export: one sheet with Link, Download and History. Share opens Link, the ▾ beside Download PDF
@@ -17,13 +18,19 @@ import { LinkTab } from "@/features/resume/share/link-tab";
*/
export function ShareSheet() {
const tab = useEditorStore((state) => state.shareTab);
// Closing keeps the open tab on screen until the sheet has slid away.
const [shownTab, onOpenChangeComplete] = useClosingValue(tab);
const setTab = useEditorStore((state) => state.setShareTab);
const setHistoryVersion = useEditorStore((state) => state.setHistoryVersion);
const [, setMode] = useEditorMode();
const isPhone = useBreakpoint() === "mobile";
return (
<Sheet open={tab !== null} onOpenChange={(open) => !open && setTab(null)}>
<Sheet
open={tab !== null}
onOpenChange={(open) => !open && setTab(null)}
onOpenChangeComplete={onOpenChangeComplete}
>
<SheetContent
side={isPhone ? "bottom" : "right"}
closeLabel={t`Close`}
@@ -36,7 +43,7 @@ export function ShareSheet() {
</SheetHeader>
<Tabs
value={tab ?? "link"}
value={shownTab ?? "link"}
onValueChange={(value) => {
// The page shows a version only while History is open.
if (value !== "history") setHistoryVersion(null);
@@ -23,6 +23,7 @@ import {
getExportFormats,
} from "@/features/resume/share/download-tab";
import { HistoryTimeline } from "@/features/resume/share/history-tab";
import { useClosingValue } from "@/hooks/use-closing-value";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
@@ -32,13 +33,19 @@ import { client, orpc } from "@/libs/orpc/client";
*/
export function LetterShareSheet() {
const tab = useEditorStore((state) => state.shareTab);
// Closing keeps the open tab on screen until the sheet has slid away.
const [shownTab, onOpenChangeComplete] = useClosingValue(tab);
const setTab = useEditorStore((state) => state.setShareTab);
const setHistoryVersion = useEditorStore((state) => state.setHistoryVersion);
const isPhone = useBreakpoint() === "mobile";
const history = useLetterHistory(tab === "history");
return (
<Sheet open={tab !== null} onOpenChange={(open) => !open && setTab(null)}>
<Sheet
open={tab !== null}
onOpenChange={(open) => !open && setTab(null)}
onOpenChangeComplete={onOpenChangeComplete}
>
<SheetContent
side={isPhone ? "bottom" : "right"}
closeLabel={t`Close`}
@@ -51,7 +58,7 @@ export function LetterShareSheet() {
</SheetHeader>
<Tabs
value={tab === "history" ? "history" : "download"}
value={shownTab === "history" ? "history" : "download"}
onValueChange={(value) => {
// The page shows a version only while History is open.
if (value !== "history") setHistoryVersion(null);