OTTODesign System

Code

Auth Field

Storybook group: Components · Sidebar path: Components/Auth Field · Extracted 28.09.2026

Version Tag Status API
v1 <oc-auth-field-v1> Stable, allowed for generation AuthFieldV1

Overview (v1)

Source: ./src/components/auth-field/v1/Overview.mdx

Auth field

The auth field component is an alternative to the auth field component to support password managers. This component is designed to be used in a login form or any other form where credentials are required.

Note: Not all password managers are able to handle shadow DOM, so this component is a workaround to support them.

Default variation

Story Default:

<oc-auth-field-v1>
  <input
    type="text"
    aria-describedby="hintXYZ"
    maxlength="250"
    id="inputXYZ"
    aria-required="true"
    spellcheck="false"
    autocorrect="off"
  />
  <label for="inputXYZ">Label</label>
</oc-auth-field-v1>

Configuration

The auth field component offers styling variants such as default and error. This component is configurable, allowing you to tailor its features and appearance to your specific needs. To explore all the available options and adjust the component, use the component configurator and see the changes affect the component in real-time.

Usage guidelines

Before integrating the auth field component into your project, make sure you have correctly installed the OTTO components package. Look through the variations page for examples of possible component variations. Here, you can discover both common and specific variations that address different use cases.

Info

See the Auth field UX documentation for detailed user experience guidelines.

Layout considerations

This component has visual overflow. It extends beyond its bounding box and is clipped by parent containers with overflow: hidden. Ensure the parent container has sufficient padding to accommodate the component's full visual area.

Accessibility

The auth field component comes with a set of built-in accessibility features to ensure a seamless experience for all users.

Use aria-label

To make the auth field recognizable for screen readers, use the default slot to provide a clear and descriptive label. Use the aria-label attribute on the label to provide additional context if the visible label is not sufficient. See the general accessibility documentation for guidance on using oc-aria-label, including how it works with link and masked link behavior.

Invalidate the auth field

If the auth field is invalid, the aria-invalid attribute must be set to true on the input element. You should also provide an error message to the user by using the error slot and reference it in the aria-describedby attribute on the input element.

Further reading

Configuration (v1)

Source: ./src/components/auth-field/v1/Configuration.mdx

Auth field configuration

Configure the auth field component with the controls below and see the changes live in the preview canvas. Click the Show code button within the preview canvas to see the source code for the current component configuration.

Story Default:

<oc-auth-field-v1>
  <input
    type="text"
    aria-describedby="hintXYZ"
    maxlength="250"
    id="inputXYZ"
    aria-required="true"
    spellcheck="false"
    autocorrect="off"
  />
  <label for="inputXYZ">Label</label>
</oc-auth-field-v1>

Interactive configurator (Storybook controls); every option is listed in the API section of this file.

API v1

Source: ./src/components/auth-field/v1/AuthFieldV1.API.g.mdx

Auth Field v1 API

API: <oc-auth-field-v1> (AuthFieldV1)

The auth field component provides a single-line input field for user text input. It is mainly used for login forms or registration forms. The component consists of an input field and a label. The input field can be of type text, email, or password. The component can be styled in a default or error state.

Attributes / properties
Attribute Type Default Required Description
variant "default" | "error" "default" no Sets the main styling and behavior of the auth field.
password-visible boolean false no Allows to programmatically synchronize the toggle state of the password visibility for multiple password fields, e.g. for password confirmation. As a result, both toggle buttons show and hide the password value of both password fields simultaneously. This attribute is only available for auth field components with type="password".
show-counter boolean false no Indicates whether a counter for minlength or maxlength is shown.

Note: minlength and maxlength attributes need to be set on the input element.
without-optional-label boolean false no Excludes the automatic "(optional)" label suffix even if the feature toggle is active.
Slots
Slot Required Description
default yes The input and label elements for the auth field. Both elements need to be present and should be linked by the for attribute of the label element.

Note: Order is important. The input element needs to be before the label element.
hint no Hint for the input field.

The hint needs to be referenced by the aria-describedby attribute of the input element.
error no The error message for the input field.

The error needs to be referenced by the aria-describedby attribute of the input element.

aria-invalid should set to true on the input field.
Events
Event Detail type Description
oc-property-change OcAuthFieldV1Events["oc-property-change"] Whenever a property value changes, this event triggers. Use this event to track all property changes within the component.

Refer to the Events documentation for more information.
oc-mount { component: string; } Fired when the component is mounted to the DOM. The event is fired when the onMount hook of the component is called by the runtime.
oc-unmount { component: string; } Fired when the component is unmounted from the DOM. The event is fired when the function returned by the onMount hook of the component is called by the runtime.

Variations (v1)

Source: ./src/components/auth-field/v1/Variations.mdx

Variations

Listed below are the most common variations of the auth field component as well as specific component variations for different use cases.

You can explore all available options using the component configurator, adjust the component to your needs, and see the changes live in a preview canvas.

Default

Story: components-auth-field-variations--default · tags: components

The default configuration uses the default variant and the default slot to render the input field and the label.

Args: defaultSlot=(see snippet)

<oc-auth-field-v1>
  <input
    type="text"
    aria-describedby="hintXYZ"
    maxlength="250"
    id="inputXYZ"
    aria-required="true"
    spellcheck="false"
    autocorrect="off"
  />
  <label for="inputXYZ">Label</label>
</oc-auth-field-v1>

Error

Story: components-auth-field-variations--invalid · tags: components

The error variant uses the error slot to render the error message, and the hint slot to render a hint.

Args: variant=error, defaultSlot=(see snippet), errorSlot=<div slot="error" id="errorXYZ">Your input is not valid.</div>, hintSlot=<div slot="hint" id="hintXYZ">Some hint</div>

<oc-auth-field-v1 variant="error">
  <div slot="error" id="errorXYZ">Your input is not valid.</div>
  <div slot="hint" id="hintXYZ">Some hint</div>
  <input
    type="email"
    aria-describedby="errorXYZ hintXYZ"
    maxlength="250"
    id="inputXYZ"
    aria-required="true"
    aria-invalid="true"
    autocomplete=""
  />
  <label for="inputXYZ">E-Mail</label>
</oc-auth-field-v1>

Max length

Story: components-auth-field-variations--max-length · tags: components

Variation that uses a text input field in the default slot, limiting the number of characters that can be entered.

Args: show-counter=true, defaultSlot=(see snippet)

<oc-auth-field-v1 show-counter>
  <input
    type="text"
    aria-describedby="hintXYZ"
    maxlength="250"
    id="inputXYZ"
    aria-required="true"
    spellcheck="false"
    autocorrect="off"
  />
  <label for="inputXYZ">Label</label>
</oc-auth-field-v1>

Password

Story: components-auth-field-variations--password · tags: components

Variation that uses a password input field in the default slot, with a hint in the hint slot.

Args: defaultSlot=(see snippet), hintSlot=<div slot="hint" id="hintXYZ">Some hint</div>

<oc-auth-field-v1>
  <div slot="hint" id="hintXYZ">Some hint</div>
  <input
    type="password"
    aria-describedby="hintXYZ"
    minlength="8"
    id="inputXYZ"
    aria-required="true"
    autocomplete=""
  />
  <label for="inputXYZ">Password</label>
</oc-auth-field-v1>

Demo: login form

Story: components-auth-field-variations--demo-login · tags: components

A demo showcasing how to use the oc-auth-field-v1 component in a login form.

<form novalidate>
  <oc-auth-field-v1>
    <input
      name="email"
      type="email"
      aria-describedby="errorEmail hintEmail"
      maxlength="250"
      id="inputEmail"
      aria-required="true"
      spellcheck="false"
      autocorrect="off"
      aria-invalid="false"
    />
    <label for="inputEmail">Username</label>
    <div slot="error" id="errorEmail"></div>
    <div slot="hint" id="hintEmail">Enter username</div>
  </oc-auth-field-v1>

  <oc-auth-field-v1>
    <input
      name="password"
      type="password"
      aria-describedby="errorPassword hintPassword"
      minlength="8"
      id="inputPassword"
      aria-required="true"
      aria-invalid="false"
      autocomplete="current-password"
    />
    <label for="inputPassword">Password</label>
    <div slot="error" id="errorPassword"></div>
    <div slot="hint" id="hintPassword">Enter password</div>
  </oc-auth-field-v1>

  <!-- Workaround for Safari < 18. -->
  <!-- Fixed with Safari 18: https://bugs.webkit.org/show_bug.cgi?id=279595 -->
  <input type="hidden" />

  <div class="buttons">
    <oc-button-v1 type="reset" variant="secondary">Reset</oc-button-v1>
    <oc-button-v1 type="submit">Submit</oc-button-v1>
  </div>
</form>

<script>
  (() => {
    const [form] = document.getElementsByTagName("form");
    const [resetButton] = document.getElementsByTagName("oc-button-v1");
    const [ocEmail, ocPassword] = document.getElementsByTagName("oc-auth-field-v1");
    const inputEmail = document.getElementById("inputEmail");
    const inputPassword = document.getElementById("inputPassword");

    resetButton.addEventListener("click", () => {
      inputEmail.ariaInvalid = "false";
      ocEmail.variant = "default";
      ocEmail.querySelector("[slot=error]").textContent = "";

      inputPassword.ariaInvalid = "false";
      ocPassword.variant = "default";
      ocPassword.querySelector("[slot=error]").textContent = "";
    });

    const validateEmail = () => {
      if (!inputEmail.value) {
        inputEmail.ariaInvalid = "true";
        ocEmail.querySelector("[slot=error]").textContent =
          "Please enter your email address.";
      } else if (inputEmail.validity.typeMismatch) {
        inputEmail.ariaInvalid = "true";
        ocEmail.querySelector("[slot=error]").textContent =
          "Please enter a valid email address.";
      } else {
        inputEmail.ariaInvalid = "false";
        ocEmail.querySelector("[slot=error]").textContent = "";
      }
      ocEmail.variant = inputEmail.ariaInvalid === "true" ? "error" : "default";
    };

    const validatePassword = () => {
      if (!inputPassword.value) {
        inputPassword.ariaInvalid = "true";
        ocPassword.querySelector("[slot=error]").textContent = "Please enter your password.";
      } else if (inputPassword.value.length < 8) {
        inputPassword.ariaInvalid = "true";
        ocPassword.querySelector("[slot=error]").textContent =
          "Password must be at least 8 characters long.";
      } else {
        inputPassword.ariaInvalid = "false";
        ocPassword.querySelector("[slot=error]").textContent = "";
      }

      ocPassword.variant = inputPassword.ariaInvalid === "true" ? "error" : "default";
    };

    form.addEventListener("submit", (ev) => {
      validateEmail();
      validatePassword();

      // focus the first invalid field
      if (inputEmail.ariaInvalid === "true") {
        inputEmail.focus();
      } else if (inputPassword.ariaInvalid === "true") {
        inputPassword.focus();
      }

      if (inputEmail.ariaInvalid !== "true" && inputPassword.ariaInvalid !== "true") {
        let formData = new FormData(form);
        formData.set("password", "********"); // just in case
        let data = JSON.stringify(Object.fromEntries(formData));
        alert("sending " + data);
      }
      ev.preventDefault();
    });
  })();
</script>

${safariObscureHint}

<style>
  form {
    display: flex;
    flex-direction: column;
    gap: 24px;
  }

  .buttons {
    display: flex;
    gap: 16px;
  }
