From d7406fa2774cf317b83d4d0b3e677502a65de2f5 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Wed, 7 Oct 2026 17:35:21 +0200 Subject: [PATCH] fix(prompt-input): report files dropped by a partial rejection --- .../prompt-input-report-partial-rejections.md | 5 + .../components/(chatbot)/prompt-input.mdx | 3 +- .../elements/__tests__/prompt-input.test.tsx | 93 +++++++++++++++++++ packages/elements/src/prompt-input.tsx | 26 ++++++ 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 .changeset/prompt-input-report-partial-rejections.md diff --git a/.changeset/prompt-input-report-partial-rejections.md b/.changeset/prompt-input-report-partial-rejections.md new file mode 100644 index 00000000..3bfe1037 --- /dev/null +++ b/.changeset/prompt-input-report-partial-rejections.md @@ -0,0 +1,5 @@ +--- +"ai-elements": patch +--- + +Call PromptInput onError when only some of the added files fail accept or maxFileSize diff --git a/apps/docs/content/components/(chatbot)/prompt-input.mdx b/apps/docs/content/components/(chatbot)/prompt-input.mdx index ad2c9f5b..ac396140 100644 --- a/apps/docs/content/components/(chatbot)/prompt-input.mdx +++ b/apps/docs/content/components/(chatbot)/prompt-input.mdx @@ -308,7 +308,8 @@ Buttons can display tooltips with optional keyboard shortcut hints. Hover over t type: "number", }, onError: { - description: "Handler for file validation errors.", + description: + "Handler for file validation errors. Also called when only some of the added files are rejected; the rest are still added.", type: '(err: { code: "max_files" | "max_file_size" | "accept", message: string }) => void', }, "...props": { diff --git a/packages/elements/__tests__/prompt-input.test.tsx b/packages/elements/__tests__/prompt-input.test.tsx index d961a3ba..036b645a 100644 --- a/packages/elements/__tests__/prompt-input.test.tsx +++ b/packages/elements/__tests__/prompt-input.test.tsx @@ -1930,6 +1930,99 @@ describe("file validation", () => { }); }); +describe("partially rejected files", () => { + const renderWithAttachments = ( + props: Partial>, + filesToAdd: File[] + ) => { + const AttachmentConsumer = () => { + const attachments = usePromptInputAttachments(); + return ( + <> + +
{attachments.files.length}
+ + ); + }; + + return ( + + + + + + + ); + }; + + it("reports files that do not match accept and adds the rest", async () => { + setupPromptInputTests(); + const onError = vi.fn(); + const user = userEvent.setup(); + const image = new File(["image"], "photo.png", { type: "image/png" }); + const text = new File(["text"], "notes.txt", { type: "text/plain" }); + + render( + renderWithAttachments({ accept: "image/*", onError }, [image, text]) + ); + await user.click(screen.getByTestId("add-files")); + + expect(screen.getByTestId("count")).toHaveTextContent("1"); + expect(onError).toHaveBeenCalledWith({ + code: "accept", + message: expect.any(String), + }); + }); + + it("reports files over maxFileSize and adds the rest", async () => { + setupPromptInputTests(); + const onError = vi.fn(); + const user = userEvent.setup(); + const small = new File(["small"], "small.txt", { type: "text/plain" }); + const large = new File(["large"], "large.txt", { type: "text/plain" }); + Object.defineProperty(large, "size", { value: 2000 }); + + render( + renderWithAttachments({ maxFileSize: 1000, onError }, [small, large]) + ); + await user.click(screen.getByTestId("add-files")); + + expect(screen.getByTestId("count")).toHaveTextContent("1"); + expect(onError).toHaveBeenCalledWith({ + code: "max_file_size", + message: expect.any(String), + }); + }); + + it("reports partially rejected files inside PromptInputProvider", async () => { + setupPromptInputTests(); + const onError = vi.fn(); + const user = userEvent.setup(); + const { PromptInputProvider } = await import("../src/prompt-input"); + const image = new File(["image"], "photo.png", { type: "image/png" }); + const text = new File(["text"], "notes.txt", { type: "text/plain" }); + + render( + + {renderWithAttachments({ accept: "image/*", onError }, [image, text])} + + ); + await user.click(screen.getByTestId("add-files")); + + expect(screen.getByTestId("count")).toHaveTextContent("1"); + expect(onError).toHaveBeenCalledWith({ + code: "accept", + message: expect.any(String), + }); + }); +}); + describe("drag and drop", () => { it("renders with globalDrop prop", () => { setupPromptInputTests(); diff --git a/packages/elements/src/prompt-input.tsx b/packages/elements/src/prompt-input.tsx index 412c846d..3a8081a3 100644 --- a/packages/elements/src/prompt-input.tsx +++ b/packages/elements/src/prompt-input.tsx @@ -586,6 +586,13 @@ export const PromptInput = ({ }); return; } + if (accepted.length < incoming.length) { + onError?.({ + code: "accept", + message: + "Some files do not match the accepted types and were not added.", + }); + } const withinSize = (f: File) => maxFileSize ? f.size <= maxFileSize : true; const sized = accepted.filter(withinSize); @@ -596,6 +603,12 @@ export const PromptInput = ({ }); return; } + if (sized.length < accepted.length) { + onError?.({ + code: "max_file_size", + message: "Some files exceed the maximum size and were not added.", + }); + } setItems((prev) => { const capacity = @@ -650,6 +663,13 @@ export const PromptInput = ({ }); return; } + if (accepted.length < incoming.length) { + onError?.({ + code: "accept", + message: + "Some files do not match the accepted types and were not added.", + }); + } const withinSize = (f: File) => maxFileSize ? f.size <= maxFileSize : true; const sized = accepted.filter(withinSize); @@ -660,6 +680,12 @@ export const PromptInput = ({ }); return; } + if (sized.length < accepted.length) { + onError?.({ + code: "max_file_size", + message: "Some files exceed the maximum size and were not added.", + }); + } const currentCount = files.length; const capacity =