Skip to content

Commit

Permalink
Merge pull request #84 from brainstormforce/component/dialog
Browse files Browse the repository at this point in the history
SUR-283 - Organism: Implement Dialog
  • Loading branch information
vrundakansara authored Sep 26, 2024
2 parents 80bc53d + 20b2498 commit b892bdb
Show file tree
Hide file tree
Showing 14 changed files with 810 additions and 6 deletions.
7 changes: 7 additions & 0 deletions .storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ const preview = {
},
},
},
decorators: [
(Story) => (
<div style={{ fontFamily: 'Figtree, sans-serif' }}>
<Story />
</div>
),
],
};

export default preview;
2 changes: 1 addition & 1 deletion dist/force-ui-rtl.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/force-ui.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom'), 'version' => 'db1bc2308688800294fc');
<?php return array('dependencies' => array('react', 'react-dom'), 'version' => 'ac9a2b72d6bd314ad79d');
2 changes: 1 addition & 1 deletion dist/force-ui.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/force-ui.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/utils/withTW.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array(), 'version' => 'f5765dea688f24fb260e');
<?php return array('dependencies' => array(), 'version' => 'f22b9f270d93a29717fd');
2 changes: 1 addition & 1 deletion dist/utils/withTW.js

Large diffs are not rendered by default.

