CodeNewbie Community 🌱

Cover image for How To Create Accessible Form with Boring Design?
Vanza Setia
Vanza Setia

Posted on • Updated on

How To Create Accessible Form with Boring Design?

The form is a common element on the website. We must make sure that it is accessible to different users. This article helps you to understand how to create an accessible form.

Table of Contents

See the Website

You can use these resources as your reference. Here are the links.

Starter Files

You can try to follow along by downloading the starter files (ZIP). You can visit the repository first before downloading the starter files.

Here is what you will get.

β”œβ”€β”€ design
β”‚   β”œβ”€β”€ banner.png
β”‚   β”œβ”€β”€ desktop.png
β”‚   β”œβ”€β”€ hover-state.png
β”‚   β”œβ”€β”€ invalid-email.png
β”‚   β”œβ”€β”€ invalid-form.png
β”‚   └── mobile.png
β”œβ”€β”€ svg 
β”‚   └── icon-error.svg
β”œβ”€β”€ .gitignore
β”œβ”€β”€ index.html
β”œβ”€β”€ README.md
β”œβ”€β”€ style-guide.md
Enter fullscreen mode Exit fullscreen mode

Feel free to follow along!

HTML

Every website needs HTML. HTML can impact the way assistive technology, search engine bots, and other tools understand the page content.

So, it is important to get it right.

Planning

So, how do we structure the page?

Here is what I came up with.

main
  h1
  form
    div
      label
        span
        input:text
      p
    div
      label
        span
        input:email
      p
    button:submit
Enter fullscreen mode Exit fullscreen mode

For your information: I use Emmet syntax as the syntax for my pseudo-code. It's up to you how to write pseudo-code.

There are reasons why I decided to write the HTML markup like this.

  • main - all page content must live inside the landmark element. This will help search engine bots and assistive technologies to understand the page content. In this case, all of them are the main content of the page.
  • h1 - each page must have one h1. No more, no less.
  • form - wrap all the input elements with form.
  • div - wrapper for each input. Only for styling purposes.
  • label element to wrap the input - this way, I don't need to use the for attribute on the label. Also, I don't have to add id on each input element. It's less code to write.
  • span - make the label for the input a block element. This way, the text, and the input are on its line.
  • input with correct type value - to make the mobile users get the right keyboard layout.
  • p - to hold the alert message. Text content must always be wrapped by a meaningful element.
  • button with type="submit" - don't use input with type="submit". It's a legacy element that is used before the button element exists.

Let's turn that pseudo-code into real-code.

<main>
  <h1>Accessible Form - Project Example</h1>
  <form>
    <div>
      <label>
        <span>Name</span>
        <input type="text" />
      </label>
      <p></p>
    </div>
    <div>
      <label>
        <span>Email</span>
        <input type="email" />
      </label>
      <p></p>
    </div>
    <button type="submit">Sign Up</button>
  </form>
</main>
Enter fullscreen mode Exit fullscreen mode

That HTML code gives us this.

Accessible form without styling

The page is in 200% zoom level. That's why it looks big.

The page only gets the browser's default styling. But, we can understand the page content.

This can be one of the ways to validate HTML markup. If you can't understand the page content without styling, then there's a good chance there's something wrong with the HTML.

ARIA Attributes

ARIA (Accessible Rich Internet Application) attributes allow us to extend the ability of HTML accessibility.

It's important to know that no ARIA is better than bad ARIA. So, we need to have a reason to use ARIA attributes. Otherwise, we will only make the website to be more inaccessible.

For your information: Increased ARIA usage on pages correlated to higher detected errors. The more ARIA attributes that were present, the more detected accessibility errors could be expected. From WebAIM: The WebAIM Million - The 2022 report on the accessibility of the top 1,000,000 home pages.

We need ARIA attributes to make the alert messages get pronounced by screen readers. aria-live attribute allows us to do that.

If you don't know about aria-live, check the MDN documentation for the aria-live attribute.

<p aria-live="polite"></p>
Enter fullscreen mode Exit fullscreen mode

The p is empty at first. Then, we will use JavaScript to inject the alert message.

