fabian-hiller / modular-forms

The modular and type-safe form library for SolidJS, Qwik and Preact
https://modularforms.dev
MIT License
983 stars 53 forks source link

Phone Number Input #170

Open ucod3 opened 7 months ago

ucod3 commented 7 months ago

Hey Fabian,

I'm testing modular forms with Qwik. I'm using the component from the playground Git repository and have successfully created a custom toPhoneNumber function. It formats a 10-digit number correctly when I type it in. However, I've noticed that I can still type in a string, which is not the default behavior for phone number inputs on the web. Do I need to implement separate logic to handle this, or is there a way to accomplish this through the form API?

index.tsc

import { type DocumentHead } from "@builder.io/qwik-city";
import type { SubmitHandler } from "@modular-forms/qwik";

import { $, component$ } from "@builder.io/qwik";
import { routeLoader$ } from "@builder.io/qwik-city";
import type { InitialValues } from "@modular-forms/qwik";
import { formAction$, valiForm$, useForm } from "@modular-forms/qwik";
import {
  email,
  type Input,
  minLength,
  object,
  string,
  regex,
} from "valibot";
import { TextInput } from "~/components/TextInput";
import { toPhoneNumber } from "~/components/toPhoneNumber";

const LoginSchema = object({
  name: object({
    first: string([minLength(1, "Please enter first name.")]),

    last: string([minLength(1, "Please enter last name.")]),
  }),
  email: string([
    minLength(1, "Please enter an email."),
    email("The email address is badly formatted."),
  ]),
  phone: string([
    minLength(1, "Please enter your phone number."),
    regex(/^\+?(?:[0-9] ?){6,14}[0-9]$/, "Please enter a valid phone number."),
  ]),
});

type LoginForm = Input<typeof LoginSchema>;

const getInitialValues = (): InitialValues<LoginForm> => ({
  name: {
    first: "",
    last: "",
  },
  email: "",
  phone: "",
});

export const useFormLoader = routeLoader$<InitialValues<LoginForm>>(() =>
  getInitialValues(),
);

// export const useFormAction = formAction$<LoginForm>((values) => {
//   // Runs on server
// }, valiForm$(LoginSchema));

export default component$(() => {
  const [loginForm, { Form, Field }] = useForm<LoginForm>({
    loader: useFormLoader(),
    // action: useFormAction(),
    validate: valiForm$(LoginSchema),
  });

  const handleSubmit: SubmitHandler<LoginForm> = $((values, event) => {
    console.log(object(values));
  });

  return (
    <Form onSubmit$={handleSubmit} class="container flex flex-col gap-4">
      <Field name="name.first">
        {(field, props) => (
          <TextInput
            {...props}
            value={field.value}
            error={field.error}
            type="text"
            // label="First Name"
            placeholder="First Name"
            required
          />
        )}
      </Field>
      <Field name="name.last">
        {(field, props) => (
          <TextInput
            {...props}
            value={field.value}
            error={field.error}
            type="text"
            placeholder="Last Name"
            required
          />
        )}
      </Field>
      <Field name="email">
        {(field, props) => (
          <TextInput
            {...props}
            value={field.value}
            error={field.error}
            type="email"
            placeholder="example@email.com"
            required
          />
        )}
      </Field>

      <Field
        name="phone"
        transform={toPhoneNumber({ on: "input" })}
      >
        {(field, props) => (
          <TextInput
            {...props}
            value={field.value}
            error={field.error}
            type="tel"
            placeholder="(000) 000-0000"
            required
            maxlength={14}
          />
        )}
      </Field>
      <div>{loginForm.response.message}</div>
      <button type="submit">Login</button>
    </Form>
  );
});

export const head: DocumentHead = {
  title: "Modular Forms",
  meta: [
    {
      name: "description",
      content: "Modular Form  description",
    },
  ],
};

toPhoneNumber.tsx

import type { TransformOptions } from "@modular-forms/qwik";
import { toCustom$ } from "@modular-forms/qwik";

export function toPhoneNumber(options: TransformOptions) {
  return toCustom$<string>((value) => {
    if (value === undefined) return;
    // Remove everything that is not a number
    const numbers = value.replace(/\D/g, "");

    // Continue if string is not empty
    if (numbers) {
      // Extract area, first 3 and last 4
      const matchResult = numbers.match(/(\d{0,3})(\d{0,3})(\d{0,4})/);

      if (matchResult !== null) {
        const [, area, first3, last4] = matchResult;

        // If length or first 3 is less than 1
        if (first3.length < 1) {
          return `(${area}`;
        }

        // If length or last 4 is less than 1
        if (last4.length < 1) {
          return `(${area}) ${first3}`;
        }

        // Otherwise return full US number
        return `(${area}) ${first3}-${last4}`;
      }
    }

    // Otherwise return an empty string
    return "";
  }, options);
}

TextInput.tsx

import { component$, type QRL, useSignal, useTask$ } from "@builder.io/qwik";
import clsx from "clsx";
import { InputError } from "./InputError";
import { InputLabel } from "./InputLabel";

type TextInputProps = {
  ref: QRL<(element: HTMLInputElement) => void>;
  type: "text" | "email" | "tel" | "password" | "url" | "number" | "date";
  name: string;
  value: string | number | undefined;
  onInput$: (event: Event, element: HTMLInputElement) => void;
  onChange$: (event: Event, element: HTMLInputElement) => void;
  onBlur$: (event: Event, element: HTMLInputElement) => void;
  placeholder?: string;
  required?: boolean;
  class?: string;
  label?: string;
  error?: string;
  form?: string;
  maxlength?: number;
};

/**
 * Text input field that users can type into. Various decorations can be
 * displayed in or around the field to communicate the entry requirements.
 */
export const TextInput = component$(
  ({ label, value, error, ...props }: TextInputProps) => {
    const { name, required } = props;
    const input = useSignal<string | number>();
    useTask$(({ track }) => {
      if (!Number.isNaN(track(() => value))) {
        input.value = value;
      }
    });
    return (
      <div class={clsx("px-8 lg:px-10", props.class)}>
        <InputLabel name={name} label={label} required={required} />
        <input
          {...props}
          class={clsx(
            "h-14 w-full rounded-2xl border-2 bg-white px-5 text-amber-800 outline-none placeholder:text-slate-500 dark:bg-gray-900 md:h-16 md:text-lg lg:h-[70px] lg:px-6 lg:text-xl",
            error
              ? "border-red-600/50 dark:border-red-400/50"
              : "border-slate-200 hover:border-slate-300 focus:border-sky-600/50 dark:border-slate-800 dark:hover:border-slate-700 dark:focus:border-sky-400/50",
          )}
          id={name}
          value={input.value}
          aria-invalid={!!error}
          aria-errormessage={`${name}-error`}
        />
        <InputError name={name} error={error} />
      </div>
    );
  },
);
fabian-hiller commented 7 months ago

You can try to filter and remove letters in toPhoneNumber with value.replace(/[a-z]/gi, '') or value.replace(/\D/g, ''). But it seems like that's already in your code.