Sekrab Garage

Svelte Input Component

Native Validation

SvelteJavaScript November 10, 26
Subscribe to Sekrab Parts newsletter.
Series:

Form Validation in Svelte

This series is dedicated to Svelte Input component, which I will attempt to automate the validation without being obtrusive to the native HTML input elements, using native JavaScript validation.
  1. 13 days ago
    Programmatic attachment in Svelte
  2. 3 days ago
    Native Validation

The validation that we want to cover are the basics:

  • required
  • invalid format (native format, url, email, number)
  • invalid format (pattern or custom regex)
  • invalid range (max, and min, date range, future, past)
  • invalid length (minLength and maxLength)
  • returned of function call (custom function)
  • at least one option selected
Follow along on StackBlitz

We want to end up with as little obstruction to the native input as possible, that validates upon submission, and flag our input as "invalid." First, the actual form exists on the page that consumes the component:

<script lang="ts">
	const formState = $state({
		address: null
	});
	const submit = (e) => {
		// validate then submit
		e.preventDefault();
		// to create a function for validation later
		if (!ValidateForm(e.target as HTMLFormElement)) {
	      // invalid
	      return;
	    }
	    // go ahead
	}
</script>

<form onsubmit={submit}>
<OlInput placeholder="Address">
{#snippet input(ref)}
  <input id="address" required={true} type="text" bind:value={formState.address}  {...ref} />
{/snippet}
</OlInput>

In our component, we add a span for the "required" message, and update its text to be "Required." Let's call it "feedback span"

// OnInput.svelte
<script>

	// assign errorText = "Required" if the field is required
	let errorText = $state('');
	const refElement = {
		// ...
		[createAttachmentKey()]: (node: HTMLInputElement) => {
		untrack(() => {
           // ...
           // if required, assign a messag
           if (node.required){
	           errorText = "Required";
           }
        });
      return () => {
        el = undefined;
      };

    }

  };
</script>

<div class="ol-field" id={id}>
 //...
	@render input(refElement)} 
	<!-- add a span for required text -->
	<span class="ol-feedback">{errorText}</span>
  // ...
</div>

Then we control the ol-feedback with css:

.ol-feedback {

  background-color: var(--sh-red);
  color: var(--sh-white);
  /* hide it initially */
  display: none;
}

To make use of our custom validation, first the form element must turn off their native validation, and we need to start using validity functions, and validity css pseudo styles.

<script>
	const submit = (e) => {
    e.preventDefault();
    // we will be using:
    e.target.checkValidity();
    
    // and sometimes this
    // someElement.setCustomValidity();
    
    // we will also listen to 
    // element.addEventListener('invalid');

  };
</script>
<form novalidate onsubmit={submit} ...>
</form>
<style>

.ol-feedback {
	 display: none;
}
/* we eill be using his pseudo selectors */
.ol-input:invalid~.ol-feedback {
	display: block;
}
</style>

We can also target the input itself to show it as invalid:

.ol-input:invalid {
   box-shadow: inset 0 0 0 1px var(--sh-red);
}

To start here, the field will start with its invalid state:
![[Pasted image 20261105182744.png]]

So in order to ignore the initial state until Submit button is clicked (the correct user friendly behavior and don't listen to anyone trying to convince you otherwise), we add a second class helper: .was-validated, and add it upon first submission.

.was-validated {
	.ol-input:invalid {
	   box-shadow: inset 0 0 0 1px var(--sh-red);
	}
	.ol-input:invalid~.ol-feedback {
		display: block;
	}
}

Let's move our validation function to the library to reuse:

// new file in lib/input/form.ts
export const ValidateForm = (form: HTMLFormElement): boolean => {
  // add class to mark invalid fields visually
  form.classList.add("was-validated");
  if (!form.checkValidity()){ 
	  return false;
  }
  return true;
}

Then upon submission, we just pass the form to ValidateForm

// +page.svelte
const submit = (e) => {

    e.preventDefault();  
    if (!ValidateForm(e.target as HTMLFormElement)) {
	   // toast error
      return;
    }
    // move on with form
  };

Nothing much to it. This works for all field types, number, date, email. Let's move on to the second validation.

anchorValidate format

We have two built in formats that we can rely on natively: email and url. We also have the native pattern attributes. The only thing we need to handle in these cases is the error message. It can be :

  • a generic: "Invalid format" (with a help tip of the required format), this is the recommended user friendly way, I will rant about it a bit below.
  • A specific invalid "something" format, where the "something" is the dynamic part
  • A fall back to a custom error message (keep this for extreme cases)

Why a tip and generic "Invalid format" is better than: "Please enter a valid email?" Because it's almost 2030. That's more than a quarter of a century since the inception of wildly used internet forms. The generation you used to depend upon to read those error messages, now rely on their kids to fill out their forms. The kids replacing them, they never read those error messages. They are too cocky to even consider reading the error messages. I have seen kids rage clicking on buttons that kept telling them, guys, you just need to God damn check the terms and conditions checkbox! And they would outright tell you, this site is sss-stupid!

So what do you do? Keep them short, and to the point: emails and urls, we all know their formats, other stuff? state an example in the help tip. If it gets more complicated, gradually build up with a custom clear error message. If you are not the British Government, remove the noise. If you are, update your wardrobe. Use aria attributes! It can still be done without repeating yourself.

Enough ranting.

anchorNative Validity

HTML natively produces a validity object which is quite helpful to decide the error message, console out an input validity object, you will get this:

// example of input.validity
{
	badInput: false
	customError: false
	patternMismatch: false
	rangeOverflow: false
	rangeUnderflow: false
	stepMismatch: false
	tooLong: false
	tooShort: false
	typeMismatch: false
	valid: true
	valueMissing: false
}  

Our required fields, would output valueMissing as true.

<OlInput placeholder="Email">
  {#snippet input(ref)}
    <input {...ref} type="email" bind:value={formState.email} id="email" autocomplete="email" />
  {/snippet}
</OlInput>
<OlInput placeholder="Url">
  {#snippet input(ref)}
    <input {...ref} type="url" bind:value={formState.url} id="url" />
  {/snippet}
</OlInput>
<OlInput placeholder="Phone">
  {#snippet input(ref)}
    <input {...ref} type="tel" bind:value={formState.phone} id="phone" />
  {/snippet}
</OlInput>

In order to decide what the error message for each, we can rely on the field type, and the type or pattern mismatch validity error, let's create a generic function to handle this:

export const ErrorMessage = (input: HTMLInputElement) => {
  const validity = input.validity;
  // default
  if (validity.valueMissing) {
   return 'Required'
  }
  // get type mismatch first then pattern
  if (validity.typeMismatch) {
    // invalid email, or url format
    const type = input.type;
    return `Invalid ${type} format`;
  }
  if (validity.patternMismatch) {
    // this is for tel, it has no type mismatch, so we provide pattern
    // you can create a lookup array to be more friendly with these errors
    // detailed below
    const pattern = input.pattern;
    return `Invalid format: ${pattern}`;
  }
}

There's a reason to wait for validation to decide the message, and that's to be dynamic as to which error message to show, is it "required" or "invalid email format?" So we listen:

// OlInput.ts

// ... refElement attachment
untrack(() => {
    node.addEventListener('invalid', (e) => {
	    errorText = ErrorMessage(<HTMLInputElement>e.target);
	});
});

One missing edge, is that this applies upon form submission and checkValidity first call. But the error label appears right afterwards because it depends on native css :invalid. So in order to have something to show we have two options:

  • either prepare a default error label for each field
  • or listen to input

I chose to listen to input events, it also makes the error message change upon user input:

// OlInput.ts
node.addEventListener('invalid', (e) => {
	errorText = ErrorMessage(e.target);
});
// also listen to input changes to have something to watch after first invalid event
node.addEventListener('input', (e) => {
	errorText = ErrorMessage(e.target);
});        

With this, we also covered required and formatted. So if we add required to an email field, it will show Required at first, the Invalid email format when filled with wrong email format.

anchorThe case of TEL

Native HTML type tel has no validation attached to it, so what we must do is define a pattern. The problem is to automate the message, we need to pull a few strings:

// form.ts
if (validity.patternMismatch) {
    const pattern = input.pattern;
    // this will show Invalid format: [0-9]3-[0-9]3-[0-9]4
    return `Invalid format: ${pattern}`;
  }

This isn't nice, so we probably want to invent a new data attribute to lookup a proper error message. Let's call it data-format;

+page.svelte
 <OlInput placeholder="Phone">
    {#snippet input(ref)}
    <input {...ref} type="tel" data-format="phone" pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}"  />
     {/snippet}
 </OlInput>

Then let's use that in our ErrorMessage

// form.ts
export const ErrorMessage = (input: HTMLInputElement) => {
  const validity = input.validity;
  // ...
  if (validity.patternMismatch) {
    const format = input.dataset.format;
    return `Invalid ${format} format`;
  }
}

This also takes care of all pattern custom validation. One enhancement can be introduced, is to drop the default pattern and always use data-pattern with a lookup key-value array. This will be more maintainable instead of keeping the pattern in one place, and the format in another.

// form.ts 
// introduce a key-value array
export const InputPatterns = new Map<string, any>([
   ['phone', '[0-9]3-[0-9]3-[0-9]4']
]);

Then in our component initialization, we assign the pattern by code

// OlInput.ts

// untrack
if (node.dataset?.pattern) {
    let _pattern = InputPatterns.get(node.dataset.pattern);
    if (_pattern) {
		node.setAttribute('pattern', _pattern);
	}
}

And in our error message function, we read data-pattern for message

// form.ts
export const ErrorMessage = (input: HTMLInputElement) => {
  const validity = input.validity;
  // ...
  if (validity.patternMismatch) {
    const pattern = input.dataset.pattern;
    return `Invalid ${pattern} format`;
  }
}

anchorValidating range, and length

This needs no extra work really, just adding more to our ErrorMessage function.

+page.svelte
	<OlInput placeholder="Mars">
      {#snippet input(ref)}
        <input {...ref} type="number" id="mars" min={2} max={4} step="0.1" />
      {/snippet}
      {#snippet help()}
        Range of 2 - 4
      {/snippet}
    </OlInput>

    <OlInput placeholder="Skippy">
      {#snippet input(ref)}
        <input {...ref}  type="text" id="skippy" minlength={3} maxlength={6} />
      {/snippet}
      {#snippet help()}
        Three to 6 characters
      {/snippet}
    </OlInput>

Then the error text:

// form.ts
export const ErrorMessage = (input: HTMLInputElement) => {
	// ...
  if (validity.rangeOverflow) {
    return 'Too large';
  }
  if (validity.rangeUnderflow) {
    return 'Too small';
  }
  if (validity.tooLong) {
    return 'Too long';
  }
  if (validity.tooShort) {
    return 'Too short';
  }
};

But wait...

  • The minlength and maxlength constraints are only checked on user-provided input. They are not checked if a value is set programmatically, even when explicitly calling checkValidity() or reportValidity().

MDN documentation

But what if the data is set programmatically? how do we make sure it does not submit back with invalid content? This must be taken care of, separately.

We have a couple of options:

  • Never use minlength and instead always use pattern
  • Programmatically change minlength and maxlength to RegEx pattern
  • Keep fingers crossed and hope user inputs
  • Belated validation, check upon submission

We'll detail those and a bit more, next Tuesday 😴

  1. 13 days ago
    Programmatic attachment in Svelte
  2. 3 days ago
    Native Validation
Have a comment?
Tweet sekrabbin.
Subscribe to Sekrab Parts newsletter.