Suggestions
패키지 네임스페이스가 아니라 레시피입니다. Composer 위의 제안 프롬프트는 이름을 붙인 목록 안의 Button이고, 답의 버전을 오가는 "2 of 3" 전환기는 답의 Toolbar에 넣은 Button 두 개와 개수입니다. 어떤 프롬프트를 제안할지, 답이 어떻게 갈라지는지는 제품이 정합니다. Suggestions.* export는 없습니다.
이 페이지의 내용
제안을 골라 필드에 넣은 다음 보내 보세요. 답을 다시 생성하면 버전이 하나 늘고, 화살표로 버전 사이를 오갈 수 있습니다.
Put the name in aria-label, or in a visually hidden label inside it. The icon itself stays hidden from screen readers.
제안 프롬프트
빈 대화에서는 시작 프롬프트를, 답 다음에는 이어서 물을 프롬프트를 같은 목록으로 보여 주세요. 예제는 이어서 물을 프롬프트를 보여 줍니다. 제안을 누르면 그 텍스트가 필드에 들어가고 포커스가 필드로 옮겨 가므로, 보내기 전에 고칠 수 있고 Enter로 보낼 수 있습니다. 답이 진행되는 동안과 필드에 공백이 아닌 텍스트가 있는 동안에는 목록을 숨겨서, 제안이 쓰던 초안을 덮어쓰지 않게 하세요. 각 항목의 너비를 줄 너비로 제한하면, 다 들어가지 않는 제안은 잘리고 레이블이 잘린 다른 Button처럼 전체 텍스트를 tooltip으로 보여 줍니다.
import { Button, Composer, Field, Stack } from '@tounsoo/todae';
const pick = (text: string) => {
setValue(text);
field.current?.focus();
};
{!busy && value.trim() === '' && suggestions.length > 0 && (
<Stack.Root as="ul" direction="row" gap="condensed" wrap aria-label="Suggestions">
{suggestions.map((text) => (
<li key={text} style={{ maxInlineSize: '100%' }}>
<Button.Root variant="secondary" onClick={() => pick(text)}>
{text}
</Button.Root>
</li>
))}
</Stack.Root>
)}
<Composer.Root busy={busy} onSubmit={send}>
<Field.Root aria-label="Message">
<Field.Textarea ref={field} value={value} onChange={(event) => setValue(event.target.value)} autoGrow />
<Field.Row>
<Composer.Submit disabled={value.trim() === ''} onStop={stop} />
</Field.Row>
</Field.Root>
</Composer.Root>
제안을 바로 보내려면 그 텍스트를 필드의 상태를 거치지 않고 메시지를 보내는 함수에 직접 넘기세요. 필드의 상태를 거치면 submit 핸들러는 아직 빈 값을 읽습니다. 답이 시작되면 목록이 사라지므로, 먼저 포커스를 필드로 옮기세요.
답의 버전
답의 각 버전을 그 답이 응답하는 메시지 아래의 형제로 두고, 어느 버전을 보여 주는지 기억하세요. 답 다음의 차례는 그 버전에 속하므로, 버전을 바꾸면 그 뒤의 차례도 바뀝니다. 다시 생성하면 버전이 하나 늘고 그 버전을 보여 줍니다. 사람이 보낸 메시지를 편집할 때도 같은 방식이며, 전환기는 그 사람의 메시지에 놓입니다.
전환기는 답의 툴바 맨 앞에 두고 그 뒤에 구분선을 넣습니다. 양 끝에서는 Pagination의 단계처럼 해당 버튼에 disabled가 아니라 aria-disabled를 줍니다. 비활성 모양으로 그려지면서 포커스를 유지하는데, disabled는 포커스를 페이지로 떨어뜨립니다. Button.Root는 그 버튼의 클릭을 무시합니다. 두 버튼 모두 개수를 설명으로 가지므로, 버튼으로 옮겨 가면 위치가 읽힙니다. 변화가 버튼에서 떨어진 곳에서 일어나므로 바꾼 다음에는 새 개수를 알리세요.
import { Button, Text, Toolbar, useAnnouncer } from '@tounsoo/todae';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useId } from 'react';
export function VersionSwitcher({
index,
count,
locked,
onShow,
}: {
index: number;
count: number;
// True while a reply runs, so the turn it belongs to stays in view.
locked: boolean;
onShow: (index: number) => void;
}) {
const announce = useAnnouncer();
const position = useId();
const held = (to: number) => locked || to < 0 || to >= count;
const show = (to: number) => {
onShow(to);
announce(`Reply ${to + 1} of ${count}`);
};
return (
<>
<Button.Root
variant="ghost"
aria-label="Previous reply"
aria-describedby={position}
aria-disabled={held(index - 1) || undefined}
onClick={() => show(index - 1)}
>
<Button.Icon>
<ChevronLeft />
</Button.Icon>
</Button.Root>
<Text.Root as="span" id={position} tone="muted">
{index + 1} of {count}
</Text.Root>
<Button.Root
variant="ghost"
aria-label="Next reply"
aria-describedby={position}
aria-disabled={held(index + 1) || undefined}
onClick={() => show(index + 1)}
>
<Button.Icon>
<ChevronRight />
</Button.Icon>
</Button.Root>
<Toolbar.Separator />
</>
);
}
버전이 두 개 이상일 때만 전환기를 렌더링하세요. 답의 Message.Root는 버전이 아니라 그 답이 응답하는 메시지로 key를 주어, 버전을 바꿔도 같은 메시지와 포커스를 가진 버튼이 남게 하세요.
답 하나가 진행되는 동안에는 다른 모든 답의 Regenerate를 전환기처럼 aria-disabled로 막아 두세요. Message actions에서 Regenerate는 진행 중인 답을 취소합니다. 답이 갈라진 뒤에는 앞의 답을 다시 생성하면 더 새로운 질문도 더 이상 보이지 않는 버전에 답 없이 남게 됩니다. 진행 중인 답의 Regenerate는 그대로 동작하며 그 답을 바꿉니다.
// pendingReply is the id of the one reply in flight, or null.
const regenerateHeld = (reply: { id: string }) => pendingReply !== null && pendingReply !== reply.id;
<Button.Root
variant="ghost"
aria-label="Regenerate"
aria-disabled={regenerateHeld(reply) || undefined}
onClick={() => regenerate(reply)}
>
<Button.Icon>
<RotateCcw />
</Button.Icon>
</Button.Root>
접근성을 지키는 조합
Todae가 맡는 것: Toolbar.Root는 답의 버튼에 탭 정지 하나와 화살표 키를 줍니다. aria-disabled인 Button.Root는 비활성 모양으로 그려지고, 포커스를 유지하며, 클릭을 무시합니다. 아이콘만 있는 Button.Root는 aria-label을 tooltip으로 보여 주고, 레이블이 잘린 Button은 전체 텍스트를 보여 줍니다. Composer.Root는 Enter로 보내고 IME 조합은 건드리지 않습니다. 메시지를 Conversation에 넣으면 답이 시작되고 끝날 때 알립니다.
직접 제공할 것: 제안 목록과 아이콘만 있는 각 버튼의 이름. 제안이 필드를 채울 때 필드로 옮기는 포커스. 전환기 양 끝과, 답이 진행되는 동안 다른 답의 Regenerate에 주는 aria-disabled. 전환기 버튼의 설명이 되는 개수와 새 버전의 알림. 제품의 언어로 쓴 모든 이름과 개수.
주의할 점: 사람이 쓴 초안을 덮어쓰는 제안. 나타날 때 포커스를 옮기거나 알려지는 제안. 제안은 답마다 함께 오므로 Tab으로 닿게 두세요. 목록의 버튼에 포커스가 있는 채로 목록을 없애는 것. 제안을 바로 보낼 때 그렇게 됩니다. 답만 바꾸고 다른 버전의 이후 차례는 그대로 두는 전환기. 아직 streaming 중인 버전에서 다른 버전으로 바꾸는 것. 그 답이 속한 차례가 가려지므로, 예제처럼 답이 진행되는 동안에는 전환기와 다른 답의 Regenerate를 막아 두세요. 새 질문의 답을 멈추는 것. 질문에 답이 없고 다시 생성할 것도 없게 되므로, 질문을 필드에 되돌려 다시 보낼 수 있게 하세요. 사람이 이미 다른 초안을 쓰기 시작했다면, 답을 멈춘 상태로 남겨 Regenerate로 다시 요청할 수 있게 하세요.
언제 쓰나요
쓸 때: 사람이 시작할 수 있는 프롬프트, 답 다음에 어시스턴트가 제안하는 후속 질문, 다시 생성하거나 편집한 차례의 버전 사이 이동.
쓰지 않을 때: 입력 중인 텍스트 완성 → 멘션과 슬래시 명령. 복사나 평가처럼 메시지 하나에 대한 동작 → Message actions.