Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 55 additions & 12 deletions components/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
} from "react-hook-form";

import clsx from "clsx";
import { Check, LoaderCircle } from "lucide-react";
import { Check, LoaderCircle, type LucideIcon, Save } from "lucide-react";
import { createElement } from "react";
import { cn } from "../lib/utils.js";
import { Button, type ButtonProps } from "./shadcn/button.js";
import {
Expand Down Expand Up @@ -242,34 +243,76 @@ function Checkbox<T extends FieldValues>({ name, label }: BaseInputProps<T>) {
}
Form.Checkbox = Checkbox;

interface SubmitLabelMapping {
save: string;
saving: string;
saved: string;
}

const submitLabelKnownMappings: Record<string, SubmitLabelMapping> = {
save: { save: "Save", saving: "Saving", saved: "Saved" },
unlock: { save: "Unlock", saving: "Unlocking", saved: "Unlocked" },
};

interface SubmitProps extends ButtonProps {
label: React.ReactNode;
label: string | SubmitLabelMapping;
skipDirtyCheck?: boolean;
isSubmitting?: boolean;
submittingLabel?: string;
successLabel?: string;
}

function Submit({
skipDirtyCheck = false,
label,
label = submitLabelKnownMappings.save,
isSubmitting: isSubmittingOverride = false,
...rest
}: SubmitProps) {
const {
formState: { isValid, isDirty, isSubmitting },
formState: { isValid, isDirty, isSubmitting, isSubmitSuccessful },
} = useFormContext();

const isSubmittingWithOverride = isSubmittingOverride || isSubmitting;

const disabled = skipDirtyCheck
? !isValid || isSubmitting
: !isDirty || !isValid || isSubmitting;
? !isValid || isSubmittingWithOverride
: !isDirty || !isValid || isSubmittingWithOverride;

const labelMapping =
typeof label === "string"
? submitLabelKnownMappings[label.toLowerCase()] || label
: label;

let computedLabel: string;
if (!labelMapping) {
// at this point it can only be a string
computedLabel = label as string;
} else if (isSubmittingWithOverride) {
computedLabel = labelMapping.saving;
} else if (isSubmitSuccessful) {
computedLabel = labelMapping.saved;
} else {
computedLabel = labelMapping.save;
}

let icon: LucideIcon;
if (isSubmittingWithOverride) {
icon = LoaderCircle;
} else if (isDirty) {
icon = Save;
} else if (isSubmitSuccessful) {
icon = Check;
} else {
icon = Save;
}
const iconProps = isSubmittingWithOverride
? { className: "animate-spin" }
: {};

return (
<Button type="submit" disabled={disabled} {...rest}>
{isSubmitting || isSubmittingOverride ? (
<LoaderCircle className="animate-spin" />
) : (
<Check />
)}
{label}
{createElement(icon, iconProps)}
{computedLabel}
</Button>
);
}
Expand Down
106 changes: 106 additions & 0 deletions stories/forms/Submit.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { Meta, StoryObj } from "@storybook/react-vite";

import { zodResolver } from "@hookform/resolvers/zod";
import React from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Form } from "../../components/form.js";

const meta = {
title: "Components/Form/Submit",
component: Form.Submit,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
decorators: [
(Story) => (
<div className="w-md">
<Story />
</div>
),
],
} satisfies Meta<typeof Form.Submit>;

export default meta;
type Story = StoryObj<typeof meta>;

export const WithRequiredInput: Story = {
decorators: [
(Story) => {
const schema = z.object({
required: z.string().min(1),
});

const form = useForm({
resolver: zodResolver(schema),
});

const onSubmit = async () => {
console.log("submitting");
await new Promise((resolve) => setTimeout(resolve, 500));
console.log("submitted");
form.reset({}, { keepValues: true });
};

return (
<Form form={form} onSubmit={onSubmit}>
<Form.Text
name="required"
label="Required field"
className="w-full"
/>
<div className="flex gap-2">
<Story />
</div>
</Form>
);
},
],

args: {
name: "default",
label: "Save",
className: "w-full",
},
};

export const WithOptionalInput: Story = {
decorators: [
(Story) => {
const schema = z.object({
optional: z.string().optional(),
});

const form = useForm({
resolver: zodResolver(schema),
});

const onSubmit = async () => {
console.log("submitting");
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("submitted");
form.reset({}, { keepValues: true });
};

return (
<Form form={form} onSubmit={onSubmit}>
<Form.Text
name="optional"
label="Optional field"
className="w-full"
/>
<div className="flex gap-2">
<Story />
</div>
</Form>
);
},
],

args: {
name: "default",
label: "Submit",
className: "w-full",
},
};