컴포넌트
Chat
대화 목록과 그룹 선택 패널을 표시합니다.
설치
pnpm dlx ply-ui add chat공통 설치 설정을 먼저 적용합니다.
사용법
대화 목록과 그룹 입력 패널에 앱 데이터를 전달합니다. 네트워크 통신, 인증, 메시지 전송은 포함하지 않습니다. 그룹 예제에서는 생성 요청 대신 전달된 값을 표시합니다.
구성
| 구성 요소 | 역할 |
|---|---|
ChatPanel | 대화 UI를 담는 표면입니다. |
ChatDock | 대화 목록, 검색, 접기를 제공합니다. conversations와 선택 콜백을 받습니다. |
ChatGroupPanel | 그룹 이름과 멤버를 입력받습니다. people, onClose, onCreate를 연결합니다. |
그룹 만들기
ChatGroupPanel은 이름과 멤버를 입력받습니다. 2명 이상 선택하면 만들기 버튼이 활성화되고, 최대 5명까지 선택할 수 있습니다. onCreate가 받는 이름과 ID 배열을 서버 요청에 연결합니다.
구현 코드
components/ui/chat.tsx
"use client";
import { useId, useState, type ComponentProps, type ReactNode } from "react";
import { Button as PrimitiveButton } from "@base-ui/react/button";
import { cx } from "../../lib/cx";
import { Button } from "./button";
import { Icon } from "./icon";
import { IconButton } from "./icon-button";
import { Input } from "./input";
import { Checkbox } from "./checkbox";
import "./chat.css";
export type ChatPerson = {
id: string;
name: string;
presence?: string;
avatar?: ReactNode;
};
export type ChatConversation = {
id: string;
title: string;
preview: string;
updatedAt: string;
unread?: boolean;
avatar?: ReactNode;
};
/** 표시 데이터와 이벤트만 받습니다. 네트워크/인증/실제 계정 로직은 포함하지 않습니다. */
export function ChatPanel({ className, ...props }: ComponentProps<"section">) {
return <section {...props} className={cx("rbx-chat-panel", className)} />;
}
function AvatarPlaceholder({ name }: { name: string }) {
return (
<span className="rbx-chat-avatar" aria-hidden="true">
{name.slice(0, 1).toUpperCase()}
</span>
);
}
export function ChatDock({
conversations,
onOpenConversation,
onNewGroup,
className,
}: {
conversations: ChatConversation[];
onOpenConversation?: (id: string) => void;
onNewGroup?: () => void;
className?: string;
}) {
const id = useId();
const [query, setQuery] = useState("");
const [collapsed, setCollapsed] = useState(false);
const filtered = conversations.filter((item) =>
item.title.toLowerCase().includes(query.toLowerCase()),
);
return (
<ChatPanel
aria-labelledby={id}
className={className}
data-collapsed={collapsed || undefined}
>
<header className="rbx-chat-header">
<h2 id={id}>Chat</h2>
<IconButton
icon="icon-regular-person-plus"
size="sm"
variant="utility"
circular
aria-label="New Chat Group"
disabled={!onNewGroup}
onClick={onNewGroup}
/>
<IconButton
icon="icon-regular-chevron-large-down"
size="sm"
variant="utility"
circular
aria-label={collapsed ? "Expand Chat" : "Collapse Chat"}
aria-expanded={!collapsed}
onClick={() => setCollapsed(!collapsed)}
/>
</header>
{!collapsed && (
<>
<div className="rbx-chat-search">
<Input
controlSize="sm"
aria-label="Search conversations"
placeholder="Search"
value={query}
onValueChange={setQuery}
leading={<Icon name="icon-filled-magnifying-glass" size={16} />}
/>
</div>
<div className="rbx-chat-list">
{filtered.map((item) => (
<PrimitiveButton
key={item.id}
className="rbx-chat-row"
onClick={() => onOpenConversation?.(item.id)}
data-unread={item.unread || undefined}
>
{item.avatar ?? <AvatarPlaceholder name={item.title} />}
<span className="rbx-chat-row-body">
<span className="rbx-chat-title-line">
<span className="rbx-chat-row-title">{item.title}</span>
<span className="rbx-chat-time">{item.updatedAt}</span>
</span>
<span className="rbx-chat-preview">{item.preview}</span>
</span>
</PrimitiveButton>
))}
{!filtered.length && (
<p className="rbx-chat-empty">No conversations found</p>
)}
</div>
</>
)}
</ChatPanel>
);
}
export function ChatGroupPanel({
people,
onClose,
onCreate,
className,
}: {
people: ChatPerson[];
onClose?: () => void;
onCreate?: (name: string, memberIds: string[]) => void;
className?: string;
}) {
const id = useId();
const [name, setName] = useState("");
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const filtered = people.filter((person) =>
person.name.toLowerCase().includes(query.toLowerCase()),
);
return (
<ChatPanel aria-labelledby={id} className={cx("rbx-chat-group", className)}>
<header className="rbx-chat-header">
<h2 id={id}>New Chat Group</h2>
<IconButton
icon="icon-regular-x"
size="sm"
variant="utility"
circular
aria-label="Close group panel"
onClick={onClose}
/>
</header>
<div className="rbx-chat-search">
<Input
controlSize="sm"
aria-label="Name your chat group"
placeholder="Name your chat group"
value={name}
onValueChange={setName}
leading={<Icon name="icon-regular-person-plus" size={16} />}
/>
</div>
<div className="rbx-chat-search">
<Input
controlSize="sm"
aria-label="Search for connections"
placeholder="Search for connections"
value={query}
onValueChange={setQuery}
leading={<Icon name="icon-filled-magnifying-glass" size={16} />}
trailing={
<span className="rbx-chat-count">({selected.length}/5)</span>
}
/>
</div>
<div className="rbx-chat-list">
{filtered.map((person) => (
<label className="rbx-chat-person" key={person.id}>
{person.avatar ?? <AvatarPlaceholder name={person.name} />}
<span className="rbx-chat-row-body">
<span className="rbx-chat-person-name">{person.name}</span>
<span className="rbx-chat-preview">
{person.presence ?? "Offline"}
</span>
</span>
<Checkbox.Root
size="sm"
checked={selected.includes(person.id)}
disabled={!selected.includes(person.id) && selected.length >= 5}
onCheckedChange={(checked) =>
setSelected((ids) =>
checked
? [...ids, person.id]
: ids.filter((id) => id !== person.id),
)
}
>
<Checkbox.Indicator>
<Icon name="icon-filled-check" size={20} />
</Checkbox.Indicator>
</Checkbox.Root>
</label>
))}
{!filtered.length && (
<p className="rbx-chat-empty">No connections found</p>
)}
</div>
<footer className="rbx-chat-footer">
<Button size="sm" variant="standard" onClick={onClose}>
Cancel
</Button>
<Button
size="sm"
disabled={selected.length < 2 || !onCreate}
onClick={() => onCreate?.(name.trim(), selected)}
>
Create
</Button>
</footer>
</ChatPanel>
);
}
components/ui/chat.css
.rbx-chat-panel {
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 286px;
height: 360px;
max-width: 100%;
overflow: hidden;
border: 1px solid var(--rbx-color-stroke-muted);
border-radius: 16px 16px 0 0;
background: var(--rbx-color-surface-100);
color: var(--rbx-color-content-emphasis);
box-shadow: var(--rbx-shadow-transient-low);
}
.rbx-chat-panel[data-collapsed] {
height: 48px;
}
.rbx-chat-group {
width: 260px;
box-shadow: var(--rbx-shadow-transient-high);
}
.rbx-chat-header {
height: 48px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
}
.rbx-chat-header h2 {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
font: var(--rbx-typography-title-medium-font);
}
.rbx-chat-search {
padding: 0 8px 8px;
flex-shrink: 0;
}
.rbx-chat-list {
display: flex;
flex-direction: column;
overflow-y: auto;
min-height: 0;
flex: 1;
scrollbar-width: thin;
}
.rbx-chat-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--rbx-color-shift-300);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font: var(--rbx-typography-title-medium-font);
}
.rbx-chat-row {
display: flex;
flex-shrink: 0;
width: 100%;
align-items: center;
gap: 12px;
padding: 4px 12px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.rbx-chat-row:hover,
.rbx-chat-person:hover {
background: var(--rbx-color-shift-100);
}
.rbx-chat-row-body {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
gap: 2px;
}
.rbx-chat-title-line {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
gap: 8px;
}
.rbx-chat-row-title,
.rbx-chat-person-name {
font: var(--rbx-typography-body-medium-font);
}
.rbx-chat-row-title,
.rbx-chat-preview,
.rbx-chat-person-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rbx-chat-time,
.rbx-chat-preview,
.rbx-chat-count {
font: var(--rbx-typography-caption-medium-font);
color: var(--rbx-color-content-muted);
}
.rbx-chat-time {
flex-shrink: 0;
}
.rbx-chat-row[data-unread] .rbx-chat-row-title {
font: var(--rbx-typography-title-medium-font);
}
.rbx-chat-row[data-unread] :is(.rbx-chat-time, .rbx-chat-preview) {
color: var(--rbx-color-content-emphasis);
}
.rbx-chat-person {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
padding: 4px 8px;
cursor: pointer;
}
.rbx-chat-person .rbx-chat-row-body {
gap: 0;
}
.rbx-chat-footer {
display: flex;
flex-shrink: 0;
gap: 8px;
padding: 12px;
}
.rbx-chat-footer > .rbx-button {
flex: 1;
}
.rbx-chat-empty {
padding: 12px;
font: var(--rbx-typography-body-small-font);
color: var(--rbx-color-content-default);
}