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:
parent
c31a209543
commit
a069fe3011
16 changed files with 38 additions and 205 deletions
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue