Files
ISA-Frontend/.claude/skills/html-template/references/combined-patterns.md
Lorenz Hilpert bf30ec1213 feat(skills): create html-template skill for E2E and ARIA attributes
- Create comprehensive html-template skill with 3 reference files
  - E2E Testing Attributes (data-what, data-which patterns)
  - ARIA Accessibility Attributes (roles, properties, states, WCAG)
  - Combined Patterns (real-world examples with both)
- Move E2E attribute guidance from command to skill
- Add extensive ARIA accessibility documentation
- Update angular-template skill with cross-references
- Remove dev-add-e2e-attrs command (functionality now in skill)

The new skill provides 3,322 lines of comprehensive documentation
covering both testing and accessibility best practices for HTML
templates, with practical examples for forms, navigation, tables,
dialogs, and more.

Benefits:
- Clean separation: Angular syntax vs HTML attributes
- Reusable: html-template works with any HTML context
- Comprehensive: E2E + ARIA in one place
- Integrated: Works seamlessly with angular-template skill
2025-10-29 13:21:29 +01:00

28 KiB
Raw Blame History

Combined E2E & ARIA Patterns - Real-World Examples

This reference demonstrates how to use E2E testing attributes and ARIA accessibility attributes together in production-ready components.

Table of Contents

Overview

Combining E2E and ARIA attributes creates components that are:

  • Testable: Reliable selectors for automated testing
  • Accessible: Usable with assistive technologies
  • Maintainable: Attributes survive refactoring
  • Compliant: Meet WCAG and legal requirements

Pattern: Every interactive element should have both types of attributes.

Complete Form Example

Registration Form

<form
  (ngSubmit)="onSubmit()"
  data-what="registration-form"
  data-which="user-signup"
  role="form"
  aria-labelledby="form-title">

  <h2 id="form-title">Create Account</h2>

  <!-- Username Field -->
  <div class="form-field">
    <label id="username-label" for="username-input">
      Username <span aria-label="required">*</span>
    </label>
    <input
      id="username-input"
      type="text"
      [(ngModel)]="username"
      name="username"
      data-what="username-input"
      data-which="registration-form"
      aria-labelledby="username-label"
      aria-required="true"
      [attr.aria-invalid]="usernameError ? 'true' : null"
      aria-describedby="username-hint username-error"
      (blur)="validateUsername()" />

    <span id="username-hint" class="hint">
      3-20 characters, letters and numbers only
    </span>

    @if (usernameError) {
      <span
        id="username-error"
        class="error"
        data-what="error-message"
        data-which="username-field"
        role="alert"
        aria-live="polite">
        {{ usernameError }}
      </span>
    }
  </div>

  <!-- Email Field -->
  <div class="form-field">
    <label id="email-label" for="email-input">
      Email Address <span aria-label="required">*</span>
    </label>
    <input
      id="email-input"
      type="email"
      [(ngModel)]="email"
      name="email"
      data-what="email-input"
      data-which="registration-form"
      aria-labelledby="email-label"
      aria-required="true"
      [attr.aria-invalid]="emailError ? 'true' : null"
      aria-describedby="email-hint email-error"
      (blur)="validateEmail()" />

    <span id="email-hint" class="hint">
      We'll never share your email
    </span>

    @if (emailError) {
      <span
        id="email-error"
        class="error"
        data-what="error-message"
        data-which="email-field"
        role="alert"
        aria-live="polite">
        {{ emailError }}
      </span>
    }
  </div>

  <!-- Password Field -->
  <div class="form-field">
    <label id="password-label" for="password-input">
      Password <span aria-label="required">*</span>
    </label>
    <input
      id="password-input"
      type="password"
      [(ngModel)]="password"
      name="password"
      data-what="password-input"
      data-which="registration-form"
      aria-labelledby="password-label"
      aria-required="true"
      [attr.aria-invalid]="passwordError ? 'true' : null"
      aria-describedby="password-hint password-error"
      (input)="checkPasswordStrength()" />

    <span id="password-hint" class="hint">
      At least 8 characters with uppercase, lowercase, and numbers
    </span>

    <!-- Password Strength Indicator -->
    <div
      class="password-strength"
      data-what="password-strength"
      data-which="registration-form"
      [attr.data-strength]="passwordStrength"
      role="status"
      aria-live="polite"
      aria-label="Password strength indicator">
      Strength: {{ passwordStrength }}
    </div>

    @if (passwordError) {
      <span
        id="password-error"
        class="error"
        data-what="error-message"
        data-which="password-field"
        role="alert"
        aria-live="polite">
        {{ passwordError }}
      </span>
    }
  </div>

  <!-- Terms Checkbox -->
  <div class="form-field">
    <label>
      <input
        id="terms-checkbox"
        type="checkbox"
        [(ngModel)]="agreedToTerms"
        name="terms"
        data-what="terms-checkbox"
        data-which="registration-form"
        aria-required="true"
        aria-describedby="terms-label" />
      <span id="terms-label">
        I agree to the <a href="/terms" target="_blank" aria-label="Terms of service (opens in new tab)">Terms of Service</a>
      </span>
    </label>
  </div>

  <!-- Form Actions -->
  <div class="form-actions">
    <button
      type="submit"
      [disabled]="!isFormValid"
      data-what="submit-button"
      data-which="registration-form"
      [attr.aria-disabled]="!isFormValid"
      aria-label="Submit registration form">
      Create Account
    </button>

    <button
      type="button"
      (click)="onCancel()"
      data-what="cancel-button"
      data-which="registration-form"
      aria-label="Cancel registration">
      Cancel
    </button>
  </div>

  <!-- Loading State -->
  @if (isSubmitting) {
    <div
      class="loading"
      data-what="loading-indicator"
      data-which="registration-form"
      role="status"
      aria-live="polite"
      aria-label="Submitting registration">
      <span aria-hidden="true"></span>
      Submitting...
    </div>
  }
