feat(ui): animate sidebar open/close
Sidebars now slide open/closed with a 200ms cubic-bezier(0.22, 1, 0.36, 1) transition on width. Inline transitionProperty/Duration/TimingFunction are used directly on the aside so the change is browser-driven rather than relying on Tailwind arbitrary classes that didn't reliably trigger the width transition. Inner sidebar content is pinned to the open width with shrink-0 so it stays full-size while the outer aside collapses around it, eliminating the vertical jitter that came from content reflowing when its container shrank. The chat-frame keeps a constant 1px border and 10px radius on all four sides — only the left/right border color toggles between border/50 and transparent — so no layout reflow happens when sidebars open. Mask-corner overlays are always mounted and animate their left/right offset and opacity in lockstep with the sidebars instead of mounting/unmounting.
This commit is contained in:
@@ -606,58 +606,54 @@ export const MainLayout: React.FC = () => {
|
|||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="relative flex flex-1 min-h-0 overflow-hidden bg-sidebar" data-page-scroll-lock="true">
|
<div className="relative flex flex-1 min-h-0 overflow-hidden bg-sidebar" data-page-scroll-lock="true">
|
||||||
{isSidebarOpen ? (
|
<div
|
||||||
<>
|
aria-hidden
|
||||||
<div
|
className="pointer-events-none absolute top-0 z-0 bg-sidebar transition-[left,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
||||||
aria-hidden
|
style={{
|
||||||
className="pointer-events-none absolute top-0 z-0 bg-sidebar"
|
left: `${isSidebarOpen ? visibleSidebarWidth : 0}px`,
|
||||||
style={{
|
opacity: isSidebarOpen ? 1 : 0,
|
||||||
left: `${visibleSidebarWidth}px`,
|
width: '10px',
|
||||||
width: '10px',
|
height: '10px',
|
||||||
height: '10px',
|
WebkitMaskImage: 'radial-gradient(circle at 100% 100%, transparent calc(10px - 1px), black 10px)',
|
||||||
WebkitMaskImage: 'radial-gradient(circle at 100% 100%, transparent calc(10px - 1px), black 10px)',
|
maskImage: 'radial-gradient(circle at 100% 100%, transparent calc(10px - 1px), black 10px)',
|
||||||
maskImage: 'radial-gradient(circle at 100% 100%, transparent calc(10px - 1px), black 10px)',
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<div
|
||||||
<div
|
aria-hidden
|
||||||
aria-hidden
|
className="pointer-events-none absolute bottom-0 z-0 bg-sidebar transition-[left,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
||||||
className="pointer-events-none absolute bottom-0 z-0 bg-sidebar"
|
style={{
|
||||||
style={{
|
left: `${isSidebarOpen ? visibleSidebarWidth : 0}px`,
|
||||||
left: `${visibleSidebarWidth}px`,
|
opacity: isSidebarOpen ? 1 : 0,
|
||||||
width: '10px',
|
width: '10px',
|
||||||
height: '10px',
|
height: '10px',
|
||||||
WebkitMaskImage: 'radial-gradient(circle at 100% 0%, transparent calc(10px - 1px), black 10px)',
|
WebkitMaskImage: 'radial-gradient(circle at 100% 0%, transparent calc(10px - 1px), black 10px)',
|
||||||
maskImage: 'radial-gradient(circle at 100% 0%, transparent calc(10px - 1px), black 10px)',
|
maskImage: 'radial-gradient(circle at 100% 0%, transparent calc(10px - 1px), black 10px)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
<div
|
||||||
) : null}
|
aria-hidden
|
||||||
{isRightSidebarOpen ? (
|
className="pointer-events-none absolute top-0 z-0 bg-sidebar transition-[right,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
||||||
<>
|
style={{
|
||||||
<div
|
right: `${isRightSidebarOpen ? visibleRightSidebarWidth : 0}px`,
|
||||||
aria-hidden
|
opacity: isRightSidebarOpen ? 1 : 0,
|
||||||
className="pointer-events-none absolute top-0 z-0 bg-sidebar"
|
width: '10px',
|
||||||
style={{
|
height: '10px',
|
||||||
right: `${visibleRightSidebarWidth}px`,
|
WebkitMaskImage: 'radial-gradient(circle at 0 100%, transparent calc(10px - 1px), black 10px)',
|
||||||
width: '10px',
|
maskImage: 'radial-gradient(circle at 0 100%, transparent calc(10px - 1px), black 10px)',
|
||||||
height: '10px',
|
}}
|
||||||
WebkitMaskImage: 'radial-gradient(circle at 0 100%, transparent calc(10px - 1px), black 10px)',
|
/>
|
||||||
maskImage: 'radial-gradient(circle at 0 100%, transparent calc(10px - 1px), black 10px)',
|
<div
|
||||||
}}
|
aria-hidden
|
||||||
/>
|
className="pointer-events-none absolute bottom-0 z-0 bg-sidebar transition-[right,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
||||||
<div
|
style={{
|
||||||
aria-hidden
|
right: `${isRightSidebarOpen ? visibleRightSidebarWidth : 0}px`,
|
||||||
className="pointer-events-none absolute bottom-0 z-0 bg-sidebar"
|
opacity: isRightSidebarOpen ? 1 : 0,
|
||||||
style={{
|
width: '10px',
|
||||||
right: `${visibleRightSidebarWidth}px`,
|
height: '10px',
|
||||||
width: '10px',
|
WebkitMaskImage: 'radial-gradient(circle at 0 0, transparent calc(10px - 1px), black 10px)',
|
||||||
height: '10px',
|
maskImage: 'radial-gradient(circle at 0 0, transparent calc(10px - 1px), black 10px)',
|
||||||
WebkitMaskImage: 'radial-gradient(circle at 0 0, transparent calc(10px - 1px), black 10px)',
|
}}
|
||||||
maskImage: 'radial-gradient(circle at 0 0, transparent calc(10px - 1px), black 10px)',
|
/>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<Sidebar
|
<Sidebar
|
||||||
isOpen={isSidebarOpen}
|
isOpen={isSidebarOpen}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -668,9 +664,9 @@ export const MainLayout: React.FC = () => {
|
|||||||
<div className={cn(
|
<div className={cn(
|
||||||
'relative flex flex-1 min-w-0 flex-col overflow-hidden',
|
'relative flex flex-1 min-w-0 flex-col overflow-hidden',
|
||||||
'bg-background',
|
'bg-background',
|
||||||
'border-y border-border/50',
|
'border border-border/50 rounded-[10px]',
|
||||||
isSidebarOpen && 'border-l border-border/50 rounded-tl-[10px] rounded-bl-[10px]',
|
!isSidebarOpen && 'border-l-transparent',
|
||||||
isRightSidebarOpen && 'border-r border-border/50 rounded-tr-[10px] rounded-br-[10px]'
|
!isRightSidebarOpen && 'border-r-transparent'
|
||||||
)} data-page-scroll-lock="true">
|
)} data-page-scroll-lock="true">
|
||||||
<div className="flex flex-1 min-h-0 overflow-hidden" data-page-scroll-lock="true">
|
<div className="flex flex-1 min-h-0 overflow-hidden" data-page-scroll-lock="true">
|
||||||
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true">
|
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true">
|
||||||
|
|||||||
@@ -34,12 +34,13 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children, cl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sidebar.style.setProperty('--oc-right-sidebar-width', `${nextWidth}px`);
|
sidebar.style.width = `${nextWidth}px`;
|
||||||
|
sidebar.style.minWidth = `${nextWidth}px`;
|
||||||
|
sidebar.style.maxWidth = `${nextWidth}px`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const appliedWidth = isOpen
|
const openWidth = Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || RIGHT_SIDEBAR_CONTENT_WIDTH));
|
||||||
? Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || RIGHT_SIDEBAR_CONTENT_WIDTH))
|
const appliedWidth = isOpen ? openWidth : 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
const handlePointerDown = (event: React.PointerEvent) => {
|
const handlePointerDown = (event: React.PointerEvent) => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -101,22 +102,25 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children, cl
|
|||||||
}
|
}
|
||||||
}, [isResizing]);
|
}, [isResizing]);
|
||||||
|
|
||||||
|
const currentWidth = isResizing ? (resizingWidthRef.current ?? appliedWidth) : appliedWidth;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex h-full overflow-hidden border-l border-border/40',
|
'relative flex h-full overflow-hidden border-l border-border/40 will-change-[width] motion-reduce:transition-none',
|
||||||
'bg-sidebar',
|
'bg-sidebar',
|
||||||
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
|
|
||||||
!isOpen && 'border-l-0',
|
!isOpen && 'border-l-0',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: 'var(--oc-right-sidebar-width)',
|
width: `${currentWidth}px`,
|
||||||
minWidth: 'var(--oc-right-sidebar-width)',
|
minWidth: `${currentWidth}px`,
|
||||||
maxWidth: 'var(--oc-right-sidebar-width)',
|
maxWidth: `${currentWidth}px`,
|
||||||
['--oc-right-sidebar-width' as string]: `${isResizing ? (resizingWidthRef.current ?? appliedWidth) : appliedWidth}px`,
|
|
||||||
overflowX: 'clip',
|
overflowX: 'clip',
|
||||||
|
transitionProperty: isResizing ? 'none' : 'width, min-width, max-width',
|
||||||
|
transitionDuration: '200ms',
|
||||||
|
transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||||
}}
|
}}
|
||||||
aria-hidden={!isOpen || appliedWidth === 0}
|
aria-hidden={!isOpen || appliedWidth === 0}
|
||||||
>
|
>
|
||||||
@@ -137,10 +141,11 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children, cl
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-10 flex h-full min-h-0 w-full flex-col transition-opacity duration-300 ease-in-out',
|
'relative z-10 flex h-full min-h-0 shrink-0 flex-col transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
|
||||||
isResizing && 'pointer-events-none',
|
isResizing && 'pointer-events-none',
|
||||||
!isOpen && 'pointer-events-none select-none opacity-0'
|
!isOpen && 'pointer-events-none select-none opacity-0'
|
||||||
)}
|
)}
|
||||||
|
style={{ width: `${openWidth}px` }}
|
||||||
aria-hidden={!isOpen}
|
aria-hidden={!isOpen}
|
||||||
>
|
>
|
||||||
{isOpen ? children : null}
|
{isOpen ? children : null}
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, cl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sidebar.style.setProperty('--oc-left-sidebar-width', `${nextWidth}px`);
|
sidebar.style.width = `${nextWidth}px`;
|
||||||
|
sidebar.style.minWidth = `${nextWidth}px`;
|
||||||
|
sidebar.style.maxWidth = `${nextWidth}px`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -56,10 +58,11 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, cl
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appliedWidth = isOpen ? Math.min(
|
const openWidth = Math.min(
|
||||||
SIDEBAR_MAX_WIDTH,
|
SIDEBAR_MAX_WIDTH,
|
||||||
Math.max(SIDEBAR_MIN_WIDTH, sidebarWidth || SIDEBAR_CONTENT_WIDTH)
|
Math.max(SIDEBAR_MIN_WIDTH, sidebarWidth || SIDEBAR_CONTENT_WIDTH)
|
||||||
) : 0;
|
);
|
||||||
|
const appliedWidth = isOpen ? openWidth : 0;
|
||||||
|
|
||||||
const handlePointerDown = (event: React.PointerEvent) => {
|
const handlePointerDown = (event: React.PointerEvent) => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -114,22 +117,25 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, cl
|
|||||||
setSidebarWidth(finalWidth);
|
setSidebarWidth(finalWidth);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const currentWidth = isResizing ? (resizingWidthRef.current ?? appliedWidth) : appliedWidth;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex h-full overflow-hidden border-r border-border/40',
|
'relative flex h-full overflow-hidden border-r border-border/40 will-change-[width] motion-reduce:transition-none',
|
||||||
'bg-sidebar',
|
'bg-sidebar',
|
||||||
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
|
|
||||||
!isOpen && 'border-r-0',
|
!isOpen && 'border-r-0',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: 'var(--oc-left-sidebar-width)',
|
width: `${currentWidth}px`,
|
||||||
minWidth: 'var(--oc-left-sidebar-width)',
|
minWidth: `${currentWidth}px`,
|
||||||
maxWidth: 'var(--oc-left-sidebar-width)',
|
maxWidth: `${currentWidth}px`,
|
||||||
['--oc-left-sidebar-width' as string]: `${isResizing ? (resizingWidthRef.current ?? appliedWidth) : appliedWidth}px`,
|
|
||||||
overflowX: 'clip',
|
overflowX: 'clip',
|
||||||
|
transitionProperty: isResizing ? 'none' : 'width, min-width, max-width',
|
||||||
|
transitionDuration: '200ms',
|
||||||
|
transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||||
}}
|
}}
|
||||||
aria-hidden={!isOpen || appliedWidth === 0}
|
aria-hidden={!isOpen || appliedWidth === 0}
|
||||||
>
|
>
|
||||||
@@ -150,11 +156,11 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, cl
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-10 flex h-full flex-col transition-opacity duration-300 ease-in-out',
|
'relative z-10 flex h-full shrink-0 flex-col transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
|
||||||
isResizing && 'pointer-events-none',
|
isResizing && 'pointer-events-none',
|
||||||
!isOpen && 'pointer-events-none select-none opacity-0'
|
!isOpen && 'pointer-events-none select-none opacity-0'
|
||||||
)}
|
)}
|
||||||
style={{ width: 'var(--oc-left-sidebar-width)', overflowX: 'hidden' }}
|
style={{ width: `${openWidth}px`, overflowX: 'hidden' }}
|
||||||
aria-hidden={!isOpen}
|
aria-hidden={!isOpen}
|
||||||
>
|
>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
|||||||
Reference in New Issue
Block a user