diff --git a/components/email/__tests__/recipient-chip-drag.test.tsx b/components/email/__tests__/recipient-chip-drag.test.tsx index 68f90dec..f3138b38 100644 --- a/components/email/__tests__/recipient-chip-drag.test.tsx +++ b/components/email/__tests__/recipient-chip-drag.test.tsx @@ -230,7 +230,7 @@ describe('RecipientChipInput drag and drop', () => { fireEvent.dragStart(chipSpan, { dataTransfer: dt }); const payload = JSON.parse(dt.getData('application/x-recipient-chip')); - expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' }); + expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to', fromIndex: 0 }); }); it('keeps a display name with a comma in a single chip (array model)', async () => { @@ -244,7 +244,7 @@ describe('RecipientChipInput drag and drop', () => { const dt = new MockDataTransfer(); fireEvent.dragStart(chipSpan, { dataTransfer: dt }); const payload = JSON.parse(dt.getData('application/x-recipient-chip')); - expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' }); + expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to', fromIndex: 0 }); }); it('onDragEnd clears the opacity class on the chip', async () => { @@ -343,4 +343,121 @@ describe('RecipientChipInput drag and drop', () => { const ccLabel = await screen.findByText('cc_label'); expect(ccLabel).toBeInTheDocument(); }); + + // ─── Reordering within / across fields (#593) ───────────────────────────────── + // jsdom ignores `clientX` in fireEvent's init for drag events (it's a + // read-only MouseEvent getter) and gives every element a zero-size rect at + // (0,0). So we dispatch events with `clientX` forced via defineProperty; with + // the rect midpoint at 0, clientX>0 lands AFTER the hovered chip, <0 BEFORE. + + const THREE = { ...BASE_DATA, to: 'alice@example.com, bob@example.com, carol@example.com, ' }; + + const chipByText = async (text: string) => + (await screen.findByText(text)).closest('[draggable]') as HTMLElement; + + /** Dispatch a drag event with a real clientX (fireEvent init drops it). */ + const fireDnd = (type: 'dragover' | 'drop', el: HTMLElement, dt: MockDataTransfer, clientX: number) => { + const e = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(e, 'clientX', { value: clientX }); + Object.defineProperty(e, 'dataTransfer', { value: dt }); + act(() => { fireEvent(el, e); }); + }; + const BEFORE = -100; + const AFTER = 100; + + /** Ordered chip labels of the field-container that holds `anchorText`. */ + const orderIn = (anchorText: string) => { + const containers = Array.from(document.querySelectorAll('[class*="flex-wrap"]')); + const c = containers.find(el => + Array.from(el.querySelectorAll('[draggable]')).some(d => d.textContent?.includes(anchorText)) + ) as HTMLElement; + return Array.from(c.querySelectorAll('[draggable]')).map(el => el.textContent?.trim() ?? ''); + }; + + /** All draggable chips (across fields) whose label contains `text`. */ + const draggableChipsWith = (text: string) => + Array.from(document.querySelectorAll('[draggable]')).filter(el => el.textContent?.includes(text)); + + it('reorders a chip to the end of the same field (drop after the last chip)', async () => { + render(); + await screen.findByText('alice@example.com'); + const alice = await chipByText('alice@example.com'); + const carol = await chipByText('carol@example.com'); + + const dt = new MockDataTransfer(); + fireEvent.dragStart(alice, { dataTransfer: dt }); // fromIndex 0 + fireDnd('dragover', carol, dt, AFTER); // after carol -> index 3 + fireDnd('drop', carol, dt, AFTER); + + expect(orderIn('bob@example.com')).toEqual([ + 'bob@example.com', 'carol@example.com', 'alice@example.com', + ]); + }); + + it('reorders a chip to the front of the same field (drop before the first chip)', async () => { + render(); + await screen.findByText('carol@example.com'); + const carol = await chipByText('carol@example.com'); + const alice = await chipByText('alice@example.com'); + + const dt = new MockDataTransfer(); + fireEvent.dragStart(carol, { dataTransfer: dt }); // fromIndex 2 + fireDnd('dragover', alice, dt, BEFORE); // before alice -> index 0 + fireDnd('drop', alice, dt, BEFORE); + + expect(orderIn('alice@example.com')).toEqual([ + 'carol@example.com', 'alice@example.com', 'bob@example.com', + ]); + }); + + it('dropping a chip onto its own position leaves the order unchanged', async () => { + render(); + await screen.findByText('bob@example.com'); + const bob = await chipByText('bob@example.com'); + + const dt = new MockDataTransfer(); + fireEvent.dragStart(bob, { dataTransfer: dt }); // fromIndex 1 + fireDnd('dragover', bob, dt, BEFORE); // before itself -> index 1 (no-op) + fireDnd('drop', bob, dt, BEFORE); + + expect(orderIn('bob@example.com')).toEqual([ + 'alice@example.com', 'bob@example.com', 'carol@example.com', + ]); + }); + + it('moves a chip into another field at the drop position (cross-field reorder)', async () => { + render(); + await screen.findByText('alice@example.com'); + const alice = await chipByText('alice@example.com'); // To + const y = await chipByText('y@example.com'); // Cc + + const dt = new MockDataTransfer(); + fireEvent.dragStart(alice, { dataTransfer: dt }); + fireDnd('dragover', y, dt, BEFORE); // before y -> index 1 in Cc + fireDnd('drop', y, dt, BEFORE); + + // alice lands between x and y; To no longer holds it (count only real chips, + // not the leftover jsdom drag-preview element) + expect(orderIn('x@example.com')).toEqual([ + 'x@example.com', 'alice@example.com', 'y@example.com', + ]); + expect(draggableChipsWith('alice@example.com')).toHaveLength(1); + }); + + it('shows a drop caret only while a chip is dragged over the field', async () => { + render(); + await screen.findByText('alice@example.com'); + const alice = await chipByText('alice@example.com'); + const bob = await chipByText('bob@example.com'); + + const dt = new MockDataTransfer(); + fireEvent.dragStart(alice, { dataTransfer: dt }); + expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull(); + + fireDnd('dragover', bob, dt, BEFORE); + expect(document.querySelector('[data-testid="recipient-drop-caret"]')).not.toBeNull(); + + fireEvent.dragEnd(alice); + expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull(); + }); }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index df7543bf..8d41f476 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -936,7 +936,10 @@ export function EmailComposer({ } }, [plainTextMode]); - const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => { + // Move a chip from one recipient field to another. `toIndex`, when given, + // inserts at that position in the destination (drag-and-drop reordering, + // #593); omitted, it appends (e.g. dropping onto a hidden Cc/Bcc button). + const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => { if (fromField === toField) return; const setters = { to: setTo, cc: setCc, bcc: setBcc }; const groupKey = (r: Recipient) => r.group ? r.group.members.map(m => m.email.toLowerCase()).join(',') : ''; @@ -946,7 +949,13 @@ export function EmailComposer({ const idx = prev.findIndex(r => sameRecipient(r, recipient)); return idx === -1 ? prev : prev.filter((_, i) => i !== idx); }); - setters[toField](prev => prev.some(r => sameRecipient(r, recipient)) ? prev : [...prev, recipient]); + setters[toField](prev => { + if (prev.some(r => sameRecipient(r, recipient))) return prev; + const at = toIndex == null ? prev.length : Math.max(0, Math.min(toIndex, prev.length)); + const next = [...prev]; + next.splice(at, 0, recipient); + return next; + }); if (toField === 'cc') setShowCc(true); if (toField === 'bcc') setShowBcc(true); }, [setTo, setCc, setBcc, setShowCc, setShowBcc]); @@ -2895,7 +2904,7 @@ function RecipientChipInput({ validationError?: boolean; validationMessage?: string; onTab?: () => void; - onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => void; + onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => void; }) { const t = useTranslations('email_composer'); const tCommon = useTranslations('common'); @@ -2904,6 +2913,9 @@ function RecipientChipInput({ const [editValue, setEditValue] = useState(''); const [isDragOver, setIsDragOver] = useState(false); const [draggingIndex, setDraggingIndex] = useState(null); + // Gap (0..chips.length) a dragged chip would drop into; drives the insertion + // caret and positional drop for reordering (#593). null when not dragging. + const [dropIndex, setDropIndex] = useState(null); const editInputRef = useRef(null); // Focus edit input when editing starts @@ -3060,27 +3072,81 @@ function RecipientChipInput({ onAutoBlur(e, field); }; + const isChipDrag = (e: React.DragEvent) => + e.dataTransfer.types.includes('application/x-recipient-chip'); + + // Dragging over empty container space (past the last chip / over the input) + // targets the end of the list. const handleContainerDragOver = (e: React.DragEvent) => { - if (!e.dataTransfer.types.includes('application/x-recipient-chip')) return; + if (!isChipDrag(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setIsDragOver(true); + setDropIndex(chips.length); }; const handleContainerDragLeave = (e: React.DragEvent) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) { setIsDragOver(false); + setDropIndex(null); + } + }; + + // Dragging over a chip picks the gap before or after it based on which half + // the pointer is in (mirrored for RTL). stopPropagation keeps the container + // handler from overriding this finer target. + const handleChipDragOver = (e: React.DragEvent, index: number) => { + if (!isChipDrag(e)) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'move'; + const rect = e.currentTarget.getBoundingClientRect(); + const rtl = typeof window !== 'undefined' && + getComputedStyle(e.currentTarget as Element).direction === 'rtl'; + const past = rtl + ? e.clientX < rect.left + rect.width / 2 + : e.clientX > rect.left + rect.width / 2; + setIsDragOver(true); + setDropIndex(past ? index + 1 : index); + }; + + // Insert the dragged chip at `target`. Same-field is a local reorder; + // cross-field routes through onMoveChip with the destination index (#593). + const performDrop = (e: React.DragEvent, target: number) => { + e.preventDefault(); + setIsDragOver(false); + setDropIndex(null); + setDraggingIndex(null); + const raw = e.dataTransfer.getData('application/x-recipient-chip'); + if (!raw) return; + let payload: { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc'; fromIndex?: number }; + try { + payload = JSON.parse(raw); + } catch { + return; + } + const { recipient, fromField, fromIndex } = payload; + const to = Math.max(0, Math.min(target, chips.length)); + + if (fromField === field) { + const from = typeof fromIndex === 'number' + ? fromIndex + : chips.findIndex(c => c.email === recipient.email && (c.name ?? '') === (recipient.name ?? '')); + if (from < 0 || from >= chips.length) return; + // Removing the source before `to` shifts the target left by one. + const insertAt = to > from ? to - 1 : to; + if (insertAt === from) return; // dropped onto its own position + const next = [...chips]; + const [moved] = next.splice(from, 1); + next.splice(insertAt, 0, moved); + onChipsChange(next); + } else { + onMoveChip(recipient, fromField, field, to); } }; const handleContainerDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - const raw = e.dataTransfer.getData('application/x-recipient-chip'); - if (!raw) return; - const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' }; - if (fromField === field) return; - onMoveChip(recipient, fromField, field); + performDrop(e, dropIndex ?? chips.length); }; return ( @@ -3100,20 +3166,29 @@ function RecipientChipInput({ const isEditing = editingChip?.index === i; const chipDisplay = formatChipDisplay(chip); return ( + + {dropIndex === i && ( + + )} { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field })); + e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field, fromIndex: i })); // Show the address while dragging, matching the email-list drag preview. const dragPreview = createChipDragPreview(chip.group ? chipDisplay : chip.email); e.dataTransfer.setDragImage(dragPreview, 0, 0); requestAnimationFrame(() => dragPreview.remove()); setDraggingIndex(i); }} - onDragEnd={() => setDraggingIndex(null)} + onDragEnd={() => { setDraggingIndex(null); setDropIndex(null); }} + onDragOver={(e) => handleChipDragOver(e, i)} + onDrop={(e) => { e.stopPropagation(); performDrop(e, dropIndex ?? i); }} className={cn( "inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-sm border border-border transition-colors", isEditing @@ -3179,8 +3254,16 @@ function RecipientChipInput({ )} + ); })} + {dropIndex === chips.length && chips.length > 0 && ( + + )} {!editingChip && (