import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core"; import { closestCenter, DndContext, DragOverlay, KeyboardSensor, PointerSensor, useSensor, useSensors, } from "@dnd-kit/core"; import { rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { PencilSimpleIcon, XIcon } from "@phosphor-icons/react"; import { AnimatePresence, m } from "motion/react"; import * as React from "react"; import { createPortal } from "react-dom"; import { Badge } from "@reactive-resume/ui/components/badge"; import { Input } from "@reactive-resume/ui/components/input"; import { Kbd } from "@reactive-resume/ui/components/kbd"; import { cn } from "@reactive-resume/utils/style"; import { useControlledState } from "@/hooks/use-controlled-state"; const RETURN_KEY = "Enter"; const COMMA_KEY = ","; const EMPTY_CHIPS: string[] = []; type ChipItemProps = { id: string; chip: string; index: number; isEditing: boolean; onEdit: (index: number) => void; onRemove: (index: number) => void; }; type ChipDragPreviewProps = { chip: string; }; function ChipDragPreview({ chip }: ChipDragPreviewProps) { return ( {chip} ); } type ChipDragOverlayProps = { activeChip: string | null; }; function ChipDragOverlay({ activeChip }: ChipDragOverlayProps) { const overlay = ( {activeChip ? : null} ); if (typeof document === "undefined") return overlay; return createPortal(overlay, document.body); } function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style = { transition, zIndex: isDragging ? 10 : undefined, transform: CSS.Transform.toString(transform), }; return ( {chip} ); } type Props = Omit, "value" | "onChange"> & { value?: string[]; defaultValue?: string[]; onChange?: (value: string[]) => void; hideDescription?: boolean; }; export function ChipInput({ value, defaultValue = EMPTY_CHIPS, onChange, className, hideDescription = false, ...props }: Props) { const [chips, setChips] = useControlledState({ value, defaultValue, onChange, }); const [input, setInput] = React.useState(""); const [editingIndex, setEditingIndex] = React.useState(null); const [activeChip, setActiveChip] = React.useState(null); const inputRef = React.useRef(null); const dndContextId = React.useId(); const isEditingKeyword = editingIndex !== null; const hasChips = chips.length > 0; const addChips = React.useCallback( (values: string[]) => { const nextValues = values.flatMap((chip) => { const trimmed = chip.trim(); return trimmed ? [trimmed] : []; }); if (nextValues.length === 0) return; const newChips = [...new Set([...chips, ...nextValues])]; setChips(newChips); }, [chips, setChips], ); const addChip = React.useCallback( (chip: string) => { addChips([chip]); }, [addChips], ); const updateChip = React.useCallback( (index: number, newValue: string) => { const trimmed = newValue.trim(); if (!trimmed || index < 0 || index >= chips.length) return; const existingIndex = chips.findIndex((c, i) => c === trimmed && i !== index); if (existingIndex !== -1) return; const newChips = [...chips]; newChips[index] = trimmed; setChips(newChips); }, [chips, setChips], ); const removeChip = React.useCallback( (index: number) => { if (index < 0 || index >= chips.length) return; const newChips = chips.slice(0, index).concat(chips.slice(index + 1)); setChips(newChips); if (editingIndex === index) { setEditingIndex(null); setInput(""); } else if (editingIndex !== null && editingIndex > index) { setEditingIndex((current) => (current !== null && current > index ? current - 1 : current)); } }, [chips, setChips, editingIndex], ); const handleEdit = React.useCallback( (index: number) => { setEditingIndex(index); setInput(chips[index]); inputRef.current?.focus(); }, [chips], ); const handleReorder = React.useCallback( (newOrder: string[]) => { if (editingIndex !== null) { const editingChip = chips[editingIndex]; const newIndex = newOrder.indexOf(editingChip); if (newIndex !== -1 && newIndex !== editingIndex) { setEditingIndex(newIndex); } } setChips(newOrder); }, [chips, editingIndex, setChips], ); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 3 }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const handleDragStart = React.useCallback((event: DragStartEvent) => { setActiveChip(event.active.id as string); }, []); const handleDragCancel = React.useCallback(() => { setActiveChip(null); }, []); const handleDragEnd = React.useCallback( (event: DragEndEvent) => { setActiveChip(null); const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = chips.indexOf(active.id as string); const newIndex = chips.indexOf(over.id as string); if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { const newOrder = [...chips]; const [removed] = newOrder.splice(oldIndex, 1); newOrder.splice(newIndex, 0, removed); handleReorder(newOrder); } }, [chips, handleReorder], ); const handleInputChange = React.useCallback( (e: React.ChangeEvent) => { const newValue = e.target.value; if (editingIndex !== null) { if (newValue.includes(",")) { updateChip(editingIndex, newValue.replace(",", "")); setEditingIndex(null); setInput(""); } else { setInput(newValue); } return; } if (newValue.includes(",")) { const parts = newValue.split(","); addChips(parts.slice(0, -1)); setInput(parts[parts.length - 1]); } else { setInput(newValue); } }, [addChips, editingIndex, updateChip], ); const handleKeyDown = React.useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === ",") { e.preventDefault(); if (editingIndex !== null) { if (input.trim()) { updateChip(editingIndex, input); } setEditingIndex(null); setInput(""); } else if (input.trim()) { addChip(input); setInput(""); } } else if (e.key === "Escape" && editingIndex !== null) { setEditingIndex(null); setInput(""); } }, [input, addChip, editingIndex, updateChip], ); return (
inputRef.current?.focus()} className="overflow-hidden rounded-lg border border-input bg-background/40 transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/20" >
{chips.map((chip, idx) => ( ))}
{chips.length > 0 && ( {isEditingKeyword ? Edit : chips.length} )}
{!hideDescription && (

Press {RETURN_KEY} or {COMMA_KEY} to add or save the current keyword.

)}
); }