OTTODesign System

Code

Button

Storybook group: Components · Sidebar path: Components/Button · Extracted 28.09.2026

Version Tag Status API
v1 <oc-button-v1> Stable, allowed for generation ButtonV1

Overview (v1)

Source: ./src/components/button/v1/Overview.mdx

Button

The button component is used to trigger specific actions in your application. It supports various styles, sizes, and can incorporate icons. It can be configured to fit content, be disabled, and set with an ARIA label for accessibility.

Default variation

Story Default:

<oc-button-v1 variant="primary" size="100">Label</oc-button-v1>

Configuration

The button component is available in the main styling variants primary, secondary, and tertiary. 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 button 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 Button UX documentation for detailed user experience guidelines.

Accessibility

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

Keyboard navigation
Shortcut Description
Space / Enter Activates the button

Important

If the button component or a normal HTML button with the type submit is used in a form, pressing Enter on any of the following form elements triggers the submit action immediately (implicit submit): oc-checkbox, oc-radio-button, oc-switch-v2, oc-selection-tile, and oc-text-field.

Use oc-aria-label

To make the button recognizable for screen readers, use the oc-aria-label attribute to provide clear and descriptive context information. See the general accessibility documentation for guidance on using oc-aria-label, including how it works with link and masked link behavior.

Multiple buttons

When multiple buttons with the same visible label are present, ensure each button is uniquely labeled so users understand their purpose.

See an example here.

Buttons with icons

The icon inside the button component has the state aria-hidden="true" , which hides the icon from assistive technologies and prevents it from being read out loud by screen readers.

Further reading

Configuration (v1)

Source: ./src/components/button/v1/Configuration.mdx

Button configuration

Configure the button 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-button-v1 variant="primary" size="100">Label</oc-button-v1>

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

API v1

Source: ./src/components/button/v1/ButtonV1.API.g.mdx

Button v1 API

API: <oc-button-v1> (ButtonV1)

The Button component is used to trigger specific actions in your application. It supports various styles, sizes, and can incorporate icons. It can be configured to fit content, be disabled, and set with an ARIA label for accessibility.

Attributes / properties
Attribute Type Default Required Description
variant "primary" | "secondary" | "secondary-on-color" | "secondary-over-color" | "tertiary" | "custom-color-strong" | "custom-color-soft" "primary" no Sets the main styling variant of the button.

Note: "secondary-on-color" is deprecated, use "secondary-over-color" instead.
type "submit" | "reset" | "button" undefined no Sets the type of the button. Can be one of: "button", "reset", "submit".
size "50" | "100" "100" no Sets the size of the button.
icon-type-left icon name (428 values; see Icon list in `storybook/components/icon/README.md`) undefined no Sets the icon for the left side of the button.
icon-type-right icon name (428 values; see Icon list in `storybook/components/icon/README.md`) undefined no Sets the icon for the right side of the button.
disabled boolean false no Indicates whether the button is disabled.
loading "" | "keep-native-events" | "true" false no Indicates whether the button is in a loading state. When enabled, displays a loading animation.

For buttons of type "submit", the loading state also stops the propagation of click events after the first click to prevent multiple form submissions. Set to "keep-native-events" to enable the loading state but keep native events for this case.

Note: The loading animation overwrites the icon on the right side set via icon-type-right.
success boolean false no Indicates whether the button is in a success state. This is a visual state that indicates a successful action. It does not change the functionality of the button.
fit-content boolean false no Indicates whether the button width should fit its content. This attribute only applies to size="100".
fill-parent boolean false no Indicates whether the button width should fill to its parent. This attribute only applies to size="50".
form string undefined no Sets the form id the button belongs to. Only available as an attribute.
formaction string undefined no Sets the action for form submission. Only available as an attribute. Only for type="submit".
formmethod string undefined no Sets the method for form submission. Only available as an attribute. Only for type="submit".
href string undefined no Deprecated: Use an a tag in the default slot instead.

Visit SEO optimization techniques for more information.

Sets the link target of the button.
base64-href string undefined no Sets the base64 encoded link target of the button. Use this attribute to prevent search engines from indexing the link target.
target "_blank" | "_self" | "_parent" | "_top" undefined no Sets the target attribute of the button.

Note: Only applies when href or base64Href is set.
rel string undefined no Sets the rel attribute of the button.