We choose polite because the validation runs after the users try to submit the form. If the validation runs when the users are typing, we use assertive to immediately tell the users about the error.

Next, we need to tell that the p is linked to the input.

We use aria-describedby. This allows us to tell assistive technologies that the p is used to describe the linked input element.

If you don't know about aria-describedby, check the MDN documentation for the aria-describedby attribute.

<div>
  <label>
    <span>Name</span>
    <input type="text" aria-describedby="name-alert" />
  </label>
  <p id="name-alert" aria-live="polite"></p>
</div>
<div>
  <label>
    <span>Email</span>
    <input type="email" aria-describedby="email-alert" />
  </label>
  <p id="email-alert" aria-live="polite"></p>
</div>
Enter fullscreen mode Exit fullscreen mode

Finish the HTML

We need to tell the users that all the inputs are required.

<input type="text" required aria-describedby="name-alert" />
<input type="email" required aria-describedby="email-alert" />
Enter fullscreen mode Exit fullscreen mode

Then, we don't want native HTML validation. We will create our validation with JavaScript.

<form novalidate></form>
Enter fullscreen mode Exit fullscreen mode

Validate HTML

So, here's what we have so far.

<main>
  <h1>Accessible Form - Project Example</h1>
  <form novalidate>
    <div>
      <label>
        <span>Name</span>
        <input type="text" required aria-describedby="name-alert" />
      </label>
      <p id="name-alert" aria-live="polite"></p>
    </div>
    <div>
      <label>
        <span>Email</span>
        <input type="email" required aria-describedby="email-alert" />
      </label>
      <p id="email-alert" aria-live="polite"></p>
    </div>
    <button type="submit">Sign Up</button>
  </form>
</main>
Enter fullscreen mode Exit fullscreen mode

Before we are moving to the styling process, let's make sure that our HTML is valid.

We use The W3C Markup Validation Service.

Everything should be valid if you use my starter files and have the exact HTML that I wrote in this blog post.

CSS

So, we are done with the HTML. Now, all we need to do is to make it looks better.

CSS Reset

First, we need to use a CSS reset. This way, we can have a good foundation for our styling.

I recommend using the Modern CSS Reset from Piccalilli (Andy Bell).

CSS Methodology

I use BEM (Block Element Modifier) for the class naming convention.

Planning

Now, let's take a look at the HTML. But, we are now thinking the styling.

This is what I came up with.

<main class="flow">
  <h1 class="text-align-center title">Accessible Form - Project Example</h1>
  <form class="card flow form" novalidate>
    <div class="form__control">
      <label class="form__wrapper">
        <span class="form__label">Name</span>
        <input class="form__input focus-visible-black" type="text" required aria-describedby="name-alert" />
      </label>
      <p class="form__alert" id="name-alert" aria-live="polite"></p>
    </div>
    <div>
      <label class="form__wrapper">
        <span class="form__label">Email</span>
        <input class="form__input focus-visible-black" type="email" required aria-describedby="email-alert" />
      </label>
      <p class="form__alert" id="email-alert" aria-live="polite"></p>
    </div>
    <button 
      class="button button--block button--round button--bold button--uppercase button--black focus-visible-black"
      type="submit"
    >
      Sign Up
    </button>
  </form>
</main>
Enter fullscreen mode Exit fullscreen mode

Custom Properties

Let's prepare the CSS custom properties (or variables).

:root {
  --black: hsl(0, 0%, 0%);
  --white: hsl(0, 0%, 100%);
  --very-light-gray: hsl(0, 0%, 90%);
  --red: hsl(0, 100%, 46%);
  --body-background-color: var(--very-light-gray);
  --card-background-color: var(--white);
  --card-text-color: var(--black);
  --card-box-shadow: 0 3.125rem 3.125rem -1.5625rem rgba(75, 92, 154, 0.24);
  --card-max-width: 31.25rem;
  --invalid-color: var(--red);
}
Enter fullscreen mode Exit fullscreen mode

I recommend writing the variables' names with hyphenated naming conventions. CSS properties are based on hyphenated things such as background-color, font-size, etc. So, to make everything consistent, we will follow the language naming convention.

