Skip to content

ARIA-IMPLEMENTATION-EXAMPLES.md #659

@bourgeoa

Description

@bourgeoa

ARIA Implementation Examples for Solid-UI

Date: January 15, 2026
Related: Accessibility Checklist, Theme System Analysis


Overview

This document provides complete ARIA implementation examples for the Solid-UI theme system and common components. Copy and adapt these patterns for accessibility-compliant widgets.


1. Theme Switcher Component

Basic Select Implementation

/**
 * Creates an accessible theme switcher select element
 * @param {Document} dom - DOM context
 * @param {Object} options - Configuration options
 * @returns {HTMLElement} Theme switcher widget
 */
export function createThemeSwitcher(dom, options = {}) {
  const container = dom.createElement('div')
  container.className = 'theme-switcher-container'
  
  // Label
  const label = dom.createElement('label')
  label.htmlFor = 'solid-ui-theme-select'
  label.textContent = options.label || 'Color Theme'
  
  // Select element with ARIA
  const select = dom.createElement('select')
  select.id = 'solid-ui-theme-select'
  select.className = 'theme-switcher'
  select.setAttribute('aria-label', 'Choose application color theme')
  select.setAttribute('aria-describedby', 'theme-help')
  
  // Add themes as options
  Object.entries(themeLoader.themes).forEach(([name, path]) => {
    const option = dom.createElement('option')
    option.value = name
    option.textContent = name.charAt(0).toUpperCase() + name.slice(1)
    if (name === themeLoader.currentTheme) {
      option.selected = true
    }
    select.appendChild(option)
  })
  
  // Help text (visible)
  const helpText = dom.createElement('span')
  helpText.id = 'theme-help'
  helpText.className = 'help-text'
  helpText.textContent = 'Changes the color scheme of the application'
  
  // Change handler with announcement
  select.addEventListener('change', async (e) => {
    const themeName = e.target.value
    const themeLabel = e.target.options[e.target.selectedIndex].textContent
    
    // Update theme
    await themeLoader.loadTheme(themeName)
    
    // Announce change to screen readers
    announceThemeChange(dom, themeLabel)
  })
  
  // Assemble
  container.appendChild(label)
  container.appendChild(select)
  container.appendChild(helpText)
  
  return container
}

/**
 * Announces theme changes to screen readers
 * @param {Document} dom - DOM context
 * @param {string} themeName - Name of the theme
 */
function announceThemeChange(dom, themeName) {
  // Create or reuse announcement region
  let announcement = dom.getElementById('theme-announcement')
  if (!announcement) {
    announcement = dom.createElement('div')
    announcement.id = 'theme-announcement'
    announcement.setAttribute('role', 'status')
    announcement.setAttribute('aria-live', 'polite')
    announcement.setAttribute('aria-atomic', 'true')
    announcement.className = 'sr-only'
    dom.body.appendChild(announcement)
  }
  
  // Update announcement
  announcement.textContent = `Theme changed to ${themeName}`
  
  // Clear after announcement (optional)
  setTimeout(() => {
    announcement.textContent = ''
  }, 1000)
}

Advanced Custom Dropdown Implementation

/**
 * Creates an accessible custom theme switcher with dropdown
 * Follows ARIA combobox pattern
 */
