Skip to main content
Glama
PressableSpan.tsx6.93 kB
'use client'; import { type FC, type HTMLAttributes, type MouseEventHandler, useCallback, useEffect, useRef, useState, } from 'react'; import { cn } from '../../utils/cn'; const DEFAULT_PRESS_DETECT_DURATION = 400; /** * Props for the PressableSpan component */ type PressableSpanProps = { /** * Callback function triggered when a long press is detected * @example * ```tsx * <PressableSpan onPress={() => console.log('Long pressed!')}> * Press and hold me * </PressableSpan> * ``` */ onPress: () => void; /** * Optional callback function triggered when clicking outside the component while it's in selecting state * @example * ```tsx * <PressableSpan * onPress={() => setIsEditing(true)} * onClickOutside={() => setIsEditing(false)} * > * Click outside to deselect * </PressableSpan> * ``` */ onClickOutside?: () => void; /** * Duration in milliseconds for long press detection * @default 400 * @example * ```tsx * <PressableSpan pressDuration={800} onPress={() => {}}> * Longer press required * </PressableSpan> * ``` */ pressDuration?: number; /** * External control for the selecting state * @example * ```tsx * <PressableSpan isSelecting={isEditing} onPress={() => {}}> * Externally controlled * </PressableSpan> * ``` */ isSelecting?: boolean; } & HTMLAttributes<HTMLSpanElement>; /** * PressableSpan - An interactive span element that responds to long press gestures * * A versatile component that detects long press interactions and provides visual feedback. * Commonly used for text editing interfaces, selection systems, and interactive content * that requires differentiation between quick clicks and intentional long presses. * * ## Key Features * - **Long Press Detection**: Configurable press duration for different interaction patterns * - **Visual Feedback**: Smooth outline transitions to indicate interactive and selected states * - **Click Outside Detection**: Automatically deselects when clicking outside the component * - **Touch Support**: Works seamlessly on both desktop and mobile devices * - **Accessible**: Keyboard navigation support and proper ARIA attributes * * ## Use Cases * - Text editing interfaces where long press activates edit mode * - Content selection systems with visual feedback * - Interactive cards or elements that need press-and-hold activation * - Mobile-friendly interfaces requiring long press gestures * * ## Accessibility * - Uses semantic `role="button"` for proper screen reader announcement * - Keyboard navigable with `tabIndex={0}` * - Focus management with proper blur handling * - Visual outline indicators for focus and selection states * * @example * Basic usage with long press detection: * ```tsx * <PressableSpan onPress={() => setIsEditing(true)}> * Press and hold to edit this text * </PressableSpan> * ``` * * @example * With custom press duration and click outside handling: * ```tsx * <PressableSpan * pressDuration={600} * onPress={() => setIsEditing(true)} * onClickOutside={() => setIsEditing(false)} * isSelecting={isEditing} * > * Custom behavior configuration * </PressableSpan> * ``` * * @example * In a content editing context: * ```tsx * <PressableSpan * onPress={() => startEditingContent(contentId)} * onClickOutside={() => saveAndExitEditing()} * className="prose-text" * > * {editableContent} * </PressableSpan> * ``` */ export const PressableSpan: FC<PressableSpanProps> = ({ children, onPress: onSelect, onClickOutside: onUnselect, pressDuration = DEFAULT_PRESS_DETECT_DURATION, isSelecting: isSelectingProp, ...props }) => { const elementRef = useRef<HTMLSpanElement>(null); const [isSelectingState, setIsSelectingState] = useState(isSelectingProp); const pressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const handleOnLongPress = () => { setIsSelectingState(true); onSelect(); }; const startPressTimer = () => { pressTimerRef.current = setTimeout(() => { handleOnLongPress(); }, pressDuration); }; const clearPressTimer = () => { if (pressTimerRef.current) { clearTimeout(pressTimerRef.current); pressTimerRef.current = null; } }; const handleMouseDown = () => { clearPressTimer(); // Ensure any previous timer is cleared startPressTimer(); }; const handleMouseUp = () => { clearPressTimer(); }; // Use useCallback to ensure the function identity remains stable const handleClickOutside = useCallback( (event: MouseEvent) => { if ( elementRef.current && !elementRef.current.contains(event.target as Node) ) { setIsSelectingState(false); onUnselect?.(); } }, [onUnselect] ); useEffect(() => { // Attach click outside listener document.addEventListener('mousedown', handleClickOutside); return () => { // Cleanup document.removeEventListener('mousedown', handleClickOutside); // clearPressTimer(); // Ensure to clear the timer when component unmounts }; }, [handleClickOutside]); const handleOnClick: MouseEventHandler<HTMLDivElement> = (e) => { if (isSelectingState) { e.preventDefault(); e.stopPropagation(); } }; const handleOnBlur = () => { // Stop editing when the element loses focus setIsSelectingState(false); }; /** * Handle keyboard interactions * - Enter/Space: Trigger long press action immediately * - Escape: Cancel selection/editing state */ const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => { switch (e.key) { case 'Enter': case ' ': e.preventDefault(); handleOnLongPress(); break; case 'Escape': e.preventDefault(); setIsSelectingState(false); onUnselect?.(); break; default: break; } }; const isCurrentlySelecting = isSelectingProp ?? isSelectingState; return ( <span className={cn( 'inline cursor-pointer select-none rounded-md outline outline-2 outline-transparent outline-offset-4 transition-all delay-100 duration-200', isCurrentlySelecting ? 'outline-inherit' : 'hover:outline-inherit' )} role="button" tabIndex={0} aria-pressed={isCurrentlySelecting ? 'true' : 'false'} aria-label={`${isCurrentlySelecting ? 'Selected' : 'Selectable'} content`} onKeyDown={handleKeyDown} onClick={handleOnClick} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} onTouchStart={handleMouseDown} onTouchEnd={handleMouseUp} onTouchCancel={handleMouseUp} onBlur={handleOnBlur} ref={elementRef} {...props} > {children} </span> ); };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server