컴포넌트

Chat

대화 목록과 그룹 선택 패널을 표시합니다.

설치

pnpm dlx ply-ui add chat

공통 설치 설정을 먼저 적용합니다.

사용법

대화 목록과 그룹 입력 패널에 앱 데이터를 전달합니다. 네트워크 통신, 인증, 메시지 전송은 포함하지 않습니다. 그룹 예제에서는 생성 요청 대신 전달된 값을 표시합니다.

구성

구성 요소역할
ChatPanel대화 UI를 담는 표면입니다.
ChatDock대화 목록, 검색, 접기를 제공합니다. conversations와 선택 콜백을 받습니다.
ChatGroupPanel그룹 이름과 멤버를 입력받습니다. people, onClose, onCreate를 연결합니다.

그룹 만들기

ChatGroupPanel은 이름과 멤버를 입력받습니다. 2명 이상 선택하면 만들기 버튼이 활성화되고, 최대 5명까지 선택할 수 있습니다. onCreate가 받는 이름과 ID 배열을 서버 요청에 연결합니다.

구현 코드

components/ui/chat.tsx
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
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);
}

목차