For the font size, I use Utopia to generate CSS variables for font sizes. This helps us to have fluid typography effortlessly. I used the following setting to generate the fluid font sizes.

We only need the following variables.

:root {
  --step--1: clamp(0.88rem, calc(0.96rem + -0.14vw), 0.94rem);
  --step-0: clamp(1.13rem, calc(1.06rem + 0.32vw), 1.25rem);
  --step-1: clamp(1.35rem, calc(1.14rem + 1.06vw), 1.77rem);
  --step-2: clamp(1.62rem, calc(1.17rem + 2.23vw), 2.5rem);
}
Enter fullscreen mode Exit fullscreen mode

Then, we use the --step-0 as the body's font size.

:root {
  --body-font-size: var(--step-0);
}
Enter fullscreen mode Exit fullscreen mode

Centering the elements

This is the way.

body {
  display: grid;
  place-items: center;
}
Enter fullscreen mode Exit fullscreen mode

Spacing

For the spacing between the h1 and the card is using .flow class. The same goes for the space among elements inside the form.

.flow > * + * {
  margin-block-start: var(--space, 1em);
}
Enter fullscreen mode Exit fullscreen mode

For your information: The * + * selector is called "Lobotomized Owl Selector". It will only apply the margin-block-start (the logical property for margin-top) if there's an element before it. I recommend reading "Get to Know The Lobotomized Owl Selector - Pine"

If we want a different amount of space, we can define the --space property. For example, if we need more space between input elements inside the card, we can do the following.

.card {
  --space: 1.4rem;
}
Enter fullscreen mode Exit fullscreen mode

Focus Visible

The users can use the keyboard to navigate through interactive elements. To tell them where they are currently, we can create a custom :focus-visible styling.

.focus-visible-black:focus-visible {
  outline: 0.2rem dashed var(--black);
  outline-offset: 0.3rem;
}
Enter fullscreen mode Exit fullscreen mode

I decided to use a CSS class for this size of the project. Usually, I will have each component has its :focus-visible styling.

Button

For the button, I want it to apply the styling with single-responsibility principle. It means each modifier class is only responsible for one thing.

.button {
  --button-border: 0.2rem solid var(--black);
  cursor: pointer;
  border: none;
  border-inline: var(--button-border);
  border-block: var(--button-border);
  padding-inline: 0.6rem;
  padding-block: 0.6rem;
  text-decoration: none;
}

.button--round {
  border-radius: 0.5em;
}

.button--block {
  display: block;
  width: 100%;
}

.button--bold {
  font-weight: 700;
}

.button--uppercase {
  text-transform: uppercase;
}

.button--black {
  background-color: var(--black);
  color: var(--white);
}
Enter fullscreen mode Exit fullscreen mode

The purpose of doing this is to make the button can have different styles. So, we are not limited to only having one styling.

For example, .button-submit can only be used for submit button. It is not reusable. But, when we separate all the styling, we can have different button styles. This way, we can compose different CSS classes to create different buttons.

Other Elements

The rest of the styling is straightforward. So, you can try to understand the code by yourself. As a developer, understanding other's people code is a beneficial skill.

If you don't understand something then feel free to ask your questions in the comment.

One more thing, you can try to challenge yourself to write the styling by yourself. Know that the site is responsive without any media queries.

Validate CSS

Don't forget to validate your CSS! I recommend using The W3C CSS Validation Service.

JavaScript

Finally, the JavaScript part! πŸŽ‰

I will cover Regular Expression, best practices, and how I did the form validation.

Planning

For me, I planned my JavaScript with plain text.

FUNCTION clearAllAlerts () {
  "make the form to initial state"
}

FUNCTION checkAllInputs () {
  "check all the inputs"
  THEN
  "if the input is not valid then show the alert message"
}

FUNCTION validateForm () {
  "clear all the alert messages"
  "check all the input elements"
  IF "the form is not valid" {
    prevent form submission
  }
}

form.addEventListener("submit", validateForm)
Enter fullscreen mode Exit fullscreen mode

HTML