export function createCustomThemeSwitcher(dom, options = {}) {
  const container = dom.createElement('div')
  container.className = 'custom-theme-switcher'
  
  // Button trigger
  const button = dom.createElement('button')
  button.type = 'button'
  button.id = 'theme-button'
  button.className = 'theme-button'
  button.setAttribute('aria-haspopup', 'listbox')
  button.setAttribute('aria-expanded', 'false')
  button.setAttribute('aria-labelledby', 'theme-button-label')
  
  const buttonLabel = dom.createElement('span')
  buttonLabel.id = 'theme-button-label'
  buttonLabel.textContent = 'Choose Theme: '
  
  const currentTheme = dom.createElement('span')
  currentTheme.textContent = themeLoader.currentTheme
  currentTheme.setAttribute('aria-live', 'polite')
  
  button.appendChild(buttonLabel)
  button.appendChild(currentTheme)
  
  // Listbox for options
  const listbox = dom.createElement('ul')
  listbox.id = 'theme-listbox'
  listbox.className = 'theme-listbox'
  listbox.setAttribute('role', 'listbox')
  listbox.setAttribute('aria-labelledby', 'theme-button-label')
  listbox.style.display = 'none'
  
  let selectedIndex = 0
  const themes = Object.entries(themeLoader.themes)
  
  themes.forEach(([name, path], index) => {
    const option = dom.createElement('li')
    option.setAttribute('role', 'option')
    option.id = `theme-option-${name}`
    option.textContent = name.charAt(0).toUpperCase() + name.slice(1)
    option.dataset.value = name
    
    if (name === themeLoader.currentTheme) {
      option.setAttribute('aria-selected', 'true')
      option.className = 'theme-option selected'
      selectedIndex = index
    } else {
      option.setAttribute('aria-selected', 'false')
      option.className = 'theme-option'
    }
    
    // Click handler
    option.addEventListener('click', () => {
      selectTheme(name, option)
    })
    
    listbox.appendChild(option)
  })
  
  // Toggle dropdown
  function toggleDropdown(show) {
    const isExpanded = show !== undefined ? show : button.getAttribute('aria-expanded') === 'false'
    button.setAttribute('aria-expanded', String(isExpanded))
    listbox.style.display = isExpanded ? 'block' : 'none'
    
    if (isExpanded) {
      // Focus first or selected option
      const selectedOption = listbox.querySelector('[aria-selected="true"]')
      if (selectedOption) {
        selectedOption.focus()
      }
    }
  }
  
  // Select theme
  function selectTheme(themeName, option) {
    // Update UI
    listbox.querySelectorAll('[role="option"]').forEach(opt => {
      opt.setAttribute('aria-selected', 'false')
      opt.classList.remove('selected')
    })
    option.setAttribute('aria-selected', 'true')
    option.classList.add('selected')
    
    currentTheme.textContent = option.textContent
    
    // Load theme
    themeLoader.loadTheme(themeName)
    
    // Close dropdown
    toggleDropdown(false)
    button.focus()
    
    // Announce
    announceThemeChange(dom, option.textContent)
  }
  
  // Keyboard navigation
  listbox.addEventListener('keydown', (e) => {
    const options = Array.from(listbox.querySelectorAll('[role="option"]'))
    const currentIndex = options.findIndex(opt => opt === dom.activeElement)
    
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        if (currentIndex < options.length - 1) {
          options[currentIndex + 1].focus()
        }
        break
        
      case 'ArrowUp':
        e.preventDefault()
        if (currentIndex > 0) {
          options[currentIndex - 1].focus()
        }
        break
        
      case 'Home':
        e.preventDefault()
        options[0].focus()
        break
        
      case 'End':
        e.preventDefault()
        options[options.length - 1].focus()
        break
        
      case 'Enter':
      case ' ':
        e.preventDefault()
        if (dom.activeElement.hasAttribute('data-value')) {
          selectTheme(dom.activeElement.dataset.value, dom.activeElement)
        }
        break
        
      case 'Escape':
        e.preventDefault()
        toggleDropdown(false)
        button.focus()
        break
    }
  })
  
  // Button click
  button.addEventListener('click', () => {
    toggleDropdown()
  })
  
  // Click outside to close
  dom.addEventListener('click', (e) => {
    if (!container.contains(e.target)) {
      toggleDropdown(false)
    }
  })
  
  container.appendChild(button)
  container.appendChild(listbox)
  
  return container
}

2. Button Components

Standard Button with Icon

/**
 * Creates an accessible button with icon
 */
export function createIconButton(dom, options) {
  const button = dom.createElement('button')
  button.type = options.type || 'button'
  button.className = options.className || 'icon-button'
  
  // Accessible label (required for icon-only buttons)
  button.setAttribute('aria-label', options.label)
  
  // Optional tooltip
  if (options.tooltip) {
    button.setAttribute('title', options.tooltip)
  }
  
  // Icon (decorative, hidden from screen readers)
  const icon = dom.createElement('span')
  icon.className = 'icon'
  icon.setAttribute('aria-hidden', 'true')
  icon.textContent = options.icon || '⚙️'
  
  button.appendChild(icon)
  
  // Click handler
  if (options.onClick) {
    button.addEventListener('click', options.onClick)
  }
  
  return button
}

// Usage
const settingsButton = createIconButton(dom, {
  label: 'Open settings',
  tooltip: 'Settings',
  icon: '⚙️',
  onClick: () => openSettings()
})

Toggle Button (Switch)

/**
 * Creates an accessible toggle/switch button
 */
