mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
📱 feat: Offer a Dismissable Strip of Chat Beside the Mobile Drawer (#14990)
* feat: leave a dismissable strip of chat beside the mobile drawer The drawer took the whole viewport, so opening it read as a screen change rather than a layer over the conversation, and the only ways back were the header button and a swipe. It now stops at 80% and the chat stays visible behind a scrim, which is itself the dismiss target: tapping it closes the drawer and returns to the conversation, which never navigated away. The scrim renders as a sibling of the pane rather than inside it, because the pane is inert while the drawer is open and would swallow the click. Drawer width and pane travel derive from one constant so they cannot drift. Closing had to change with it. A programmatic close repositioned the pane instantly, which was invisible only because a full-width opaque drawer covered the jump; with a strip on screen that jump lands in plain view, so both surfaces animate together, the motion the drag path already produced. The spec that pinned the old reveal is rewritten to pin this. The easing also changed: the previous curve spent its last third of the duration on a few percent of the distance, which read as the panel sticking just before it landed, most obvious on close. Three things the scrim has to respect, each found in review: - It routes through useSidebarToggle rather than writing the atom, so the slide still starts imperatively and a large conversation cannot stall it. - It drops its fade under prefers-reduced-motion, matching the snap kickDrawerAnimation already performs. - It stays the pointer target until the close animation settles, derived from the committed state so every close path is covered, and cleared on a timer so a scrim unmounting at the breakpoint cannot strand it. Focus returns to the drawer's opener once the closed state commits, since the scrim goes aria-hidden and untabbable. * fix: close the mobile drawer predictably from every path Move the close handling out of Root into useDrawerDismiss, which fixes three things the split scrim-owned version got wrong: A breakpoint crossing derives the drawer closed with nothing to animate, so narrowing a window or rotating a tablet armed the pointer guard and left a transparent full-screen scrim swallowing taps for 300ms. The scrim stays the pointer target through that guard, where the state has already committed. A tap there closed again, a no-op that never reached the focus handoff, stranding the restore flag to fire on a later close. Focus was only restored when the scrim itself closed the drawer, and only to the header opener. Closing from the drawer button or Escape left focus in a subtree that goes inert, and routes that render no opener left it on the scrim once it went aria-hidden. Every close path now restores, to the opener or the pane, and only when the close is what dropped focus. * style: sort imports in the new drawer hook * fix: reclaim focus from the scrim when Escape closes the drawer The drawer's Escape handler closes it without going through the scrim, so a keyboard user who tabbed there kept focus on a button that becomes aria-hidden and untabbable. Inert drops focus to the body by itself; aria-hidden does not, so it has to count as lost too. * feat: make the mobile chat strip a setting, off by default The drawer covering the full width and closing by swipe stays the default. Turning the setting on stops it short of the edge, leaving a strip of the conversation visible that also closes the drawer when tapped. Both surfaces read one custom property for how far the drawer opens, so the value can change at runtime without threading a number through the swipe gesture, and their travel still cannot drift apart. The fallback is the default, so anything rendered outside the property's scope agrees too. The scrim moves into its own component, which is what makes its tab order, aria-hidden and pointer-events states testable. * fix: keep the reveal close on the default full-width drawer Making the strip opt-in put the paired close animation on the default path, where the drawer covers the pane: selecting a conversation then visibly shifted the chat leftward while the new one committed into the moving layer, which is the regression the reveal existed to avoid. The reveal is now chosen from geometry rather than the setting, since it is safe exactly when the drawer hides the pane, however the width was arrived at. The drawer also transitions its width, so toggling the setting while it is open moves both surfaces on one curve instead of jumping the width in a frame while the pane eases across the transition. * fix: honour reduced motion when the strip setting changes the width Changing the setting updates the width custom property directly rather than going through the snap path, so the drawer eased its width and the pane its transform for the full transition even for a user who asked for no motion. The preference now reaches the declarative styles on both surfaces, and the snap no longer hands an animating transition back afterwards, which is what left the element ready to ease the next change. * fix: cover the gesture snap, the close frame and the breakpoint focus The gesture settle restored the transitions directly rather than through the reduced-motion handoff, so a swipe left both surfaces ready to animate the next width change. The close guard was armed from a passive effect, which runs after paint, leaving one frame where the pane had dropped inert and the scrim had not yet taken the pointer back. It is armed in the committing frame now. Crossing into mobile with focus inside the expanded desktop sidebar drops it when that subtree unmounts. The guard is still right to stay disarmed there, since nothing animates, but the focus handoff has to run, so the two no longer share an early return. * fix: keep the pointer guard tied to a pane that actually moves Disabling the strip unmounts the scrim at once while the drawer needs the whole transition to widen, so a close begun in that window still slid the pane with nothing holding the pointer. The scrim now stays mounted while a close is in flight. Arming that guard is tied to the same geometry the close path already branches on. A close under a drawer that covers the pane is a reveal, with the pane already in place, so holding the pointer there would only make the default configuration feel unresponsive for the length of the transition. * fix: guard the swipe close and hand focus back off the mobile breakpoint The guard read the drawer's width to decide whether the pane was moving, but a swipe animates the pane at any width, so the default configuration went unguarded through the one close path that does move it. It now asks the pane itself: the reveal leaves transition none behind, every animated path leaves the shared transition on it before the state commits. Leaving mobile unmounts the drawer and the scrim, so focus sitting on either went to the document. The same handoff runs for that direction, and it now confirms the opener actually took focus rather than assuming: the opener stays mounted across breakpoints but is hidden on desktop. The scrim is imported through the mobile directory's barrel. * fix: address PR review bot findings Codex: - Start the scrim fade with the drawer slide, not the deferred Recoil commit - Keep the scrim focus ring inside the overflow-hidden shell * fix: address PR review bot findings Codex: - Capture pointer events on the scrim as soon as an open slide starts - Expire the close guard at the animation deadline, not a fresh 300ms - Keep pointer capture through a reveal close while the drawer still slides * fix: hand focus back once the close guard releases Codex: - Defer focus restoration until the pane is no longer inert The guard reapplies inert to the pane in the same commit the close lands, and both the opener and the pane itself sit inside it, so the handoff was ejected to the body with no dependency left to re-run it. The release now flushes before focus moves. * fix: drop the scrim pointer override when the close slide starts Codex: - Clear the opening pointer override on close The opening kick writes an inline pointer-events override that only the buffered release cleared, so a dismiss inside that window left the invisible scrim swallowing taps past the guard. The close now hands capture back to the classes, which already hold it for the guard's duration. * fix: carry the focus handoff and the slide's own clocks through a close Codex: - Preserve focus when the motion preference changes mid-close - Keep the scrim armed when an opening is canceled - Stabilize the drawer width before closing mid-toggle The handoff is now keyed off the guard releasing rather than the timer, so a guard cancelled by a dependency change still hands focus back once the pane sheds inert. A close that cancels an uncommitted open never reaches the isClosing classes, so it keeps the scrim's pointer override instead of returning capture that nothing else holds. And the close pins the drawer's measured width, so a width transition still in flight cannot drive its edge from a second clock and open a gap against the pane. * fix: guard a close the committed state never reports Codex: - Guard canceled opens when the strip is disabled - Stabilize the drawer width before an in-flight swipe A second toggle inside the deferred flip, or an open drag that falls short, moves the pane without expanded ever changing, so the guard had no transition to arm from and the default configuration left the pane live as it uncovered. The slide now reports itself and arms the guard directly, which also makes the scrim's pointer override unconditional again: every close hands capture back to the classes. Claiming a gesture drops the transition, which lands a width still easing toward the strip target on that target in the same frame, so the touchstart snapshot went stale and held a gap open between the surfaces for the rest of the drag. The claim remeasures. * fix: cover the opening travel and compose the scrim's button Codex: - Guard the pane during default drawer opens - Compose the shared button primitive for the scrim Recoil's flip is deferred past the opening frames and the closing transition outlives it at the other end, so the committed state brackets the travel too late and drops it too early. The guard is now named for what it measures and arms for any slide the committed state does not report, so the default configuration covers the pane while the drawer travels over it. Only a close records the focus handoff; an open hands focus to the drawer's header. The scrim now composes the shared Button, keeping only the inset ring the overflow-hidden shell requires.
This commit is contained in:
parent
b40fcb4c5a
commit
0f376884bd
13 changed files with 1464 additions and 51 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
<SidebarChatProvider>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
58
client/src/components/UnifiedSidebar/mobile/Scrim.tsx
Normal file
58
client/src/components/UnifiedSidebar/mobile/Scrim.tsx
Normal file
|
|
@ -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<HTMLElement>) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<Button
|
||||
id={MOBILE_SCRIM_ID}
|
||||
variant={null}
|
||||
size={null}
|
||||
aria-label={localize('com_nav_close_sidebar')}
|
||||
onClick={onClick}
|
||||
tabIndex={expanded ? 0 : -1}
|
||||
aria-hidden={!expanded || undefined}
|
||||
className={cn(
|
||||
'absolute inset-0 rounded-none bg-surface-overlay/50',
|
||||
/** Inset: the shell is overflow-hidden, and the shared ring's offset
|
||||
* puts it 2px outside the box, which the shell would clip. */
|
||||
'focus-visible:ring-inset focus-visible:ring-offset-0',
|
||||
!expanded && !isSliding && 'pointer-events-none',
|
||||
)}
|
||||
style={{
|
||||
zIndex: DRAWER_Z_INDEX - 1,
|
||||
/** Style rather than a class so a kicked fade (inline opacity written
|
||||
* at animation start) is not interrupted when Recoil commits the
|
||||
* matching value three frames later. */
|
||||
opacity: expanded ? 1 : 0,
|
||||
transition: prefersReducedMotion ? undefined : `opacity ${TRANSITION_MS}ms ${EASING}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import Scrim from '../Scrim';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
function setup({
|
||||
expanded,
|
||||
isSliding = false,
|
||||
prefersReducedMotion = false,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
isSliding?: boolean;
|
||||
prefersReducedMotion?: boolean;
|
||||
}) {
|
||||
const onClick = jest.fn();
|
||||
render(
|
||||
<Scrim
|
||||
expanded={expanded}
|
||||
isSliding={isSliding}
|
||||
prefersReducedMotion={prefersReducedMotion}
|
||||
onClick={onClick}
|
||||
/>,
|
||||
);
|
||||
return { scrim: screen.getByRole('button', { hidden: true }), onClick };
|
||||
}
|
||||
|
||||
describe('mobile drawer Scrim', () => {
|
||||
it('is a reachable dismiss target while the drawer is open', () => {
|
||||
const { scrim } = setup({ expanded: true });
|
||||
|
||||
expect(scrim).toHaveAttribute('tabindex', '0');
|
||||
expect(scrim).not.toHaveAttribute('aria-hidden');
|
||||
expect(scrim.style.opacity).toBe('1');
|
||||
expect(scrim).not.toHaveClass('pointer-events-none');
|
||||
});
|
||||
|
||||
it('leaves the tab order and the accessibility tree once closed', () => {
|
||||
const { scrim } = setup({ expanded: false });
|
||||
|
||||
expect(scrim).toHaveAttribute('tabindex', '-1');
|
||||
expect(scrim).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(scrim.style.opacity).toBe('0');
|
||||
expect(scrim).toHaveClass('pointer-events-none');
|
||||
});
|
||||
|
||||
/**
|
||||
* The parent shell is overflow-hidden and the global :focus-visible outline
|
||||
* sits 2px outside the box, so an inset ring is the only indicator that
|
||||
* actually paints.
|
||||
*/
|
||||
it('keeps the focus ring inside the overflow-hidden shell', () => {
|
||||
const { scrim } = setup({ expanded: true });
|
||||
|
||||
expect(scrim).toHaveClass(
|
||||
'focus-visible:outline-none',
|
||||
'focus-visible:ring-2',
|
||||
'focus-visible:ring-inset',
|
||||
'focus-visible:ring-text-primary',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The state commits before the drawer and pane finish moving, so releasing
|
||||
* the pointer target early lets a tap through to a control sliding by
|
||||
* underneath.
|
||||
*/
|
||||
it('stays the pointer target while the close is still animating', () => {
|
||||
const { scrim } = setup({ expanded: false, isSliding: true });
|
||||
|
||||
expect(scrim).not.toHaveClass('pointer-events-none');
|
||||
});
|
||||
|
||||
it('drops the fade under reduced motion, where both surfaces snap', () => {
|
||||
const { scrim } = setup({ expanded: true, prefersReducedMotion: true });
|
||||
|
||||
expect(scrim.style.transition).toBe('');
|
||||
});
|
||||
|
||||
it('reports taps to its owner', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { scrim, onClick } = setup({ expanded: true });
|
||||
|
||||
await user.click(scrim);
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,3 +2,4 @@ export { default as MobileHeader } from './Header';
|
|||
export { default as MobileBottomBar } from './BottomBar';
|
||||
export { default as MobilePanelSwitcher } from './Switcher';
|
||||
export { default as MobileShortcutTargets } from './ShortcutTargets';
|
||||
export { default as MobileDrawerScrim } from './Scrim';
|
||||
|
|
|
|||
519
client/src/hooks/Nav/__tests__/useDrawerDismiss.spec.tsx
Normal file
519
client/src/hooks/Nav/__tests__/useDrawerDismiss.spec.tsx
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
import { act, renderHook } from '@testing-library/react';
|
||||
import {
|
||||
MOBILE_DRAWER_ID,
|
||||
MOBILE_DRAWER_TRANSITION,
|
||||
SIDEBAR_TRANSITION,
|
||||
TRANSITION_MS,
|
||||
} from '~/components/UnifiedSidebar/constants';
|
||||
import { markDrawerAnimationStart, notifyDrawerSlide } from '../useDrawerSwipe';
|
||||
import { OPEN_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar';
|
||||
import useDrawerDismiss from '../useDrawerDismiss';
|
||||
|
||||
type Props = {
|
||||
expanded: boolean;
|
||||
isSmallScreen: boolean;
|
||||
prefersReducedMotion: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Defaults to a pane the close is animating, which is the shape the pointer
|
||||
* guard exists for. Pass `none` to model the reveal close, where the pane is
|
||||
* repositioned instantly and never moves under the user.
|
||||
*/
|
||||
function setup({
|
||||
expanded,
|
||||
isSmallScreen,
|
||||
prefersReducedMotion = false,
|
||||
paneTransition = SIDEBAR_TRANSITION,
|
||||
drawerTransition = MOBILE_DRAWER_TRANSITION,
|
||||
}: Omit<Props, 'prefersReducedMotion'> & {
|
||||
prefersReducedMotion?: boolean;
|
||||
paneTransition?: string;
|
||||
drawerTransition?: string;
|
||||
}) {
|
||||
const pane = document.createElement('div');
|
||||
pane.tabIndex = -1;
|
||||
pane.style.transition = paneTransition;
|
||||
const drawer = document.createElement('div');
|
||||
drawer.id = MOBILE_DRAWER_ID;
|
||||
drawer.style.transition = drawerTransition;
|
||||
document.body.append(pane, drawer);
|
||||
const paneRef: React.RefObject<HTMLElement> = { current: pane };
|
||||
const setOpen = jest.fn();
|
||||
|
||||
const view = renderHook((props: Props) => useDrawerDismiss({ ...props, paneRef, setOpen }), {
|
||||
initialProps: { expanded, isSmallScreen, prefersReducedMotion },
|
||||
});
|
||||
|
||||
return { ...view, pane, setOpen };
|
||||
}
|
||||
|
||||
function addOpener() {
|
||||
const opener = document.createElement('button');
|
||||
opener.id = OPEN_SIDEBAR_ID;
|
||||
document.body.appendChild(opener);
|
||||
return opener;
|
||||
}
|
||||
|
||||
function clickScrim(onScrimClick: (event: React.MouseEvent<HTMLElement>) => void) {
|
||||
const scrim = document.createElement('button');
|
||||
document.body.appendChild(scrim);
|
||||
scrim.focus();
|
||||
act(() => {
|
||||
onScrimClick({ currentTarget: scrim } as unknown as React.MouseEvent<HTMLElement>);
|
||||
});
|
||||
return scrim;
|
||||
}
|
||||
|
||||
describe('useDrawerDismiss', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
markDrawerAnimationStart(null);
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('the pointer guard', () => {
|
||||
it('holds the scrim through the close transition', () => {
|
||||
const { result, rerender } = setup({ expanded: true, isSmallScreen: true });
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
expect(result.current.isSliding).toBe(true);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TRANSITION_MS);
|
||||
});
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Crossing to mobile derives the drawer closed with nothing to animate, so
|
||||
* arming here would leave a transparent full-screen scrim over the whole
|
||||
* viewport, swallowing every tap for the length of the guard.
|
||||
*/
|
||||
it('does not arm when an expanded desktop sidebar crosses into mobile', () => {
|
||||
const { result, rerender } = setup({ expanded: true, isSmallScreen: false });
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* The reveal close repositions the pane instantly, but the drawer is still
|
||||
* sliding away. Dropping inert on the pane at the Recoil commit would let
|
||||
* a second tap hit a conversation control as it uncovers.
|
||||
*/
|
||||
it('arms through a reveal close while the drawer is still sliding', () => {
|
||||
const { result, rerender } = setup({
|
||||
expanded: true,
|
||||
isSmallScreen: true,
|
||||
paneTransition: 'none',
|
||||
});
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
|
||||
expect(result.current.isSliding).toBe(true);
|
||||
});
|
||||
|
||||
it('does not arm when neither surface is transitioning', () => {
|
||||
const { result, rerender } = setup({
|
||||
expanded: true,
|
||||
isSmallScreen: true,
|
||||
paneTransition: 'none',
|
||||
drawerTransition: 'none',
|
||||
});
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* The slide started at kick time; a delayed Recoil commit must not add a
|
||||
* fresh 300ms of pointer-blocking after the surfaces have already settled.
|
||||
*/
|
||||
it('holds the guard only for the time left on the animation deadline', () => {
|
||||
markDrawerAnimationStart(0);
|
||||
jest.spyOn(performance, 'now').mockReturnValue(200);
|
||||
const { result, rerender } = setup({ expanded: true, isSmallScreen: true });
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
expect(result.current.isSliding).toBe(true);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
});
|
||||
|
||||
it('does not arm when the animation deadline has already passed', () => {
|
||||
markDrawerAnimationStart(0);
|
||||
jest.spyOn(performance, 'now').mockReturnValue(TRANSITION_MS + 50);
|
||||
const { result, rerender } = setup({ expanded: true, isSmallScreen: true });
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* A swipe animates the pane at any drawer width, so reading the width
|
||||
* instead of the pane left the default configuration unguarded through the
|
||||
* one close path that does move it.
|
||||
*/
|
||||
it('arms for a close that animates the pane behind a full-width drawer', () => {
|
||||
const { result, rerender } = setup({
|
||||
expanded: true,
|
||||
isSmallScreen: true,
|
||||
paneTransition: SIDEBAR_TRANSITION,
|
||||
});
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
|
||||
expect(result.current.isSliding).toBe(true);
|
||||
});
|
||||
|
||||
it('does not arm under reduced motion, where both surfaces snap', () => {
|
||||
const { result, rerender } = setup({
|
||||
expanded: true,
|
||||
isSmallScreen: true,
|
||||
prefersReducedMotion: true,
|
||||
});
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: true });
|
||||
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
});
|
||||
|
||||
it('releases the guard when the viewport crosses to desktop mid-close', () => {
|
||||
const { result, rerender } = setup({ expanded: true, isSmallScreen: true });
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
expect(result.current.isSliding).toBe(true);
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: false, prefersReducedMotion: false });
|
||||
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('focus after the close', () => {
|
||||
it('hands focus to the opener when the close dropped it', () => {
|
||||
const opener = addOpener();
|
||||
const { rerender } = setup({ expanded: true, isSmallScreen: true });
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TRANSITION_MS);
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
|
||||
/**
|
||||
* The guard puts `inert` back on the pane for its duration, and both the
|
||||
* opener and the pane itself sit inside it, so a handoff made at the commit
|
||||
* would be ejected to the body with nothing left to run it again.
|
||||
*/
|
||||
it('waits for the close guard to release before handing focus over', () => {
|
||||
const opener = addOpener();
|
||||
const { result, rerender } = setup({ expanded: true, isSmallScreen: true });
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
expect(result.current.isSliding).toBe(true);
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TRANSITION_MS);
|
||||
});
|
||||
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
|
||||
/** Every close path goes through the committed state, not just the scrim. */
|
||||
it('restores focus under reduced motion too', () => {
|
||||
const opener = addOpener();
|
||||
const { rerender } = setup({
|
||||
expanded: true,
|
||||
isSmallScreen: true,
|
||||
prefersReducedMotion: true,
|
||||
});
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: true });
|
||||
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
|
||||
/**
|
||||
* A route that returns a loading or error state before its header renders
|
||||
* has no opener, while the shortcut and the pane swipe still open the
|
||||
* drawer.
|
||||
*/
|
||||
it('falls back to the pane when the route renders no opener', () => {
|
||||
const { rerender, pane } = setup({ expanded: true, isSmallScreen: true });
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TRANSITION_MS);
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(pane);
|
||||
});
|
||||
|
||||
it('leaves focus alone when something else took it deliberately', () => {
|
||||
addOpener();
|
||||
const composer = document.createElement('textarea');
|
||||
document.body.appendChild(composer);
|
||||
const { rerender } = setup({ expanded: true, isSmallScreen: true });
|
||||
composer.focus();
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TRANSITION_MS);
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(composer);
|
||||
});
|
||||
|
||||
/**
|
||||
* The drawer's own Escape handler closes it without going through the
|
||||
* scrim, so a keyboard user who tabbed to the scrim keeps focus on it as it
|
||||
* becomes aria-hidden and untabbable.
|
||||
*/
|
||||
it('reclaims focus from the scrim when Escape closed the drawer', () => {
|
||||
const opener = addOpener();
|
||||
const scrim = document.createElement('button');
|
||||
document.body.appendChild(scrim);
|
||||
const { rerender } = setup({ expanded: true, isSmallScreen: true });
|
||||
scrim.focus();
|
||||
scrim.setAttribute('aria-hidden', 'true');
|
||||
scrim.tabIndex = -1;
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TRANSITION_MS);
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
|
||||
/**
|
||||
* The expanded desktop sidebar is replaced by the closed mobile drawer, so
|
||||
* focus inside it is dropped. The guard is right to stay disarmed there,
|
||||
* since nothing animates, but the focus handoff still has to run.
|
||||
*/
|
||||
it('restores focus when an expanded desktop sidebar crosses into mobile', () => {
|
||||
const opener = addOpener();
|
||||
const { result, rerender } = setup({ expanded: true, isSmallScreen: false });
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
|
||||
expect(document.activeElement).toBe(opener);
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
});
|
||||
|
||||
it('restores focus on a close that snaps under reduced motion', () => {
|
||||
const opener = addOpener();
|
||||
const { rerender } = setup({
|
||||
expanded: true,
|
||||
isSmallScreen: true,
|
||||
prefersReducedMotion: true,
|
||||
});
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: true });
|
||||
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
|
||||
/**
|
||||
* Leaving mobile unmounts the drawer and the scrim, so focus sitting on
|
||||
* either goes with them. The opener stays mounted across breakpoints but is
|
||||
* hidden on desktop, where focusing it does nothing, so the pane takes it.
|
||||
*/
|
||||
it('hands focus to the pane when the viewport leaves mobile', () => {
|
||||
const opener = addOpener();
|
||||
/** Hidden the way the header hides it at this breakpoint. */
|
||||
opener.focus = () => undefined;
|
||||
const { rerender, pane } = setup({ expanded: true, isSmallScreen: true });
|
||||
|
||||
rerender({ expanded: true, isSmallScreen: false, prefersReducedMotion: false });
|
||||
|
||||
expect(document.activeElement).toBe(pane);
|
||||
});
|
||||
|
||||
/**
|
||||
* A dependency change cancels the guard and takes its timer, and with it
|
||||
* the handoff, before the replacement run can see the close: its
|
||||
* `wasExpanded` is already false. The debt has to survive that.
|
||||
*/
|
||||
it('hands focus over when a motion-preference change cancels the guard', () => {
|
||||
const opener = addOpener();
|
||||
const { result, rerender } = setup({ expanded: true, isSmallScreen: true });
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
expect(result.current.isSliding).toBe(true);
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: true });
|
||||
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
|
||||
it('reclaims focus stranded inside the drawer once it goes inert', () => {
|
||||
const opener = addOpener();
|
||||
const drawer = document.createElement('div');
|
||||
drawer.setAttribute('inert', '');
|
||||
const close = document.createElement('button');
|
||||
drawer.appendChild(close);
|
||||
document.body.appendChild(drawer);
|
||||
const { rerender } = setup({ expanded: true, isSmallScreen: true });
|
||||
close.focus();
|
||||
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TRANSITION_MS);
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a slide the committed state never reports', () => {
|
||||
/**
|
||||
* A second toggle inside the deferred flip retargets the slide without
|
||||
* `expanded` ever having flipped, and an open drag that falls short snaps
|
||||
* both surfaces back the same way. The pane moves either way, so the guard
|
||||
* has to cover it.
|
||||
*/
|
||||
it('arms for a close slide that never committed an open', () => {
|
||||
const { result } = setup({ expanded: false, isSmallScreen: true });
|
||||
|
||||
act(() => {
|
||||
notifyDrawerSlide(false);
|
||||
});
|
||||
|
||||
expect(result.current.isSliding).toBe(true);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TRANSITION_MS);
|
||||
});
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
});
|
||||
|
||||
it('hands focus over when that guard releases', () => {
|
||||
const opener = addOpener();
|
||||
const { result } = setup({ expanded: false, isSmallScreen: true });
|
||||
|
||||
act(() => {
|
||||
notifyDrawerSlide(false);
|
||||
});
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TRANSITION_MS);
|
||||
});
|
||||
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
|
||||
/** The committed close arms above, for what is LEFT of the motion. */
|
||||
it('leaves a committed close to the state transition', () => {
|
||||
const { result } = setup({ expanded: true, isSmallScreen: true });
|
||||
|
||||
act(() => {
|
||||
notifyDrawerSlide(false);
|
||||
});
|
||||
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* The deferred flip leaves the opening frames outside the committed state
|
||||
* too, and without the strip nothing else covers the pane while the drawer
|
||||
* travels over it.
|
||||
*/
|
||||
it('arms for an opening slide the flip has not committed', () => {
|
||||
const { result } = setup({ expanded: false, isSmallScreen: true });
|
||||
|
||||
act(() => {
|
||||
notifyDrawerSlide(true);
|
||||
});
|
||||
|
||||
expect(result.current.isSliding).toBe(true);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TRANSITION_MS);
|
||||
});
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
});
|
||||
|
||||
/** Only a close strands focus; the drawer's header takes it on an open. */
|
||||
it('does not reclaim focus when an opening slide releases', () => {
|
||||
const opener = addOpener();
|
||||
const { result } = setup({ expanded: false, isSmallScreen: true });
|
||||
|
||||
act(() => {
|
||||
notifyDrawerSlide(true);
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TRANSITION_MS);
|
||||
});
|
||||
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
expect(document.activeElement).not.toBe(opener);
|
||||
});
|
||||
|
||||
it('does not arm under reduced motion, where the slide snaps', () => {
|
||||
const { result } = setup({
|
||||
expanded: false,
|
||||
isSmallScreen: true,
|
||||
prefersReducedMotion: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
notifyDrawerSlide(false);
|
||||
});
|
||||
|
||||
expect(result.current.isSliding).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the scrim itself', () => {
|
||||
it('closes the drawer and gives up its own focus', () => {
|
||||
const { result, setOpen } = setup({ expanded: true, isSmallScreen: true });
|
||||
|
||||
const scrim = clickScrim(result.current.onScrimClick);
|
||||
|
||||
expect(setOpen).toHaveBeenCalledWith(false);
|
||||
expect(document.activeElement).not.toBe(scrim);
|
||||
});
|
||||
|
||||
/**
|
||||
* The scrim stays the pointer target through the guard, where the state has
|
||||
* already committed. Closing again would be a no-op that never reaches the
|
||||
* focus handoff, so the tap has to be swallowed outright.
|
||||
*/
|
||||
it('swallows a tap landing after the close has committed', () => {
|
||||
const { result, rerender, setOpen } = setup({ expanded: true, isSmallScreen: true });
|
||||
rerender({ expanded: false, isSmallScreen: true, prefersReducedMotion: false });
|
||||
expect(result.current.isSliding).toBe(true);
|
||||
setOpen.mockClear();
|
||||
|
||||
const scrim = clickScrim(result.current.onScrimClick);
|
||||
|
||||
expect(setOpen).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).not.toBe(scrim);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3,8 +3,13 @@ import useDrawerSwipe, {
|
|||
findHorizontalScrollBlocker,
|
||||
getPendingDrawerFlip,
|
||||
kickDrawerAnimation,
|
||||
setDrawerSlideListener,
|
||||
} from '../useDrawerSwipe';
|
||||
import { MOBILE_DRAWER_ID } from '~/components/UnifiedSidebar/constants';
|
||||
import {
|
||||
MOBILE_DRAWER_ID,
|
||||
MOBILE_PANE_SHIFT,
|
||||
MOBILE_SCRIM_ID,
|
||||
} from '~/components/UnifiedSidebar/constants';
|
||||
|
||||
const DRAWER_WIDTH = 375;
|
||||
|
||||
|
|
@ -39,11 +44,14 @@ type Harness = {
|
|||
unmount: () => void;
|
||||
};
|
||||
|
||||
const setup = (open: boolean, reducedMotion = false): Harness => {
|
||||
/** Default: the drawer covers the viewport, which is the shipped default. Pass
|
||||
* a wider viewport to model the strip setting, where it does not. */
|
||||
const setup = (open: boolean, reducedMotion = false, viewportWidth = DRAWER_WIDTH): Harness => {
|
||||
const pane = document.createElement('div');
|
||||
const drawer = document.createElement('div');
|
||||
drawer.id = MOBILE_DRAWER_ID;
|
||||
Object.defineProperty(drawer, 'clientWidth', { value: DRAWER_WIDTH, configurable: true });
|
||||
Object.defineProperty(window, 'innerWidth', { value: viewportWidth, configurable: true });
|
||||
document.body.append(pane, drawer);
|
||||
|
||||
jest.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
|
|
@ -94,8 +102,17 @@ const setup = (open: boolean, reducedMotion = false): Harness => {
|
|||
};
|
||||
};
|
||||
|
||||
function attachScrim(opacity: string) {
|
||||
const scrim = document.createElement('button');
|
||||
scrim.id = MOBILE_SCRIM_ID;
|
||||
scrim.style.opacity = opacity;
|
||||
document.body.append(scrim);
|
||||
return scrim;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.getElementById(MOBILE_DRAWER_ID)?.remove();
|
||||
document.getElementById(MOBILE_SCRIM_ID)?.remove();
|
||||
});
|
||||
|
||||
describe('useDrawerSwipe — opening from the chat pane', () => {
|
||||
|
|
@ -120,7 +137,7 @@ describe('useDrawerSwipe — opening from the chat pane', () => {
|
|||
|
||||
expect(harness.onOpenChange).toHaveBeenCalledWith(true);
|
||||
expect(harness.drawer.style.transform).toBe('translate3d(0, 0, 0)');
|
||||
expect(harness.pane.style.transform).toBe('translateX(100%)');
|
||||
expect(harness.pane.style.transform).toBe(MOBILE_PANE_SHIFT);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
|
|
@ -234,6 +251,27 @@ describe('useDrawerSwipe — opening from the chat pane', () => {
|
|||
harness.unmount();
|
||||
});
|
||||
|
||||
/**
|
||||
* Nor once the flip settles: the strip setting changes the drawer's width
|
||||
* without passing through any of these paths, so a transition handed back
|
||||
* here would leave that change animating for a user who asked for none.
|
||||
*/
|
||||
it('leaves no transition behind after a snap settles', () => {
|
||||
jest.useFakeTimers();
|
||||
const harness = setup(false, true);
|
||||
harness.swipe(harness.pane, [
|
||||
{ x: 20, y: 100, t: 0 },
|
||||
{ x: 240, y: 100, t: 50 },
|
||||
]);
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(harness.drawer.style.transition).toBe('none');
|
||||
expect(harness.pane.style.transition).toBe('none');
|
||||
jest.useRealTimers();
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it('reverts cleanly when the browser cancels the touch', () => {
|
||||
const harness = setup(false);
|
||||
harness.swipe(
|
||||
|
|
@ -267,6 +305,20 @@ describe('useDrawerSwipe — closing from the drawer', () => {
|
|||
harness.unmount();
|
||||
});
|
||||
|
||||
it('starts the scrim fade when a swipe commits the close', () => {
|
||||
const harness = setup(true, false, Math.round(DRAWER_WIDTH / 0.8));
|
||||
const scrim = attachScrim('1');
|
||||
harness.swipe(harness.drawer, [
|
||||
{ x: 350, y: 100, t: 0 },
|
||||
{ x: 250, y: 100, t: 50 },
|
||||
{ x: 150, y: 100, t: 100 },
|
||||
]);
|
||||
|
||||
expect(scrim.style.opacity).toBe('0');
|
||||
expect(harness.onOpenChange).toHaveBeenCalledWith(false);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it('ignores a rightward swipe while already open', () => {
|
||||
const harness = setup(true);
|
||||
harness.swipe(harness.drawer, [
|
||||
|
|
@ -288,7 +340,7 @@ describe('useDrawerSwipe — interruptions and re-arming', () => {
|
|||
{ x: 220, y: 100, t: 50 },
|
||||
]);
|
||||
expect(harness.onOpenChange).toHaveBeenCalledWith(true);
|
||||
expect(harness.pane.style.transform).toBe('translateX(100%)');
|
||||
expect(harness.pane.style.transform).toBe(MOBILE_PANE_SHIFT);
|
||||
|
||||
harness.rerender({ open: true, enabled: true });
|
||||
harness.rerender({ open: false, enabled: true });
|
||||
|
|
@ -313,7 +365,7 @@ describe('useDrawerSwipe — interruptions and re-arming', () => {
|
|||
expect(harness.drawer.style.transform).toBe('translate3d(0, 0, 0)');
|
||||
|
||||
jest.runAllTimers();
|
||||
expect(harness.pane.style.transform).toBe('translateX(100%)');
|
||||
expect(harness.pane.style.transform).toBe(MOBILE_PANE_SHIFT);
|
||||
expect(harness.drawer.style.transform).toBe('');
|
||||
jest.useRealTimers();
|
||||
harness.unmount();
|
||||
|
|
@ -358,7 +410,7 @@ describe('useDrawerSwipe — interruptions and re-arming', () => {
|
|||
expect(harness.drawer.style.transition).not.toBe('none');
|
||||
/** React committed the open pane transform before this cleanup and will
|
||||
* not re-assert it — the release must restore it, not clear it. */
|
||||
expect(harness.pane.style.transform).toBe('translateX(100%)');
|
||||
expect(harness.pane.style.transform).toBe(MOBILE_PANE_SHIFT);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
|
|
@ -448,7 +500,7 @@ describe('useDrawerSwipe — kickDrawerAnimation (button toggles)', () => {
|
|||
});
|
||||
|
||||
expect(transformAtApply).toEqual(['translate3d(0, 0, 0)']);
|
||||
expect(harness.pane.style.transform).toBe('translateX(100%)');
|
||||
expect(harness.pane.style.transform).toBe(MOBILE_PANE_SHIFT);
|
||||
expect(harness.drawer.style.willChange).toBe('transform');
|
||||
expect(harness.onOpenChange).not.toHaveBeenCalled();
|
||||
|
||||
|
|
@ -458,7 +510,7 @@ describe('useDrawerSwipe — kickDrawerAnimation (button toggles)', () => {
|
|||
jest.runAllTimers();
|
||||
expect(harness.drawer.style.transform).toBe('');
|
||||
expect(harness.drawer.style.willChange).toBe('');
|
||||
expect(harness.pane.style.transform).toBe('translateX(100%)');
|
||||
expect(harness.pane.style.transform).toBe(MOBILE_PANE_SHIFT);
|
||||
jest.useRealTimers();
|
||||
harness.unmount();
|
||||
});
|
||||
|
|
@ -494,6 +546,7 @@ describe('useDrawerSwipe — kickDrawerAnimation (button toggles)', () => {
|
|||
kickDrawerAnimation(false, jest.fn());
|
||||
|
||||
expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)');
|
||||
/** Retargeting to a close takes the reveal, since this drawer covers. */
|
||||
expect(harness.pane.style.transform).toBe('none');
|
||||
|
||||
harness.rerender({ open: false, enabled: true });
|
||||
|
|
@ -537,7 +590,13 @@ describe('useDrawerSwipe — kickDrawerAnimation (button toggles)', () => {
|
|||
harness.unmount();
|
||||
});
|
||||
|
||||
it('snaps without a transition under reduced motion', () => {
|
||||
/**
|
||||
* The transition is not handed back after the snap either. The strip setting
|
||||
* changes the drawer's width without passing through this path, so a restored
|
||||
* transition would leave that one change animating for a user who asked for
|
||||
* no motion.
|
||||
*/
|
||||
it('snaps under reduced motion and leaves no transition behind', () => {
|
||||
jest.useFakeTimers();
|
||||
const harness = setup(false, true);
|
||||
|
||||
|
|
@ -548,7 +607,23 @@ describe('useDrawerSwipe — kickDrawerAnimation (button toggles)', () => {
|
|||
|
||||
harness.rerender({ open: true, enabled: true });
|
||||
jest.runAllTimers();
|
||||
expect(harness.drawer.style.transition).not.toBe('none');
|
||||
expect(harness.drawer.style.transition).toBe('none');
|
||||
expect(harness.pane.style.transition).toBe('none');
|
||||
jest.useRealTimers();
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
/** The counterpart: with motion allowed, the surfaces do get them back. */
|
||||
it('hands the transitions back once a slide settles', () => {
|
||||
jest.useFakeTimers();
|
||||
const harness = setup(false);
|
||||
|
||||
kickDrawerAnimation(true, jest.fn());
|
||||
harness.rerender({ open: true, enabled: true });
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(harness.drawer.style.transition).toContain('transform');
|
||||
expect(harness.pane.style.transition).toContain('transform');
|
||||
jest.useRealTimers();
|
||||
harness.unmount();
|
||||
});
|
||||
|
|
@ -604,25 +679,23 @@ describe('useDrawerSwipe — kickDrawerAnimation (button toggles)', () => {
|
|||
queued.shift()?.(0);
|
||||
|
||||
expect(harness.drawer.style.transform).toBe('translate3d(0, 0, 0)');
|
||||
expect(harness.pane.style.transform).toBe('translateX(100%)');
|
||||
expect(harness.pane.style.transform).toBe(MOBILE_PANE_SHIFT);
|
||||
|
||||
harness.rerender({ open: true, enabled: true });
|
||||
jest.advanceTimersByTime(400);
|
||||
expect(harness.drawer.style.transform).toBe('');
|
||||
expect(harness.pane.style.transform).toBe('translateX(100%)');
|
||||
expect(harness.pane.style.transform).toBe(MOBILE_PANE_SHIFT);
|
||||
jest.useRealTimers();
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
/**
|
||||
* A programmatic close is a REVEAL: the pane repositions instantly under
|
||||
* the opaque drawer's cover and only the drawer animates away. Animating
|
||||
* the pane in from the right made every tap-to-navigate close visibly
|
||||
* shift the chat leftward while the new conversation committed into the
|
||||
* moving layer mid-slide. The gesture keeps the paired motion — a finger
|
||||
* drags both — via `settle`, which this does not touch.
|
||||
* A reveal while the drawer covers the pane: repositioning instantly under an
|
||||
* opaque full-width layer is invisible, and animating the pane in from the
|
||||
* right instead makes every tap-to-navigate close visibly shift the chat
|
||||
* while the new conversation commits into the moving layer.
|
||||
*/
|
||||
it('reveals on a programmatic close: pane snaps under cover, only the drawer slides', () => {
|
||||
it('reveals rather than animating when the drawer covers the pane', () => {
|
||||
jest.useFakeTimers();
|
||||
const harness = setup(true);
|
||||
|
||||
|
|
@ -633,6 +706,49 @@ describe('useDrawerSwipe — kickDrawerAnimation (button toggles)', () => {
|
|||
expect(harness.pane.style.transform).toBe('none');
|
||||
expect(harness.pane.style.transition).toBe('none');
|
||||
|
||||
harness.rerender({ open: false, enabled: true });
|
||||
jest.runAllTimers();
|
||||
expect(harness.drawer.style.transform).toBe('');
|
||||
jest.useRealTimers();
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
/**
|
||||
* With the strip enabled the drawer stops short of the edge, so that instant
|
||||
* reposition would land in the visible slice. Both surfaces animate together
|
||||
* instead, matching what a finger drag already produces.
|
||||
*/
|
||||
/**
|
||||
* The pane tracks the drawer's width through its own transform, so a runtime
|
||||
* width change has to move on the same curve or the newly exposed slice has
|
||||
* no conversation under it for the length of the transition.
|
||||
*/
|
||||
it('transitions the drawer width alongside its transform', () => {
|
||||
jest.useFakeTimers();
|
||||
const harness = setup(true, false, Math.round(DRAWER_WIDTH / 0.8));
|
||||
|
||||
kickDrawerAnimation(false, jest.fn());
|
||||
|
||||
expect(harness.drawer.style.transition).toContain('width');
|
||||
expect(harness.drawer.style.transition).toContain('transform');
|
||||
/** Flex-driven, and animating it would reach the desktop collapse too. */
|
||||
expect(harness.pane.style.transition).toContain('transform');
|
||||
expect(harness.pane.style.transition).not.toContain('width');
|
||||
jest.useRealTimers();
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it('animates both surfaces when a strip of chat is left visible', () => {
|
||||
jest.useFakeTimers();
|
||||
const harness = setup(true, false, Math.round(DRAWER_WIDTH / 0.8));
|
||||
|
||||
kickDrawerAnimation(false, jest.fn());
|
||||
|
||||
expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)');
|
||||
expect(harness.drawer.style.transition).not.toBe('none');
|
||||
expect(harness.pane.style.transform).toBe('translate3d(0, 0, 0)');
|
||||
expect(harness.pane.style.transition).not.toBe('none');
|
||||
|
||||
harness.rerender({ open: false, enabled: true });
|
||||
jest.runAllTimers();
|
||||
expect(harness.pane.style.transition).not.toBe('none');
|
||||
|
|
@ -706,6 +822,157 @@ describe('useDrawerSwipe — kickDrawerAnimation (button toggles)', () => {
|
|||
jest.useRealTimers();
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
/**
|
||||
* The drawer slide starts in the kick's frame; Recoil commits three frames
|
||||
* later (longer on a large conversation). Driving the scrim from expanded
|
||||
* would leave it opaque over a drawer that has already gone, then fade it
|
||||
* for another 300ms.
|
||||
*/
|
||||
it('starts the scrim fade with the drawer slide, before the state flip', () => {
|
||||
jest.useFakeTimers();
|
||||
const harness = setup(true, false, Math.round(DRAWER_WIDTH / 0.8));
|
||||
const scrim = attachScrim('1');
|
||||
const opacityAtApply: string[] = [];
|
||||
kickDrawerAnimation(false, () => {
|
||||
opacityAtApply.push(scrim.style.opacity);
|
||||
});
|
||||
|
||||
expect(opacityAtApply).toEqual(['0']);
|
||||
jest.useRealTimers();
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it('starts the scrim fade-in with the open slide', () => {
|
||||
jest.useFakeTimers();
|
||||
const harness = setup(false, false, Math.round(DRAWER_WIDTH / 0.8));
|
||||
const scrim = attachScrim('0');
|
||||
const opacityAtApply: string[] = [];
|
||||
kickDrawerAnimation(true, () => {
|
||||
opacityAtApply.push(scrim.style.opacity);
|
||||
});
|
||||
|
||||
expect(opacityAtApply).toEqual(['1']);
|
||||
jest.useRealTimers();
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
/**
|
||||
* Recoil still has expanded=false for those three frames, so the class
|
||||
* pointer-events-none would leave the fading-in scrim click-through: a tap
|
||||
* on the exposed strip would hit a conversation control instead of dismiss.
|
||||
*/
|
||||
it('captures pointer events on the scrim as soon as the open slide starts', () => {
|
||||
jest.useFakeTimers();
|
||||
const harness = setup(false, false, Math.round(DRAWER_WIDTH / 0.8));
|
||||
const scrim = attachScrim('0');
|
||||
scrim.style.pointerEvents = 'none';
|
||||
const pointerAtApply: string[] = [];
|
||||
kickDrawerAnimation(true, () => {
|
||||
pointerAtApply.push(scrim.style.pointerEvents);
|
||||
});
|
||||
|
||||
expect(pointerAtApply).toEqual(['auto']);
|
||||
jest.useRealTimers();
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
/**
|
||||
* A dismiss inside the opening settle window leaves that slide's override in
|
||||
* place, and inline styles beat the closed pointer-events-none class. The
|
||||
* guard expires at the animation deadline but the release runs a buffer
|
||||
* later, so the invisible scrim would keep swallowing taps in between.
|
||||
*/
|
||||
it('releases the pointer override when the close slide starts', () => {
|
||||
jest.useFakeTimers();
|
||||
const harness = setup(true, false, Math.round(DRAWER_WIDTH / 0.8));
|
||||
const scrim = attachScrim('1');
|
||||
scrim.style.pointerEvents = 'auto';
|
||||
const pointerAtApply: string[] = [];
|
||||
kickDrawerAnimation(false, () => {
|
||||
pointerAtApply.push(scrim.style.pointerEvents);
|
||||
});
|
||||
|
||||
expect(pointerAtApply).toEqual(['']);
|
||||
jest.useRealTimers();
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
/**
|
||||
* Two toggles inside the three-frame flip window leave expanded false
|
||||
* throughout, so the dismiss guard sees no state transition to arm from. The
|
||||
* slide has to report itself or nothing covers the pane as it uncovers.
|
||||
*/
|
||||
it('reports a close slide that cancels an uncommitted open', () => {
|
||||
jest.useFakeTimers();
|
||||
const harness = setup(false, false, Math.round(DRAWER_WIDTH / 0.8));
|
||||
const slides: boolean[] = [];
|
||||
setDrawerSlideListener((next) => slides.push(next));
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
(window.requestAnimationFrame as unknown as jest.Mock).mockImplementation(
|
||||
(callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return 0;
|
||||
},
|
||||
);
|
||||
const applyState = jest.fn();
|
||||
|
||||
kickDrawerAnimation(true, applyState);
|
||||
frames.shift()?.(0);
|
||||
/** The deferred flip never reaches its third frame: the second kick lands
|
||||
* first, which is the whole point of this path. */
|
||||
frames.length = 0;
|
||||
|
||||
kickDrawerAnimation(false, applyState);
|
||||
frames.shift()?.(0);
|
||||
|
||||
expect(applyState).not.toHaveBeenCalled();
|
||||
expect(slides).toEqual([true, false]);
|
||||
setDrawerSlideListener(null);
|
||||
jest.useRealTimers();
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
/**
|
||||
* The strip setting and a rotation both restart the drawer's width
|
||||
* transition, which then runs on its own clock. Driving the drawer's edge
|
||||
* from that clock and the pane's from the close would open a gap between
|
||||
* the two surfaces for the rest of the slide.
|
||||
*/
|
||||
it('pins the drawer width to the close slide and hands it back on open', () => {
|
||||
jest.useFakeTimers();
|
||||
const viewportWidth = Math.round(DRAWER_WIDTH / 0.8);
|
||||
const harness = setup(true, false, viewportWidth);
|
||||
|
||||
kickDrawerAnimation(false, jest.fn());
|
||||
expect(harness.drawer.style.width).toBe(`${viewportWidth}px`);
|
||||
|
||||
kickDrawerAnimation(true, jest.fn());
|
||||
expect(harness.drawer.style.width).not.toBe(`${viewportWidth}px`);
|
||||
|
||||
jest.useRealTimers();
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
/**
|
||||
* Dropping the transition on claim lands a width still easing toward the
|
||||
* strip target on that target in the same frame, so the touchstart snapshot
|
||||
* is stale. Both surfaces read it, so the stale value holds a gap open
|
||||
* between the drawer's edge and the pane for the rest of the drag.
|
||||
*/
|
||||
it('remeasures the drawer width when a gesture claims mid width transition', () => {
|
||||
const harness = setup(false);
|
||||
Object.defineProperty(harness.drawer, 'clientWidth', { value: 340, configurable: true });
|
||||
harness.pane.dispatchEvent(touchEvent('touchstart', [createTouch(0, 0)], 0));
|
||||
Object.defineProperty(harness.drawer, 'clientWidth', { value: 300, configurable: true });
|
||||
|
||||
harness.pane.dispatchEvent(touchEvent('touchmove', [createTouch(20, 0)], 16));
|
||||
harness.pane.dispatchEvent(touchEvent('touchmove', [createTouch(60, 0)], 32));
|
||||
|
||||
expect(harness.drawer.style.transform).toBe('translate3d(-240px, 0, 0)');
|
||||
expect(harness.pane.style.transform).toBe('translate3d(60px, 0, 0)');
|
||||
harness.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findHorizontalScrollBlocker', () => {
|
||||
|
|
|
|||
236
client/src/hooks/Nav/useDrawerDismiss.ts
Normal file
236
client/src/hooks/Nav/useDrawerDismiss.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { MouseEvent, RefObject } from 'react';
|
||||
import { MOBILE_DRAWER_ID, TRANSITION_MS } from '~/components/UnifiedSidebar/constants';
|
||||
import { getDrawerAnimationStartedAt, setDrawerSlideListener } from './useDrawerSwipe';
|
||||
import { OPEN_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar';
|
||||
|
||||
/**
|
||||
* Every path that closes the mobile drawer leaves the same two problems, so
|
||||
* they are answered once here rather than per control: the surfaces the user
|
||||
* acted on stop being focusable, and the drawer and pane keep moving after the
|
||||
* state has already committed.
|
||||
*/
|
||||
export default function useDrawerDismiss({
|
||||
expanded,
|
||||
isSmallScreen,
|
||||
prefersReducedMotion,
|
||||
paneRef,
|
||||
setOpen,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
isSmallScreen: boolean;
|
||||
prefersReducedMotion: boolean;
|
||||
paneRef: RefObject<HTMLElement>;
|
||||
setOpen: (open: boolean) => void;
|
||||
}): {
|
||||
isSliding: boolean;
|
||||
onScrimClick: (event: MouseEvent<HTMLElement>) => void;
|
||||
} {
|
||||
/** Whether either surface is still travelling. Recoil's flip is deferred
|
||||
* past the first frames and the transition outlives it at the other end, so
|
||||
* the committed state brackets the wrong window; a tap outside it reaches a
|
||||
* control on the pane sliding underneath. The scrim holds the pointer and
|
||||
* the pane stays inert for exactly this. */
|
||||
const [isSliding, setIsSliding] = useState(false);
|
||||
const wasExpandedRef = useRef(expanded);
|
||||
const wasSmallScreenRef = useRef(isSmallScreen);
|
||||
/** The guard puts `inert` back on the pane, and both the opener and the pane
|
||||
* itself sit inside it, so a handoff made while it is armed would only be
|
||||
* dropped to the body. An armed close records the debt here and the release
|
||||
* below pays it, whether the release comes from the timer or from a
|
||||
* dependency change cancelling the guard. */
|
||||
const handoffPendingRef = useRef(false);
|
||||
const guardTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
/** Read from the slide listener below, which fires outside React's render. */
|
||||
const expandedRef = useRef(expanded);
|
||||
expandedRef.current = expanded;
|
||||
|
||||
const armGuard = useCallback((duration: number, handoff: boolean) => {
|
||||
if (guardTimerRef.current != null) {
|
||||
clearTimeout(guardTimerRef.current);
|
||||
}
|
||||
handoffPendingRef.current = handoffPendingRef.current || handoff;
|
||||
setIsSliding(true);
|
||||
guardTimerRef.current = setTimeout(() => {
|
||||
guardTimerRef.current = null;
|
||||
setIsSliding(false);
|
||||
}, duration);
|
||||
}, []);
|
||||
|
||||
const disarmGuard = useCallback(() => {
|
||||
if (guardTimerRef.current != null) {
|
||||
clearTimeout(guardTimerRef.current);
|
||||
guardTimerRef.current = null;
|
||||
}
|
||||
setIsSliding(false);
|
||||
}, []);
|
||||
|
||||
/** Layout rather than passive: the guard below has to be armed in the frame
|
||||
* the close commits. A passive effect runs after paint, leaving one frame
|
||||
* where the pane has dropped `inert` and the scrim has not yet taken the
|
||||
* pointer back, which is a tap reaching a control still sliding past. */
|
||||
useLayoutEffect(() => {
|
||||
const wasExpanded = wasExpandedRef.current;
|
||||
const wasSmallScreen = wasSmallScreenRef.current;
|
||||
wasExpandedRef.current = expanded;
|
||||
wasSmallScreenRef.current = isSmallScreen;
|
||||
|
||||
/** Leaving mobile unmounts the drawer and the scrim, so focus sitting on
|
||||
* either goes with them, exactly as it does on a close. */
|
||||
if (wasSmallScreen && !isSmallScreen) {
|
||||
restoreDrawerFocus(paneRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSmallScreen || !wasExpanded || expanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = closeGuardDuration({
|
||||
wasSmallScreen,
|
||||
prefersReducedMotion,
|
||||
pane: paneRef.current,
|
||||
});
|
||||
|
||||
/** Nothing is still moving, so the pane never takes `inert` back and the
|
||||
* handoff can land in this frame. Runs for a breakpoint crossing too: the
|
||||
* expanded desktop sidebar is replaced by the closed mobile drawer, so
|
||||
* focus inside it is dropped just as surely as by a deliberate close. */
|
||||
if (remaining == null) {
|
||||
restoreDrawerFocus(paneRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
/** Timer rather than transitionend: the scrim unmounts when the viewport
|
||||
* crosses to desktop, and a handler that never fires would strand the
|
||||
* guard on. */
|
||||
armGuard(remaining, true);
|
||||
return disarmGuard;
|
||||
}, [expanded, isSmallScreen, prefersReducedMotion, paneRef, armGuard, disarmGuard]);
|
||||
|
||||
/** Every slide that starts while the committed state is closed: an open,
|
||||
* before its deferred flip lands, and a close whose open never committed at
|
||||
* all (a second toggle retargeting inside that window, or an open drag that
|
||||
* falls short). Neither reaches the effect above, and on the default
|
||||
* configuration nothing else covers the pane while the drawer travels over
|
||||
* it. A committed close still arms above, where the deadline is what is
|
||||
* left of the motion rather than all of it. */
|
||||
useEffect(() => {
|
||||
if (!isSmallScreen || prefersReducedMotion) {
|
||||
return;
|
||||
}
|
||||
setDrawerSlideListener((next) => {
|
||||
if (expandedRef.current) {
|
||||
return;
|
||||
}
|
||||
/** Only a close strands focus: an open hands it to the drawer's header. */
|
||||
armGuard(TRANSITION_MS, !next);
|
||||
});
|
||||
return () => {
|
||||
setDrawerSlideListener(null);
|
||||
disarmGuard();
|
||||
};
|
||||
}, [isSmallScreen, prefersReducedMotion, armGuard, disarmGuard]);
|
||||
|
||||
/** Keyed off the release rather than the close, so a guard cancelled by a
|
||||
* dependency change hands focus over too: its timer is gone, and the effect
|
||||
* above has already recorded the close it will never rerun for. */
|
||||
useLayoutEffect(() => {
|
||||
if (isSliding || !handoffPendingRef.current) {
|
||||
return;
|
||||
}
|
||||
handoffPendingRef.current = false;
|
||||
restoreDrawerFocus(paneRef.current);
|
||||
}, [isSliding, paneRef]);
|
||||
|
||||
const onScrimClick = useCallback(
|
||||
(event: MouseEvent<HTMLElement>) => {
|
||||
/** The scrim goes aria-hidden on close, so focus cannot stay here; the
|
||||
* effect above hands it on once the pane has shed `inert`. */
|
||||
event.currentTarget.blur();
|
||||
/** Still the pointer target through the guard, where closing again would
|
||||
* be a no-op that never reaches that effect. */
|
||||
if (!expanded) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
},
|
||||
[expanded, setOpen],
|
||||
);
|
||||
|
||||
return { isSliding, onScrimClick };
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the close is still moving, or null when nothing is. Crossing the
|
||||
* breakpoint derives the drawer closed with nothing to animate, and both
|
||||
* surfaces snap under reduced motion; arming anyway would leave a transparent
|
||||
* full-screen scrim swallowing taps for the length of the guard.
|
||||
*/
|
||||
function closeGuardDuration({
|
||||
wasSmallScreen,
|
||||
prefersReducedMotion,
|
||||
pane,
|
||||
}: {
|
||||
wasSmallScreen: boolean;
|
||||
prefersReducedMotion: boolean;
|
||||
pane: HTMLElement | null;
|
||||
}): number | null {
|
||||
if (!wasSmallScreen || prefersReducedMotion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The guard exists to cover anything still moving. The reveal close
|
||||
* repositions the pane instantly (`transition: none`) while the drawer keeps
|
||||
* sliding, and a swipe animates the pane at any width, so asking only one
|
||||
* surface misses a path. */
|
||||
const paneTransition = pane?.style.transition ?? '';
|
||||
const drawerTransition = document.getElementById(MOBILE_DRAWER_ID)?.style.transition ?? '';
|
||||
const stillMoving = (transition: string) => transition !== '' && transition !== 'none';
|
||||
if (!stillMoving(paneTransition) && !stillMoving(drawerTransition)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The slide started at kick time. A delayed Recoil commit must not add a
|
||||
* fresh TRANSITION_MS after the compositor has already settled. */
|
||||
const startedAt = getDrawerAnimationStartedAt();
|
||||
const remaining =
|
||||
startedAt == null
|
||||
? TRANSITION_MS
|
||||
: Math.max(0, TRANSITION_MS - (performance.now() - startedAt));
|
||||
return remaining === 0 ? null : remaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawer goes `inert` and the scrim `aria-hidden`, so whichever control
|
||||
* closed the drawer stops being focusable. Inert drops focus to the body on its
|
||||
* own; `aria-hidden` does not, which is what Escape on a focused scrim leaves
|
||||
* behind. Anything that took focus deliberately, a composer autofocused after
|
||||
* picking a conversation, keeps it.
|
||||
*/
|
||||
function restoreDrawerFocus(pane: HTMLElement | null): void {
|
||||
const active = document.activeElement;
|
||||
const lost =
|
||||
!(active instanceof HTMLElement) ||
|
||||
active === document.body ||
|
||||
active.closest('[inert]') != null ||
|
||||
active.closest('[aria-hidden="true"]') != null;
|
||||
if (!lost) {
|
||||
return;
|
||||
}
|
||||
|
||||
const opener = document.getElementById(OPEN_SIDEBAR_ID);
|
||||
if (opener != null) {
|
||||
opener.focus();
|
||||
/** It stays mounted across breakpoints but is hidden on desktop, where the
|
||||
* focus simply does not take, so confirm it rather than assume it. */
|
||||
if (document.activeElement === opener) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Routes that return a loading or error state before rendering their header
|
||||
* have no opener, while the shortcut and the pane swipe still open the
|
||||
* drawer, so fall back to the pane rather than leaving focus on the body. */
|
||||
pane?.focus();
|
||||
}
|
||||
|
|
@ -2,6 +2,10 @@ import { useEffect, useRef } from 'react';
|
|||
import {
|
||||
TRANSITION_MS,
|
||||
MOBILE_DRAWER_ID,
|
||||
MOBILE_DRAWER_WIDTH,
|
||||
MOBILE_DRAWER_TRANSITION,
|
||||
MOBILE_PANE_SHIFT,
|
||||
MOBILE_SCRIM_ID,
|
||||
SIDEBAR_TRANSITION,
|
||||
} from '~/components/UnifiedSidebar/constants';
|
||||
|
||||
|
|
@ -20,6 +24,47 @@ const VELOCITY_HOLD_MS = 100;
|
|||
/** Inline styles are cleared this long after TRANSITION_MS, then classes own the state. */
|
||||
const SETTLE_BUFFER_MS = 80;
|
||||
|
||||
/**
|
||||
* Whether the drawer hides the pane entirely, which is what makes repositioning
|
||||
* the pane instantly invisible. A geometric fact rather than a reading of the
|
||||
* setting: the reveal is safe exactly when there is nothing uncovered to see it
|
||||
* in, however the width was arrived at.
|
||||
*/
|
||||
const drawerCoversPane = (drawer: HTMLElement): boolean =>
|
||||
(drawer.clientWidth || window.innerWidth) >= window.innerWidth;
|
||||
|
||||
/** Same kick as the drawer/pane transforms: the scrim must not wait for the
|
||||
* Recoil commit, which a large conversation can delay past the slide. */
|
||||
const setScrimOpacity = (open: boolean) => {
|
||||
const scrim = document.getElementById(MOBILE_SCRIM_ID);
|
||||
if (scrim == null) {
|
||||
return;
|
||||
}
|
||||
scrim.style.opacity = open ? '1' : '0';
|
||||
/** Recoil still has expanded=false during an open kick, so the class
|
||||
* pointer-events-none would leave the fading-in scrim click-through. Every
|
||||
* close hands capture back to the expanded/isSliding classes, which hold it
|
||||
* for exactly the slide; keeping the override would outlive them by the
|
||||
* settle buffer and swallow taps on the strip. */
|
||||
scrim.style.pointerEvents = open ? 'auto' : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* A close whose open never committed — a second toggle retargets the slide
|
||||
* inside the deferred flip, or an open drag falls short and snaps back — moves
|
||||
* the pane without `expanded` ever changing, so the dismiss guard has no state
|
||||
* transition to arm from. The slide reports itself here instead.
|
||||
*/
|
||||
let slideListener: ((next: boolean) => void) | null = null;
|
||||
|
||||
export function setDrawerSlideListener(listener: ((next: boolean) => void) | null): void {
|
||||
slideListener = listener;
|
||||
}
|
||||
|
||||
export function notifyDrawerSlide(next: boolean): void {
|
||||
slideListener?.(next);
|
||||
}
|
||||
|
||||
/** Surfaces where a horizontal drag means selection or caret work, never navigation. */
|
||||
const TEXT_SURFACE_SELECTOR = 'textarea, input, select, [contenteditable="true"]';
|
||||
|
||||
|
|
@ -39,6 +84,18 @@ export function getPendingDrawerFlip(): boolean | null {
|
|||
return pendingFlipTarget;
|
||||
}
|
||||
|
||||
/** Compositor clock for the in-flight slide, so the close guard can expire
|
||||
* at the animation deadline rather than TRANSITION_MS after a delayed commit. */
|
||||
let animationStartedAt: number | null = null;
|
||||
|
||||
export function markDrawerAnimationStart(at: number | null = performance.now()): void {
|
||||
animationStartedAt = at;
|
||||
}
|
||||
|
||||
export function getDrawerAnimationStartedAt(): number | null {
|
||||
return animationStartedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the drawer/pane slide imperatively and owns WHEN the caller's state
|
||||
* flip runs. Wrapping the flip in `startTransition` is not enough: Recoil
|
||||
|
|
@ -165,17 +222,38 @@ type Gesture = {
|
|||
rafScheduled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the two surfaces settle back to. Nothing under reduced motion, where
|
||||
* every drawer move snaps: handing an animating value back would leave the
|
||||
* element ready to ease the next change, and the strip setting changes the
|
||||
* drawer's width without passing through the snap path at all.
|
||||
*/
|
||||
const settledTransitions = (): { drawer: string; pane: string } =>
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
? { drawer: 'none', pane: 'none' }
|
||||
: { drawer: MOBILE_DRAWER_TRANSITION, pane: SIDEBAR_TRANSITION };
|
||||
|
||||
/** Returns every transient inline property to what React/classes render for
|
||||
* `paneOpen`. The drawer's transform is class-driven so clearing suffices, but
|
||||
* the pane's is a React style prop React will NOT re-assert while its value is
|
||||
* unchanged — an open pane must get its committed transform back explicitly. */
|
||||
const releaseInlineStyles = (drawer: HTMLElement, pane: HTMLElement, paneOpen: boolean) => {
|
||||
const settled = settledTransitions();
|
||||
drawer.style.transform = '';
|
||||
drawer.style.willChange = '';
|
||||
drawer.style.transition = SIDEBAR_TRANSITION;
|
||||
pane.style.transform = paneOpen ? 'translateX(100%)' : '';
|
||||
/** The close pins a measured width, and clearing would drop the declarative
|
||||
* value with it: React will not re-assert a style prop whose value it has
|
||||
* not changed. */
|
||||
drawer.style.width = MOBILE_DRAWER_WIDTH;
|
||||
drawer.style.transition = settled.drawer;
|
||||
pane.style.transform = paneOpen ? MOBILE_PANE_SHIFT : '';
|
||||
pane.style.willChange = '';
|
||||
pane.style.transition = SIDEBAR_TRANSITION;
|
||||
pane.style.transition = settled.pane;
|
||||
markDrawerAnimationStart(null);
|
||||
const scrim = document.getElementById(MOBILE_SCRIM_ID);
|
||||
if (scrim != null) {
|
||||
scrim.style.pointerEvents = '';
|
||||
}
|
||||
};
|
||||
|
||||
const dragTransforms = (gesture: Gesture): { drawer: string; pane: string } => {
|
||||
|
|
@ -290,18 +368,22 @@ export default function useDrawerSwipe({
|
|||
onOpenChangeRef.current(next);
|
||||
const id = setTimeout(() => {
|
||||
settleRef.current = null;
|
||||
gesture.drawer.style.transition = SIDEBAR_TRANSITION;
|
||||
gesture.pane.style.transition = SIDEBAR_TRANSITION;
|
||||
const settled = settledTransitions();
|
||||
gesture.drawer.style.transition = settled.drawer;
|
||||
gesture.pane.style.transition = settled.pane;
|
||||
}, SETTLE_BUFFER_MS);
|
||||
settleRef.current = { id, target: next, drawer: gesture.drawer, pane: gesture.pane };
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { drawer: drawerEl, pane: paneEl } = gesture;
|
||||
drawerEl.style.transition = SIDEBAR_TRANSITION;
|
||||
drawerEl.style.transition = MOBILE_DRAWER_TRANSITION;
|
||||
paneEl.style.transition = SIDEBAR_TRANSITION;
|
||||
drawerEl.style.transform = next ? 'translate3d(0, 0, 0)' : 'translate3d(-100%, 0, 0)';
|
||||
paneEl.style.transform = next ? 'translateX(100%)' : 'translate3d(0, 0, 0)';
|
||||
paneEl.style.transform = next ? MOBILE_PANE_SHIFT : 'translate3d(0, 0, 0)';
|
||||
markDrawerAnimationStart();
|
||||
notifyDrawerSlide(next);
|
||||
setScrimOpacity(next);
|
||||
if (next !== open) {
|
||||
onOpenChangeRef.current(next);
|
||||
}
|
||||
|
|
@ -336,8 +418,9 @@ export default function useDrawerSwipe({
|
|||
pane.style.transition = 'none';
|
||||
const id = setTimeout(() => {
|
||||
settleRef.current = null;
|
||||
drawer.style.transition = SIDEBAR_TRANSITION;
|
||||
pane.style.transition = SIDEBAR_TRANSITION;
|
||||
const settled = settledTransitions();
|
||||
drawer.style.transition = settled.drawer;
|
||||
pane.style.transition = settled.pane;
|
||||
}, SETTLE_BUFFER_MS);
|
||||
settleRef.current = { id, target: next, drawer, pane };
|
||||
return;
|
||||
|
|
@ -367,22 +450,37 @@ export default function useDrawerSwipe({
|
|||
if (armed == null || armed.target !== next || armed.id != null) {
|
||||
return;
|
||||
}
|
||||
drawer.style.transition = SIDEBAR_TRANSITION;
|
||||
drawer.style.transition = MOBILE_DRAWER_TRANSITION;
|
||||
/** A width transition still in flight (the strip setting changing, or
|
||||
* a rotation re-resolving the percentage) runs on its own clock, so
|
||||
* the drawer's edge and the pane's would separate for the rest of it.
|
||||
* Pinning the measured width settles the drawer onto this slide's
|
||||
* clock; the opening kick hands the property back. */
|
||||
drawer.style.width = next
|
||||
? MOBILE_DRAWER_WIDTH
|
||||
: `${drawer.getBoundingClientRect().width || window.innerWidth}px`;
|
||||
drawer.style.transform = next ? 'translate3d(0, 0, 0)' : 'translate3d(-100%, 0, 0)';
|
||||
markDrawerAnimationStart();
|
||||
notifyDrawerSlide(next);
|
||||
setScrimOpacity(next);
|
||||
if (next) {
|
||||
pane.style.transition = SIDEBAR_TRANSITION;
|
||||
pane.style.transform = 'translateX(100%)';
|
||||
} else {
|
||||
/** A programmatic close is a REVEAL: the pane repositions
|
||||
* instantly beneath the opaque drawer's cover and only the
|
||||
* drawer slides away, uncovering content already in place.
|
||||
* Animating the pane in from the right made every
|
||||
* tap-to-navigate close visibly shift the chat leftward while
|
||||
* the new conversation committed into the moving layer
|
||||
* mid-slide. The gesture keeps the paired both-move motion in
|
||||
* `settle` — there a finger drags both surfaces. */
|
||||
pane.style.transform = MOBILE_PANE_SHIFT;
|
||||
} else if (drawerCoversPane(drawer)) {
|
||||
/** A programmatic close is a REVEAL while the drawer is full width:
|
||||
* the pane repositions instantly beneath the opaque cover and only
|
||||
* the drawer slides away, uncovering content already in place.
|
||||
* Animating the pane in from the right makes every tap-to-navigate
|
||||
* close visibly shift the chat leftward while the new conversation
|
||||
* commits into the moving layer mid-slide. */
|
||||
pane.style.transition = 'none';
|
||||
pane.style.transform = 'none';
|
||||
} else {
|
||||
/** With the strip enabled that jump would land in the visible slice,
|
||||
* so both surfaces animate together, which is the motion the drag
|
||||
* path already produces in `settle`. */
|
||||
pane.style.transition = SIDEBAR_TRANSITION;
|
||||
pane.style.transform = 'translate3d(0, 0, 0)';
|
||||
}
|
||||
void drawer.getBoundingClientRect();
|
||||
armed.id = setTimeout(() => {
|
||||
|
|
@ -473,6 +571,12 @@ export default function useDrawerSwipe({
|
|||
gesture.pane.style.transition = 'none';
|
||||
gesture.drawer.style.willChange = 'transform';
|
||||
gesture.pane.style.willChange = 'transform';
|
||||
/** Dropping the transition lands a width still easing toward the
|
||||
* strip target on that target in this frame, so the touchstart
|
||||
* snapshot is now stale — and the paired transforms below read it
|
||||
* for both surfaces, which would hold a gap open for the whole
|
||||
* drag. */
|
||||
gesture.width = gesture.drawer.clientWidth || window.innerWidth;
|
||||
}
|
||||
} else if (Math.abs(dy) >= ACTIVATION_DISTANCE) {
|
||||
gesture.phase = 'dead';
|
||||
|
|
|
|||
|
|
@ -2410,5 +2410,7 @@
|
|||
"com_insights_start_date": "Start date",
|
||||
"com_insights_end_date": "End date",
|
||||
"com_insights_invalid_date_range": "Choose an end date after the start date, within {{days}} days.",
|
||||
"com_insights_sparkline_accessibility": "{{label}} over time"
|
||||
"com_insights_sparkline_accessibility": "{{label}} over time",
|
||||
"com_nav_mobile_drawer_strip": "Show chat beside the sidebar on mobile",
|
||||
"com_nav_mobile_drawer_strip_info": "The sidebar stops short of the edge so a strip of the conversation stays visible. Tapping it closes the sidebar."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
import { useState, useEffect, useRef, useCallback, startTransition } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useMediaQuery } from '@librechat/client';
|
||||
import {
|
||||
UnifiedSidebar,
|
||||
SIDEBAR_TRANSITION,
|
||||
MOBILE_DRAWER_WIDTH_VAR,
|
||||
MOBILE_DRAWER_STRIP_WIDTH,
|
||||
MOBILE_DRAWER_FULL_WIDTH,
|
||||
MOBILE_PANE_SHIFT,
|
||||
} from '~/components/UnifiedSidebar';
|
||||
import {
|
||||
PromptGroupsProvider,
|
||||
AssistantsMapContext,
|
||||
|
|
@ -15,16 +25,19 @@ import {
|
|||
useAgentsMap,
|
||||
useFileMap,
|
||||
} from '~/hooks';
|
||||
import { UnifiedSidebar, SIDEBAR_TRANSITION } from '~/components/UnifiedSidebar';
|
||||
import KeyboardShortcutsDialog from '~/components/Nav/KeyboardShortcutsDialog';
|
||||
import KeyboardDeleteDialog from '~/components/Nav/KeyboardDeleteDialog';
|
||||
import { useUserTermsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import { MobileDrawerScrim } from '~/components/UnifiedSidebar/mobile';
|
||||
import useKeyboardShortcuts from '~/hooks/useKeyboardShortcuts';
|
||||
import useDrawerDismiss from '~/hooks/Nav/useDrawerDismiss';
|
||||
import useSidebarToggle from '~/hooks/Nav/useSidebarToggle';
|
||||
import useSidebarState from '~/hooks/Nav/useSidebarState';
|
||||
import { TermsAndConditionsModal } from '~/components/ui';
|
||||
import useDrawerSwipe from '~/hooks/Nav/useDrawerSwipe';
|
||||
import { useHealthCheck } from '~/data-provider';
|
||||
import { Banner } from '~/components/Banners';
|
||||
import store from '~/store';
|
||||
|
||||
/** Isolates keyboard shortcut listeners so they only mount after auth. */
|
||||
function KeyboardShortcutsProvider() {
|
||||
|
|
@ -46,7 +59,26 @@ export default function Root() {
|
|||
expanded: sidebarExpanded,
|
||||
setExpanded: setSidebarExpanded,
|
||||
} = useSidebarState();
|
||||
/** The one path drawer mutations take: it kicks the slide imperatively and
|
||||
* defers the Recoil flip, so a large conversation cannot stall first motion. */
|
||||
const { setSidebarOpen } = useSidebarToggle();
|
||||
/** The drawer and pane snap under reduced motion (see kickDrawerAnimation),
|
||||
* so the scrim must not keep fading on its own. */
|
||||
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
|
||||
/** Off by default, matching the drawer that covers the screen and closes by
|
||||
* swipe. Opting in narrows it and gives the strip a dismiss target. */
|
||||
const drawerStrip = useRecoilValue(store.mobileDrawerStrip);
|
||||
const paneRef = useRef<HTMLDivElement>(null);
|
||||
/** Keyed off the committed state rather than the scrim's own click, because
|
||||
* the header button, Escape, conversation selection and the bottom bar all
|
||||
* close the drawer too. */
|
||||
const { isSliding, onScrimClick } = useDrawerDismiss({
|
||||
expanded: sidebarExpanded,
|
||||
isSmallScreen,
|
||||
prefersReducedMotion,
|
||||
paneRef,
|
||||
setOpen: setSidebarOpen,
|
||||
});
|
||||
/** Focus handoff lives in the drawer header's own expanded-effect — the
|
||||
* commit drives it, so every opener (button, swipe) is covered without a
|
||||
* timer racing the deferred state flip. */
|
||||
|
|
@ -112,20 +144,54 @@ export default function Root() {
|
|||
<PromptGroupsProvider>
|
||||
<Banner onHeightChange={setBannerHeight} />
|
||||
<div className="flex" style={{ height: `calc(100dvh - ${bannerHeight}px)` }}>
|
||||
<div className="relative z-0 flex h-full w-full overflow-hidden">
|
||||
<div
|
||||
className="relative z-0 flex h-full w-full overflow-hidden"
|
||||
/** The drawer and the pane both read this, so their travel
|
||||
* cannot disagree about how far the drawer opens. */
|
||||
style={
|
||||
{
|
||||
[MOBILE_DRAWER_WIDTH_VAR]: drawerStrip
|
||||
? MOBILE_DRAWER_STRIP_WIDTH
|
||||
: MOBILE_DRAWER_FULL_WIDTH,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<UnifiedSidebar />
|
||||
<div
|
||||
ref={paneRef}
|
||||
className="relative flex h-full max-w-full flex-1 flex-col overflow-hidden"
|
||||
/** Focus target of last resort when the drawer closes on a
|
||||
* route that renders no opener. Not in the tab order. */
|
||||
tabIndex={-1}
|
||||
className="relative flex h-full max-w-full flex-1 flex-col overflow-hidden focus:outline-none"
|
||||
style={{
|
||||
/** Self-referential, so it needs no width literal and survives rotation. */
|
||||
transform: isSmallScreen && sidebarExpanded ? 'translateX(100%)' : 'none',
|
||||
transition: SIDEBAR_TRANSITION,
|
||||
/** A percentage of the pane's own width, so it tracks the
|
||||
* drawer without a literal and survives rotation. */
|
||||
transform: isSmallScreen && sidebarExpanded ? MOBILE_PANE_SHIFT : 'none',
|
||||
transition: prefersReducedMotion ? undefined : SIDEBAR_TRANSITION,
|
||||
}}
|
||||
inert={isSmallScreen && sidebarExpanded ? '' : undefined}
|
||||
/** Recoil's flip is deferred past the opening frames and
|
||||
* the closing transition outlives it at the other end, so
|
||||
* `isSliding` covers the travel `sidebarExpanded` brackets
|
||||
* too late and drops too early. */
|
||||
inert={isSmallScreen && (sidebarExpanded || isSliding) ? '' : undefined}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
{/* Without the strip the scrim exists only for the travel:
|
||||
through a close that began while the strip was still on
|
||||
(disabling it unmounts the scrim at once, but the drawer
|
||||
needs the whole transition to widen), and through an open
|
||||
the deferred flip has not committed yet. Once expanded
|
||||
lands, a full-width drawer covers it, so keeping it
|
||||
mounted would only expose a duplicate dismiss control. */}
|
||||
{isSmallScreen && (drawerStrip || (isSliding && !sidebarExpanded)) && (
|
||||
<MobileDrawerScrim
|
||||
expanded={sidebarExpanded}
|
||||
isSliding={isSliding}
|
||||
prefersReducedMotion={prefersReducedMotion}
|
||||
onClick={onScrimClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PromptGroupsProvider>
|
||||
|
|
|
|||
|
|
@ -102,6 +102,12 @@ const localStorageAtoms = {
|
|||
modularChat: atomWithLocalStorage('modularChat', true),
|
||||
LaTeXParsing: atomWithLocalStorage('LaTeXParsing', true),
|
||||
centerFormOnLanding: atomWithLocalStorage('centerFormOnLanding', true),
|
||||
/**
|
||||
* Whether the mobile drawer stops short of the edge, leaving a strip of the
|
||||
* conversation visible that also closes it when tapped. Off by default: the
|
||||
* drawer covers the screen and the swipe closes it.
|
||||
*/
|
||||
mobileDrawerStrip: atomWithLocalStorage('mobileDrawerStrip', false),
|
||||
showFooter: atomWithLocalStorage('showFooter', true),
|
||||
|
||||
// Commands settings
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue