Skip to content

Instantly share code, notes, and snippets.

@andrecasal
Created March 30, 2023 23:37
Show Gist options
  • Save andrecasal/659df4ae0df4c4e14f9db6c9a8ced1ac to your computer and use it in GitHub Desktop.
Save andrecasal/659df4ae0df4c4e14f9db6c9a8ced1ac to your computer and use it in GitHub Desktop.
remix-validity-state

Remix Validity State

remix-validity-state is a small React form validation library that aims to embrace HTML input validation and play nicely with Remix and React Router primitives (specifically submitting forms to action handlers). However, it's worth noting that this library doesn't use anything specific from Remix or React Router and could be leveraged in any React application.

Warning

This library is still in a beta stage. It's feeling more stable these days but I can't guarantee there won't be some breaking changes ahead 😀. Please read the release notes for each new version as they'll always be highlighted there.

Design Goals

This library is built with the following design goals in mind:

1. Leverage built-in HTML input validation attributes verbatim

What ever happened to good old <input required maxlength="30" />? Far too often we reach for some custom validation library just to check that a value is not empty (and potentially ship a boatload of JS to the client in order to do so). Let's use what we have readily available when we can! That way we don't have to relearn something new. If you already know some of the HTML validation attributes...then you're ready to use this library.

2. Share validations between client and server

Thanks to Remix, this is finally much more straightforward than it has been in the past. But wait 🤔, aren't we using DOM validations? We don't have a DOM on the server?!? Don't worry - in true Remix spirit, we emulate the DOM validations on the server.

3. Expose validation results via a ValidityState-like API

We will need an API to explain the validation state of an input...good news - the web already has one! Let's #useThePlatform and build on top of ValidityState.

4. Permit custom sync/async validations beyond those built into HTML

Congrats for making it to bullet 4 and not leaving as soon as we mentioned the super-simple HTML validations. Don't worry - it's not lost on me that folks need to check their email addresses for uniqueness in the DB. We've got you covered with custom sync/async validations.

5. Provide limited abstractions to simplify form markup generation

Semantically correct and accessible <form> markup is verbose. Any convenient form library oughta provide some wrapper components to make simple forms easy. However, any form library worth it's weight has to offer low level access to allow for true custom forms, and the ability to built custom abstractions for your application use-case. Therefore, any wrapper components will be little more than syntactic sugar on top of the lower level APIs.

Installation

> npm install remix-validity-state

# or

> yarn add remix-validity-state

Info

This library is bundled for modern browsers (see .browserslistrc). If you need to support older browsers you may need to configure your build process accordingly.

Usage

Demo App

There's a sample Remix app deployed to rvs.fly.dev that you can check out. This app source code is stored in this repository in the demo-app/ folder, so you can also open it in StackBlitz or run it locally:

Open in StackBlitz

To run the app locally:

git clone git@github.com:brophdawg11/remix-validity-state.git
cd remix-validity-state/demo-app
npm ci
npm run dev

Getting Started

Define your form validations

In order to share validations between server and client, we define a single object containing all of our form field validations, keyed by the input names. Validations are specified using the built-in HTML validation attributes, exactly as you'd render them onto a JSX <input>.

If you're using TypeScript (and you should!) you should define a schema that corresponds to the FormDefinition interface so you can benefit from proper type inference on library APIs.

interface FormSchema {
  inputs: {
    firstName: InputDefinition;
    middleInitial: InputDefinition;
    lastName: InputDefinition;
    emailAddress: InputDefinition;
  };
}

let formDefinition: FormSchema = {
  inputs: {
    firstName: {
      validationAttrs: {
        required: true,
        maxLength: 50,
      },
    },
    middleInitial: {
      validationAttrs: {
        pattern: "^[a-zA-Z]{1}$",
      },
    },
    lastName: {
      validationAttrs: {
        required: true,
        maxLength: 50,
      },
    },
    emailAddress: {
      validationAttrs: {
        type: "email",
        required: true,
        maxLength: 50,
      },
    },
  },
};

This allows us to directly render these attributes onto our HTML inputs internally via something like <input name="firstName" {...formDefinition.inputs.firstName.validationAttrs} />

Provide your validations via FormProvider

In order to make these validations easily accessible, we provide them via a <FormProvider> that should wrap your underlying <form> element. We do this with a wrapper component around the actual context for better TypeScript inference.

import { FormProvider } from "remix-validity-state";

function MyFormPage() {
  return (
    <FormProvider formDefinition={formDefinition}>
      {/* Your <form> goes in here */}
    </FormProvider>
  );
}

Render <Input> Components inside your FormProvider

import { FormProvider } from "remix-validity-state";

function MyFormPage() {
  return (
    <FormProvider formDefinition={formDefinition}>
      <Input name="firstName" label="First Name" />
      <Input name="middleInitial" label="Middle Name" />
      <Input name="lastName" label="Last Name" />
      <Input name="emailAddress" label="Email Address" />
    </FormProvider>
  );
}

The <Input> component is our wrapper that handles the <label>, <input>, and real-time error display. The name serves as the key and will look up our validation attributes from your formDefinition and include them on the underlying <input />.

Wire up server-side validations

In Remix, your submit your forms to an action which receives the FormData. In your action, call validateServerFormData with the formData and your previously defined formDefinition:

import { validateServerFormData } from "remix-validity-state";

export async function action({ request }: ActionArgs) {
  const formData = await request.formData();
  const serverFormInfo = await validateServerFormData(
    formData,
    formDefinitions
  );
  if (!serverFormInfo.valid) {
    // Uh oh - we found some errors, send them back up to the UI for display
    // serverFormInfo contains:
    //  - submittedValues - all of the form input values submitted in formData
    //  - inputs - InputInfo objects representing the ValidityState of each input
    //  - errorMessages - error messages to display
    return json({ serverFormInfo });
  }
  // Congrats!  Your form data is valid - do what ya gotta do with it
}

Add your server action response to the FormProvider

When we validate on the server, we may get errors back that we didn't catch during client-side validation (or we didn't run because JS hadn't yet loaded!). In order to render those, we can provide the response from validateServerFormData to our FormProvider and it'll be used internally. The serverFormInfo also contains all of the submitted input values to be pre-populated into the inputs in a no-JS scenario.

import { Field, FormProvider } from "remix-validity-state";

export default function MyRemixRouteComponent() {
  let actionData = useActionData<typeof action>();

  return (
    <FormProvider
      formDefinition={formDefinition},
      serverFormInfo={actionData?.serverFormInfo},
    >
      <Input name="firstName" label="First Name" />
      <Input name="middleInitial" label="Middle Name" />
      <Input name="lastName" label="Last Name" />
      <Input name="emailAddress" label="Email Address" />
    </FormProvider>
  );
}

That's it!

You've now got a real-time client-side validated form wired up with your rock-solid server validations!

Advanced Usages and Concepts

ExtendedValidityState

Internally, we use what we call an ExtendedValidityState data structure which is the same format as ValidityState, plus any additional custom validations. This looks like the following:

let extendedValidityState = {
  badInput: false, // currently unused
  customError: false, // currently unused
  rangeOverflow: false, // Did we fail 'max'?
  rangeUnderflow: false, // Did we fail 'min'?
  patternMismatch: false, // Did we fail 'pattern'?
  stepMismatch: false, // Did we fail 'step'?
  tooLong: false, // Did we fail 'maxlength'?
  tooShort: false, // Did we fail 'minlength'?
  typeMismatch: false, // Did we fail 'type'?
  valueMissing: false, // Did we fail 'required'?
  valid: true, // Is the input valid?

  // Custom validations are appended directly in here as well!
  uniqueEmail: false, // Did we fail the unique email check?
};

Multiple Inputs with the Same Name

It's totally valid in HTML to submit multiple inputs with the same name, and they end up in FormData and can be accessed as an array using FormData.getAll(). To support this, our serverFormInfo.inputs and serverFormInfo.submittedValues types are both either a value or an array depending on whether we encountered multiple input values for a given name.

// consider submitting the following inputs:
<input name="username" value="brophdawg11" />
<input name="hobbies" value="golf" />
<input name="hobbies" value="skiing" />
<input name="hobbies" value="coding" />

