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 && (