컴포넌트
Chat
대화 목록과 그룹 선택 패널을 표시합니다.
설치
pnpm dlx ply-ui add chat공통 설치 설정을 먼저 적용합니다.
사용법
대화 목록과 그룹 입력 패널에 앱 데이터를 전달합니다. 네트워크 통신, 인증, 메시지 전송은 포함하지 않습니다. 그룹 예제에서는 생성 요청 대신 전달된 값을 표시합니다.
구성
| 구성 요소 | 역할 |
|---|---|
ChatPanel | 대화 UI를 담는 표면입니다. |
ChatDock | 대화 목록, 검색, 접기를 제공합니다. conversations와 선택 콜백을 받습니다. |
ChatGroupPanel | 그룹 이름과 멤버를 입력받습니다. people, onClose, onCreate를 연결합니다. |
문구 바꾸기
제목, 버튼 이름, 검색 안내, 빈 상태 문구의 기본값은 한국어입니다. labels에 바꿀 항목만 지정하면 나머지는 기본값을 유지합니다. 기본값은 defaultChatDockLabels, defaultChatGroupLabels로 확인할 수 있습니다.
<ChatDock
conversations={conversations}
labels={{
title: "Chat",
search: "Search conversations",
empty: "No conversations found",
}}
/>그룹 만들기
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;
};
/** ChatDock의 표시·읽기 문구입니다. 일부만 바꿔도 나머지는 기본값을 사용합니다. */
export type ChatDockLabels = {
title: string;
newGroup: string;
collapse: string;
expand: string;
search: string;
searchPlaceholder: string;
empty: string;
};
export const defaultChatDockLabels: ChatDockLabels = {
title: "채팅",
newGroup: "새 채팅 그룹",
collapse: "채팅 접기",
expand: "채팅 펼치기",
search: "대화 검색",
searchPlaceholder: "검색",
empty: "찾은 대화가 없습니다",
};
/** ChatGroupPanel의 표시·읽기 문구입니다. 일부만 바꿔도 나머지는 기본값을 사용합니다. */
export type ChatGroupLabels = {
title: string;
close: string;
name: string;
namePlaceholder: string;
search: string;
searchPlaceholder: string;
empty: string;
offline: string;
cancel: string;
create: string;
};
export const defaultChatGroupLabels: ChatGroupLabels = {
title: "새 채팅 그룹",
close: "그룹 패널 닫기",
name: "채팅 그룹 이름",
namePlaceholder: "채팅 그룹 이름",
search: "친구 검색",
searchPlaceholder: "친구 검색",
empty: "찾은 친구가 없습니다",
offline: "오프라인",
cancel: "취소",
create: "만들기",
};
/** 표시 데이터와 이벤트만 받습니다. 네트워크/인증/실제 계정 로직은 포함하지 않습니다. */
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,
labels: customLabels,
className,
}: {
conversations: ChatConversation[];
onOpenConversation?: (id: string) => void;
onNewGroup?: () => void;
/** 제목·버튼·검색·빈 상태 문구입니다. 지정한 항목만 바꿉니다. */
labels?: Partial<ChatDockLabels>;
className?: string;
}) {
const labels = { ...defaultChatDockLabels, ...customLabels };
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}>{labels.title}</h2>
<IconButton
icon="icon-regular-person-plus"
size="sm"
variant="utility"
circular
aria-label={labels.newGroup}
disabled={!onNewGroup}
onClick={onNewGroup}
/>
<IconButton
icon="icon-regular-chevron-large-down"
size="sm"
variant="utility"
circular
aria-label={collapsed ? labels.expand : labels.collapse}
aria-expanded={!collapsed}
onClick={() => setCollapsed(!collapsed)}
/>
</header>
{!collapsed && (
<>
<div className="rbx-chat-search">
<Input
controlSize="sm"
aria-label={labels.search}
placeholder={labels.searchPlaceholder}
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">{labels.empty}</p>
)}
</div>
</>
)}
</ChatPanel>
);
}
export function ChatGroupPanel({
people,
onClose,
onCreate,
labels: customLabels,
className,
}: {
people: ChatPerson[];
onClose?: () => void;
onCreate?: (name: string, memberIds: string[]) => void;
/** 제목·입력·버튼·빈 상태 문구입니다. 지정한 항목만 바꿉니다. */
labels?: Partial<ChatGroupLabels>;
className?: string;
}) {
const labels = { ...defaultChatGroupLabels, ...customLabels };
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}>{labels.title}</h2>
<IconButton
icon="icon-regular-x"
size="sm"
variant="utility"
circular
aria-label={labels.close}
onClick={onClose}
/>
</header>
<div className="rbx-chat-search">
<Input
controlSize="sm"
aria-label={labels.name}
placeholder={labels.namePlaceholder}
value={name}
onValueChange={setName}
leading={<Icon name="icon-regular-person-plus" size={16} />}
/>
</div>
<div className="rbx-chat-search">
<Input
controlSize="sm"
aria-label={labels.search}
placeholder={labels.searchPlaceholder}
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 ?? labels.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">{labels.empty}</p>}
</div>
<footer className="rbx-chat-footer">
<Button size="sm" variant="standard" onClick={onClose}>
{labels.cancel}
</Button>
<Button
size="sm"
disabled={selected.length < 2 || !onCreate}
onClick={() => onCreate?.(name.trim(), selected)}
>
{labels.create}
</Button>
</footer>
</ChatPanel>
);
}
components/ui/chat.css
@layer theme, base, components, utilities;
@layer components.ply-base, components.ply;
@layer components.ply {
.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);
}
}