// In your action:
async function action({ request }) {
  let formData = await request.formData();
  let serverFormInfo = validateServerFormInfo(formData, formDefinition);
  // serverFormInfo will have the shape:
  // {
  //    submittedValues: {
  //      username: 'brophdawg11',
  //      hobbies: ['golf', 'skiing', 'coding']
  //    },
  //    inputs: {
  //      userName: InputInfo,
  //      hobbies: [InputInfo, InputInfo, InputInfo]
  //    }
  // }

Dynamic (Form-Dependent) Validation Attributes

Most of the time, your built-in validation attributes will be static (required: true or maxLength: 30 etc.). However, sometimes you need the validation attribute to be dependent on the current value of another input in the form. Consider 2 numeric inputs: low and high. If low has a value, then high sets it's min validation attribute to the value of low and vice versa:

let formDefinition: FormSchema = {
  inputs: {
    low: {
      validationAttrs: {
        type: "number",
        max: (fd) => (fd.get("high") ? Number(fd.get("high")) : undefined),
      },
    },
    high: {
      validationAttrs: {
        type: "number",
        min: (fd) => (fd.get("low") ? Number(fd.get("low")) : undefined),
      },
    },
  },
};

In order for dynamic/form-dependent validations like this to work reliably, we have to be able to update one input when the value of another input changes. By default, useValidatedInput and <Input> are scoped to a single input. So if you are using dynamic built-in validations then you should provide a <FormProvider formRef> property with a ref to your form element, that way the library can listen for change events and update dependent validations accordingly.

Custom Validations

Custom validations are implemented as a sync or async function returning a boolean, and you add them directly into your formDefinition object alongside where you define HTML validations:

const formDefinition: FormSchema = {
  inputs: {
    emailAddress: {
      validationAttrs: {
        required: true,
        maxLength: 50,
      },
      customValidations: {
        async uniqueEmail(value) {
            let res = await fetch(...);
            let data = await res.json();
            return data.isUnique === true;
        },
      }
  },
}

Server-only Validations

While this library tries to lean-into shared validations between client and server, there are also good reasons not to share validations entirely. Most of the time, this comes down to keeping client-bundles small and/or needing direct server or DB access for certain validations.

One approach is to just perform server-only validations manually after calling validateServerFormInfo:

import { validateServerFormData } from "remix-validity-state";

export async function action({ request }: ActionArgs) {
  const formData = await request.formData();
  const serverFormInfo = await validateServerFormData(
    formData,
    formDefinitions
  );
  if (!serverFormInfo.valid) {
    return json({ serverFormInfo });
  }

  // Now that we know our shared validations passed, we can perform more complex validations
  let isEmailUnique = await checkEmail(
    serverFormInfo.submittedValues.emailAddress
  );
  if (!isEmailUnique) {
    return json({
      serverFormInfo,
      errors: { email: "Email address is not unique " },
    });
  }
  // ...
}

This may be sufficient in some cases, but also now requires you to support a new error messaging UI separate from the one already handled via <Input> and/or provided by useValidatedInput().info.errorMessages.

To support this common use-case, you can pass a set of customValidations server-only implementations to validateServerFormData, which will be used instead of the validations you define in the shared formDefinition. Usually, you'll just put a stub () => true in your shared validations so the client is aware of the validation.

import type { ServerOnlyCustomValidations } from 'remix-validity-state'
import { validateServerFormData } from 'remix-validity-state'

let formDefinition: FormSchema = {
  inputs: {
    emailAddress: {
      validationAttrs: {
        type: "email",
        required: true,
      },
      customValidations: {
        // always "valid" in shared validations
        uniqueEmail: () => true,
      },
      errorMessages: {
        uniqueEmail: () => 'Email address already in use!'',
      },
    },
  }
};

const serverCustomValidations: ServerOnlyCustomValidations<FormSchema> = {
  emailAddress: {
    async uniqueEmail(value) {
      let isUnique = await checkEmail(value);
      return isUnique;
    },
  },
};

export async function action({ request }: ActionArgs) {
  const formData = await request.formData();
  const serverFormInfo = await validateServerFormData(
    formData,
    formDefinitions,
    serverCustomValidations
  );
  // serverFormInfo.valid here will be reflective of the custom server-only
  // validation and will leverage your shared `errorMessages`
  if (!serverFormInfo.valid) {
    return json({ serverFormInfo });
  }
  // ...
}

Error Messages

Basic error messaging is handled out of the box by <Input> for built-in HTML validations. If you are using custom validations, or if you want to override the built-in messaging, you can provide custom error messages in our formDefinition. Custom error messages can either be a static string, or a function that receives the attribute value (built-in validations only), the input name, and the input value:

const formDefinition: FormSchema = {
  inputs: { ... },
  errorMessages: {
    valueMissing: "This field is required",
    tooLong: (attrValue, name, value) =>
      `The ${name} field can only be up to ${attrValue} characters, ` +
      `but you have entered ${value.length}`,
    uniqueEmail: (_, name, value) =>
      `The email address ${value} is already taken`,
  }
};

You can also provide field-specific error messages if needed, whioch will override the global error messages:

const formDefinition: FormSchema = {
  inputs: {
    firstName: {
      validationAttrs: { ... },
      errorMessages: {
        valueMissing: "Please enter a last name!",
      }
    }
  },
  errorMessages: {
    valueMissing: "This field is required",
  }
};

useValidatedInput()

This is the bread and butter of the library - and <Input> is really nothing more than a wrapper around this hook. This is useful if you require more control over the direct rendering of your input, label or error elements. Let's take a look at what it gives you. The only required input is the input name:

let { info, ref, getInputAttrs, getLabelAttrs, getErrorsAttrs } =
  useValidatedInput({
    name: "firstName",
  });

The returned info value is of the following structure:

interface InputInfo {
  // The current value of this input
  value: string | null;
  // Has this input been blur'd?
  touched: boolean;
  // Has this input value changed?
  dirty: boolean;
  // Validation state, 'idle' to start and 'validating' during any
  // custom async validations
  state: "idle" | "validating" | "done";
  // The current validity state of our input
  validity?: ExtendedValidityState;
  // Map of ExtendedValidityState validation name -> error message for all current errors
  errorMessages?: Record<string, string>;
}

validity contains the current validation state of the input. Most notably validity.valid, tells you if the input is in a valid state.

errorMessages is present if the input is invalid, and contains the error messages that should be displayed to the user (keyed by the validation name in validity):

{
  tooLong: 'The email field can only be up to 50 characters, but you have entered 60',
  uniqueEmail: 'The email address john@doe.com is already taken',
}

getInputAttrs, getLabelAttrs, and getErrorsAttrs are prop getters that allow you to render you own custom <input>/<label> elements and error displays, while handling all of the validation attrs, id, for, aria-*, and other relevant attribute for your form markup.

Let's look at an example usage:

<div>
  <label {...getLabelAttrs()}>Email Address*</label>
  <input {...getInputAttrs()} />
  {info.touched && info.errorMessages ? (
    <ul {...getErrorsAttrs()}>
      {Object.values(info.errorMessages).map((msg) => (
        <li key={msg}>🆘 {msg}</li>
      ))}
    </ul>
  ) : null}
</div>

useValidatedInput can also be used without a FormProvider for formDefinition and serverFormInfo if necessary:

let { info } = useValidatedInput({
  name: "emailAddress",
  formDefinition,
  serverFormInfo,
});

Custom ref usage

Most of the time, you shouldn't need a ref for your <input> elements, since the library will take care of things under the hood for you. However, if you need one, we return the underlying ref from useValidatedInput() for consumption:

let ctx = useValidatedInput({ name: "firstName" });
// ctx.ref contains the ref

// You do not need to assign the ref here, that's done via getInputAttrs
return <input {...ctx.getInputAttrs()} />;

If that's not enough, you can also pass your own custom ref as an argument and it will be composed together with the internal ref:

let ref = React.useRef(null);
let ctx = useValidatedInput({ name: "firstName", ref });

// You still do not need to assign the ref here, that's done via getInputAttrs
return <input {...ctx.getInputAttrs()} />;

Textarea and Select Elements

Other control types work just like <input> but use a different type and are identified with an element prop. This allows for differentiation under the hood and proper type inference on the validation attributes allowed for different elements.

interface FormSchema {
  inputs: {
    biography: TextAreaDefinition;
    country: SelectDefinition;
  };
}

let formDefinition: FormSchema = {
  inputs: {
    biography: {
      element: "textarea",
      validationAttrs: {
        required: true,
        maxLength: 500,
      },
    },
    country: {
      element: "select",
      validationAttrs: {
        required: true,
      },
    },
  },
};

Radio and Checkbox Inputs

Radio and Checkbox inputs are unique in that they generally have multiple inputs of the same name and validation is dependent upon the state of all of the inputs.

interface FormSchema {
  inputs: {
    skills: InputDefinition;
    favoriteFood: InputDefinition;
  };
}

let formDefinition: FormSchema = {
  inputs: {
    programmingLanguages: {
      validationAttrs: {
        type: "checkbox",
        required: true,
        maxLength: 500,
      },
    },
    favoriteFood: {
      validationAttrs: {
        type: "radio",
        required: true,
      },
    },
  },
};

Because validation is across the group of them, it's not recommended to use the <Input> component, because that by default renders errors per-input. We really want them for the group of inputs. It's recommended to call useValidatedInput() manually for these scenarios:

function FavoriteSkill() {
  let skills = ["React", "Vue", "Preact", "Angular", "Svelte", "Solid", "Qwik"];
  let { info, getInputAttrs, getLabelAttrs, getErrorsAttrs } =
    useValidatedInput<typeof formDefinition>({ name: "skills" });

  // Since we'll share these attributes across all checkboxes we call these
  // once here to avoid calling per-input.  And since we put the input inside
  // the label we don't need the `for` attribute
  let labelAttrs = getLabelAttrs({ htmlFor: undefined });
  let inputAttrs = getInputAttrs();

  return (
    <fieldset>
      <legend>Which skills do you have?</legend>

      {/* Render checkboxes for each skill, making the id unique based on the skill */}
      {skills.map((s) => (
        <label key={s} {...labelAttrs}>
          <input
            {...{ ...inputAttrs, id: `${inputAttrs.id}--${s.toLowerCase()}` }}
          />
          &nbsp;
          {s}
        </label>
      ))}

      {/* Render errors once for the group of inputs */}
      {info.touched && info.errorMessages ? (
        <ul {...getErrorsAttrs()}>
          {Object.entries(info.errorMessages).map(([validation, msg]) => (
            <li key={validation}>🆘 {msg}</li>
          ))}
        </ul>
      ) : null}
    </fieldset>
  );
}

Styling

This library aims to be pretty hands-off when it comes to styling, since every use-case is so different. We expect most consumers will choose to create their own custom markup with direct usage of useValidatedInput. However, for simple use-cases of <Input> we expose a handful of stateful classes on the elements you may hook into with your own custom styles:

  • rvs-label - added to the built-in <label> element
    • rvs-label--touched - present when the input has been blur'd
    • rvs-label--dirty - present when the input has been changed
    • rvs-label--invalid - present when the input is invalid
    • rvs-label--validating - present when the input is processing async validations
  • rvs-input - added to the built-in <input> element
    • rvs-input--touched - present when the input has been blur'd
    • rvs-input--dirty - present when the input has been changed
    • rvs-input--invalid - present when the input is invalid
    • rvs-input--validating - present when the input is processing async validations
  • rvs-validating - present on the <p> tag that displays a Validating... message during async validation
  • rvs-errors - added to the built-in errors list <ul> element

Typescript

Now, I'm no TypeScript wizard but I have tried to make this library TypeScript friendly, and even got some good feature requests early on (thanks Kent for #7 and #9!). Hopefully over time the types will improve further, but at the moment here's the best way to get type safety and inference.

// Define an interface for your validations thats adheres to the shape of FormDefinition
interface FormSchema {
  inputs: {
    firstName: InputDefinition;
    lastName: InputDefinition;
  }
}

// Create your form definition
const formDefinition: FormSchema = {
  inputs: {
    firstName: {
      validationattrs: { required: true },
    },
    lastName: {
      validationattrs: { required: true },
    },
  }
}

// When passing formDefinition to context/hooks it will automatically infer
// your types:
<FormProvider formDefinition={formDefinition} />
useValidatedInput({ name: "firstName", formDefinition });

// Or if you are using useValidatedInput inside the context, you'll need to
// use the generic signature:
useValidatedInput<FormSchema>({ name: 'firstName' });

// Finally, the return type of validateServerFormData will have serverFormInfo.inputs
// and serverFormInfo.submittedValues properly typed with your fields

Feedback + Contributing

Feedback is absolutely welcomed! This is a bit of a side hobby for me - as I've built plenty of forms over the years and I've never been particularly satisfied with the libraries available. So this is somewhat of an attempt to build my ideal validation library - and I would love ideas that could improve it. So please feel free to file issues, opens PRs, etc.

Here's a few guidelines if you choose to contribute!

  • Find a bug? Please file an Issue with a minimal reproduction. Ideally a working example in stackblitz/codesandbox/etc., but sample code can suffice in many cases as well.
  • Fix a bug? You rock 🙌 - please open a PR.
  • Have a feature idea? Please open feature requests as a Discussion so we can use the forum there to come up with a solid API.
import * as React from "react";
////////////////////////////////////////////////////////////////////////////////
//#region Types
export type PartialRecord<K extends keyof any, T> = Partial<Record<K, T>>;
// Remove readonly modifiers from existing types to make them mutable
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
// Restrict object keys to strings, and don't permit number/Symbol
type KeyOf<T> = Extract<keyof T, string>;
type SupportedControlTypes = "input" | "textarea" | "select";
type SupportedHTMLElements =
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement;
/**
* Validation attributes built-in to the browser
*/
type BuiltInValidationAttr =
| "type"
| "required"
| "minLength"
| "maxLength"
| "min"
| "max"
| "pattern";
// Dynamic attribute value function to set validation attribute based on
// a current input value
type BuiltInValidationAttrsFunction<T> = (fd: FormData) => T | null | undefined;
// Types accepted for validation attributes
type BuiltInValidationAttrString =
| string
| BuiltInValidationAttrsFunction<string>;
type BuiltInValidationAttrNumber =
| number
| BuiltInValidationAttrsFunction<number>;
type BuiltInValidationAttrBoolean =
| boolean
| BuiltInValidationAttrsFunction<boolean>;
type BuiltInValidationAttrValue =
| BuiltInValidationAttrString
| BuiltInValidationAttrNumber
| BuiltInValidationAttrBoolean;
// Valid attributes by input type. See:
// https://html.spec.whatwg.org/multipage/input.html#do-not-apply
type InputTextValidationAttrs = {
type?: "text" | "search" | "url" | "tel" | "password";
required?: BuiltInValidationAttrBoolean;
minLength?: BuiltInValidationAttrNumber;
maxLength?: BuiltInValidationAttrNumber;
pattern?: BuiltInValidationAttrString;
};
type InputEmailValidationAttrs = {
type: "email";
multiple?: boolean;
required?: BuiltInValidationAttrBoolean;
minLength?: BuiltInValidationAttrNumber;
maxLength?: BuiltInValidationAttrNumber;
pattern?: BuiltInValidationAttrString;
};
type InputDateValidationAttrs = {
type: "date" | "month" | "week" | "time" | "datetime-local";
required?: BuiltInValidationAttrBoolean;
min?: BuiltInValidationAttrString;
max?: BuiltInValidationAttrString;
};
type InputNumberValidationAttrs = {
type: "number";
required?: BuiltInValidationAttrBoolean;
min?: BuiltInValidationAttrNumber;
max?: BuiltInValidationAttrNumber;
};
type InputRangeValidationAttrs = {
type: "range";
min?: BuiltInValidationAttrNumber;
max?: BuiltInValidationAttrNumber;
};
type InputCheckboxValidationAttrs = {
type: "checkbox";
required?: BuiltInValidationAttrBoolean;
};
type InputRadioValidationAttrs = {
type: "radio";
required?: BuiltInValidationAttrBoolean;
};
type TextAreaValidationAttrs = {
required?: BuiltInValidationAttrBoolean;
minLength?: BuiltInValidationAttrNumber;
maxLength?: BuiltInValidationAttrNumber;
};
type SelectValidationAttrs = {
required?: BuiltInValidationAttrBoolean;
multiple?: boolean;
};
type InputValidationAttrs =
| InputTextValidationAttrs
| InputEmailValidationAttrs
| InputDateValidationAttrs
| InputNumberValidationAttrs
| InputRangeValidationAttrs
| InputCheckboxValidationAttrs
| InputRadioValidationAttrs;
type ValidityStateKey = KeyOf<
Pick<
ValidityState,
| "typeMismatch"
| "valueMissing"
| "tooShort"
| "tooLong"
| "rangeUnderflow"
| "rangeOverflow"
| "patternMismatch"
>
>;
/**
* Custom validation function
*/
export interface CustomValidations {
[key: string]: (
val: string,
formData?: FormData
) => boolean | Promise<boolean>;
}
/**
* Error message - static string or () => string
*/
export type ErrorMessage =
| string
| ((attrValue: string | undefined, name: string, value: string) => string);
/**
* Definition for a single input in a form (validations + error messages)
*/
interface BaseControlDefinition {
customValidations?: CustomValidations;
errorMessages?: {
[key: string]: ErrorMessage;
};
multiple?: boolean;
}
export interface InputDefinition extends BaseControlDefinition {
element?: "input";
validationAttrs?: InputValidationAttrs;
}
export interface TextAreaDefinition extends BaseControlDefinition {
element: "textarea";
validationAttrs?: TextAreaValidationAttrs;
}
export interface SelectDefinition extends BaseControlDefinition {
element: "select";
validationAttrs?: SelectValidationAttrs;
}
type ControlDefinition =
| InputDefinition
| TextAreaDefinition
| SelectDefinition;
/**
* Form information (inputs, validations, error messages)
*/
export interface FormDefinition {
inputs: {
[key: string]: ControlDefinition;
};
errorMessages?: {
[key: string]: ErrorMessage;
};
}
/**
* Mutable version of ValidityState that we can write to
*/
type MutableValidityState = Mutable<ValidityState>;
/**
* Extended ValidityState which weill also contain our custom validations
*/
export type ExtendedValidityState = MutableValidityState &
Record<string, boolean>;
export type AsyncValidationState = "idle" | "validating" | "done";
/**
* Client-side state of the input
*/
export interface InputInfo {
value: string | null;
touched: boolean;
dirty: boolean;
state: AsyncValidationState;
validity?: ExtendedValidityState;
errorMessages?: Record<string, string>;
}
export type ServerOnlyCustomValidations<T extends FormDefinition> = Partial<{
[key in KeyOf<T["inputs"]>]: CustomValidations;
}>;
// Server-side only (currently) - validate all specified inputs in the formData
export type ServerFormInfo<
FormDef extends FormDefinition,
FormDefInputs extends FormDef["inputs"] = FormDef["inputs"]
> = {
submittedValues: {
[Key in KeyOf<FormDefInputs>]: FormDefInputs[Key]["element"] extends "textarea"
? FormDefInputs[Key]["multiple"] extends true
? string[]
: string
: FormDefInputs[Key]["element"] extends "select"
? FormDefInputs[Key]["multiple"] extends true
? string[]
: FormDefInputs[Key] extends { validationAttrs: object }
? FormDefInputs[Key]["validationAttrs"] extends { multiple: true }
? string[]
: string
: string
: FormDefInputs[Key] extends { validationAttrs: object }
? FormDefInputs[Key]["validationAttrs"] extends { type: "checkbox" }
? FormDefInputs[Key]["validationAttrs"] extends { required: true }
? string[]
: string[] | null
: FormDefInputs[Key]["validationAttrs"] extends { type: "email" }
? FormDefInputs[Key]["multiple"] extends true
? string[]
: FormDefInputs[Key]["validationAttrs"] extends { multiple: true }
? string[]
: string
: FormDefInputs[Key]["multiple"] extends true
? string[]
: string
: FormDefInputs[Key]["multiple"] extends true
? string[]
: string;
};
inputs: Record<KeyOf<FormDefInputs>, InputInfo | InputInfo[]>;
valid: boolean;
};
/**
* Validator to link HTML attribute to ValidityState key as well as provide an
* implementation for server side validation
*/
interface BuiltInValidator {
domKey: ValidityStateKey;
validate(value: string, attrValue: string): boolean;
errorMessage: ErrorMessage;
}
interface FormContextObject<T extends FormDefinition> {
formDefinition: T;
serverFormInfo?: ServerFormInfo<T>;
forceUpdate: any;
}
/**
* See https://github.com/reach/reach-ui/blob/v0.17.0/packages/utils/src/types.ts#L9
*/
type AssignableRef<ValueType> =
| React.RefCallback<ValueType>
| React.MutableRefObject<ValueType | null>;
//#endregion
////////////////////////////////////////////////////////////////////////////////
//#region Constants + Utils
// Map of ValidityState key -> HTML attribute (i.e., valueMissing -> required)
const builtInValidityToAttrMapping: Record<
ValidityStateKey,
BuiltInValidationAttr
> = {
typeMismatch: "type",
valueMissing: "required",
tooShort: "minLength",
tooLong: "maxLength",
rangeUnderflow: "min",
rangeOverflow: "max",
patternMismatch: "pattern",
};
// Directly from the spec - please do not file issues or submit PRs to change
// this unless it becomes out of sync with the spec.
// https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
const EMAIL_REGEX =
// eslint-disable-next-line no-useless-escape
/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
// Mimic browser built-in validations so we can run the on the server
const builtInValidations: Record<BuiltInValidationAttr, BuiltInValidator> = {
type: {
domKey: "typeMismatch",
validate: (value, attrValue): boolean => {
if (value.length === 0) {
return true;
}
if (attrValue === "email") {
return EMAIL_REGEX.test(value);
}
if (attrValue === "url") {
try {
// URL is available globally in node and most server-worker-API based
// environments. If URL is not available globally in your runtime
// you'll need to polyfill it
// Note - this is fairly lenient but seems to match browser behavior.
// For example, http:/something passes and the resulting url.href is
// normalized to http://something. It's tempting to do something like
// `return new URL(value).href === value` to make sure the incoming URL
// doesn't require normalization but that seems to deviate from built-in
// browser behavior. Plus then we need to deal with normalized trailing
// slashes and such as well. Unsure how deep that rabbit hole might go
// so this is fine for now.
new URL(value);
} catch (e) {
return false;
}
}
// email/url are the only types with intrinsic constraints
// https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation#semantic_input_types
return true;
},
errorMessage: (attrValue) => {
let messages: Record<string, string> = {
date: "Invalid date",
email: "Invalid email",
number: "Invalid number",
tel: "Invalid phone number",
url: "Invalid URL",
};
return (attrValue ? messages[attrValue] : null) || "Invalid value";
},
},
required: {
domKey: "valueMissing",
validate: (value) => value.length > 0,
errorMessage: () => `Field is required`,
},
minLength: {
domKey: "tooShort",
validate: (value, attrValue) =>
value.length === 0 || value.length >= Number(attrValue),
errorMessage: (attrValue) =>
`Value must be at least ${attrValue} characters`,
},
maxLength: {
domKey: "tooLong",
validate: (value, attrValue) =>
value.length === 0 || value.length <= Number(attrValue),
errorMessage: (attrValue) =>
`Value must be at most ${attrValue} characters`,
},
min: {
domKey: "rangeUnderflow",
validate: (value, attrValue) =>
value.length === 0 || Number(value) >= Number(attrValue),
errorMessage: (attrValue) =>
`Value must be greater than or equal to ${attrValue}`,
},
max: {
domKey: "rangeOverflow",
validate: (value, attrValue) =>
value.length === 0 || Number(value) <= Number(attrValue),
errorMessage: (attrValue) =>
`Value must be less than or equal to ${attrValue}`,
},
pattern: {
domKey: "patternMismatch",
validate: (value, attrValue) =>
value.length === 0 || new RegExp(attrValue).test(value),
errorMessage: () => `Value does not match the expected pattern`,
},
};
function invariant(value: boolean, message?: string): asserts value;
function invariant<T>(
value: T | null | undefined,
message?: string
): asserts value is T;
function invariant(value: any, message?: string) {
if (value === false || value === null || typeof value === "undefined") {
throw new Error(message);
}
}
function assignRef<RefValueType = unknown>(
ref: AssignableRef<RefValueType> | null | undefined,
value: RefValueType
) {
if (ref == null) return;
if (typeof ref === "function") {
ref(value);
} else {
try {
ref.current = value;
} catch (error) {
throw new Error(`Cannot assign value "${value}" to ref "${ref}"`);
}
}
}
function useComposedRefs<RefValueType = unknown>(
...refs: (AssignableRef<RefValueType> | null | undefined)[]
): React.RefCallback<RefValueType> {
return React.useCallback((node) => {
for (let ref of refs) {
assignRef(ref, node);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, refs);
}
const getInputId = (name: string, reactId: string) => `${name}--${reactId}`;
const getErrorsId = (name: string, reactId: string) =>
`${name}-errors--${reactId}`;
const composeClassNames = (classes: Array<string | undefined>) =>
classes.filter((v) => v).join(" ");
const omit = (
obj: Record<string, any>,
...keys: string[]
): Record<string, any> =>
Object.entries(obj)
.filter(([k]) => !keys.includes(k))
.reduce((acc, [k, v]) => Object.assign(acc, { [k]: v }), {});
function getBaseValidityState(): ExtendedValidityState {
return {
badInput: false,
customError: false,
rangeOverflow: false, // max
rangeUnderflow: false, // min
patternMismatch: false, // pattern
stepMismatch: false, // step
tooLong: false, // maxlength
tooShort: false, // minlength
typeMismatch: false, // type="..."
valueMissing: false, // required
// Is the input valid?
valid: true,
};
}
function IsInputDefinition(
inputDef: ControlDefinition
): inputDef is InputDefinition {
return inputDef.element === "input" || inputDef.element == null;
}
// Perform all specified html validations for a single input
// Called in a useEffect client side and from validateServerFormIno server-side
async function validateInput(
inputName: string,
validationAttrs: ControlDefinition["validationAttrs"],
customValidations: ControlDefinition["customValidations"],
value: string,
inputEl?: SupportedHTMLElements | SupportedHTMLElements[], // CSR
formData?: FormData // SSR
): Promise<ExtendedValidityState> {
let validity = getBaseValidityState();
if (!formData) {
let formEl = Array.isArray(inputEl) ? inputEl[0]?.form : inputEl?.form;
invariant(
formEl,
`validateInput expected an inputEl.form to be available for input "${inputName}"`
);
formData = new FormData(formEl);
}
if (validationAttrs) {
for (let _attr of Object.keys(validationAttrs)) {
let attr = _attr as BuiltInValidationAttr;
// Ignoring this "error" since the type narrowing to accomplish this
// would be nasty due to the differences in attribute values per input
// type. We're going to rely on the *ValidationAttrs types to ensure
// users are specifying valid attributes up front in their schemas and
// just yolo this lookup
// @ts-expect-error
let _attrValue = validationAttrs[attr] || null;
let attrValue = calculateValidationAttr(_attrValue, formData);
// Undefined attr values means the attribute doesn't exist and there's
// nothing to validate
if (attrValue == null) {
continue;
}
let builtInValidation = builtInValidations[attr];
let isInvalid = false;
let isElInvalid = (el?: SupportedHTMLElements) =>
el?.validity
? el?.validity[builtInValidation.domKey]
: !builtInValidation.validate(value, String(attrValue));
if (Array.isArray(inputEl)) {
isInvalid = inputEl.every((el) => isElInvalid(el));
} else {
isInvalid = isElInvalid(inputEl);
}
validity[builtInValidation?.domKey || attr] = isInvalid;
validity.valid = validity.valid && !isInvalid;
}
}
if (customValidations) {
for (let name of Object.keys(customValidations)) {
let validate = customValidations[name];
let isInvalid = !(await validate(value, formData));
validity[name] = isInvalid;
validity.valid = validity.valid && !isInvalid;
}
}
return validity;
}
// Perform all validations for a submitted form on the server
export async function validateServerFormData<T extends FormDefinition>(
formData: FormData,
formDefinition: T,
serverCustomValidations?: ServerOnlyCustomValidations<T>
): Promise<ServerFormInfo<T>> {
// Unsure if there's a better way to do this type of object mapping while
// keeping the keys strongly typed - but this currently complains since we
// haven't filled in the required keys yet
// @ts-expect-error
const inputs: ServerFormInfo["inputs"] = {};
const submittedValues = {} as ServerFormInfo<T>["submittedValues"];
let valid = true;
let entries = Object.entries(formDefinition.inputs) as Array<
[KeyOf<T["inputs"]>, ControlDefinition]
>;
await Promise.all(
entries.map(async ([inputName, inputDef]) => {
if (!formData.has(inputName)) {
// No values submitted
let inputInfo: InputInfo = {
value: null,
touched: true,
dirty: true,
state: "done",
validity: await validateInput(
inputName,
inputDef.validationAttrs,
inputDef.customValidations,
"",
undefined,
formData
),
};
inputs[inputName] = inputInfo;
// FIXME: ???
// @ts-expect-error
submittedValues[inputName] = null;
valid = valid && inputInfo.validity?.valid === true;
} else if (
// Multiple input controls rendered
// <input name="thing">
// <input name="thing">
inputDef.multiple ||
// <select name="thing" multiple>
(inputDef.element === "select" && inputDef.validationAttrs?.multiple) ||
// <input type="email" name="thing" multiple>
((inputDef.element == null || inputDef.element === "input") &&
inputDef.validationAttrs?.type === "email" &&
inputDef.validationAttrs?.multiple) ||
// Checkboxes are handled slightly different from normal "render multiple
// input controls" since they're inherently "choose one or more" behavior
// like a <select multiple>.
// <input type="checkbox" name="thing">
// <input type="checkbox" name="thing">
((inputDef.element == null || inputDef.element === "input") &&
inputDef.validationAttrs?.type === "checkbox")
) {
// This input can have multiple values submitted for the same name,
// so use getAll() and store in a string[]
let values = formData.getAll(inputName);
for (let value of values) {
if (typeof value === "string") {
// Always assume inputs have been modified during SSR validation
let inputInfo: InputInfo = {
value,
touched: true,
dirty: true,
state: "done",
validity: await validateInput(
inputName,
inputDef.validationAttrs,
{
...inputDef.customValidations,
...serverCustomValidations?.[inputName],
},
value,
undefined,
formData
),
};
if (Array.isArray(submittedValues[inputName])) {
// @ts-expect-error
submittedValues[inputName].push(value);
} else {
// @ts-expect-error
submittedValues[inputName] = [value];
}
if (Array.isArray(inputs[inputName])) {
inputs[inputName].push(inputInfo);
} else {
inputs[inputName] = [inputInfo];
}
valid = valid && inputInfo.validity?.valid === true;
} else {
console.warn(
`Skipping non-string value in FormData for field [${inputName}]`
);
}
}
} else {
let value = formData.get(inputName);
if (typeof value === "string") {
// Single value input
let inputInfo: InputInfo = {
value,
touched: true,
dirty: true,
state: "done",
validity: await validateInput(
inputName,
inputDef.validationAttrs,
{
...inputDef.customValidations,
...serverCustomValidations?.[inputName],
},
value,
undefined,
formData
),
};
inputs[inputName] = inputInfo;
// FIXME: ???
// @ts-expect-error
submittedValues[inputName] = value;
valid = valid && inputInfo.validity?.valid === true;
} else {
console.warn(
`Skipping non-string value in FormData for field [${inputName}]`
);
}
}
})
);
return { submittedValues, inputs, valid };
}
// Determine the defaultValue for a rendered input, properly handling inputs
// with multiple values
function getInputDefaultValue<T extends FormDefinition>(
name: string,
type?: InputValidationAttrs["type"],
serverFormInfo?: ServerFormInfo<T>,
index?: number
) {
let submittedValue = serverFormInfo?.submittedValues?.[name];
if (type === "checkbox") {
return undefined;
} else if (Array.isArray(submittedValue)) {
invariant(
index != null && index >= 0,
`Expected an "index" value for multiple-submission field "${name}"`
);
return submittedValue[index];
} else if (typeof submittedValue === "string") {
return submittedValue;
}
}
// Generate a FormData object from our submittedValues structure. This is
// needed for the _initial_ render after a document POST submission where we
// don't yet have an input ref to access the <form>, but we need a FormData
// instance to determine the initial validation attribute values (in case any
// are dynamic). So we can re-construct from what we just submitted.
// Subsequent renders then use ne wFormData(inputRef.current.form)
function generateFormDataFromServerFormInfo<T extends FormDefinition>(
submittedValues: ServerFormInfo<T>["submittedValues"]
) {
let formData = new FormData();
Object.keys(submittedValues).forEach((k) => {
let v = submittedValues[k];
if (Array.isArray(v)) {
v.forEach((v2) => formData.append(k, v2));
} else if (typeof v === "string") {
formData.set(k, v);
}
});
return formData;
}
// Calculate a single validation attribute value to render onto an individual input
function calculateValidationAttr(
attrValue: BuiltInValidationAttrValue,
formData: FormData
) {
return typeof attrValue === "function" ? attrValue(formData) : attrValue;
}
// Calculate the validation attribute values to render onto an individual input
function calculateValidationAttrs(
validationAttrs: ControlDefinition["validationAttrs"],
formData: FormData
) {
let entries = Object.entries(validationAttrs || {}) as [
BuiltInValidationAttr,
BuiltInValidationAttrValue
][];
return entries.reduce((acc, [attrName, attrValue]) => {
let value = calculateValidationAttr(attrValue, formData);
if (value != null) {
acc[attrName] = value;
}
return acc;
}, {} as Record<string, string | number | boolean>);
}
// Does our form have any dynamic attribute values that require re-evaluation
// on all form changes?
function hasDynamicAttributes(formDefinition: FormDefinition) {
return Object.values(formDefinition.inputs).some((inputDef) =>
Object.values(inputDef.validationAttrs || {}).some(
(attr) => typeof attr === "function"
)
);
}
function getClasses(
info: InputInfo,
type: "label" | SupportedControlTypes,
className?: string
) {
let { validity, state, touched, dirty } = info;
return composeClassNames([
`rvs-${type}`,
shouldShowErrors(validity, state, touched) ? `rvs-${type}--invalid` : "",
state === "validating" ? `rvs-${type}--validating` : "",
touched ? `rvs-${type}--touched` : "",
dirty ? `rvs-${type}--dirty` : "",
className,
]);
}
function shouldShowErrors(
validity: ExtendedValidityState | undefined,
state: AsyncValidationState,
touched: boolean
) {
return validity?.valid === false && state === "done" && touched;
}
// Get attributes shared across input/textarea/select elements
function getControlAttrs<T extends SupportedControlTypes>(
ctx: ReturnType<typeof useValidatedControl>,
controlType: T,
name?: string,
className?: string,
index?: number
) {
return {
ref: ctx.composedRef,
name: ctx.name,
id: getInputId(ctx.name, ctx.id),
className: getClasses(ctx.info, controlType, className),
defaultValue: getInputDefaultValue(
ctx.name,
controlType === "input" && "type" in ctx.validationAttrs
? (ctx.validationAttrs.type as InputValidationAttrs["type"])
: undefined,
ctx.serverFormInfo,
index
),
...(shouldShowErrors(ctx.info.validity, ctx.info.state, ctx.info.touched)
? {
"aria-invalid": true,
"aria-errormessage": getErrorsId(name || ctx.name, ctx.id),
}
: {}),
...ctx.validationAttrs,
};
}
// For checkbox/radio inputs, we need to listen across all inputs for the given
// name to update the validate as a group
function registerMultipleEventListeners(
inputEl: SupportedHTMLElements,
event: "blur" | "change" | "input",
handler: () => void
) {
let selector = `input[type="${inputEl.type}"][name="${inputEl.name}"]`;
Array.from(inputEl.form?.querySelectorAll(selector) || []).forEach((el) =>
el.addEventListener(event, handler)
);
return () => {
Array.from(inputEl?.form?.querySelectorAll(selector) || []).forEach((el) =>
el.removeEventListener(event, handler)
);
};
}
// Determine the current error messages to display based on the ExtendedValidityState
// On the initial client render, when we don't have a ref, we accept
// currentValidationAttrs. On subsequent renders we use the ref and read the
// up-to-date attribute value
function getCurrentErrorMessages<T extends FormDefinition>(
formDefinition: T,
inputName: KeyOf<T["inputs"]>,
inputValue: string,
validity?: ExtendedValidityState,
currentValidationAttrs?: Record<string, string | number | boolean>,
inputEl?: SupportedHTMLElements
) {
let messages = Object.entries(validity || {})
.filter((e) => e[0] !== "valid" && e[1])
.reduce((acc, [validation, valid]) => {
let attr = builtInValidityToAttrMapping[
validation as ValidityStateKey
] as BuiltInValidationAttr;
let message =
formDefinition?.inputs?.[inputName]?.errorMessages?.[validation] ||
formDefinition?.errorMessages?.[validation] ||
builtInValidations[attr]?.errorMessage;
if (typeof message === "function") {
let attrValue = inputEl
? inputEl.getAttribute(attr)
: currentValidationAttrs?.[attr];
message = message(
attrValue != null ? String(attrValue) : undefined,
inputName,
inputValue
);
}
return Object.assign(acc, {
[validation]: message,
});
}, {});
return Object.keys(messages).length > 0 ? messages : undefined;
}
//#endregion
////////////////////////////////////////////////////////////////////////////////
//#region Contexts + Components + Hooks
export const FormContext =
React.createContext<FormContextObject<FormDefinition> | null>(null);
export function useOptionalFormContext<
T extends FormDefinition
>(): FormContextObject<T> | null {
const context = React.useContext<FormContextObject<T>>(
FormContext as unknown as React.Context<FormContextObject<T>>
);
if (context) {
return context;
}
return null;
}
interface UseValidatedControlOpts<
T extends FormDefinition,
E extends SupportedHTMLElements
> {
name: KeyOf<T["inputs"]>;
formDefinition?: T;
serverFormInfo?: ServerFormInfo<T>;
ref?:
| React.ForwardedRef<E | null | undefined>
| React.Ref<E | null | undefined>;
index?: number;
forceUpdate?: any;
}
// Handle validations for a single form control
function useValidatedControl<
T extends FormDefinition,
E extends SupportedHTMLElements
>(opts: UseValidatedControlOpts<T, E>) {
let ctx = useOptionalFormContext<T>();
let name = opts.name;
let formDefinition = opts.formDefinition || ctx?.formDefinition;
let forceUpdate = opts.forceUpdate || ctx?.forceUpdate;
invariant(
formDefinition,
"useValidatedControl() must either be used inside a <FormProvider> " +
"or be passed a `formDefinition` object"
);
let inputDef = formDefinition.inputs[name];
invariant(
inputDef,
`useValidatedControl() could not find a corresponding definition ` +
`for the "${name}" input`
);
let serverFormInfo = opts.serverFormInfo || ctx?.serverFormInfo;
let wasSubmitted = false;
let serverValue: string | null = null;
let serverValidity: InputInfo["validity"] = undefined;
if (serverFormInfo != null) {
wasSubmitted = true;
let submittedValue = serverFormInfo.submittedValues[name];
let inputInfo = serverFormInfo.inputs[name];
if (
(inputDef.element == null || inputDef.element === "input") &&
inputDef.validationAttrs?.type === "checkbox"
) {
// Checkboxes aren't re-populated like others at the moment :/
} else if (Array.isArray(inputInfo) || Array.isArray(submittedValue)) {
invariant(
Array.isArray(inputInfo) && Array.isArray(submittedValue),
`Incompatible serverFormInfo structure for field "${name}"`
);
invariant(
opts.index != null && opts.index >= 0,
`Expected an "index" value for multiple-submission field "${name}"`
);
serverValue = submittedValue[opts.index];
serverValidity = inputInfo[opts.index].validity;
} else {
serverValue = submittedValue;
serverValidity = inputInfo.validity;
}
}
// Setup React state
// Need a ref to grab formData for attribute generation
let inputRef = React.useRef<E>(null);
let formData = inputRef.current?.form
? new FormData(inputRef.current.form)
: serverFormInfo
? generateFormDataFromServerFormInfo(serverFormInfo.submittedValues)
: new FormData();
let currentValidationAttrs = calculateValidationAttrs(
inputDef.validationAttrs,
formData
);
let id = React.useId();
let prevServerFormInfo = React.useRef<ServerFormInfo<T> | undefined>(
serverFormInfo
);
let composedRef = useComposedRefs(inputRef, opts.ref);
let [value, setValue] = React.useState(serverValue || "");
let [dirty, setDirty] = React.useState<boolean>(wasSubmitted);
let [touched, setTouched] = React.useState<boolean>(wasSubmitted);
let [validationState, setValidationState] = React.useState<
InputInfo["state"]
>(wasSubmitted ? "done" : "idle");
let [validity, setValidity] = React.useState<
InputInfo["validity"] | undefined
>(serverValidity);
let [currentErrorMessages, setCurrentErrorMessages] = React.useState<
Record<string, string> | undefined
>(() =>
getCurrentErrorMessages(
formDefinition!,
name,
value,
validity,
currentValidationAttrs,
undefined
)
);
let controller = React.useRef<AbortController | null>(null);
// Set InputInfo.touched on `blur` events
React.useEffect(() => {
let inputEl = inputRef.current;
if (!inputEl) {
return;
}
let inputType = IsInputDefinition(inputDef)
? inputDef.validationAttrs?.type
: null;
let handler = () => setTouched(true);
if (inputType === "checkbox" || inputType === "radio") {
return registerMultipleEventListeners(inputEl, "blur", handler);
}
inputEl.addEventListener("blur", handler);
return () => inputEl?.removeEventListener("blur", handler);
}, [inputDef, inputRef, name]);
// Set value and InputInfo.dirty on `input` events
React.useEffect(() => {
let inputEl = inputRef.current;
if (!inputEl) {
return;
}
let elementType = !IsInputDefinition(inputDef) ? inputDef.element : null;
let inputType = IsInputDefinition(inputDef)
? inputDef.validationAttrs?.type
: null;
let event: "change" | "input" =
elementType === "select" ||
inputType === "radio" ||
inputType === "checkbox"
? "change"
: "input";
let handler = function (this: E) {
setDirty(true);
setValue(this.value);
};
if (inputType === "checkbox" || inputType === "radio") {
return registerMultipleEventListeners(inputEl, event, handler);
}
inputEl.addEventListener(event, handler);
return () => inputEl?.removeEventListener(event, handler);
}, [inputDef]);
// Run validations on input value changes
React.useEffect(() => {
async function go() {
// If this is the first render after a server validation, consider us
// validated and mark dirty/touched to show errors. Then skip re-running
// validations on the client
if (prevServerFormInfo.current !== serverFormInfo) {
prevServerFormInfo.current = serverFormInfo;
setDirty(true);
setTouched(true);
setValidationState("done");
if (serverValidity) {
setValidity(serverValidity);
}
return;
}
// Abort any ongoing async validations
if (controller.current) {
controller.current.abort();
}
// Validate the input
if (!inputDef) {
setValidationState("done");
return;
}
let localController = new AbortController();
controller.current = localController;
setValidationState("validating");
let validity: ExtendedValidityState;
let inputType = IsInputDefinition(inputDef)
? inputDef.validationAttrs?.type
: null;
if (inputType === "radio" || inputType === "checkbox") {
validity = await validateInput(
name,
inputDef.validationAttrs,
inputDef.customValidations,
value,
Array.from(
inputRef.current?.form?.querySelectorAll(
`input[type="${inputType}"][name="${name}"]`
) || []
)
);
} else {
validity = await validateInput(
name,
inputDef.validationAttrs,
inputDef.customValidations,
value,
inputRef.current || undefined
);
}
if (localController.signal.aborted) {
return;
}
setValidationState("done");
setValidity(validity);
// Generate error messages based on the validations
if (validity?.valid === false) {
invariant(formDefinition, "No formDefinition available in useEffect");
invariant(
inputRef.current,
"Expected an input to be present for client-side error message generation"
);
let messages = getCurrentErrorMessages(
formDefinition,
name,
value,
validity,
undefined,
inputRef.current
);
setCurrentErrorMessages(messages);
} else {
setCurrentErrorMessages(undefined);
}
}
go().catch((e) => console.error("Error in validateInput useEffect", e));
return () => controller.current?.abort();
// Important: forceUpdate must remain included in the deps array for
// auto-revalidation on dynamic attribute value changes
}, [
forceUpdate,
formDefinition,
inputDef,
name,
serverFormInfo,
serverValidity,
value,
]);
let info: InputInfo = {
value,
dirty,
touched,
state: validationState,
validity,
errorMessages: currentErrorMessages,
};
// Provide the caller a prop getter to be spread onto the <label>
function getLabelAttrs({
...attrs
}: React.ComponentPropsWithoutRef<"label"> = {}): React.ComponentPropsWithoutRef<"label"> {
return {
className: getClasses(info, "label", attrs.className),
htmlFor: getInputId(name, id),
...omit(attrs, "className"),
};
}
// Provide the caller a prop getter to be spread onto the element containing
// their rendered validation errors
function getErrorsAttrs({
...attrs
}: React.ComponentPropsWithoutRef<"ul"> = {}): React.ComponentPropsWithoutRef<"ul"> {
return {
className: composeClassNames(["rvs-errors", attrs.className]),
id: getErrorsId(name, id),
...(shouldShowErrors(info.validity, info.state, info.touched)
? { role: "alert" }
: {}),
...omit(attrs, "className"),
};
}
return {
name,
id,
validationAttrs: currentValidationAttrs,
ref: inputRef,
composedRef,
info,
serverFormInfo,
controller,
getLabelAttrs,
getErrorsAttrs,
};
}
interface UseValidatedInputOpts<T extends FormDefinition> {
name: KeyOf<T["inputs"]>;
formDefinition?: T;
serverFormInfo?: ServerFormInfo<T>;
ref?:
| React.ForwardedRef<HTMLInputElement | null | undefined>
| React.Ref<HTMLInputElement | null | undefined>;
index?: number;
forceUpdate?: any;
}
// User-facing useValidatedControl wrapper for <input> elements
export function useValidatedInput<T extends FormDefinition>(
opts: UseValidatedInputOpts<T>
) {
let ctx = useValidatedControl<T, HTMLInputElement>(opts);
// Provide the caller a prop getter to be spread onto the <input>
function getInputAttrs({
...attrs
}: React.ComponentPropsWithoutRef<"input"> = {}): React.ComponentPropsWithoutRef<"input"> {
let controlAttrs = getControlAttrs(
ctx,
"input",
attrs.name,
attrs.className,
opts.index
);
return {
...controlAttrs,
...omit(attrs, "className", "ref"),
};
}
return {
info: ctx.info,
ref: ctx.ref,
controller: ctx.controller,
getLabelAttrs: ctx.getLabelAttrs,
getErrorsAttrs: ctx.getErrorsAttrs,
getInputAttrs,
};
}
interface UseValidatedTextAreaOpts<T extends FormDefinition> {
name: KeyOf<T["inputs"]>;
formDefinition?: T;
serverFormInfo?: ServerFormInfo<T>;
ref?:
| React.ForwardedRef<HTMLTextAreaElement | null | undefined>
| React.Ref<HTMLTextAreaElement | null | undefined>;
index?: number;
forceUpdate?: any;
}
// User-facing useValidatedControl wrapper for <textarea> elements
export function useValidatedTextArea<T extends FormDefinition>(
opts: UseValidatedTextAreaOpts<T>
) {
let ctx = useValidatedControl<T, HTMLTextAreaElement>(opts);
// Provide the caller a prop getter to be spread onto the <textarea>
function getTextAreaAttrs({
...attrs
}: React.ComponentPropsWithoutRef<"textarea"> = {}): React.ComponentPropsWithoutRef<"textarea"> {
let controlAttrs = getControlAttrs(
ctx,
"textarea",
attrs.name,
attrs.className,
opts.index
);
return {
...controlAttrs,
...omit(attrs, "className", "ref"),
};
}
return {
info: ctx.info,
ref: ctx.ref,
controller: ctx.controller,
getLabelAttrs: ctx.getLabelAttrs,
getErrorsAttrs: ctx.getErrorsAttrs,
getTextAreaAttrs,
};
}
interface UseValidatedSelectOpts<T extends FormDefinition> {
name: KeyOf<T["inputs"]>;
formDefinition?: T;
serverFormInfo?: ServerFormInfo<T>;
ref?:
| React.ForwardedRef<HTMLSelectElement | null | undefined>
| React.Ref<HTMLSelectElement | null | undefined>;
index?: number;
forceUpdate?: any;
}
// User-facing useValidatedControl wrapper for <textarea> elements
export function useValidatedSelect<T extends FormDefinition>(
opts: UseValidatedSelectOpts<T>
) {
let ctx = useValidatedControl<T, HTMLSelectElement>(opts);
// Provide the caller a prop getter to be spread onto the <textarea>
function getSelectAttrs({
...attrs
}: React.ComponentPropsWithoutRef<"select"> = {}): React.ComponentPropsWithoutRef<"select"> {
let controlAttrs = getControlAttrs(
ctx,
"select",
attrs.name,
attrs.className,
opts.index
);
return {
...controlAttrs,
...omit(attrs, "className", "ref"),
};
}
return {
info: ctx.info,
ref: ctx.ref,
controller: ctx.controller,
getLabelAttrs: ctx.getLabelAttrs,
getErrorsAttrs: ctx.getErrorsAttrs,
getSelectAttrs,
};
}
export interface FormProviderProps<T extends FormDefinition>
extends React.PropsWithChildren<{
formDefinition: T;
serverFormInfo?: ServerFormInfo<T>;
formRef?: React.RefObject<HTMLFormElement>;
}> {}
export function FormProvider<T extends FormDefinition>(
props: FormProviderProps<T>
) {
// If we have inputs using dynamic attributes, then we need to be able to
// trigger re-renders o those inputs at a higher level an time the formData
// changes, in case the dynamic attributes values need to be updated.
let [forcedUpdate, forceUpdate] = React.useState({});
React.useEffect(() => {
let formEl = props.formRef?.current;
if (!formEl || !hasDynamicAttributes(props.formDefinition)) {
return;
}
let handler = () => forceUpdate({});
formEl.addEventListener("change", handler, { capture: true });
return () =>
formEl?.removeEventListener("change", handler, { capture: true });
}, [props.formDefinition, props.formRef]);
return (
<FormContext.Provider
value={{
formDefinition: props.formDefinition,
serverFormInfo: props.serverFormInfo,
forceUpdate: forcedUpdate,
}}
>
{props.children}
</FormContext.Provider>
);
}
export interface ControlWrapperProps<T extends FormDefinition>
extends React.PropsWithChildren<{
name: string;
label?: string;
labelAttrs: React.ComponentPropsWithoutRef<"label">;
errorAttrs: React.ComponentPropsWithoutRef<"ul">;
formDefinition?: T;
serverFormInfo?: ServerFormInfo<T>;
info: InputInfo;
}> {}
// Internal utility component to handle <label> and error displays while leaving
// the <input></<textarea> control rendering to the calling component
function ControlWrapper<T extends FormDefinition>({
name,
label,
labelAttrs,
errorAttrs,
formDefinition,
serverFormInfo,
info,
children,
}: ControlWrapperProps<T>) {
invariant(
formDefinition,
`No form definition found for form control with name "${name}">`
);
// Not all input types can have a required attribute
let validationAttrs =
formDefinition.inputs[name] && formDefinition.inputs[name].validationAttrs
? formDefinition.inputs[name].validationAttrs
: null;
let isRequired =
validationAttrs && "required" in validationAttrs
? validationAttrs.required === true
: false;
let showErrors = serverFormInfo != null || info.touched;
return (
<>
{label ? (
<label {...labelAttrs}>
{label}
{isRequired ? "*" : null}
</label>
) : null}
{children}
{/* Display validation state */}
{showErrors ? <ControlErrors info={info} {...errorAttrs} /> : null}
</>
);
}
interface ControlErrorsProps extends React.ComponentPropsWithoutRef<"ul"> {
info: InputInfo;
}
function ControlErrors({ info, ...attrs }: ControlErrorsProps) {
if (info.state === "idle") {
return null;
}
if (info.state === "validating") {
return <p className="rvs-validating">Validating...</p>;
}
if (info.validity?.valid) {
return null;
}
return <Errors {...attrs} messages={info.errorMessages} />;
}
export interface InputProps<T extends FormDefinition>
extends UseValidatedInputOpts<T>,
Omit<React.ComponentPropsWithoutRef<"input">, "name"> {
label: string;
index?: number;
}
// Syntactic sugar component to handle <label>/<input> and error displays
export function Input<T extends FormDefinition>({
name,
formDefinition: formDefinitionProp,
serverFormInfo: serverFormInfoProp,
label,
index,
...inputAttrs
}: InputProps<T>) {
let ctx = useOptionalFormContext<T>();
let formDefinition = formDefinitionProp || ctx?.formDefinition;
let serverFormInfo = serverFormInfoProp || ctx?.serverFormInfo;
let { info, getInputAttrs, getLabelAttrs, getErrorsAttrs } =
useValidatedInput({ name, formDefinition, serverFormInfo, index });
return (
<ControlWrapper
name={name}
label={label}
labelAttrs={getLabelAttrs()}
errorAttrs={getErrorsAttrs()}
formDefinition={formDefinition}
serverFormInfo={serverFormInfo}
info={info}
>
<input
{...getInputAttrs({
defaultValue: getInputDefaultValue(
name,
(
formDefinition?.inputs[name]
.validationAttrs as InputValidationAttrs
).type,
serverFormInfo,
index
),
...inputAttrs,
})}
/>
</ControlWrapper>
);
}
export interface TextAreaProps<T extends FormDefinition>
extends UseValidatedInputOpts<T>,
Omit<React.ComponentPropsWithoutRef<"textarea">, "name"> {
label: string;
index?: number;
}
// Syntactic sugar component to handle <label>/<input> and error displays
export function TextArea<T extends FormDefinition>({
name,
formDefinition: formDefinitionProp,
serverFormInfo: serverFormInfoProp,
label,
index,
...inputAttrs
}: TextAreaProps<T>) {
let ctx = useOptionalFormContext<T>();
let formDefinition = formDefinitionProp || ctx?.formDefinition;
let serverFormInfo = serverFormInfoProp || ctx?.serverFormInfo;
let { info, getTextAreaAttrs, getLabelAttrs, getErrorsAttrs } =
useValidatedTextArea({ name, formDefinition, serverFormInfo, index });
return (
<ControlWrapper
name={name}
label={label}
labelAttrs={getLabelAttrs()}
errorAttrs={getErrorsAttrs()}
formDefinition={formDefinition}
serverFormInfo={serverFormInfo}
info={info}
>
<textarea
{...getTextAreaAttrs({
defaultValue: getInputDefaultValue(
name,
undefined,
serverFormInfo,
index
),
...inputAttrs,
})}
/>
</ControlWrapper>
);
}
export interface SelectProps<T extends FormDefinition>
extends UseValidatedSelectOpts<T>,
Omit<React.ComponentPropsWithoutRef<"select">, "name"> {
label: string;
index?: number;
}
// Syntactic sugar component to handle <label>/<input> and error displays
export function Select<T extends FormDefinition>({
name,
formDefinition: formDefinitionProp,
serverFormInfo: serverFormInfoProp,
label,
index,
children,
...inputAttrs
}: SelectProps<T>) {
let ctx = useOptionalFormContext<T>();
let formDefinition = formDefinitionProp || ctx?.formDefinition;
let serverFormInfo = serverFormInfoProp || ctx?.serverFormInfo;
let { info, getSelectAttrs, getLabelAttrs, getErrorsAttrs } =
useValidatedSelect({ name, formDefinition, serverFormInfo, index });
return (
<ControlWrapper
name={name}
label={label}
labelAttrs={getLabelAttrs()}
errorAttrs={getErrorsAttrs()}
formDefinition={formDefinition}
serverFormInfo={serverFormInfo}
info={info}
>
<select
{...getSelectAttrs({
defaultValue: getInputDefaultValue(
name,
undefined,
serverFormInfo,
index
),
...inputAttrs,
})}
>
{children}
</select>
</ControlWrapper>
);
}
export interface ErrorProps {
id?: string;
messages?: Record<string, string>;
}
// Display errors for a given input
export function Errors({ id, messages, ...attrs }: ErrorProps) {
if (!messages) {
return null;
}
return (
<ul {...attrs} id={id} role="alert">
{Object.entries(messages).map(([validation, message]) => (
<li key={validation}>{`🆘 ${message}`}</li>
))}
</ul>
);
}
//#endregion
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment