Files
metabuilder/hooks/useAccessible.ts
johndoe6345789 d23f4a8be4 feat: FakeMUI MUI-compatibility + shared components library + hooks consolidation
FakeMUI Components (MUI API compatibility):
- Add sx prop support to all components via sxToStyle utility
- Add MUI-style variants to Button (contained, outlined)
- Add component prop to Typography for polymorphic rendering
- Add label prop to Chip (MUI uses label vs children)
- Add edge/color/size props to IconButton
- Add component prop to List for nav rendering
- Add href support to ListItemButton
- Add variant prop to Avatar
- Add PaperProps to Drawer

New @metabuilder/components package:
- vanilla/loading - LoadingIndicator, InlineLoader, AsyncLoading
- vanilla/error - ErrorBoundary, ErrorDisplay, withErrorBoundary
- vanilla/empty-state - EmptyState + 7 specialized variants
- vanilla/skeleton - Skeleton + 6 specialized variants
- Organized by framework: vanilla/, radix/, fakemui/

Hooks consolidation (FakeMUI → root hooks/):
- useAccessible (5 accessibility hooks)
- useToast with ToastProvider
- FakeMUI re-exports from hooks for backward compatibility

WorkflowUI fixes:
- Fix showNotification → useUI error/success methods
- Fix Redux reducer setTimeout (Immer proxy issue)
- Fix useRef type error
- Update to Next.js 16.1.6 with webpack mode
- Add @metabuilder/fakemui dependency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 22:05:47 +00:00

721 lines
20 KiB
TypeScript

