From f17c56a551b7813c4d815c14735bddde49c82b18 Mon Sep 17 00:00:00 2001 From: SrGooglo Date: Mon, 16 Jun 2025 20:52:36 +0000 Subject: [PATCH] Add Drawer component, header, and custom hooks --- .../layouts/components/drawer/component.jsx | 148 +++++ .../src/layouts/components/drawer/header.jsx | 37 ++ .../src/layouts/components/drawer/hooks.js | 516 ++++++++++++++++++ 3 files changed, 701 insertions(+) create mode 100644 packages/app/src/layouts/components/drawer/component.jsx create mode 100644 packages/app/src/layouts/components/drawer/header.jsx create mode 100644 packages/app/src/layouts/components/drawer/hooks.js diff --git a/packages/app/src/layouts/components/drawer/component.jsx b/packages/app/src/layouts/components/drawer/component.jsx new file mode 100644 index 00000000..4b9c5dec --- /dev/null +++ b/packages/app/src/layouts/components/drawer/component.jsx @@ -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 ( + + {header && } + +
+ {React.createElement(children, enhancedComponentProps)} +
+
+ ) + }), +) + +Drawer.displayName = "Drawer" + +Drawer.propTypes = { + id: PropTypes.string.isRequired, + children: PropTypes.elementType.isRequired, + options: PropTypes.object, + controller: PropTypes.object.isRequired, +} + +export default Drawer diff --git a/packages/app/src/layouts/components/drawer/header.jsx b/packages/app/src/layouts/components/drawer/header.jsx new file mode 100644 index 00000000..03faf5d2 --- /dev/null +++ b/packages/app/src/layouts/components/drawer/header.jsx @@ -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 ( +
+
+ {title &&

{title}

} +
+ {actions && ( +
{actions}
+ )} + {showCloseButton && ( + + )} +
+
+
+ ) +} + +DrawerHeader.propTypes = { + title: PropTypes.string, + actions: PropTypes.node, + onClose: PropTypes.func.isRequired, + showCloseButton: PropTypes.bool, +} + +export default DrawerHeader diff --git a/packages/app/src/layouts/components/drawer/hooks.js b/packages/app/src/layouts/components/drawer/hooks.js new file mode 100644 index 00000000..e1e5669b --- /dev/null +++ b/packages/app/src/layouts/components/drawer/hooks.js @@ -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, + } +}