</form>

Product Listing

Product Grid with Filters

<div class="product-catalog">
  <!-- Search and Filter Section -->
  <div
    class="filters"
    data-what="filter-section"
    data-which="product-catalog"
    role="search"
    aria-label="Product search and filters">

    <!-- Search Input -->
    <input
      type="search"
      [(ngModel)]="searchQuery"
      (input)="onSearch()"
      placeholder="Search products..."
      data-what="search-input"
      data-which="product-catalog"
      aria-label="Search products"
      aria-describedby="search-hint" />
    <span id="search-hint" class="sr-only">
      Search by product name or SKU
    </span>

    <!-- Category Filter -->
    <select
      [(ngModel)]="selectedCategory"
      (change)="applyFilters()"
      data-what="category-select"
      data-which="product-filter"
      aria-label="Filter by category">
      <option value="">All Categories</option>
      @for (category of categories; track category.id) {
        <option [value]="category.id">{{ category.name }}</option>
      }
    </select>

    <!-- Price Range Filter -->
    <div class="price-range">
      <label id="price-range-label">Price Range</label>
      <input
        type="range"
        [(ngModel)]="maxPrice"
        (change)="applyFilters()"
        min="0"
        max="1000"
        data-what="price-slider"
        data-which="product-filter"
        aria-labelledby="price-range-label"
        aria-valuemin="0"
        aria-valuemax="1000"
        [attr.aria-valuenow]="maxPrice"
        [attr.aria-valuetext]="'Maximum price: $' + maxPrice" />
      <output>${{ maxPrice }}</output>
    </div>

    <!-- Results Count -->
    <div
      class="results-count"
      data-what="results-count"
      data-which="product-catalog"
      role="status"
      aria-live="polite"
      aria-atomic="true">
      Showing {{ filteredProducts.length }} of {{ totalProducts }} products
    </div>
  </div>

  <!-- Product Grid -->
  <div
    class="product-grid"
    data-what="product-grid"
    data-which="catalog"
    role="list"
    aria-label="Product list">

    @for (product of filteredProducts; track product.id) {
      <article
        class="product-card"
        data-what="product-card"
        [attr.data-which]="product.id"
        [attr.data-status]="product.stock > 0 ? 'in-stock' : 'out-of-stock'"
        role="listitem"
        [attr.aria-label]="product.name + ', $' + product.price">

        <!-- Product Image -->
        <a
          [routerLink]="['/products', product.id]"
          data-what="product-link"
          [attr.data-which]="product.id"
          [attr.aria-label]="'View details for ' + product.name">
          <img
            [src]="product.image"
            [alt]="product.name"
            loading="lazy" />
        </a>

        <!-- Product Info -->
        <div class="product-info">
          <h3>
            <a
              [routerLink]="['/products', product.id]"
              data-what="product-link"
              [attr.data-which]="product.id">
              {{ product.name }}
            </a>
          </h3>

          <p class="price" aria-label="Price">
            {{ product.price | currency }}
          </p>

          <!-- Stock Status -->
          <div
            class="stock-status"
            data-what="stock-badge"
            [attr.data-which]="product.id"
            [attr.data-status]="product.stock > 0 ? 'in-stock' : 'out-of-stock'"
            role="status"
            [attr.aria-label]="product.stock > 0 ? 'In stock' : 'Out of stock'">
            @if (product.stock > 0) {
              <span class="in-stock">In Stock</span>
            } @else {
              <span class="out-of-stock">Out of Stock</span>
            }
          </div>

          <!-- Add to Cart Button -->
          <button
            (click)="addToCart(product)"
            [disabled]="product.stock === 0"
            data-what="add-to-cart-button"
            [attr.data-which]="product.id"
            [attr.aria-disabled]="product.stock === 0"
            [attr.aria-label]="'Add ' + product.name + ' to cart'">
            @if (product.stock > 0) {
              <span aria-hidden="true">🛒</span> Add to Cart
            } @else {
              <span>Unavailable</span>
            }
          </button>
        </div>
      </article>
    }
  </div>

  <!-- Empty State -->
  @if (filteredProducts.length === 0) {
    <div
      class="empty-state"
      data-what="empty-state"
      data-which="product-catalog"
      role="status"
      aria-live="polite">
      <p>No products found matching your criteria.</p>
      <button
        (click)="clearFilters()"
        data-what="clear-filters-button"
        data-which="product-catalog"
        aria-label="Clear all filters">
        Clear Filters
      </button>
    </div>
  }
</div>

Shopping Cart

<div
  class="shopping-cart"
  data-what="shopping-cart"
  data-which="checkout"
  role="region"
  aria-labelledby="cart-title">

  <h2 id="cart-title">Shopping Cart</h2>

  <!-- Cart Items List -->
  <ul
    class="cart-items"
    data-what="cart-items-list"
    data-which="shopping-cart"
    role="list"
    aria-label="Items in your cart">

    @for (item of cartItems; track item.id) {
      <li
        class="cart-item"
        data-what="cart-item"
        [attr.data-which]="item.id"
        role="listitem">

        <!-- Item Image -->
        <img
          [src]="item.image"
          [alt]="item.name"
          class="item-image" />

        <!-- Item Details -->
        <div class="item-details">
          <h3>{{ item.name }}</h3>
          <p class="item-price" aria-label="Price per item">
            {{ item.price | currency }}
          </p>

          <!-- Quantity Controls -->
          <div
            class="quantity-controls"
            role="group"
            aria-labelledby="quantity-label-{{ item.id }}">

            <span id="quantity-label-{{ item.id }}" class="sr-only">
              Quantity for {{ item.name }}
            </span>

            <button
              (click)="decreaseQuantity(item)"
              [disabled]="item.quantity <= 1"
              data-what="decrease-button"
              [attr.data-which]="item.id"
              [attr.aria-disabled]="item.quantity <= 1"
              [attr.aria-label]="'Decrease quantity of ' + item.name">
              <span aria-hidden="true"></span>
            </button>

            <input
              type="number"
              [(ngModel)]="item.quantity"
              (change)="updateQuantity(item)"
              min="1"
              [max]="item.maxQuantity"
              data-what="quantity-input"
              [attr.data-which]="item.id"
              [attr.aria-label]="'Quantity of ' + item.name"
              aria-valuemin="1"
              [attr.aria-valuemax]="item.maxQuantity"
              [attr.aria-valuenow]="item.quantity" />

            <button
              (click)="increaseQuantity(item)"
              [disabled]="item.quantity >= item.maxQuantity"
              data-what="increase-button"
              [attr.data-which]="item.id"
              [attr.aria-disabled]="item.quantity >= item.maxQuantity"
              [attr.aria-label]="'Increase quantity of ' + item.name">
              <span aria-hidden="true">+</span>
            </button>
          </div>

          <!-- Remove Button -->
          <button
            (click)="removeItem(item)"
            class="remove-button"
            data-what="remove-button"
            [attr.data-which]="item.id"
            [attr.aria-label]="'Remove ' + item.name + ' from cart'">
            <span aria-hidden="true">🗑️</span> Remove
          </button>

          <!-- Item Subtotal -->
          <p
            class="item-subtotal"
            data-what="item-subtotal"
            [attr.data-which]="item.id"
            [attr.aria-label]="'Subtotal for ' + item.name">
            Subtotal: {{ item.price * item.quantity | currency }}
          </p>
        </div>
      </li>
    }
  </ul>

  <!-- Cart Summary -->
  <div
    class="cart-summary"
    data-what="cart-summary"
    data-which="shopping-cart"
    role="region"
    aria-labelledby="summary-title">

    <h3 id="summary-title">Order Summary</h3>

    <dl>
      <dt>Subtotal:</dt>
      <dd data-what="subtotal" data-which="cart-summary">
        {{ subtotal | currency }}
      </dd>

      <dt>Tax:</dt>
      <dd data-what="tax" data-which="cart-summary">
        {{ tax | currency }}
      </dd>

      <dt>Shipping:</dt>
      <dd data-what="shipping" data-which="cart-summary">
        {{ shipping | currency }}
      </dd>

      <dt><strong>Total:</strong></dt>
      <dd data-what="total" data-which="cart-summary">
        <strong>{{ total | currency }}</strong>
      </dd>
    </dl>

    <!-- Checkout Button -->
    <button
      (click)="proceedToCheckout()"
      [disabled]="cartItems.length === 0"
      class="checkout-button"
      data-what="checkout-button"
      data-which="shopping-cart"
      [attr.aria-disabled]="cartItems.length === 0"
      aria-label="Proceed to checkout">
      Proceed to Checkout
    </button>
  </div>

  <!-- Empty Cart Message -->
  @if (cartItems.length === 0) {
    <div
      class="empty-cart"
      data-what="empty-state"
      data-which="shopping-cart"
      role="status"
      aria-live="polite">
      <p>Your cart is empty</p>
      <a
        routerLink="/products"
        data-what="continue-shopping-link"
        data-which="shopping-cart"
        aria-label="Continue shopping">
        Continue Shopping
      </a>
    </div>
  }
</div>

Modal Dialogs

Confirmation Dialog

@if (showDeleteDialog) {
  <!-- Overlay -->
  <div
    class="modal-overlay"
    (click)="cancelDelete()"
    data-what="modal-overlay"
    data-which="delete-confirmation">
  </div>

  <!-- Dialog -->
  <div
    class="modal"
    data-what="confirmation-dialog"
    data-which="delete-item"
    role="alertdialog"
    aria-modal="true"
    aria-labelledby="dialog-title"
    aria-describedby="dialog-description"
    #dialogElement
    tabindex="-1">

    <h2 id="dialog-title">Confirm Deletion</h2>

    <p id="dialog-description">
      Are you sure you want to delete "{{ itemToDelete?.name }}"?
      This action cannot be undone.
    </p>

    <!-- Dialog Actions -->
    <div class="dialog-actions">
      <button
        (click)="confirmDelete()"
        class="danger-button"
        data-what="confirm-button"
        data-which="delete-dialog"
        aria-label="Confirm deletion">
        Delete
      </button>

      <button
        (click)="cancelDelete()"
        data-what="cancel-button"
        data-which="delete-dialog"
        aria-label="Cancel deletion"
        autofocus>
        Cancel
      </button>
    </div>

    <!-- Close Button -->
    <button
      (click)="cancelDelete()"
      class="close-button"
      data-what="close-button"
      data-which="dialog"
      aria-label="Close dialog">
      <span aria-hidden="true">×</span>
    </button>
  </div>
}