/**
* useAccessible Hooks
* Provides standardized accessibility attributes, test IDs, and keyboard navigation
* for React components. Migrated from @metabuilder/fakemui for broader usage.
*
* Features:
* - Consistent data-testid generation
* - ARIA attribute generation
* - Keyboard navigation handling
* - Focus management utilities
* - Live region announcements
* - Focus trapping for modals
*
* @example
* const { testId, ariaLabel } = useAccessible({
* feature: 'form',
* component: 'button',
* action: 'submit'
* })
*
* <button data-testid={testId} aria-label={ariaLabel}>
* Submit
* </button>
*/
import React from 'react'
// ============================================================================
// Types
// ============================================================================
export type AccessibilityFeature =
| 'canvas'
| 'settings'
| 'navigation'
| 'editor'
| 'workflow'
| 'project'
| 'workspace'
| 'auth'
| 'modal'
| 'toolbar'
| 'header'
| 'sidebar'
| 'form'
| 'dialog'
| 'table'
| 'menu'
| 'card'
| 'button'
| 'input'
| 'select'
export type AccessibilityComponent =
| 'item'
| 'button'
| 'input'
| 'select'
| 'checkbox'
| 'radio'
| 'label'
| 'grid'
| 'list'
| 'panel'
| 'container'
| 'header'
| 'footer'
| 'menu'
| 'tab'
| 'icon'
| 'progress'
| 'tooltip'
| 'modal'
| 'card'
| 'section'
| 'link'
| 'image'
| 'text'
| 'badge'
| 'chip'
| 'divider'
| 'stepper'
| 'slider'
| 'switch'
export type AccessibilityAction =
| 'drag'
| 'resize'
| 'click'
| 'open'
| 'close'
| 'edit'
| 'delete'
| 'submit'
| 'cancel'
| 'focus'
| 'blur'
| 'select'
| 'deselect'
| 'expand'
| 'collapse'
| 'previous'
| 'next'
| 'first'
| 'last'
| 'toggle'
| 'loading'
| 'success'
| 'error'
| 'warning'
| 'info'
| 'favorite'
| 'share'
| 'more'
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Generate standardized data-testid
* Format: {feature}-{component}-{action}
* Example: canvas-item-drag, settings-password-input
*/
export function generateTestId(
feature: AccessibilityFeature | string,
component: AccessibilityComponent | string,
action?: AccessibilityAction | string,
identifier?: string
): string {
const parts = [feature, component]
if (action) parts.push(action)
if (identifier) parts.push(identifier)
return parts.join('-')
}
/**
* Common test ID generators with presets
*/
export const testId = {
// Generic
button: (label: string) => generateTestId('form', 'button', 'click', label),
input: (name: string) => generateTestId('form', 'input', undefined, name),
select: (name: string) => generateTestId('form', 'select', undefined, name),
checkbox: (name: string) => generateTestId('form', 'checkbox', undefined, name),
radio: (name: string) => generateTestId('form', 'radio', undefined, name),
label: (name: string) => generateTestId('form', 'label', undefined, name),
link: (label: string) => generateTestId('navigation', 'link', 'click', label),
icon: (name: string) => generateTestId('form', 'icon', undefined, name),
image: (name: string) => generateTestId('form', 'image', undefined, name),
text: (content: string) => generateTestId('form', 'text', undefined, content),
badge: (label: string) => generateTestId('form', 'badge', undefined, label),
chip: (label: string) => generateTestId('form', 'chip', undefined, label),
divider: () => generateTestId('form', 'divider'),
stepper: () => generateTestId('form', 'stepper'),
slider: (name: string) => generateTestId('form', 'slider', undefined, name),
switch: (name: string) => generateTestId('form', 'switch', undefined, name),
// Canvas
canvasContainer: () => generateTestId('canvas', 'container'),
canvasGrid: () => generateTestId('canvas', 'grid'),
canvasItem: (id?: string) => generateTestId('canvas', 'item', 'drag', id),
canvasItemResize: (id?: string) => generateTestId('canvas', 'item', 'resize', id),
canvasItemDelete: (id?: string) => generateTestId('canvas', 'item', 'delete', id),
canvasZoomIn: () => generateTestId('canvas', 'button', 'click', 'zoom-in'),
canvasZoomOut: () => generateTestId('canvas', 'button', 'click', 'zoom-out'),
canvasZoomReset: () => generateTestId('canvas', 'button', 'click', 'zoom-reset'),
canvasPan: () => generateTestId('canvas', 'button', 'click', 'pan'),
canvasGridToggle: () => generateTestId('canvas', 'button', 'toggle', 'grid'),
canvasSnapToggle: () => generateTestId('canvas', 'button', 'toggle', 'snap'),
// Settings
settingsPanel: () => generateTestId('settings', 'panel'),
settingsCanvasSection: () => generateTestId('settings', 'section', undefined, 'canvas'),
settingsSecuritySection: () => generateTestId('settings', 'section', undefined, 'security'),
settingsNotificationSection: () => generateTestId('settings', 'section', undefined, 'notification'),
settingsInput: (name: string) => generateTestId('settings', 'input', undefined, name),
settingsCheckbox: (name: string) => generateTestId('settings', 'checkbox', undefined, name),
settingsSelect: (name: string) => generateTestId('settings', 'select', undefined, name),
settingsButton: (action: string) => generateTestId('settings', 'button', 'click', action),
// Navigation
navHeader: () => generateTestId('navigation', 'header'),
navSidebar: () => generateTestId('navigation', 'sidebar'),
navMenu: () => generateTestId('navigation', 'menu'),
navMenuButton: (label: string) => generateTestId('navigation', 'button', 'click', label),
navTab: (label: string) => generateTestId('navigation', 'tab', undefined, label),
navBreadcrumb: () => generateTestId('navigation', 'list'),
navLink: (label: string) => generateTestId('navigation', 'button', 'click', label),
// Editor
editorContainer: () => generateTestId('editor', 'container'),
editorToolbar: () => generateTestId('editor', 'toolbar'),
editorButton: (action: string) => generateTestId('editor', 'button', 'click', action),
editorNode: (id: string) => generateTestId('editor', 'item', undefined, id),
// Workflow/Project
workflowCard: (id: string) => generateTestId('workflow', 'card', undefined, id),
workflowCardButton: (id: string, action: string) => generateTestId('workflow', 'button', 'click', `${id}-${action}`),
projectSidebar: () => generateTestId('project', 'sidebar'),
projectList: () => generateTestId('project', 'list'),
projectItem: (id: string) => generateTestId('project', 'item', 'click', id),
// Auth
authForm: (type: 'login' | 'register') => generateTestId('auth', 'form', undefined, type),
authInput: (field: string) => generateTestId('auth', 'input', undefined, field),
authButton: (action: string) => generateTestId('auth', 'button', 'click', action),
// Modal/Dialog
modal: (name: string) => generateTestId('modal', 'modal', undefined, name),
modalClose: (name: string) => generateTestId('modal', 'button', 'click', `${name}-close`),
modalButton: (name: string, action: string) => generateTestId('modal', 'button', 'click', `${name}-${action}`),
// Table
table: (name: string) => generateTestId('table', 'table', undefined, name),
tableRow: (name: string, rowId: string) => generateTestId('table', 'item', undefined, `${name}-${rowId}`),
tableCell: (name: string, rowId: string, colId: string) => generateTestId('table', 'item', undefined, `${name}-${rowId}-${colId}`),
// Menu
menu: (name: string) => generateTestId('menu', 'menu', undefined, name),
menuItem: (label: string) => generateTestId('menu', 'button', 'click', label),
// Card
card: (id: string) => generateTestId('card', 'card', undefined, id),
cardButton: (id: string, action: string) => generateTestId('card', 'button', 'click', `${id}-${action}`),
// Help/Documentation
help: (name: string) => generateTestId('help', 'section', undefined, name),
helpButton: () => generateTestId('help', 'button', 'click', 'open'),
helpModal: (name: string) => generateTestId('help', 'modal', undefined, name),
helpSearch: () => generateTestId('help', 'input', undefined, 'search'),
helpNav: (name: string) => generateTestId('help', 'nav', undefined, name),
alert: (type: string) => generateTestId('alert', 'alert', undefined, type),
section: (id: string) => generateTestId('section', 'region', undefined, id),
listItem: (label: string) => generateTestId('list', 'item', undefined, label),
}
/**
* Generate ARIA attributes object for common patterns
*/
export const aria = {
// Button patterns
button: (label: string) => ({
'aria-label': label,
role: 'button' as const,
}),
// Toggle patterns
toggle: (label: string, isActive: boolean) => ({
'aria-label': label,
'aria-pressed': isActive,
role: 'switch' as const,
}),
// Menu/Navigation patterns
menu: () => ({
role: 'menu' as const,
}),
menuItem: (label: string) => ({
'aria-label': label,
role: 'menuitem' as const,
}),
// List patterns
list: (label?: string) => ({
...(label && { 'aria-label': label }),
role: 'list' as const,
}),
listItem: () => ({
role: 'listitem' as const,
}),
// Form patterns
label: (htmlFor: string) => ({
htmlFor,
}),
input: (ariaLabel: string, ariaDescribedBy?: string) => ({
'aria-label': ariaLabel,
...(ariaDescribedBy && { 'aria-describedby': ariaDescribedBy }),
}),
checkbox: (label: string, isChecked: boolean) => ({
'aria-label': label,
'aria-checked': isChecked,
role: 'checkbox' as const,
}),
radio: (label: string, isSelected: boolean) => ({
'aria-label': label,
'aria-checked': isSelected,
role: 'radio' as const,
}),
combobox: (isExpanded: boolean, hasPopup = true) => ({
'aria-expanded': isExpanded,
'aria-haspopup': hasPopup,
role: 'combobox' as const,
}),
// Dialog/Modal patterns
dialog: (label: string) => ({
'aria-label': label,
'aria-modal': true,
role: 'dialog' as const,
}),
// Tab patterns
tablist: () => ({
role: 'tablist' as const,
}),
tab: (isSelected: boolean, controls?: string) => ({
role: 'tab' as const,
'aria-selected': isSelected,
...(controls && { 'aria-controls': controls }),
}),
tabpanel: (label: string, isVisible: boolean) => ({
role: 'tabpanel' as const,
'aria-label': label,
...(isVisible === false && { hidden: true }),
}),
// Status/Alert patterns
status: (message: string, level: 'info' | 'warning' | 'error' | 'success' = 'info') => ({
role: 'status' as const,
'aria-label': `${level}: ${message}`,
'aria-live': (level === 'error' ? 'assertive' : 'polite') as 'assertive' | 'polite',
}),
alert: (message: string) => ({
role: 'alert' as const,
'aria-label': message,
'aria-live': 'assertive' as const,
}),
// Expandable/Collapsible patterns
collapsible: (isExpanded: boolean, controls?: string) => ({
'aria-expanded': isExpanded,
...(controls && { 'aria-controls': controls }),
}),
// Progress patterns
progressbar: (value: number, max = 100, label?: string) => ({
role: 'progressbar' as const,
'aria-valuenow': value,
'aria-valuemin': 0,
'aria-valuemax': max,
...(label && { 'aria-label': label }),
}),
// Slider patterns
slider: (value: number, min: number, max: number, label?: string) => ({
role: 'slider' as const,
'aria-valuenow': value,
'aria-valuemin': min,
'aria-valuemax': max,
...(label && { 'aria-label': label }),
}),
// Loading/Busy patterns
busy: () => ({
'aria-busy': true,
'aria-live': 'polite' as const,
}),
// Disabled patterns
disabled: () => ({
'aria-disabled': true,
}),
// Hidden patterns
hidden: () => ({
'aria-hidden': true,
}),
// Live region patterns
liveRegion: (polite = true) => ({
'aria-live': (polite ? 'polite' : 'assertive') as 'polite' | 'assertive',
'aria-atomic': true,
}),
// Description patterns
describedBy: (id: string) => ({
'aria-describedby': id,
}),
// Label by pattern
labelledBy: (id: string) => ({
'aria-labelledby': id,
}),
// Error patterns
invalid: (errorId?: string) => ({
'aria-invalid': true,
...(errorId && { 'aria-describedby': errorId }),
}),
// Required patterns
required: () => ({
'aria-required': true,
}),
}
/**
* Accessibility-focused keyboard event handler patterns
*/
export const keyboard = {
/**
* Check if key event is for activation (Enter or Space)
*/
isActivation: (key: string): boolean => key === 'Enter' || key === ' ',
/**
* Check if key is arrow key
*/
isArrow: (key: string): boolean =>
['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key),
/**
* Check if key is Escape
*/
isEscape: (key: string): boolean => key === 'Escape',
/**
* Check if key is Tab
*/
isTab: (key: string): boolean => key === 'Tab',
/**
* Get arrow direction (1 for forward, -1 for backward)
*/
getArrowDirection: (
key: string,
horizontal = true
): 0 | 1 | -1 => {
if (horizontal) {
if (key === 'ArrowRight') return 1
if (key === 'ArrowLeft') return -1
} else {
if (key === 'ArrowDown') return 1
if (key === 'ArrowUp') return -1
}
return 0
},
}
/**
* Accessibility validators
*/
export const validate = {
/**
* Validate that an element has proper aria-label or aria-labelledby
*/
hasLabel: (element: HTMLElement): boolean => {
return !!(element.getAttribute('aria-label') || element.getAttribute('aria-labelledby'))
},
/**
* Validate that form inputs have associated labels
*/
hasFormLabel: (input: HTMLInputElement): boolean => {
const id = input.id
if (!id) return false
const label = document.querySelector(`label[for="${id}"]`)
return !!label || input.hasAttribute('aria-label') || input.hasAttribute('aria-labelledby')
},
/**
* Validate that an interactive element is keyboard accessible
*/
isKeyboardAccessible: (element: HTMLElement): boolean => {
const role = element.getAttribute('role')
const tabIndex = element.tabIndex
return tabIndex >= 0 || ['button', 'link', 'menuitem', 'tab'].includes(role || '')
},
/**
* Validate that an element has sufficient color contrast
* Note: This requires runtime color computation
*/
hasContrast: (element: HTMLElement, _minRatio = 4.5): boolean => {
const style = window.getComputedStyle(element)
const bgColor = style.backgroundColor
const fgColor = style.color
return !!(bgColor && fgColor)
},
}
// ============================================================================
// React Hooks
// ============================================================================
interface UseAccessibleOptions {
feature: AccessibilityFeature | string
component: AccessibilityComponent | string
action?: AccessibilityAction | string
identifier?: string
ariaLabel?: string
ariaDescribedBy?: string
}
interface AccessibleAttributes {
'data-testid': string
'aria-label'?: string
'aria-describedby'?: string
role?: string
}
/**
* Hook for generating consistent accessibility attributes
* Combines data-testid and ARIA attributes in a single call
*
* @example
* const attrs = useAccessible({
* feature: 'form',
* component: 'button',
* action: 'submit'
* })
*
* <button {...attrs}>Submit</button>
*/
export function useAccessible(options: UseAccessibleOptions): AccessibleAttributes {
const { feature, component, action, identifier, ariaLabel, ariaDescribedBy } = options
const testIdValue = generateTestId(feature, component, action, identifier)
const attributes: AccessibleAttributes = {
'data-testid': testIdValue,
}
if (ariaLabel) {
attributes['aria-label'] = ariaLabel
}
if (ariaDescribedBy) {
attributes['aria-describedby'] = ariaDescribedBy
}
return attributes
}
/**
* Hook for keyboard navigation handling
* Provides common keyboard event handlers
*
* @example
* const keyboardProps = useKeyboardNavigation({
* onEnter: () => handleSubmit(),
* onEscape: () => handleClose(),
* onArrowUp: () => handlePrevious(),
* onArrowDown: () => handleNext()
* })
*
* <div {...keyboardProps}>Content</div>
*/
export function useKeyboardNavigation(handlers: {
onEnter?: () => void
onEscape?: () => void
onArrowUp?: () => void
onArrowDown?: () => void
onArrowLeft?: () => void
onArrowRight?: () => void
onTab?: () => void
}) {
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handlers.onEnter?.()
} else if (e.key === 'Escape') {
e.preventDefault()
handlers.onEscape?.()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
handlers.onArrowUp?.()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
handlers.onArrowDown?.()
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
handlers.onArrowLeft?.()
} else if (e.key === 'ArrowRight') {
e.preventDefault()
handlers.onArrowRight?.()
} else if (e.key === 'Tab') {
handlers.onTab?.()
}
},
[handlers]
)
return { onKeyDown: handleKeyDown }
}
/**
* Hook for managing focus
* Provides focus management utilities
*
* @example
* const { focusRef, focus } = useFocusManagement()
*
* <button ref={focusRef} onClick={() => focus()}>
* Refocus me
* </button>
*/
export function useFocusManagement() {
const ref = React.useRef<HTMLElement>(null)
const focus = React.useCallback(() => {
ref.current?.focus()
}, [])
const blur = React.useCallback(() => {
ref.current?.blur()
}, [])
return { focusRef: ref, focus, blur }
}
/**
* Hook for live region announcements
* Provides screen reader announcements
*
* @example
* const { announce, liveRegionProps, message } = useLiveRegion('polite')
*
* announce('Item deleted successfully')
*
* <div {...liveRegionProps}>{message}</div>
*/
export function useLiveRegion(politeness: 'polite' | 'assertive' = 'polite') {
const [message, setMessage] = React.useState('')
const announce = React.useCallback(
(text: string) => {
setMessage(text)
// Clear after announcement
setTimeout(() => setMessage(''), 1000)
},
[]
)
return {
announce,
liveRegionProps: {
role: 'status' as const,
'aria-live': politeness,
'aria-atomic': true as const,
},
message,
}
}
/**
* Hook for managing modal/dialog focus
* Traps focus within modal and restores on close
*
* @example
* const { focusTrapRef } = useFocusTrap(isOpen)
*
* <div ref={focusTrapRef}>
* Modal content
* </div>
*/
export function useFocusTrap(isActive: boolean) {
const containerRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (!isActive || !containerRef.current) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
const focusableElements = containerRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) as NodeListOf<HTMLElement>
if (!focusableElements || focusableElements.length === 0) return
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
const activeElement = document.activeElement
if (e.shiftKey && activeElement === firstElement) {
e.preventDefault()
lastElement.focus()
} else if (!e.shiftKey && activeElement === lastElement) {
e.preventDefault()
firstElement.focus()
}
}
const container = containerRef.current
container.addEventListener('keydown', handleKeyDown)
return () => {
container.removeEventListener('keydown', handleKeyDown)
}
}, [isActive])
return { focusTrapRef: containerRef }
}
// Default export for convenience
export default {
generateTestId,
testId,
aria,
keyboard,
validate,
useAccessible,
useKeyboardNavigation,
useFocusManagement,
useLiveRegion,
useFocusTrap,
}