Message actions
패키지 네임스페이스가 아니라 레시피입니다. 채팅 메시지 아래의 버튼은 Message.Actions 안의 Toolbar에 넣은 Button이고, 각 버튼이 무엇을 할지는 제품이 정합니다. MessageActions.* export는 없습니다.
이 페이지의 내용
질문 위에 포인터를 올리거나 그 안으로 포커스를 옮기면 동작이 보입니다. 복사, 편집, 다시 생성, 평가를 눌러 보세요.
Give it aria-label, or put a visually hidden label inside it. The icon itself stays hidden from screen readers.
복사
useClipboard가 텍스트를 쓰고 결과를 알립니다. 렌더링한 HTML이 아니라 markdown처럼 메시지의 원본 텍스트를 복사하세요. 아이콘이 체크 표시로 바뀌는 동안에도 이름은 "Copy"로 남으므로, 스크린 리더는 알림을 듣습니다.
import { Button, useClipboard } from '@tounsoo/todae';
import { Check, Copy } from 'lucide-react';
export function CopyButton({ text }: { text: string }) {
const clipboard = useClipboard();
const copied = clipboard.status === 'copied' && clipboard.text === text;
return (
<Button.Root variant="ghost" aria-label="Copy" onClick={() => clipboard.copy(text)}>
<Button.Icon>
{copied ? <Check /> : <Copy />}
</Button.Icon>
</Button.Root>
);
}
다시 생성과 평가
다시 생성은 진행 중인 답을 취소하고 백엔드에 새 답을 요청한 뒤, 답이 도착할 때까지 메시지에 streaming을 주는 평범한 버튼입니다. 답이 streaming되는 동안에도 툴바를 그대로 두고 그 뒤에 Loader의 dots를 보여 주세요. 그래야 포커스를 가진 버튼이 포커스를 잃지 않고, 포인터 아래의 버튼이 움직이지 않습니다. 평가는 하나만 고르는 ToggleGroup입니다. 고른 평가를 다시 누르면 지워지고, onValueChange가 null을 받습니다. 아이콘만 있는 버튼은 저마다 이름을 tooltip으로 보여 줍니다.
<Message.Root streaming={streaming} aria-label="Assistant">
<Message.Content>…</Message.Content>
<Message.Actions>
<Toolbar.Root aria-label="Reply">
<CopyButton text={reply.markdown} />
<Button.Root variant="ghost" aria-label="Regenerate" onClick={regenerate}>
<Button.Icon>
<RotateCcw />
</Button.Icon>
</Button.Root>
<ToggleGroup.Root aria-label="Rate this reply" value={rating} onValueChange={setRating}>
<ToggleGroup.Item value="good" variant="ghost" aria-label="Good reply">
<Button.Icon>
<ThumbsUp />
</Button.Icon>
</ToggleGroup.Item>
<ToggleGroup.Item value="bad" variant="ghost" aria-label="Bad reply">
<Button.Icon>
<ThumbsDown />
</Button.Icon>
</ToggleGroup.Item>
</ToggleGroup.Root>
</Toolbar.Root>
{streaming && <Loader.Root variant="dots" aria-label="Writing" />}
</Message.Actions>
</Message.Root>
편집
편집은 메시지의 본문과 동작을 Field.Textarea가 있는 폼으로 바꾸고, 폼을 제출하면 새 텍스트를 보냅니다. 폼이 닫히면 편집 버튼으로 포커스를 돌려놓으세요. 폼이 대신하던 버튼은 사라졌기 때문입니다.
<Message.Root from="user" aria-label="You">
{editing ? (
<form onSubmit={save}>
<Field.Root aria-label="Edit message">
<Field.Textarea value={draft} onChange={(event) => setDraft(event.target.value)} autoGrow />
</Field.Root>
<Button.Root type="button" variant="secondary" onClick={closeEditor}>
Cancel
</Button.Root>
<Button.Root type="submit">Send</Button.Root>
</form>
) : (
<>
<Message.Content>{text}</Message.Content>
<Message.Actions reveal="hover">
<Message.Time value={sentAt} locale={locale} />
<Toolbar.Root aria-label="Your message">
<CopyButton text={text} />
<Button.Root ref={editButton} variant="ghost" aria-label="Edit" onClick={() => setEditing(true)}>
<Button.Icon>
<Pencil />
</Button.Icon>
</Button.Root>
</Toolbar.Root>
</Message.Actions>
</>
)}
</Message.Root>
접근성을 지키는 조합
Todae가 맡는 것: Toolbar.Root는 버튼에 탭 정지 하나와 화살표 키를 주고, 안에 넣은 ToggleGroup은 화살표 키를 툴바에 맡깁니다. 아이콘만 있는 Button.Root는 aria-label을 tooltip으로 보여 줍니다. ToggleGroup 항목은 aria-pressed를 설정합니다. useClipboard는 복사가 됐는지 알립니다. reveal="hover"인 Message.Actions는 포커스가 메시지 안으로 들어오면 줄을 보여 줍니다.
직접 제공할 것: 각 툴바와 아이콘만 있는 각 버튼의 이름. 편집 필드의 이름. 편집기가 닫힐 때 편집 버튼으로 돌려놓는 포커스. "의견 고맙습니다"처럼 결과가 눈에 보이지 않는 동작의 피드백. useAnnouncer로 알리세요.
주의할 점: 가장 최근 답의 동작을 숨기는 것. 사람들이 가장 많이 쓰므로 거기서는 reveal을 always로 두세요. 답이 streaming되는 동안 툴바의 버튼을 없애거나 비활성화하는 것. 포커스를 가진 버튼이 포커스를 페이지에 빼앗깁니다. 이전 요청을 그대로 두는 다시 생성. 이전 답이 새 답보다 늦게 도착할 수 있습니다.
언제 쓰나요
쓸 때: 어시스턴트나 사람과의 채팅에서 메시지 하나에 대한 동작: 복사, 다시 생성, 평가, 편집.
쓰지 않을 때: 공유나 삭제처럼 대화 전체에 대한 동작 → 메시지 밖의 Menu나 Toolbar. 새 메시지 보내기 → Composer.