컴포넌트

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
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
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);
  }
}

목차