-
Notifications
You must be signed in to change notification settings - Fork 41
Open
Description
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
Labels
No labels