export function createToggleButton(dom, options) {
  const button = dom.createElement('button')
  button.type = 'button'
  button.className = 'toggle-button'
  button.setAttribute('role', 'switch')
  button.setAttribute('aria-checked', String(options.checked || false))
  button.setAttribute('aria-label', options.label)
  
  // Visual indicator
  const indicator = dom.createElement('span')
  indicator.className = 'toggle-indicator'
  indicator.setAttribute('aria-hidden', 'true')
  
  const labelSpan = dom.createElement('span')
  labelSpan.textContent = options.label
  
  button.appendChild(indicator)
  button.appendChild(labelSpan)
  
  // Toggle handler
  button.addEventListener('click', () => {
    const isChecked = button.getAttribute('aria-checked') === 'true'
    button.setAttribute('aria-checked', String(!isChecked))
    
    if (options.onChange) {
      options.onChange(!isChecked)
    }
  })
  
  return button
}

// Usage
const darkModeToggle = createToggleButton(dom, {
  label: 'Dark mode',
  checked: false,
  onChange: (enabled) => {
    if (enabled) {
      themeLoader.loadTheme('dark')
    } else {
      themeLoader.loadTheme('light')
    }
  }
})

Button with Loading State

/**
 * Creates a button that can show loading state
 */
export function createLoadingButton(dom, options) {
  const button = dom.createElement('button')
  button.type = options.type || 'button'
  button.className = 'loading-button'
  
  const text = dom.createElement('span')
  text.className = 'button-text'
  text.textContent = options.text
  
  const spinner = dom.createElement('span')
  spinner.className = 'button-spinner'
  spinner.setAttribute('aria-hidden', 'true')
  spinner.textContent = '⏳'
  spinner.style.display = 'none'
  
  button.appendChild(text)
  button.appendChild(spinner)
  
  // Set loading state
  button.setLoading = (isLoading) => {
    button.disabled = isLoading
    button.setAttribute('aria-busy', String(isLoading))
    
    if (isLoading) {
      text.textContent = options.loadingText || 'Loading...'
      spinner.style.display = 'inline-block'
    } else {
      text.textContent = options.text
      spinner.style.display = 'none'
    }
  }
  
  // Click handler
  if (options.onClick) {
    button.addEventListener('click', async () => {
      button.setLoading(true)
      try {
        await options.onClick()
      } finally {
        button.setLoading(false)
      }
    })
  }
  
  return button
}

// Usage
const saveButton = createLoadingButton(dom, {
  text: 'Save Changes',
  loadingText: 'Saving...',
  onClick: async () => {
    await saveData()
  }
})

3. Form Components

Accessible Text Input

/**
 * Creates an accessible text input field
 */
export function createTextInput(dom, options) {
  const container = dom.createElement('div')
  container.className = 'form-field'
  
  // Label (required)
  const label = dom.createElement('label')
  label.htmlFor = options.id
  label.textContent = options.label
  if (options.required) {
    label.innerHTML += ' <span aria-label="required">*</span>'
  }
  
  // Input
  const input = dom.createElement('input')
  input.type = options.type || 'text'
  input.id = options.id
  input.name = options.name || options.id
  input.className = 'form-input'
  
  if (options.required) {
    input.setAttribute('aria-required', 'true')
    input.required = true
  }
  
  if (options.placeholder) {
    input.placeholder = options.placeholder
  }
  
  // Help text
  const helpId = `${options.id}-help`
  const helpText = dom.createElement('span')
  helpText.id = helpId
  helpText.className = 'help-text'
  helpText.textContent = options.helpText || ''
  
  // Error text
  const errorId = `${options.id}-error`
  const errorText = dom.createElement('span')
  errorText.id = errorId
  errorText.className = 'error-text'
  errorText.setAttribute('role', 'alert')
  errorText.style.display = 'none'
  
  // Link descriptions
  const describedBy = [helpId]
  if (options.showError) {
    describedBy.push(errorId)
  }
  input.setAttribute('aria-describedby', describedBy.join(' '))
  
  // Validation
  input.setError = (errorMessage) => {
    if (errorMessage) {
      input.setAttribute('aria-invalid', 'true')
      input.classList.add('error')
      errorText.textContent = errorMessage
      errorText.style.display = 'block'
    } else {
      input.setAttribute('aria-invalid', 'false')
      input.classList.remove('error')
      errorText.textContent = ''
      errorText.style.display = 'none'
    }
  }
  
  // Assemble
  container.appendChild(label)
  container.appendChild(input)
  if (options.helpText) {
    container.appendChild(helpText)
  }
  container.appendChild(errorText)
  
  return { container, input }
}