I recommend selecting the HTML elements with js- classes. The purpose is to separate the classes for CSS and JavaScript. This way, if we change the styling in the future then the functionality will not break.

<form
  class="js-form"
  novalidate
>
  <div>
    <label>
      <span>Name</span>
      <input
        class="js-input"
        type="text"
        name="name"
        required
        aria-describedby="name-alert"
        data-id="name"
      />
    </label>
    <p
      id="name-alert"
      class="js-alert"
      aria-live="polite"
      data-id="name"
    ></p>
  </div>
  <div>
    <label>
      <span>Email</span>
      <input
        class="js-input"
        type="email"
        name="email"
        required
        aria-describedby="email-alert"
        data-id="email"
      />
    </label>
    <p
      id="email-alert"
      class="js-alert"
      aria-live="polite"
      data-id="email"
    ></p>
  </div>
  <button type="submit">
    Sign Up
  </button>
</form>
Enter fullscreen mode Exit fullscreen mode

Note: I remove all the styling classes so that it's easier for you to see js- classes.

There's something important to know. It's the data-id attribute.

<div>
  <label>
    <span>Name</span>
    <input
      class="js-input"
      type="text"
      name="name"
      required
      aria-describedby="name-alert"
      data-id="name"
    />
  </label>
  <p
    id="name-alert"
    class="js-alert"
    aria-live="polite"
    data-id="name"
  ></p>
</div>
Enter fullscreen mode Exit fullscreen mode

Note: we need to make sure that the input and the p has the data-id attribute and they have the same value. The above code snippet shows that the p and the input has name as the value of the data-id.

The data-id attributes are used to connect the associated error message with the input element. The benefit of doing this is no matter the position of the alert, it will always connect with the right error message.

For example, we can use the querySelector() to select the error message for the associated input.

const handleAlert = (message, input) => {
  const inputParentElement = input.parentElement;
  const alert = inputParentElement.querySelector(".js-alert");
  showAlert(message, alert, input);
}
Enter fullscreen mode Exit fullscreen mode

The problem here is that if the error message has a different parent element with the input then, this code isn't working.

So, by using data-id, it won't be a problem.

const alerts = document.querySelectorAll(".js-alert");

const handleAlert = (input, message) => {
  alerts.forEach((alert) => {
    const inputID = input.dataset.id;
    const alertID = alert.dataset.id;
    if (inputID === alertID) {
      showAlertMessage({ input, alert, message });
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

As a result, the JavaScript code doesn't depend on the structure of the HTML.

Select The DOM Elements

const form = document.querySelector(".js-form");
const inputs = document.querySelectorAll(".js-input");
const alerts = document.querySelectorAll(".js-alert");
Enter fullscreen mode Exit fullscreen mode

Form Validation

Next, we make the form listen to submit event. Then, run the form validation functionality.

const validateForm = (event) => {
  clearAllAlerts();
  const isFormValid = checkAllInputs();
  if (!isFormValid) {
    event.preventDefault();
  }
};

form.addEventListener("submit", validateForm);
Enter fullscreen mode Exit fullscreen mode

The clearAllAlerts function will select all the inputs. Then, remove the invalid styling.

It also selects all the alert elements. After that, clear the alert message from each input element.

const clearAllAlerts = () => {
  alerts.forEach((alert) => (alert.textContent = ""));
  inputs.forEach((input) => input.classList.remove("is-invalid"));
};
Enter fullscreen mode Exit fullscreen mode

For the checkAllInputs function, we use forEach to select each input. Then use switch to run necessary validation for different types of input.

const checkAllInputs = () => {
  let isNameFilled = false;
  let isEmailFilled = false;
  let isEmailValid = false;

  inputs.forEach((input) => {
    const id = input.dataset.id;
    const value = input.value;
    switch (id) {
      case "name":
        isNameFilled = isInputFilled(input, value);
        break;
      case "email":
        isEmailFilled = isInputFilled(input, value);
        if (isEmailFilled) {
          isEmailValid = validateEmail(input, value);
        }
        break;
      default:
        console.error(`${id} input doesn't exist`);
    }
  });

  const areAllInputsValid = isNameFilled && isEmailFilled && isEmailValid;
  return areAllInputsValid;
};
Enter fullscreen mode Exit fullscreen mode

Then, we create another function called isInputFilled. It has two parameters. The first one is the input element that we want to check. The second one is the value of the input.

The isInputFilled function is to check whether the input is empty or filled.

const isInputFilled = (input, value) => {
  const isFilled = !!value;
  if (!isFilled) {
    handleAlert(input, `This ${input.dataset.id} input is required`);
  }
  return isFilled;
};
Enter fullscreen mode Exit fullscreen mode

The double exclamation marks (or double NOT) are the same as Boolean(value). Since an empty string is a falsy value, it will return false. This function returns a Boolean regardless of whether it's true or false.

But, if it is false we need to show the alert message to the users. So, we create another function which is called handleAlert.

const handleAlert = (input, message) => {
  alerts.forEach((alert) => {
    const inputID = input.dataset.id;
    const alertID = alert.dataset.id;
    if (inputID === alertID) {
      showAlertMessage({ input, alert, message });
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

The purpose of handleAlert function is to get the associated alert element of the input element. Then, it calls showAlertMessage function.

const showAlertMessage = ({ input, alert, message }) => {
  alert.textContent = message;
  input.classList.add("is-invalid");
};
Enter fullscreen mode Exit fullscreen mode

The function takes an Object. Then, destructure it directly. After that, we can show the alert message to the users.

I suggest reading "Destructuring assignment - JavaScript | MDN" to learn more about destructuring.

Email Validation

For the email input, we need to check the email format. So, if the input is filled in but the email address is not valid, then we need to show a different message.

const validateEmail = (input, email) => {
  const emailValidation =
    /^(?:[a-z0-9.]){2,30}@{1}(?:[a-z0-9-]){2,30}\.{1}(?:[a-z0-9]){2,3}(?:\.(?:[a-z0-9]){2,3})?$/;
  const isValid = emailValidation.test(email);
  if (!isValid) {
    handleAlert(input, "Please provide a valid email address");
  }
  return isValid;
};
Enter fullscreen mode Exit fullscreen mode

It uses Regular Expression to validate the email address. Let me explain the Regular Expression.

  • ^ - make sure the start of the string follows the RegEx format.
  • (?:[a-z0-9.]){2,30} - the user name of the email can only contain:
    • lowercase a through z. (a-z)
    • number 0 - 9. (0-9)
    • one or more periods. (.)
    • at least two characters and can't be more than 30 characters. ({2, 30})
  • @{1} - needs to have an @ symbol
  • (?:[a-z0-9-]){2,30}- the domain name has a bit different rule from the user name. The only difference is that it allows having one or more dashes (-) instead of periods (.).
  • .{1} - must contain a period after the domain name.
  • (?:[a-z0-9]){2,3} - the top level domain can only contain:
    • lowercase a through z. (a-z)
    • number 0 - 9. (0-9)
    • at least two characters and can't be more than three characters. ({2, 3})
  • (?:.(?:[a-z0-9]){2,3})? - the second top-level domain has the same rule as the first top-level domain. But, it is optional to have this because of the question mark at the end of it.
  • $ - with a ^ at the start and a dollar sign at the end, it means that the string must follow all the RegEx rules. So, from the first character to the last character must follow the RegEx to be valid.
  • (?:) - it's a non-capturing group. It means I group a set of rules but I don't want each part of the email to be in its group. I recommend reading the MDN documentation for a better explanation.

Example of valid email addresses.

I recommend copy-pasting the RegEx to RegExr. Then, play around with it by yourself to understand it.

Conclusion

Let's do a quick recap of what we have learned.

  • Plan HTML markup
  • ARIA attributes (aria-live and aria-describedby)
  • Fluid Typography
  • Create custom :focus-visible styling
  • Separation of concern (separating CSS classes by using js- classes)
  • Regular Expression to validate the email address

You might also learn that I am not a designer. πŸ˜…

That's it! If you have any feedback or suggestions for improvements, please do let me know. You can write your suggestions in the comment section. Also, let me know if this is helpful.

By the way, do you think the design of the form is boring?

Author

Top comments (0)