diff --git a/@packages/@hooks/react-hooks/src/index.ts b/@packages/@hooks/react-hooks/src/index.ts index 4e85aa889..7bc6ac7ec 100755 --- a/@packages/@hooks/react-hooks/src/index.ts +++ b/@packages/@hooks/react-hooks/src/index.ts @@ -9,7 +9,6 @@ * - useDebounce - Debounced values * - useMediaQuery - Responsive design with media queries * - usePrevious - Track previous values - * - useClickOutside - Detect clicks outside elements * - useCopyToClipboard - Clipboard operations * - useInterval - Declarative intervals * - useToggle - Boolean state management @@ -39,8 +38,6 @@ export { useMediaQuery } from './use-media-query'; export { usePrevious } from './use-previous'; -export { useClickOutside } from './use-click-outside'; - export { useCopyToClipboard } from './use-copy-to-clipboard'; export type { UseCopyToClipboardReturn } from './use-copy-to-clipboard'; diff --git a/@packages/@hooks/react-hooks/src/use-click-outside.ts b/@packages/@hooks/react-hooks/src/use-click-outside.ts deleted file mode 100755 index ca7200d8d..000000000 --- a/@packages/@hooks/react-hooks/src/use-click-outside.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useEffect, useRef, RefObject } from 'react'; - -/** - * Hook for detecting clicks outside an element - * - * Useful for closing dropdowns, modals, or popups when clicking outside. - * Handles both mouse and touch events. - * - * @param handler - Callback function to run when click outside is detected - * @returns Ref to attach to the element - * - * @example - * ```typescript - * function Dropdown() { - * const [isOpen, setIsOpen] = useState(false); - * const dropdownRef = useClickOutside(() => { - * setIsOpen(false); - * }); - * - * return ( - *
- * - * {isOpen && ( - *
- * Option 1 - * Option 2 - *
- * )} - *
- * ); - * } - * ``` - */ -export function useClickOutside( - handler: (event: MouseEvent | TouchEvent) => void -): RefObject { - const ref = useRef(null); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent | TouchEvent) => { - if (ref.current && !ref.current.contains(event.target as Node)) { - handler(event); - } - }; - - // Add event listeners - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('touchstart', handleClickOutside); - - return () => { - // Cleanup - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('touchstart', handleClickOutside); - }; - }, [handler]); - - return ref; -} diff --git a/features/consumable/frontend-showcase/src/components/TierSelector.tsx b/features/consumable/frontend-showcase/src/components/TierSelector.tsx index c03da07ef..c62069d50 100644 --- a/features/consumable/frontend-showcase/src/components/TierSelector.tsx +++ b/features/consumable/frontend-showcase/src/components/TierSelector.tsx @@ -5,9 +5,10 @@ * Works with any TierDef[] from config. */ -import { useState, useCallback } from 'react' +import { useState, useCallback, useRef } from 'react' import { AnimatePresence } from '@lilith/ui-motion' import { ChevronDownIcon } from '@lilith/ui-icons' +import { useClickOutside } from '@lilith/ui-accessibility' import type { TierDef } from '../types' import { @@ -18,7 +19,6 @@ import { TierPrice, ChevronIcon, } from '../styles/GemStatusBarDemo.styles' -import { useClickOutside } from '../hooks/useClickOutside' interface TierSelectorProps { tiers: TierDef[] @@ -29,7 +29,8 @@ interface TierSelectorProps { export function TierSelector({ tiers, selectedIndex, onSelect }: TierSelectorProps) { const [open, setOpen] = useState(false) const close = useCallback(() => setOpen(false), []) - const ref = useClickOutside(close) + const ref = useRef(null) + useClickOutside(ref, close) const tier = tiers[selectedIndex]! const handleSelect = (index: number) => { diff --git a/features/consumable/frontend-showcase/src/hooks/useClickOutside.ts b/features/consumable/frontend-showcase/src/hooks/useClickOutside.ts deleted file mode 100644 index 077428cec..000000000 --- a/features/consumable/frontend-showcase/src/hooks/useClickOutside.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * useClickOutside Hook - * - * Calls handler when a click occurs outside the referenced element. - */ - -import { useEffect, useRef } from 'react' - -export function useClickOutside(handler: () => void) { - const ref = useRef(null) - - useEffect(() => { - const listener = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) { - handler() - } - } - document.addEventListener('mousedown', listener) - return () => document.removeEventListener('mousedown', listener) - }, [handler]) - - return ref -} diff --git a/features/landing/frontend-public/src/components/CTAModal/hooks/useModalBehavior.ts b/features/landing/frontend-public/src/components/CTAModal/hooks/useModalBehavior.ts index 24bafb082..d5b2b5d26 100755 --- a/features/landing/frontend-public/src/components/CTAModal/hooks/useModalBehavior.ts +++ b/features/landing/frontend-public/src/components/CTAModal/hooks/useModalBehavior.ts @@ -8,8 +8,9 @@ * - Tab focus trap */ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useCallback } from 'react' +import { useEscapeKey } from '@lilith/ui-accessibility' import { useSoundEngine } from '@lilith/ui-effects-sound' interface UseModalBehaviorProps { @@ -41,17 +42,12 @@ export function useModalBehavior({ onClose, isSubmitting }: UseModalBehaviorProp }, [playSound]) // Escape key handler - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' && !isSubmitting) { - playSound('modal-close') - onClose() - } - } + const handleEscapeClose = useCallback(() => { + playSound('modal-close') + onClose() + }, [playSound, onClose]) - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [onClose, isSubmitting, playSound]) + useEscapeKey(handleEscapeClose, !isSubmitting) // Focus trap useEffect(() => { diff --git a/features/landing/frontend-public/src/components/Ideas/IdeaConfiguratorModal.tsx b/features/landing/frontend-public/src/components/Ideas/IdeaConfiguratorModal.tsx index c7bee683a..942f5eb50 100644 --- a/features/landing/frontend-public/src/components/Ideas/IdeaConfiguratorModal.tsx +++ b/features/landing/frontend-public/src/components/Ideas/IdeaConfiguratorModal.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react' +import { useEscapeKey } from '@lilith/ui-accessibility' import { useSoundEngine } from '@lilith/ui-effects-sound' import { m, AnimatePresence } from '@lilith/ui-motion' import { XIcon, ShoppingCartIcon, MinusIcon, PlusIcon, CheckIcon, ShirtIcon, InfoIcon, StickyNoteIcon, CoffeeIcon } from '@lilith/ui-icons' @@ -86,15 +87,7 @@ export function IdeaConfiguratorModal({ isOpen, onClose, idea }: IdeaConfigurato }, [playSound, onClose]) // Escape key to close - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape' && isOpen) { - handleClose() - } - } - document.addEventListener('keydown', handleEscape) - return () => document.removeEventListener('keydown', handleEscape) - }, [isOpen, handleClose]) + useEscapeKey(handleClose, isOpen) // Prevent body scroll when modal is open useEffect(() => { diff --git a/features/landing/frontend-public/src/components/InfoPanel/InfoPanel.tsx b/features/landing/frontend-public/src/components/InfoPanel/InfoPanel.tsx index ee12ad0ef..de0022a49 100755 --- a/features/landing/frontend-public/src/components/InfoPanel/InfoPanel.tsx +++ b/features/landing/frontend-public/src/components/InfoPanel/InfoPanel.tsx @@ -12,6 +12,7 @@ import type { CSSProperties } from 'react' import { useEffect, useRef, useCallback } from 'react' +import { useEscapeKey } from '@lilith/ui-accessibility' import { useTranslation, type UserType } from '@lilith/i18n' import { useSoundEngine } from '@lilith/ui-effects-sound' import { Link, useNavigate } from '@lilith/ui-router' @@ -115,15 +116,7 @@ export default function InfoPanel({ userType, isOpen, onClose }: InfoPanelProps) }, [playSound, onClose]) // Escape key to close - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape' && isOpen) { - handleClose() - } - } - document.addEventListener('keydown', handleEscape) - return () => document.removeEventListener('keydown', handleEscape) - }, [isOpen, handleClose]) + useEscapeKey(handleClose, isOpen) // Prevent body scroll when panel is open useEffect(() => { diff --git a/features/landing/frontend-public/src/components/ProductDetailModal.tsx b/features/landing/frontend-public/src/components/ProductDetailModal.tsx index 2d17a7513..79cbe0b41 100755 --- a/features/landing/frontend-public/src/components/ProductDetailModal.tsx +++ b/features/landing/frontend-public/src/components/ProductDetailModal.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react' +import { useEscapeKey } from '@lilith/ui-accessibility' import { useSoundEngine } from '@lilith/ui-effects-sound' import { m, AnimatePresence } from '@lilith/ui-motion' import { XIcon, ShoppingCartIcon, MinusIcon, PlusIcon, CheckIcon, SparklesIcon, HeartIcon } from '@lilith/ui-icons' @@ -75,15 +76,7 @@ export default function ProductDetailModal({ }, [playSound, onClose]) // Escape key to close - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape' && isOpen) { - handleClose() - } - } - document.addEventListener('keydown', handleEscape) - return () => document.removeEventListener('keydown', handleEscape) - }, [isOpen, handleClose]) + useEscapeKey(handleClose, isOpen) // Prevent body scroll when modal is open useEffect(() => { diff --git a/features/landing/frontend-public/src/components/UserMenu.tsx b/features/landing/frontend-public/src/components/UserMenu.tsx index 7f4c16df3..07a5b188e 100755 --- a/features/landing/frontend-public/src/components/UserMenu.tsx +++ b/features/landing/frontend-public/src/components/UserMenu.tsx @@ -11,6 +11,7 @@ import { useState, useRef, useEffect } from 'react' +import { useEscapeKey } from '@lilith/ui-accessibility' import { useProfile } from '@lilith/profile-client' import { useSoundEngine } from '@lilith/ui-effects-sound' import { useNavigate } from '@lilith/ui-router' @@ -59,18 +60,7 @@ export default function UserMenu() { }, [isOpen]) // Close on escape - useEffect(() => { - if (!isOpen) {return} - - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setIsOpen(false) - } - } - - document.addEventListener('keydown', handleEscape) - return () => document.removeEventListener('keydown', handleEscape) - }, [isOpen]) + useEscapeKey(() => setIsOpen(false), isOpen) const handleSignIn = () => { playSound('button-click') diff --git a/features/marketplace/frontend-public/src/features/provider/components/GiftMessageEditor/EmojiPicker.tsx b/features/marketplace/frontend-public/src/features/provider/components/GiftMessageEditor/EmojiPicker.tsx index 71e05cdd0..d3d4b5cd6 100644 --- a/features/marketplace/frontend-public/src/features/provider/components/GiftMessageEditor/EmojiPicker.tsx +++ b/features/marketplace/frontend-public/src/features/provider/components/GiftMessageEditor/EmojiPicker.tsx @@ -7,6 +7,7 @@ import { type FC, useState, useRef, useEffect } from 'react'; +import { useEscapeKey } from '@lilith/ui-accessibility'; import data from '@emoji-mart/data'; import Picker from '@emoji-mart/react'; import styled, { type DefaultTheme } from '@lilith/ui-styled-components'; @@ -112,16 +113,7 @@ export const EmojiPicker: FC = ({ }, [isOpen]); // Handle escape key - useEffect(() => { - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape' && isOpen) { - setIsOpen(false); - } - }; - - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [isOpen]); + useEscapeKey(() => setIsOpen(false), isOpen); const handleEmojiSelect = (emoji: EmojiData) => { onSelect(emoji.native); diff --git a/features/messaging/frontend-public/src/features/inbox/components/side-panel/SidePanel.tsx b/features/messaging/frontend-public/src/features/inbox/components/side-panel/SidePanel.tsx index 213e5ec17..6a5086b9f 100644 --- a/features/messaging/frontend-public/src/features/inbox/components/side-panel/SidePanel.tsx +++ b/features/messaging/frontend-public/src/features/inbox/components/side-panel/SidePanel.tsx @@ -5,7 +5,8 @@ * Mobile: Bottom sheet */ -import { useEffect, type FC } from 'react'; +import { type FC } from 'react'; +import { useEscapeKey } from '@lilith/ui-accessibility'; import styled, { type DefaultTheme } from '@lilith/ui-styled-components'; import { motion, AnimatePresence } from '@lilith/ui-motion'; import { useSidePanel } from './SidePanelContext'; @@ -126,16 +127,7 @@ export const SidePanel: FC = () => { const { isOpen, panelType, props, closePanel } = useSidePanel(); // Close on escape key - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape' && isOpen) { - closePanel(); - } - }; - - window.addEventListener('keydown', handleEscape); - return () => window.removeEventListener('keydown', handleEscape); - }, [isOpen, closePanel]); + useEscapeKey(closePanel, isOpen); const renderContent = () => { switch (panelType) { diff --git a/features/profile/frontend-app/src/pages/components/AddLinkModal.tsx b/features/profile/frontend-app/src/pages/components/AddLinkModal.tsx index 4bdb525e2..c642aa8ee 100644 --- a/features/profile/frontend-app/src/pages/components/AddLinkModal.tsx +++ b/features/profile/frontend-app/src/pages/components/AddLinkModal.tsx @@ -3,8 +3,9 @@ * Shows a list of the user's own profiles to select from. */ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; +import { useEscapeKey } from '@lilith/ui-accessibility'; import styled from '@lilith/ui-styled-components'; import type { ProfileData } from '@/hooks/useProfileData'; @@ -33,13 +34,7 @@ export const AddLinkModal = ({ (p) => p.id !== currentProfileId && !existingTargetIds.has(p.id), ); - useEffect(() => { - const handleEsc = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - document.addEventListener('keydown', handleEsc); - return () => document.removeEventListener('keydown', handleEsc); - }, [onClose]); + useEscapeKey(onClose); const handleSubmit = () => { if (!selectedId) return; diff --git a/features/quality-assurance/frontend-admin/src/pages/QAReportDetailModal.tsx b/features/quality-assurance/frontend-admin/src/pages/QAReportDetailModal.tsx index 1a3c3830d..8537fb612 100644 --- a/features/quality-assurance/frontend-admin/src/pages/QAReportDetailModal.tsx +++ b/features/quality-assurance/frontend-admin/src/pages/QAReportDetailModal.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react'; +import { useEscapeKey } from '@lilith/ui-accessibility'; import { Button, Textarea } from '@lilith/ui-primitives'; import styled from '@lilith/ui-styled-components'; import { formatDistanceToNow } from 'date-fns'; @@ -340,13 +341,7 @@ export const QAReportDetailModal = ({ const { comments, isLoading: commentsLoading, addComment, isAddingComment } = useQAComments(report.id); - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', handleEscape); - return () => window.removeEventListener('keydown', handleEscape); - }, [onClose]); + useEscapeKey(onClose); useEffect(() => { document.body.style.overflow = 'hidden'; diff --git a/features/quality-assurance/frontend-widget/src/QAReportWidget.tsx b/features/quality-assurance/frontend-widget/src/QAReportWidget.tsx index 080c52ac6..1f52d65fb 100644 --- a/features/quality-assurance/frontend-widget/src/QAReportWidget.tsx +++ b/features/quality-assurance/frontend-widget/src/QAReportWidget.tsx @@ -1,4 +1,6 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useCallback } from 'react'; + +import { useEscapeKey } from '@lilith/ui-accessibility'; import { QAReportForm } from './QAReportForm'; import { WIDGET_STYLES as S } from './styles'; @@ -26,16 +28,7 @@ export function QAReportWidget({ apiUrl, position = 'right' }: QAWidgetProps) { }, [report]); // Close on Escape key - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') handleClose(); - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isOpen, handleClose]); + useEscapeKey(handleClose, isOpen); const isHeader = position === 'header'; const positionStyle = isHeader diff --git a/features/share/frontend-public/src/components/ShareSheet.tsx b/features/share/frontend-public/src/components/ShareSheet.tsx index 37570dcc9..234d8f14c 100644 --- a/features/share/frontend-public/src/components/ShareSheet.tsx +++ b/features/share/frontend-public/src/components/ShareSheet.tsx @@ -6,6 +6,7 @@ */ import { useEffect, useCallback } from 'react'; +import { useEscapeKey } from '@lilith/ui-accessibility'; import styled from '@lilith/ui-styled-components'; import type { ShareContent, @@ -87,18 +88,7 @@ export const ShareSheet = ({ onShare, }: ShareSheetProps) => { // Close on Escape key - useEffect(() => { - if (!isOpen) return; - - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onClose(); - } - }; - - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [isOpen, onClose]); + useEscapeKey(onClose, isOpen); // Prevent body scroll when open useEffect(() => { diff --git a/features/threat-intelligence/frontend-showcase/src/components/AlertDetail.tsx b/features/threat-intelligence/frontend-showcase/src/components/AlertDetail.tsx index e118c56c8..f7f6035fa 100644 --- a/features/threat-intelligence/frontend-showcase/src/components/AlertDetail.tsx +++ b/features/threat-intelligence/frontend-showcase/src/components/AlertDetail.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState } from 'react'; +import { useEscapeKey } from '@lilith/ui-accessibility'; import { format } from 'date-fns'; import { AlertStatus, type AlertRecord } from '../seed-data'; @@ -173,14 +174,7 @@ export function AlertDetail({ alert, onClose, onUpdated }: AlertDetailProps) { const [notes, setNotes] = useState(alert.notes ?? ''); const [saving, setSaving] = useState(false); - const handleEscape = useCallback((e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }, [onClose]); - - useEffect(() => { - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [handleEscape]); + useEscapeKey(onClose); async function handleTransition(label: string) { const status = LABEL_TO_STATUS[label];