324 changes: 324 additions & 0 deletions src/components/dialog/dialog.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
import {
cloneElement,
createContext,
Fragment,
isValidElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { callAll, cn } from '@/utilities/functions';
import { X } from 'lucide-react';

const DialogContext = createContext();
const useDialogState = () => useContext( DialogContext );

const animationVariants = {
open: {
opacity: 1,
transition: {
duration: 0.2,
},
},
exit: {
opacity: 0,
transition: {
duration: 0.2,
},
},
};

// Dialog component.
const Dialog = ( {
open,
setOpen,
children,
trigger,
className,
exitOnClickOutside = false,
exitOnEsc = true,
design = 'simple',
} ) => {
const isControlled = open !== undefined && setOpen !== undefined;
const [ isOpen, setIsOpen ] = useState( false );
const dialogRef = useRef( null );

const openState = useMemo(
() => ( isControlled ? open : isOpen ),
[ open, isOpen ]
);
const setOpenState = useMemo(
() => ( isControlled ? setOpen : setIsOpen ),
[ setIsOpen, setIsOpen ]
);

const handleOpen = () => {
if ( openState ) {
return;
}

setOpenState( true );
};

const handleClose = () => {
if ( ! openState ) {
return;
}

setOpenState( false );
};

const renderTrigger = useCallback( () => {
if ( isValidElement( trigger ) ) {
return cloneElement( trigger, {
onClick: callAll( handleOpen, trigger.props.onClick ),
} );
}

if ( typeof trigger === 'function' ) {
return trigger( { onClick: handleOpen } );
}

return null;
}, [ trigger, handleOpen, handleClose ] );

const handleKeyDown = ( event ) => {
switch ( event.key ) {
case 'Escape':
if ( exitOnEsc ) {
handleClose();
}
break;
default:
break;
}
};

const handleClickOutside = ( event ) => {
if (
exitOnClickOutside &&
dialogRef.current &&
! dialogRef.current.contains( event.target )
) {
handleClose();
}
};

useEffect( () => {
window.addEventListener( 'keydown', handleKeyDown );
document.addEventListener( 'mousedown', handleClickOutside );

return () => {
window.removeEventListener( 'keydown', handleKeyDown );
document.removeEventListener( 'mousedown', handleClickOutside );
};
}, [ openState ] );

// Prevent scrolling when dialog is open.
useEffect( () => {
if ( openState ) {
document.querySelector( 'html' ).style.overflow = 'hidden';
}

return () => {
document.querySelector( 'html' ).style.overflow = '';
};
}, [ openState ] );

return (
<>
{ renderTrigger() }
<DialogContext.Provider
value={ {
open: openState,
setOpen: setOpenState,
handleOpen,
handleClose,
exitOnClickOutside,
design,
} }
>
<AnimatePresence>
{ openState && (
<motion.div
className="fixed z-999999"
initial="exit"
animate="open"
exit="exit"
variants={ animationVariants }
role="dialog"
>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex items-center justify-center min-h-full">
<div
ref={ dialogRef }
className={ cn(
'flex flex-col gap-5 w-120 h-fit bg-background-primary border border-solid border-border-subtle rounded-xl shadow-soft-shadow-2xl my-5 overflow-hidden',
className
) }
>
{ typeof children === 'function'
? children( { close: handleClose } )
: children }
</div>
</div>
</div>
</motion.div>
) }
</AnimatePresence>
</DialogContext.Provider>
</>
);
};

// Backdrop for the dialog.
const DialogBackdrop = ( { className, ...props } ) => {
return (
<div
className={ cn(
'fixed inset-0 -z-10 bg-background-inverse/90 backdrop-blur-sm',
className
) }
{ ...props }
/>
);
};

// Dialog header wrapper.
const DialogHeader = ( { children, className, ...props } ) => {
return <div className={ cn( 'space-y-2 px-5 pt-5 pb-1', className ) } { ...props }>{ children }</div>;
};

// Dialog title.
const DialogTitle = ( { children, as: Tag = 'h3', className, ...props } ) => {
return (
<Tag
className={ cn(
'text-base font-semibold text-text-primary m-0 p-0',
className
) }
{ ...props }
>
{ children }
</Tag>
);
};

// Dialog description.
const DialogDescription = ( { children, as: Tag = 'p', className, ...props } ) => {
return (
<Tag
className={ cn(
'text-sm font-normal text-text-secondary my-0 ml-0 mr-1 p-0',
className
) }
{ ...props }
>
{ children }
</Tag>
);
};

// Default close button for the dialog.
const DefaultCloseButton = ( { className, ...props } ) => {
return (
<button
className={ cn(
'bg-transparent inline-flex justify-center items-center border-0 p-1 m-0 cursor-pointer focus:outline-none outline-none shadow-none',
className
) }
aria-label="Close dialog"
{ ...props }
>
<X className="size-4 text-text-primary shrink-0" />
</button>
);
};

// Close button for the dialog.
const DialogCloseButton = ( {
children,
as: Tag = Fragment,
...props
} ) => {
const { handleClose } = useDialogState();

if ( ! isValidElement( children ) || ! children ) {
return (
<DefaultCloseButton
onClick={ handleClose }
{ ...props }
/>
);
}

if ( Tag === Fragment ) {
if ( typeof children === 'function' ) {
return children( { close: handleClose } );
}

return cloneElement( children, {
onClick: handleClose,
} );
}

return (
<Tag { ...props } onClick={ handleClose }>
{ children }
</Tag>
);
};

// Dialog body.
const DialogBody = ( { children, className, ...props } ) => {
return (
<div className={ cn( 'px-5', className ) } { ...props }>
{ children }
</div>
);
};

// Dialog footer.
const DialogFooter = ( { children, className } ) => {
const { design, handleClose } = useDialogState();

const renderChildren = () => {
if ( ! children ) {
return null;
}

if ( typeof children === 'function' ) {
return children( { close: handleClose } );
}

return children;
};

return (
<div
className={ cn(
'p-4 flex justify-end gap-3',
{
'bg-background-secondary': design === 'footer-divided',
},
className
) }
>
{ renderChildren() }
</div>
);
};

export default Object.assign( Dialog, {
Backdrop: DialogBackdrop,
Title: DialogTitle,
Description: DialogDescription,
CloseButton: DialogCloseButton,
Header: DialogHeader,
Body: DialogBody,
Footer: DialogFooter,
} );
Loading

0 comments on commit b892bdb

Please sign in to comment.