UNPKG

@chayns-components/core

Version:

A set of beautiful React components for developing your own applications with chayns.

382 lines 14.4 kB
import { AnimatePresence } from 'motion/react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { MentionFinderPopupAlignment } from '../../constants/mentionFinder'; import MentionFinderItem from './mention-finder-item/MentionFinderItem'; import { StyledMentionFinder, StyledMentionFinderDragHandle, StyledMentionFinderDragHandleInner, StyledMentionFinderItemList, StyledMentionFinderOverlay, StyledMotionMentionFinderPopup } from './MentionFinder.styles'; const DRAG_CLOSE_THRESHOLD_IN_PX = 60; const isTextInputElement = element => { if (!element) { return false; } const tagName = element.tagName.toLowerCase(); return tagName === 'input' && element.type !== 'button' || tagName === 'textarea' || element.isContentEditable; }; const findTouchByIdentifier = (touchList, identifier) => { if (identifier === null) { return null; } for (let i = 0; i < touchList.length; i += 1) { const touch = touchList.item(i); if (touch && touch.identifier === identifier) { return touch; } } return null; }; const MentionFinder = ({ inputValue, members, onSelect, popupAlignment, enableDragHandle = false, dragCloseThresholdInPx = DRAG_CLOSE_THRESHOLD_IN_PX, overlayContainerSelector }) => { const [activeMember, setActiveMember] = useState(members[0]); const [focusedIndex, setFocusedIndex] = useState(0); const [shouldShowPopup, setShouldShowPopup] = useState(true); const popupRef = useRef(null); const listRef = useRef(null); const inputElementRef = useRef(null); const dragStartYRef = useRef(null); const hasTriggeredDragCloseRef = useRef(false); const activePointerIdRef = useRef(null); const activeTouchIdRef = useRef(null); const [isDraggingHandle, setIsDraggingHandle] = useState(false); const [dragOffset, setDragOffset] = useState(0); const [dragProgress, setDragProgress] = useState(0); const [overlayContainer, setOverlayContainer] = useState(null); const [fullMatch, searchString] = useMemo(() => { // eslint-disable-next-line no-irregular-whitespace const regExpMatchArray = inputValue.match(/@(?!\s)([^\s​]*)/); return [regExpMatchArray?.[0], regExpMatchArray?.[1]?.toLowerCase() ?? '']; }, [inputValue]); const filteredMembers = useMemo(() => searchString !== '' ? members.filter(({ id, info, name }) => id.toLowerCase().includes(searchString) || info?.replace('chayns', '').toLowerCase().includes(searchString) || name.toLowerCase().includes(searchString)) : members, [members, searchString]); const shouldRenderPopup = shouldShowPopup && !!fullMatch && filteredMembers.length > 0; const handleContainerBlur = useCallback(event => { const nextFocusedElement = event.relatedTarget; const currentContainer = event.currentTarget; if (!nextFocusedElement || !currentContainer.contains(nextFocusedElement) && !popupRef.current?.contains(nextFocusedElement)) { setShouldShowPopup(false); } }, []); const handleKeyDown = useCallback(event => { const targetElement = event.target; if (isTextInputElement(targetElement)) { inputElementRef.current = targetElement; } if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && shouldRenderPopup) { event.preventDefault(); const children = listRef.current?.children; if (children && children.length > 0) { const newIndex = focusedIndex !== null ? (focusedIndex + (event.key === 'ArrowUp' ? -1 : 1) + children.length) % children.length : 0; if (focusedIndex !== null) { const prevElement = children[focusedIndex]; prevElement.tabIndex = -1; } setFocusedIndex(newIndex); const member = filteredMembers[newIndex]; setActiveMember(member); const newElement = children[newIndex]; newElement.tabIndex = 0; newElement.focus(); } } else if (event.key === 'Enter' && shouldRenderPopup) { event.preventDefault(); event.stopPropagation(); if (fullMatch && activeMember) { onSelect({ fullMatch, member: activeMember }); } } else if (event.key === 'Escape' && shouldRenderPopup) { event.preventDefault(); event.stopPropagation(); setShouldShowPopup(false); window.requestAnimationFrame(() => { inputElementRef.current?.focus(); }); } }, [activeMember, filteredMembers, focusedIndex, fullMatch, onSelect, shouldRenderPopup]); const handleMemberClick = useCallback(member => { if (fullMatch) { onSelect({ fullMatch, member }); } }, [fullMatch, onSelect]); const handleMemberHover = useCallback(member => { setActiveMember(member); }, []); useEffect(() => { if (filteredMembers.length > 0) { const isActiveMemberShown = filteredMembers.some(({ id }) => id === activeMember?.id); if (!isActiveMemberShown) { setActiveMember(filteredMembers[0]); } } }, [activeMember?.id, filteredMembers]); const items = useMemo(() => filteredMembers.map(member => /*#__PURE__*/React.createElement(MentionFinderItem, { isActive: member.id === activeMember?.id, key: member.id, member: member, onClick: handleMemberClick, onHover: handleMemberHover })), [activeMember, filteredMembers, handleMemberClick, handleMemberHover]); useEffect(() => { setDragOffset(0); setDragProgress(0); hasTriggeredDragCloseRef.current = false; setShouldShowPopup(true); }, [inputValue]); useEffect(() => { if (!shouldRenderPopup) { return; } const activeElement = document.activeElement; if (isTextInputElement(activeElement)) { inputElementRef.current = activeElement; } }, [shouldRenderPopup]); useEffect(() => { if (shouldShowPopup) { window.addEventListener('keydown', handleKeyDown, true); } return () => { window.removeEventListener('keydown', handleKeyDown, true); }; }, [handleKeyDown, shouldShowPopup]); const closePopupViaDrag = useCallback(() => { if (hasTriggeredDragCloseRef.current) { return; } hasTriggeredDragCloseRef.current = true; setDragOffset(0); setDragProgress(0); setShouldShowPopup(false); }, []); const handleOverlayClick = useCallback(() => { closePopupViaDrag(); }, [closePopupViaDrag]); const updateDragByClientY = useCallback(clientY => { if (dragStartYRef.current === null) { return false; } const closingDirectionMultiplier = popupAlignment === MentionFinderPopupAlignment.Bottom ? -1 : 1; const rawDelta = clientY - dragStartYRef.current; const normalizedDelta = rawDelta * closingDirectionMultiplier; const positiveDelta = Math.max(0, normalizedDelta); const limitedDelta = Math.min(positiveDelta, dragCloseThresholdInPx); setDragOffset(limitedDelta * closingDirectionMultiplier); setDragProgress(Math.min(positiveDelta / dragCloseThresholdInPx, 1)); if (positiveDelta >= dragCloseThresholdInPx) { dragStartYRef.current = null; setIsDraggingHandle(false); closePopupViaDrag(); return true; } return false; }, [closePopupViaDrag, dragCloseThresholdInPx, popupAlignment]); const handleDragPointerDown = useCallback(event => { if (event.pointerType === 'touch') { return; } if (event.pointerType === 'mouse' && event.button !== 0) { return; } if (dragStartYRef.current !== null) { return; } activePointerIdRef.current = event.pointerId; dragStartYRef.current = event.clientY; hasTriggeredDragCloseRef.current = false; setDragOffset(0); setDragProgress(0); setIsDraggingHandle(true); event.currentTarget.setPointerCapture(event.pointerId); event.stopPropagation(); }, []); const handleDragPointerMove = useCallback(event => { if (event.pointerType === 'touch' || activePointerIdRef.current !== event.pointerId) { return; } if (dragStartYRef.current === null) { return; } event.preventDefault(); event.stopPropagation(); const hasClosed = updateDragByClientY(event.clientY); if (hasClosed) { event.currentTarget.releasePointerCapture(event.pointerId); activePointerIdRef.current = null; } }, [updateDragByClientY]); const resetDragState = useCallback((shouldClose = false) => { dragStartYRef.current = null; setIsDraggingHandle(false); if (shouldClose) { closePopupViaDrag(); } else { setDragOffset(0); setDragProgress(0); } }, [closePopupViaDrag]); const handleDragPointerUp = useCallback(event => { if (event.pointerType === 'touch' || activePointerIdRef.current !== event.pointerId) { return; } event.stopPropagation(); const shouldClose = dragProgress >= 1; resetDragState(shouldClose); event.currentTarget.releasePointerCapture(event.pointerId); activePointerIdRef.current = null; }, [dragProgress, resetDragState]); const handleDragPointerCancel = useCallback(event => { if (event.pointerType === 'touch' || activePointerIdRef.current !== event.pointerId) { return; } event.stopPropagation(); resetDragState(); event.currentTarget.releasePointerCapture(event.pointerId); activePointerIdRef.current = null; }, [resetDragState]); const handleDragTouchStart = useCallback(event => { if (event.touches.length === 0 || dragStartYRef.current !== null) { return; } const touch = event.touches[0]; if (!touch) return; activeTouchIdRef.current = touch.identifier; dragStartYRef.current = touch.clientY; hasTriggeredDragCloseRef.current = false; setDragOffset(0); setDragProgress(0); setIsDraggingHandle(true); event.preventDefault(); event.stopPropagation(); }, []); const handleDragTouchMove = useCallback(event => { if (dragStartYRef.current === null || activeTouchIdRef.current === null) { return; } const touch = findTouchByIdentifier(event.changedTouches, activeTouchIdRef.current) ?? findTouchByIdentifier(event.touches, activeTouchIdRef.current); if (!touch) return; event.preventDefault(); event.stopPropagation(); const hasClosed = updateDragByClientY(touch.clientY); if (hasClosed) { activeTouchIdRef.current = null; } }, [updateDragByClientY]); const handleDragTouchEnd = useCallback(event => { if (activeTouchIdRef.current === null) return; const touch = findTouchByIdentifier(event.changedTouches, activeTouchIdRef.current); if (!touch) return; event.preventDefault(); event.stopPropagation(); activeTouchIdRef.current = null; const shouldClose = dragProgress >= 1; resetDragState(shouldClose); }, [dragProgress, resetDragState]); const handleDragTouchCancel = useCallback(event => { if (activeTouchIdRef.current === null) return; const touch = findTouchByIdentifier(event.changedTouches, activeTouchIdRef.current); if (!touch) return; event.preventDefault(); event.stopPropagation(); activeTouchIdRef.current = null; resetDragState(); }, [resetDragState]); const dragHandle = useMemo(() => { if (!enableDragHandle) { return null; } return /*#__PURE__*/React.createElement(StyledMentionFinderDragHandle, { $alignment: popupAlignment }, /*#__PURE__*/React.createElement(StyledMentionFinderDragHandleInner, { $alignment: popupAlignment, $dragOffset: dragOffset, $dragProgress: dragProgress, $isDragging: isDraggingHandle, onPointerDown: handleDragPointerDown, onPointerMove: handleDragPointerMove, onPointerUp: handleDragPointerUp, onPointerCancel: handleDragPointerCancel, onTouchStart: handleDragTouchStart, onTouchMove: handleDragTouchMove, onTouchEnd: handleDragTouchEnd, onTouchCancel: handleDragTouchCancel })); }, [enableDragHandle, popupAlignment, dragOffset, dragProgress, isDraggingHandle, handleDragPointerDown, handleDragPointerMove, handleDragPointerUp, handleDragPointerCancel, handleDragTouchStart, handleDragTouchMove, handleDragTouchEnd, handleDragTouchCancel]); useEffect(() => { if (!enableDragHandle || !shouldRenderPopup) { setOverlayContainer(null); return; } const container = popupRef.current?.closest(overlayContainerSelector ?? '.dialog-inner, .chayns-threads, .page-provider, .tapp'); if (container) { setOverlayContainer(container); return; } if (typeof document !== 'undefined') { setOverlayContainer(document.body); } }, [enableDragHandle, overlayContainerSelector, shouldRenderPopup]); const overlayPortal = overlayContainer && enableDragHandle ? /*#__PURE__*/createPortal(/*#__PURE__*/React.createElement(AnimatePresence, { initial: false }, shouldRenderPopup && /*#__PURE__*/React.createElement(StyledMentionFinderOverlay, { key: "mention-finder-overlay", animate: { opacity: 1 }, exit: { opacity: 0 }, initial: { opacity: 0 }, onClick: handleOverlayClick })), overlayContainer) : null; return /*#__PURE__*/React.createElement(React.Fragment, null, overlayPortal, /*#__PURE__*/React.createElement(StyledMentionFinder, { className: "beta-chayns-mention-finder", onBlur: handleContainerBlur }, /*#__PURE__*/React.createElement(AnimatePresence, { initial: false }, shouldRenderPopup && /*#__PURE__*/React.createElement(StyledMotionMentionFinderPopup, { ref: popupRef, animate: { height: 'auto', opacity: 1, y: dragOffset }, className: "prevent-lose-focus", exit: { height: 0, opacity: 0 }, initial: { height: 0, opacity: 0 }, $popupAlignment: popupAlignment, $isDragging: enableDragHandle, transition: { duration: 0.15 }, tabIndex: 0 }, enableDragHandle && popupAlignment === MentionFinderPopupAlignment.Top && dragHandle, /*#__PURE__*/React.createElement(StyledMentionFinderItemList, { ref: listRef }, items), enableDragHandle && popupAlignment === MentionFinderPopupAlignment.Bottom && dragHandle)))); }; MentionFinder.displayName = 'MentionFinder'; export default MentionFinder; //# sourceMappingURL=MentionFinder.js.map