webui : improve accessibility for visually impaired people (#13551)

* webui : improve accessibility for visually impaired people

* add a11y for extra contents

* fix some labels being read twice

* add skip to main content
This commit is contained in:
Xuan-Son Nguyen
2025-05-16 21:49:01 +02:00
committed by GitHub
parent 06c1e4abc1
commit aea9f8b4e7
10 changed files with 147 additions and 48 deletions

Binary file not shown.

View File

@ -28,13 +28,13 @@ function AppLayout() {
return ( return (
<> <>
<Sidebar /> <Sidebar />
<div <main
className="drawer-content grow flex flex-col h-screen w-screen mx-auto px-4 overflow-auto bg-base-100" className="drawer-content grow flex flex-col h-screen w-screen mx-auto px-4 overflow-auto bg-base-100"
id="main-scroll" id="main-scroll"
> >
<Header /> <Header />
<Outlet /> <Outlet />
</div> </main>
{ {
<SettingDialog <SettingDialog
show={showSettings} show={showSettings}

View File

@ -18,16 +18,26 @@ export default function ChatInputExtraContextItem({
if (!items) return null; if (!items) return null;
return ( return (
<div className="flex flex-row gap-4 overflow-x-auto py-2 px-1 mb-1"> <div
className="flex flex-row gap-4 overflow-x-auto py-2 px-1 mb-1"
role="group"
aria-description="Selected files"
>
{items.map((item, i) => ( {items.map((item, i) => (
<div <div
className="indicator" className="indicator"
key={i} key={i}
onClick={() => clickToShow && setShow(i)} onClick={() => clickToShow && setShow(i)}
tabIndex={0}
aria-description={
clickToShow ? `Click to show: ${item.name}` : undefined
}
role={clickToShow ? 'button' : 'menuitem'}
> >
{removeItem && ( {removeItem && (
<div className="indicator-item indicator-top"> <div className="indicator-item indicator-top">
<button <button
aria-label="Remove file"
className="btn btn-neutral btn-sm w-4 h-4 p-0 rounded-full" className="btn btn-neutral btn-sm w-4 h-4 p-0 rounded-full"
onClick={() => removeItem(i)} onClick={() => removeItem(i)}
> >
@ -46,13 +56,16 @@ export default function ChatInputExtraContextItem({
<> <>
<img <img
src={item.base64Url} src={item.base64Url}
alt={item.name} alt={`Preview image for ${item.name}`}
className="w-14 h-14 object-cover rounded-md" className="w-14 h-14 object-cover rounded-md"
/> />
</> </>
) : ( ) : (
<> <>
<div className="w-14 h-14 flex items-center justify-center"> <div
className="w-14 h-14 flex items-center justify-center"
aria-description="Document icon"
>
<DocumentTextIcon className="h-8 w-14 text-base-content/50" /> <DocumentTextIcon className="h-8 w-14 text-base-content/50" />
</div> </div>
@ -66,16 +79,25 @@ export default function ChatInputExtraContextItem({
))} ))}
{showingItem && ( {showingItem && (
<dialog className="modal modal-open"> <dialog
className="modal modal-open"
aria-description={`Preview ${showingItem.name}`}
>
<div className="modal-box"> <div className="modal-box">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<b>{showingItem.name ?? 'Extra content'}</b> <b>{showingItem.name ?? 'Extra content'}</b>
<button className="btn btn-ghost btn-sm"> <button
className="btn btn-ghost btn-sm"
aria-label="Close preview dialog"
>
<XMarkIcon className="h-5 w-5" onClick={() => setShow(-1)} /> <XMarkIcon className="h-5 w-5" onClick={() => setShow(-1)} />
</button> </button>
</div> </div>
{showingItem.type === 'imageFile' ? ( {showingItem.type === 'imageFile' ? (
<img src={showingItem.base64Url} alt={showingItem.name} /> <img
src={showingItem.base64Url}
alt={`Preview image for ${showingItem.name}`}
/>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<pre className="whitespace-pre-wrap break-words text-sm"> <pre className="whitespace-pre-wrap break-words text-sm">

View File

@ -83,13 +83,20 @@ export default function ChatMessage({
if (!viewingChat) return null; if (!viewingChat) return null;
const isUser = msg.role === 'user';
return ( return (
<div className="group" id={id}> <div
className="group"
id={id}
role="group"
aria-description={`Message from ${msg.role}`}
>
<div <div
className={classNames({ className={classNames({
chat: true, chat: true,
'chat-start': msg.role !== 'user', 'chat-start': !isUser,
'chat-end': msg.role === 'user', 'chat-end': isUser,
})} })}
> >
{msg.extra && msg.extra.length > 0 && ( {msg.extra && msg.extra.length > 0 && (
@ -99,7 +106,7 @@ export default function ChatMessage({
<div <div
className={classNames({ className={classNames({
'chat-bubble markdown': true, 'chat-bubble markdown': true,
'chat-bubble bg-transparent': msg.role !== 'user', 'chat-bubble bg-transparent': !isUser,
})} })}
> >
{/* textarea for editing message */} {/* textarea for editing message */}
@ -142,7 +149,7 @@ export default function ChatMessage({
) : ( ) : (
<> <>
{/* render message as markdown */} {/* render message as markdown */}
<div dir="auto"> <div dir="auto" tabIndex={0}>
{thought && ( {thought && (
<ThoughtProcess <ThoughtProcess
isThinking={!!isThinking && !!isPending} isThinking={!!isThinking && !!isPending}
@ -196,13 +203,18 @@ export default function ChatMessage({
})} })}
> >
{siblingLeafNodeIds && siblingLeafNodeIds.length > 1 && ( {siblingLeafNodeIds && siblingLeafNodeIds.length > 1 && (
<div className="flex gap-1 items-center opacity-60 text-sm"> <div
className="flex gap-1 items-center opacity-60 text-sm"
role="navigation"
aria-description={`Message version ${siblingCurrIdx + 1} of ${siblingLeafNodeIds.length}`}
>
<button <button
className={classNames({ className={classNames({
'btn btn-sm btn-ghost p-1': true, 'btn btn-sm btn-ghost p-1': true,
'opacity-20': !prevSibling, 'opacity-20': !prevSibling,
})} })}
onClick={() => prevSibling && onChangeSibling(prevSibling)} onClick={() => prevSibling && onChangeSibling(prevSibling)}
aria-label="Previous message version"
> >
<ChevronLeftIcon className="h-4 w-4" /> <ChevronLeftIcon className="h-4 w-4" />
</button> </button>
@ -215,6 +227,7 @@ export default function ChatMessage({
'opacity-20': !nextSibling, 'opacity-20': !nextSibling,
})} })}
onClick={() => nextSibling && onChangeSibling(nextSibling)} onClick={() => nextSibling && onChangeSibling(nextSibling)}
aria-label="Next message version"
> >
<ChevronRightIcon className="h-4 w-4" /> <ChevronRightIcon className="h-4 w-4" />
</button> </button>
@ -223,7 +236,7 @@ export default function ChatMessage({
{/* user message */} {/* user message */}
{msg.role === 'user' && ( {msg.role === 'user' && (
<BtnWithTooltips <BtnWithTooltips
className="btn-mini show-on-hover w-8 h-8" className="btn-mini w-8 h-8"
onClick={() => setEditingContent(msg.content)} onClick={() => setEditingContent(msg.content)}
disabled={msg.content === null} disabled={msg.content === null}
tooltipsContent="Edit message" tooltipsContent="Edit message"
@ -236,7 +249,7 @@ export default function ChatMessage({
<> <>
{!isPending && ( {!isPending && (
<BtnWithTooltips <BtnWithTooltips
className="btn-mini show-on-hover w-8 h-8" className="btn-mini w-8 h-8"
onClick={() => { onClick={() => {
if (msg.content !== null) { if (msg.content !== null) {
onRegenerateMessage(msg as Message); onRegenerateMessage(msg as Message);
@ -250,10 +263,7 @@ export default function ChatMessage({
)} )}
</> </>
)} )}
<CopyButton <CopyButton className="btn-mini w-8 h-8" content={msg.content} />
className="btn-mini show-on-hover w-8 h-8"
content={msg.content}
/>
</div> </div>
)} )}
</div> </div>
@ -271,6 +281,8 @@ function ThoughtProcess({
}) { }) {
return ( return (
<div <div
role="button"
aria-label="Toggle thought process display"
tabIndex={0} tabIndex={0}
className={classNames({ className={classNames({
'collapse bg-none': true, 'collapse bg-none': true,
@ -292,7 +304,11 @@ function ThoughtProcess({
)} )}
</div> </div>
</div> </div>
<div className="collapse-content text-base-content/70 text-sm p-1"> <div
className="collapse-content text-base-content/70 text-sm p-1"
tabIndex={0}
aria-description="Thought process content"
>
<div className="border-l-2 border-base-content/20 pl-4 mb-4"> <div className="border-l-2 border-base-content/20 pl-4 mb-4">
<MarkdownDisplay content={content} /> <MarkdownDisplay content={content} />
</div> </div>

View File

@ -279,7 +279,11 @@ export default function ChatScreen() {
function ServerInfo() { function ServerInfo() {
const { serverProps } = useAppContext(); const { serverProps } = useAppContext();
return ( return (
<div className="card card-sm shadow-sm border-1 border-base-content/20 text-base-content/70 mb-6"> <div
className="card card-sm shadow-sm border-1 border-base-content/20 text-base-content/70 mb-6"
tabIndex={0}
aria-description="Server information"
>
<div className="card-body"> <div className="card-body">
<b>Server Info</b> <b>Server Info</b>
<p> <p>
@ -311,6 +315,8 @@ function ChatInput({
return ( return (
<div <div
role="group"
aria-label="Chat input"
className={classNames({ className={classNames({
'flex items-end pt-8 pb-6 sticky bottom-0 bg-base-100': true, 'flex items-end pt-8 pb-6 sticky bottom-0 bg-base-100': true,
'opacity-50': isDrag, // simply visual feedback to inform user that the file will be accepted 'opacity-50': isDrag, // simply visual feedback to inform user that the file will be accepted
@ -400,13 +406,15 @@ function ChatInput({
'btn w-8 h-8 p-0 rounded-full': true, 'btn w-8 h-8 p-0 rounded-full': true,
'btn-disabled': isGenerating, 'btn-disabled': isGenerating,
})} })}
aria-label="Upload file"
tabIndex={0}
role="button"
> >
<PaperClipIcon className="h-5 w-5" /> <PaperClipIcon className="h-5 w-5" />
</label> </label>
<input <input
id="file-upload" id="file-upload"
type="file" type="file"
className="hidden"
disabled={isGenerating} disabled={isGenerating}
{...getInputProps()} {...getInputProps()}
hidden hidden
@ -422,6 +430,7 @@ function ChatInput({
<button <button
className="btn btn-primary w-8 h-8 p-0 rounded-full" className="btn btn-primary w-8 h-8 p-0 rounded-full"
onClick={onSend} onClick={onSend}
aria-label="Send message"
> >
<ArrowUpIcon className="h-5 w-5" /> <ArrowUpIcon className="h-5 w-5" />
</button> </button>

View File

@ -38,8 +38,12 @@ export default function Header() {
{/* action buttons (top right) */} {/* action buttons (top right) */}
<div className="flex items-center"> <div className="flex items-center">
<div className="tooltip tooltip-bottom" data-tip="Settings"> <div
<button className="btn" onClick={() => setShowSettings(true)}> className="tooltip tooltip-bottom"
data-tip="Settings"
onClick={() => setShowSettings(true)}
>
<button className="btn" aria-hidden={true}>
{/* settings button */} {/* settings button */}
<Cog8ToothIcon className="w-5 h-5" /> <Cog8ToothIcon className="w-5 h-5" />
</button> </button>

View File

@ -335,14 +335,22 @@ export default function SettingDialog({
}; };
return ( return (
<dialog className={classNames({ modal: true, 'modal-open': show })}> <dialog
className={classNames({ modal: true, 'modal-open': show })}
aria-label="Settings dialog"
>
<div className="modal-box w-11/12 max-w-3xl"> <div className="modal-box w-11/12 max-w-3xl">
<h3 className="text-lg font-bold mb-6">Settings</h3> <h3 className="text-lg font-bold mb-6">Settings</h3>
<div className="flex flex-col md:flex-row h-[calc(90vh-12rem)]"> <div className="flex flex-col md:flex-row h-[calc(90vh-12rem)]">
{/* Left panel, showing sections - Desktop version */} {/* Left panel, showing sections - Desktop version */}
<div className="hidden md:flex flex-col items-stretch pr-4 mr-4 border-r-2 border-base-200"> <div
className="hidden md:flex flex-col items-stretch pr-4 mr-4 border-r-2 border-base-200"
role="complementary"
aria-description="Settings sections"
tabIndex={0}
>
{SETTING_SECTIONS.map((section, idx) => ( {SETTING_SECTIONS.map((section, idx) => (
<div <button
key={idx} key={idx}
className={classNames({ className={classNames({
'btn btn-ghost justify-start font-normal w-44 mb-1': true, 'btn btn-ghost justify-start font-normal w-44 mb-1': true,
@ -352,12 +360,16 @@ export default function SettingDialog({
dir="auto" dir="auto"
> >
{section.title} {section.title}
</div> </button>
))} ))}
</div> </div>
{/* Left panel, showing sections - Mobile version */} {/* Left panel, showing sections - Mobile version */}
<div className="md:hidden flex flex-row gap-2 mb-4"> {/* This menu is skipped on a11y, otherwise it's repeated the desktop version */}
<div
className="md:hidden flex flex-row gap-2 mb-4"
aria-disabled={true}
>
<details className="dropdown"> <details className="dropdown">
<summary className="btn bt-sm w-full m-1"> <summary className="btn bt-sm w-full m-1">
{SETTING_SECTIONS[sectionIdx].title} {SETTING_SECTIONS[sectionIdx].title}

View File

@ -50,44 +50,72 @@ export default function Sidebar() {
id="toggle-drawer" id="toggle-drawer"
type="checkbox" type="checkbox"
className="drawer-toggle" className="drawer-toggle"
aria-label="Toggle sidebar"
defaultChecked defaultChecked
/> />
<div className="drawer-side h-screen lg:h-screen z-50 lg:max-w-64"> <div
className="drawer-side h-screen lg:h-screen z-50 lg:max-w-64"
role="complementary"
aria-label="Sidebar"
tabIndex={0}
>
<label <label
htmlFor="toggle-drawer" htmlFor="toggle-drawer"
aria-label="close sidebar" aria-label="Close sidebar"
className="drawer-overlay" className="drawer-overlay"
></label> ></label>
<a
href="#main-scroll"
className="absolute -left-80 top-0 w-1 h-1 overflow-hidden"
>
Skip to main content
</a>
<div className="flex flex-col bg-base-200 min-h-full max-w-64 py-4 px-4"> <div className="flex flex-col bg-base-200 min-h-full max-w-64 py-4 px-4">
<div className="flex flex-row items-center justify-between mb-4 mt-4"> <div className="flex flex-row items-center justify-between mb-4 mt-4">
<h2 className="font-bold ml-4">Conversations</h2> <h2 className="font-bold ml-4" role="heading">
Conversations
</h2>
{/* close sidebar button */} {/* close sidebar button */}
<label htmlFor="toggle-drawer" className="btn btn-ghost lg:hidden"> <label
htmlFor="toggle-drawer"
className="btn btn-ghost lg:hidden"
aria-label="Close sidebar"
role="button"
tabIndex={0}
>
<XMarkIcon className="w-5 h-5" /> <XMarkIcon className="w-5 h-5" />
</label> </label>
</div> </div>
{/* new conversation button */} {/* new conversation button */}
<div <button
className={classNames({ className={classNames({
'btn btn-ghost justify-start px-2': true, 'btn btn-ghost justify-start px-2': true,
'btn-soft': !currConv, 'btn-soft': !currConv,
})} })}
onClick={() => navigate('/')} onClick={() => navigate('/')}
aria-label="New conversation"
> >
<PencilSquareIcon className="w-5 h-5" /> <PencilSquareIcon className="w-5 h-5" />
New conversation New conversation
</div> </button>
{/* list of conversations */} {/* list of conversations */}
{groupedConv.map((group, i) => ( {groupedConv.map((group, i) => (
<div key={i}> <div key={i} role="group">
{/* group name (by date) */} {/* group name (by date) */}
{group.title ? ( {group.title ? (
// we use btn class here to make sure that the padding/margin are aligned with the other items // we use btn class here to make sure that the padding/margin are aligned with the other items
<b className="btn btn-ghost btn-xs bg-none btn-disabled block text-xs text-base-content text-start px-2 mb-0 mt-6 font-bold"> <b
className="btn btn-ghost btn-xs bg-none btn-disabled block text-xs text-base-content text-start px-2 mb-0 mt-6 font-bold"
role="note"
aria-description={group.title}
tabIndex={0}
>
{group.title} {group.title}
</b> </b>
) : ( ) : (
@ -184,20 +212,23 @@ function ConversationItem({
}) { }) {
return ( return (
<div <div
role="menuitem"
tabIndex={0}
aria-label={conv.name}
className={classNames({ className={classNames({
'group flex flex-row btn btn-ghost justify-start items-center font-normal px-2 h-9': 'group flex flex-row btn btn-ghost justify-start items-center font-normal px-2 h-9':
true, true,
'btn-soft': isCurrConv, 'btn-soft': isCurrConv,
})} })}
> >
<div <button
key={conv.id} key={conv.id}
className="w-full overflow-hidden truncate text-start" className="w-full overflow-hidden truncate text-start"
onClick={onSelect} onClick={onSelect}
dir="auto" dir="auto"
> >
{conv.name} {conv.name}
</div> </button>
<div className="dropdown dropdown-end h-5"> <div className="dropdown dropdown-end h-5">
<BtnWithTooltips <BtnWithTooltips
// on mobile, we always show the ellipsis icon // on mobile, we always show the ellipsis icon
@ -211,22 +242,23 @@ function ConversationItem({
</BtnWithTooltips> </BtnWithTooltips>
{/* dropdown menu */} {/* dropdown menu */}
<ul <ul
aria-label="More options"
tabIndex={0} tabIndex={0}
className="dropdown-content menu bg-base-100 rounded-box z-[1] p-2 shadow" className="dropdown-content menu bg-base-100 rounded-box z-[1] p-2 shadow"
> >
<li onClick={onRename}> <li onClick={onRename} tabIndex={0}>
<a> <a>
<PencilIcon className="w-4 h-4" /> <PencilIcon className="w-4 h-4" />
Rename Rename
</a> </a>
</li> </li>
<li onClick={onDownload}> <li onClick={onDownload} tabIndex={0}>
<a> <a>
<ArrowDownTrayIcon className="w-4 h-4" /> <ArrowDownTrayIcon className="w-4 h-4" />
Download Download
</a> </a>
</li> </li>
<li className="text-error" onClick={onDelete}> <li className="text-error" onClick={onDelete} tabIndex={0}>
<a> <a>
<TrashIcon className="w-4 h-4" /> <TrashIcon className="w-4 h-4" />
Delete Delete

View File

@ -34,9 +34,6 @@ html {
/* TODO: fix markdown table */ /* TODO: fix markdown table */
} }
.show-on-hover {
@apply md:opacity-0 md:group-hover:opacity-100;
}
.btn-mini { .btn-mini {
@apply cursor-pointer; @apply cursor-pointer;
} }

View File

@ -52,13 +52,20 @@ export function BtnWithTooltips({
tooltipsContent: string; tooltipsContent: string;
disabled?: boolean; disabled?: boolean;
}) { }) {
// the onClick handler is on the container, so screen readers can safely ignore the inner button
// this prevents the label from being read twice
return ( return (
<div className="tooltip tooltip-bottom" data-tip={tooltipsContent}> <div
className="tooltip tooltip-bottom"
data-tip={tooltipsContent}
role="button"
onClick={onClick}
>
<button <button
className={`${className ?? ''} flex items-center justify-center`} className={`${className ?? ''} flex items-center justify-center`}
onClick={onClick}
disabled={disabled} disabled={disabled}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
aria-hidden={true}
> >
{children} {children}
</button> </button>