Skip to content

Commit 3fe8cd1

Browse files
authored
fix: improve AskAI panel accessibility (#616)
* perf: remove PostHog, optimize image assets, and tune bundle imports Drop the unused posthog-js dependency and its NEXT_PUBLIC_POSTHOG_* env vars; analytics now runs through Plausible and the env-gated Core Web Vitals monitor only. Add scripts/optimize-images.mjs (sharp) and an optimize:images npm script, then run it across public/: raster assets are recompressed in place, oversized images are capped retina-safe, and Open Graph cards are resized to 1200x630. This cuts public/ from 110 MB to 61 MB. The 11.6 MB changelog GIF is converted to a 1.9 MB animated WebP. Enable experimental.optimizePackageImports for lucide-react and framer-motion to tree-shake their barrel files. * feat: add JSON-LD structured data and canonical URLs Introduce lib/structured-data.ts schema builders and a <JsonLd> component, then wire schema.org metadata into the key page types: Organization, WebSite and SoftwareApplication on the home page, TechArticle + BreadcrumbList on docs, and BlogPosting/Article + BreadcrumbList on blog and changelog entries. Add per-page canonical URLs and richer Open Graph article metadata, and give the sitemap per-route priority and changefreq instead of a blanket daily value. * fix: improve AskAI panel accessibility Mark the slide-over panel as role="dialog" with an accessible name, close it on Escape from anywhere within it, and restore focus to the trigger button when it closes so keyboard users are not stranded. Expose aria-haspopup and aria-expanded on the trigger.
1 parent 13fa9e2 commit 3fe8cd1

1 file changed

Lines changed: 29 additions & 21 deletions

File tree

‎components/ai/AskAI.tsx‎

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,8 @@ export function AskAI() {
285285
const [mode, setMode] = useState<'search' | 'page'>('search')
286286
const [shareUrl, setShareUrl] = useState<string | null>(null)
287287
const scrollRef = useRef<HTMLDivElement>(null)
288+
const triggerRef = useRef<HTMLButtonElement>(null)
289+
const prevOpenRef = useRef(false)
288290
const shareTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
289291
const router = useRouter()
290292
const pathname = usePathname()
@@ -336,11 +338,7 @@ export function AskAI() {
336338
for (const m of messages) {
337339
if (m.role !== 'assistant' || navigatedRef.current.has(m.id)) continue
338340
for (const part of m.parts ?? []) {
339-
if (
340-
part.type === 'tool-navigate' &&
341-
'state' in part &&
342-
part.state === 'output-available'
343-
) {
341+
if (part.type === 'tool-navigate' && 'state' in part && part.state === 'output-available') {
344342
const result = part.output as { action?: string; url?: string } | undefined
345343
if (
346344
result?.action === 'navigate' &&
@@ -360,8 +358,7 @@ export function AskAI() {
360358
if (!text || isLoading) return
361359
setInput('')
362360
setShareUrl(null)
363-
const context =
364-
mode === 'search' && isDocsPage ? `[I'm currently on ${pathname}] ` : ''
361+
const context = mode === 'search' && isDocsPage ? `[I'm currently on ${pathname}] ` : ''
365362
sendMessage({ text: context + text })
366363
}, [input, isLoading, sendMessage, pathname, mode, isDocsPage])
367364

@@ -453,31 +450,46 @@ export function AskAI() {
453450
} catch {
454451
// invalid hash, ignore
455452
}
456-
// eslint-disable-next-line react-hooks/exhaustive-deps
453+
// eslint-disable-next-line react-hooks/exhaustive-deps
457454
}, [])
458455

459-
// Keyboard shortcut: Cmd/Ctrl + .
456+
// Keyboard shortcuts: Cmd/Ctrl + . toggles the panel; Escape closes it from
457+
// anywhere within (WCAG 2.1.2 — no keyboard trap).
460458
useEffect(() => {
461459
const handler = (e: KeyboardEvent) => {
462460
if ((e.metaKey || e.ctrlKey) && e.key === '.') {
463461
e.preventDefault()
464462
setOpen((o) => !o)
463+
} else if (e.key === 'Escape' && open) {
464+
setOpen(false)
465465
}
466466
}
467467
window.addEventListener('keydown', handler)
468468
return () => window.removeEventListener('keydown', handler)
469-
}, [])
469+
}, [open])
470+
471+
// Restore focus to the trigger when the panel closes (WCAG 2.4.3) so keyboard
472+
// users are not stranded at the top of the document.
473+
useEffect(() => {
474+
if (prevOpenRef.current && !open) {
475+
triggerRef.current?.focus()
476+
}
477+
prevOpenRef.current = open
478+
}, [open])
470479

471480
return (
472481
<>
473482
{/* Floating trigger */}
474483
<button
484+
ref={triggerRef}
475485
onClick={() => setOpen(true)}
476486
className={cn(
477487
'fixed bottom-5 right-5 z-50 flex items-center gap-2 rounded-2xl border border-fd-border bg-fd-card px-4 py-2.5 text-sm font-medium text-fd-muted-foreground shadow-lg transition-all hover:bg-fd-accent hover:text-fd-accent-foreground',
478488
open && 'pointer-events-none opacity-0',
479489
)}
480490
aria-label="Ask AI (Ctrl+.)"
491+
aria-haspopup="dialog"
492+
aria-expanded={open}
481493
>
482494
<LCIcon className="size-5" />
483495
Ask AI
@@ -489,6 +501,8 @@ export function AskAI() {
489501
{/* Panel */}
490502
{open && (
491503
<div
504+
role="dialog"
505+
aria-label="Ask AI"
492506
className={cn(
493507
'fixed z-50 flex flex-col border-fd-border bg-fd-card',
494508
'inset-x-0 bottom-0 h-[85vh] rounded-t-2xl border-t shadow-2xl',
@@ -602,8 +616,7 @@ export function AskAI() {
602616
?.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
603617
.map((p) => p.text)
604618
.join('')
605-
const docRefs =
606-
m.role === 'assistant' && fullText ? extractDocRefs(fullText) : []
619+
const docRefs = m.role === 'assistant' && fullText ? extractDocRefs(fullText) : []
607620

608621
// Collect sources from search tool results
609622
const sources: { title: string; url: string }[] = []
@@ -615,9 +628,7 @@ export function AskAI() {
615628
part.state === 'output-available' &&
616629
'output' in part
617630
) {
618-
const output = part.output as
619-
| { title: string; url: string }[]
620-
| undefined
631+
const output = part.output as { title: string; url: string }[] | undefined
621632
if (Array.isArray(output)) {
622633
for (const s of output) {
623634
if (s.url && s.title) sources.push({ title: s.title, url: s.url })
@@ -660,9 +671,8 @@ export function AskAI() {
660671
'state' in part &&
661672
part.state === 'output-available'
662673
) {
663-
const res = (
664-
part as { output?: { url?: string; title?: string } }
665-
).output
674+
const res = (part as { output?: { url?: string; title?: string } })
675+
.output
666676
if (res?.url) {
667677
return (
668678
<Link
@@ -694,9 +704,7 @@ export function AskAI() {
694704
key={i}
695705
className="mb-1.5 flex items-center gap-1.5 text-xs text-fd-muted-foreground"
696706
>
697-
<Search
698-
className={cn('size-3', !isDone && 'animate-pulse')}
699-
/>
707+
<Search className={cn('size-3', !isDone && 'animate-pulse')} />
700708
{isDone ? 'Searched docs' : 'Searching docs…'}
701709
</div>
702710
)

0 commit comments

Comments
 (0)