Note: Only applies when href or base64Href is set.
oc-aria-label string undefined no Sets the ARIA label of the button.
Slots
Slot Required Description
default yes Sets the text content of the button.
Events
Event Detail type Description
click PointerEvent Clicking the button or pressing Enter or Space while the button is focused triggers the click event.
oc-property-change OcButtonV1Events["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.
CSS custom properties
Custom property Default Description
--background-color undefined Sets a custom background color through a CSS variable.

Note: The preferred way of using colors is via design tokens instead of hex values.

Note: This CSS variable only applies when using variant="custom-color-strong" or variant="custom-color-soft".
--text-color undefined Sets a custom text color through a CSS variable.

Note: The preferred way of using colors is via design tokens instead of hex values.

Note: This CSS variable only applies when using variant="custom-color-strong" or variant="custom-color-soft".

Variations (v1)

Source: ./src/components/button/v1/Variations.mdx

Variations

Listed below are the most common variations of the button 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-button-variations--default · tags: components, button, v1

The default configuration uses size=100 and the primary variant.

<oc-button-v1 variant="primary" size="100">Label</oc-button-v1>

Primary 100 with icons left and right

Story: components-button-variations--button-100-primary-with-icons-left-right · tags: components, button, v1

The primary variant, with size=100, and icons on both the left and right sides.

Args: icon-type-left=arrow-left, icon-type-right=arrow-right

<oc-button-v1 variant="primary" size="100" icon-type-left="arrow-left" icon-type-right="arrow-right">
  Label
</oc-button-v1>

Primary 100 with fit-content and icon left

Story: components-button-variations--button-100-primary-with-icon-left-fit-content · tags: components, button, v1

The primary variant, with size=100 and fit-content=true, and an icon on the left side.

Args: icon-type-left=arrow-left, fit-content=true

<oc-button-v1 variant="primary" size="100" icon-type-left="arrow-left" fit-content>Label</oc-button-v1>

Primary 100 disabled with icon right

Story: components-button-variations--button-100-primary-disabled-with-icon-right · tags: components, button, v1

The disabled primary variant, with size=100, and an icon on the right side.

Args: icon-type-right=arrow-right, disabled=true

<oc-button-v1 variant="primary" size="100" icon-type-right="arrow-right" disabled>Label</oc-button-v1>

Primary 100 with loading state

Story: components-button-variations--button-100-with-loading-state · tags: components, button, v1

The primary variant, with size=100, and the setting for showing a loading spinner. Make sure to place the button as well as the placeholder / actual content in a common parent container. This way - together witha couple of aria roles -, a screen reader is able to identify the container as a status / live region that is being updated.

Args: size=100, variant=primary, defaultSlot=Label, loading=true

<div role="status" aria-live="assertive" aria-atomic="true">
  <!-- Content goes here -->
  <oc-button-v1 variant="primary" size="100" loading>Label</oc-button-v1>
</div>
Story source (TypeScript, verbatim from Storybook)
{
  name: "Primary 100 with loading state",
  parameters: {
    // Disables Chromatic's snapshotting, as the spinner is animated und thus leads to diffs
    chromatic: {
      disableSnapshot: true,
      hideInChromatic: true
    }
  },
  args: {
    size: "100",
    variant: "primary",
    defaultSlot: "Label",
    loading: true
  },
  render(args) {
    const {
      defaultSlot,
      ...props
    } = args;
    return html` <div role="status" aria-live="assertive" aria-atomic="true">
      <!-- Content goes here -->
      <oc-button-v1 ${spread(props)}>${unsafeHTML(defaultSlot)}</oc-button-v1>
    </div>`;
  }
}

Primary 50 with icons left and right

Story: components-button-variations--button-50-primary-with-icons-left-right · tags: components, button, v1

The primary variant, with size=50, and icons on both the left and right sides.

Args: size=50, icon-type-left=arrow-left, icon-type-right=arrow-right

<oc-button-v1 variant="primary" size="50" icon-type-left="arrow-left" icon-type-right="arrow-right">
  Label
</oc-button-v1>

Primary 50 disabled with icon right

Story: components-button-variations--button-50-primary-disabled-with-icon-right · tags: components, button, v1

The disabled primary variant, with size=50, and an icon on the right side.

Args: size=50, icon-type-right=arrow-right, disabled=true

<oc-button-v1 variant="primary" size="50" icon-type-right="arrow-right" disabled>Label</oc-button-v1>

Secondary 100

Story: components-button-variations--button-100-secondary · tags: components, button, v1

The secondary variant, with size=100.

Args: variant=secondary

<oc-button-v1 variant="secondary" size="100">Label</oc-button-v1>

Secondary 50

Story: components-button-variations--button-50-secondary · tags: components, button, v1

The secondary variant, with size=50.

Args: size=50, variant=secondary

<oc-button-v1 variant="secondary" size="50">Label</oc-button-v1>

Secondary-Over-Color 100

Story: components-button-variations--button-100-secondary-on-color · tags: components, button, v1

The secondary-over-color variant, with size=100.

Args: variant=secondary-over-color

<oc-button-v1 variant="secondary-over-color" size="100">Label</oc-button-v1>

Tertiary 100 with icon left

Story: components-button-variations--button-100-tertiary-with-icon-left · tags: components, button, v1

The tertiary variant, with size=100, and an icon on the left side.

Args: variant=tertiary, icon-type-left=arrow-left

<oc-button-v1 variant="tertiary" size="100" icon-type-left="arrow-left">Label</oc-button-v1>

Tertiary 50 with icon left

Story: components-button-variations--button-50-tertiary-with-icon-left · tags: components, button, v1

The tertiary variant, with size=50, and an icon on the left side.

Args: size=50, variant=tertiary, icon-type-left=arrow-left

<oc-button-v1 variant="tertiary" size="50" icon-type-left="arrow-left">Label</oc-button-v1>

Story: components-button-variations--with-link-behavior · tags: components, button, v1

The button component as a link. Search engines will detect the href.

Args: defaultSlot=<!--The <a> tag should be placed inside the light DOM to improve SEO.--><a href='#'>Label</a>

<oc-button-v1 variant="primary" size="100">
  <!--The <a> tag should be placed inside the light DOM to improve SEO.--><a href='#'>Label</a>
</oc-button-v1>

Story: components-button-variations--with-masked-link-behavior · tags: components, button, v1

The button component as a masked link. Search engines will not detect the href.

Args: base64-href=Iw==

<oc-button-v1 variant="primary" size="100" base64-href="Iw==">Label</oc-button-v1>

Story: components-button-variations--with-link-switch-behavior · tags: components, button, v1

The button component as a link with switch behavior. Search engines will detect the href, while users receive the Base64-encoded version.

Args: base64-href=Iz92YXJpYW50PWZvbw==, defaultSlot=<!--Search engines will detect the href, while users receive the Base64-encoded version.--><a href='#'>Label</a>

<oc-button-v1 variant="primary" size="100" base64-href="Iz92YXJpYW50PWZvbw==">
  <!--Search engines will detect the href, while users receive the Base64-encoded version.--><a href='#'>Label</a>
</oc-button-v1>

Demo: accessible multiple buttons

Story: components-button-variations--demo-accessible-multiple-buttons · tags: components, button, v1

Demonstration of how to make multiple buttons with the same label accessible.

<ul>
  <li class="oc-p-50">
    Produkt 1
    <oc-button-v1
      size="50"
      variant="primary"
      oc-aria-label="Produkt 1 zum Warenkorb hinzufügen"
      >Zum Warenkorb hinzufügen
    </oc-button-v1>
  </li>
  <li class="oc-p-50">
    Produkt 2
    <oc-button-v1
      size="50"
      variant="primary"
      oc-aria-label="Produkt 2 zum Warenkorb hinzufügen"
      >Zum Warenkorb hinzufügen
    </oc-button-v1>
  </li>
  <li class="oc-p-50">
    Produkt 3
    <oc-button-v1
      size="50"
      variant="primary"
      oc-aria-label="Produkt 2 zum Warenkorb hinzufügen"
      >Zum Warenkorb hinzufügen
    </oc-button-v1>
  </li>
</ul>
Story source (TypeScript, verbatim from Storybook)
{
  name: "Demo: accessible multiple buttons",
  parameters: {
    controls: {
      disabled: true
    }
  },
  argTypes: hideControlsBadge(Metadata),
  render() {
    return html`
      <ul>
        <li class="oc-p-50">
          Produkt 1
          <oc-button-v1
            size="50"
            variant="primary"
            oc-aria-label="Produkt 1 zum Warenkorb hinzufügen"
            >Zum Warenkorb hinzufügen
          </oc-button-v1>
        </li>
        <li class="oc-p-50">
          Produkt 2
          <oc-button-v1
            size="50"
            variant="primary"
            oc-aria-label="Produkt 2 zum Warenkorb hinzufügen"
            >Zum Warenkorb hinzufügen
          </oc-button-v1>
        </li>
        <li class="oc-p-50">
          Produkt 3
          <oc-button-v1
            size="50"
            variant="primary"
            oc-aria-label="Produkt 2 zum Warenkorb hinzufügen"
            >Zum Warenkorb hinzufügen
          </oc-button-v1>
        </li>
      </ul>
    `;
  }
}

Demo: secondary button to success

Story: components-button-variations--demo-secondary-button-to-success · tags: components, button, v1

Demonstration of how to change a secondary button to a success button.

<oc-button-v1 id="follow" size="100" variant="secondary" fit-content oc-aria-label="Folgen">
  Folgen
</oc-button-v1>

<script>
  (() => {
    const followButton = document.getElementById("follow");
    followButton.addEventListener("click", followAction);

    function followAction() {
      followButton.success = true;
      followButton.iconTypeLeft = "check";
      followButton.innerHTML = "Gefolgt";
      followButton.ocAriaLabel = "Gefolgt";

      followButton.removeEventListener("click", followAction);
      followButton.addEventListener("click", unfollowAction);
    }

    function unfollowAction() {
      followButton.success = false;
      followButton.iconTypeLeft = undefined;
      followButton.innerHTML = "Folgen";
      followButton.ocAriaLabel = "Folgen";

      followButton.removeEventListener("click", unfollowAction);
      followButton.addEventListener("click", followAction);
    }
  })();
</script>
Story source (TypeScript, verbatim from Storybook)
{
  name: "Demo: secondary button to success",
  parameters: {
    controls: {
      disabled: true
    }
  },
  argTypes: hideControlsBadge(Metadata),
  render() {
    return html`
      <oc-button-v1 id="follow" size="100" variant="secondary" fit-content oc-aria-label="Folgen">
        Folgen
      </oc-button-v1>

      <script>
        (() => {
          const followButton = document.getElementById("follow");
          followButton.addEventListener("click", followAction);

          function followAction() {
            followButton.success = true;
            followButton.iconTypeLeft = "check";
            followButton.innerHTML = "Gefolgt";
            followButton.ocAriaLabel = "Gefolgt";

            followButton.removeEventListener("click", followAction);
            followButton.addEventListener("click", unfollowAction);
          }

          function unfollowAction() {
            followButton.success = false;
            followButton.iconTypeLeft = undefined;
            followButton.innerHTML = "Folgen";
            followButton.ocAriaLabel = "Folgen";

            followButton.removeEventListener("click", unfollowAction);
            followButton.addEventListener("click", followAction);
          }
        })();
      </script>
    `;
  }
}

Demo: using native button element

Story: components-button-variations--demo-using-native-button-element · tags: components, button, v1

Args: defaultSlot=<button>Label</button>

<oc-button-v1 variant="primary" size="100"><button>Label</button></oc-button-v1>

Custom color strong

Story: components-button-variations--custom-color-strong · tags: components, button, v1

Demonstration of how to use the custom-color-strong variant with custom background and text colors.

Args: variant=custom-color-strong, icon-type-left=smiley-positive, icon-type-right=arrow-right, --background-color=var(--oc-semantic-color-background-strong-pink), --text-color=var(--oc-semantic-color-background-soft-pink)

<oc-button-v1 class="${className}" variant="custom-color-strong" size="100" icon-type-left="smiley-positive" icon-type-right="arrow-right" style="--background-color: var(--oc-semantic-color-background-strong-pink); --text-color: var(--oc-semantic-color-background-soft-pink)">Label</oc-button-v1>
${css}
Story source (TypeScript, verbatim from Storybook)
{
  name: "Custom color strong",
  args: {
    variant: "custom-color-strong",
    "icon-type-left": "smiley-positive",
    "icon-type-right": "arrow-right",
    "--background-color": "var(--oc-semantic-color-background-strong-pink)",
    "--text-color": "var(--oc-semantic-color-background-soft-pink)"
  },
  render(args) {
    const {
      defaultSlot,
      ...props
    } = args;
    const {
      css,
      className
    } = cssVariablesExample(props);
    return html`
      <oc-button-v1 class="${className}" ${spread(props)}>${unsafeHTML(defaultSlot)}</oc-button-v1>
      ${css}
    `;
  }
}

Custom color soft

Story: components-button-variations--custom-color-soft · tags: components, button, v1

Demonstration of how to use the custom-color-soft variant with custom background and text colors.

Args: variant=custom-color-soft, icon-type-left=smiley-positive, icon-type-right=arrow-right, --background-color=var(--oc-semantic-color-background-soft-pink), --text-color=var(--oc-semantic-color-background-strong-pink)

<oc-button-v1 class="${className}" variant="custom-color-soft" size="100" icon-type-left="smiley-positive" icon-type-right="arrow-right" style="--background-color: var(--oc-semantic-color-background-soft-pink); --text-color: var(--oc-semantic-color-background-strong-pink)">Label</oc-button-v1>
${css}
Story source (TypeScript, verbatim from Storybook)
{
  name: "Custom color soft",
  args: {
    variant: "custom-color-soft",
    "icon-type-left": "smiley-positive",
    "icon-type-right": "arrow-right",
    "--background-color": "var(--oc-semantic-color-background-soft-pink)",
    "--text-color": "var(--oc-semantic-color-background-strong-pink)"
  },
  render(args) {
    const {
      defaultSlot,
      ...props
    } = args;
    const {
      css,
      className
    } = cssVariablesExample(props);
    return html`
      <oc-button-v1 class="${className}" ${spread(props)}>${unsafeHTML(defaultSlot)}</oc-button-v1>
      ${css}
    `;
  }
}

Demo: custom colors

Story: components-button-variations--demo-custom-colors · tags: components, button, v1

Comprehensive demo showcasing multiple buttons with custom background and text colors using design tokens. This demo demonstrates the flexibility of the custom-color-strong and custom-color-soft variants.

<style>
  .demo-custom-colors {
    display: flex;
    flex-direction: column;
    gap: 2rem;
    padding: 2rem;
  }

  .demo-row {
    display: flex;
    gap: 1rem;
    flex-wrap: wrap;
    align-items: center;
  }

  .demo-item {
    width: 100%;
    max-width: 328px;
  }
</style>

<div class="demo-custom-colors">
  <div class="demo-row">
    <div class="demo-item">
      <oc-button-v1
        variant="custom-color-strong"
        icon-type-left="smiley-positive"
        icon-type-right="arrow-right"
        style="--background-color: var(--oc-semantic-color-background-strong-mint);"
      >
        Label
      </oc-button-v1>
    </div>

    <div class="demo-item">
      <oc-button-v1
        variant="custom-color-strong"
        icon-type-left="smiley-positive"
        icon-type-right="arrow-right"
        style="--background-color: var(--oc-semantic-color-background-strong-pink); --text-color: var(--oc-semantic-color-background-soft-pink)"
      >
        Label
      </oc-button-v1>
    </div>

    <div class="demo-item">
      <oc-button-v1
        variant="custom-color-strong"
        icon-type-left="smiley-positive"
        icon-type-right="arrow-right"
        style="--background-color: #522EB7"
      >
        Label
      </oc-button-v1>
    </div>
  </div>

  <div class="demo-row" style="margin-top: 1.5rem">
    <div class="demo-item">
      <oc-button-v1
        variant="custom-color-soft"
        icon-type-left="smiley-positive"
        icon-type-right="arrow-right"
        style="--background-color: var(--oc-semantic-color-background-soft-mint)"
      >
        Label
      </oc-button-v1>
    </div>

    <div class="demo-item">
      <oc-button-v1
        variant="custom-color-soft"
        icon-type-left="smiley-positive"
        icon-type-right="arrow-right"
        style="--background-color: var(--oc-semantic-color-background-soft-pink); --text-color: var(--oc-semantic-color-background-strong-pink)"
      >
        Label
      </oc-button-v1>
    </div>

    <div class="demo-item">
      <oc-button-v1
        variant="custom-color-soft"
        icon-type-left="smiley-positive"
        icon-type-right="arrow-right"
        style="--background-color: #E5D5FE; --text-color: #522EB7"
      >
        Label
      </oc-button-v1>
    </div>
  </div>
</div>
Story source (TypeScript, verbatim from Storybook)
{
  name: "Demo: custom colors",
  parameters: {
    controls: {
      disabled: true
    }
  },
  argTypes: hideControlsBadge(Metadata),
  render() {
    return html`
      <style>
        .demo-custom-colors {
          display: flex;
          flex-direction: column;
          gap: 2rem;
          padding: 2rem;
        }

        .demo-row {
          display: flex;
          gap: 1rem;
          flex-wrap: wrap;
          align-items: center;
        }

        .demo-item {
          width: 100%;
          max-width: 328px;
        }
      </style>

      <div class="demo-custom-colors">
        <div class="demo-row">
          <div class="demo-item">
            <oc-button-v1
              variant="custom-color-strong"
              icon-type-left="smiley-positive"
              icon-type-right="arrow-right"
              style="--background-color: var(--oc-semantic-color-background-strong-mint);"
            >
              Label
            </oc-button-v1>
          </div>

          <div class="demo-item">
            <oc-button-v1
              variant="custom-color-strong"
              icon-type-left="smiley-positive"
              icon-type-right="arrow-right"
              style="--background-color: var(--oc-semantic-color-background-strong-pink); --text-color: var(--oc-semantic-color-background-soft-pink)"
            >
              Label
            </oc-button-v1>
          </div>

          <div class="demo-item">
            <oc-button-v1
              variant="custom-color-strong"
              icon-type-left="smiley-positive"
              icon-type-right="arrow-right"
              style="--background-color: #522EB7"
            >
              Label
            </oc-button-v1>
          </div>
        </div>

        <div class="demo-row" style="margin-top: 1.5rem">
          <div class="demo-item">
            <oc-button-v1
              variant="custom-color-soft"
              icon-type-left="smiley-positive"
              icon-type-right="arrow-right"
              style="--background-color: var(--oc-semantic-color-background-soft-mint)"
            >
              Label
            </oc-button-v1>
          </div>

          <div class="demo-item">
            <oc-button-v1
              variant="custom-color-soft"
              icon-type-left="smiley-positive"
              icon-type-right="arrow-right"
              style="--background-color: var(--oc-semantic-color-background-soft-pink); --text-color: var(--oc-semantic-color-background-strong-pink)"
            >
              Label
            </oc-button-v1>
          </div>

          <div class="demo-item">
            <oc-button-v1
              variant="custom-color-soft"
              icon-type-left="smiley-positive"
              icon-type-right="arrow-right"
              style="--background-color: #E5D5FE; --text-color: #522EB7"
            >
              Label
            </oc-button-v1>
          </div>
        </div>
      </div>
    `;
  }
}

Interaction tests (ButtonV1.form.interactions.stories.ts)

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

Form Association

Story: components-button-interaction-tests--form-association · tags: play-fn

<div id="form-association">
  <oc-button-v1 type="submit">no form</oc-button-v1>
  <oc-button-v1 type="submit" form="form-association-form" formaction="/bar"
    >referenced form
  </oc-button-v1>
  <form id="form-association-form" method="get" action="/foo">
    <oc-button-v1 type="submit" formmethod="post">within form</oc-button-v1>
  </form>
</div>
Story source (TypeScript, verbatim from Storybook)
{
  render() {
    return html` <div id="form-association">
      <oc-button-v1 type="submit">no form</oc-button-v1>
      <oc-button-v1 type="submit" form="form-association-form" formaction="/bar"
        >referenced form
      </oc-button-v1>
      <form id="form-association-form" method="get" action="/foo">
        <oc-button-v1 type="submit" formmethod="post">within form</oc-button-v1>
      </form>
    </div>`;
  },
  async play({
    canvasElement
  }) {
    const form = canvasElement.getElementsByTagName("form").item(0)!;
    const [button1, button2, button3] = Array.from(canvasElement.getElementsByTagName("oc-button-v1"));

    // @ts-expect-error -- internally defined property
    await expect(button1.internals.form).toBe(null);
    // @ts-expect-error -- internally defined property
    await expect(button2.internals.form).toBe(form);
    // @ts-expect-error -- internally defined property
    await expect(button3.internals.form).toBe(form);
  }
}

Should Not Submit When Disabled

Story: components-button-interaction-tests--should-not-submit-when-disabled · tags: play-fn

<form id="form1" method="get" action="/foo">
  <label>
    <input type="text" name="user" />
  </label>
  <oc-button-v1 type="submit" disabled>never submit me</oc-button-v1>
  <oc-button-v1 type="submit" disabled formaction="/trigger-me"
    >never submit me
  </oc-button-v1>
  <button type="submit"></button>
</form>
Story source (TypeScript, verbatim from Storybook)
{
  render() {
    return html`
      <form id="form1" method="get" action="/foo">
        <label>
          <input type="text" name="user" />
        </label>
        <oc-button-v1 type="submit" disabled>never submit me</oc-button-v1>
        <oc-button-v1 type="submit" disabled formaction="/trigger-me"
          >never submit me
        </oc-button-v1>
        <button type="submit"></button>
      </form>
    `;
  },
  async play({
    canvasElement
  }) {
    const [button1] = Array.from(canvasElement.getElementsByTagName("oc-button-v1"));
    const input = canvasElement.getElementsByTagName("input").item(0)!;
    const form = canvasElement.querySelector("form") as HTMLFormElement;
    let numberOfSubmitEvents = 0;
    form.addEventListener("submit", ev => {
      ev.preventDefault();
      numberOfSubmitEvents += 1;
    });
    await userEvent.type(input, "{Enter}");
    await userEvent.type(button1, "{Enter}");
    await expect(numberOfSubmitEvents, "never submit disabled button").toBe(0);
  }
}

Should Not Submit From Textarea

Story: components-button-interaction-tests--should-not-submit-from-textarea · tags: play-fn

<form id="form1">
  <label>
    <textarea name="user"> </textarea>
  </label>
  <oc-button-v1 type="submit">never submit me</oc-button-v1>
</form>
Story source (TypeScript, verbatim from Storybook)
{
  render() {
    return html`
      <form id="form1">
        <label>
          <textarea name="user"> </textarea>
        </label>
        <oc-button-v1 type="submit">never submit me</oc-button-v1>
      </form>
    `;
  },
  async play({
    canvasElement
  }) {
    const input = canvasElement.getElementsByTagName("textarea").item(0)!;
    const form = canvasElement.querySelector("form") as HTMLFormElement;
    let numberOfSubmitEvents = 0;
    form.addEventListener("submit", ev => {
      ev.preventDefault();
      numberOfSubmitEvents += 1;
    });
    await userEvent.type(input, "{Enter}");
    await expect(numberOfSubmitEvents, "never submit disabled button").toBe(0);
  }
}

Should Work With Two Buttons

Story: components-button-interaction-tests--should-work-with-two-buttons · tags: play-fn

<form id="form1" method="get" action="/foo">
  <label>
    <input type="text" name="user" />
  </label>
  <oc-button-v1 type="submit">do not submit me</oc-button-v1>
  <oc-button-v1 type="submit" formaction="/trigger-me">submit</oc-button-v1>
</form>
Story source (TypeScript, verbatim from Storybook)
{
  render() {
    return html`
      <form id="form1" method="get" action="/foo">
        <label>
          <input type="text" name="user" />
        </label>
        <oc-button-v1 type="submit">do not submit me</oc-button-v1>
        <oc-button-v1 type="submit" formaction="/trigger-me">submit</oc-button-v1>
      </form>
    `;
  },
  async play({
    canvasElement
  }) {
    const button2 = canvasElement.getElementsByTagName("oc-button-v1").item(1)!;
    const form = canvasElement.querySelector("form") as HTMLFormElement;
    let submitter: HTMLElement | null = null;
    let numberOfSubmitEvents = 0;
    form.addEventListener("submit", ev => {
      ev.preventDefault();
      submitter = ev.submitter;
      numberOfSubmitEvents += 1;
    });
    await userEvent.type(button2, "{Enter}");
    await expect(numberOfSubmitEvents, "submit once").toBe(1);
    await expect(
    // @ts-expect-error -- internally defined property
    submitter?.formAction.endsWith("/trigger-me"), "should triggered second button only").toBeTruthy();
    await userEvent.click(button2);
    await expect(numberOfSubmitEvents, "second submit").toBe(2);
    await expect(
    // @ts-expect-error -- internally defined property
    submitter?.formAction.endsWith("/trigger-me"), "should triggered second button only").toBeTruthy();
  }
}

Should Trigger Form Action

Story: components-button-interaction-tests--should-trigger-form-action · tags: play-fn

<form id="form1" method="get" action="/foo">
  <label>
    <input type="text" name="user" />
  </label>
  <oc-button-v1 type="submit" formaction="/trigger-me">submit</oc-button-v1>
  <input type="submit" formaction="/do-not-trigger-me" />
  <button type="submit" formaction="/do-not-trigger-me"></button>
  <oc-button-v1 type="submit" formaction="/do-not-trigger-me">submit</oc-button-v1>
</form>
Story source (TypeScript, verbatim from Storybook)
{
  render() {
    return html`
      <form id="form1" method="get" action="/foo">
        <label>
          <input type="text" name="user" />
        </label>
        <oc-button-v1 type="submit" formaction="/trigger-me">submit</oc-button-v1>
        <input type="submit" formaction="/do-not-trigger-me" />
        <button type="submit" formaction="/do-not-trigger-me"></button>
        <oc-button-v1 type="submit" formaction="/do-not-trigger-me">submit</oc-button-v1>
      </form>
    `;
  },
  async play({
    canvasElement
  }) {
    const input = canvasElement.getElementsByTagName("input").item(0)!;
    const form = canvasElement.querySelector("form") as HTMLFormElement;
    let submitter: HTMLElement | null = null;
    let numberOfSubmitEvents = 0;
    form.addEventListener("submit", ev => {
      ev.preventDefault();
      submitter = ev.submitter;
      numberOfSubmitEvents += 1;
    });
    await userEvent.type(input, "{Enter}");
    await expect(numberOfSubmitEvents, "submit once").toBe(1);
    await expect(
    // @ts-expect-error -- internally defined property
    submitter?.formAction.endsWith("/trigger-me"), "should have form action '/trigger-me'").toBeTruthy();
  }
}

Should Not Be Triggered By Non Form Elements

Story: components-button-interaction-tests--should-not-be-triggered-by-non-form-elements · tags: play-fn

<form id="form1" method="get" action="/foo">
  <label>
    <input type="text" name="user" />
  </label>
  <a href="#">Foo</a>
  <oc-button-v1 type="submit" formaction="/trigger-me">submit</oc-button-v1>
</form>
Story source (TypeScript, verbatim from Storybook)
{
  render() {
    return html`
      <form id="form1" method="get" action="/foo">
        <label>
          <input type="text" name="user" />
        </label>
        <a href="#">Foo</a>
        <oc-button-v1 type="submit" formaction="/trigger-me">submit</oc-button-v1>
      </form>
    `;
  },
  async play({
    canvasElement
  }) {
    const link = canvasElement.getElementsByTagName("a").item(0)!;
    const form = canvasElement.querySelector("form") as HTMLFormElement;
    link.addEventListener("click", ev => ev.preventDefault());
    let numberOfSubmitEvents = 0;
    form.addEventListener("submit", ev => {
      ev.preventDefault();
      numberOfSubmitEvents += 1;
    });
    await userEvent.type(link, "{Enter}");
    await expect(numberOfSubmitEvents, "should not trigger submit").toBe(0);
  }
}

Should Submit Like Native Buttons

Story: components-button-interaction-tests--should-submit-like-native-buttons · tags: play-fn

<form id="form1">
  <label>
    {Enter}
    <input type="text" name="name" />
  </label>
  <oc-button-v1 type="submit" data-should-submit></oc-button-v1>
  <oc-button-v1 type="submit" data-should-not-submit></oc-button-v1>
</form>

<form id="form2">
  <label>
    {Enter}
    <input type="text" name="name" />
  </label>
  <input type="submit" data-should-submit />
  <oc-button-v1 type="submit" data-should-not-submit></oc-button-v1>
</form>

<form id="form3">
  <label>
    {Enter}
    <input type="text" name="name" />
  </label>
  Should submit the form but not by oc-button-v1
</form>

<form id="form4">
  <input type="submit" data-should-submit />
  <oc-button-v1 type="submit" data-should-not-submit></oc-button-v1>
  <label>
    {Enter}
    <input type="text" name="name" form="form4" />
  </label>
</form>

<form id="form5">
  <label>
    {Enter}
    <input type="text" name="name" />
  </label>
  <oc-button-v1 type="submit" disabled data-should-not-submit></oc-button-v1>
  <oc-button-v1 type="submit" data-should-not-submit></oc-button-v1>
</form>

<form id="form6">
  <label>
    {Enter}
    <input type="text" name="name" />
  </label>
  <input type="submit" disabled data-should-not-submit />
  <oc-button-v1 type="submit" data-should-not-submit></oc-button-v1>
</form>

<form id="form7">
  <label>
    {Enter}
    <input type="text" name="name" />
  </label>
</form>

<oc-button-v1 type="submit" form="form7" data-should-submit></oc-button-v1>
<oc-button-v1 type="submit" form="form7" data-should-not-submit></oc-button-v1>
<button type="submit" form="form7" data-should-not-submit></button>
Story source (TypeScript, verbatim from Storybook)
{
  render() {
    return html`
      <form id="form1">
        <label>
          {Enter}
          <input type="text" name="name" />
        </label>
        <oc-button-v1 type="submit" data-should-submit></oc-button-v1>
        <oc-button-v1 type="submit" data-should-not-submit></oc-button-v1>
      </form>

      <form id="form2">
        <label>
          {Enter}
          <input type="text" name="name" />
        </label>
        <input type="submit" data-should-submit />
        <oc-button-v1 type="submit" data-should-not-submit></oc-button-v1>
      </form>

      <form id="form3">
        <label>
          {Enter}
          <input type="text" name="name" />
        </label>
        Should submit the form but not by oc-button-v1
      </form>

      <form id="form4">
        <input type="submit" data-should-submit />
        <oc-button-v1 type="submit" data-should-not-submit></oc-button-v1>
        <label>
          {Enter}
          <input type="text" name="name" form="form4" />
        </label>
      </form>

      <form id="form5">
        <label>
          {Enter}
          <input type="text" name="name" />
        </label>
        <oc-button-v1 type="submit" disabled data-should-not-submit></oc-button-v1>
        <oc-button-v1 type="submit" data-should-not-submit></oc-button-v1>
      </form>

      <form id="form6">
        <label>
          {Enter}
          <input type="text" name="name" />
        </label>
        <input type="submit" disabled data-should-not-submit />
        <oc-button-v1 type="submit" data-should-not-submit></oc-button-v1>
      </form>

      <form id="form7">
        <label>
          {Enter}
          <input type="text" name="name" />
        </label>
      </form>

      <oc-button-v1 type="submit" form="form7" data-should-submit></oc-button-v1>
      <oc-button-v1 type="submit" form="form7" data-should-not-submit></oc-button-v1>
      <button type="submit" form="form7" data-should-not-submit></button>
    `;
  },
  async play({
    canvasElement
  }) {
    const inputs = Array.from(canvasElement.querySelectorAll(`input[name="name"]`)) as HTMLInputElement[];
    const submits: {
      form: string;
      submitter: HTMLElement | null;
    }[] = [];
    canvasElement.querySelectorAll("form").forEach(form => {
      form.addEventListener("submit", ev => {
        ev.preventDefault();
        submits.push({
          form: form.id,
          submitter: ev.submitter
        });
      });
    });

    // eslint-disable-next-line no-restricted-syntax
    for (const input of inputs) {
      // eslint-disable-next-line no-await-in-loop
      await userEvent.type(input, "{Enter}");
    }
    await expect(submits.length).toBe(5);
    await expect(submits[0].form).toBe("form1");
    await expect(submits[0].submitter?.dataset.shouldNotSubmit).toBeUndefined();
    await expect(submits[1].form).toBe("form2");
    await expect(submits[1].submitter?.dataset.shouldNotSubmit).toBeUndefined();
    await expect(submits[2].form).toBe("form3");
    await expect(submits[2].submitter).toBe(undefined);
    await expect(submits[3].form).toBe("form4");
    await expect(submits[3].submitter?.dataset.shouldSubmit).toBeDefined();
    await expect(submits[4].form).toBe("form7");
    await expect(submits[4].submitter?.dataset.shouldNotSubmit).toBeUndefined();
  }
}

Interaction tests (ButtonV1.interactions.stories.ts)

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

Trigger Form Submit When Button Type Is Set

Story: components-button-interaction-tests--trigger-form-submit-when-button-type-is-set · tags: play-fn

Args: variant=primary, size=100, type=submit

<form onsubmit="return false;">
  <label for="username">Username</label>
  <input id="username" />
  <oc-button-v1 variant="primary" size="100" type="submit">Submit Form</oc-button-v1>
</form>
Story source (TypeScript, verbatim from Storybook)
{
  args: {
    variant: "primary",
    size: "100",
    type: "submit"
  },
  render(args) {
    return html` <form onsubmit="return false;">
      <label for="username">Username</label>
      <input id="username" />
      <oc-button-v1 ${spread(args)}>Submit Form</oc-button-v1>
    </form>`;
  },
  play: async ({
    canvasElement
  }) => {
    const innerButton = getByShadowRole(canvasElement, "button");
    const form = canvasElement.querySelector("form");
    let formSubmitted = false;
    if (form) {
      form.addEventListener("submit", event => {
        formSubmitted = true;
        event.preventDefault();
      });
    }
    await expect(formSubmitted).toBeFalsy();
    await click(innerButton);
    await expect(formSubmitted).toBeTruthy();
  }
}

Do Not Trigger Form Submit When Button Type Is Not Set

Story: components-button-interaction-tests--do-not-trigger-form-submit-when-button-type-is-not-set · tags: play-fn

Args: variant=primary, size=100

<form onsubmit="return false;">
  <label for="username">Username</label>
  <input id="username" />
  <oc-button-v1 variant="primary" size="100">Submit Form</oc-button-v1>
</form>
Story source (TypeScript, verbatim from Storybook)
{
  args: {
    variant: "primary",
    size: "100"
  },
  render(args) {
    return html` <form onsubmit="return false;">
      <label for="username">Username</label>
      <input id="username" />
      <oc-button-v1 ${spread(args)}>Submit Form</oc-button-v1>
    </form>`;
  },
  play: async ({
    canvasElement
  }) => {
    const innerButton = getByShadowRole(canvasElement, "button");
    const form = canvasElement.querySelector("form");
    let formSubmitted = false;
    if (form) {
      form.addEventListener("submit", event => {
        formSubmitted = true;
        event.preventDefault();
      });
    }
    await expect(formSubmitted).toBeFalsy();
    await click(innerButton);
    await expect(formSubmitted).toBeFalsy();
  }
}

Trigger Form Reset When Button Type Is Set

Story: components-button-interaction-tests--trigger-form-reset-when-button-type-is-set · tags: play-fn

Args: variant=primary, size=100, type=reset

<form onsubmit="return false;" onreset="return false;">
  <label for="username">Username</label>
  <input id="username" />
  <oc-button-v1 variant="primary" size="100" type="reset">Reset Form</oc-button-v1>
</form>
Story source (TypeScript, verbatim from Storybook)
{
  args: {
    variant: "primary",
    size: "100",
    type: "reset"
  },
  render(args) {
    return html` <form onsubmit="return false;" onreset="return false;">
      <label for="username">Username</label>
      <input id="username" />
      <oc-button-v1 ${spread(args)}>Reset Form</oc-button-v1>
    </form>`;
  },
  play: async ({
    canvasElement
  }) => {
    const innerButton = getByShadowRole(canvasElement, "button");
    const form = canvasElement.querySelector("form");
    let formResetted = false;
    if (form) {
      form.addEventListener("reset", event => {
        formResetted = true;
        event.preventDefault();
      });
    }
    await expect(formResetted).toBeFalsy();
    await click(innerButton);
    await expect(formResetted).toBeTruthy();
  }
}

Should Show Loading On Click

Story: components-button-interaction-tests--should-show-loading-on-click · tags: play-fn

Args: variant=primary, size=100, type=button

<oc-button-v1 variant="primary" size="100" type="button">Button</oc-button-v1>
Story source (TypeScript, verbatim from Storybook)
{
  args: {
    variant: "primary",
    size: "100",
    type: "button"
  },
  parameters: {
    // Disables Chromatic's snapshotting, as the spinner is animated und thus leads to diffs
    chromatic: {
      disableSnapshot: true
    }
  },
  render(args) {
    return html` <oc-button-v1 ${spread(args)}>Button</oc-button-v1>`;
  },
  play: async ({
    canvasElement
  }) => {
    // given
    const ocButton = canvasElement.getElementsByTagName("oc-button-v1").item(0)!;
    const button = getByShadowRole(canvasElement, "button");
    button.addEventListener("click", () => {
      ocButton.setAttribute("loading", "");
    });

    // there should be no spinner before clicking the button
    const noSpinner = Array.from(button.childNodes).find(node => node.nodeName === "OC-SPINNER-V1");
    await expect(noSpinner, "no spinner should be rendered before clicking the button").toBeFalsy();

    // when
    await click(button);

    // then
    // wait for some time because of the default debounce (?)
    await waitFor("spinner to arrive", async () => {
      const spinner = Array.from(button.childNodes).find(node => node.nodeName === "OC-SPINNER-V1");
      await expect(spinner, "spinner should be rendered after clicking the button").toBeTruthy();
    });
  }
}

Should Not Trigger Click When Loading Is True

Story: components-button-interaction-tests--should-not-trigger-click-when-loading-is-true · tags: play-fn

<oc-button-v1 type="submit">Button</oc-button-v1>
Story source (TypeScript, verbatim from Storybook)
{
  render() {
    return html` <oc-button-v1 type="submit">Button</oc-button-v1>`;
  },
  play: async ({
    canvasElement
  }) => {
    const ocButton = canvasElement.getElementsByTagName("oc-button-v1").item(0)!;
    const button = getByShadowRole(canvasElement, "button");
    let clickCount = 0;
    let documentClickCount = 0;
    document.addEventListener("click", () => {
      documentClickCount++;
    });
    ocButton.addEventListener("click", () => {
      clickCount++;
    });

    // when
    await click(button);

    // then
    await expect(clickCount, "direct click count should be 1 after first click").toBe(1);
    await expect(documentClickCount, "document click count should be 1 after first click").toBe(1);

    // when loading is set to true
    ocButton.setAttribute("loading", "");

    // give it time to settle
    await waitFor("spinner to arrive", async () => {
      const spinner = Array.from(button.childNodes).find(node => node.nodeName === "OC-SPINNER-V1");
      await expect(spinner, "spinner should be rendered after clicking the button").toBeTruthy();
    });
    await click(button);

    // then the click event should not be triggered again
    await expect(clickCount, "direct click count should increase when loading is true").toBe(2);
    // TODO slop: the document click count should not increase because the click event should not propagate, but currently it does for the first click after setting loading to true. This is because the bouncer logic is only applied on subsequent clicks, but not on the first click after setting loading to true. This is a known issue and will be fixed in the next iteration.
    await expect(documentClickCount, "document click count should not increase when loading is true, but does for the first click after setting loading to true").toBe(2);
    await click(button);

    // then the click event should not be triggered again
    await expect(clickCount, "direct click count should increase when loading is true").toBe(3);
    await expect(documentClickCount, "document click count should not increase when loading is true, and now works for the second click after setting loading to true").toBe(2);

    // and the spinner should be rendered inside the button
    const spinner = Array.from(button.childNodes).find(node => node.nodeName === "OC-SPINNER-V1");
    await expect(spinner, "spinner should be rendered when loading is true").toBeTruthy();
  }
}

Should Handle Base 64 Href

Story: components-button-interaction-tests--should-handle-base-64-href · tags: play-fn

<oc-button-v1 base64-href="L2Zvbw==">Link</oc-button-v1>
Story source (TypeScript, verbatim from Storybook)
{
  render() {
    return html` <oc-button-v1 base64-href="L2Zvbw==">Link</oc-button-v1>`;
  },
  async play({
    canvasElement
  }) {
    const ocButton = canvasElement.getElementsByTagName("oc-button-v1").item(0)!;
    ocButton.focus();
    let linkElement: HTMLAnchorElement;
    await waitFor("render link element inside shadow root", async () => {
      // instead of the button element there should now be an anchor element
      const buttonElement = ocButton.shadowRoot!.querySelector("button");
      linkElement = ocButton.shadowRoot!.querySelector("a")!;
      await expect(buttonElement, "no button should be rendered").toBeNull();
      await expect(linkElement.tagName, "anchor should be rendered").toBe("A");
    });

    // anchor element inside the shadow root should have the correct href attribute
    await expect(linkElement!.getAttribute("href")).toBe(`/foo`);

    // anchor element inside the shadow root should have the same fully href as the host
    await expect(ocButton.href).toBe(`${window.location.origin}/foo`);
    await expect(linkElement!.href).toBe(`${window.location.origin}/foo`);
  }
}