결제 실패 화면은 두 경우로 나눕니다. 에도 공유합니다.
메시징
채널 · 스레드 · DM 의 대화 화면입니다. 사이드바 · 메시지 · 목록 · 스레드가 같은 ./chat 서브패스에 있습니다.
메시지 창은 카드 면 위에 놓입니다. 상태(보내는 중 · 실패 · 수정됨 · 삭제됨)는 본문을 흐리게 하지 않고 모양과 글자로 말합니다. 나를 부른 메시지는 행이 아니라 멘션 칩만 강조합니다(결정 #30). 남의 입력 중 · 접속 변화는 스크린리더에 알리지 않습니다(결정 #31). 서브패스는 radix-ui 와 @tanstack/react-virtual 을 선택 peer 로 요구합니다(결정 #34).
- import
- import { ChannelSidebar, ChannelItem, DmItem, ChannelFinder, CreateChannelDialog, MessageItem, MentionChip, MessageList, ThreadPanel, ThreadSummary } from "@uyoolabs/ui/chat";
- 쓰는 경우
- 사람끼리 이야기하는 채널 · 스레드 · DM. 첫 소비자는 레퍼런스 앱 채널 화면입니다.
- 쓰지 않는 경우
- 알림 · 활동 피드처럼 대화가 아닌 목록(ActivityFeed · NotificationCenter), AI 대화(uyoopack 의 스크롤러는 이번에 바꾸지 않습니다)에는 쓰지 않습니다. 메시징이 없는 제품의 주 내비는 W1 Sidebar 를 그대로 씁니다. 새 메시지 선 · 멘션 칩에 빨강을 쓰지 않습니다.
ChannelSidebar
W1 Sidebar · SidebarSection 위에 채널 종류 · 접속 점 · 쓰던 글 · 보내지 못함 · 항목 동작을 쌓습니다.
키보드
| 키 | 동작 |
|---|---|
| Tab | 항목 · 섹션 머리 · + · ⋯ 를 차례로(링크 목록이라 roving 이 없다) |
| Alt+↑·Alt+↓ | 이전 · 다음 채널로(onSelect) |
| Alt+Shift+↑·Alt+Shift+↓ | 이전 · 다음 안 읽은 채널로 |
| ⌘J·Ctrl+J | 채널 · 사람 찾기(onFind). IME 조합 중이면 무시 |
| Shift+F10·메뉴 키 | 항목의 ⋯ 메뉴. 오른쪽 클릭 · 길게 누르기도 같다 |
| ↑·↓·Enter·Esc | 찾기 안: 고르기 · 이동(조합 중 Enter 는 확정) · 닫고 찾기 단추로 |
MessageItem · MentionChip
손을 올리거나 포커스하면 오른쪽 위에 동작 막대가 뜹니다.
오늘 오후에 올리겠습니다.
시안 두 장 올립니다.
키보드
| 키 | 동작 |
|---|---|
| Enter | 스레드 열기(메시지에 포커스가 있을 때) |
| R | 답장 |
| E | 수정(내 것) |
| ⌫·Delete | 삭제 확인 대화상자(되돌릴 수 없어 빨강) |
| Shift+F10 | 더 보기 메뉴 |
| ←·→ | 동작 막대 안에서 단추 사이(APG Toolbar) |
MessageList
바닥에서 64px 안이면 새 내용을 따라가고, 위로 올리면 놓아 줍니다(uyoopack message-scroller 의 규칙). 200개를 넘으면 가상 스크롤로 그립니다.
#디자인 채널의 시작입니다
이도현 님이 9월 3일에 만들었습니다.
결제 실패 화면은 두 경우로 나눕니다.
- 카드 한도 초과 2) 일시 오류
재시도 버튼은 키캡으로 바꿨습니다.
문구 확인 부탁드립니다.
3~5일 안에 배포합니다.
키보드
| 키 | 동작 |
|---|---|
| ↑·↓ | 이전 · 다음 메시지(article) |
| PageUp·PageDown | 화면 단위로 이전 · 다음 메시지 |
| Home·End | 처음 · 끝. End 는 '최신으로' 와 같다 |
| Esc | 안 읽음을 모두 읽음으로(onMarkRead) |
스크롤 영역은 이름 붙은 region 입니다. APG Feed(role=feed)는 article 밖의 역할 · 포커스 요소를 허용하지 않아 날짜 메뉴 단추 · 구분선과 함께 둘 수 없습니다. 대신 Feed 의 키를 그대로 줍니다.
ThreadPanel · ThreadSummary
본문 오른쪽 패널(폭 400). 1024 미만에서는 드로어로 뜹니다.
키보드
| 키 | 동작 |
|---|---|
| Enter | (요약 · 메시지에서) 패널을 열고 작성창으로 포커스 |
| Esc | 작성창이 비었을 때 닫고 연 자리로. 쓰던 글이 있으면 닫지 않는다 |
| Tab | 머리(⋯ · ×) → 원 메시지 → 답글 → 작성창 → 채널에도 보내기 |
props
소스의 타입과 JSDoc 에서 생성한 표입니다. 컴포넌트를 고치면 pnpm gen 으로 다시 만듭니다.
ChannelSidebar
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| header | ReactNode | — | 머리 줄(WorkspaceSwitcher) |
| footer | ReactNode | — | 바닥 줄(StatusMenu 로 감싼 SidebarUser) |
| top | ReactNode | — | 맨 위 고정 항목(스레드 · 멘션 · 초안). 보통 SidebarList |
| sections필수 | ChannelSection[] | — | 섹션 순서는 앱이 정한다(별표 → 채널 → DM → 사용자 섹션) |
| onSectionToggle | (id: string, open: boolean) => void | — | |
| currentId | string | — | aria-current="page" 를 줄 항목 |
| onSelect | (id: string) => void | — | 항목을 누르거나 Alt+↑↓ 로 옮길 때 |
| onFind | () => void | — | 찾기 단추(⌘J). 주지 않으면 찾기 단추를 그리지 않는다 |
| onMove | (itemId: string, toSectionId: string, index: number) => void | — | 항목을 다른 섹션으로 옮긴다. 주면 ⋯ 의 '섹션으로 옮기기' 가 켜진다(끌어 옮기기는 아직 없다, ADR 0008) |
| state | "expanded" | "collapsed" | — | W1 Sidebar 와 같은 값. 접히면 섹션을 숨기고 멘션 합계를 '안 읽음' 칸 하나로 모은다(결정 #17) |
| lang | "ko" | "en" | "ja" | — | |
| labels | Partial<ChannelSidebarLabels> | — |
이 밖에 <nav> props(children · onSelect 제외)를 그대로 받습니다.
ChannelItem
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| channel필수 | ChannelRef | — | kind 로 아이콘: public "#" · private 자물쇠(모양이 종류를 말한다) |
| href | string | — | |
| current | boolean | — | 지금 화면. 없으면 ChannelSidebar 의 currentId 와 비교한다 |
| unread | boolean | — | 600 · 진한 글자 |
| mentions | number | — | solid 칩(빨강 아님, 결정 #16). 99 넘으면 99+ |
| muted | boolean | — | 흐린 글자 + 종 끔. 멘션 칩은 남는다 |
| draft | boolean | — | 끝 자리 연필(쓰던 글이 있다) |
| actions | ReactNode | — | ⋯ 메뉴 내용(DropdownMenuItem 들) |
| onClick | () => void | — | |
| lang | "ko" | "en" | "ja" | — | |
| className | string | — |
DmItem
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| people필수 | ChatPerson[] | — | 1명이면 아바타 20 + 접속 점 8, 여럿이면 사람 수를 담은 사각 |
| id | string | — | 대화 id. 없으면 첫 사람 id |
| href | string | — | |
| current | boolean | — | |
| presence | "online" | "away" | "dnd" | "offline" | — | 1:1 일 때만. 점 둘레 틈은 --uyoo-cut(사이드바 well, 현재 항목 raised) |
| self | boolean | false | 나와의 DM. 이름 뒤에 (나) |
| unread | boolean | — | |
| mentions | number | — | DM 의 새 메시지는 멘션처럼 solid 칩 |
| failed | boolean | false | 보내지 못한 메시지가 있다. 끝 자리 경고 아이콘(bad) |
| muted | boolean | — | |
| actions | ReactNode | — | |
| onClick | () => void | — | |
| lang | "ko" | "en" | "ja" | — | |
| className | string | — |
ChannelFinder
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| open | boolean | — | |
| defaultOpen | boolean | false | |
| onOpenChange | (open: boolean) => void | — | |
| query | string | — | |
| defaultQuery | string | "" | |
| onQueryChange | (query: string) => void | — | |
| results필수 | ChannelFinderResult[] | — | |
| onSelect필수 | (id: string) => void | — | 참여하지 않은 공개 채널은 미리 보기로 연다(참여 단추는 작성창 자리) |
| onCreate | () => void | — | 바닥 줄 '채널 만들기' |
| onBrowse | () => void | — | 바닥 줄 '채널 둘러보기' |
| loading | boolean | false | |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<ChannelFinderLabels> | — |
CreateChannelDialog
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| open | boolean | — | |
| defaultOpen | boolean | false | |
| onOpenChange | (open: boolean) => void | — | |
| validateName | (name: string) => Promise<string | boolean | null> | string | boolean | null | — | 300ms 쉬고 부른다. 문자열이면 그 오류 문장, true 면 기본 중복 문장(labels.duplicate), null 이면 통과 |
| onCreate필수 | (input: { name: string; kind: "public" | "private" }) => Promise<void> | void | — | |
| defaultKind | "public" | "private" | "public" | |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<CreateChannelLabels> | — |
MessageItem
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| id필수 | string | — | |
| author필수 | MessageAuthor | — | |
| time필수 | string | — | UTC ISO. lang · timeZone 으로 바꿔 보이고, 손을 올리면 전체 날짜(title) |
| body필수 | ReactNode | — | 문자열이면 마크다운으로 그린다(원시 HTML 은 해석하지 않는다). 노드를 주면 그대로 |
| grouped | boolean | false | 같은 사람 · 5분 안 · 같은 날이면 목록이 true 로 넘긴다. 아바타 · 이름을 빼고 시각은 hover · 포커스에만 |
| state | "sent" | "sending" | "failed" | "sent" | 기본 sent |
| error | string | — | failed 의 이유. 없으면 labels.failed |
| editedAt | string | — | UTC ISO. 있으면 본문 끝에 (수정됨) |
| deleted | boolean | false | 삭제됨. 스레드 답글이 있을 때만 자리를 남긴다(없으면 목록에서 뺀다). 반응 · 동작 막대 없음 |
| replyTo | { id: string; author: string; excerpt: string; onJump: () => void } | — | 답장 인용 한 줄. 누르면 원문으로 |
| fromThread | { rootExcerpt: string; onOpen: () => void } | — | 채널에도 보낸 스레드 답글 |
| thread | ReactNode | — | ThreadSummary |
| reactions | ReactNode | — | Reactions |
| attachments | ReactNode | — | FileAttachment · ImageGrid · LinkPreview |
| actions | ReactNode | — | 동작 막대를 통째로 바꾼다. 없으면 빠른 반응 · 반응 추가 · 스레드 · (내 것) 수정 · 더 보기 |
| quickReactions | string[] | [] | 빠른 반응(최근 사용 앞 3) |
| onReact | (emoji: string) => void | — | |
| onAddReaction | () => void | — | 반응 추가(이모지 고르기를 앱이 연다) |
| own | boolean | false | 내 메시지. 수정 · 삭제 항목과 E · ⌫ 키를 켠다 |
| canDelete | boolean | — | 관리자처럼 남의 메시지도 지울 수 있다 |
| active | boolean | false | 스레드 패널에 열린 메시지(primary-soft 면) |
| highlighted | boolean | false | 도착 표시. 1.6초 뒤 스스로 걷는다 |
| selfId | string | — | 보는 사람의 id. 본문에서 나를 부른 멘션 칩을 강조한다(결정 #30) |
| onRetry | () => void | — | 같은 client id 로 다시 보내 중복을 막는 것은 앱이 한다 |
| onDelete | () => void | — | 실패 메시지 지우기 · 삭제 확인 뒤 |
| onReply | () => void | — | |
| onOpenThread | () => void | — | |
| onEdit | () => void | — | |
| onCopyLink | () => void | — | |
| onMarkUnread | () => void | — | |
| onPin | () => void | — | |
| onAuthorClick | () => void | — | |
| onMentionClick | (mention: MentionRef) => void | — | 본문 멘션 칩을 눌렀을 때(사람 카드 · 채널 이동) |
| renderMention | (mention: MentionRef & { self: boolean }) => ReactNode | — | 멘션 칩을 직접 그린다(ProfileCardTrigger 로 감싸기 등) |
| timeZone | string | — | 표시 시간대(IANA). 없으면 lib/format 의 기본값 |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<MessageItemLabels> | — | |
| className | string | — |
MentionChip
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| kind | "user" | "channel" | "user" | user: @이름 · channel: #이름 |
| id필수 | string | — | 사람 · 채널 id. 마크다운의 mention:id · channel:id 그대로다 |
| label필수 | string | — | 보이는 이름. @ · # 는 컴포넌트가 붙인다 |
| self | boolean | false | 나를 부른 멘션. primary-soft 면 + primary 1px 테두리 + 600(결정 #30: 행이 아니라 칩만 강조한다) |
| unknown | boolean | false | 나간 사람 · 권한 없는 비공개 채널. 칩을 풀고 흐린 글자만 남긴다 |
| href | string | — | 주면 링크(사람 카드 · 채널 주소) |
| onClick | () => void | — | href 가 없으면 버튼(사람 카드 열기) |
| asChild | boolean | false | 자식 요소(라우터 링크 · ProfileCardTrigger 의 단추)에 칩 모양을 입힌다 |
| children | ReactElement | — | |
| lang | "ko" | "en" | "ja" | — | |
| labels | Partial<MentionChipLabels> | — | |
| className | string | — |
MessageList
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| items필수 | T[] | — | 오래된 것 → 새것 |
| renderItem필수 | ( item: T, ctx: { grouped: boolean; active: boolean; highlighted: boolean }, ) => ReactNode | — | 보통 <MessageItem grouped={ctx.grouped} active={ctx.active} …> |
| lastReadId | string | — | 마지막으로 읽은 메시지 id. 처음 받은 값으로 '새 메시지' 선을 고정하고 목록이 다시 마운트될 때까지 옮기지 않는다 |
| onMarkRead | () => void | — | 안 읽음 띠의 '모두 읽음으로 표시' · Esc |
| hasMoreBefore | boolean | false | |
| loadingBefore | boolean | false | |
| errorBefore | string | null | — | |
| onLoadBefore | () => void | — | 위 끝에서 300px 안에 들어오면 미리 부른다. errorBefore 는 문장(빈 문자열이면 기본 문구) |
| onRetryBefore | () => void | — | |
| intro | ReactNode | — | 위 끝까지 다 불렀을 때 맨 위에 그릴 것(채널의 시작, ChannelIntro) |
| stickToBottom | boolean | true | 바닥 따라가기. 기본 true |
| onReachBottom | () => void | — | 바닥이 보이면(읽음 처리) |
| newCount | number | 0 | 위로 올려 읽는 동안 새로 온 메시지 수. '최신으로' 단추에 더한다 |
| typing | ReactNode | — | TypingIndicator. 목록 밖 아래 20 을 늘 잡아 둔다 |
| loading | boolean | false | 처음 불러오는 중(뼈대 4줄, 300ms 뒤 · 최소 400ms) |
| error | string | null | — | |
| onRetry | () => void | — | |
| empty | ReactNode | — | 빈 채널. 없으면 기본 빈 상태 |
| groupWindowMs | number | 5 * 60_000 | 묶음 시간. 기본 5분 |
| timeZone | string | — | 날짜 구분 · 시각의 시간대(IANA). 없으면 lib/format 의 기본값 |
| now | string | — | 오늘 · 어제를 가르는 기준(UTC ISO). 테스트 · 서버 렌더 · 시각 회귀에서 고정 |
| onJumpToDate | (target: JumpTarget) => void | — | 날짜 이름표 메뉴. 없으면 이름표는 누를 수 없는 글자다 |
| activeId | string | — | 스레드에 열린 메시지 |
| highlightId | string | — | 도착 표시를 줄 메시지. 가운데로 스크롤한다 |
| selfId | string | — | 보는 사람의 id. 내가 보낸 메시지는 언제나 바닥으로 데려가고 라이브 영역에서 읽지 않는다 |
| getAnnouncement | (item: T) => { name: string; text: string } | null | — | 라이브 영역이 읽을 이름 · 첫 줄. 없으면 개수만 읽는다 |
| announceScopeRef | RefObject<HTMLElement | null> | — | 라이브 영역은 포커스가 이 안(대화 영역: 목록 + 작성창)에 있을 때만 읽는다. 없으면 목록 안 |
| onJumpToLatest | () => void | — | '최신으로' 를 누른 뒤(앱이 작성창으로 포커스를 돌려보낸다) |
| label필수 | string | — | 목록 접근성 이름 '#디자인 메시지' |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<MessageListLabels> | — | |
| className | string | — |
ChannelIntro
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| name필수 | string | — | |
| kind | "public" | "private" | "public" | |
| description | ReactNode | — | 만든 사람과 날짜 같은 한 줄 |
| actions | ReactNode | — | 멤버 추가 같은 동작(sm 버튼) |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<MessageListLabels> | — |
DayDivider
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| time필수 | string | — | 그 날의 한 시각(UTC ISO) |
| timeZone | string | — | |
| now | Date | — | 오늘 · 어제를 가르는 기준. 테스트 · 서버 렌더에서 고정 |
| onJumpToDate | (target: JumpTarget) => void | — | |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<MessageListLabels> | — |
NewMessagesDivider
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| label필수 | string | — |
JumpToLatest
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| count | number | 0 | |
| onClick필수 | () => void | — | |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<MessageListLabels> | — |
ThreadPanel
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| root필수 | ReactNode | — | 원 메시지. 보통 <MessageItem>(스레드 요약은 그리지 않는다 — 여기가 그 스레드다) |
| rootDeleted | boolean | false | '원 메시지가 삭제되었습니다' 자리만 남기고 답글은 보인다 |
| channelName필수 | string | — | 머리의 '#디자인' |
| replyCount필수 | number | — | |
| replies필수 | ReactNode | — | <MessageList>(같은 바닥 따라가기 · 새 메시지 선) |
| composer필수 | ReactNode | — | <Composer placeholder={labels.replyPlaceholder}> |
| alsoSendToChannel | boolean | false | 채널에도 보내기. 기억하지 않는다 — 패널을 열 때마다 false 로 시작한다(제어) |
| onAlsoSendChange | (value: boolean) => void | — | |
| following | boolean | — | ⋯ 메뉴 '스레드 알림 끄기 · 켜기' |
| onFollowChange | (value: boolean) => void | — | |
| onCopyLink | () => void | — | |
| onOpenInWindow | () => void | — | |
| onClose필수 | () => void | — | |
| loading | boolean | false | 답글 뼈대 |
| error | string | null | — | |
| onRetry | () => void | — | |
| variant | "panel" | "sheet" | "panel" | panel: 본문 오른쪽(폭 400). lg(1024) 미만이면 앱이 sheet 를 넘긴다 |
| width | number | — | 패널 폭(--uyoo-thread-w). 320–560 |
| autoFocus | boolean | true | 그릴 때 작성창으로 포커스. 기본 true(답글을 쓰러 왔다) |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<ThreadPanelLabels> | — | |
| className | string | — |
ThreadSummary
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| count필수 | number | — | |
| participants필수 | ThreadParticipant[] | — | 앞 3명만 그린다 |
| lastReplyAt필수 | string | — | UTC ISO → '10분 전' · '어제'(lang) |
| unread | boolean | false | |
| onOpen필수 | () => void | — | |
| now | string | — | 상대 시각의 기준. 테스트 · 서버 렌더에서 고정 |
| timeZone | string | — | |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<ThreadPanelLabels> | — | |
| className | string | — |