mirror of
https://github.com/LibreChat-AI/librechat.ai.git
synced 2026-03-27 10:48:32 +07:00
Patch fumadocs-ui to fix accessibility issues reported by amberhinds, plus additional issues found via WCAG 2.1 audit: Reported issues: - #513: Add aria-label to docs navigation toggle button - #514: Add aria-haspopup="dialog" and aria-label to search buttons - #515: Fix close search button accessible name to include visible text - #516: Add role="status" live region for screen reader search announcements - #517: Change search suggestions from <button> to <a> links with role="option" - #518: Add nav landmarks for sidebar, breadcrumbs, TOC, and pagination Additional fixes found via audit: - Theme toggle button: add aria-label="Toggle theme" (WCAG 4.1.2) - Sidebar <aside>: add aria-label="Docs sidebar" (WCAG 4.1.2) - Collapse sidebar button: dynamic label based on state (WCAG 4.1.2) - Heading anchor link icons: add peer-focus-visible:opacity-100 (WCAG 2.4.7) - Breadcrumbs: use <ol>/<li> structure, aria-hidden on separator SVGs (WCAG 1.3.1) - Feedback textarea: add aria-label (WCAG 4.1.2) Closes #513 Closes #514 Closes #515 Closes #516 Closes #517 Closes #518
149 lines
5.0 KiB
TypeScript
149 lines
5.0 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useTransition, type SyntheticEvent } from 'react'
|
|
import { ThumbsUp, ThumbsDown, Loader2 } from 'lucide-react'
|
|
import { usePathname } from 'next/navigation'
|
|
import { Collapsible, CollapsibleContent } from 'fumadocs-ui/components/ui/collapsible'
|
|
import { submitFeedback } from '@/app/actions/feedback'
|
|
|
|
type Opinion = 'good' | 'bad'
|
|
|
|
interface StoredFeedback {
|
|
opinion: Opinion
|
|
message: string
|
|
url: string
|
|
}
|
|
|
|
function useStoredFeedback(url: string) {
|
|
const key = `docs-feedback-${url}`
|
|
const [stored, setStored] = useState<StoredFeedback | null>(null)
|
|
|
|
useEffect(() => {
|
|
const item = localStorage.getItem(key)
|
|
if (!item) return
|
|
try {
|
|
const parsed = JSON.parse(item) as StoredFeedback
|
|
if (parsed.opinion && parsed.url) setStored(parsed)
|
|
} catch {
|
|
// ignore invalid data
|
|
}
|
|
}, [key])
|
|
|
|
function save(feedback: StoredFeedback | null) {
|
|
if (feedback) {
|
|
localStorage.setItem(key, JSON.stringify(feedback))
|
|
} else {
|
|
localStorage.removeItem(key)
|
|
}
|
|
setStored(feedback)
|
|
}
|
|
|
|
return { stored, save }
|
|
}
|
|
|
|
export function Feedback() {
|
|
const url = usePathname() ?? '/'
|
|
const { stored, save } = useStoredFeedback(url)
|
|
const [opinion, setOpinion] = useState<Opinion | null>(null)
|
|
const [message, setMessage] = useState('')
|
|
const [pending, startTransition] = useTransition()
|
|
|
|
function submit(e?: SyntheticEvent) {
|
|
if (!opinion) return
|
|
e?.preventDefault()
|
|
|
|
const payload = { opinion, message, url }
|
|
save(payload)
|
|
setMessage('')
|
|
setOpinion(null)
|
|
|
|
startTransition(async () => {
|
|
await submitFeedback(payload)
|
|
})
|
|
}
|
|
|
|
const activeOpinion = stored?.opinion ?? opinion
|
|
|
|
return (
|
|
<Collapsible
|
|
open={opinion !== null || stored !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setOpinion(null)
|
|
}}
|
|
className="not-prose border-y border-fd-border py-3"
|
|
>
|
|
<div className="flex flex-row items-center gap-2">
|
|
<p className="pe-2 text-sm font-medium text-fd-foreground">How is this guide?</p>
|
|
<button
|
|
disabled={stored !== null}
|
|
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors [&_svg]:size-4 ${
|
|
activeOpinion === 'good'
|
|
? 'border-fd-border bg-fd-accent text-fd-accent-foreground [&_svg]:fill-current'
|
|
: 'border-fd-border text-fd-muted-foreground hover:bg-fd-accent'
|
|
} disabled:cursor-not-allowed`}
|
|
onClick={() => setOpinion('good')}
|
|
aria-label="Good"
|
|
>
|
|
<ThumbsUp />
|
|
Good
|
|
</button>
|
|
<button
|
|
disabled={stored !== null}
|
|
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors [&_svg]:size-4 ${
|
|
activeOpinion === 'bad'
|
|
? 'border-fd-border bg-fd-accent text-fd-accent-foreground [&_svg]:fill-current'
|
|
: 'border-fd-border text-fd-muted-foreground hover:bg-fd-accent'
|
|
} disabled:cursor-not-allowed`}
|
|
onClick={() => setOpinion('bad')}
|
|
aria-label="Bad"
|
|
>
|
|
<ThumbsDown />
|
|
Bad
|
|
</button>
|
|
</div>
|
|
<CollapsibleContent className="mt-3">
|
|
{stored ? (
|
|
<div className="flex flex-col items-center gap-3 rounded-xl bg-fd-card px-3 py-6 text-center text-sm text-fd-muted-foreground">
|
|
<p className="flex items-center gap-2">
|
|
{pending && <Loader2 className="size-3.5 animate-spin" />}
|
|
Thank you for your feedback!
|
|
</p>
|
|
<button
|
|
className="rounded-md border border-fd-border px-3 py-1.5 text-xs font-medium text-fd-muted-foreground transition-colors hover:bg-fd-accent"
|
|
onClick={() => {
|
|
setOpinion(stored.opinion)
|
|
save(null)
|
|
}}
|
|
>
|
|
Submit again
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<form className="flex flex-col gap-3" onSubmit={submit}>
|
|
<textarea
|
|
autoFocus
|
|
aria-label="Additional feedback"
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
className="resize-none rounded-lg border border-fd-border bg-fd-secondary p-3 text-sm text-fd-secondary-foreground placeholder:text-fd-muted-foreground focus-visible:outline-none"
|
|
placeholder="Any additional feedback? (optional)"
|
|
rows={3}
|
|
onKeyDown={(e) => {
|
|
if (!e.shiftKey && e.key === 'Enter') {
|
|
submit(e)
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="w-fit rounded-md border border-fd-border px-3 py-1.5 text-sm font-medium text-fd-foreground transition-colors hover:bg-fd-accent"
|
|
>
|
|
Submit
|
|
</button>
|
|
</form>
|
|
)}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)
|
|
}
|