diff --git a/__test__/components/PeopleSearch.test.tsx b/__test__/components/PeopleSearch.test.tsx
index 79463fa..c3c9f1a 100644
--- a/__test__/components/PeopleSearch.test.tsx
+++ b/__test__/components/PeopleSearch.test.tsx
@@ -214,6 +214,171 @@ describe("PeopleSearch", () => {
});
});
+ describe("paste multiple attendees", () => {
+ function setupFreeSolo(selectedUsers: User[] = []) {
+ const onChange = jest.fn();
+ renderWithProviders(
+
+ );
+ return { onChange };
+ }
+
+ it("splits pasted comma-separated emails into individual attendees", async () => {
+ const { onChange } = setupFreeSolo();
+ const input = screen.getByRole("combobox");
+
+ fireEvent.paste(input, {
+ clipboardData: {
+ getData: () => "alice@example.com, bob@example.com",
+ },
+ });
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.arrayContaining([
+ expect.objectContaining({ email: "alice@example.com" }),
+ expect.objectContaining({ email: "bob@example.com" }),
+ ])
+ );
+ });
+ });
+
+ it("splits pasted semicolon-separated emails", async () => {
+ const { onChange } = setupFreeSolo();
+ const input = screen.getByRole("combobox");
+
+ fireEvent.paste(input, {
+ clipboardData: {
+ getData: () => "alice@example.com;bob@example.com",
+ },
+ });
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.arrayContaining([
+ expect.objectContaining({ email: "alice@example.com" }),
+ expect.objectContaining({ email: "bob@example.com" }),
+ ])
+ );
+ });
+ });
+
+ it("splits pasted newline-separated emails", async () => {
+ const { onChange } = setupFreeSolo();
+ const input = screen.getByRole("combobox");
+
+ fireEvent.paste(input, {
+ clipboardData: {
+ getData: () => "alice@example.com\nbob@example.com",
+ },
+ });
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.arrayContaining([
+ expect.objectContaining({ email: "alice@example.com" }),
+ expect.objectContaining({ email: "bob@example.com" }),
+ ])
+ );
+ });
+ });
+
+ it("splits pasted space-separated emails", async () => {
+ const { onChange } = setupFreeSolo();
+ const input = screen.getByRole("combobox");
+
+ fireEvent.paste(input, {
+ clipboardData: {
+ getData: () => "alice@example.com bob@example.com",
+ },
+ });
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.arrayContaining([
+ expect.objectContaining({ email: "alice@example.com" }),
+ expect.objectContaining({ email: "bob@example.com" }),
+ ])
+ );
+ });
+ });
+
+ it("skips duplicate emails already in selectedUsers", async () => {
+ const existing: User = {
+ email: "alice@example.com",
+ displayName: "Alice",
+ };
+ const { onChange } = setupFreeSolo([existing]);
+ const input = screen.getByRole("combobox");
+
+ fireEvent.paste(input, {
+ clipboardData: {
+ getData: () => "alice@example.com, bob@example.com",
+ },
+ });
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.arrayContaining([
+ existing,
+ expect.objectContaining({ email: "bob@example.com" }),
+ ])
+ );
+ // Should NOT have alice duplicated
+ const call = onChange.mock.calls[0][1];
+ const aliceCount = call.filter(
+ (u: User) => u.email === "alice@example.com"
+ ).length;
+ expect(aliceCount).toBe(1);
+ });
+ });
+
+ it("leaves invalid text in input when some emails are invalid", async () => {
+ const { onChange } = setupFreeSolo();
+ const input = screen.getByRole("combobox");
+
+ fireEvent.paste(input, {
+ clipboardData: {
+ getData: () => "alice@example.com, not-an-email, bob@example.com",
+ },
+ });
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.arrayContaining([
+ expect.objectContaining({ email: "alice@example.com" }),
+ expect.objectContaining({ email: "bob@example.com" }),
+ ])
+ );
+ });
+ });
+
+ it("does not intercept paste of a single email", async () => {
+ const { onChange } = setupFreeSolo();
+ const input = screen.getByRole("combobox");
+
+ fireEvent.paste(input, {
+ clipboardData: {
+ getData: () => "alice@example.com",
+ },
+ });
+
+ // Single email should NOT trigger multi-paste handler
+ expect(onChange).not.toHaveBeenCalled();
+ });
+ });
+
it("retains input value when field loses focus (blur)", async () => {
let resolveSearch: (value: User[]) => void;
const searchPromise = new Promise((resolve) => {
diff --git a/src/components/Attendees/PeopleSearch.tsx b/src/components/Attendees/PeopleSearch.tsx
index 63a8d93..1eeb45c 100644
--- a/src/components/Attendees/PeopleSearch.tsx
+++ b/src/components/Attendees/PeopleSearch.tsx
@@ -27,6 +27,7 @@ import {
import { useI18n } from "twake-i18n";
import { ResourceIcon } from "./ResourceIcon";
import { isValidEmail } from "../../utils/isValidEmail";
+import { usePasteHandler } from "./usePasteHandler";
export interface User {
email: string;
@@ -124,6 +125,15 @@ export function PeopleSearch({
[query, selectedUsers, onChange, t, setInputError, setQuery]
);
+ const handlePaste = usePasteHandler({
+ freeSolo,
+ selectedUsers,
+ onChange,
+ setQuery,
+ setInputError,
+ t,
+ });
+
const defaultRenderInput = useCallback(
(params: AutocompleteRenderInputParams) => {
const inputProps = {
@@ -150,6 +160,7 @@ export function PeopleSearch({
inputProps: {
...params.inputProps,
autoComplete: "off",
+ onPaste: handlePaste,
},
};
@@ -206,7 +217,14 @@ export function PeopleSearch({
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
- [inputError, t, onToggleEventPreview, loading, searchPlaceholder]
+ [
+ inputError,
+ t,
+ onToggleEventPreview,
+ loading,
+ searchPlaceholder,
+ handlePaste,
+ ]
);
return (
diff --git a/src/components/Attendees/usePasteHandler.ts b/src/components/Attendees/usePasteHandler.ts
new file mode 100644
index 0000000..c4139d6
--- /dev/null
+++ b/src/components/Attendees/usePasteHandler.ts
@@ -0,0 +1,70 @@
+import { useCallback } from "react";
+import type { SyntheticEvent } from "react";
+import { isValidEmail } from "../../utils/isValidEmail";
+import type { User } from "./PeopleSearch";
+
+export function usePasteHandler({
+ freeSolo,
+ selectedUsers,
+ onChange,
+ setQuery,
+ setInputError,
+ t,
+}: {
+ freeSolo?: boolean;
+ selectedUsers: User[];
+ onChange: (event: SyntheticEvent, users: User[]) => void;
+ setQuery: (value: string) => void;
+ setInputError: (error: string | null) => void;
+ t: (key: string) => string;
+}) {
+ return useCallback(
+ (event: React.ClipboardEvent) => {
+ if (!freeSolo) return;
+
+ const pasted = event.clipboardData.getData("text/plain");
+ if (!pasted) return;
+
+ // Split by comma, semicolon, newline, or whitespace
+ const chunks = pasted
+ .split(/[,;\n\r\s]+/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+ // If there is only one chunk, let the default Autocomplete behaviour handle it
+ if (chunks.length <= 1) return;
+
+ event.preventDefault();
+
+ const existingEmails = new Set(selectedUsers.map((u) => u.email));
+ const validUsers: User[] = [];
+ const invalid: string[] = [];
+
+ for (const chunk of chunks) {
+ if (!isValidEmail(chunk)) {
+ invalid.push(chunk);
+ } else if (!existingEmails.has(chunk)) {
+ existingEmails.add(chunk);
+ validUsers.push({ email: chunk, displayName: chunk });
+ }
+ // silently skip duplicates
+ }
+
+ if (validUsers.length > 0) {
+ onChange(event, [...selectedUsers, ...validUsers]);
+ }
+
+ if (invalid.length > 0) {
+ // Leave the invalid text in the input for manual correction
+ setQuery(invalid.join(", "));
+ setInputError(
+ t("peopleSearch.invalidEmail").replace("%{email}", invalid.join(", "))
+ );
+ } else {
+ setQuery("");
+ setInputError(null);
+ }
+ },
+ [freeSolo, selectedUsers, onChange, setQuery, setInputError, t]
+ );
+}