@chayns-components/core
Version:
A set of beautiful React components for developing your own applications with chayns.
382 lines • 14.4 kB
JavaScript
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