import PropTypes from 'prop-types' import { createContext, useCallback, useContext, useLayoutEffect, useRef, useState } from 'react' import { HolderOutlined } from '@ant-design/icons' import classNames from 'classnames' const SegmentedNavReorderContext = createContext(null) const getEnabledOptions = (options, disabled) => options.filter((option) => !disabled && !option.disabled) const getItemMetrics = (element) => { if (!element) { return null } return { left: element.offsetLeft, top: element.offsetTop, width: element.offsetWidth, height: element.offsetHeight } } export const SegmentedNavDragHandle = ({ value }) => { const reorder = useContext(SegmentedNavReorderContext) const dragProps = reorder?.getDragHandleProps?.(value) if (!dragProps) { return null } return ( event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onDragStart={(event) => dragProps.onDragStart(event)} onDragEnd={dragProps.onDragEnd} > ) } SegmentedNavDragHandle.propTypes = { value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired } const SegmentedNav = ({ value, options = [], onChange, onReorder, className, disabled = false }) => { const groupRef = useRef(null) const thumbRef = useRef(null) const itemRefs = useRef(new Map()) const prevValueRef = useRef(value) const transitionGenRef = useRef(0) const finishGenRef = useRef(0) const [isAnimating, setIsAnimating] = useState(false) const [thumbRect, setThumbRect] = useState(null) const [draggedValue, setDraggedValue] = useState(null) const [dropTarget, setDropTarget] = useState(null) const enabledOptions = getEnabledOptions(options, disabled) const isReordering = Boolean(onReorder && draggedValue != null) const setItemRef = useCallback((optionValue, element) => { if (element) { itemRefs.current.set(optionValue, element) } else { itemRefs.current.delete(optionValue) } }, []) useLayoutEffect(() => { if (prevValueRef.current === value) { return undefined } const fromMetrics = getItemMetrics( itemRefs.current.get(prevValueRef.current) ) const toMetrics = getItemMetrics(itemRefs.current.get(value)) prevValueRef.current = value if (!fromMetrics || !toMetrics) { return undefined } const transitionGen = transitionGenRef.current + 1 transitionGenRef.current = transitionGen setIsAnimating(true) setThumbRect(fromMetrics) let endFrame = 0 const startFrame = requestAnimationFrame(() => { endFrame = requestAnimationFrame(() => { if (transitionGenRef.current === transitionGen) { finishGenRef.current = transitionGen setThumbRect(toMetrics) } }) }) return () => { cancelAnimationFrame(startFrame) cancelAnimationFrame(endFrame) } }, [value]) const finishAnimation = useCallback(() => { setIsAnimating(false) setThumbRect(null) }, []) const handleThumbTransitionEnd = useCallback( (event) => { if (event.target !== thumbRef.current) { return } if ( (event.propertyName === 'transform' || event.propertyName === 'width') && finishGenRef.current === transitionGenRef.current ) { finishAnimation() } }, [finishAnimation] ) const clearDragState = useCallback(() => { setDraggedValue(null) setDropTarget(null) }, []) const handleDragStart = useCallback( (event, optionValue) => { if (!onReorder) return event.stopPropagation() const item = event.currentTarget.closest('.segmented-nav-item') if (item) { const rect = item.getBoundingClientRect() event.dataTransfer.setDragImage( item, event.clientX - rect.left, event.clientY - rect.top ) } setDraggedValue(optionValue) event.dataTransfer.effectAllowed = 'move' event.dataTransfer.setData('text/plain', String(optionValue)) }, [onReorder] ) const handleDragEnd = useCallback(() => { clearDragState() }, [clearDragState]) const handleItemDragOver = useCallback( (event, option) => { if (!onReorder || draggedValue == null || !option.reorderable) { return } if (String(option.value) === String(draggedValue)) { return } event.preventDefault() event.stopPropagation() event.dataTransfer.dropEffect = 'move' const rect = event.currentTarget.getBoundingClientRect() setDropTarget({ value: option.value, insertBefore: event.clientX < rect.left + rect.width / 2 }) }, [draggedValue, onReorder] ) const handleItemDrop = useCallback( (event, option) => { if (!onReorder || draggedValue == null || !option.reorderable) { return } event.preventDefault() event.stopPropagation() const target = dropTarget || { value: option.value, insertBefore: true } if (String(draggedValue) !== String(target.value)) { onReorder(draggedValue, target.value, target.insertBefore) } clearDragState() }, [clearDragState, draggedValue, dropTarget, onReorder] ) const handleGroupDragOver = useCallback( (event) => { if (!onReorder || draggedValue == null) { return } const reorderableOptions = options.filter((option) => option.reorderable) if (reorderableOptions.length === 0) { return } if ( event.target instanceof Element && event.target.closest('.segmented-nav-item') ) { return } event.preventDefault() event.dataTransfer.dropEffect = 'move' const lastOption = reorderableOptions[reorderableOptions.length - 1] setDropTarget({ value: lastOption.value, insertBefore: false }) }, [draggedValue, onReorder, options] ) const handleGroupDrop = useCallback( (event) => { if (!onReorder || draggedValue == null) { return } if ( event.target instanceof Element && event.target.closest('.segmented-nav-item') ) { return } event.preventDefault() event.stopPropagation() const reorderableOptions = options.filter((option) => option.reorderable) const lastOption = reorderableOptions[reorderableOptions.length - 1] if (lastOption && String(draggedValue) !== String(lastOption.value)) { onReorder(draggedValue, lastOption.value, false) } clearDragState() }, [clearDragState, draggedValue, onReorder, options] ) const getDragHandleProps = useCallback( (optionValue) => { const option = options.find( (item) => String(item.value) === String(optionValue) ) if (!onReorder || !option?.reorderable) { return null } return { onDragStart: (event) => handleDragStart(event, optionValue), onDragEnd: handleDragEnd } }, [handleDragEnd, handleDragStart, onReorder, options] ) const handleKeyDown = (event, optionIndex) => { if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') { return } event.preventDefault() const direction = event.key === 'ArrowRight' ? 1 : -1 const currentEnabledIndex = enabledOptions.findIndex( (option) => option.value === value ) const startIndex = currentEnabledIndex === -1 ? optionIndex : currentEnabledIndex const nextIndex = (startIndex + direction + enabledOptions.length) % enabledOptions.length onChange?.(enabledOptions[nextIndex].value) } const reorderContextValue = onReorder ? { getDragHandleProps } : null return (
{isAnimating && thumbRect && (
) } SegmentedNav.propTypes = { value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), options: PropTypes.arrayOf( PropTypes.shape({ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) .isRequired, label: PropTypes.node, disabled: PropTypes.bool, reorderable: PropTypes.bool, className: PropTypes.string }) ), onChange: PropTypes.func, onReorder: PropTypes.func, className: PropTypes.string, disabled: PropTypes.bool } export default SegmentedNav