From d0219f205a0d866330c51c5182de2717516e5175 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Fri, 31 Jul 2026 18:58:20 +0000 Subject: [PATCH] fix(elements): size text-bearing prompt input buttons to their label PromptInputButton defaulted any single child to the icon-sm square, clipping text-only labels like Action. Detect text content recursively (including wrapped labels and fragments, excluding svg titles) and default to sm whenever text is present; an explicit size prop still wins. --- .../elements/__tests__/prompt-input.test.tsx | 76 ++++++++++++++++++- packages/elements/src/prompt-input.tsx | 49 ++++++++---- 2 files changed, 109 insertions(+), 16 deletions(-) diff --git a/packages/elements/__tests__/prompt-input.test.tsx b/packages/elements/__tests__/prompt-input.test.tsx index d961a3ba..0b73e824 100644 --- a/packages/elements/__tests__/prompt-input.test.tsx +++ b/packages/elements/__tests__/prompt-input.test.tsx @@ -4,6 +4,7 @@ import { userEvent } from "@testing-library/user-event"; import React from "react"; import type { AttachmentData } from "../src/attachments"; + import { Attachment, AttachmentInfo, @@ -785,7 +786,80 @@ describe("promptInputButton", () => { ); - expect(screen.getByRole("button", { name: "Action" })).toBeInTheDocument(); + const button = screen.getByRole("button", { name: "Action" }); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("data-size", "sm"); + expect(button.className).not.toContain("size-8"); + }); + + it("sizes a single wrapped text label as a text button", () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + render( + + + + Label + + + + ); + const button = screen.getByRole("button", { name: "Label" }); + expect(button).toHaveAttribute("data-size", "sm"); + expect(button.className).not.toContain("size-8"); + }); + + it("sizes a lone icon as a square icon button", () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + render( + + + + + Icon + + + + + ); + const button = screen.getByRole("button", { name: "Icon" }); + expect(button).toHaveAttribute("data-size", "icon-sm"); + expect(button.className).toContain("size-8"); + }); + + it("sizes icon-plus-text as a text button", () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + render( + + + + + Icon + + Search + + + + ); + const button = screen.getByRole("button", { name: /search/i }); + expect(button).toHaveAttribute("data-size", "sm"); + }); + + it("respects an explicit size prop over the heuristic", () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + render( + + + Action + + + ); + const button = screen.getByRole("button", { name: "Action" }); + expect(button).toHaveAttribute("data-size", "icon-sm"); + expect(button.className).toContain("size-8"); }); it("renders button with string tooltip", async () => { diff --git a/packages/elements/src/prompt-input.tsx b/packages/elements/src/prompt-input.tsx index 412c846d..619051e9 100644 --- a/packages/elements/src/prompt-input.tsx +++ b/packages/elements/src/prompt-input.tsx @@ -1,5 +1,20 @@ "use client"; +import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai"; +import type { + ChangeEvent, + ChangeEventHandler, + ClipboardEventHandler, + ComponentProps, + FormEvent, + FormEventHandler, + HTMLAttributes, + KeyboardEventHandler, + PropsWithChildren, + ReactNode, + RefObject, +} from "react"; + import { Command, CommandEmpty, @@ -40,7 +55,6 @@ import { TooltipTrigger, } from "@repo/shadcn-ui/components/ui/tooltip"; import { cn } from "@repo/shadcn-ui/lib/utils"; -import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai"; import { CornerDownLeftIcon, ImageIcon, @@ -50,22 +64,10 @@ import { XIcon, } from "lucide-react"; import { nanoid } from "nanoid"; -import type { - ChangeEvent, - ChangeEventHandler, - ClipboardEventHandler, - ComponentProps, - FormEvent, - FormEventHandler, - HTMLAttributes, - KeyboardEventHandler, - PropsWithChildren, - ReactNode, - RefObject, -} from "react"; import { Children, createContext, + isValidElement, useCallback, useContext, useEffect, @@ -78,6 +80,20 @@ import { // Helpers // ============================================================================ +const hasTextContent = (node: ReactNode): boolean => { + if (typeof node === "string" || typeof node === "number") { + return node !== ""; + } + if (Array.isArray(node)) { + return node.some(hasTextContent); + } + if (isValidElement<{ children?: ReactNode }>(node)) { + // Text inside (e.g. ) is accessibility metadata, not a label. + return node.type === "svg" ? false : hasTextContent(node.props.children); + } + return false; +}; + const convertBlobUrlToDataUrl = async (url: string): Promise => { try { const response = await fetch(url); @@ -1132,7 +1148,10 @@ export const PromptInputButton = ({ ...props }: PromptInputButtonProps) => { const newSize = - size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm"); + size ?? + (Children.count(props.children) > 1 || hasTextContent(props.children) + ? "sm" + : "icon-sm"); const button = (