diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx index 0d6ef12b58..081959157d 100644 --- a/client/src/components/Nav/Settings/registry.tsx +++ b/client/src/components/Nav/Settings/registry.tsx @@ -121,6 +121,19 @@ export const registry: SettingEntry[] = [ switchId: 'showScrollButton', }), }, + { + id: 'mobileDrawerStrip', + tab: GENERAL, + section: 'layout', + labelKey: 'com_nav_mobile_drawer_strip', + keywords: ['mobile', 'sidebar', 'drawer', 'swipe'], + Component: toggleControl({ + stateAtom: store.mobileDrawerStrip, + localizationKey: 'com_nav_mobile_drawer_strip', + hoverCardText: 'com_nav_mobile_drawer_strip_info', + switchId: 'mobileDrawerStrip', + }), + }, { id: 'chatTitleInTab', tab: GENERAL, diff --git a/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx b/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx index dc0647b5e4..7495e14051 100644 --- a/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx +++ b/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx @@ -1,5 +1,6 @@ import { useCallback, useState, useEffect, useRef, memo } from 'react'; import { useForm } from 'react-hook-form'; +import { useMediaQuery } from '@librechat/client'; import { useLocation, useNavigate } from 'react-router-dom'; import type { ReactNode } from 'react'; import type { ChatFormValues } from '~/common'; @@ -8,9 +9,10 @@ import { EXPANDED_MIN, TRANSITION_MS, EASING, - SIDEBAR_TRANSITION, + MOBILE_DRAWER_TRANSITION, DRAWER_Z_INDEX, MOBILE_DRAWER_ID, + MOBILE_DRAWER_WIDTH, } from './constants'; import { ChatContext, ChatFormProvider, ActivePanelProvider } from '~/Providers'; import { MobileHeader, MobileBottomBar, MobileShortcutTargets } from './mobile'; @@ -49,6 +51,7 @@ function UnifiedSidebar() { const navigate = useNavigate(); const { isSmallScreen, expanded } = useSidebarState(); const { setSidebarOpen } = useSidebarToggle(); + const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); const [sidebarWidth, setSidebarWidth] = useState(getInitialWidth); const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth); const [isResizing, setIsResizing] = useState(false); @@ -188,10 +191,17 @@ function UnifiedSidebar() { /** The close swipe reads horizontal touches here (the drawer holds no * horizontal scrollers), while pinch-zoom stays with the browser — * this full-viewport surface must not disable zooming entirely. */ - 'fixed inset-y-0 left-0 flex w-full touch-pan-y touch-pinch-zoom flex-col bg-surface-primary-alt', + 'fixed inset-y-0 left-0 flex touch-pan-y touch-pinch-zoom flex-col bg-surface-primary-alt', expanded ? 'translate-x-0' : '-translate-x-full', )} - style={{ transition: SIDEBAR_TRANSITION, zIndex: DRAWER_Z_INDEX }} + style={{ + width: MOBILE_DRAWER_WIDTH, + /** The strip setting changes the width without passing through the + * snap path, so the preference has to reach the declarative style + * too or that one change still animates. */ + transition: prefersReducedMotion ? undefined : MOBILE_DRAWER_TRANSITION, + zIndex: DRAWER_Z_INDEX, + }} inert={!expanded ? '' : undefined} > diff --git a/client/src/components/UnifiedSidebar/constants.ts b/client/src/components/UnifiedSidebar/constants.ts index ba26af57bc..8953123590 100644 --- a/client/src/components/UnifiedSidebar/constants.ts +++ b/client/src/components/UnifiedSidebar/constants.ts @@ -1,7 +1,32 @@ export const COLLAPSED_WIDTH = 52; export const EXPANDED_MIN = 360; + +/** + * How much of the viewport the mobile drawer covers. + * + * Full width by default, where the drawer reads as its own screen and the + * swipe closes it. Opting into the strip stops it short of the edge so a slice + * of the conversation stays visible, which keeps the drawer reading as a layer + * over the conversation and gives the close gesture a target to tap. + * + * Both the drawer and the pane read the one custom property, so their travel + * cannot drift apart (see SIDEBAR_TRANSITION) and the setting can change at + * runtime without threading a number through either. The fallback is the + * default, so anything rendered outside the property's scope still agrees. + */ +export const MOBILE_DRAWER_WIDTH_VAR = '--mobile-drawer-width'; +export const MOBILE_DRAWER_FULL_WIDTH = '100%'; +export const MOBILE_DRAWER_STRIP_WIDTH = '80%'; +export const MOBILE_DRAWER_WIDTH = `var(${MOBILE_DRAWER_WIDTH_VAR}, ${MOBILE_DRAWER_FULL_WIDTH})`; +export const MOBILE_PANE_SHIFT = `translateX(${MOBILE_DRAWER_WIDTH})`; export const TRANSITION_MS = 300; -export const EASING = 'cubic-bezier(0.2, 0, 0, 1)'; +/** + * Decelerating, but it settles rather than crawls. The previous + * cubic-bezier(0.2, 0, 0, 1) spent its last third of time on a few percent of + * distance, which reads as the drawer sticking just before it lands — most + * obvious on close, where the tail is the part you watch. + */ +export const EASING = 'cubic-bezier(0.32, 0.72, 0, 1)'; /** * The drawer and the chat pane move as one object, so they must stay @@ -9,6 +34,16 @@ export const EASING = 'cubic-bezier(0.2, 0, 0, 1)'; */ export const SIDEBAR_TRANSITION = `transform ${TRANSITION_MS}ms ${EASING}`; +/** + * The drawer also transitions its width, because that is the one property the + * pane tracks through its own transform. Changing the strip setting while the + * drawer is open would otherwise jump the width in a frame while the pane + * eased across 300ms, leaving the newly exposed slice with no conversation + * under it. Only the drawer needs this: the pane's width is flex-driven and + * animating it would reach the desktop sidebar's collapse as well. + */ +export const MOBILE_DRAWER_TRANSITION = `${SIDEBAR_TRANSITION}, width ${TRANSITION_MS}ms ${EASING}`; + /** * The mobile drawer is opaque and full-screen, so it sits above the chat. * @@ -26,3 +61,9 @@ export const DRAWER_Z_INDEX = 110; * the drawer element without threading a ref across sibling trees. */ export const MOBILE_DRAWER_ID = 'mobile-drawer'; + +/** + * Lets a kicked toggle start the scrim fade with the drawer, rather than + * waiting for the deferred Recoil commit that a large conversation stalls. + */ +export const MOBILE_SCRIM_ID = 'mobile-drawer-scrim'; diff --git a/client/src/components/UnifiedSidebar/mobile/Scrim.tsx b/client/src/components/UnifiedSidebar/mobile/Scrim.tsx new file mode 100644 index 0000000000..a11167cfed --- /dev/null +++ b/client/src/components/UnifiedSidebar/mobile/Scrim.tsx @@ -0,0 +1,58 @@ +import { Button } from '@librechat/client'; +import type { MouseEvent } from 'react'; +import { DRAWER_Z_INDEX, MOBILE_SCRIM_ID, TRANSITION_MS, EASING } from '../constants'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +/** + * Covers the strip of conversation the drawer leaves visible, and dismisses it + * when tapped. Rendered as a sibling of the chat pane rather than inside it, + * because the pane is inert while the drawer is open and would swallow the + * click. + * + * The shared button carries the focus treatment; `variant`/`size` are cleared + * because a full-bleed surface wants none of the chrome, not a different set + * of it. + */ +export default function Scrim({ + expanded, + isSliding, + prefersReducedMotion, + onClick, +}: { + expanded: boolean; + /** Both surfaces keep travelling outside the committed state, and a tap in + * that window would otherwise reach a control on the pane sliding past. */ + isSliding: boolean; + prefersReducedMotion: boolean; + onClick: (event: MouseEvent) => void; +}) { + const localize = useLocalize(); + + return ( +