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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions