📢 fix: Remove Side Panel Elements from Screen Reader when Hidden (#10648)

* fix: remove side panel elements from screen reader when hidden

There's both left & right side panels; elements of both of them
are hidden when dismissed. However, currently they are being hidden
by using classes to hide their UI (such as making the sidebar
zero width).

That works for visually dismissing these elements, but they can still
be viewed by a screen reader (using the tab key to jump between
interactable elements). That can be a rather confusing experience
for anyone visually impaired (such as duplicate buttons, or buttons
that do nothing).

--------

I've changed it so hidden elements are fully removed from the render.
This prevents them from being interactable via keyboard.

I leveraged Motion to duplicate the animations as they happened before.
I subtly cleaned up the animations while I was at it.

* Implemented reasonable suggestions from Copilot review
This commit is contained in:
Daniel Lew 2025-11-25 12:56:32 -06:00 committed by GitHub
parent 9211d59388
commit ffcca3254e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 49 additions and 44 deletions

View file

@ -11,6 +11,7 @@ import BookmarkMenu from './Menus/BookmarkMenu';
import { TemporaryChat } from './TemporaryChat';
import AddMultiConvo from './AddMultiConvo';
import { useHasAccess } from '~/hooks';
import { AnimatePresence, motion } from 'framer-motion';
const defaultInterface = getConfigDefaults().interface;
@ -38,24 +39,24 @@ export default function Header() {
return (
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
<div className="mx-1 flex items-center gap-2">
<div
className={`flex items-center gap-2 ${
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
} ${
!navVisible
? 'translate-x-0 opacity-100'
: 'pointer-events-none translate-x-[-100px] opacity-0'
}`}
<div className="mx-1 flex items-center">
<AnimatePresence initial={false}>
{!navVisible && (
<motion.div
className={`flex items-center gap-2`}
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
key="header-buttons"
>
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
<HeaderNewChat />
</div>
<div
className={`flex items-center gap-2 ${
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
} ${!navVisible ? 'translate-x-0' : 'translate-x-[-100px]'}`}
>
</motion.div>
)}
</AnimatePresence>
<div className={navVisible ? 'flex items-center gap-2' : 'ml-2 flex items-center gap-2'}>
<ModelSelector startupConfig={startupConfig} />
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}
{hasAccessToBookmarks === true && <BookmarkMenu />}

View file

@ -9,8 +9,10 @@ import { clearMessagesCache } from '~/utils';
import store from '~/store';
export default function MobileNav({
navVisible,
setNavVisible,
}: {
navVisible: boolean;
setNavVisible: Dispatch<SetStateAction<boolean>>;
}) {
const localize = useLocalize();
@ -25,7 +27,7 @@ export default function MobileNav({
type="button"
data-testid="mobile-header-new-chat-button"
aria-label={localize('com_nav_open_sidebar')}
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
className={`m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover ${navVisible ? 'invisible' : ''}`}
onClick={() =>
setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { AnimatePresence, motion } from 'framer-motion';
import { useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { ConversationListResponse } from 'librechat-data-provider';
@ -190,22 +191,21 @@ const Nav = memo(
return (
<>
<div
<AnimatePresence initial={false}>
{navVisible && (
<motion.div
data-testid="nav"
className={cn(
'nav active max-w-[320px] flex-shrink-0 transform overflow-x-hidden bg-surface-primary-alt transition-all duration-200 ease-in-out',
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt',
'md:max-w-[260px]',
)}
style={{
width: navVisible ? navWidth : '0px',
transform: navVisible ? 'translateX(0)' : 'translateX(-100%)',
}}
initial={{ width: 0 }}
animate={{ width: navWidth }}
exit={{ width: 0 }}
transition={{ duration: 0.2 }}
key="nav"
>
<div className="h-full w-[320px] md:w-[260px]">
<div className="flex h-full flex-col">
<div
className={`flex h-full flex-col transition-opacity duration-200 ease-in-out ${navVisible ? 'opacity-100' : 'opacity-0'}`}
>
<div className="flex h-full flex-col">
<nav
id="chat-history-nav"
@ -235,9 +235,9 @@ const Nav = memo(
</nav>
</div>
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{isSmallScreen && <NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />}
</>
);

View file

@ -147,11 +147,13 @@ const SidePanelGroup = memo(
{artifacts != null && isSmallScreen && (
<div className="fixed inset-0 z-[100]">{artifacts}</div>
)}
{!hideSidePanel && interfaceConfig.sidePanel === true && (
<button
aria-label="Close right side panel"
className={`nav-mask ${!isCollapsed ? 'active' : ''}`}
onClick={handleClosePanel}
/>
)}
</>
);
},

View file

@ -75,7 +75,7 @@ export default function Root() {
<div className="relative z-0 flex h-full w-full overflow-hidden">
<Nav navVisible={navVisible} setNavVisible={setNavVisible} />
<div className="relative flex h-full max-w-full flex-1 flex-col overflow-hidden">
<MobileNav setNavVisible={setNavVisible} />
<MobileNav navVisible={navVisible} setNavVisible={setNavVisible} />
<Outlet context={{ navVisible, setNavVisible } satisfies ContextType} />
</div>
</div>