Skip to content

Commit 478e718

Browse files
gregceclaude
andcommitted
Improve accessibility across docs components
Add comprehensive accessibility improvements for WCAG compliance: - Add aria-hidden="true" to all decorative SVG icons - Add aria-expanded attributes to expandable controls (accordion, folder toggles) - Add aria-haspopup="dialog" to search trigger button - Add role="listbox/option" with aria-selected to search results - Add focus-visible ring styles for keyboard navigation - Increase theme toggle touch target to 44x44px minimum - Refactor Tabs to use proper ARIA tabs pattern with: - role="tablist/tab/tabpanel" - aria-selected, aria-controls, aria-labelledby - Arrow key navigation between tabs - Proper tabIndex management Bump version to 1.2.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent c099f5b commit 478e718

8 files changed

Lines changed: 112 additions & 47 deletions

File tree

packages/create-unmint/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "create-unmint",
3-
"version": "1.0.0",
3+
"version": "1.2.0",
44
"description": "Create a new Unmint documentation project",
55
"type": "module",
66
"bin": {

packages/template/app/components/docs/docs-header.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export function DocsHeader({ tree }: DocsHeaderProps) {
3333
aria-label="Open menu"
3434
aria-expanded={isMobileMenuOpen}
3535
>
36-
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
36+
<svg aria-hidden="true" className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
3737
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
3838
</svg>
3939
</button>
@@ -68,7 +68,7 @@ export function DocsHeader({ tree }: DocsHeaderProps) {
6868
className="p-2 text-muted-foreground hover:text-foreground transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
6969
aria-label="GitHub"
7070
>
71-
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
71+
<svg aria-hidden="true" className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
7272
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
7373
</svg>
7474
</a>

packages/template/app/components/docs/docs-sidebar.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function DocsSidebar({ tree }: DocsSidebarProps) {
2525
className="flex items-center gap-3 py-1 text-sm text-[var(--accent)] font-medium hover:opacity-80 transition-opacity"
2626
>
2727
<span className="flex items-center justify-center w-7 h-7 rounded-md bg-[var(--accent-muted)]">
28-
<svg className="w-4 h-4 text-[var(--accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
28+
<svg aria-hidden="true" className="w-4 h-4 text-[var(--accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
2929
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
3030
</svg>
3131
</span>
@@ -41,7 +41,7 @@ export function DocsSidebar({ tree }: DocsSidebarProps) {
4141
className="flex items-center gap-3 py-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
4242
>
4343
<span className="flex items-center justify-center w-7 h-7 rounded-md bg-gray-100 dark:bg-gray-800">
44-
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
44+
<svg aria-hidden="true" className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
4545
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
4646
</svg>
4747
</span>
@@ -56,7 +56,7 @@ export function DocsSidebar({ tree }: DocsSidebarProps) {
5656
className="flex items-center gap-3 py-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
5757
>
5858
<span className="flex items-center justify-center w-7 h-7 rounded-md bg-gray-100 dark:bg-gray-800">
59-
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
59+
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
6060
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
6161
</svg>
6262
</span>

packages/template/app/components/docs/mdx/accordion.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ export function Accordion({ title, children, defaultOpen = false }: AccordionPro
2828
<div>
2929
<button
3030
onClick={() => setIsOpen(!isOpen)}
31-
className="flex w-full items-center justify-between px-4 py-4 text-left font-medium text-foreground hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
31+
aria-expanded={isOpen}
32+
className="flex w-full items-center justify-between px-4 py-4 text-left font-medium text-foreground hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-inset"
3233
>
3334
<span>{title}</span>
3435
<svg
36+
aria-hidden="true"
3537
className={cn(
3638
'w-5 h-5 text-muted-foreground transition-transform duration-200',
3739
isOpen && 'rotate-180'
Lines changed: 85 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
'use client'
22

3-
import { useState, createContext, useContext } from 'react'
3+
import { useState, createContext, useContext, Children, isValidElement, useId } from 'react'
44
import { cn } from '@/lib/utils'
55

66
interface TabsContextValue {
77
activeTab: string
88
setActiveTab: (tab: string) => void
9+
tabsId: string
910
}
1011

1112
const TabsContext = createContext<TabsContextValue | null>(null)
@@ -17,10 +18,87 @@ interface TabsProps {
1718

1819
export function Tabs({ children, defaultValue }: TabsProps) {
1920
const [activeTab, setActiveTab] = useState(defaultValue || '')
21+
const tabsId = useId()
22+
23+
// Extract tab titles and content from children
24+
const tabs: { title: string; content: React.ReactNode }[] = []
25+
Children.forEach(children, (child) => {
26+
if (isValidElement<TabProps>(child) && child.props.title) {
27+
tabs.push({
28+
title: child.props.title,
29+
content: child.props.children,
30+
})
31+
}
32+
})
33+
34+
// Set default active tab if not set
35+
const currentActiveTab = activeTab || (tabs[0]?.title ?? '')
2036

2137
return (
22-
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
23-
<div className="my-6">{children}</div>
38+
<TabsContext.Provider value={{ activeTab: currentActiveTab, setActiveTab, tabsId }}>
39+
<div className="my-6">
40+
{/* Tab list */}
41+
<div role="tablist" aria-label="Tabs" className="flex border-b border-border">
42+
{tabs.map((tab, index) => {
43+
const isActive = currentActiveTab === tab.title
44+
const tabId = `${tabsId}-tab-${index}`
45+
const panelId = `${tabsId}-panel-${index}`
46+
47+
return (
48+
<button
49+
key={tab.title}
50+
role="tab"
51+
id={tabId}
52+
aria-selected={isActive}
53+
aria-controls={panelId}
54+
tabIndex={isActive ? 0 : -1}
55+
onClick={() => setActiveTab(tab.title)}
56+
onKeyDown={(e) => {
57+
if (e.key === 'ArrowRight') {
58+
e.preventDefault()
59+
const nextIndex = (index + 1) % tabs.length
60+
setActiveTab(tabs[nextIndex].title)
61+
} else if (e.key === 'ArrowLeft') {
62+
e.preventDefault()
63+
const prevIndex = (index - 1 + tabs.length) % tabs.length
64+
setActiveTab(tabs[prevIndex].title)
65+
}
66+
}}
67+
className={cn(
68+
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
69+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2',
70+
isActive
71+
? 'border-[var(--accent)] text-[var(--accent)]'
72+
: 'border-transparent text-muted-foreground hover:text-foreground'
73+
)}
74+
>
75+
{tab.title}
76+
</button>
77+
)
78+
})}
79+
</div>
80+
81+
{/* Tab panels */}
82+
{tabs.map((tab, index) => {
83+
const isActive = currentActiveTab === tab.title
84+
const tabId = `${tabsId}-tab-${index}`
85+
const panelId = `${tabsId}-panel-${index}`
86+
87+
return (
88+
<div
89+
key={tab.title}
90+
role="tabpanel"
91+
id={panelId}
92+
aria-labelledby={tabId}
93+
hidden={!isActive}
94+
tabIndex={0}
95+
className={cn('pt-4 [&>pre]:mt-0', !isActive && 'hidden')}
96+
>
97+
{tab.content}
98+
</div>
99+
)
100+
})}
101+
</div>
24102
</TabsContext.Provider>
25103
)
26104
}
@@ -30,31 +108,9 @@ interface TabProps {
30108
children: React.ReactNode
31109
}
32110

111+
// Tab is now just a data container, rendering is handled by Tabs
33112
export function Tab({ title, children }: TabProps) {
34-
const context = useContext(TabsContext)
35-
if (!context) return null
36-
37-
const { activeTab, setActiveTab } = context
38-
const isActive = activeTab === title || (!activeTab && title)
39-
40-
return (
41-
<>
42-
<button
43-
onClick={() => setActiveTab(title)}
44-
className={cn(
45-
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
46-
isActive
47-
? 'border-[var(--accent)] text-[var(--accent)]'
48-
: 'border-transparent text-muted-foreground hover:text-foreground'
49-
)}
50-
>
51-
{title}
52-
</button>
53-
{isActive && (
54-
<div className="pt-4 [&>pre]:mt-0">
55-
{children}
56-
</div>
57-
)}
58-
</>
59-
)
113+
// This component is used for data extraction only
114+
// Actual rendering happens in Tabs component
115+
return null
60116
}

packages/template/app/components/docs/mobile-sidebar.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export function MobileSidebar({ tree, isOpen, onClose }: MobileSidebarProps) {
135135
className="p-2 -mr-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
136136
aria-label="Close menu"
137137
>
138-
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
138+
<svg aria-hidden="true" className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
139139
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
140140
</svg>
141141
</button>
@@ -153,7 +153,7 @@ export function MobileSidebar({ tree, isOpen, onClose }: MobileSidebarProps) {
153153
className="flex items-center gap-3 py-2 px-2 text-sm text-[var(--accent)] font-medium hover:bg-[var(--accent-muted)] rounded-md transition-colors min-h-[44px]"
154154
>
155155
<span className="flex items-center justify-center w-7 h-7 rounded-md bg-[var(--accent-muted)]">
156-
<svg className="w-4 h-4 text-[var(--accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
156+
<svg aria-hidden="true" className="w-4 h-4 text-[var(--accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
157157
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
158158
</svg>
159159
</span>
@@ -169,7 +169,7 @@ export function MobileSidebar({ tree, isOpen, onClose }: MobileSidebarProps) {
169169
className="flex items-center gap-3 py-2 px-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors min-h-[44px]"
170170
>
171171
<span className="flex items-center justify-center w-7 h-7 rounded-md bg-muted">
172-
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
172+
<svg aria-hidden="true" className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
173173
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
174174
</svg>
175175
</span>
@@ -184,7 +184,7 @@ export function MobileSidebar({ tree, isOpen, onClose }: MobileSidebarProps) {
184184
className="flex items-center gap-3 py-2 px-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors min-h-[44px]"
185185
>
186186
<span className="flex items-center justify-center w-7 h-7 rounded-md bg-muted">
187-
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
187+
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
188188
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
189189
</svg>
190190
</span>
@@ -243,10 +243,12 @@ function MobileSidebarNode({ node, pathname, onNavigate }: MobileSidebarNodeProp
243243
<div>
244244
<button
245245
onClick={() => setIsExpanded(!isExpanded)}
246-
className="flex items-center justify-between w-full py-2 px-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors min-h-[44px]"
246+
aria-expanded={isExpanded}
247+
className="flex items-center justify-between w-full py-2 px-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors min-h-[44px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-inset"
247248
>
248249
<span>{node.name}</span>
249250
<svg
251+
aria-hidden="true"
250252
className={cn('w-4 h-4 transition-transform', isExpanded && 'rotate-90')}
251253
fill="none"
252254
stroke="currentColor"

packages/template/app/components/docs/search-dialog.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function SearchTrigger() {
3131
<button
3232
type="button"
3333
onClick={() => setOpen(true)}
34+
aria-haspopup="dialog"
3435
className={cn(
3536
'flex items-center gap-3 px-4 py-2.5 rounded-lg w-full max-w-md',
3637
'bg-muted/50 border border-border/50',
@@ -198,7 +199,7 @@ function SearchDialog({ onClose }: SearchDialogProps) {
198199
<p>Searching...</p>
199200
</div>
200201
) : results.length > 0 ? (
201-
<ul ref={resultsRef} className="py-2">
202+
<ul ref={resultsRef} role="listbox" aria-label="Search results" className="py-2">
202203
{results.map((result, index) => {
203204
// Build breadcrumb path
204205
const breadcrumbPath = result.breadcrumbs && result.breadcrumbs.length > 0
@@ -211,8 +212,11 @@ function SearchDialog({ onClose }: SearchDialogProps) {
211212
type="button"
212213
onClick={() => handleSelect(result.url)}
213214
onMouseEnter={() => setSelectedIndex(index)}
215+
role="option"
216+
aria-selected={selectedIndex === index}
214217
className={cn(
215-
'w-full px-4 py-3 text-left transition-colors focus:outline-none',
218+
'w-full px-4 py-3 text-left transition-colors',
219+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent)]',
216220
selectedIndex === index
217221
? 'bg-gray-100 dark:bg-gray-800'
218222
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50'
@@ -265,6 +269,7 @@ function SearchDialog({ onClose }: SearchDialogProps) {
265269
function SearchIcon({ className }: { className?: string }) {
266270
return (
267271
<svg
272+
aria-hidden="true"
268273
className={className}
269274
fill="none"
270275
viewBox="0 0 24 24"

packages/template/app/components/docs/theme-toggle.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,21 @@ export function ThemeToggle() {
1212
}, [])
1313

1414
if (!mounted) {
15-
return <div className="w-9 h-9" />
15+
return <div className="w-11 h-11" />
1616
}
1717

1818
return (
1919
<button
2020
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
21-
className="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
21+
className="flex items-center justify-center w-11 h-11 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
2222
aria-label="Toggle theme"
2323
>
2424
{theme === 'dark' ? (
25-
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
25+
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
2626
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
2727
</svg>
2828
) : (
29-
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
29+
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
3030
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
3131
</svg>
3232
)}

0 commit comments

Comments
 (0)