// Usage
const { container, input } = createTextInput(dom, {
  id: 'username',
  label: 'Username',
  required: true,
  helpText: 'Must be 3-20 characters',
  placeholder: 'Enter username'
})

// Validation example
input.addEventListener('blur', () => {
  if (input.value.length < 3) {
    input.setError('Username must be at least 3 characters')
  } else {
    input.setError(null)
  }
})

4. Chat Components

Chat Message with ARIA

/**
 * Creates an accessible chat message
 */
export function createChatMessage(dom, options) {
  const message = dom.createElement('li')
  message.className = 'chat-message'
  message.setAttribute('role', 'article')
  
  // Author
  const author = dom.createElement('span')
  author.className = 'message-author'
  author.textContent = options.author
  
  // Timestamp
  const timestamp = dom.createElement('time')
  timestamp.className = 'message-time'
  timestamp.setAttribute('datetime', options.timestamp)
  timestamp.textContent = formatTime(options.timestamp)
  
  // Content
  const content = dom.createElement('p')
  content.className = 'message-content'
  content.textContent = options.content
  
  message.appendChild(author)
  message.appendChild(timestamp)
  message.appendChild(content)
  
  return message
}

### Chat Container with Live Region

```javascript
/**
 * Creates an accessible chat container
 */
export function createChatContainer(dom, options) {
  const container = dom.createElement('div')
  container.className = 'chat-container'
  container.setAttribute('role', 'log')
  container.setAttribute('aria-label', options.label || 'Chat messages')
  
  // Message list
  const messageList = dom.createElement('ul')
  messageList.className = 'message-list'
  messageList.setAttribute('aria-live', 'polite')
  messageList.setAttribute('aria-atomic', 'false')
  messageList.setAttribute('aria-relevant', 'additions')
  
  // Screen reader announcement region (separate from visual)
  const srAnnouncement = dom.createElement('div')
  srAnnouncement.className = 'sr-only'
  srAnnouncement.setAttribute('role', 'status')
  srAnnouncement.setAttribute('aria-live', 'polite')
  srAnnouncement.setAttribute('aria-atomic', 'true')
  
  // Add message function
  container.addMessage = (messageData) => {
    // Add to visual list
    const message = createChatMessage(dom, messageData)
    messageList.appendChild(message)
    
    // Announce to screen readers
    srAnnouncement.textContent = `New message from ${messageData.author}: ${messageData.content}`
    
    // Clear announcement after it's been read
    setTimeout(() => {
      srAnnouncement.textContent = ''
    }, 1000)
    
    // Scroll to bottom
    messageList.scrollTop = messageList.scrollHeight
  }
  
  container.appendChild(messageList)
  container.appendChild(srAnnouncement)
  
  return container
}

5. Dialog/Modal Components

/**
 * Creates an accessible modal dialog
 */
export function createDialog(dom, options) {
  // Backdrop
  const backdrop = dom.createElement('div')
  backdrop.className = 'dialog-backdrop'
  backdrop.setAttribute('aria-hidden', 'true')
  
  // Dialog container
  const dialog = dom.createElement('div')
  dialog.className = 'dialog'
  dialog.setAttribute('role', 'dialog')
  dialog.setAttribute('aria-modal', 'true')
  dialog.setAttribute('aria-labelledby', 'dialog-title')
  dialog.setAttribute('aria-describedby', 'dialog-description')
  
  // Title
  const title = dom.createElement('h2')
  title.id = 'dialog-title'
  title.textContent = options.title
  
  // Description
  const description = dom.createElement('p')
  description.id = 'dialog-description'
  description.textContent = options.description
  
  // Content
  const content = dom.createElement('div')
  content.className = 'dialog-content'
  if (options.content) {
    content.appendChild(options.content)
  }
  
  // Actions
  const actions = dom.createElement('div')
  actions.className = 'dialog-actions'
  
  const cancelButton = dom.createElement('button')
  cancelButton.type = 'button'
  cancelButton.textContent = options.cancelText || 'Cancel'
  cancelButton.addEventListener('click', () => dialog.close())
  
  const confirmButton = dom.createElement('button')
  confirmButton.type = 'button'
  confirmButton.textContent = options.confirmText || 'Confirm'
  confirmButton.addEventListener('click', () => {
    if (options.onConfirm) {
      options.onConfirm()
    }
    dialog.close()
  })
  
  actions.appendChild(cancelButton)
  actions.appendChild(confirmButton)
  
  // Assemble
  dialog.appendChild(title)
  dialog.appendChild(description)
  dialog.appendChild(content)
  dialog.appendChild(actions)
  
  backdrop.appendChild(dialog)
  
  // Focus trap
  const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  let firstFocusable, lastFocusable
  
  function updateFocusableElements() {
    const focusables = dialog.querySelectorAll(focusableElements)
    firstFocusable = focusables[0]
    lastFocusable = focusables[focusables.length - 1]
  }
  
  dialog.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') {
      dialog.close()
      return
    }
    
    if (e.key === 'Tab') {
      if (e.shiftKey) {
        if (dom.activeElement === firstFocusable) {
          e.preventDefault()
          lastFocusable.focus()
        }
      } else {
        if (dom.activeElement === lastFocusable) {
          e.preventDefault()
          firstFocusable.focus()
        }
      }
    }
  })
  
  // Open/close methods
  let previousFocus
  
  dialog.open = () => {
    previousFocus = dom.activeElement
    dom.body.appendChild(backdrop)
    updateFocusableElements()
    firstFocusable.focus()
    dom.body.style.overflow = 'hidden'
  }
  
  dialog.close = () => {
    backdrop.remove()
    dom.body.style.overflow = ''
    if (previousFocus) {
      previousFocus.focus()
    }
  }
  
  // Close on backdrop click
  backdrop.addEventListener('click', (e) => {
    if (e.target === backdrop) {
      dialog.close()
    }
  })
  
  return dialog
}

// Usage
const confirmDialog = createDialog(dom, {
  title: 'Confirm Action',
  description: 'Are you sure you want to delete this item?',
  confirmText: 'Delete',
  cancelText: 'Cancel',
  onConfirm: () => {
    deleteItem()
  }
})

confirmDialog.open()

6. Utility Functions

Focus Management

/**
 * Manages focus for dynamic content
 */
export const focusManager = {
  /**
   * Stores current focus element
   */
  storeFocus() {
    this._previousFocus = document.activeElement
  },
  
  /**
   * Restores previously focused element
   */
  restoreFocus() {
    if (this._previousFocus && this._previousFocus.focus) {
      this._previousFocus.focus()
    }
  },
  
  /**
   * Moves focus to element
   */
  moveFocusTo(element) {
    if (element && element.focus) {
      element.focus()
    }
  },
  
  /**
   * Gets all focusable elements in container
   */
  getFocusableElements(container) {
    const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    return Array.from(container.querySelectorAll(selector))
      .filter(el => !el.disabled && el.offsetParent !== null)
  }
}

Announcement Helpers

/**
 * Announces message to screen readers
 */
export function announce(dom, message, priority = 'polite') {
  const announcement = dom.createElement('div')
  announcement.setAttribute('role', priority === 'assertive' ? 'alert' : 'status')
  announcement.setAttribute('aria-live', priority)
  announcement.setAttribute('aria-atomic', 'true')
  announcement.className = 'sr-only'
  announcement.textContent = message
  
  dom.body.appendChild(announcement)
  
  setTimeout(() => {
    announcement.remove()
  }, 1000)
}

// Usage
announce(dom, 'Theme changed successfully')
announce(dom, 'Error: Could not save changes', 'assertive')

Complete CSS for Accessibility

/* accessibility.css - Core accessibility styles */

/* Screen reader only content */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

.sr-only-focusable:focus {
  position: static;
  width: auto;
  height: auto;
  overflow: visible;
  clip: auto;
  white-space: normal;
}

/* Focus indicators */
:focus-visible {
  outline: 2px solid var(--sui-focus-color, #667eea);
  outline-offset: 2px;
}

/* Skip links */
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: var(--sui-primary);
  color: white;
  padding: 0.5em 1em;
  text-decoration: none;
  z-index: 100;
}

.skip-link:focus {
  top: 0;
}

/* High contrast mode */
@media (prefers-contrast: high) {
  :root {
    --sui-border: #000;
    --sui-text: #000;
    --sui-bg: #fff;
  }
  
  button,
  input,
  select {
    border: 2px solid currentColor;
  }
}

/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* Touch target size */
button,
a,
input,
select,
textarea {
  min-height: 44px;
  min-width: 44px;
}

/* Visible focus for keyboard users only */
:focus:not(:focus-visible) {
  outline: none;
}

Status: Reference Implementation
Last Updated: January 15, 2026
Maintainer: Solid-UI Team

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions