feat(composer): drag-to-reorder To/Cc/Bcc recipient chips (#593)
Recipient chips could already be dragged between the To/Cc/Bcc fields, but a drop always appended and same-field drops were a no-op, so recipients could not be rearranged without deleting and re-adding them. Add positional drag-and-drop: while dragging a chip, an insertion caret shows the gap it would land in (based on which half of the hovered chip the pointer is over, mirrored for RTL); dropping inserts it there. - same-field drop reorders the chip locally (via onChipsChange), using the source index carried in the drag payload (fromIndex) and adjusting for the removal shift; dropping onto its own position is a no-op; - cross-field drop inserts at the drop position: handleMoveChip gained an optional toIndex (omitted = append, e.g. dropping onto a hidden Cc/Bcc button, preserving existing behaviour); - per-chip onDragOver computes the target gap; the container handles the trailing gap (past the last chip / over the input). No new user-facing strings (the caret is purely visual), so no locale changes. Tests (components/email/__tests__/recipient-chip-drag.test.tsx): reorder to end / front, self-drop no-op, cross-field positional insert, and caret visibility. Also add the missing findComposeIdentityId export to the reply-identity mock in the recipient drag/paste suites so <EmailComposer> mounts in compose mode.
This commit is contained in:
committed by
Linus Rath
parent
9072bf8470
commit
37152504b4
@@ -230,7 +230,7 @@ describe('RecipientChipInput drag and drop', () => {
|
|||||||
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||||
|
|
||||||
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
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 () => {
|
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();
|
const dt = new MockDataTransfer();
|
||||||
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||||
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
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 () => {
|
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');
|
const ccLabel = await screen.findByText('cc_label');
|
||||||
expect(ccLabel).toBeInTheDocument();
|
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(<EmailComposer initialData={THREE} />);
|
||||||
|
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(<EmailComposer initialData={THREE} />);
|
||||||
|
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(<EmailComposer initialData={THREE} />);
|
||||||
|
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(<EmailComposer initialData={{ ...BASE_DATA, to: 'alice@example.com, ', cc: 'x@example.com, y@example.com, ' }} />);
|
||||||
|
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(<EmailComposer initialData={THREE} />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -936,7 +936,10 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}, [plainTextMode]);
|
}, [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;
|
if (fromField === toField) return;
|
||||||
const setters = { to: setTo, cc: setCc, bcc: setBcc };
|
const setters = { to: setTo, cc: setCc, bcc: setBcc };
|
||||||
const groupKey = (r: Recipient) => r.group ? r.group.members.map(m => m.email.toLowerCase()).join(',') : '';
|
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));
|
const idx = prev.findIndex(r => sameRecipient(r, recipient));
|
||||||
return idx === -1 ? prev : prev.filter((_, i) => i !== idx);
|
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 === 'cc') setShowCc(true);
|
||||||
if (toField === 'bcc') setShowBcc(true);
|
if (toField === 'bcc') setShowBcc(true);
|
||||||
}, [setTo, setCc, setBcc, setShowCc, setShowBcc]);
|
}, [setTo, setCc, setBcc, setShowCc, setShowBcc]);
|
||||||
@@ -2895,7 +2904,7 @@ function RecipientChipInput({
|
|||||||
validationError?: boolean;
|
validationError?: boolean;
|
||||||
validationMessage?: string;
|
validationMessage?: string;
|
||||||
onTab?: () => void;
|
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 t = useTranslations('email_composer');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
@@ -2904,6 +2913,9 @@ function RecipientChipInput({
|
|||||||
const [editValue, setEditValue] = useState('');
|
const [editValue, setEditValue] = useState('');
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
const [draggingIndex, setDraggingIndex] = useState<number | null>(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<number | null>(null);
|
||||||
const editInputRef = useRef<HTMLInputElement | null>(null);
|
const editInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Focus edit input when editing starts
|
// Focus edit input when editing starts
|
||||||
@@ -3060,27 +3072,81 @@ function RecipientChipInput({
|
|||||||
onAutoBlur(e, field);
|
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) => {
|
const handleContainerDragOver = (e: React.DragEvent) => {
|
||||||
if (!e.dataTransfer.types.includes('application/x-recipient-chip')) return;
|
if (!isChipDrag(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
setIsDragOver(true);
|
setIsDragOver(true);
|
||||||
|
setDropIndex(chips.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContainerDragLeave = (e: React.DragEvent) => {
|
const handleContainerDragLeave = (e: React.DragEvent) => {
|
||||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
setIsDragOver(false);
|
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) => {
|
const handleContainerDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
performDrop(e, dropIndex ?? chips.length);
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -3100,20 +3166,29 @@ function RecipientChipInput({
|
|||||||
const isEditing = editingChip?.index === i;
|
const isEditing = editingChip?.index === i;
|
||||||
const chipDisplay = formatChipDisplay(chip);
|
const chipDisplay = formatChipDisplay(chip);
|
||||||
return (
|
return (
|
||||||
|
<React.Fragment key={`${chip.email}-${i}`}>
|
||||||
|
{dropIndex === i && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-testid="recipient-drop-caret"
|
||||||
|
className="w-0.5 self-stretch min-h-[20px] rounded-full bg-primary pointer-events-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
key={`${chip.email}-${i}`}
|
|
||||||
draggable={!isEditing}
|
draggable={!isEditing}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
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.
|
// Show the address while dragging, matching the email-list drag preview.
|
||||||
const dragPreview = createChipDragPreview(chip.group ? chipDisplay : chip.email);
|
const dragPreview = createChipDragPreview(chip.group ? chipDisplay : chip.email);
|
||||||
e.dataTransfer.setDragImage(dragPreview, 0, 0);
|
e.dataTransfer.setDragImage(dragPreview, 0, 0);
|
||||||
requestAnimationFrame(() => dragPreview.remove());
|
requestAnimationFrame(() => dragPreview.remove());
|
||||||
setDraggingIndex(i);
|
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(
|
className={cn(
|
||||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-sm border border-border transition-colors",
|
"inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-sm border border-border transition-colors",
|
||||||
isEditing
|
isEditing
|
||||||
@@ -3179,8 +3254,16 @@ function RecipientChipInput({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{dropIndex === chips.length && chips.length > 0 && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-testid="recipient-drop-caret"
|
||||||
|
className="w-0.5 self-stretch min-h-[20px] rounded-full bg-primary pointer-events-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!editingChip && (
|
{!editingChip && (
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
Reference in New Issue
Block a user