-
-
Notifications
You must be signed in to change notification settings - Fork 52
Drawer POC #1145
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Drawer POC #1145
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
'use client'; | ||
|
||
import DrawerRoot from './fragments/DrawerRoot'; | ||
import DrawerTrigger from './fragments/DrawerTrigger'; | ||
import DrawerPortal from './fragments/DrawerPortal'; | ||
import DrawerOverlay from './fragments/DrawerOverlay'; | ||
import DrawerContent from './fragments/DrawerContent'; | ||
import DrawerTitle from './fragments/DrawerTitle'; | ||
import DrawerDescription from './fragments/DrawerDescription'; | ||
import DrawerClose from './fragments/DrawerClose'; | ||
|
||
const Drawer = () => { | ||
console.warn('Direct usage of Drawer is not supported. Please use Drawer.Root, Drawer.Content, etc. instead.'); | ||
return null; | ||
}; | ||
|
||
Drawer.Root = DrawerRoot; | ||
Drawer.Trigger = DrawerTrigger; | ||
Drawer.Portal = DrawerPortal; | ||
Drawer.Overlay = DrawerOverlay; | ||
Drawer.Content = DrawerContent; | ||
Drawer.Title = DrawerTitle; | ||
Drawer.Description = DrawerDescription; | ||
Drawer.Close = DrawerClose; | ||
|
||
export default Drawer; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
import { createContext, useContext } from 'react'; | ||
|
||
type DrawerContextType = { | ||
rootClass: string; | ||
side: 'top' | 'right' | 'bottom' | 'left'; | ||
transitionDuration: number; | ||
transitionTimingFunction: string; | ||
isOpen: boolean; | ||
handleOverlayClick: () => void; | ||
nestingLevel: number; | ||
zIndex: number; | ||
dragProgress: number; | ||
handleDragProgress: (progress: number) => void; | ||
handleDragEnd: (finalProgress: number) => void; | ||
}; | ||
|
||
export const DrawerContext = createContext<DrawerContextType>({ | ||
rootClass: '', | ||
side: 'bottom', | ||
transitionDuration: 350, | ||
transitionTimingFunction: 'cubic-bezier(0.32, 0.72, 0, 1)', | ||
isOpen: false, | ||
handleOverlayClick: () => {}, | ||
nestingLevel: 0, | ||
zIndex: 50, | ||
dragProgress: 0, | ||
handleDragProgress: () => {}, | ||
handleDragEnd: () => {} | ||
}); | ||
|
||
// Hook to get the current nesting level from parent drawer contexts | ||
export const useDrawerNesting = () => { | ||
try { | ||
const parentContext = useContext(DrawerContext); | ||
// Check if we're actually in a parent drawer context (not the default) | ||
if (parentContext && parentContext.rootClass && parentContext.rootClass !== '') { | ||
return { | ||
nestingLevel: parentContext.nestingLevel + 1, | ||
zIndex: parentContext.zIndex + 10 | ||
}; | ||
} | ||
return { | ||
nestingLevel: 0, | ||
zIndex: 50 | ||
}; | ||
} catch { | ||
return { | ||
nestingLevel: 0, | ||
zIndex: 50 | ||
}; | ||
} | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
'use client'; | ||
import React, { useContext } from 'react'; | ||
import { DrawerContext } from '../context/DrawerContext'; | ||
import { clsx } from 'clsx'; | ||
import DialogPrimitive from '~/core/primitives/Dialog'; | ||
|
||
export type DrawerCloseProps = { | ||
children: React.ReactNode; | ||
className?: string; | ||
asChild?: boolean; | ||
} | ||
|
||
const DrawerClose = ({ children, className = '', asChild, ...props }: DrawerCloseProps) => { | ||
const { rootClass } = useContext(DrawerContext); | ||
|
||
return ( | ||
<DialogPrimitive.Action | ||
className={clsx(`${rootClass}-close`, className)} | ||
asChild={asChild} | ||
{...props} | ||
> | ||
{children} | ||
</DialogPrimitive.Action> | ||
); | ||
}; | ||
|
||
export default DrawerClose; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,260 @@ | ||
'use client'; | ||
import React, { useContext, useState, useEffect, useRef } from 'react'; | ||
import { DrawerContext } from '../context/DrawerContext'; | ||
import { clsx } from 'clsx'; | ||
|
||
export type DrawerContentProps = { | ||
children: React.ReactNode; | ||
className?: string; | ||
} | ||
|
||
const DrawerContent = ({ children, className = '' } : DrawerContentProps) => { | ||
const { rootClass, side, isOpen, transitionDuration, transitionTimingFunction, zIndex, dragProgress, handleDragProgress, handleDragEnd } = useContext(DrawerContext); | ||
const [shouldRender, setShouldRender] = useState(isOpen); | ||
const [animationState, setAnimationState] = useState<'entering' | 'entered' | 'exiting' | 'exited'>(isOpen ? 'entered' : 'exited'); | ||
const [isDragging, setIsDragging] = useState(false); | ||
const timeoutRef = useRef<NodeJS.Timeout>(); | ||
const containerRef = useRef<HTMLDivElement>(null); | ||
|
||
// Handle animation states with improved timing | ||
useEffect(() => { | ||
if (timeoutRef.current) { | ||
clearTimeout(timeoutRef.current); | ||
} | ||
|
||
if (isOpen) { | ||
setShouldRender(true); | ||
setAnimationState('entering'); | ||
|
||
// Use double RAF for smoother animation timing, synchronized with overlay | ||
requestAnimationFrame(() => { | ||
requestAnimationFrame(() => { | ||
setAnimationState('entered'); | ||
}); | ||
}); | ||
} else { | ||
setAnimationState('exiting'); | ||
|
||
// Wait for exit animation to complete before unmounting | ||
timeoutRef.current = setTimeout(() => { | ||
setShouldRender(false); | ||
setAnimationState('exited'); | ||
}, transitionDuration); | ||
} | ||
|
||
return () => { | ||
if (timeoutRef.current) { | ||
clearTimeout(timeoutRef.current); | ||
} | ||
}; | ||
}, [isOpen, transitionDuration]); | ||
|
||
// Drag functionality | ||
const initialProgressRef = useRef(0); | ||
const dragThresholdRef = useRef(false); | ||
const DRAG_THRESHOLD = 10; // pixels | ||
|
||
const handleDragStart = (clientY: number, clientX: number) => { | ||
// Don't immediately set dragging - wait for threshold | ||
initialProgressRef.current = dragProgress; | ||
dragThresholdRef.current = false; | ||
}; | ||
|
||
const handleDragMove = (clientY: number, clientX: number, startY: number, startX: number) => { | ||
if (!containerRef.current) return; | ||
|
||
const deltaY = clientY - startY; | ||
const deltaX = clientX - startX; | ||
|
||
// Check if we've exceeded the drag threshold | ||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); | ||
if (!dragThresholdRef.current && distance > DRAG_THRESHOLD) { | ||
dragThresholdRef.current = true; | ||
setIsDragging(true); | ||
} | ||
|
||
// Only process drag if we've exceeded threshold | ||
if (!dragThresholdRef.current) return; | ||
|
||
let progress = initialProgressRef.current; // Start from initial progress | ||
|
||
switch (side) { | ||
case 'bottom': { | ||
// Dragging up closes, dragging down opens | ||
const bottomMovement = -deltaY / window.innerHeight; | ||
progress = Math.max(0, Math.min(1, initialProgressRef.current + bottomMovement)); | ||
break; | ||
} | ||
case 'top': { | ||
// Dragging down closes, dragging up opens | ||
const topMovement = deltaY / window.innerHeight; | ||
progress = Math.max(0, Math.min(1, initialProgressRef.current + topMovement)); | ||
break; | ||
} | ||
case 'right': { | ||
// Dragging left closes, dragging right opens | ||
const rightMovement = -deltaX / window.innerWidth; | ||
progress = Math.max(0, Math.min(1, initialProgressRef.current + rightMovement)); | ||
break; | ||
} | ||
case 'left': { | ||
// Dragging right closes, dragging left opens | ||
const leftMovement = deltaX / window.innerWidth; | ||
progress = Math.max(0, Math.min(1, initialProgressRef.current + leftMovement)); | ||
break; | ||
} | ||
} | ||
|
||
handleDragProgress(progress); | ||
}; | ||
|
||
const handleLocalDragEnd = () => { | ||
// Only handle drag end if we actually started dragging | ||
if (dragThresholdRef.current) { | ||
setIsDragging(false); | ||
// Snap to open or closed based on current progress | ||
const finalProgress = dragProgress > 0.5 ? 1 : 0; | ||
handleDragEnd(finalProgress); | ||
} | ||
dragThresholdRef.current = false; | ||
}; | ||
|
||
// Mouse events | ||
const handleMouseDown = (e: React.MouseEvent) => { | ||
// Don't prevent default immediately - let normal clicks work | ||
const startY = e.clientY; | ||
const startX = e.clientX; | ||
handleDragStart(startY, startX); | ||
|
||
const handleMouseMove = (e: MouseEvent) => { | ||
// Prevent default once we start dragging | ||
if (dragThresholdRef.current) { | ||
e.preventDefault(); | ||
} | ||
handleDragMove(e.clientY, e.clientX, startY, startX); | ||
}; | ||
|
||
const handleMouseUp = () => { | ||
handleLocalDragEnd(); | ||
document.removeEventListener('mousemove', handleMouseMove); | ||
document.removeEventListener('mouseup', handleMouseUp); | ||
}; | ||
|
||
document.addEventListener('mousemove', handleMouseMove); | ||
document.addEventListener('mouseup', handleMouseUp); | ||
}; | ||
|
||
// Touch events | ||
const handleTouchStart = (e: React.TouchEvent) => { | ||
const touch = e.touches[0]; | ||
const startY = touch.clientY; | ||
const startX = touch.clientX; | ||
handleDragStart(startY, startX); | ||
|
||
const handleTouchMove = (e: TouchEvent) => { | ||
// Only prevent default once we start dragging | ||
if (dragThresholdRef.current) { | ||
e.preventDefault(); | ||
} | ||
const touch = e.touches[0]; | ||
handleDragMove(touch.clientY, touch.clientX, startY, startX); | ||
}; | ||
|
||
const handleTouchEnd = () => { | ||
handleLocalDragEnd(); | ||
document.removeEventListener('touchmove', handleTouchMove); | ||
document.removeEventListener('touchend', handleTouchEnd); | ||
}; | ||
|
||
document.addEventListener('touchmove', handleTouchMove, { passive: false }); | ||
document.addEventListener('touchend', handleTouchEnd); | ||
}; | ||
|
||
// Style positioning based on side with enhanced animations | ||
const getDrawerStyles = () => { | ||
const baseStyles = { | ||
position: 'fixed' as const, | ||
zIndex, | ||
willChange: 'transform', | ||
transition: isDragging ? 'none' : `transform ${transitionDuration}ms ${transitionTimingFunction}`, | ||
backfaceVisibility: 'hidden' as const | ||
}; | ||
|
||
// Use drag progress if dragging or if dragProgress is not the default state | ||
// Otherwise use normal animation state | ||
const shouldUseDragProgress = isDragging || (dragProgress > 0 && dragProgress < 1); | ||
let translatePercent = 0; | ||
|
||
if (shouldUseDragProgress) { | ||
// Use drag progress to determine position (0 = closed, 1 = open) | ||
translatePercent = (1 - dragProgress) * 100; | ||
} else { | ||
// Use normal animation state | ||
const isVisible = animationState === 'entered'; | ||
translatePercent = isVisible ? 0 : 100; | ||
} | ||
|
||
switch (side) { | ||
case 'top': | ||
return { | ||
...baseStyles, | ||
top: 0, | ||
left: 0, | ||
right: 0, | ||
transform: `translate3d(0, -${translatePercent}%, 0)` | ||
}; | ||
case 'right': | ||
return { | ||
...baseStyles, | ||
top: 0, | ||
right: 0, | ||
bottom: 0, | ||
transform: `translate3d(${translatePercent}%, 0, 0)` | ||
}; | ||
case 'left': | ||
return { | ||
...baseStyles, | ||
top: 0, | ||
left: 0, | ||
bottom: 0, | ||
transform: `translate3d(-${translatePercent}%, 0, 0)` | ||
}; | ||
case 'bottom': | ||
default: | ||
return { | ||
...baseStyles, | ||
bottom: 0, | ||
left: 0, | ||
right: 0, | ||
transform: `translate3d(0, ${translatePercent}%, 0)` | ||
}; | ||
} | ||
}; | ||
|
||
// Don't render if not supposed to be visible | ||
if (!shouldRender) { | ||
return null; | ||
} | ||
|
||
return ( | ||
<div | ||
Check warning on line 240 in src/components/ui/Drawer/fragments/DrawerContent.tsx
|
||
ref={containerRef} | ||
style={getDrawerStyles()} | ||
className={clsx(`${rootClass}-content`, className)} | ||
role="dialog" | ||
aria-modal="true" | ||
data-state={isOpen ? 'open' : 'closed'} | ||
data-side={side} | ||
onMouseDown={handleMouseDown} | ||
onTouchStart={handleTouchStart} | ||
onClick={(event) => { | ||
// Prevent clicks on content from propagating to overlay | ||
event.stopPropagation(); | ||
}} | ||
Comment on lines
+240
to
+253
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Add keyboard support and improve accessibility. The drawer content has mouse/touch handlers but lacks keyboard support. Additionally, the non-interactive div with event handlers raises accessibility concerns. Consider these improvements:
<div
ref={containerRef}
style={getDrawerStyles()}
className={clsx(`${rootClass}-content`, className)}
role="dialog"
aria-modal="true"
+ aria-grabbed={isDragging}
+ tabIndex={0}
data-state={isOpen ? 'open' : 'closed'}
data-side={side}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
+ onKeyDown={handleKeyDown}
onClick={(event) => {
// Prevent clicks on content from propagating to overlay
event.stopPropagation();
}}
> Add keyboard handler: const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) return;
switch (e.key) {
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
e.preventDefault();
// Implement keyboard-based drag logic
break;
case 'Escape':
handleDragEnd(0);
break;
}
}; 🧰 Tools🪛 GitHub Check: lint[warning] 240-240: [warning] 240-240: 🤖 Prompt for AI Agents
|
||
> | ||
{children} | ||
</div> | ||
); | ||
}; | ||
|
||
export default DrawerContent; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Extract drag logic into a custom hook for better maintainability.
The drag functionality spans over 100 lines and makes the component complex. Consider extracting it into a reusable hook.
Create a custom hook:
Then simplify the component:
🤖 Prompt for AI Agents