</style>
Story source (TypeScript, verbatim from Storybook)
{
  name: "Demo: login form",
  parameters: {
    controls: {
      disabled: true
    }
  },
  argTypes: hideControlsBadge(Metadata),
  render() {
    return html`
      <form novalidate>
        <oc-auth-field-v1>
          <input
            name="email"
            type="email"
            aria-describedby="errorEmail hintEmail"
            maxlength="250"
            id="inputEmail"
            aria-required="true"
            spellcheck="false"
            autocorrect="off"
            aria-invalid="false"
          />
          <label for="inputEmail">Username</label>
          <div slot="error" id="errorEmail"></div>
          <div slot="hint" id="hintEmail">Enter username</div>
        </oc-auth-field-v1>

        <oc-auth-field-v1>
          <input
            name="password"
            type="password"
            aria-describedby="errorPassword hintPassword"
            minlength="8"
            id="inputPassword"
            aria-required="true"
            aria-invalid="false"
            autocomplete="current-password"
          />
          <label for="inputPassword">Password</label>
          <div slot="error" id="errorPassword"></div>
          <div slot="hint" id="hintPassword">Enter password</div>
        </oc-auth-field-v1>

        <!-- Workaround for Safari < 18. -->
        <!-- Fixed with Safari 18: https://bugs.webkit.org/show_bug.cgi?id=279595 -->
        <input type="hidden" />

        <div class="buttons">
          <oc-button-v1 type="reset" variant="secondary">Reset</oc-button-v1>
          <oc-button-v1 type="submit">Submit</oc-button-v1>
        </div>
      </form>

      <script>
        (() => {
          const [form] = document.getElementsByTagName("form");
          const [resetButton] = document.getElementsByTagName("oc-button-v1");
          const [ocEmail, ocPassword] = document.getElementsByTagName("oc-auth-field-v1");
          const inputEmail = document.getElementById("inputEmail");
          const inputPassword = document.getElementById("inputPassword");

          resetButton.addEventListener("click", () => {
            inputEmail.ariaInvalid = "false";
            ocEmail.variant = "default";
            ocEmail.querySelector("[slot=error]").textContent = "";

            inputPassword.ariaInvalid = "false";
            ocPassword.variant = "default";
            ocPassword.querySelector("[slot=error]").textContent = "";
          });

          const validateEmail = () => {
            if (!inputEmail.value) {
              inputEmail.ariaInvalid = "true";
              ocEmail.querySelector("[slot=error]").textContent =
                "Please enter your email address.";
            } else if (inputEmail.validity.typeMismatch) {
              inputEmail.ariaInvalid = "true";
              ocEmail.querySelector("[slot=error]").textContent =
                "Please enter a valid email address.";
            } else {
              inputEmail.ariaInvalid = "false";
              ocEmail.querySelector("[slot=error]").textContent = "";
            }
            ocEmail.variant = inputEmail.ariaInvalid === "true" ? "error" : "default";
          };

          const validatePassword = () => {
            if (!inputPassword.value) {
              inputPassword.ariaInvalid = "true";
              ocPassword.querySelector("[slot=error]").textContent = "Please enter your password.";
            } else if (inputPassword.value.length < 8) {
              inputPassword.ariaInvalid = "true";
              ocPassword.querySelector("[slot=error]").textContent =
                "Password must be at least 8 characters long.";
            } else {
              inputPassword.ariaInvalid = "false";
              ocPassword.querySelector("[slot=error]").textContent = "";
            }

            ocPassword.variant = inputPassword.ariaInvalid === "true" ? "error" : "default";
          };

          form.addEventListener("submit", (ev) => {
            validateEmail();
            validatePassword();

            // focus the first invalid field
            if (inputEmail.ariaInvalid === "true") {
              inputEmail.focus();
            } else if (inputPassword.ariaInvalid === "true") {
              inputPassword.focus();
            }

            if (inputEmail.ariaInvalid !== "true" && inputPassword.ariaInvalid !== "true") {
              let formData = new FormData(form);
              formData.set("password", "********"); // just in case
              let data = JSON.stringify(Object.fromEntries(formData));
              alert("sending " + data);
            }
            ev.preventDefault();
          });
        })();
      </script>

      ${safariObscureHint}

      <style>
        form {
          display: flex;
          flex-direction: column;
          gap: 24px;
        }

        .buttons {
          display: flex;
          gap: 16px;
        }
      </style>
    `;
  }
}

Demo: password repeat

Story: components-auth-field-variations--demo-password-repeat · tags: components

A demo showcasing how to use the oc-auth-field-v1 component in a registration form with a password repeat field.

<form novalidate>
  <oc-auth-field-v1>
    <input
      name="password"
      type="password"
      aria-describedby="errorPassword1 hintPassword1"
      minlength="8"
      id="inputPassword1"
      aria-required="true"
      aria-invalid="false"
      autocomplete="new-password"
    />
    <label for="inputPassword1">Password</label>
    <div slot="error" id="errorPassword1"></div>
    <div slot="hint" id="hintPassword1">Enter password</div>
  </oc-auth-field-v1>

  <oc-auth-field-v1>
    <input
      name="password"
      type="password"
      aria-describedby="errorPassword2 hintPassword2"
      minlength="8"
      id="inputPassword2"
      aria-required="true"
      aria-invalid="false"
      autocomplete="new-password"
    />
    <label for="inputPassword2">Repeat</label>
    <div slot="error" id="errorPassword2"></div>
    <div slot="hint" id="hintPassword2">Repeat password</div>
  </oc-auth-field-v1>
</form>

${safariObscureHint}

<script>
  (() => {
    const [ocPassword1, ocPassword2] = document.getElementsByTagName("oc-auth-field-v1");

    ocPassword1.addEventListener("oc-property-change", (ev) => {
      if (ev.detail.passwordVisible) {
        ocPassword2.passwordVisible = ev.detail.passwordVisible.current;
      }
    });

    ocPassword2.addEventListener("oc-property-change", (ev) => {
      if (ev.detail.passwordVisible) {
        ocPassword1.passwordVisible = ev.detail.passwordVisible.current;
      }
    });
  })();
</script>

<style>
  form {
    display: flex;
    flex-direction: column;
    gap: 24px;
  }

  .buttons {
    display: flex;
    gap: 16px;
  }
</style>
Story source (TypeScript, verbatim from Storybook)
{
  name: "Demo: password repeat",
  parameters: {
    controls: {
      disabled: true
    }
  },
  argTypes: hideControlsBadge(Metadata),
  render() {
    return html`
      <form novalidate>
        <oc-auth-field-v1>
          <input
            name="password"
            type="password"
            aria-describedby="errorPassword1 hintPassword1"
            minlength="8"
            id="inputPassword1"
            aria-required="true"
            aria-invalid="false"
            autocomplete="new-password"
          />
          <label for="inputPassword1">Password</label>
          <div slot="error" id="errorPassword1"></div>
          <div slot="hint" id="hintPassword1">Enter password</div>
        </oc-auth-field-v1>

        <oc-auth-field-v1>
          <input
            name="password"
            type="password"
            aria-describedby="errorPassword2 hintPassword2"
            minlength="8"
            id="inputPassword2"
            aria-required="true"
            aria-invalid="false"
            autocomplete="new-password"
          />
          <label for="inputPassword2">Repeat</label>
          <div slot="error" id="errorPassword2"></div>
          <div slot="hint" id="hintPassword2">Repeat password</div>
        </oc-auth-field-v1>
      </form>

      ${safariObscureHint}

      <script>
        (() => {
          const [ocPassword1, ocPassword2] = document.getElementsByTagName("oc-auth-field-v1");

          ocPassword1.addEventListener("oc-property-change", (ev) => {
            if (ev.detail.passwordVisible) {
              ocPassword2.passwordVisible = ev.detail.passwordVisible.current;
            }
          });

          ocPassword2.addEventListener("oc-property-change", (ev) => {
            if (ev.detail.passwordVisible) {
              ocPassword1.passwordVisible = ev.detail.passwordVisible.current;
            }
          });
        })();
      </script>

      <style>
        form {
          display: flex;
          flex-direction: column;
          gap: 24px;
        }

        .buttons {
          display: flex;
          gap: 16px;
        }
      </style>
    `;
  }
}

Interaction tests (AuthFieldV1.interactions.stories.ts)

Interaction test stories (automated tests, not usage patterns).

Label Should Be On The Top

Story: components-auth-field-interaction-tests--label-should-be-on-the-top · tags: components, auth-field, v1, play-fn

<oc-auth-field-v1>
  <input
    name="email"
    type="email"
    aria-describedby="hintEmail"
    maxlength="250"
    id="inputEmail"
    aria-required="true"
    spellcheck="false"
    autocorrect="off"
  />
  <label for="inputEmail">Username</label>
  <div slot="hint" id="hintEmail">Enter username</div>
</oc-auth-field-v1>
Story source (TypeScript, verbatim from Storybook)
{
  render: () => html`
    <oc-auth-field-v1>
      <input
        name="email"
        type="email"
        aria-describedby="hintEmail"
        maxlength="250"
        id="inputEmail"
        aria-required="true"
        spellcheck="false"
        autocorrect="off"
      />
      <label for="inputEmail">Username</label>
      <div slot="hint" id="hintEmail">Enter username</div>
    </oc-auth-field-v1>
  `,
  play: async ({
    canvasElement
  }) => {
    const input = canvasElement.querySelector("input")!;
    const label = canvasElement.querySelector("label")!;
    await expect(label.getBoundingClientRect().toJSON(), "should initially be not on the top").toMatchObject({
      bottom: 52,
      height: 24,
      left: 28,
      top: 28,
      x: 28,
      y: 28
    });
    await userEvent.type(input, "foo@bar.de");
    await waitFor(() => expect(label.getBoundingClientRect().toJSON(), "should be on the top").toMatchObject({
      bottom: 24,
      height: 16,
      left: 28,
      top: 8,
      x: 28,
      y: 8
    }));
  }
}

Password Toggle Should Show Password

Story: components-auth-field-interaction-tests--password-toggle-should-show-password · tags: components, auth-field, v1, play-fn

<oc-auth-field-v1>
  <input
    name="password"
    type="password"
    aria-describedby="hintPassword"
    maxlength="250"
    id="inputPassword"
    aria-required="true"
  />
  <label for="inputPassword">Password</label>
  <div slot="hint" id="hintPassword">Enter password</div>
</oc-auth-field-v1>
Story source (TypeScript, verbatim from Storybook)
{
  render: () => html`
    <oc-auth-field-v1>
      <input
        name="password"
        type="password"
        aria-describedby="hintPassword"
        maxlength="250"
        id="inputPassword"
        aria-required="true"
      />
      <label for="inputPassword">Password</label>
      <div slot="hint" id="hintPassword">Enter password</div>
    </oc-auth-field-v1>
  `,
  play: async ({
    canvasElement
  }) => {
    const input = canvasElement.querySelector("input")!;
    const passwordToggle = canvasElement.querySelector("oc-auth-field-v1")!.shadowRoot!.querySelector(".auth-field__password-toggle")! as HTMLElement;
    await expect(passwordToggle).toBeVisible();
    await expect(input.getAttribute("type"), "password should be visible").toBe("password");
    await userEvent.type(input, "MyTestPassword123");
    await click(passwordToggle);
    await expect(input.getAttribute("type"), "password should be visible").toBe("text");
  }
}

Hint And Error Should Have Correct Colors

Story: components-auth-field-interaction-tests--hint-and-error-should-have-correct-colors · tags: components, auth-field, v1, play-fn

<oc-auth-field-v1 variant="error">
  <input
    type="email"
    aria-describedby="errorXYZ hintXYZ"
    maxlength="250"
    id="inputXYZ"
    aria-required="true"
    aria-invalid="true"
    autocomplete=""
  />
  <label for="inputXYZ">E-Mail</label>
  <div slot="error" id="errorXYZ">Your input is not valid.</div>
  <div slot="hint" id="hintXYZ">Some hint</div>
</oc-auth-field-v1>
Story source (TypeScript, verbatim from Storybook)
{
  render: () => html`
    <oc-auth-field-v1 variant="error">
      <input
        type="email"
        aria-describedby="errorXYZ hintXYZ"
        maxlength="250"
        id="inputXYZ"
        aria-required="true"
        aria-invalid="true"
        autocomplete=""
      />
      <label for="inputXYZ">E-Mail</label>
      <div slot="error" id="errorXYZ">Your input is not valid.</div>
      <div slot="hint" id="hintXYZ">Some hint</div>
    </oc-auth-field-v1>
  `,
  play: async ({
    canvasElement
  }) => {
    const hint = canvasElement.querySelector("[slot=hint]")!;
    const error = canvasElement.querySelector("[slot=error]")!;
    await expect(getComputedStyle(hint).color).toBe("rgb(109, 109, 109)");
    await expect(getComputedStyle(error).color).toBe("rgb(179, 0, 35)");
  }
}

Should Submit Form On Enter

Story: components-auth-field-interaction-tests--should-submit-form-on-enter · tags: components, auth-field, v1, play-fn

<form>
  <oc-auth-field-v1>
    <input
      name="email"
      type="email"
      aria-describedby="hintEmail"
      maxlength="250"
      id="inputEmail"
      aria-required="true"
      spellcheck="false"
      autocorrect="off"
    />
    <label for="inputEmail">Username</label>
    <div slot="hint" id="hintEmail">Enter username</div>
  </oc-auth-field-v1>

  <br />

  <oc-auth-field-v1>
    <input
      name="password"
      type="password"
      aria-describedby="hintPassword"
      maxlength="250"
      id="inputPassword"
      aria-required="true"
    />
    <label for="inputPassword">Password</label>
    <div slot="hint" id="hintPassword">Enter password</div>
  </oc-auth-field-v1>

  <br />

  <button type="submit">Submit</button>
</form>
Story source (TypeScript, verbatim from Storybook)
{
  render: () => html`
    <form>
      <oc-auth-field-v1>
        <input
          name="email"
          type="email"
          aria-describedby="hintEmail"
          maxlength="250"
          id="inputEmail"
          aria-required="true"
          spellcheck="false"
          autocorrect="off"
        />
        <label for="inputEmail">Username</label>
        <div slot="hint" id="hintEmail">Enter username</div>
      </oc-auth-field-v1>

      <br />

      <oc-auth-field-v1>
        <input
          name="password"
          type="password"
          aria-describedby="hintPassword"
          maxlength="250"
          id="inputPassword"
          aria-required="true"
        />
        <label for="inputPassword">Password</label>
        <div slot="hint" id="hintPassword">Enter password</div>
      </oc-auth-field-v1>

      <br />

      <button type="submit">Submit</button>
    </form>
  `,
  play: async ({
    canvasElement
  }) => {
    const form = canvasElement.querySelector("form")!;
    const button = canvasElement.querySelector("button")!;
    const [emailInput, passwordInput] = Array.from(canvasElement.querySelectorAll("input"));
    let formData;
    form.addEventListener("submit", event => {
      event.preventDefault();
      formData = Object.fromEntries(new FormData(form).entries());
    });
    await userEvent.type(emailInput, "foo@bar.de");
    await userEvent.type(passwordInput, "MyTestPassword123");
    await click(button);
    await expect(formData).toStrictEqual({
      email: "foo@bar.de",
      password: "MyTestPassword123"
    });
  }
}