From 1ebfb286ad3080026325fe6719fa06969c1f946c Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:40:46 +0200 Subject: [PATCH] feat: drag and drop recipient chips between To/CC/BCC fields Adds native HTML5 drag-and-drop so users can move recipient email address chips between the To, CC, and BCC fields in the composer. Chips dragged onto the Cc/Bcc toggle buttons auto-reveal the hidden field and place the chip there. --- .../__tests__/recipient-chip-drag.test.tsx | 345 ++++++++++++++++++ components/email/email-composer.tsx | 95 ++++- lib/__tests__/email-composer-utils.test.ts | 59 +++ lib/email-composer-utils.ts | 33 ++ 4 files changed, 528 insertions(+), 4 deletions(-) create mode 100644 components/email/__tests__/recipient-chip-drag.test.tsx diff --git a/components/email/__tests__/recipient-chip-drag.test.tsx b/components/email/__tests__/recipient-chip-drag.test.tsx new file mode 100644 index 00000000..47005c88 --- /dev/null +++ b/components/email/__tests__/recipient-chip-drag.test.tsx @@ -0,0 +1,345 @@ +import { render, screen, act } from '@testing-library/react'; +import { fireEvent } from '@testing-library/dom'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +// ─── Heavy component mocks ──────────────────────────────────────────────────── + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: ({ onChange }: { onChange?: (html: string) => void }) => ( + React.createElement('div', { 'data-testid': 'rich-text-editor', onClick: () => onChange?.('') }) + ), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ ref: { current: null } }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +// ─── Store mocks ────────────────────────────────────────────────────────────── +// vi.mock factories are hoisted, so all values must be defined inline. + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { identities: [], defaultIdentityId: null }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/smime-store', () => { + const state = { + certs: [], + signingEnabled: false, + encryptionEnabled: false, + defaultSigningCertId: null, + defaultEncryptionCertId: null, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSmimeStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { + draftSaveEnabled: false, + sendRawEmail: async () => ({ sent: true }), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + sendDelaySeconds: 0, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {} }, +})); + +vi.mock('@/lib/plugin-hooks', () => ({ + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onSend: { call: async () => [] }, + beforeSend: { call: async () => [] }, + }, + contactHooks: { + search: { call: async () => [] }, + }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, +})); + +vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null })); +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/signature-utils', () => ({ + appendPlainTextSignature: (body: string) => body, + getPlainTextSignature: () => '', +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/smime/smime-sign', () => ({ smimeSign: async () => null })); +vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null })); +vi.mock('@/lib/smime/mime-builder', () => ({ + buildMimeMessage: () => null, + wrapCmsAsSmimeMessage: () => null, +})); +vi.mock('@/lib/debug', () => ({ debug: () => {} })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +// ─── DataTransfer polyfill ──────────────────────────────────────────────────── + +/** jsdom's built-in DataTransfer doesn't fully support setData/getData in synthetic drag events. */ +class MockDataTransfer { + private _data: Record = {}; + types: string[] = []; + effectAllowed = ''; + dropEffect = ''; + + setData(type: string, data: string) { + this._data[type] = data; + if (!this.types.includes(type)) this.types.push(type); + } + + getData(type: string): string { + return this._data[type] ?? ''; + } +} + +// ─── Shared test data ───────────────────────────────────────────────────────── + +const BASE_DATA = { + to: 'alice@example.com, ', + cc: '', + bcc: '', + subject: '', + body: '', + showCc: true, + showBcc: true, + selectedIdentityId: null, + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('RecipientChipInput drag and drop', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('renders recipient chips with draggable="true"', async () => { + render(); + + const chipText = await screen.findByText('alice@example.com'); + const chipSpan = chipText.closest('[draggable]'); + expect(chipSpan).not.toBeNull(); + expect(chipSpan).toHaveAttribute('draggable', 'true'); + }); + + it('onDragStart encodes chip value and source field into dataTransfer', async () => { + render(); + + const chipText = await screen.findByText('alice@example.com'); + const chipSpan = chipText.closest('[draggable]') as HTMLElement; + + const dt = new MockDataTransfer(); + fireEvent.dragStart(chipSpan, { dataTransfer: dt }); + + const payload = JSON.parse(dt.getData('application/x-recipient-chip')); + expect(payload).toEqual({ chip: 'alice@example.com', fromField: 'to' }); + }); + + it('onDragEnd clears the opacity class on the chip', async () => { + render(); + + const chipText = await screen.findByText('alice@example.com'); + const chipSpan = chipText.closest('[draggable]') as HTMLElement; + + fireEvent.dragStart(chipSpan, { dataTransfer: new MockDataTransfer() }); + expect(chipSpan.className).toContain('opacity-50'); + + fireEvent.dragEnd(chipSpan); + expect(chipSpan.className).not.toContain('opacity-50'); + }); + + it('dragOver on a different field container adds ring indicator; dragLeave removes it', async () => { + render(); + + await screen.findByText('alice@example.com'); + + // The flex-wrap containers are the actual drop zones + const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]')); + // To-container has the draggable chip; cc-container doesn't + const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement; + const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement; + + if (!ccContainer) return; + + const dt = new MockDataTransfer(); + dt.setData('application/x-recipient-chip', JSON.stringify({ chip: 'alice@example.com', fromField: 'to' })); + + fireEvent.dragOver(ccContainer, { dataTransfer: dt }); + expect(ccContainer.className).toContain('ring-primary'); + + fireEvent.dragLeave(ccContainer, { relatedTarget: null }); + expect(ccContainer.className).not.toContain('ring-primary'); + }); + + it('drop on a different field container moves the chip', async () => { + render(); + await screen.findByText('alice@example.com'); + + const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]')); + const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement; + const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement; + + if (!toContainer || !ccContainer) return; + + const dt = new MockDataTransfer(); + dt.setData('application/x-recipient-chip', JSON.stringify({ chip: 'alice@example.com', fromField: 'to' })); + fireEvent.dragOver(ccContainer, { dataTransfer: dt }); + act(() => { + fireEvent.drop(ccContainer, { dataTransfer: dt }); + }); + + // Chip should still appear exactly once (moved, not duplicated or lost) + await screen.findByText('alice@example.com'); + expect(screen.getAllByText('alice@example.com')).toHaveLength(1); + + // The To container must now be empty + expect(toContainer.querySelectorAll('[draggable]')).toHaveLength(0); + }); + + it('drop on the same field container is a no-op', async () => { + render(); + await screen.findByText('alice@example.com'); + + const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]')); + const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement; + + const dt = new MockDataTransfer(); + dt.setData('application/x-recipient-chip', JSON.stringify({ chip: 'alice@example.com', fromField: 'to' })); + fireEvent.dragOver(toContainer, { dataTransfer: dt }); + act(() => { + fireEvent.drop(toContainer, { dataTransfer: dt }); + }); + + // Chip stays present exactly once + expect(screen.getAllByText('alice@example.com')).toHaveLength(1); + }); + + it('dropping a chip onto the hidden Cc button shows the CC field', async () => { + render(); + await screen.findByText('alice@example.com'); + + const ccButton = screen.getByRole('button', { name: 'Cc' }); + + const dt = new MockDataTransfer(); + dt.setData('application/x-recipient-chip', JSON.stringify({ chip: 'alice@example.com', fromField: 'to' })); + fireEvent.dragOver(ccButton, { dataTransfer: dt }); + act(() => { + fireEvent.drop(ccButton, { dataTransfer: dt }); + }); + + // cc_label is rendered by the mock translation as its key string + const ccLabel = await screen.findByText('cc_label'); + expect(ccLabel).toBeInTheDocument(); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 22122143..b577babb 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -45,6 +45,8 @@ import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { rewriteCidImagesForEditor, replaceInlineImagePlaceholders, + removeChipFromFieldValue, + addChipToFieldValue, } from "@/lib/email-composer-utils"; import { RichTextEditor } from "@/components/email/rich-text-editor"; import type { Editor } from "@tiptap/react"; @@ -436,6 +438,8 @@ export function EmailComposer({ const [body, setBody] = useState(initialData?.body ?? getInitialBody()); const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc()); const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false); + const [isDraggingChipOverCc, setIsDraggingChipOverCc] = useState(false); + const [isDraggingChipOverBcc, setIsDraggingChipOverBcc] = useState(false); const [requestReadReceipt, setRequestReadReceipt] = useState(requestReadReceiptDefault); const [draftId, setDraftId] = useState(initialData?.draftId ?? null); // Mirror of draftId for synchronous reads inside chained saves; React's @@ -866,6 +870,14 @@ export function EmailComposer({ } }, [plainTextMode]); + const handleMoveChip = useCallback((chip: string, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => { + const setters = { to: setTo, cc: setCc, bcc: setBcc }; + setters[fromField](prev => removeChipFromFieldValue(prev, chip)); + setters[toField](prev => addChipToFieldValue(prev, chip)); + if (toField === 'cc') setShowCc(true); + if (toField === 'bcc') setShowBcc(true); + }, [setTo, setCc, setBcc, setShowCc, setShowBcc]); + const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => { if (autocompleteTimeoutRef.current) { clearTimeout(autocompleteTimeoutRef.current); @@ -2126,13 +2138,30 @@ export function EmailComposer({ validationError={validationErrors.to} validationMessage={t('validation.recipient_required')} onTab={focusSubject} + onMoveChip={handleMoveChip} />
@@ -2140,7 +2169,23 @@ export function EmailComposer({ variant="ghost" size="sm" onClick={() => setShowBcc(!showBcc)} - className="text-xs h-7 px-2" + className={cn("text-xs h-7 px-2", isDraggingChipOverBcc && "ring-2 ring-primary/50")} + onDragOver={(e) => { + if (!e.dataTransfer.types.includes('application/x-recipient-chip')) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setIsDraggingChipOverBcc(true); + }} + onDragLeave={() => setIsDraggingChipOverBcc(false)} + onDrop={(e) => { + e.preventDefault(); + setIsDraggingChipOverBcc(false); + const raw = e.dataTransfer.getData('application/x-recipient-chip'); + if (!raw) return; + const { chip, fromField } = JSON.parse(raw) as { chip: string; fromField: 'to' | 'cc' | 'bcc' }; + if (fromField !== 'bcc') handleMoveChip(chip, fromField, 'bcc'); + setShowBcc(true); + }} > Bcc @@ -2165,6 +2210,7 @@ export function EmailComposer({ autoSelectedIndex={autoSelectedIndex} dropdownRef={ccDropdownRef} onInsertAutocomplete={insertAutocomplete} + onMoveChip={handleMoveChip} />
)} @@ -2187,6 +2233,7 @@ export function EmailComposer({ autoSelectedIndex={autoSelectedIndex} dropdownRef={bccDropdownRef} onInsertAutocomplete={insertAutocomplete} + onMoveChip={handleMoveChip} /> )} @@ -2731,6 +2778,7 @@ function RecipientChipInput({ validationError, validationMessage, onTab, + onMoveChip, }: { value: string; onChange: (value: string) => void; @@ -2748,12 +2796,15 @@ function RecipientChipInput({ validationError?: boolean; validationMessage?: string; onTab?: () => void; + onMoveChip: (chip: string, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => void; }) { const t = useTranslations('email_composer'); const tCommon = useTranslations('common'); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<{ index: number; chip: string }>(); const [editingChip, setEditingChip] = useState<{ index: number; chip: string; editType: 'email' | 'name' } | null>(null); const [editValue, setEditValue] = useState(''); + const [isDragOver, setIsDragOver] = useState(false); + const [draggingIndex, setDraggingIndex] = useState(null); const editInputRef = useRef(null); const allParts = value.split(',').map(s => s.trim()).filter(Boolean); @@ -2920,14 +2971,41 @@ function RecipientChipInput({ onAutoBlur(e, field); }; + const handleContainerDragOver = (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes('application/x-recipient-chip')) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setIsDragOver(true); + }; + + const handleContainerDragLeave = (e: React.DragEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragOver(false); + } + }; + + const handleContainerDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const raw = e.dataTransfer.getData('application/x-recipient-chip'); + if (!raw) return; + const { chip: draggedChip, fromField } = JSON.parse(raw) as { chip: string; fromField: 'to' | 'cc' | 'bcc' }; + if (fromField === field) return; + onMoveChip(draggedChip, fromField, field); + }; + return (
inputRef.current?.focus()} + onDragOver={handleContainerDragOver} + onDragLeave={handleContainerDragLeave} + onDrop={handleContainerDrop} > {chips.map((chip, i) => { const isEditing = editingChip?.index === i; @@ -2935,11 +3013,20 @@ function RecipientChipInput({ return ( { + e.stopPropagation(); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ chip, fromField: field })); + setDraggingIndex(i); + }} + onDragEnd={() => setDraggingIndex(null)} className={cn( "inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-sm border border-border transition-colors", isEditing ? "bg-background ring-1 ring-ring" - : "bg-secondary text-secondary-foreground hover:bg-accent" + : "bg-secondary text-secondary-foreground hover:bg-accent cursor-grab active:cursor-grabbing", + !isEditing && draggingIndex === i && "opacity-50" )} onContextMenu={isEditing ? undefined : (e) => handleContextMenu(e, i, chip)} > diff --git a/lib/__tests__/email-composer-utils.test.ts b/lib/__tests__/email-composer-utils.test.ts index 0daa17b4..9b5a407c 100644 --- a/lib/__tests__/email-composer-utils.test.ts +++ b/lib/__tests__/email-composer-utils.test.ts @@ -4,6 +4,8 @@ import { rewriteCidImagesForEditor, replaceInlineImagePlaceholders, INLINE_IMAGE_PLACEHOLDER, + removeChipFromFieldValue, + addChipToFieldValue, } from "../email-composer-utils"; describe("plainTextToComposerBody", () => { @@ -112,3 +114,60 @@ describe("replaceInlineImagePlaceholders", () => { expect(out).toBe(html); }); }); + +describe("removeChipFromFieldValue", () => { + it("removes the target chip and preserves others", () => { + const result = removeChipFromFieldValue("alice@example.com, bob@example.com, ", "alice@example.com"); + expect(result).toBe("bob@example.com, "); + }); + + it("removes a chip with a display name", () => { + const result = removeChipFromFieldValue("Alice , bob@example.com, ", "Alice "); + expect(result).toBe("bob@example.com, "); + }); + + it("handles removing the only chip", () => { + const result = removeChipFromFieldValue("alice@example.com, ", "alice@example.com"); + expect(result).toBe(""); + }); + + it("returns the value unchanged when chip is not found", () => { + const value = "alice@example.com, bob@example.com, "; + expect(removeChipFromFieldValue(value, "carol@example.com")).toBe(value); + }); + + it("preserves in-progress input text after removing a chip", () => { + const result = removeChipFromFieldValue("alice@example.com, bob@example.com, car", "alice@example.com"); + expect(result).toBe("bob@example.com, car"); + }); + + it("handles an empty field value", () => { + expect(removeChipFromFieldValue("", "alice@example.com")).toBe(""); + }); + + it("removes only the first occurrence when chip appears multiple times", () => { + const result = removeChipFromFieldValue("alice@example.com, alice@example.com, bob@example.com, ", "alice@example.com"); + expect(result).toBe("alice@example.com, bob@example.com, "); + }); +}); + +describe("addChipToFieldValue", () => { + it("appends a chip to a field with existing chips", () => { + const result = addChipToFieldValue("alice@example.com, ", "bob@example.com"); + expect(result).toBe("alice@example.com, bob@example.com, "); + }); + + it("appends a chip to an empty field", () => { + expect(addChipToFieldValue("", "alice@example.com")).toBe("alice@example.com, "); + }); + + it("preserves in-progress input text when appending", () => { + const result = addChipToFieldValue("alice@example.com, bob", "carol@example.com"); + expect(result).toBe("alice@example.com, carol@example.com, bob"); + }); + + it("appends a chip with a display name", () => { + const result = addChipToFieldValue("alice@example.com, ", "Bob "); + expect(result).toBe("alice@example.com, Bob , "); + }); +}); diff --git a/lib/email-composer-utils.ts b/lib/email-composer-utils.ts index e41fdb8f..ed1f2d13 100644 --- a/lib/email-composer-utils.ts +++ b/lib/email-composer-utils.ts @@ -52,6 +52,39 @@ export function rewriteCidImagesForEditor(html: string): string { return touched ? doc.body.innerHTML : html; } +/** + * Parses the chip array and trailing in-progress input text from a + * comma-separated recipient field value (e.g. "Alice , bob@x.com, b"). + * A trailing comma means "bob@x.com" is a committed chip and "b" is the live input. + */ +function parseFieldValue(fieldValue: string): { chips: string[]; inputText: string } { + const allParts = fieldValue.split(',').map(s => s.trim()).filter(Boolean); + const hasTrailingComma = fieldValue.trimEnd().endsWith(','); + const chips = hasTrailingComma ? allParts : allParts.slice(0, -1); + const inputText = hasTrailingComma ? '' : (allParts[allParts.length - 1] ?? ''); + return { chips, inputText }; +} + +function buildFieldValue(chips: string[], inputText: string): string { + if (chips.length === 0) return inputText; + return chips.join(', ') + ', ' + inputText; +} + +/** Removes the first occurrence of `chip` from a recipient field value string. */ +export function removeChipFromFieldValue(fieldValue: string, chip: string): string { + const { chips, inputText } = parseFieldValue(fieldValue); + const idx = chips.indexOf(chip); + if (idx === -1) return fieldValue; + const remaining = chips.filter((_, i) => i !== idx); + return buildFieldValue(remaining, inputText); +} + +/** Appends `chip` as a committed entry to a recipient field value string. */ +export function addChipToFieldValue(fieldValue: string, chip: string): string { + const { chips, inputText } = parseFieldValue(fieldValue); + return buildFieldValue([...chips, chip], inputText); +} + /** * Replaces the placeholder src on `` elements with the * resolved data URL once the inline blob has been fetched. Leaves images