Navigation Patterns

Main Navigation with Dropdown

<nav
  class="main-nav"
  data-what="main-navigation"
  data-which="header"
  role="navigation"
  aria-label="Main navigation">

  <ul role="menubar">
    @for (item of menuItems; track item.id) {
      <li role="none">
        @if (item.children) {
          <!-- Dropdown Menu Item -->
          <button
            (click)="toggleSubmenu(item.id)"
            (keydown.arrowDown)="openSubmenu(item.id)"
            [attr.aria-expanded]="item.expanded"
            aria-haspopup="menu"
            [attr.aria-controls]="'submenu-' + item.id"
            data-what="nav-menu-button"
            [attr.data-which]="item.id"
            role="menuitem">
            {{ item.label }}
            <span aria-hidden="true"></span>
          </button>

          <!-- Submenu -->
          <ul
            [id]="'submenu-' + item.id"
            [attr.aria-hidden]="!item.expanded"
            role="menu"
            [attr.aria-label]="item.label + ' submenu'"
            data-what="submenu"
            [attr.data-which]="item.id">
            @for (child of item.children; track child.id) {
              <li role="none">
                <a
                  [routerLink]="child.url"
                  role="menuitem"
                  data-what="nav-link"
                  [attr.data-which]="child.id"
                  [attr.aria-current]="isCurrentPage(child.url) ? 'page' : null">
                  {{ child.label }}
                </a>
              </li>
            }
          </ul>
        } @else {
          <!-- Simple Link -->
          <a
            [routerLink]="item.url"
            role="menuitem"
            data-what="nav-link"
            [attr.data-which]="item.id"
            [attr.aria-current]="isCurrentPage(item.url) ? 'page' : null">
            {{ item.label }}
          </a>
        }
      </li>
    }
  </ul>
</nav>

Data Tables

Sortable Data Table

<table
  class="data-table"
  data-what="orders-table"
  data-which="customer-dashboard"
  role="table"
  aria-labelledby="table-title"
  aria-describedby="table-description">

  <caption id="table-title">Recent Orders</caption>
  <p id="table-description" class="sr-only">
    A table showing your recent orders with sortable columns
  </p>

  <thead>
    <tr role="row">
      <!-- Sortable Column Headers -->
      <th scope="col" role="columnheader">
        <button
          (click)="sortBy('orderId')"
          data-what="sort-button"
          data-which="order-id-column"
          [attr.aria-sort]="getSortDirection('orderId')"
          aria-label="Sort by order ID">
          Order ID
          <span aria-hidden="true">{{ getSortIcon('orderId') }}</span>
        </button>
      </th>

      <th scope="col" role="columnheader">
        <button
          (click)="sortBy('date')"
          data-what="sort-button"
          data-which="date-column"
          [attr.aria-sort]="getSortDirection('date')"
          aria-label="Sort by date">
          Date
          <span aria-hidden="true">{{ getSortIcon('date') }}</span>
        </button>
      </th>

      <th scope="col" role="columnheader">
        <button
          (click)="sortBy('total')"
          data-what="sort-button"
          data-which="total-column"
          [attr.aria-sort]="getSortDirection('total')"
          aria-label="Sort by total">
          Total
          <span aria-hidden="true">{{ getSortIcon('total') }}</span>
        </button>
      </th>

      <th scope="col" role="columnheader">Status</th>
      <th scope="col" role="columnheader">Actions</th>
    </tr>
  </thead>

  <tbody>
    @for (order of sortedOrders; track order.id) {
      <tr
        role="row"
        data-what="table-row"
        [attr.data-which]="order.id"
        [attr.data-status]="order.status">

        <td role="cell">{{ order.id }}</td>
        <td role="cell">{{ order.date | date }}</td>
        <td role="cell">{{ order.total | currency }}</td>
        <td role="cell">
          <span
            class="status-badge"
            data-what="status-badge"
            [attr.data-which]="order.id"
            [attr.data-status]="order.status">
            {{ order.status }}
          </span>
        </td>
        <td role="cell">
          <button
            (click)="viewOrder(order)"
            data-what="view-button"
            [attr.data-which]="order.id"
            [attr.aria-label]="'View order ' + order.id">
            View
          </button>
          <button
            (click)="downloadInvoice(order)"
            data-what="download-button"
            [attr.data-which]="order.id"
            [attr.aria-label]="'Download invoice for order ' + order.id">
            Invoice
          </button>
        </td>
      </tr>
    }
  </tbody>
</table>

Search and Filters

Advanced Search with Live Results

<div
  class="search-container"
  data-what="search-container"
  data-which="site-search"
  role="search"
  aria-label="Site search">

  <!-- Search Input with Autocomplete -->
  <div class="search-input-wrapper">
    <input
      type="search"
      [(ngModel)]="searchQuery"
      (input)="onSearchInput()"
      (focus)="showSuggestions = true"
      (blur)="hideSuggestions()"
      placeholder="Search..."
      data-what="search-input"
      data-which="site-search"
      aria-label="Search site"
      aria-autocomplete="list"
      [attr.aria-expanded]="showSuggestions && suggestions.length > 0"
      [attr.aria-controls]="'search-suggestions'"
      [attr.aria-activedescendant]="activeSuggestionId"
      role="combobox" />

    <!-- Search Button -->
    <button
      (click)="performSearch()"
      data-what="search-button"
      data-which="site-search"
      aria-label="Perform search">
      <span aria-hidden="true">🔍</span>
    </button>
  </div>

  <!-- Search Suggestions -->
  @if (showSuggestions && suggestions.length > 0) {
    <ul
      id="search-suggestions"
      class="suggestions"
      data-what="suggestions-list"
      data-which="site-search"
      role="listbox"
      aria-label="Search suggestions">

      @for (suggestion of suggestions; track suggestion.id; let idx = $index) {
        <li
          [id]="'suggestion-' + idx"
          role="option"
          [attr.aria-selected]="idx === activeSuggestionIndex"
          (click)="selectSuggestion(suggestion)"
          data-what="suggestion-item"
          [attr.data-which]="suggestion.id"
          [attr.data-index]="idx">
          {{ suggestion.text }}
        </li>
      }
    </ul>
  }

  <!-- Search Results Count -->
  @if (searchPerformed) {
    <div
      class="results-summary"
      data-what="results-summary"
      data-which="site-search"
      role="status"
      aria-live="polite"
      aria-atomic="true">
      Found {{ searchResults.length }} results for "{{ searchQuery }}"
    </div>
  }
</div>

Notifications and Alerts

Toast Notifications

<!-- Success Toast -->
@if (showSuccessToast) {
  <div
    class="toast success"
    data-what="toast-notification"
    data-which="success"
    [attr.data-type]="'success'"
    role="status"
    aria-live="polite"
    aria-atomic="true">

    <span aria-hidden="true"></span>
    <span>{{ successMessage }}</span>

    <button
      (click)="dismissToast('success')"
      data-what="dismiss-button"
      data-which="success-toast"
      aria-label="Dismiss success notification">
      <span aria-hidden="true">×</span>
    </button>
  </div>
}

<!-- Error Alert -->
@if (showErrorAlert) {
  <div
    class="alert error"
    data-what="alert-notification"
    data-which="error"
    [attr.data-type]="'error'"
    role="alert"
    aria-live="assertive"
    aria-atomic="true">

    <span aria-hidden="true"></span>
    <span>{{ errorMessage }}</span>

    <button
      (click)="dismissAlert('error')"
      data-what="dismiss-button"
      data-which="error-alert"
      aria-label="Dismiss error alert">
      <span aria-hidden="true">×</span>
    </button>
  </div>
}

Multi-Step Forms

Wizard with Progress Indicator

<div
  class="form-wizard"
  data-what="multi-step-form"
  data-which="checkout-wizard"
  role="region"
  aria-labelledby="wizard-title">

  <h2 id="wizard-title">Checkout Process</h2>

  <!-- Progress Indicator -->
  <ol
    class="progress-steps"
    data-what="progress-indicator"
    data-which="checkout-wizard"
    role="list"
    aria-label="Checkout progress">

    @for (step of steps; track step.id; let idx = $index) {
      <li
        role="listitem"
        data-what="progress-step"
        [attr.data-which]="step.id"
        [attr.data-status]="getStepStatus(idx)"
        [attr.aria-current]="currentStep === idx ? 'step' : null">

        <span
          class="step-number"
          [attr.aria-label]="'Step ' + (idx + 1)">
          {{ idx + 1 }}
        </span>
        <span class="step-label">{{ step.label }}</span>
      </li>
    }
  </ol>

  <!-- Step Content -->
  <div
    class="step-content"
    data-what="step-content"
    [attr.data-which]="'step-' + currentStep"
    role="tabpanel"
    [attr.aria-labelledby]="'step-label-' + currentStep">

    <!-- Content varies by step -->
    {{ currentStepContent }}
  </div>

  <!-- Navigation Buttons -->
  <div class="wizard-navigation">
    <button
      (click)="previousStep()"
      [disabled]="currentStep === 0"
      data-what="previous-button"
      data-which="checkout-wizard"
      [attr.aria-disabled]="currentStep === 0"
      aria-label="Go to previous step">
      ← Previous
    </button>

    @if (currentStep < steps.length - 1) {
      <button
        (click)="nextStep()"
        [disabled]="!isCurrentStepValid"
        data-what="next-button"
        data-which="checkout-wizard"
        [attr.aria-disabled]="!isCurrentStepValid"
        aria-label="Go to next step">
        Next →
      </button>
    } @else {
      <button
        (click)="submitForm()"
        [disabled]="!isFormComplete"
        data-what="submit-button"
        data-which="checkout-wizard"
        [attr.aria-disabled]="!isFormComplete"
        aria-label="Complete checkout">
        Complete Order
      </button>
    }
  </div>
</div>

Best Practices Summary

Every Interactive Element Needs Both

<!-- ✅ GOOD: Both E2E and ARIA attributes -->
<button
  data-what="submit-button"
  data-which="order-form"
  aria-label="Submit order">
  Submit
</button>

<!-- ❌ BAD: Missing ARIA -->
<button
  data-what="submit-button"
  data-which="order-form">
  Submit
</button>

<!-- ❌ BAD: Missing E2E attributes -->
<button aria-label="Submit order">
  Submit
</button>

Attributes Work Together

  • E2E data-what: Describes element type for testing
  • ARIA role: Describes element purpose for accessibility
  • E2E data-which: Identifies specific instance for testing
  • ARIA label: Provides accessible name for screen readers
  • E2E data-*: Additional test context
  • ARIA states: Dynamic state information

Maintain Both During Updates

When you update HTML:

  1. Add/update E2E attributes for testing
  2. Add/update ARIA attributes for accessibility
  3. Keep both in sync with element's purpose
  4. Update tests to use new selectors
  5. Test with screen readers

Summary

Combining E2E and ARIA attributes creates:

  • Testable components with reliable selectors
  • Accessible interfaces for all users
  • Maintainable code that survives refactoring
  • Compliant applications meeting legal requirements
  • Professional quality expected in production

Always use both attribute types together on every interactive element.