mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2025-06-27 03:55:20 +00:00
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:
Binary file not shown.
@ -28,13 +28,13 @@ function AppLayout() {
|
||||
return (
|
||||
<>
|
||||
<Sidebar />
|
||||
<div
|
||||
<main
|
||||
className="drawer-content grow flex flex-col h-screen w-screen mx-auto px-4 overflow-auto bg-base-100"
|
||||
id="main-scroll"
|
||||
>
|
||||
<Header />
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
{
|
||||
<SettingDialog
|
||||
show={showSettings}
|
||||
|
@ -18,16 +18,26 @@ export default function ChatInputExtraContextItem({
|
||||
if (!items) return null;
|
||||
|
||||
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) => (
|
||||
<div
|
||||
className="indicator"
|
||||
key={i}
|
||||
onClick={() => clickToShow && setShow(i)}
|
||||
tabIndex={0}
|
||||
aria-description={
|
||||
clickToShow ? `Click to show: ${item.name}` : undefined
|
||||
}
|
||||
role={clickToShow ? 'button' : 'menuitem'}
|
||||
>
|
||||
{removeItem && (
|
||||
<div className="indicator-item indicator-top">
|
||||
<button
|
||||
aria-label="Remove file"
|
||||
className="btn btn-neutral btn-sm w-4 h-4 p-0 rounded-full"
|
||||
onClick={() => removeItem(i)}
|
||||
>
|
||||
@ -46,13 +56,16 @@ export default function ChatInputExtraContextItem({
|
||||
<>
|
||||
<img
|
||||
src={item.base64Url}
|
||||
alt={item.name}
|
||||
alt={`Preview image for ${item.name}`}
|
||||
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" />
|
||||
</div>
|
||||
|
||||
@ -66,16 +79,25 @@ export default function ChatInputExtraContextItem({
|
||||
))}
|
||||
|
||||
{showingItem && (
|
||||
<dialog className="modal modal-open">
|
||||
<dialog
|
||||
className="modal modal-open"
|
||||
aria-description={`Preview ${showingItem.name}`}
|
||||
>
|
||||
<div className="modal-box">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<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)} />
|
||||
</button>
|
||||
</div>
|
||||
{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">
|
||||
<pre className="whitespace-pre-wrap break-words text-sm">
|
||||
|
@ -83,13 +83,20 @@ export default function ChatMessage({
|
||||
|
||||
if (!viewingChat) return null;
|
||||
|
||||
const isUser = msg.role === 'user';
|
||||
|
||||
return (
|
||||
<div className="group" id={id}>
|
||||
<div
|
||||
className="group"
|
||||
id={id}
|
||||
role="group"
|
||||
aria-description={`Message from ${msg.role}`}
|
||||
>
|
||||
<div
|
||||
className={classNames({
|
||||
chat: true,
|
||||
'chat-start': msg.role !== 'user',
|
||||
'chat-end': msg.role === 'user',
|
||||
'chat-start': !isUser,
|
||||
'chat-end': isUser,
|
||||
})}
|
||||
>
|
||||
{msg.extra && msg.extra.length > 0 && (
|
||||
@ -99,7 +106,7 @@ export default function ChatMessage({
|
||||
<div
|
||||
className={classNames({
|
||||
'chat-bubble markdown': true,
|
||||
'chat-bubble bg-transparent': msg.role !== 'user',
|
||||
'chat-bubble bg-transparent': !isUser,
|
||||
})}
|
||||
>
|
||||
{/* textarea for editing message */}
|
||||
@ -142,7 +149,7 @@ export default function ChatMessage({
|
||||
) : (
|
||||
<>
|
||||
{/* render message as markdown */}
|
||||
<div dir="auto">
|
||||
<div dir="auto" tabIndex={0}>
|
||||
{thought && (
|
||||
<ThoughtProcess
|
||||
isThinking={!!isThinking && !!isPending}
|
||||
@ -196,13 +203,18 @@ export default function ChatMessage({
|
||||
})}
|
||||
>
|
||||
{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
|
||||
className={classNames({
|
||||
'btn btn-sm btn-ghost p-1': true,
|
||||
'opacity-20': !prevSibling,
|
||||
})}
|
||||
onClick={() => prevSibling && onChangeSibling(prevSibling)}
|
||||
aria-label="Previous message version"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@ -215,6 +227,7 @@ export default function ChatMessage({
|
||||
'opacity-20': !nextSibling,
|
||||
})}
|
||||
onClick={() => nextSibling && onChangeSibling(nextSibling)}
|
||||
aria-label="Next message version"
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@ -223,7 +236,7 @@ export default function ChatMessage({
|
||||
{/* user message */}
|
||||
{msg.role === 'user' && (
|
||||
<BtnWithTooltips
|
||||
className="btn-mini show-on-hover w-8 h-8"
|
||||
className="btn-mini w-8 h-8"
|
||||
onClick={() => setEditingContent(msg.content)}
|
||||
disabled={msg.content === null}
|
||||
tooltipsContent="Edit message"
|
||||
@ -236,7 +249,7 @@ export default function ChatMessage({
|
||||
<>
|
||||
{!isPending && (
|
||||
<BtnWithTooltips
|
||||
className="btn-mini show-on-hover w-8 h-8"
|
||||
className="btn-mini w-8 h-8"
|
||||
onClick={() => {
|
||||
if (msg.content !== null) {
|
||||
onRegenerateMessage(msg as Message);
|
||||
@ -250,10 +263,7 @@ export default function ChatMessage({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<CopyButton
|
||||
className="btn-mini show-on-hover w-8 h-8"
|
||||
content={msg.content}
|
||||
/>
|
||||
<CopyButton className="btn-mini w-8 h-8" content={msg.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -271,6 +281,8 @@ function ThoughtProcess({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
aria-label="Toggle thought process display"
|
||||
tabIndex={0}
|
||||
className={classNames({
|
||||
'collapse bg-none': true,
|
||||
@ -292,7 +304,11 @@ function ThoughtProcess({
|
||||
)}
|
||||
</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">
|
||||
<MarkdownDisplay content={content} />
|
||||
</div>
|
||||
|
@ -279,7 +279,11 @@ export default function ChatScreen() {
|
||||
function ServerInfo() {
|
||||
const { serverProps } = useAppContext();
|
||||
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">
|
||||
<b>Server Info</b>
|
||||
<p>
|
||||
@ -311,6 +315,8 @@ function ChatInput({
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Chat input"
|
||||
className={classNames({
|
||||
'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
|
||||
@ -400,13 +406,15 @@ function ChatInput({
|
||||
'btn w-8 h-8 p-0 rounded-full': true,
|
||||
'btn-disabled': isGenerating,
|
||||
})}
|
||||
aria-label="Upload file"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
<PaperClipIcon className="h-5 w-5" />
|
||||
</label>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
className="hidden"
|
||||
disabled={isGenerating}
|
||||
{...getInputProps()}
|
||||
hidden
|
||||
@ -422,6 +430,7 @@ function ChatInput({
|
||||
<button
|
||||
className="btn btn-primary w-8 h-8 p-0 rounded-full"
|
||||
onClick={onSend}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<ArrowUpIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
@ -38,8 +38,12 @@ export default function Header() {
|
||||
|
||||
{/* action buttons (top right) */}
|
||||
<div className="flex items-center">
|
||||
<div className="tooltip tooltip-bottom" data-tip="Settings">
|
||||
<button className="btn" onClick={() => setShowSettings(true)}>
|
||||
<div
|
||||
className="tooltip tooltip-bottom"
|
||||
data-tip="Settings"
|
||||
onClick={() => setShowSettings(true)}
|
||||
>
|
||||
<button className="btn" aria-hidden={true}>
|
||||
{/* settings button */}
|
||||
<Cog8ToothIcon className="w-5 h-5" />
|
||||
</button>
|
||||
|
@ -335,14 +335,22 @@ export default function SettingDialog({
|
||||
};
|
||||
|
||||
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">
|
||||
<h3 className="text-lg font-bold mb-6">Settings</h3>
|
||||
<div className="flex flex-col md:flex-row h-[calc(90vh-12rem)]">
|
||||
{/* 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) => (
|
||||
<div
|
||||
<button
|
||||
key={idx}
|
||||
className={classNames({
|
||||
'btn btn-ghost justify-start font-normal w-44 mb-1': true,
|
||||
@ -352,12 +360,16 @@ export default function SettingDialog({
|
||||
dir="auto"
|
||||
>
|
||||
{section.title}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<summary className="btn bt-sm w-full m-1">
|
||||
{SETTING_SECTIONS[sectionIdx].title}
|
||||
|
@ -50,44 +50,72 @@ export default function Sidebar() {
|
||||
id="toggle-drawer"
|
||||
type="checkbox"
|
||||
className="drawer-toggle"
|
||||
aria-label="Toggle sidebar"
|
||||
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
|
||||
htmlFor="toggle-drawer"
|
||||
aria-label="close sidebar"
|
||||
aria-label="Close sidebar"
|
||||
className="drawer-overlay"
|
||||
></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-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 */}
|
||||
<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" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* new conversation button */}
|
||||
<div
|
||||
<button
|
||||
className={classNames({
|
||||
'btn btn-ghost justify-start px-2': true,
|
||||
'btn-soft': !currConv,
|
||||
})}
|
||||
onClick={() => navigate('/')}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<PencilSquareIcon className="w-5 h-5" />
|
||||
New conversation
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* list of conversations */}
|
||||
{groupedConv.map((group, i) => (
|
||||
<div key={i}>
|
||||
<div key={i} role="group">
|
||||
{/* group name (by date) */}
|
||||
{group.title ? (
|
||||
// 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}
|
||||
</b>
|
||||
) : (
|
||||
@ -184,20 +212,23 @@ function ConversationItem({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="menuitem"
|
||||
tabIndex={0}
|
||||
aria-label={conv.name}
|
||||
className={classNames({
|
||||
'group flex flex-row btn btn-ghost justify-start items-center font-normal px-2 h-9':
|
||||
true,
|
||||
'btn-soft': isCurrConv,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
<button
|
||||
key={conv.id}
|
||||
className="w-full overflow-hidden truncate text-start"
|
||||
onClick={onSelect}
|
||||
dir="auto"
|
||||
>
|
||||
{conv.name}
|
||||
</div>
|
||||
</button>
|
||||
<div className="dropdown dropdown-end h-5">
|
||||
<BtnWithTooltips
|
||||
// on mobile, we always show the ellipsis icon
|
||||
@ -211,22 +242,23 @@ function ConversationItem({
|
||||
</BtnWithTooltips>
|
||||
{/* dropdown menu */}
|
||||
<ul
|
||||
aria-label="More options"
|
||||
tabIndex={0}
|
||||
className="dropdown-content menu bg-base-100 rounded-box z-[1] p-2 shadow"
|
||||
>
|
||||
<li onClick={onRename}>
|
||||
<li onClick={onRename} tabIndex={0}>
|
||||
<a>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
Rename
|
||||
</a>
|
||||
</li>
|
||||
<li onClick={onDownload}>
|
||||
<li onClick={onDownload} tabIndex={0}>
|
||||
<a>
|
||||
<ArrowDownTrayIcon className="w-4 h-4" />
|
||||
Download
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-error" onClick={onDelete}>
|
||||
<li className="text-error" onClick={onDelete} tabIndex={0}>
|
||||
<a>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
Delete
|
||||
|
@ -34,9 +34,6 @@ html {
|
||||
/* TODO: fix markdown table */
|
||||
}
|
||||
|
||||
.show-on-hover {
|
||||
@apply md:opacity-0 md:group-hover:opacity-100;
|
||||
}
|
||||
.btn-mini {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
@ -52,13 +52,20 @@ export function BtnWithTooltips({
|
||||
tooltipsContent: string;
|
||||
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 (
|
||||
<div className="tooltip tooltip-bottom" data-tip={tooltipsContent}>
|
||||
<div
|
||||
className="tooltip tooltip-bottom"
|
||||
data-tip={tooltipsContent}
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
<button
|
||||
className={`${className ?? ''} flex items-center justify-center`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
onMouseLeave={onMouseLeave}
|
||||
aria-hidden={true}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
Reference in New Issue
Block a user