mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-18 06:54:15 +00:00
Add Drawer component, header, and custom hooks
This commit is contained in:
parent
582790ba88
commit
f17c56a551
148
packages/app/src/layouts/components/drawer/component.jsx
Normal file
148
packages/app/src/layouts/components/drawer/component.jsx
Normal file
@ -0,0 +1,148 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import classnames from "classnames"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import DrawerHeader from "./header"
|
||||
|
||||
const Drawer = React.memo(
|
||||
forwardRef(({ id, children, options = {}, controller }, ref) => {
|
||||
const [header, setHeader] = useState(options.header)
|
||||
|
||||
const {
|
||||
position = "left",
|
||||
style = {},
|
||||
props: componentProps = {},
|
||||
onDone,
|
||||
onFail,
|
||||
} = options
|
||||
|
||||
const setExtraActions = useCallback((actions) => {
|
||||
setHeader((prev) => ({ ...prev, actions: actions }))
|
||||
}, [])
|
||||
|
||||
const setDrawerHeader = useCallback((header) => {
|
||||
setHeader(header)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
if (typeof options.onClose === "function") {
|
||||
options.onClose()
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
controller.close(id, { transition: 150 })
|
||||
}, 150)
|
||||
}, [id, controller, options.onClose])
|
||||
|
||||
const handleDone = useCallback(
|
||||
(...context) => {
|
||||
if (typeof onDone === "function") {
|
||||
onDone(context)
|
||||
}
|
||||
},
|
||||
[onDone],
|
||||
)
|
||||
|
||||
const handleFail = useCallback(
|
||||
(...context) => {
|
||||
if (typeof onFail === "function") {
|
||||
onFail(context)
|
||||
}
|
||||
},
|
||||
[onFail],
|
||||
)
|
||||
|
||||
const animationVariants = useMemo(() => {
|
||||
const slideDirection = position === "right" ? 100 : -100
|
||||
|
||||
return {
|
||||
initial: {
|
||||
x: slideDirection,
|
||||
opacity: 0,
|
||||
},
|
||||
animate: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
exit: {
|
||||
x: slideDirection,
|
||||
opacity: 0,
|
||||
},
|
||||
}
|
||||
}, [position])
|
||||
|
||||
const enhancedComponentProps = useMemo(
|
||||
() => ({
|
||||
...componentProps,
|
||||
setHeader,
|
||||
close: handleClose,
|
||||
handleDone,
|
||||
handleFail,
|
||||
}),
|
||||
[componentProps, handleClose, handleDone, handleFail],
|
||||
)
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
close: handleClose,
|
||||
handleDone,
|
||||
handleFail,
|
||||
options,
|
||||
id,
|
||||
}),
|
||||
[handleClose, handleDone, handleFail, options, id],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller) {
|
||||
throw new Error(`Cannot mount a drawer without a controller`)
|
||||
}
|
||||
|
||||
if (!children) {
|
||||
throw new Error(`Empty component`)
|
||||
}
|
||||
}, [controller, children])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
key={id}
|
||||
id={id}
|
||||
className={classnames("drawer", `drawer-${position}`)}
|
||||
style={style}
|
||||
{...animationVariants}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 20,
|
||||
}}
|
||||
>
|
||||
{header && <DrawerHeader {...header} onClose={handleClose} />}
|
||||
|
||||
<div className="drawer-content">
|
||||
{React.createElement(children, enhancedComponentProps)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
Drawer.displayName = "Drawer"
|
||||
|
||||
Drawer.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
children: PropTypes.elementType.isRequired,
|
||||
options: PropTypes.object,
|
||||
controller: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
export default Drawer
|
37
packages/app/src/layouts/components/drawer/header.jsx
Normal file
37
packages/app/src/layouts/components/drawer/header.jsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from "react"
|
||||
import PropTypes from "prop-types"
|
||||
|
||||
const DrawerHeader = ({ title, actions, onClose, showCloseButton = true }) => {
|
||||
if (!title && !actions && !showCloseButton) return null
|
||||
|
||||
return (
|
||||
<div className="drawer-header">
|
||||
<div className="drawer-header-content">
|
||||
{title && <h3 className="drawer-title">{title}</h3>}
|
||||
<div className="drawer-header-actions">
|
||||
{actions && (
|
||||
<div className="drawer-custom-actions">{actions}</div>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
className="drawer-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
DrawerHeader.propTypes = {
|
||||
title: PropTypes.string,
|
||||
actions: PropTypes.node,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
showCloseButton: PropTypes.bool,
|
||||
}
|
||||
|
||||
export default DrawerHeader
|
516
packages/app/src/layouts/components/drawer/hooks.js
Normal file
516
packages/app/src/layouts/components/drawer/hooks.js
Normal file
@ -0,0 +1,516 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
|
||||
import { useDrawer } from "./index.jsx"
|
||||
|
||||
/**
|
||||
* Hook for managing drawer state with local persistence
|
||||
*/
|
||||
export const useDrawerState = (drawerId, initialOptions = {}) => {
|
||||
const drawer = useDrawer()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [options, setOptions] = useState(initialOptions)
|
||||
const optionsRef = useRef(options)
|
||||
|
||||
useEffect(() => {
|
||||
optionsRef.current = options
|
||||
}, [options])
|
||||
|
||||
const open = useCallback(
|
||||
(component, newOptions = {}) => {
|
||||
const mergedOptions = { ...optionsRef.current, ...newOptions }
|
||||
setOptions(mergedOptions)
|
||||
setIsOpen(true)
|
||||
drawer.open(drawerId, component, mergedOptions)
|
||||
},
|
||||
[drawer, drawerId],
|
||||
)
|
||||
|
||||
const close = useCallback(
|
||||
(params = {}) => {
|
||||
setIsOpen(false)
|
||||
drawer.close(drawerId, params)
|
||||
},
|
||||
[drawer, drawerId],
|
||||
)
|
||||
|
||||
const toggle = useCallback(
|
||||
(component, newOptions = {}) => {
|
||||
if (isOpen) {
|
||||
close()
|
||||
} else {
|
||||
open(component, newOptions)
|
||||
}
|
||||
},
|
||||
[isOpen, open, close],
|
||||
)
|
||||
|
||||
const updateOptions = useCallback((newOptions) => {
|
||||
setOptions((prev) => ({ ...prev, ...newOptions }))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
options,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
updateOptions,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing drawer queues and sequences
|
||||
*/
|
||||
export const useDrawerQueue = () => {
|
||||
const drawer = useDrawer()
|
||||
const [queue, setQueue] = useState([])
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
const isProcessing = useRef(false)
|
||||
|
||||
const addToQueue = useCallback((id, component, options = {}) => {
|
||||
setQueue((prev) => [...prev, { id, component, options }])
|
||||
}, [])
|
||||
|
||||
const processNext = useCallback(async () => {
|
||||
if (isProcessing.current) return
|
||||
|
||||
isProcessing.current = true
|
||||
|
||||
const nextIndex = currentIndex + 1
|
||||
if (nextIndex < queue.length) {
|
||||
const item = queue[nextIndex]
|
||||
setCurrentIndex(nextIndex)
|
||||
|
||||
// Close previous drawer if exists
|
||||
if (currentIndex >= 0) {
|
||||
const prevItem = queue[currentIndex]
|
||||
await new Promise((resolve) => {
|
||||
drawer.close(prevItem.id, { transition: 200 })
|
||||
setTimeout(resolve, 200)
|
||||
})
|
||||
}
|
||||
|
||||
// Open next drawer
|
||||
drawer.open(item.id, item.component, {
|
||||
...item.options,
|
||||
onClose: () => {
|
||||
item.options.onClose?.()
|
||||
processNext()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
isProcessing.current = false
|
||||
}, [currentIndex, queue, drawer])
|
||||
|
||||
const processPrevious = useCallback(async () => {
|
||||
if (isProcessing.current || currentIndex <= 0) return
|
||||
|
||||
isProcessing.current = true
|
||||
|
||||
const prevIndex = currentIndex - 1
|
||||
const currentItem = queue[currentIndex]
|
||||
const prevItem = queue[prevIndex]
|
||||
|
||||
// Close current drawer
|
||||
await new Promise((resolve) => {
|
||||
drawer.close(currentItem.id, { transition: 200 })
|
||||
setTimeout(resolve, 200)
|
||||
})
|
||||
|
||||
// Open previous drawer
|
||||
setCurrentIndex(prevIndex)
|
||||
drawer.open(prevItem.id, prevItem.component, prevItem.options)
|
||||
|
||||
isProcessing.current = false
|
||||
}, [currentIndex, queue, drawer])
|
||||
|
||||
const clearQueue = useCallback(() => {
|
||||
setQueue([])
|
||||
setCurrentIndex(-1)
|
||||
drawer.closeAll()
|
||||
}, [drawer])
|
||||
|
||||
const startQueue = useCallback(() => {
|
||||
if (queue.length > 0) {
|
||||
setCurrentIndex(-1)
|
||||
processNext()
|
||||
}
|
||||
}, [queue, processNext])
|
||||
|
||||
return {
|
||||
queue,
|
||||
currentIndex,
|
||||
addToQueue,
|
||||
processNext,
|
||||
processPrevious,
|
||||
clearQueue,
|
||||
startQueue,
|
||||
hasNext: currentIndex < queue.length - 1,
|
||||
hasPrevious: currentIndex > 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for form handling in drawers
|
||||
*/
|
||||
export const useDrawerForm = (drawerId, initialData = {}) => {
|
||||
const drawerState = useDrawerState(drawerId)
|
||||
const [formData, setFormData] = useState(initialData)
|
||||
const [errors, setErrors] = useState({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
const updateField = useCallback(
|
||||
(field, value) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
setIsDirty(true)
|
||||
|
||||
// Clear field error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: null }))
|
||||
}
|
||||
},
|
||||
[errors],
|
||||
)
|
||||
|
||||
const setFieldError = useCallback((field, error) => {
|
||||
setErrors((prev) => ({ ...prev, [field]: error }))
|
||||
}, [])
|
||||
|
||||
const clearErrors = useCallback(() => {
|
||||
setErrors({})
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setFormData(initialData)
|
||||
setErrors({})
|
||||
setIsDirty(false)
|
||||
setIsSubmitting(false)
|
||||
}, [initialData])
|
||||
|
||||
const openForm = useCallback(
|
||||
(component, options = {}) => {
|
||||
const formOptions = {
|
||||
...options,
|
||||
confirmOnOutsideClick: isDirty,
|
||||
confirmOnOutsideClickText:
|
||||
"You have unsaved changes. Are you sure you want to close?",
|
||||
onClose: () => {
|
||||
if (
|
||||
isDirty &&
|
||||
!window.confirm(
|
||||
"You have unsaved changes. Are you sure you want to close?",
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
reset()
|
||||
options.onClose?.()
|
||||
},
|
||||
}
|
||||
|
||||
drawerState.open(component, formOptions)
|
||||
},
|
||||
[drawerState, isDirty, reset],
|
||||
)
|
||||
|
||||
const submit = useCallback(
|
||||
async (submitFn, options = {}) => {
|
||||
const { validate, onSuccess, onError } = options
|
||||
|
||||
setIsSubmitting(true)
|
||||
clearErrors()
|
||||
|
||||
try {
|
||||
// Run validation if provided
|
||||
if (validate) {
|
||||
const validationErrors = await validate(formData)
|
||||
if (
|
||||
validationErrors &&
|
||||
Object.keys(validationErrors).length > 0
|
||||
) {
|
||||
setErrors(validationErrors)
|
||||
setIsSubmitting(false)
|
||||
return { success: false, errors: validationErrors }
|
||||
}
|
||||
}
|
||||
|
||||
// Submit form
|
||||
const result = await submitFn(formData)
|
||||
|
||||
// Handle success
|
||||
setIsDirty(false)
|
||||
onSuccess?.(result)
|
||||
drawerState.close()
|
||||
|
||||
return { success: true, data: result }
|
||||
} catch (error) {
|
||||
const errorMessage = error.message || "An error occurred"
|
||||
setErrors({ _global: errorMessage })
|
||||
onError?.(error)
|
||||
|
||||
return { success: false, error: errorMessage }
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[formData, drawerState, clearErrors],
|
||||
)
|
||||
|
||||
return {
|
||||
...drawerState,
|
||||
formData,
|
||||
errors,
|
||||
isSubmitting,
|
||||
isDirty,
|
||||
updateField,
|
||||
setFieldError,
|
||||
clearErrors,
|
||||
reset,
|
||||
openForm,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for keyboard shortcuts in drawers
|
||||
*/
|
||||
export const useDrawerKeyboard = (shortcuts = {}) => {
|
||||
const drawer = useDrawer()
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event) => {
|
||||
// Only handle shortcuts when drawers are open
|
||||
if (drawer.drawersLength() === 0) return
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
const ctrlKey = event.ctrlKey || event.metaKey
|
||||
const altKey = event.altKey
|
||||
const shiftKey = event.shiftKey
|
||||
|
||||
// Build shortcut key combination
|
||||
let combination = ""
|
||||
if (ctrlKey) combination += "ctrl+"
|
||||
if (altKey) combination += "alt+"
|
||||
if (shiftKey) combination += "shift+"
|
||||
combination += key
|
||||
|
||||
// Execute shortcut if found
|
||||
const shortcut = shortcuts[combination]
|
||||
if (shortcut) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
shortcut(event)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
}, [shortcuts, drawer])
|
||||
|
||||
return {
|
||||
addShortcut: useCallback(
|
||||
(key, handler) => {
|
||||
shortcuts[key] = handler
|
||||
},
|
||||
[shortcuts],
|
||||
),
|
||||
|
||||
removeShortcut: useCallback(
|
||||
(key) => {
|
||||
delete shortcuts[key]
|
||||
},
|
||||
[shortcuts],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for drawer animations and transitions
|
||||
*/
|
||||
export const useDrawerAnimation = (options = {}) => {
|
||||
const { duration = 300, easing = "ease-out", stagger = 100 } = options
|
||||
|
||||
const [isAnimating, setIsAnimating] = useState(false)
|
||||
|
||||
const createVariants = useCallback((position = "left") => {
|
||||
const slideDirection = position === "right" ? 100 : -100
|
||||
|
||||
return {
|
||||
initial: {
|
||||
x: slideDirection,
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
},
|
||||
animate: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
},
|
||||
exit: {
|
||||
x: slideDirection,
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
},
|
||||
}
|
||||
}, [])
|
||||
|
||||
const createTransition = useCallback(
|
||||
(delay = 0) => ({
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 20,
|
||||
duration: duration / 1000,
|
||||
delay: delay / 1000,
|
||||
}),
|
||||
[duration],
|
||||
)
|
||||
|
||||
const staggeredTransition = useCallback(
|
||||
(index = 0) => createTransition(index * stagger),
|
||||
[createTransition, stagger],
|
||||
)
|
||||
|
||||
const animateSequence = useCallback(
|
||||
async (animations) => {
|
||||
setIsAnimating(true)
|
||||
|
||||
for (let i = 0; i < animations.length; i++) {
|
||||
const animation = animations[i]
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
animation()
|
||||
resolve()
|
||||
}, i * stagger)
|
||||
})
|
||||
}
|
||||
|
||||
setTimeout(() => setIsAnimating(false), duration)
|
||||
},
|
||||
[stagger, duration],
|
||||
)
|
||||
|
||||
return {
|
||||
isAnimating,
|
||||
createVariants,
|
||||
createTransition,
|
||||
staggeredTransition,
|
||||
animateSequence,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for drawer persistence (localStorage)
|
||||
*/
|
||||
export const useDrawerPersistence = (key, initialState = {}) => {
|
||||
const [state, setState] = useState(() => {
|
||||
try {
|
||||
const item = localStorage.getItem(`drawer_${key}`)
|
||||
return item ? JSON.parse(item) : initialState
|
||||
} catch {
|
||||
return initialState
|
||||
}
|
||||
})
|
||||
|
||||
const updateState = useCallback(
|
||||
(newState) => {
|
||||
setState(newState)
|
||||
try {
|
||||
localStorage.setItem(`drawer_${key}`, JSON.stringify(newState))
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Failed to save drawer state to localStorage:",
|
||||
error,
|
||||
)
|
||||
}
|
||||
},
|
||||
[key],
|
||||
)
|
||||
|
||||
const clearState = useCallback(() => {
|
||||
setState(initialState)
|
||||
try {
|
||||
localStorage.removeItem(`drawer_${key}`)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Failed to clear drawer state from localStorage:",
|
||||
error,
|
||||
)
|
||||
}
|
||||
}, [key, initialState])
|
||||
|
||||
return [state, updateState, clearState]
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for drawer accessibility features
|
||||
*/
|
||||
export const useDrawerAccessibility = (options = {}) => {
|
||||
const { trapFocus = true, autoFocus = true, restoreFocus = true } = options
|
||||
|
||||
const previousActiveElement = useRef(null)
|
||||
const drawerRef = useRef(null)
|
||||
|
||||
const setupAccessibility = useCallback(() => {
|
||||
// Store the currently focused element
|
||||
if (restoreFocus) {
|
||||
previousActiveElement.current = document.activeElement
|
||||
}
|
||||
|
||||
// Auto focus the drawer
|
||||
if (autoFocus && drawerRef.current) {
|
||||
const firstFocusable = drawerRef.current.querySelector(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
if (firstFocusable) {
|
||||
firstFocusable.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Set up focus trap
|
||||
if (trapFocus) {
|
||||
const handleTabKey = (e) => {
|
||||
if (e.key !== "Tab" || !drawerRef.current) return
|
||||
|
||||
const focusableElements = drawerRef.current.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement =
|
||||
focusableElements[focusableElements.length - 1]
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
lastElement.focus()
|
||||
e.preventDefault()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
firstElement.focus()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleTabKey)
|
||||
return () => document.removeEventListener("keydown", handleTabKey)
|
||||
}
|
||||
}, [trapFocus, autoFocus, restoreFocus])
|
||||
|
||||
const cleanupAccessibility = useCallback(() => {
|
||||
// Restore focus to previous element
|
||||
if (restoreFocus && previousActiveElement.current) {
|
||||
previousActiveElement.current.focus()
|
||||
}
|
||||
}, [restoreFocus])
|
||||
|
||||
return {
|
||||
drawerRef,
|
||||
setupAccessibility,
|
||||
cleanupAccessibility,
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user