Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/components/ui/Drawer/Drawer.tsx
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;
52 changes: 52 additions & 0 deletions src/components/ui/Drawer/context/DrawerContext.tsx
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
};
}
};
27 changes: 27 additions & 0 deletions src/components/ui/Drawer/fragments/DrawerClose.tsx
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;
260 changes: 260 additions & 0 deletions src/components/ui/Drawer/fragments/DrawerContent.tsx
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);
};
Comment on lines +52 to +171
Copy link
Contributor

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:

// useDraggableDrawer.ts
export const useDraggableDrawer = ({
    side,
    dragProgress,
    handleDragProgress,
    handleDragEnd,
}: UseDraggableDrawerProps) => {
    const [isDragging, setIsDragging] = useState(false);
    const initialProgressRef = useRef(0);
    const dragThresholdRef = useRef(false);
    
    // ... move all drag logic here ...
    
    return {
        isDragging,
        handleMouseDown,
        handleTouchStart,
    };
};

Then simplify the component:

const { isDragging, handleMouseDown, handleTouchStart } = useDraggableDrawer({
    side,
    dragProgress,
    handleDragProgress,
    handleDragEnd,
});
🤖 Prompt for AI Agents
In src/components/ui/Drawer/fragments/DrawerContent.tsx around lines 52 to 171,
the drag functionality code is lengthy and complex within the component. Extract
all drag-related state, refs, and handlers (handleDragStart, handleDragMove,
handleLocalDragEnd, handleMouseDown, handleTouchStart, and their inner event
handlers) into a new custom hook named useDraggableDrawer. This hook should
accept side, dragProgress, handleDragProgress, and handleDragEnd as parameters,
manage isDragging state internally, and return isDragging along with
handleMouseDown and handleTouchStart functions. Then, replace the existing drag
logic in the component with calls to this hook to simplify and improve
maintainability.


// 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

View workflow job for this annotation

GitHub Actions / lint

Non-interactive elements should not be assigned mouse or keyboard event listeners

Check warning on line 240 in src/components/ui/Drawer/fragments/DrawerContent.tsx

View workflow job for this annotation

GitHub Actions / lint

Visible, non-interactive elements with click handlers must have at least one keyboard listener
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

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:

  1. Add keyboard event handlers for arrow keys to enable keyboard-based dragging
  2. Add appropriate ARIA attributes for draggable content
  3. Make the element focusable when draggable
 <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:
Non-interactive elements should not be assigned mouse or keyboard event listeners


[warning] 240-240:
Visible, non-interactive elements with click handlers must have at least one keyboard listener

🤖 Prompt for AI Agents
In src/components/ui/Drawer/fragments/DrawerContent.tsx around lines 240 to 253,
the drawer content div has mouse and touch event handlers but lacks keyboard
support and accessibility features. To fix this, add a keyboard event handler
for arrow keys and Escape to enable keyboard-based dragging and closing. Make
the div focusable by adding a tabIndex attribute when draggable, and include
appropriate ARIA attributes such as aria-grabbed or aria-dropeffect to indicate
draggable content. Attach the new handleKeyDown function to the div's onKeyDown
event to handle keyboard interactions.

>
{children}
</div>
);
};

export default DrawerContent;
Loading
Loading