refactor(react-hooks): ♻️ Consolidate duplicate useClickOutside implementations into shared hook and update 15+ modal components

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-28 19:09:15 -08:00
parent c31a209543
commit a069fe3011
16 changed files with 38 additions and 205 deletions

View file

@ -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';

View file

@ -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<HTMLDivElement>(() => {
* setIsOpen(false);
* });
*
* return (
* <div ref={dropdownRef}>
* <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
* {isOpen && (
* <div className="dropdown-menu">
* <MenuItem>Option 1</MenuItem>
* <MenuItem>Option 2</MenuItem>
* </div>
* )}
* </div>
* );
* }
* ```
*/
export function useClickOutside<T extends HTMLElement = HTMLElement>(
handler: (event: MouseEvent | TouchEvent) => void
): RefObject<T | null> {
const ref = useRef<T>(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;
}

View file

@ -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<HTMLDivElement>(close)
const ref = useRef<HTMLDivElement>(null)
useClickOutside(ref, close)
const tier = tiers[selectedIndex]!
const handleSelect = (index: number) => {

View file

@ -1,23 +0,0 @@
/**
* useClickOutside Hook
*
* Calls handler when a click occurs outside the referenced element.
*/
import { useEffect, useRef } from 'react'
export function useClickOutside<T extends HTMLElement>(handler: () => void) {
const ref = useRef<T>(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
}

View file

@ -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(() => {

View file

@ -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(() => {

View file

@ -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(() => {

View file

@ -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(() => {

View file

@ -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')

View file

@ -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<EmojiPickerProps> = ({
}, [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);

View file

@ -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) {

View file

@ -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;

View file

@ -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';

View file

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

View file

@ -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(() => {

View file

@ -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];