Another message
++ Before selected words after. +
+{" 안녕하세요 👋\n世界 "}
+ {label}
++ {children} ++
(null); + const [announcement, setAnnouncement] = useState(""); + const inputRef = useRef(null); + const composing = useRef(false); + const quoteId = useId(); + const hintId = useId(); + + const handleReply = useCallback((nextQuote: Quote) => { + setQuote(nextQuote); + setAnnouncement(`Quote added. Replying to ${nextQuote.from}.`); + inputRef.current?.focus(); + }, []); + + const removeQuote = useCallback(() => { + setQuote(null); + setAnnouncement("Quote removed."); + inputRef.current?.focus(); + }, []); + + const handleSubmit = useCallback( + ({ text }: PromptInputMessage) => { + if (!text.trim()) { + return; + } + + // Local preview. In an app, keep the draft and quote until sending succeeds. + setMessages((current) => [ + ...current, + { from: "user", id: nanoid(), quote: quote ?? undefined, text }, + ]); + setInput(""); + setQuote(null); + setAnnouncement("Reply added to the conversation."); + inputRef.current?.focus(); + }, + [quote] + ); + + const handleChange = useCallback >( + (event) => setInput(event.currentTarget.value), + [] + ); + const handleCompositionStart = useCallback(() => { + composing.current = true; + }, []); + const handleCompositionEnd = useCallback(() => { + composing.current = false; + }, []); + const handleInputKeyDown = useCallback< + KeyboardEventHandler + >((event) => { + // Some IMEs dispatch their confirmation Enter after compositionend. + if (event.key === "Enter" && event.keyCode === 229) { + event.preventDefault(); + } + }, []); + const handleKeyDown = useCallback >( + (event) => { + if ( + event.key !== "Escape" || + !quote || + event.defaultPrevented || + composing.current || + event.nativeEvent.isComposing || + event.keyCode === 229 + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + removeQuote(); + }, + [quote, removeQuote] + ); + + return ( + ++ ); +}; + +export default Example; From 275965acfec28c96cf9ada5032eb3bb8d012e733 Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:24:53 -0400 Subject: [PATCH 2/2] fix(message): avoid duplicate reply action names --- .../content/components/(chatbot)/message.mdx | 5 +- .../elements/__tests__/message-reply.test.tsx | 55 +++++++++++++++++++ packages/elements/src/message.tsx | 8 ++- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/apps/docs/content/components/(chatbot)/message.mdx b/apps/docs/content/components/(chatbot)/message.mdx index 280173e1..d9f2f6f9 100644 --- a/apps/docs/content/components/(chatbot)/message.mdx +++ b/apps/docs/content/components/(chatbot)/message.mdx @@ -177,7 +177,7 @@ focus to the input. `MessageQuote` can also display a sent message's reference: omit `onRemove` for a read-only quote. Quote text is rendered as text, without interpreting Markdown or HTML. Long quotes scroll within the preview and can be focused to scroll with the -keyboard. Labels can be localized; pass children to `MessageReply` to replace its +keyboard. Labels can be localized; text children provide the accessible name. Pass `label` or `aria-label` with custom icon children. Pass children to `MessageReply` to replace its default icon. ### State and keyboard behavior @@ -372,7 +372,8 @@ as user-provided context, just like the rest of the message. type: "React.RefObject+ Select part of a message, then choose Reply to quote it. Without a + selection, Reply quotes the whole message. +
++ ++ {messages.map((message) => ( + ++ ))} + + + {quote && ( + + + {announcement} + ++ + )} ++ {quote.text} + ++ + + {quote + ? "Esc to remove quote" + : "Enter to send · Shift+Enter for a new line"} + + ++ ", }, label: { - description: "Accessible label for the reply action.", + description: + "Accessible label for the reply action. Defaults to Reply when children are absent; set it for custom icon children.", type: "string", default: '"Reply"', }, diff --git a/packages/elements/__tests__/message-reply.test.tsx b/packages/elements/__tests__/message-reply.test.tsx index 3172e4bb..e8e11aac 100644 --- a/packages/elements/__tests__/message-reply.test.tsx +++ b/packages/elements/__tests__/message-reply.test.tsx @@ -39,6 +39,61 @@ const renderReply = (onReply: (text: string) => void) => { }; describe("messageReply", () => { + it("uses visible reply text without appending the default label", () => { + render( + + Respond + + ); + expect( + screen.getByRole("button", { exact: true, name: "Respond" }) + ).toBeInTheDocument(); + }); + + it("uses visible children when a tooltip is supplied", () => { + render( ++ Respond + + ); + expect( + screen.getByRole("button", { exact: true, name: "Respond" }) + ).toBeInTheDocument(); + }); + + it("labels the default reply icon", () => { + render(); + expect( + screen.getByRole("button", { exact: true, name: "Reply" }) + ).toBeInTheDocument(); + }); + + it("accepts an explicit label for a custom icon", () => { + render( + + + + ); + expect( + screen.getByRole("button", { exact: true, name: "Quote message" }) + ).toBeInTheDocument(); + }); + + it("keeps aria-label as the accessible name", () => { + render( ++ Respond + + ); + expect( + screen.getByRole("button", { exact: true, name: "Respond to assistant" }) + ).toBeInTheDocument(); + }); + it("replies with the original message when nothing is selected", async () => { const onReply = vi.fn(); renderReply(onReply); diff --git a/packages/elements/src/message.tsx b/packages/elements/src/message.tsx index 0ad53c55..10e80709 100644 --- a/packages/elements/src/message.tsx +++ b/packages/elements/src/message.tsx @@ -107,7 +107,7 @@ export const MessageAction = ({ const button = ( ); @@ -163,7 +163,7 @@ export type MessageReplyProps = MessageActionProps & { export const MessageReply = ({ children, - label = "Reply", + label, onClick, onPointerDown, onPointerCancel, @@ -218,7 +218,9 @@ export const MessageReply = ({ return (