diff --git a/components/form.tsx b/components/form.tsx index be95c18..c964697 100644 --- a/components/form.tsx +++ b/components/form.tsx @@ -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 { @@ -242,34 +243,76 @@ function Checkbox({ name, label }: BaseInputProps) { } Form.Checkbox = Checkbox; +interface SubmitLabelMapping { + save: string; + saving: string; + saved: string; +} + +const submitLabelKnownMappings: Record = { + 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 ( ); } diff --git a/stories/forms/Submit.stories.tsx b/stories/forms/Submit.stories.tsx new file mode 100644 index 0000000..c1e442a --- /dev/null +++ b/stories/forms/Submit.stories.tsx @@ -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) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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 ( +
+ +
+ +
+ + ); + }, + ], + + 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 ( +
+ +
+ +
+ + ); + }, + ], + + args: { + name: "default", + label: "Submit", + className: "w-full", + }, +};